94521af890
将 spacetimedb、SDK、lib 精确升级到 2.8.3 并更新 Cargo.lock。 用 2.8.3 CLI 重新生成 spacetime-client Rust bindings。 同步本地 dev、生产 provision、容器镜像和事务 smoke 的版本与 commit 门禁。 更新后端契约、开发运维、项目记忆和 SpacetimeDB skills 的 2.8.3 口径。 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/200 Co-authored-by: suzmii <suzmii@qq.com> Co-committed-by: suzmii <suzmii@qq.com>
793 lines
21 KiB
JavaScript
793 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { chmod, mkdtemp, rm } from 'node:fs/promises';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { createSpacetimeWebIdentity } from './spacetime-migration-common.mjs';
|
|
|
|
const repoRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
);
|
|
const database = 'editor-idempotency-smoke';
|
|
const expectedSpacetimeVersion = '2.8.3';
|
|
const expectedSpacetimeCommit = '8e410d2842147bd8e5a32a9589cc00c19f7478e2';
|
|
const commandTimeoutMs = 5 * 60 * 1000;
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function appendOutput(current, chunk) {
|
|
const next = `${current}${chunk}`;
|
|
return next.length <= 24_000 ? next : next.slice(-24_000);
|
|
}
|
|
|
|
export function sanitizeDiagnostic(value, sensitiveValues = []) {
|
|
let sanitized = String(value);
|
|
for (const sensitiveValue of sensitiveValues) {
|
|
if (typeof sensitiveValue === 'string' && sensitiveValue.length > 0) {
|
|
sanitized = sanitized.split(sensitiveValue).join('[redacted]');
|
|
}
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
export function runCommand(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: options.cwd ?? repoRoot,
|
|
env: options.env ?? process.env,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const timeout = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
reject(
|
|
new Error(
|
|
sanitizeDiagnostic(
|
|
`${command} timed out after ${options.timeoutMs ?? commandTimeoutMs}ms`,
|
|
options.sensitiveValues,
|
|
),
|
|
),
|
|
);
|
|
}, options.timeoutMs ?? commandTimeoutMs);
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
stdout = appendOutput(stdout, chunk.toString());
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr = appendOutput(stderr, chunk.toString());
|
|
});
|
|
child.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
child.on('exit', (code, signal) => {
|
|
clearTimeout(timeout);
|
|
const diagnostic = sanitizeDiagnostic(
|
|
`${stdout}\n${stderr}`.trim(),
|
|
options.sensitiveValues,
|
|
);
|
|
if (signal) {
|
|
reject(new Error(`${command} exited via ${signal}: ${diagnostic}`));
|
|
} else if (code !== 0) {
|
|
reject(new Error(`${command} exited with ${code}: ${diagnostic}`));
|
|
} else {
|
|
resolve({ stdout, stderr });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export function parseProcedureOutput(stdout, procedureName = 'procedure') {
|
|
let value;
|
|
try {
|
|
value = JSON.parse(String(stdout).trim());
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${procedureName} stdout is not JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
assert(
|
|
Array.isArray(value),
|
|
`${procedureName} returned an invalid result shape.`,
|
|
);
|
|
if (value.length === 4) {
|
|
const folder = decodeOption(value[1]);
|
|
const library = decodeOption(value[2]);
|
|
return {
|
|
ok: value[0],
|
|
record: folder ?? library,
|
|
errorMessage: decodeOption(value[3]),
|
|
};
|
|
}
|
|
assert(
|
|
value.length === 3,
|
|
`${procedureName} returned an invalid result shape.`,
|
|
);
|
|
return {
|
|
ok: value[0],
|
|
record: decodeOption(value[1]),
|
|
errorMessage: decodeOption(value[2]),
|
|
};
|
|
}
|
|
|
|
export function parseSqlRows(stdout, queryLabel = 'SQL') {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(String(stdout).trim());
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${queryLabel} stdout is not JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
assert(
|
|
Array.isArray(payload) &&
|
|
payload.length === 1 &&
|
|
Array.isArray(payload[0]?.rows),
|
|
`${queryLabel} returned an invalid result shape.`,
|
|
);
|
|
return payload[0].rows;
|
|
}
|
|
|
|
function decodeOption(value) {
|
|
if (Array.isArray(value) && value.length === 2 && value[0] === 0) {
|
|
return value[1];
|
|
}
|
|
if (Array.isArray(value) && value.length === 2 && value[0] === 1) {
|
|
return null;
|
|
}
|
|
if (value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
throw new Error(`Unexpected SATS option: ${JSON.stringify(value)}`);
|
|
}
|
|
|
|
async function reservePort() {
|
|
const server = net.createServer();
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = server.address();
|
|
assert(
|
|
address && typeof address === 'object',
|
|
'Failed to reserve a local port.',
|
|
);
|
|
const port = address.port;
|
|
await new Promise((resolve, reject) =>
|
|
server.close((error) => (error ? reject(error) : resolve())),
|
|
);
|
|
return port;
|
|
}
|
|
|
|
function startStandalone(dataDir, port) {
|
|
const child = spawn(
|
|
'spacetime',
|
|
[
|
|
'start',
|
|
'--in-memory',
|
|
'--data-dir',
|
|
dataDir,
|
|
'--listen-addr',
|
|
`127.0.0.1:${port}`,
|
|
'--non-interactive',
|
|
],
|
|
{
|
|
cwd: repoRoot,
|
|
env: process.env,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
},
|
|
);
|
|
let output = '';
|
|
child.stdout.on('data', (chunk) => {
|
|
output = appendOutput(output, chunk.toString());
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
output = appendOutput(output, chunk.toString());
|
|
});
|
|
return { child, output: () => output };
|
|
}
|
|
|
|
async function waitForStandalone(serverUrl, processState) {
|
|
const deadline = Date.now() + 30_000;
|
|
while (Date.now() < deadline) {
|
|
if (processState.child.exitCode !== null) {
|
|
throw new Error(
|
|
`SpacetimeDB exited during startup: ${processState.output().trim()}`,
|
|
);
|
|
}
|
|
try {
|
|
const response = await fetch(`${serverUrl}/v1/ping`);
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// Startup is still in progress.
|
|
}
|
|
await delay(200);
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for SpacetimeDB: ${processState.output().trim()}`,
|
|
);
|
|
}
|
|
|
|
export async function stopStandalone(child) {
|
|
if (child.exitCode !== null) {
|
|
return;
|
|
}
|
|
if (process.platform === 'win32') {
|
|
await stopWindowsProcessTree(child);
|
|
return;
|
|
}
|
|
child.kill('SIGTERM');
|
|
const exited = await Promise.race([
|
|
once(child, 'exit').then(() => true),
|
|
delay(5_000).then(() => false),
|
|
]);
|
|
if (!exited && child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await once(child, 'exit');
|
|
}
|
|
}
|
|
|
|
async function stopWindowsProcessTree(child) {
|
|
if (typeof child.pid === 'number') {
|
|
await runTaskKill(child.pid);
|
|
}
|
|
const exited = await Promise.race([
|
|
once(child, 'exit').then(() => true),
|
|
delay(5_000).then(() => false),
|
|
]);
|
|
if (!exited && child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await once(child, 'exit');
|
|
}
|
|
}
|
|
|
|
function runTaskKill(pid) {
|
|
return new Promise((resolve, reject) => {
|
|
const taskKill = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
|
stdio: 'ignore',
|
|
shell: false,
|
|
});
|
|
taskKill.once('error', reject);
|
|
taskKill.once('exit', (code, signal) => {
|
|
if (code === 0 || code === 128 || code === 1) {
|
|
resolve();
|
|
return;
|
|
}
|
|
reject(new Error(`taskkill exited with ${signal ?? code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
function cliPrefix(configPath) {
|
|
return ['--config-path', configPath];
|
|
}
|
|
|
|
async function callProcedure(configPath, serverUrl, procedureName, input) {
|
|
const result = await runCommand('spacetime', [
|
|
...cliPrefix(configPath),
|
|
'call',
|
|
'--server',
|
|
serverUrl,
|
|
'--yes',
|
|
'--no-config',
|
|
database,
|
|
procedureName,
|
|
JSON.stringify(input),
|
|
]);
|
|
return parseProcedureOutput(result.stdout, procedureName);
|
|
}
|
|
|
|
async function sqlRows(configPath, serverUrl, query, queryLabel) {
|
|
const result = await runCommand('spacetime', [
|
|
...cliPrefix(configPath),
|
|
'sql',
|
|
'--server',
|
|
serverUrl,
|
|
'--yes',
|
|
'--no-config',
|
|
'--format',
|
|
'json',
|
|
database,
|
|
query,
|
|
]);
|
|
return parseSqlRows(result.stdout, queryLabel);
|
|
}
|
|
|
|
function projectInput(projectId, title, nowMicros = 1_000) {
|
|
return {
|
|
project_id: projectId,
|
|
owner_user_id: 'receipt-owner',
|
|
title,
|
|
now_micros: nowMicros,
|
|
};
|
|
}
|
|
|
|
function resourceInput(overrides = {}) {
|
|
const none = [1, []];
|
|
const some = (value) => [0, value];
|
|
return {
|
|
resource_id: 'resource-a',
|
|
project_id: 'project-a',
|
|
owner_user_id: 'receipt-owner',
|
|
asset_object_id: none,
|
|
image_src: '/generated/editor/resource-a.png',
|
|
object_key: none,
|
|
width: 32,
|
|
height: 32,
|
|
source_type: 'generated',
|
|
prompt: some('receipt smoke'),
|
|
actual_prompt: none,
|
|
model: some('smoke'),
|
|
provider: some('smoke'),
|
|
task_id: some('receipt-smoke-task'),
|
|
source_resource_id: none,
|
|
asset_kind: some('icon'),
|
|
generation_inputs_json: none,
|
|
updated_at_micros: 1_000,
|
|
image_sequence_frames_json: none,
|
|
image_sequence_duration_ms: none,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function folderInput(overrides = {}) {
|
|
return {
|
|
folder_id: 'folder-a',
|
|
owner_user_id: 'receipt-owner',
|
|
label: 'Folder A',
|
|
sort_order: 100,
|
|
now_micros: 1_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function assertOk(result, label) {
|
|
assert(result.ok === true, `${label} failed: ${result.errorMessage}`);
|
|
assert(result.record !== null, `${label} did not return a record.`);
|
|
}
|
|
|
|
function assertBodyConflict(result, label) {
|
|
assert(result.ok === false, `${label} unexpectedly succeeded.`);
|
|
assert(
|
|
result.errorMessage ===
|
|
'幂等创建冲突:同一 Idempotency-Key 的创建正文不一致',
|
|
`${label} returned an unexpected error: ${result.errorMessage}`,
|
|
);
|
|
}
|
|
|
|
function assertDeletedConflict(result, recordLabel) {
|
|
assert(
|
|
result.ok === false,
|
|
`${recordLabel} replay unexpectedly recreated the record.`,
|
|
);
|
|
assert(
|
|
typeof result.errorMessage === 'string' &&
|
|
result.errorMessage.includes('已删除'),
|
|
`${recordLabel} replay returned an unexpected error: ${result.errorMessage}`,
|
|
);
|
|
}
|
|
|
|
async function runSmoke(configPath, serverUrl) {
|
|
const projectA = projectInput('project-a', 'First');
|
|
assertOk(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_and_return',
|
|
projectA,
|
|
),
|
|
'project first create',
|
|
);
|
|
assertOk(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_and_return',
|
|
{ ...projectA, now_micros: 2_000 },
|
|
),
|
|
'project exact replay',
|
|
);
|
|
assertBodyConflict(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_and_return',
|
|
{ ...projectA, title: 'Drifted' },
|
|
),
|
|
'project drift replay',
|
|
);
|
|
|
|
const resourceA = resourceInput();
|
|
assertOk(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_resource_and_return',
|
|
resourceA,
|
|
),
|
|
'resource first create',
|
|
);
|
|
assertOk(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_resource_and_return',
|
|
{ ...resourceA, updated_at_micros: 2_000 },
|
|
),
|
|
'resource exact replay',
|
|
);
|
|
assertBodyConflict(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_resource_and_return',
|
|
{ ...resourceA, width: 64 },
|
|
),
|
|
'resource drift replay',
|
|
);
|
|
|
|
const folderA = folderInput();
|
|
assertOk(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_asset_folder_and_return',
|
|
folderA,
|
|
),
|
|
'asset folder first create',
|
|
);
|
|
assertOk(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_asset_folder_and_return',
|
|
{ ...folderA, now_micros: 2_000 },
|
|
),
|
|
'asset folder exact replay',
|
|
);
|
|
assertBodyConflict(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_asset_folder_and_return',
|
|
{ ...folderA, label: 'Drifted Folder' },
|
|
),
|
|
'asset folder drift replay',
|
|
);
|
|
|
|
const sameRaceInput = projectInput('project-same-race', 'Same Race');
|
|
const sameRace = await Promise.all([
|
|
callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_and_return',
|
|
sameRaceInput,
|
|
),
|
|
callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_and_return',
|
|
sameRaceInput,
|
|
),
|
|
]);
|
|
sameRace.forEach((result, index) =>
|
|
assertOk(result, `same-body race ${index + 1}`),
|
|
);
|
|
|
|
const conflictRace = await Promise.all([
|
|
callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_and_return',
|
|
projectInput('project-conflict-race', 'Race A'),
|
|
),
|
|
callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_and_return',
|
|
projectInput('project-conflict-race', 'Race B'),
|
|
),
|
|
]);
|
|
assert(
|
|
conflictRace.filter((result) => result.ok).length === 1,
|
|
'Different-body race did not commit exactly one request.',
|
|
);
|
|
assert(
|
|
conflictRace.filter((result) => !result.ok).length === 1,
|
|
'Different-body race did not reject exactly one request.',
|
|
);
|
|
assertBodyConflict(
|
|
conflictRace.find((result) => !result.ok),
|
|
'different-body race loser',
|
|
);
|
|
|
|
const projectsBeforeDelete = await sqlRows(
|
|
configPath,
|
|
serverUrl,
|
|
'SELECT project_id, owner_user_id, title FROM editor_project',
|
|
'project rows before delete',
|
|
);
|
|
const resourcesBeforeDelete = await sqlRows(
|
|
configPath,
|
|
serverUrl,
|
|
'SELECT resource_id, project_id, owner_user_id FROM editor_project_resource',
|
|
'resource rows before delete',
|
|
);
|
|
const receiptsBeforeDelete = await sqlRows(
|
|
configPath,
|
|
serverUrl,
|
|
'SELECT owner_user_id, namespace, request_record_id, result_record_id FROM editor_idempotent_create_receipt',
|
|
'receipt rows before delete',
|
|
);
|
|
const foldersBeforeDelete = await sqlRows(
|
|
configPath,
|
|
serverUrl,
|
|
'SELECT folder_id, owner_user_id, system_default FROM editor_asset_folder',
|
|
'asset folder rows before delete',
|
|
);
|
|
assert(
|
|
projectsBeforeDelete.length === 3,
|
|
'Unexpected project count before deletion.',
|
|
);
|
|
assert(
|
|
resourcesBeforeDelete.length === 1,
|
|
'Unexpected resource count before deletion.',
|
|
);
|
|
assert(
|
|
receiptsBeforeDelete.length === 5,
|
|
'Unexpected receipt count before deletion.',
|
|
);
|
|
const customFoldersBeforeDelete = foldersBeforeDelete.filter(
|
|
([, , systemDefault]) => !systemDefault,
|
|
);
|
|
assert(
|
|
customFoldersBeforeDelete.length === 1,
|
|
'Unexpected custom folder count before deletion.',
|
|
);
|
|
for (const [projectId, ownerUserId] of projectsBeforeDelete) {
|
|
assert(
|
|
ownerUserId === 'receipt-owner',
|
|
`Project ${projectId} has the wrong owner.`,
|
|
);
|
|
assert(
|
|
receiptsBeforeDelete.some(
|
|
([owner, namespace, requestId, resultId]) =>
|
|
owner === ownerUserId &&
|
|
namespace === 'editor-project' &&
|
|
requestId === projectId &&
|
|
resultId === projectId,
|
|
),
|
|
`Project ${projectId} is missing its durable receipt.`,
|
|
);
|
|
}
|
|
for (const [resourceId, projectId, ownerUserId] of resourcesBeforeDelete) {
|
|
assert(
|
|
projectsBeforeDelete.some(([candidate]) => candidate === projectId),
|
|
`Resource ${resourceId} is orphaned from project ${projectId}.`,
|
|
);
|
|
assert(
|
|
receiptsBeforeDelete.some(
|
|
([owner, namespace, requestId, resultId]) =>
|
|
owner === ownerUserId &&
|
|
namespace === 'editor-project-resource' &&
|
|
requestId === resourceId &&
|
|
resultId === resourceId,
|
|
),
|
|
`Resource ${resourceId} is missing its durable receipt.`,
|
|
);
|
|
}
|
|
for (const [folderId, ownerUserId] of customFoldersBeforeDelete) {
|
|
assert(
|
|
receiptsBeforeDelete.some(
|
|
([owner, namespace, requestId, resultId]) =>
|
|
owner === ownerUserId &&
|
|
namespace === 'editor-asset-folder' &&
|
|
requestId === folderId &&
|
|
resultId === folderId,
|
|
),
|
|
`Asset folder ${folderId} is missing its durable receipt.`,
|
|
);
|
|
}
|
|
|
|
const deleteResult = await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'delete_editor_project_and_return',
|
|
{ project_id: 'project-a', owner_user_id: 'receipt-owner' },
|
|
);
|
|
assertOk(deleteResult, 'project delete');
|
|
assertDeletedConflict(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_and_return',
|
|
projectA,
|
|
),
|
|
'project',
|
|
);
|
|
assertDeletedConflict(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_project_resource_and_return',
|
|
resourceA,
|
|
),
|
|
'resource',
|
|
);
|
|
const deleteFolderResult = await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'delete_editor_asset_folder_and_return',
|
|
{
|
|
folder_id: 'folder-a',
|
|
owner_user_id: 'receipt-owner',
|
|
updated_at_micros: 3_000,
|
|
},
|
|
);
|
|
assertOk(deleteFolderResult, 'asset folder delete');
|
|
assertDeletedConflict(
|
|
await callProcedure(
|
|
configPath,
|
|
serverUrl,
|
|
'create_editor_asset_folder_and_return',
|
|
folderA,
|
|
),
|
|
'asset folder',
|
|
);
|
|
|
|
const projectsAfterDelete = await sqlRows(
|
|
configPath,
|
|
serverUrl,
|
|
'SELECT project_id FROM editor_project',
|
|
'project rows after delete',
|
|
);
|
|
const resourcesAfterDelete = await sqlRows(
|
|
configPath,
|
|
serverUrl,
|
|
'SELECT resource_id FROM editor_project_resource',
|
|
'resource rows after delete',
|
|
);
|
|
const receiptsAfterDelete = await sqlRows(
|
|
configPath,
|
|
serverUrl,
|
|
'SELECT owner_user_id, namespace, request_record_id, result_record_id FROM editor_idempotent_create_receipt',
|
|
'receipt rows after delete',
|
|
);
|
|
const foldersAfterDelete = await sqlRows(
|
|
configPath,
|
|
serverUrl,
|
|
'SELECT folder_id, system_default FROM editor_asset_folder',
|
|
'asset folder rows after delete',
|
|
);
|
|
assert(
|
|
projectsAfterDelete.length === 2 &&
|
|
projectsAfterDelete.every(([projectId]) => projectId !== 'project-a'),
|
|
'Deleted project was recreated or another project was lost.',
|
|
);
|
|
assert(
|
|
resourcesAfterDelete.length === 0,
|
|
'Project deletion left an orphan resource.',
|
|
);
|
|
assert(
|
|
foldersAfterDelete.length === 1 && foldersAfterDelete[0][1] === true,
|
|
'Asset folder deletion removed the default folder or left a custom-folder orphan.',
|
|
);
|
|
assert(
|
|
receiptsAfterDelete.length === receiptsBeforeDelete.length,
|
|
'Deletion removed or duplicated a durable receipt.',
|
|
);
|
|
}
|
|
|
|
export async function main() {
|
|
let tempDir;
|
|
let standalone;
|
|
let loginToken = '';
|
|
try {
|
|
const version = await runCommand('spacetime', ['--version'], {
|
|
timeoutMs: 30_000,
|
|
});
|
|
assert(
|
|
version.stdout.includes(
|
|
`spacetimedb tool version ${expectedSpacetimeVersion}`,
|
|
),
|
|
`Expected SpacetimeDB ${expectedSpacetimeVersion}, got: ${version.stdout.trim()}`,
|
|
);
|
|
assert(
|
|
version.stdout.includes(`Commit: ${expectedSpacetimeCommit}`),
|
|
`Expected SpacetimeDB commit ${expectedSpacetimeCommit}, got: ${version.stdout.trim()}`,
|
|
);
|
|
|
|
tempDir = await mkdtemp(
|
|
path.join(os.tmpdir(), 'genarrative-editor-idempotency-smoke-'),
|
|
);
|
|
const port = await reservePort();
|
|
const serverUrl = `http://127.0.0.1:${port}`;
|
|
standalone = startStandalone(path.join(tempDir, 'data'), port);
|
|
await waitForStandalone(serverUrl, standalone);
|
|
|
|
const identity = await createSpacetimeWebIdentity({ database, serverUrl });
|
|
loginToken = identity.token;
|
|
const configPath = path.join(tempDir, 'cli.toml');
|
|
await runCommand(
|
|
'spacetime',
|
|
[...cliPrefix(configPath), 'login', '--token', loginToken],
|
|
{ sensitiveValues: [loginToken] },
|
|
);
|
|
await chmod(configPath, 0o600);
|
|
|
|
const wasmPath =
|
|
process.env.GENARRATIVE_EDITOR_IDEMPOTENCY_SMOKE_WASM?.trim();
|
|
const publishArgs = [
|
|
...cliPrefix(configPath),
|
|
'publish',
|
|
database,
|
|
'--server',
|
|
serverUrl,
|
|
'--yes=all',
|
|
'--no-config',
|
|
];
|
|
if (wasmPath) {
|
|
publishArgs.push('--bin-path', path.resolve(repoRoot, wasmPath));
|
|
} else {
|
|
publishArgs.push('--module-path', 'server-rs/crates/spacetime-module');
|
|
}
|
|
await runCommand('spacetime', publishArgs, {
|
|
env: {
|
|
...process.env,
|
|
CARGO_TARGET_DIR: path.join(tempDir, 'cargo-target'),
|
|
},
|
|
sensitiveValues: [loginToken],
|
|
});
|
|
|
|
await runSmoke(configPath, serverUrl);
|
|
console.log(
|
|
'[editor-idempotency-smoke] Passed exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.',
|
|
);
|
|
} catch (error) {
|
|
const standaloneOutput = sanitizeDiagnostic(
|
|
standalone?.output().trim() ?? '',
|
|
[loginToken],
|
|
);
|
|
console.error(
|
|
`[editor-idempotency-smoke] Failed: ${sanitizeDiagnostic(
|
|
error instanceof Error ? error.message : String(error),
|
|
[loginToken],
|
|
)}`,
|
|
);
|
|
if (standaloneOutput) {
|
|
console.error(standaloneOutput);
|
|
}
|
|
process.exitCode = 1;
|
|
} finally {
|
|
if (standalone) {
|
|
await stopStandalone(standalone.child);
|
|
}
|
|
if (tempDir) {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
|
|
) {
|
|
await main();
|
|
}
|