@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.
@@ -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
@@ -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 "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;
@@ -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 AND the production preview kinds (integrate / rebuild / revert) 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";
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 registerPreviewJobKinds(syntheticJobKinds(ctx), fakePreviewServices(ctx), HARNESS_PREVIEW_TIMING);
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: { open?: { changeId: string; headSha: string }[]; faults?: FakeFault[] }): Promise<void> {
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.2",
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);