Files
ttstd-infinite-canvas/web/test/generation-asset-repository.node.test.ts

416 lines
17 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationAssetStorageLock, type GenerationAssetRecord } from "../src/services/generation-asset-repository";
import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "../src/services/generation-artifact-sink";
test("generation asset insert waits for persistence and reload returns the same durable row", async () => {
let assets: GenerationAssetRecord[] = [];
let durableAssets: GenerationAssetRecord[] = [];
let releasePersistence!: () => void;
const persistenceGate = new Promise<void>((resolve) => {
releasePersistence = resolve;
});
let settled = false;
const inserting = insertOrReturnGenerationAsset({
storageScope: "test-scope",
effectKey: "materialize:task-persistence:0",
assetId: "generation-stable-id",
createAsset: () => ({
id: "generation-stable-id",
metadata: { generationEffectKey: "materialize:task-persistence:0" },
}),
updateAssets: (updater) => {
assets = updater(assets);
},
readAssets: () => assets,
readPersistedAssets: async () => structuredClone(durableAssets),
persistAssets: async (nextAssets) => {
await persistenceGate;
durableAssets = structuredClone(nextAssets);
},
}).then((assetId) => {
settled = true;
return assetId;
});
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(settled, false, "Agent effect must not complete while persistence is pending");
releasePersistence();
const assetId = await inserting;
assets = structuredClone(durableAssets);
assert.equal(assets.length, 1);
assert.equal(assets[0]?.id, assetId);
assert.equal(assets[0]?.metadata?.generationEffectKey, "materialize:task-persistence:0");
});
test("generation asset insertion does not resurrect a row deleted from durable storage", async () => {
const deleted = {
id: "asset-deleted",
metadata: { title: "stale memory row" },
} satisfies GenerationAssetRecord;
let assets: GenerationAssetRecord[] = [deleted];
let durableAssets: GenerationAssetRecord[] = [];
let persistenceWrites = 0;
await insertOrReturnGenerationAsset({
storageScope: "test-deletion-scope",
effectKey: "materialize:after-delete:0",
assetId: "asset-generated",
createAsset: () => ({
id: "asset-generated",
metadata: { generationEffectKey: "materialize:after-delete:0" },
}),
updateAssets: (updater) => {
assets = updater(assets);
},
readAssets: () => assets,
readPersistedAssets: async () => structuredClone(durableAssets),
persistAssets: async (nextAssets) => {
persistenceWrites += 1;
durableAssets = structuredClone(nextAssets);
},
});
assert.deepEqual(
durableAssets.map((asset) => asset.id),
["asset-generated"],
);
assert.deepEqual(
assets.map((asset) => asset.id),
["asset-generated"],
);
assert.equal(persistenceWrites, 1);
});
test("generation asset replay preserves the hydrated in-memory preview for the same durable row", async () => {
const effectKey = "materialize:hydrated-preview:0";
const persisted = {
id: "asset-hydrated-preview",
title: "durable title",
coverUrl: "https://persisted.invalid/preview.png",
data: { dataUrl: "https://persisted.invalid/preview.png", storageKey: "generation-image:test-scope:hydrated-preview" },
metadata: { generationEffectKey: effectKey, durableMarker: "durable" },
} satisfies GenerationAssetRecord & { title: string; coverUrl: string; data: { dataUrl: string; storageKey: string } };
const hydrated = {
...persisted,
title: "stale hydrated title",
coverUrl: "blob:hydrated-cover",
data: { ...persisted.data, dataUrl: "blob:hydrated-preview" },
metadata: { generationEffectKey: effectKey, durableMarker: "stale" },
};
let assets: (typeof hydrated)[] = [hydrated];
const assetId = await insertOrReturnGenerationAsset({
storageScope: "test-scope",
effectKey,
assetId: persisted.id,
createAsset: () => {
throw new Error("existing generation asset must be reused");
},
updateAssets: (updater) => {
assets = updater(assets) as (typeof hydrated)[];
},
readAssets: () => assets,
readPersistedAssets: async () => [persisted],
persistAssets: async () => {
throw new Error("existing generation asset must not be rewritten");
},
});
assert.equal(assetId, persisted.id);
assert.equal(assets[0]?.coverUrl, "blob:hydrated-cover");
assert.equal(assets[0]?.data.dataUrl, "blob:hydrated-preview");
assert.equal(assets[0]?.data.storageKey, persisted.data.storageKey);
assert.equal(assets[0]?.title, "durable title");
assert.equal(assets[0]?.metadata?.durableMarker, "durable");
});
test("generation asset replay does not carry a blob preview across a changed storage key", async () => {
const effectKey = "materialize:hydrated-preview-storage-change:0";
const persisted = {
id: "asset-hydrated-preview-storage-change",
coverUrl: "https://persisted.invalid/new-preview.png",
data: { dataUrl: "https://persisted.invalid/new-preview.png", storageKey: "generation-image:test-scope:new-key" },
metadata: { generationEffectKey: effectKey },
} satisfies GenerationAssetRecord & { coverUrl: string; data: { dataUrl: string; storageKey: string } };
const hydrated = {
...persisted,
coverUrl: "blob:stale-cover",
data: { dataUrl: "blob:stale-preview", storageKey: "generation-image:test-scope:old-key" },
};
let assets: Array<typeof persisted | typeof hydrated> = [hydrated];
await insertOrReturnGenerationAsset({
storageScope: "test-scope",
effectKey,
assetId: persisted.id,
createAsset: () => {
throw new Error("existing generation asset must be reused");
},
updateAssets: (updater) => {
assets = updater(assets as (typeof persisted)[]) as Array<typeof persisted | typeof hydrated>;
},
readAssets: () => assets as (typeof persisted)[],
readPersistedAssets: async () => [persisted],
persistAssets: async () => {
throw new Error("existing generation asset must not be rewritten");
},
});
assert.equal(assets[0]?.coverUrl, persisted.coverUrl);
assert.equal(assets[0]?.data.dataUrl, persisted.data.dataUrl);
assert.equal(assets[0]?.data.storageKey, persisted.data.storageKey);
});
test("generation asset replay rejects a deterministic row deleted after its base revision", async () => {
let assets: GenerationAssetRecord[] = [];
await assert.rejects(
insertOrReturnGenerationAsset({
storageScope: "test-tombstone-scope",
effectKey: "materialize:tombstoned:0",
assetId: "asset-tombstoned",
createAsset: () => ({
id: "asset-tombstoned",
metadata: { generationEffectKey: "materialize:tombstoned:0" },
}),
updateAssets: (updater) => {
assets = updater(assets);
},
readAssets: () => assets,
readPersistedAssets: async () => [],
isAssetDeleted: () => true,
persistAssets: async () => {
throw new Error("tombstoned generation asset must not be persisted");
},
} as Parameters<typeof insertOrReturnGenerationAsset>[0]),
/生成素材已被用户删除/,
);
assert.deepEqual(assets, []);
});
test("concurrent generation asset inserts merge inside a per-scope storage lock", async () => {
const durableByScope = new Map<string, GenerationAssetRecord[]>();
let releaseFirstWrite!: () => void;
const firstWriteGate = new Promise<void>((resolve) => {
releaseFirstWrite = resolve;
});
let firstWriteStartedResolve!: () => void;
const firstWriteStarted = new Promise<void>((resolve) => {
firstWriteStartedResolve = resolve;
});
const insert = (scope: string, effectKey: string, assetId: string, blockWrite = false) => {
let assets: GenerationAssetRecord[] = [];
return insertOrReturnGenerationAsset({
storageScope: scope,
effectKey,
assetId,
createAsset: () => ({
id: assetId,
metadata: { generationEffectKey: effectKey },
}),
updateAssets: (updater) => {
assets = updater(assets);
},
readAssets: () => assets,
readPersistedAssets: async () => structuredClone(durableByScope.get(scope) ?? []),
persistAssets: async (nextAssets) => {
if (blockWrite) {
firstWriteStartedResolve();
await firstWriteGate;
}
durableByScope.set(scope, structuredClone(nextAssets));
},
});
};
const first = insert("account-A", "materialize:concurrent-a:0", "asset-a", true);
await firstWriteStarted;
let secondSettled = false;
const second = insert("account-A", "materialize:concurrent-b:0", "asset-b").then((assetId) => {
secondSettled = true;
return assetId;
});
let otherScopeSettled = false;
const otherScope = insert("account-B", "materialize:isolated:0", "asset-other").then(() => {
otherScopeSettled = true;
});
await otherScope;
assert.equal(otherScopeSettled, true, "another user scope must not wait on account-A's lock");
assert.equal(secondSettled, false, "same-scope insert must wait for the first write");
releaseFirstWrite();
await Promise.all([first, second, otherScope]);
assert.deepEqual(
durableByScope
.get("account-A")
?.map((asset) => asset.id)
.sort(),
["asset-a", "asset-b"],
);
assert.deepEqual(
durableByScope.get("account-B")?.map((asset) => asset.id),
["asset-other"],
);
});
test("browser generation asset writes use the storage-level Web Locks boundary", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
const lockNames: string[] = [];
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
locks: {
request: async <T>(name: string, callback: () => Promise<T>) => {
lockNames.push(name);
return callback();
},
},
},
});
try {
await withGenerationAssetStorageLock("account-A", async () => undefined);
assert.deepEqual(lockNames, ["yingce:generation-asset-storage-lock:account-A"]);
} finally {
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
if (originalNavigator === undefined) delete (globalThis as { navigator?: unknown }).navigator;
else Object.defineProperty(globalThis, "navigator", { configurable: true, value: originalNavigator });
}
});
test("competing browser generation asset writes fail closed before entering storage without Web Locks", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const originalDocument = (globalThis as { document?: unknown }).document;
const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
Object.defineProperty(globalThis, "document", { configurable: true, value: {} });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} });
let entered = 0;
try {
const compete = () =>
withGenerationAssetStorageLock(
"account-no-web-locks",
async () => {
entered += 1;
},
{ requireCrossRealmLock: true },
);
await Promise.all([assert.rejects(compete(), /跨标签存储锁/), assert.rejects(compete(), /跨标签存储锁/)]);
assert.equal(entered, 0, "neither competing generation write may enter the critical section without a cross-tab lock");
} finally {
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
if (originalDocument === undefined) delete (globalThis as { document?: unknown }).document;
else Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument });
if (originalNavigator === undefined) delete (globalThis as { navigator?: unknown }).navigator;
else Object.defineProperty(globalThis, "navigator", { configurable: true, value: originalNavigator });
}
});
test("an aborted generation asset fence prevents the durable catalog write", async () => {
const controller = new AbortController();
controller.abort();
let persistenceWrites = 0;
await assert.rejects(
insertOrReturnGenerationAsset({
storageScope: "aborted-generation-asset",
effectKey: "materialize:aborted-generation-asset:0",
assetId: "asset-aborted-generation-asset",
signal: controller.signal,
createAsset: () => ({ id: "asset-aborted-generation-asset", metadata: { generationEffectKey: "materialize:aborted-generation-asset:0" } }),
updateAssets: () => undefined,
readAssets: () => [],
readPersistedAssets: async () => [],
persistAssets: async () => {
persistenceWrites += 1;
},
}),
(error: unknown) => error instanceof DOMException && error.name === "AbortError",
);
assert.equal(persistenceWrites, 0);
});
test("generation asset lock flush drains tails added while the flush is running", async () => {
let releaseFirst!: () => void;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
let firstStartedResolve!: () => void;
const firstStarted = new Promise<void>((resolve) => {
firstStartedResolve = resolve;
});
const first = withGenerationAssetStorageLock("flush-scope", async () => {
firstStartedResolve();
await firstGate;
});
await firstStarted;
let flushSettled = false;
const flushing = flushGenerationAssetStorageLocks().then(() => {
flushSettled = true;
});
let releaseSecond!: () => void;
const secondGate = new Promise<void>((resolve) => {
releaseSecond = resolve;
});
let secondStartedResolve!: () => void;
const secondStarted = new Promise<void>((resolve) => {
secondStartedResolve = resolve;
});
const second = withGenerationAssetStorageLock("flush-scope", async () => {
secondStartedResolve();
await secondGate;
});
releaseFirst();
await secondStarted;
try {
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(flushSettled, false, "flush must wait for a tail queued during the active flush");
} finally {
releaseSecond();
await Promise.all([first, second, flushing]);
}
assert.equal(flushSettled, true);
});
test("generation artifact sink recovers after ack crash without a second materialization", async () => {
const durable = new Map<string, { storageKey: string }>();
const effectKey = "materialize:task-ack-crash:0";
const storageKey = generationArtifactStorageKey(effectKey, "image", "test-scope");
let materializations = 0;
const run = () =>
loadOrStoreGenerationArtifact({
effectKey: storageKey,
read: async (key) => durable.get(key) ?? null,
materialize: async () => {
materializations += 1;
return { storageKey };
},
write: async (key, artifact) => {
durable.set(key, artifact);
},
});
await run();
// Simulate a process crash after the durable sink succeeded but before the Agent effect ack.
for (let replay = 0; replay < 3; replay += 1) await run();
assert.equal(materializations, 1);
assert.equal(durable.size, 1);
assert.equal(durable.get(storageKey)?.storageKey, storageKey);
});