@tokenoftrust/storefront-runner 2.4.2 → 2.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/lib/social/golive.ts +1 -1
- package/apps/storefront/test/pipeline/fake-preview-services.ts +32 -3
- package/apps/storefront/test/pipeline/harness.ts +15 -3
- package/apps/storefront/test/pipeline/pipeline-host.ts +84 -5
- package/apps/storefront/test/pipeline/publish-kinds.ts +162 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/tenant-assets.ts +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* S1 — the social go-live hook + idempotency.
|
|
3
3
|
*
|
|
4
|
-
* WHERE IT FIRES: at the true go-live edge — after
|
|
4
|
+
* WHERE IT FIRES: at the true go-live edge — after the tenant pipeline's publish job
|
|
5
5
|
* flips a tenant's `live` channel to a freshly-shipped version and records the
|
|
6
6
|
* release ledger. NOT on `POST /api/admin/blog`'s `publish` (which only opens a
|
|
7
7
|
* candidate PR a human still promotes) and NOT on accept-to-preview
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* email, then the object is evicted
|
|
16
16
|
* - `reaccept-times-out` after the forge merged, the re-accept of a resumed merge answers a
|
|
17
17
|
* transient timeout instead of a refusal
|
|
18
|
+
* - `slow-build` every file of a build takes a few milliseconds (a build that is
|
|
19
|
+
* still running seconds after its intent was answered)
|
|
20
|
+
* The fake also records what each job reported to MCP (`fake_events` kind `report`) and which
|
|
21
|
+
* shas the preview could not render (`refused`).
|
|
18
22
|
* A fault fires `times` times (0 = every time); its firing is persisted before it acts.
|
|
19
23
|
*/
|
|
20
24
|
import type { DurableObjectState } from "cloudflare:workers";
|
|
@@ -29,6 +33,9 @@ import type {
|
|
|
29
33
|
/** Files a fake build materializes. */
|
|
30
34
|
export const FAKE_FILE_COUNT = 250;
|
|
31
35
|
|
|
36
|
+
/** How long one file of a `slow-build` takes: the whole build, about a second and a half. */
|
|
37
|
+
export const SLOW_FILE_MS = 6;
|
|
38
|
+
|
|
32
39
|
export interface FakeFault {
|
|
33
40
|
point: string;
|
|
34
41
|
times: number;
|
|
@@ -43,6 +50,9 @@ export interface FakeState {
|
|
|
43
50
|
staged: string[];
|
|
44
51
|
refreshes: string[];
|
|
45
52
|
emails: string[];
|
|
53
|
+
/** `<sha>:<evidence|refused>`, in report order. */
|
|
54
|
+
reports: string[];
|
|
55
|
+
refused: string[];
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
export function fakeTables(state: DurableObjectState): void {
|
|
@@ -66,6 +76,8 @@ export function configureFake(
|
|
|
66
76
|
faults?: FakeFault[];
|
|
67
77
|
/** Other open candidates a base advance rebuilds; `conflicts` ones email their author. */
|
|
68
78
|
rebaseCandidates?: { changeId: string; pr: number; conflicts?: boolean }[];
|
|
79
|
+
/** `[ancestor, descendant]` commit pairs the fake forge answers `isAncestor` true for. */
|
|
80
|
+
ancestry?: [string, string][];
|
|
69
81
|
},
|
|
70
82
|
): void {
|
|
71
83
|
fakeTables(state);
|
|
@@ -76,6 +88,9 @@ export function configureFake(
|
|
|
76
88
|
for (const f of config.faults ?? []) {
|
|
77
89
|
sql.exec(`INSERT OR REPLACE INTO fake_faults (point, times, fired) VALUES (?, ?, 0)`, f.point, f.times);
|
|
78
90
|
}
|
|
91
|
+
for (const [ancestor, descendant] of config.ancestry ?? []) {
|
|
92
|
+
sql.exec(`INSERT OR IGNORE INTO fake_ancestry (ancestor, descendant) VALUES (?, ?)`, ancestor, descendant);
|
|
93
|
+
}
|
|
79
94
|
for (const c of config.rebaseCandidates ?? []) {
|
|
80
95
|
sql.exec(`INSERT OR REPLACE INTO fake_candidates (change_id, pr, conflicts) VALUES (?, ?, ?)`, c.changeId, c.pr, c.conflicts ? 1 : 0);
|
|
81
96
|
}
|
|
@@ -103,6 +118,8 @@ export function readFakeState(state: DurableObjectState): FakeState {
|
|
|
103
118
|
staged: events("stage"),
|
|
104
119
|
refreshes: events("refresh"),
|
|
105
120
|
emails: events("email"),
|
|
121
|
+
reports: events("report"),
|
|
122
|
+
refused: events("refused"),
|
|
106
123
|
};
|
|
107
124
|
}
|
|
108
125
|
|
|
@@ -181,9 +198,11 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
|
|
|
181
198
|
cause: new ForgeTransportError("timed out reading the body", { transient: true, errorKind: "timeout" }),
|
|
182
199
|
};
|
|
183
200
|
}
|
|
201
|
+
const slow = await fires("slow-build");
|
|
184
202
|
for (let file = 0; file < FAKE_FILE_COUNT; file += 1) {
|
|
185
203
|
const path = `content/file-${file}.json`;
|
|
186
204
|
if (resume.done(path)) continue;
|
|
205
|
+
if (slow) await new Promise((resolve) => setTimeout(resolve, SLOW_FILE_MS));
|
|
187
206
|
if (await fires(`evict-at-file:${file}`)) evict(`at file ${file}`);
|
|
188
207
|
sql.exec(`INSERT INTO fake_file_runs (sha, file) VALUES (?, ?)`, sha, file);
|
|
189
208
|
await resume.record(path, { storedVersionId: `v-${sha}-${file}`, issues: [], skippedHere: false });
|
|
@@ -191,8 +210,17 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
|
|
|
191
210
|
return { ok: true, versionId: sha, digest: `digest-${sha}` };
|
|
192
211
|
},
|
|
193
212
|
|
|
194
|
-
async evidence() {
|
|
195
|
-
return { promotable: true };
|
|
213
|
+
async evidence({ sha }) {
|
|
214
|
+
return { promotable: true, report: { sha, promotable: true, checks: [] } };
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async reportOutcome({ sha, outcome }) {
|
|
218
|
+
sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('report', ?)`, `${sha}:${outcome.kind}`);
|
|
219
|
+
return { reported: true };
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async recordPreviewRefused({ sha }) {
|
|
223
|
+
sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('refused', ?)`, sha);
|
|
196
224
|
},
|
|
197
225
|
|
|
198
226
|
async previewPointer() {
|
|
@@ -204,8 +232,9 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
|
|
|
204
232
|
return sql.exec(`SELECT 1 FROM fake_ancestry WHERE ancestor = ? AND descendant = ?`, ancestor, descendant).toArray().length > 0;
|
|
205
233
|
},
|
|
206
234
|
|
|
207
|
-
async advancePreview({ sha, membership, jobId, kind }) {
|
|
235
|
+
async advancePreview({ sha, membership, jobId, kind, releasable }) {
|
|
208
236
|
setPointer(sha);
|
|
237
|
+
if (!releasable) return;
|
|
209
238
|
sql.exec(
|
|
210
239
|
`INSERT INTO fake_advances (sha, membership, job_id, kind) VALUES (?, ?, ?, ?)`,
|
|
211
240
|
sha,
|
|
@@ -21,7 +21,19 @@ interface HarnessEnv {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
type TenantPipelineStub = {
|
|
24
|
-
[K in
|
|
24
|
+
[K in
|
|
25
|
+
| "submit"
|
|
26
|
+
| "deliverExternal"
|
|
27
|
+
| "job"
|
|
28
|
+
| "jobs"
|
|
29
|
+
| "transitions"
|
|
30
|
+
| "facts"
|
|
31
|
+
| "harness"
|
|
32
|
+
| "probeStaleWrites"
|
|
33
|
+
| "configureFake"
|
|
34
|
+
| "fakeState"
|
|
35
|
+
| "spans"
|
|
36
|
+
| "publishHarness"]: TenantPipeline[K];
|
|
25
37
|
};
|
|
26
38
|
|
|
27
39
|
export const harnessEnv = env as unknown as HarnessEnv;
|
|
@@ -105,9 +117,9 @@ export async function waitTerminal(tenant: string, jobIds: string[], timeoutMs =
|
|
|
105
117
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
106
118
|
continue;
|
|
107
119
|
}
|
|
108
|
-
const running = jobs.filter((j) => j.state === "running").length;
|
|
120
|
+
const running = jobs.filter((j) => j.state === "running" || j.state === "awaiting-external").length;
|
|
109
121
|
maxRunning = Math.max(maxRunning, running);
|
|
110
|
-
expect(running, "running jobs observed at once").toBeLessThanOrEqual(1);
|
|
122
|
+
expect(running, "running (or awaiting) jobs observed at once").toBeLessThanOrEqual(1);
|
|
111
123
|
const wanted = jobIds.map((id) => jobs.find((j) => j.jobId === id));
|
|
112
124
|
if (wanted.every((j) => j && TERMINAL.has(j.state))) return wanted as JobView[];
|
|
113
125
|
if (Date.now() > deadline) {
|
|
@@ -2,16 +2,29 @@
|
|
|
2
2
|
* The Worker that hosts the TenantPipeline Durable Object for the harness, in its own isolate
|
|
3
3
|
* (as in production, where callers never share the object's isolate — and where an eviction
|
|
4
4
|
* restarts the object without touching its callers). It runs the production engine with the
|
|
5
|
-
* synthetic job kinds
|
|
6
|
-
* services (`./fake-preview-services.ts`)
|
|
5
|
+
* synthetic job kinds, the production preview kinds (integrate / rebuild / revert / sync-preview) over
|
|
6
|
+
* fake services (`./fake-preview-services.ts`) and the real publish kinds over the stand-in world
|
|
7
|
+
* (`./publish-kinds.ts`), and answers intents through `submitIntent`.
|
|
7
8
|
*/
|
|
8
9
|
import type { DurableObjectState } from "cloudflare:workers";
|
|
9
10
|
import { TenantPipeline as DeployedTenantPipeline } from "../../src/lib/pipeline/tenant-pipeline";
|
|
10
|
-
import { submitIntent, type TenantPipelineNamespace } from "../../src/lib/pipeline/intake";
|
|
11
|
+
import { submitIntent, tenantPipeline, type TenantPipelineNamespace } from "../../src/lib/pipeline/intake";
|
|
12
|
+
import { submitPreviewSync } from "../../src/lib/pipeline/preview-sync-intake";
|
|
11
13
|
import { StaleExecutionError, type JobKindRegistry, type PipelineIntent } from "../../src/lib/pipeline/job-runner";
|
|
12
14
|
import { abandonedContexts, syntheticJobKinds, syntheticSpans } from "./synthetic-job";
|
|
13
15
|
import { registerPreviewJobKinds, type PreviewJobTiming } from "../../src/lib/pipeline/preview-jobs";
|
|
14
16
|
import { configureFake, fakePreviewServices, readFakeState, type FakeFault, type FakeState } from "./fake-preview-services";
|
|
17
|
+
import { readLivePointer } from "../../src/lib/pipeline/publish/live-pointer";
|
|
18
|
+
import { listReleaseReceipts, readReleaseTruth } from "../../src/lib/change/store/aggregate-release-store";
|
|
19
|
+
import { seedGreenAggregate, testVersionStore } from "../../src/lib/pipeline/publish/publish-test-support";
|
|
20
|
+
import {
|
|
21
|
+
armedAtFaults,
|
|
22
|
+
armPublishFault,
|
|
23
|
+
firedPublishFaults,
|
|
24
|
+
publishWorldOf,
|
|
25
|
+
registerHarnessPublishKinds,
|
|
26
|
+
type PublishFault,
|
|
27
|
+
} from "./publish-kinds";
|
|
15
28
|
|
|
16
29
|
/** The preview kinds' timing in the harness: the production shape, scaled to milliseconds. */
|
|
17
30
|
export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
|
|
@@ -24,11 +37,18 @@ export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
|
|
|
24
37
|
|
|
25
38
|
export class TenantPipeline extends DeployedTenantPipeline {
|
|
26
39
|
protected override jobKinds(ctx: DurableObjectState): JobKindRegistry {
|
|
27
|
-
return
|
|
40
|
+
return registerHarnessPublishKinds(
|
|
41
|
+
ctx,
|
|
42
|
+
registerPreviewJobKinds(syntheticJobKinds(ctx), fakePreviewServices(ctx), HARNESS_PREVIEW_TIMING),
|
|
43
|
+
);
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
/** Harness-only: open candidates and arm faults for the fake preview services. */
|
|
31
|
-
async configureFake(config: {
|
|
47
|
+
async configureFake(config: {
|
|
48
|
+
open?: { changeId: string; headSha: string }[];
|
|
49
|
+
faults?: FakeFault[];
|
|
50
|
+
ancestry?: [string, string][];
|
|
51
|
+
}): Promise<void> {
|
|
32
52
|
configureFake(this.ctx, config);
|
|
33
53
|
}
|
|
34
54
|
|
|
@@ -42,6 +62,45 @@ export class TenantPipeline extends DeployedTenantPipeline {
|
|
|
42
62
|
return readFakeState(this.ctx);
|
|
43
63
|
}
|
|
44
64
|
|
|
65
|
+
/** Harness-only: arrange and read the publish stand-in world inside this object's storage. */
|
|
66
|
+
async publishHarness(op: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
|
67
|
+
const { kv, world } = publishWorldOf(this.ctx);
|
|
68
|
+
const store = testVersionStore(kv);
|
|
69
|
+
const tenantId = String(args.tenantId ?? "");
|
|
70
|
+
switch (op) {
|
|
71
|
+
case "configure":
|
|
72
|
+
return world.configure(args.patch as never);
|
|
73
|
+
case "seedGreen":
|
|
74
|
+
await seedGreenAggregate(kv, store, args as never);
|
|
75
|
+
return null;
|
|
76
|
+
case "completeRun":
|
|
77
|
+
return world.completeRun(String(args.dispatchId), args.conclusion as never, args.served as never);
|
|
78
|
+
case "serveOnCloudFront":
|
|
79
|
+
return world.serveOnCloudFront(String(args.sha), (args.treeDigest as string | null) ?? null);
|
|
80
|
+
case "completeStage":
|
|
81
|
+
return world.completeStage(String(args.dispatchId), args.conclusion as never, args.digest as never);
|
|
82
|
+
case "dispatches":
|
|
83
|
+
return world.dispatches();
|
|
84
|
+
case "promotionStatus":
|
|
85
|
+
return world.promotionStatus();
|
|
86
|
+
case "live":
|
|
87
|
+
return readLivePointer(store, tenantId);
|
|
88
|
+
case "receipts":
|
|
89
|
+
return (await listReleaseReceipts(kv, tenantId, "preview")).map((r) => r.receiptId);
|
|
90
|
+
case "truth":
|
|
91
|
+
return readReleaseTruth(kv, tenantId, "preview");
|
|
92
|
+
case "armFault":
|
|
93
|
+
armPublishFault(this.ctx, args as unknown as PublishFault);
|
|
94
|
+
return null;
|
|
95
|
+
case "firedFaults":
|
|
96
|
+
return firedPublishFaults(this.ctx);
|
|
97
|
+
case "armedAtFaults":
|
|
98
|
+
return armedAtFaults(this.ctx);
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(`unknown publish harness op ${op}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
45
104
|
/** Harness-only reads of the synthetic step log. */
|
|
46
105
|
async harness(jobId: string): Promise<{
|
|
47
106
|
runs: { step: number; chunk: number; attempt: number }[];
|
|
@@ -91,8 +150,28 @@ interface Env {
|
|
|
91
150
|
TENANT_PIPELINE: TenantPipelineNamespace;
|
|
92
151
|
}
|
|
93
152
|
|
|
153
|
+
/**
|
|
154
|
+
* `/sync/<tenant>` answers a forwarded push the way `/api/preview/reconcile` does: submit the
|
|
155
|
+
* `sync-preview` intent and answer 202. `/sync-hold/<tenant>` submits, then holds the response open
|
|
156
|
+
* (a forward the caller kills mid-flight): the job must not depend on the request.
|
|
157
|
+
*/
|
|
158
|
+
async function syncRoute(request: Request, env: Env, tenant: string, hold: boolean): Promise<Response> {
|
|
159
|
+
const body = (await request.json()) as { repo: string; sha: string };
|
|
160
|
+
const result = await submitPreviewSync(
|
|
161
|
+
tenantPipeline(env.TENANT_PIPELINE, tenant),
|
|
162
|
+
tenant,
|
|
163
|
+
{ repo: body.repo, sha: body.sha, source: "push" },
|
|
164
|
+
{ actor: { type: "service", id: "harness-forward" }, admissionDecisionId: null },
|
|
165
|
+
);
|
|
166
|
+
if (!result.ok) return Response.json({ error: result.code }, { status: 400 });
|
|
167
|
+
if (hold) await new Promise((resolve) => setTimeout(resolve, 10_000));
|
|
168
|
+
return Response.json({ jobId: result.jobId, state: result.state, duplicate: result.duplicate }, { status: 202 });
|
|
169
|
+
}
|
|
170
|
+
|
|
94
171
|
export default {
|
|
95
172
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
173
|
+
const sync = /^\/(sync|sync-hold)\/([^/]+)$/.exec(new URL(request.url).pathname);
|
|
174
|
+
if (request.method === "POST" && sync) return syncRoute(request, env, decodeURIComponent(sync[2]!), sync[1] === "sync-hold");
|
|
96
175
|
const match = /^\/intents\/([^/]+)$/.exec(new URL(request.url).pathname);
|
|
97
176
|
if (request.method !== "POST" || !match) return new Response("not found", { status: 404 });
|
|
98
177
|
const response = await submitIntent(env.TENANT_PIPELINE, decodeURIComponent(match[1]!), (await request.json()) as PipelineIntent);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REAL publish / rollback / hotfix job kinds for the TenantPipeline fault harness, over the
|
|
3
|
+
* stand-in world (`src/lib/pipeline/publish/publish-test-support.ts`: GitHub Actions, CloudFront,
|
|
4
|
+
* the Cloudflare edge, the MCP promotion ledger). Test-only.
|
|
5
|
+
*
|
|
6
|
+
* Every byte of state — the job's KV (release ledger, version store, `live` pointer) and the
|
|
7
|
+
* stand-ins' — lives in the Durable Object's own SQLite, so it survives the evictions the harness
|
|
8
|
+
* causes. Timing is shortened so the CloudFront callback window, GitHub run lookups and proof
|
|
9
|
+
* retries play out in seconds.
|
|
10
|
+
*
|
|
11
|
+
* Faults are armed per object (each test uses its own tenant) at a named step — or `on-terminal`, the
|
|
12
|
+
* kind's compensation after a terminal transition — `before` its work or `after` it (once the work
|
|
13
|
+
* is done, before the engine records the step): `evict` aborts the
|
|
14
|
+
* object, `throw` fails the attempt. A fault's remaining count is persisted (and flushed) before it
|
|
15
|
+
* acts, so it survives the eviction it causes.
|
|
16
|
+
*/
|
|
17
|
+
import type { DurableObjectState } from "cloudflare:workers";
|
|
18
|
+
import { JobKindRegistry, type JobKindDefinition, type StepDefinition } from "../../src/lib/pipeline/job-runner";
|
|
19
|
+
import { registerPublishJobKinds, type PublishKindTuning, type PublishTiming } from "../../src/lib/pipeline/publish/publish-job";
|
|
20
|
+
import { PublishWorld, standInPublishEnvironment, type TestKv } from "../../src/lib/pipeline/publish/publish-test-support";
|
|
21
|
+
|
|
22
|
+
export const PUBLISH_HARNESS_TIMING: PublishTiming = {
|
|
23
|
+
awaitWindowMs: 1_500,
|
|
24
|
+
runGraceMs: 500,
|
|
25
|
+
proofDelayMs: 30,
|
|
26
|
+
proofProbes: { synchronous: 3, asynchronous: 5 },
|
|
27
|
+
stageWindowMs: 1_500,
|
|
28
|
+
stageChecks: 3,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const PUBLISH_HARNESS_TUNING: PublishKindTuning = {
|
|
32
|
+
leaseMs: 3_000,
|
|
33
|
+
retryBaseMs: 20,
|
|
34
|
+
deadlineMs: { synchronous: 30_000, asynchronous: 30_000 },
|
|
35
|
+
hotfixExtraMs: 0,
|
|
36
|
+
compensationRetryBaseMs: 300,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface PublishFault {
|
|
40
|
+
step: string;
|
|
41
|
+
at: "before" | "after";
|
|
42
|
+
mode: "evict" | "throw";
|
|
43
|
+
/** How many times it fires (default 1). */
|
|
44
|
+
times?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tables(state: DurableObjectState): void {
|
|
48
|
+
const { sql } = state.storage;
|
|
49
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS harness_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
50
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, mode TEXT, remaining INTEGER)`);
|
|
51
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_fired (seq INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, mode TEXT)`);
|
|
52
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_armed (seq INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, armed INTEGER)`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** At each fired fault: how many compensations the engine held armed for jobs not yet terminal. */
|
|
56
|
+
export function armedAtFaults(state: DurableObjectState): { step: string; at: string; armed: number }[] {
|
|
57
|
+
tables(state);
|
|
58
|
+
return state.storage.sql.exec<{ step: string; at: string; armed: number }>(`SELECT step, at, armed FROM harness_publish_armed ORDER BY seq`).toArray();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function armedCompensations(state: DurableObjectState): number {
|
|
62
|
+
try {
|
|
63
|
+
return state.storage.sql
|
|
64
|
+
.exec<{ n: number }>(
|
|
65
|
+
`SELECT COUNT(*) AS n FROM terminal_hooks h JOIN jobs j ON j.job_id = h.job_id WHERE j.state NOT IN ('succeeded', 'needs-developer', 'stuck')`,
|
|
66
|
+
)
|
|
67
|
+
.one().n;
|
|
68
|
+
} catch {
|
|
69
|
+
return -1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A KV over the object's SQLite: durable across evictions, visible to every later execution. */
|
|
74
|
+
export function sqlKv(state: DurableObjectState): TestKv {
|
|
75
|
+
const { sql } = state.storage;
|
|
76
|
+
return {
|
|
77
|
+
get: async (key) => sql.exec<{ value: string }>(`SELECT value FROM harness_kv WHERE key = ?`, key).toArray()[0]?.value ?? null,
|
|
78
|
+
put: async (key, value) => {
|
|
79
|
+
sql.exec(`INSERT INTO harness_kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value);
|
|
80
|
+
},
|
|
81
|
+
delete: async (key) => {
|
|
82
|
+
sql.exec(`DELETE FROM harness_kv WHERE key = ?`, key);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function armPublishFault(state: DurableObjectState, fault: PublishFault): void {
|
|
88
|
+
tables(state);
|
|
89
|
+
state.storage.sql.exec(
|
|
90
|
+
`INSERT INTO harness_publish_faults (step, at, mode, remaining) VALUES (?, ?, ?, ?)`,
|
|
91
|
+
fault.step,
|
|
92
|
+
fault.at,
|
|
93
|
+
fault.mode,
|
|
94
|
+
fault.times ?? 1,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function firedPublishFaults(state: DurableObjectState): { step: string; at: string; mode: string }[] {
|
|
99
|
+
tables(state);
|
|
100
|
+
return state.storage.sql.exec<{ step: string; at: string; mode: string }>(`SELECT step, at, mode FROM harness_publish_fired ORDER BY seq`).toArray();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function maybeFault(state: DurableObjectState, step: string, at: "before" | "after"): Promise<void> {
|
|
104
|
+
const { sql } = state.storage;
|
|
105
|
+
const fault = sql
|
|
106
|
+
.exec<{ id: number; mode: string }>(`SELECT id, mode FROM harness_publish_faults WHERE step = ? AND at = ? AND remaining > 0 ORDER BY id LIMIT 1`, step, at)
|
|
107
|
+
.toArray()[0];
|
|
108
|
+
if (!fault) return;
|
|
109
|
+
sql.exec(`UPDATE harness_publish_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
|
|
110
|
+
sql.exec(`INSERT INTO harness_publish_fired (step, at, mode) VALUES (?, ?, ?)`, step, at, fault.mode);
|
|
111
|
+
sql.exec(`INSERT INTO harness_publish_armed (step, at, armed) VALUES (?, ?, ?)`, step, at, armedCompensations(state));
|
|
112
|
+
// Everything written so far — including a step's own effect, for an `after` fault — is made
|
|
113
|
+
// durable first, exactly as a real crash after the write would leave it.
|
|
114
|
+
await state.storage.sync();
|
|
115
|
+
if (fault.mode === "evict") {
|
|
116
|
+
state.abort(`synthetic eviction ${at} ${step}`);
|
|
117
|
+
throw new Error("abort() returned");
|
|
118
|
+
}
|
|
119
|
+
throw new Error(`synthetic fault ${at} ${step}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function withFaults(state: DurableObjectState, definition: JobKindDefinition<unknown>): JobKindDefinition<unknown> {
|
|
123
|
+
const onTerminal = definition.onTerminal;
|
|
124
|
+
return {
|
|
125
|
+
...definition,
|
|
126
|
+
// The compensation after a terminal transition can be faulted too (as the step "on-terminal").
|
|
127
|
+
...(onTerminal
|
|
128
|
+
? {
|
|
129
|
+
onTerminal: async (info: Parameters<NonNullable<JobKindDefinition<unknown>["onTerminal"]>>[0]) => {
|
|
130
|
+
await maybeFault(state, "on-terminal", "before");
|
|
131
|
+
await onTerminal(info);
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
: {}),
|
|
135
|
+
steps: definition.steps.map(
|
|
136
|
+
(step): StepDefinition<unknown> => ({
|
|
137
|
+
...step,
|
|
138
|
+
async run(ctx) {
|
|
139
|
+
await maybeFault(state, step.name, "before");
|
|
140
|
+
const outcome = await step.run(ctx);
|
|
141
|
+
await maybeFault(state, step.name, "after");
|
|
142
|
+
return outcome;
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function publishWorldOf(state: DurableObjectState): { kv: TestKv; world: PublishWorld } {
|
|
150
|
+
tables(state);
|
|
151
|
+
const kv = sqlKv(state);
|
|
152
|
+
return { kv, world: new PublishWorld(kv) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Register the publish kinds (fault-wrapped, over the stand-in world) into `registry`. */
|
|
156
|
+
export function registerHarnessPublishKinds(state: DurableObjectState, registry: JobKindRegistry): JobKindRegistry {
|
|
157
|
+
const { kv, world } = publishWorldOf(state);
|
|
158
|
+
const environment = standInPublishEnvironment({ kv, world, now: () => Date.now(), timing: PUBLISH_HARNESS_TIMING });
|
|
159
|
+
const kinds = registerPublishJobKinds(new JobKindRegistry(), async () => environment, PUBLISH_HARNESS_TUNING);
|
|
160
|
+
for (const name of kinds.list()) registry.register(withFaults(state, kinds.get(name)!));
|
|
161
|
+
return registry;
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.4",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -425,7 +425,7 @@ export class AssetsPointerConflictError extends Error {
|
|
|
425
425
|
}
|
|
426
426
|
|
|
427
427
|
/**
|
|
428
|
-
* Point a channel at a snapshot versionId (CAS on `rev`). KV has no hardware CAS,
|
|
428
|
+
* Point a channel at a snapshot versionId — or unset it with `null` (CAS on `rev`). KV has no hardware CAS,
|
|
429
429
|
* so the caller reads the pointer once, passes its `rev` as `expectedRev`, and
|
|
430
430
|
* this rejects the write if the stored `rev` has since moved — two concurrent
|
|
431
431
|
* asset-promotions for one tenant can no longer silently clobber each other
|
|
@@ -436,7 +436,7 @@ export async function setAssetsChannel(
|
|
|
436
436
|
kv: AssetsKv,
|
|
437
437
|
tenantId: string,
|
|
438
438
|
channel: CustomizationEnv,
|
|
439
|
-
versionId: string,
|
|
439
|
+
versionId: string | null,
|
|
440
440
|
expectedRev: number,
|
|
441
441
|
): Promise<AssetsPointer> {
|
|
442
442
|
const current = await readAssetsPointer(kv, tenantId);
|