@tokenoftrust/storefront-runner 2.4.3 → 2.4.5

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.
@@ -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 `AggregateShipOrchestrator`
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
@@ -21,7 +21,19 @@ interface HarnessEnv {
21
21
  }
22
22
 
23
23
  type TenantPipelineStub = {
24
- [K in "submit" | "job" | "jobs" | "transitions" | "facts" | "harness" | "probeStaleWrites" | "configureFake" | "fakeState" | "spans"]: TenantPipeline[K];
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;
@@ -89,7 +101,8 @@ const TERMINAL = new Set<JobState>(["succeeded", "needs-developer", "stuck"]);
89
101
 
90
102
  /**
91
103
  * Polls until every job is terminal. On every sample asserts the tenant never has more than one
92
- * running job. Create stubs for later reads after this returns (see the eviction note below).
104
+ * active (running or awaiting) job per lane. Create stubs for later reads after this returns (see
105
+ * the eviction note below).
93
106
  */
94
107
  export async function waitTerminal(tenant: string, jobIds: string[], timeoutMs = 20_000): Promise<JobView[]> {
95
108
  const deadline = Date.now() + timeoutMs;
@@ -105,9 +118,11 @@ export async function waitTerminal(tenant: string, jobIds: string[], timeoutMs =
105
118
  await new Promise((resolve) => setTimeout(resolve, 25));
106
119
  continue;
107
120
  }
108
- const running = jobs.filter((j) => j.state === "running").length;
109
- maxRunning = Math.max(maxRunning, running);
110
- expect(running, "running jobs observed at once").toBeLessThanOrEqual(1);
121
+ const active = jobs.filter((j) => j.state === "running" || j.state === "awaiting-external");
122
+ maxRunning = Math.max(maxRunning, active.length);
123
+ for (const lane of ["live", "preview"]) {
124
+ expect(active.filter((j) => j.lane === lane).length, `active ${lane}-lane jobs observed at once`).toBeLessThanOrEqual(1);
125
+ }
111
126
  const wanted = jobIds.map((id) => jobs.find((j) => j.jobId === id));
112
127
  if (wanted.every((j) => j && TERMINAL.has(j.state))) return wanted as JobView[];
113
128
  if (Date.now() > deadline) {
@@ -2,8 +2,9 @@
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 AND the production preview kinds (integrate / rebuild / revert / sync-preview) over fake
6
- * services (`./fake-preview-services.ts`), and answers intents through `submitIntent`.
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";
@@ -13,6 +14,17 @@ import { StaleExecutionError, type JobKindRegistry, type PipelineIntent } from "
13
14
  import { abandonedContexts, syntheticJobKinds, syntheticSpans } from "./synthetic-job";
14
15
  import { registerPreviewJobKinds, type PreviewJobTiming } from "../../src/lib/pipeline/preview-jobs";
15
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";
16
28
 
17
29
  /** The preview kinds' timing in the harness: the production shape, scaled to milliseconds. */
18
30
  export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
@@ -25,7 +37,10 @@ export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
25
37
 
26
38
  export class TenantPipeline extends DeployedTenantPipeline {
27
39
  protected override jobKinds(ctx: DurableObjectState): JobKindRegistry {
28
- return registerPreviewJobKinds(syntheticJobKinds(ctx), fakePreviewServices(ctx), HARNESS_PREVIEW_TIMING);
40
+ return registerHarnessPublishKinds(
41
+ ctx,
42
+ registerPreviewJobKinds(syntheticJobKinds(ctx), fakePreviewServices(ctx), HARNESS_PREVIEW_TIMING),
43
+ );
29
44
  }
30
45
 
31
46
  /** Harness-only: open candidates and arm faults for the fake preview services. */
@@ -47,6 +62,45 @@ export class TenantPipeline extends DeployedTenantPipeline {
47
62
  return readFakeState(this.ctx);
48
63
  }
49
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
+
50
104
  /** Harness-only reads of the synthetic step log. */
51
105
  async harness(jobId: string): Promise<{
52
106
  runs: { step: number; chunk: number; attempt: number }[];
@@ -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",
3
+ "version": "2.4.5",
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);