Files
Genarrative/scripts/spacetime-editor-idempotency-smoke.mjs
suzmii 94521af890
Project CI / Repository checks (push) Successful in 2m52s
Project CI / Frontend tests (push) Successful in 10m40s
Project CI / Backend tests (push) Successful in 6m26s
Project CI / Native shell tests (push) Successful in 18m17s
升级 SpacetimeDB 工具链到 2.8.3 (#200)
将 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>
2026-08-27 17:10:17 +08:00

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();
}