@tokenoftrust/storefront-runner 2.4.5 → 2.4.6
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.
|
@@ -16,7 +16,7 @@ import { registerPreviewJobKinds, type PreviewJobTiming } from "../../src/lib/pi
|
|
|
16
16
|
import { configureFake, fakePreviewServices, readFakeState, type FakeFault, type FakeState } from "./fake-preview-services";
|
|
17
17
|
import { readLivePointer } from "../../src/lib/pipeline/publish/live-pointer";
|
|
18
18
|
import { listReleaseReceipts, readReleaseTruth } from "../../src/lib/change/store/aggregate-release-store";
|
|
19
|
-
import { seedGreenAggregate, testVersionStore } from "../../src/lib/pipeline/publish/publish-test-support";
|
|
19
|
+
import { projectionStore, seedGreenAggregate, testVersionStore, type TestKv } from "../../src/lib/pipeline/publish/publish-test-support";
|
|
20
20
|
import {
|
|
21
21
|
armedAtFaults,
|
|
22
22
|
armPublishFault,
|
|
@@ -40,9 +40,31 @@ export class TenantPipeline extends DeployedTenantPipeline {
|
|
|
40
40
|
return registerHarnessPublishKinds(
|
|
41
41
|
ctx,
|
|
42
42
|
registerPreviewJobKinds(syntheticJobKinds(ctx), fakePreviewServices(ctx), HARNESS_PREVIEW_TIMING),
|
|
43
|
+
this.pointers,
|
|
43
44
|
);
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The pointer is projected to the harness KV (the object's SQLite), where `live` reads it as the
|
|
49
|
+
* edge would. `failPointerPuts` makes the next N puts of a pointer key fail (a KV write lost).
|
|
50
|
+
*/
|
|
51
|
+
protected override async pointerKv(): Promise<TestKv | null> {
|
|
52
|
+
const { kv } = publishWorldOf(this.ctx);
|
|
53
|
+
const { sql } = this.ctx.storage;
|
|
54
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS harness_pointer_faults (k INTEGER PRIMARY KEY CHECK (k = 1), remaining INTEGER NOT NULL)`);
|
|
55
|
+
return {
|
|
56
|
+
...kv,
|
|
57
|
+
put: async (key, value) => {
|
|
58
|
+
const remaining = sql.exec<{ remaining: number }>(`SELECT remaining FROM harness_pointer_faults`).toArray()[0]?.remaining ?? 0;
|
|
59
|
+
if (key.startsWith("cust:pointer:") && remaining > 0) {
|
|
60
|
+
sql.exec(`UPDATE harness_pointer_faults SET remaining = remaining - 1`);
|
|
61
|
+
throw new Error("synthetic KV put failure");
|
|
62
|
+
}
|
|
63
|
+
await kv.put(key, value);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
/** Harness-only: open candidates and arm faults for the fake preview services. */
|
|
47
69
|
async configureFake(config: {
|
|
48
70
|
open?: { changeId: string; headSha: string }[];
|
|
@@ -65,7 +87,10 @@ export class TenantPipeline extends DeployedTenantPipeline {
|
|
|
65
87
|
/** Harness-only: arrange and read the publish stand-in world inside this object's storage. */
|
|
66
88
|
async publishHarness(op: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
|
67
89
|
const { kv, world } = publishWorldOf(this.ctx);
|
|
68
|
-
|
|
90
|
+
// Arranging goes through the object's own pointer authority; `live` and `pointer` read the KV
|
|
91
|
+
// projection — what the edge serves.
|
|
92
|
+
const store = testVersionStore(kv, this.pointers);
|
|
93
|
+
const projection = projectionStore(kv);
|
|
69
94
|
const tenantId = String(args.tenantId ?? "");
|
|
70
95
|
switch (op) {
|
|
71
96
|
case "configure":
|
|
@@ -84,11 +109,20 @@ export class TenantPipeline extends DeployedTenantPipeline {
|
|
|
84
109
|
case "promotionStatus":
|
|
85
110
|
return world.promotionStatus();
|
|
86
111
|
case "live":
|
|
87
|
-
return readLivePointer(
|
|
112
|
+
return readLivePointer(projection, tenantId);
|
|
113
|
+
case "pointer":
|
|
114
|
+
return projection.getPointer(tenantId);
|
|
88
115
|
case "receipts":
|
|
89
116
|
return (await listReleaseReceipts(kv, tenantId, "preview")).map((r) => r.receiptId);
|
|
90
117
|
case "truth":
|
|
91
118
|
return readReleaseTruth(kv, tenantId, "preview");
|
|
119
|
+
case "failPointerPuts":
|
|
120
|
+
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS harness_pointer_faults (k INTEGER PRIMARY KEY CHECK (k = 1), remaining INTEGER NOT NULL)`);
|
|
121
|
+
this.ctx.storage.sql.exec(
|
|
122
|
+
`INSERT INTO harness_pointer_faults (k, remaining) VALUES (1, ?) ON CONFLICT(k) DO UPDATE SET remaining = excluded.remaining`,
|
|
123
|
+
Number(args.times ?? 1),
|
|
124
|
+
);
|
|
125
|
+
return null;
|
|
92
126
|
case "armFault":
|
|
93
127
|
armPublishFault(this.ctx, args as unknown as PublishFault);
|
|
94
128
|
return null;
|
|
@@ -11,13 +11,26 @@
|
|
|
11
11
|
* Faults are armed per object (each test uses its own tenant) at a named step — or `on-terminal`, the
|
|
12
12
|
* kind's compensation after a terminal transition — `before` its work or `after` it (once the work
|
|
13
13
|
* is done, before the engine records the step): `evict` aborts the
|
|
14
|
-
* object, `throw` fails the attempt
|
|
15
|
-
*
|
|
14
|
+
* object, `throw` fails the attempt, `preview-write` moves the shared preview channel to `versionId`
|
|
15
|
+
* through the object's pointer authority (what a preview job does) and lets the step go on. A fault's
|
|
16
|
+
* remaining count is persisted (and flushed) before it acts, so it survives the eviction it causes.
|
|
17
|
+
*
|
|
18
|
+
* The job's version store writes the pointer through the object's own pointer authority (its
|
|
19
|
+
* SQLite record, projected to the harness KV) — the production writer.
|
|
16
20
|
*/
|
|
17
21
|
import type { DurableObjectState } from "cloudflare:workers";
|
|
18
22
|
import { JobKindRegistry, type JobKindDefinition, type StepDefinition } from "../../src/lib/pipeline/job-runner";
|
|
19
23
|
import { registerPublishJobKinds, type PublishKindTuning, type PublishTiming } from "../../src/lib/pipeline/publish/publish-job";
|
|
20
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
PublishWorld,
|
|
26
|
+
movePreview,
|
|
27
|
+
standInPublishEnvironment,
|
|
28
|
+
testVersionStore,
|
|
29
|
+
type TestKv,
|
|
30
|
+
} from "../../src/lib/pipeline/publish/publish-test-support";
|
|
31
|
+
|
|
32
|
+
type SiteVersionStore = ReturnType<typeof testVersionStore>;
|
|
33
|
+
type PointerWriter = NonNullable<Parameters<typeof testVersionStore>[1]>;
|
|
21
34
|
|
|
22
35
|
export const PUBLISH_HARNESS_TIMING: PublishTiming = {
|
|
23
36
|
awaitWindowMs: 1_500,
|
|
@@ -39,7 +52,9 @@ export const PUBLISH_HARNESS_TUNING: PublishKindTuning = {
|
|
|
39
52
|
export interface PublishFault {
|
|
40
53
|
step: string;
|
|
41
54
|
at: "before" | "after";
|
|
42
|
-
mode: "evict" | "throw";
|
|
55
|
+
mode: "evict" | "throw" | "preview-write";
|
|
56
|
+
/** `preview-write`: the version the preview channel moves to. */
|
|
57
|
+
versionId?: string;
|
|
43
58
|
/** How many times it fires (default 1). */
|
|
44
59
|
times?: number;
|
|
45
60
|
}
|
|
@@ -47,7 +62,7 @@ export interface PublishFault {
|
|
|
47
62
|
function tables(state: DurableObjectState): void {
|
|
48
63
|
const { sql } = state.storage;
|
|
49
64
|
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)`);
|
|
65
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, mode TEXT, remaining INTEGER, version_id TEXT)`);
|
|
51
66
|
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_fired (seq INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, mode TEXT)`);
|
|
52
67
|
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_armed (seq INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, armed INTEGER)`);
|
|
53
68
|
}
|
|
@@ -87,11 +102,12 @@ export function sqlKv(state: DurableObjectState): TestKv {
|
|
|
87
102
|
export function armPublishFault(state: DurableObjectState, fault: PublishFault): void {
|
|
88
103
|
tables(state);
|
|
89
104
|
state.storage.sql.exec(
|
|
90
|
-
`INSERT INTO harness_publish_faults (step, at, mode, remaining) VALUES (?, ?, ?, ?)`,
|
|
105
|
+
`INSERT INTO harness_publish_faults (step, at, mode, remaining, version_id) VALUES (?, ?, ?, ?, ?)`,
|
|
91
106
|
fault.step,
|
|
92
107
|
fault.at,
|
|
93
108
|
fault.mode,
|
|
94
109
|
fault.times ?? 1,
|
|
110
|
+
fault.versionId ?? null,
|
|
95
111
|
);
|
|
96
112
|
}
|
|
97
113
|
|
|
@@ -100,10 +116,19 @@ export function firedPublishFaults(state: DurableObjectState): { step: string; a
|
|
|
100
116
|
return state.storage.sql.exec<{ step: string; at: string; mode: string }>(`SELECT step, at, mode FROM harness_publish_fired ORDER BY seq`).toArray();
|
|
101
117
|
}
|
|
102
118
|
|
|
103
|
-
|
|
119
|
+
interface FaultScope {
|
|
120
|
+
tenantId: string;
|
|
121
|
+
store: SiteVersionStore;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function maybeFault(state: DurableObjectState, step: string, at: "before" | "after", scope: FaultScope | null): Promise<void> {
|
|
104
125
|
const { sql } = state.storage;
|
|
105
126
|
const fault = sql
|
|
106
|
-
.exec<{ id: number; mode: string
|
|
127
|
+
.exec<{ id: number; mode: string; version_id: string | null }>(
|
|
128
|
+
`SELECT id, mode, version_id FROM harness_publish_faults WHERE step = ? AND at = ? AND remaining > 0 ORDER BY id LIMIT 1`,
|
|
129
|
+
step,
|
|
130
|
+
at,
|
|
131
|
+
)
|
|
107
132
|
.toArray()[0];
|
|
108
133
|
if (!fault) return;
|
|
109
134
|
sql.exec(`UPDATE harness_publish_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
|
|
@@ -112,6 +137,11 @@ async function maybeFault(state: DurableObjectState, step: string, at: "before"
|
|
|
112
137
|
// Everything written so far — including a step's own effect, for an `after` fault — is made
|
|
113
138
|
// durable first, exactly as a real crash after the write would leave it.
|
|
114
139
|
await state.storage.sync();
|
|
140
|
+
if (fault.mode === "preview-write") {
|
|
141
|
+
if (!scope || !fault.version_id) throw new Error(`a preview-write fault needs a job step and a version (${at} ${step})`);
|
|
142
|
+
await movePreview(scope.store, scope.tenantId, fault.version_id);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
115
145
|
if (fault.mode === "evict") {
|
|
116
146
|
state.abort(`synthetic eviction ${at} ${step}`);
|
|
117
147
|
throw new Error("abort() returned");
|
|
@@ -119,7 +149,7 @@ async function maybeFault(state: DurableObjectState, step: string, at: "before"
|
|
|
119
149
|
throw new Error(`synthetic fault ${at} ${step}`);
|
|
120
150
|
}
|
|
121
151
|
|
|
122
|
-
function withFaults(state: DurableObjectState, definition: JobKindDefinition<unknown
|
|
152
|
+
function withFaults(state: DurableObjectState, definition: JobKindDefinition<unknown>, store: SiteVersionStore): JobKindDefinition<unknown> {
|
|
123
153
|
const onTerminal = definition.onTerminal;
|
|
124
154
|
return {
|
|
125
155
|
...definition,
|
|
@@ -127,7 +157,7 @@ function withFaults(state: DurableObjectState, definition: JobKindDefinition<unk
|
|
|
127
157
|
...(onTerminal
|
|
128
158
|
? {
|
|
129
159
|
onTerminal: async (info: Parameters<NonNullable<JobKindDefinition<unknown>["onTerminal"]>>[0]) => {
|
|
130
|
-
await maybeFault(state, "on-terminal", "before");
|
|
160
|
+
await maybeFault(state, "on-terminal", "before", null);
|
|
131
161
|
await onTerminal(info);
|
|
132
162
|
},
|
|
133
163
|
}
|
|
@@ -136,9 +166,9 @@ function withFaults(state: DurableObjectState, definition: JobKindDefinition<unk
|
|
|
136
166
|
(step): StepDefinition<unknown> => ({
|
|
137
167
|
...step,
|
|
138
168
|
async run(ctx) {
|
|
139
|
-
await maybeFault(state, step.name, "before");
|
|
169
|
+
await maybeFault(state, step.name, "before", { tenantId: ctx.tenantId, store });
|
|
140
170
|
const outcome = await step.run(ctx);
|
|
141
|
-
await maybeFault(state, step.name, "after");
|
|
171
|
+
await maybeFault(state, step.name, "after", { tenantId: ctx.tenantId, store });
|
|
142
172
|
return outcome;
|
|
143
173
|
},
|
|
144
174
|
}),
|
|
@@ -152,11 +182,12 @@ export function publishWorldOf(state: DurableObjectState): { kv: TestKv; world:
|
|
|
152
182
|
return { kv, world: new PublishWorld(kv) };
|
|
153
183
|
}
|
|
154
184
|
|
|
155
|
-
/** Register the publish kinds (fault-wrapped, over the stand-in world) into `registry
|
|
156
|
-
export function registerHarnessPublishKinds(state: DurableObjectState, registry: JobKindRegistry): JobKindRegistry {
|
|
185
|
+
/** Register the publish kinds (fault-wrapped, over the stand-in world) into `registry`; `pointers` is the object's authority. */
|
|
186
|
+
export function registerHarnessPublishKinds(state: DurableObjectState, registry: JobKindRegistry, pointers: PointerWriter): JobKindRegistry {
|
|
157
187
|
const { kv, world } = publishWorldOf(state);
|
|
158
|
-
const
|
|
188
|
+
const store = testVersionStore(kv, pointers);
|
|
189
|
+
const environment = standInPublishEnvironment({ kv, world, store, now: () => Date.now(), timing: PUBLISH_HARNESS_TIMING });
|
|
159
190
|
const kinds = registerPublishJobKinds(new JobKindRegistry(), async () => environment, PUBLISH_HARNESS_TUNING);
|
|
160
|
-
for (const name of kinds.list()) registry.register(withFaults(state, kinds.get(name)
|
|
191
|
+
for (const name of kinds.list()) registry.register(withFaults(state, kinds.get(name)!, store));
|
|
161
192
|
return registry;
|
|
162
193
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.6",
|
|
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",
|
|
@@ -46,7 +46,13 @@ export type ChannelPointers = Partial<Record<ChannelName, string>>;
|
|
|
46
46
|
* — and are written in the SAME CAS pointer write (`setChannel`), so they can
|
|
47
47
|
* never drift. Absent for a hosted tenant (which only ever sets/reads
|
|
48
48
|
* `channels`).
|
|
49
|
-
* - `rev`:
|
|
49
|
+
* - `rev`: the record's revision, incremented on every successful write. The CAS token for a
|
|
50
|
+
* write of the per-path `live` / `test` selections.
|
|
51
|
+
* - `channelRevs`: each channel's own revision, incremented only when that channel moves (absent
|
|
52
|
+
* = 0). The CAS token for a write of that channel, so a `preview` move never conflicts with —
|
|
53
|
+
* or stands down — a `live` write, and the reverse.
|
|
54
|
+
*
|
|
55
|
+
* The edge reads this record from KV; the tenant's pipeline Durable Object is its only writer.
|
|
50
56
|
*/
|
|
51
57
|
export interface PointerRecord {
|
|
52
58
|
tenantId: string;
|
|
@@ -55,6 +61,7 @@ export interface PointerRecord {
|
|
|
55
61
|
test: Selection;
|
|
56
62
|
channels?: ChannelPointers;
|
|
57
63
|
channelDigests?: ChannelPointers;
|
|
64
|
+
channelRevs?: Partial<Record<ChannelName, number>>;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
export function emptyPointer(tenantId: string): PointerRecord {
|
|
@@ -91,12 +91,22 @@ export const ASSET_SHA256_METADATA_KEY = "sha256";
|
|
|
91
91
|
/** Per-tenant asset pointer: the live/test snapshot versionId + a CAS token. */
|
|
92
92
|
export interface AssetsPointer {
|
|
93
93
|
tenantId: string;
|
|
94
|
-
/**
|
|
94
|
+
/** The record's revision; bumped on every successful write. */
|
|
95
95
|
rev: number;
|
|
96
96
|
/** Snapshot versionId selected for the live channel (null = none published). */
|
|
97
97
|
live: string | null;
|
|
98
98
|
/** Snapshot versionId selected for the test channel. */
|
|
99
99
|
test: string | null;
|
|
100
|
+
/**
|
|
101
|
+
* Each channel's own revision, bumped only when that channel moves (absent = 0): the CAS token
|
|
102
|
+
* for a write of that channel, so a `test` move never conflicts with a `live` write.
|
|
103
|
+
*/
|
|
104
|
+
channelRevs?: Partial<Record<CustomizationEnv, number>>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A channel's own revision (0 before its first write) — the token a write of that channel presents. */
|
|
108
|
+
export function assetsChannelRev(pointer: AssetsPointer, channel: CustomizationEnv): number {
|
|
109
|
+
return pointer.channelRevs?.[channel] ?? 0;
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
export function emptyAssetsPointer(tenantId: string): AssetsPointer {
|
|
@@ -397,6 +407,7 @@ export async function readAssetsPointer(
|
|
|
397
407
|
rev: typeof p.rev === "number" ? p.rev : 0,
|
|
398
408
|
live: typeof p.live === "string" ? p.live : null,
|
|
399
409
|
test: typeof p.test === "string" ? p.test : null,
|
|
410
|
+
...(p.channelRevs && typeof p.channelRevs === "object" ? { channelRevs: { ...p.channelRevs } } : {}),
|
|
400
411
|
};
|
|
401
412
|
} catch {
|
|
402
413
|
// Fail safe: a malformed record reads as empty (never wedges writes).
|
|
@@ -416,62 +427,65 @@ export class AssetsPointerConflictError extends Error {
|
|
|
416
427
|
readonly tenantId: string,
|
|
417
428
|
readonly expectedRev: number,
|
|
418
429
|
readonly foundRev: number,
|
|
430
|
+
readonly channel?: CustomizationEnv,
|
|
419
431
|
) {
|
|
420
432
|
super(
|
|
421
|
-
`Assets pointer for "${tenantId}" changed (expected rev ${expectedRev}, found ${foundRev}).`,
|
|
433
|
+
`Assets pointer for "${tenantId}" changed (${channel ? `channel ${channel}: ` : ""}expected rev ${expectedRev}, found ${foundRev}).`,
|
|
422
434
|
);
|
|
423
435
|
this.name = "AssetsPointerConflictError";
|
|
424
436
|
}
|
|
425
437
|
}
|
|
426
438
|
|
|
439
|
+
/** The record a write of `channel` stores over `current`: that channel, its revision and the record's bumped. */
|
|
440
|
+
function movedChannel(current: AssetsPointer, channel: CustomizationEnv, versionId: string | null): AssetsPointer {
|
|
441
|
+
return {
|
|
442
|
+
...current,
|
|
443
|
+
rev: current.rev + 1,
|
|
444
|
+
[channel]: versionId,
|
|
445
|
+
channelRevs: { ...current.channelRevs, [channel]: assetsChannelRev(current, channel) + 1 },
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
427
449
|
/**
|
|
428
|
-
* Point a channel at a snapshot versionId — or unset it with `null`
|
|
429
|
-
* so the caller reads the pointer once,
|
|
430
|
-
* this rejects the write if
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* `KVVersionStore.setChannel(..., expectedRev)`.
|
|
450
|
+
* Point a channel at a snapshot versionId — or unset it with `null` — CAS on THAT CHANNEL's
|
|
451
|
+
* revision (`assetsChannelRev`). KV has no hardware CAS, so the caller reads the pointer once,
|
|
452
|
+
* passes the channel's revision as `expectedChannelRev`, and this rejects the write if it has since
|
|
453
|
+
* moved — two concurrent writes of one channel can no longer silently clobber each other, while a
|
|
454
|
+
* write of the other channel never refuses this one. Same shape as `KVVersionStore.setChannel`.
|
|
434
455
|
*/
|
|
435
456
|
export async function setAssetsChannel(
|
|
436
457
|
kv: AssetsKv,
|
|
437
458
|
tenantId: string,
|
|
438
459
|
channel: CustomizationEnv,
|
|
439
460
|
versionId: string | null,
|
|
440
|
-
|
|
461
|
+
expectedChannelRev: number,
|
|
441
462
|
): Promise<AssetsPointer> {
|
|
442
463
|
const current = await readAssetsPointer(kv, tenantId);
|
|
443
|
-
|
|
444
|
-
|
|
464
|
+
const found = assetsChannelRev(current, channel);
|
|
465
|
+
if (found !== expectedChannelRev) {
|
|
466
|
+
throw new AssetsPointerConflictError(tenantId, expectedChannelRev, found, channel);
|
|
445
467
|
}
|
|
446
|
-
const next
|
|
447
|
-
...current,
|
|
448
|
-
rev: current.rev + 1,
|
|
449
|
-
[channel]: versionId,
|
|
450
|
-
};
|
|
468
|
+
const next = movedChannel(current, channel, versionId);
|
|
451
469
|
await kv.put(assetsPointerKey(tenantId), JSON.stringify(next));
|
|
452
470
|
return next;
|
|
453
471
|
}
|
|
454
472
|
|
|
455
473
|
/**
|
|
456
|
-
* Promote the test snapshot to live (atomic pointer flip), CAS-guarded on `
|
|
457
|
-
* The caller supplies
|
|
458
|
-
*
|
|
459
|
-
* rather than being silently overwritten.
|
|
474
|
+
* Promote the test snapshot to live (atomic pointer flip), CAS-guarded on the `live` channel's
|
|
475
|
+
* revision. The caller supplies it from a single prior `readAssetsPointer`; a `live` write that
|
|
476
|
+
* lands in between throws `AssetsPointerConflictError` rather than being silently overwritten.
|
|
460
477
|
*/
|
|
461
478
|
export async function promoteAssets(
|
|
462
479
|
kv: AssetsKv,
|
|
463
480
|
tenantId: string,
|
|
464
|
-
|
|
481
|
+
expectedLiveRev: number,
|
|
465
482
|
): Promise<AssetsPointer> {
|
|
466
483
|
const current = await readAssetsPointer(kv, tenantId);
|
|
467
|
-
|
|
468
|
-
|
|
484
|
+
const found = assetsChannelRev(current, "live");
|
|
485
|
+
if (found !== expectedLiveRev) {
|
|
486
|
+
throw new AssetsPointerConflictError(tenantId, expectedLiveRev, found, "live");
|
|
469
487
|
}
|
|
470
|
-
const next
|
|
471
|
-
...current,
|
|
472
|
-
rev: current.rev + 1,
|
|
473
|
-
live: current.test,
|
|
474
|
-
};
|
|
488
|
+
const next = movedChannel(current, "live", current.test);
|
|
475
489
|
await kv.put(assetsPointerKey(tenantId), JSON.stringify(next));
|
|
476
490
|
return next;
|
|
477
491
|
}
|