@voyantjs/workflows-orchestrator-cloudflare 0.28.3 → 0.30.0

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.
Files changed (44) hide show
  1. package/README.md +23 -11
  2. package/dist/cloudflare-edge-driver.d.ts +49 -0
  3. package/dist/cloudflare-edge-driver.d.ts.map +1 -0
  4. package/dist/cloudflare-edge-driver.js +317 -0
  5. package/dist/dispatchers.d.ts +87 -0
  6. package/dist/dispatchers.d.ts.map +1 -0
  7. package/dist/dispatchers.js +83 -0
  8. package/dist/do-store.d.ts.map +1 -1
  9. package/dist/do-store.js +12 -0
  10. package/dist/durable-object.d.ts +13 -6
  11. package/dist/durable-object.d.ts.map +1 -1
  12. package/dist/durable-object.js +13 -4
  13. package/dist/event-handler.d.ts +23 -0
  14. package/dist/event-handler.d.ts.map +1 -0
  15. package/dist/event-handler.js +241 -0
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +14 -6
  19. package/dist/manifest-handler.d.ts +16 -0
  20. package/dist/manifest-handler.d.ts.map +1 -0
  21. package/dist/manifest-handler.js +92 -0
  22. package/dist/manifest-kv-store.d.ts +59 -0
  23. package/dist/manifest-kv-store.d.ts.map +1 -0
  24. package/dist/manifest-kv-store.js +134 -0
  25. package/dist/types.d.ts +7 -15
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/worker.d.ts +19 -0
  28. package/dist/worker.d.ts.map +1 -1
  29. package/dist/worker.js +41 -0
  30. package/package.json +3 -3
  31. package/src/cloudflare-edge-driver.ts +435 -0
  32. package/src/dispatchers.ts +162 -0
  33. package/src/do-store.ts +13 -0
  34. package/src/durable-object.ts +30 -9
  35. package/src/event-handler.ts +302 -0
  36. package/src/index.ts +32 -8
  37. package/src/manifest-handler.ts +113 -0
  38. package/src/manifest-kv-store.ts +186 -0
  39. package/src/types.ts +7 -19
  40. package/src/worker.ts +64 -0
  41. package/dist/dispatch-handler.d.ts +0 -20
  42. package/dist/dispatch-handler.d.ts.map +0 -1
  43. package/dist/dispatch-handler.js +0 -31
  44. package/src/dispatch-handler.ts +0 -51
@@ -0,0 +1,302 @@
1
+ // HTTP handler for `POST /api/events` — the synchronous event-ingest
2
+ // endpoint. Loads the registered manifest from KV, runs the pure
3
+ // `routeEvent` from `@voyantjs/workflows-orchestrator`, and forwards
4
+ // each match into the existing `/trigger` DO surface with a derived
5
+ // idempotencyKey.
6
+ //
7
+ // Response shape mirrors `IngestEventResponse` from
8
+ // `@voyantjs/workflows/driver`:
9
+ //
10
+ // { ok: true, eventId, matches: [...] }
11
+ // { ok: false, reason: "manifest_not_registered" | ... }
12
+ //
13
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §15.
14
+
15
+ import { deriveStableEventId } from "@voyantjs/workflows/events"
16
+ import type { WorkflowManifest } from "@voyantjs/workflows/protocol"
17
+ import { routeEvent } from "@voyantjs/workflows-orchestrator"
18
+
19
+ import type { CfManifestStore } from "./manifest-kv-store.js"
20
+ import type { DurableObjectNamespaceLike } from "./worker.js"
21
+
22
+ const ALLOWED_ENVS = new Set(["production", "preview", "development"])
23
+
24
+ export interface EventHandlerDeps<Id = unknown> {
25
+ /** KV-backed manifest store (read-only path here). */
26
+ manifestStore: CfManifestStore
27
+ /** DO namespace used to forward each match to the run DO. */
28
+ runDO: DurableObjectNamespaceLike<Id>
29
+ /** id generator for new triggers; defaults to `run_<random>`. */
30
+ idGenerator?: () => string
31
+ /** Injectable clock. */
32
+ now?: () => number
33
+ /** Tenant metadata stamped on every triggered run. */
34
+ tenantMeta?: {
35
+ tenantId: string
36
+ projectId: string
37
+ organizationId: string
38
+ tenantScript?: string
39
+ }
40
+ /** Optional logger. */
41
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
42
+ }
43
+
44
+ const DEFAULT_TENANT_META = {
45
+ tenantId: "default",
46
+ projectId: "default",
47
+ organizationId: "default",
48
+ }
49
+
50
+ interface IngestEnvelope {
51
+ name: string
52
+ data: unknown
53
+ metadata?: Record<string, unknown> & { eventId?: string }
54
+ emittedAt: string
55
+ }
56
+
57
+ interface IngestRequestBody {
58
+ environment: string
59
+ envelope: IngestEnvelope
60
+ idempotencyKey?: string
61
+ }
62
+
63
+ export async function handleIngestEvent<Id>(
64
+ req: Request,
65
+ deps: EventHandlerDeps<Id>,
66
+ ): Promise<Response> {
67
+ // Body parse + validate.
68
+ let raw: unknown
69
+ try {
70
+ raw = await req.json()
71
+ } catch (err) {
72
+ return json(400, {
73
+ error: "invalid_json",
74
+ message: err instanceof Error ? err.message : String(err),
75
+ })
76
+ }
77
+ const validation = validateBody(raw)
78
+ if (!validation.ok) return json(400, validation.error)
79
+ const body = validation.body
80
+
81
+ // Manifest lookup.
82
+ const manifestEnvelope = await deps.manifestStore.getCurrent(body.environment)
83
+ if (!manifestEnvelope) {
84
+ return json(200, {
85
+ ok: false,
86
+ reason: "manifest_not_registered",
87
+ message: `No manifest is registered for environment "${body.environment}".`,
88
+ })
89
+ }
90
+ const manifest = manifestEnvelope.manifest as unknown as WorkflowManifest
91
+
92
+ // Event id derivation — use the caller-stamped one when present, fall
93
+ // back to a content-derived id so external callers without a forwarder
94
+ // still get sensible idempotency.
95
+ const eventId = body.envelope.metadata?.eventId ?? (await deriveStableEventId(body.envelope))
96
+
97
+ // Route through the manifest's filters.
98
+ const routed = routeEvent({
99
+ manifest,
100
+ envelope: {
101
+ name: body.envelope.name,
102
+ data: body.envelope.data,
103
+ metadata: body.envelope.metadata,
104
+ emittedAt: body.envelope.emittedAt,
105
+ },
106
+ eventId,
107
+ idempotencyOverride: body.idempotencyKey,
108
+ })
109
+
110
+ // Forward each match into the existing /trigger DO route.
111
+ const matches: unknown[] = []
112
+ let anyTriggered = false
113
+ let anyFailed = false
114
+ const tenantMeta = deps.tenantMeta ?? DEFAULT_TENANT_META
115
+
116
+ for (const entry of routed) {
117
+ if (entry.status === "skipped") {
118
+ matches.push({
119
+ filterId: entry.filterId,
120
+ status: "skipped",
121
+ reason: entry.reason,
122
+ details: entry.details,
123
+ })
124
+ continue
125
+ }
126
+
127
+ const runId = `idem-${entry.targetWorkflowId}-${entry.idempotencyKey}`
128
+ const triggerPayload = {
129
+ runId,
130
+ workflowId: entry.targetWorkflowId,
131
+ workflowVersion: "v1",
132
+ input: entry.input,
133
+ tenantMeta,
134
+ environment: body.environment,
135
+ idempotencyKey: entry.idempotencyKey,
136
+ triggeredBy: {
137
+ kind: "event" as const,
138
+ eventId,
139
+ eventType: body.envelope.name,
140
+ filterId: entry.filterId,
141
+ },
142
+ }
143
+
144
+ try {
145
+ const forward = new Request("https://do-internal/trigger", {
146
+ method: "POST",
147
+ headers: { "content-type": "application/json" },
148
+ body: JSON.stringify(triggerPayload),
149
+ })
150
+ const id = deps.runDO.idFromName(runId)
151
+ const stub = deps.runDO.get(id)
152
+ const resp = await stub.fetch(forward)
153
+ if (resp.status >= 200 && resp.status < 300) {
154
+ matches.push({
155
+ filterId: entry.filterId,
156
+ targetWorkflowId: entry.targetWorkflowId,
157
+ runId,
158
+ idempotencyKey: entry.idempotencyKey,
159
+ status: "queued",
160
+ })
161
+ anyTriggered = true
162
+ } else {
163
+ const errBody = await safeReadText(resp)
164
+ deps.logger?.("error", "trigger DO failed", {
165
+ status: resp.status,
166
+ body: errBody.slice(0, 256),
167
+ })
168
+ matches.push({
169
+ filterId: entry.filterId,
170
+ targetWorkflowId: entry.targetWorkflowId,
171
+ status: "error",
172
+ reason: `do_returned_${resp.status}`,
173
+ })
174
+ anyFailed = true
175
+ }
176
+ } catch (err) {
177
+ deps.logger?.("error", "trigger forward threw", {
178
+ error: err instanceof Error ? err.message : String(err),
179
+ })
180
+ matches.push({
181
+ filterId: entry.filterId,
182
+ targetWorkflowId: entry.targetWorkflowId,
183
+ status: "error",
184
+ reason: err instanceof Error ? err.message : String(err),
185
+ })
186
+ anyFailed = true
187
+ }
188
+ }
189
+
190
+ if (matches.length > 0 && !anyTriggered && anyFailed) {
191
+ return json(502, {
192
+ ok: false,
193
+ reason: "trigger_failed_for_all_matches",
194
+ message: "every matched filter failed to trigger",
195
+ })
196
+ }
197
+
198
+ return json(200, { ok: true, eventId, matches })
199
+ }
200
+
201
+ // ---- Validation ----
202
+
203
+ function validateBody(
204
+ raw: unknown,
205
+ ):
206
+ | { ok: true; body: IngestRequestBody }
207
+ | { ok: false; error: { error: string; message: string } } {
208
+ if (typeof raw !== "object" || raw === null) {
209
+ return { ok: false, error: { error: "invalid_body", message: "expected JSON object" } }
210
+ }
211
+ const r = raw as Record<string, unknown>
212
+
213
+ if (typeof r.environment !== "string" || !ALLOWED_ENVS.has(r.environment)) {
214
+ return {
215
+ ok: false,
216
+ error: {
217
+ error: "invalid_body",
218
+ message: `"environment" must be one of ${[...ALLOWED_ENVS].join(", ")}`,
219
+ },
220
+ }
221
+ }
222
+ if (typeof r.envelope !== "object" || r.envelope === null) {
223
+ return {
224
+ ok: false,
225
+ error: { error: "invalid_body", message: '"envelope" must be an object' },
226
+ }
227
+ }
228
+ const envelope = r.envelope as Record<string, unknown>
229
+ if (typeof envelope.name !== "string" || envelope.name.length === 0) {
230
+ return {
231
+ ok: false,
232
+ error: { error: "invalid_body", message: '"envelope.name" must be a non-empty string' },
233
+ }
234
+ }
235
+ if (typeof envelope.emittedAt !== "string" || envelope.emittedAt.length === 0) {
236
+ return {
237
+ ok: false,
238
+ error: {
239
+ error: "invalid_body",
240
+ message: '"envelope.emittedAt" must be an ISO timestamp string',
241
+ },
242
+ }
243
+ }
244
+ if (
245
+ envelope.metadata !== undefined &&
246
+ (typeof envelope.metadata !== "object" || envelope.metadata === null)
247
+ ) {
248
+ return {
249
+ ok: false,
250
+ error: {
251
+ error: "invalid_body",
252
+ message: '"envelope.metadata" must be an object when supplied',
253
+ },
254
+ }
255
+ }
256
+ if (r.idempotencyKey !== undefined && typeof r.idempotencyKey !== "string") {
257
+ return {
258
+ ok: false,
259
+ error: { error: "invalid_body", message: '"idempotencyKey" must be a string when supplied' },
260
+ }
261
+ }
262
+ return {
263
+ ok: true,
264
+ body: {
265
+ environment: r.environment,
266
+ envelope: {
267
+ name: envelope.name,
268
+ data: envelope.data,
269
+ metadata: envelope.metadata as Record<string, unknown> | undefined,
270
+ emittedAt: envelope.emittedAt,
271
+ },
272
+ idempotencyKey: r.idempotencyKey as string | undefined,
273
+ },
274
+ }
275
+ }
276
+
277
+ // ---- Internal helpers ----
278
+
279
+ // Fallback id derivation lives in `@voyantjs/workflows/events`'s
280
+ // `deriveStableEventId` and is used inline above — content-derived so
281
+ // external callers (HTTP retries, third-party webhooks) dedupe naturally
282
+ // across re-deliveries (architecture doc §15.2).
283
+
284
+ async function safeReadText(resp: Response): Promise<string> {
285
+ try {
286
+ return await resp.text()
287
+ } catch {
288
+ return ""
289
+ }
290
+ }
291
+
292
+ function json(status: number, body: unknown): Response {
293
+ return new Response(JSON.stringify(body), {
294
+ status,
295
+ headers: {
296
+ "content-type": "application/json; charset=utf-8",
297
+ "access-control-allow-origin": "*",
298
+ "access-control-allow-methods": "GET,POST,OPTIONS",
299
+ "access-control-allow-headers": "content-type, x-voyant-protocol",
300
+ },
301
+ })
302
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // @voyantjs/workflows-orchestrator-cloudflare
2
2
  //
3
3
  // Cloudflare Worker + Durable Object adapter for @voyantjs/workflows-orchestrator.
4
- // Composes the protocol-agnostic state machine in @voyantjs/workflows-orchestrator
5
- // with a DO-backed run store and a dispatch-namespace step handler.
4
+ // Composes the protocol-agnostic state machine with a DO-backed run
5
+ // store and a pluggable step dispatcher (`StepDispatcher`).
6
6
  //
7
7
  // Typical wrangler.jsonc layout:
8
8
  //
@@ -15,14 +15,20 @@
15
15
  // { "name": "WORKFLOW_RUN_DO", "class_name": "WorkflowRunDO" }
16
16
  // ]
17
17
  // },
18
- // "dispatch_namespaces": [
19
- // { "binding": "DISPATCHER", "namespace": "voyant-tenants" }
20
- // ],
21
18
  // "migrations": [
22
19
  // { "tag": "v1", "new_sqlite_classes": ["WorkflowRunDO"] }
23
20
  // ]
24
21
  // }
25
22
  //
23
+ // Pick a dispatcher in your DO's `deps()` based on where workflow
24
+ // code lives:
25
+ // * createInlineDispatcher — same Worker as the orchestrator
26
+ // * createServiceBindingDispatcher — sibling Worker via service binding
27
+ // * createHttpDispatcher — arbitrary HTTP endpoint
28
+ //
29
+ // Hosted multi-tenant providers implement custom dispatchers in their
30
+ // own deployment code.
31
+ //
26
32
  // See docs/runtime-protocol.md §2 and docs/design.md §6 for the
27
33
  // design this adapter implements.
28
34
 
@@ -33,15 +39,33 @@ export {
33
39
  createCfContainerStepRunner,
34
40
  } from "./cf-container-runner.js"
35
41
  export {
36
- createDispatchStepHandler,
37
- type DispatchHandlerDeps,
38
- } from "./dispatch-handler.js"
42
+ type CloudflareEdgeDriverOptions,
43
+ createCloudflareEdgeDriver,
44
+ } from "./cloudflare-edge-driver.js"
45
+ export {
46
+ createHttpDispatcher,
47
+ createInlineDispatcher,
48
+ createServiceBindingDispatcher,
49
+ type HttpDispatcherOptions,
50
+ type ServiceBindingDispatcherOptions,
51
+ type ServiceBindingLike,
52
+ type StepDispatcher,
53
+ type StepDispatcherContext,
54
+ } from "./dispatchers.js"
39
55
  export { createDurableObjectRunStore } from "./do-store.js"
40
56
  export {
41
57
  type DurableObjectDeps,
42
58
  handleDurableObjectAlarm,
43
59
  handleDurableObjectRequest,
44
60
  } from "./durable-object.js"
61
+ export {
62
+ type CfManifestEnvelope,
63
+ type CfManifestStore,
64
+ type CreateKvManifestStoreOptions,
65
+ createInMemoryKv,
66
+ createKvManifestStore,
67
+ type KvNamespaceLike,
68
+ } from "./manifest-kv-store.js"
45
69
  export {
46
70
  createR2Presigner,
47
71
  type PresignArgs,
@@ -0,0 +1,113 @@
1
+ // HTTP handlers for `/api/manifests*`. Mounted by the worker before the
2
+ // existing `/api/runs/*` routes; both share the same auth.
3
+ //
4
+ // POST /api/manifests { environment, manifest } → { ok, versionId }
5
+ // GET /api/manifests/:env → manifest | 404
6
+ //
7
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §8.2.
8
+
9
+ import type { CfManifestStore } from "./manifest-kv-store.js"
10
+
11
+ const ALLOWED_ENVS = new Set(["production", "preview", "development"])
12
+
13
+ export interface ManifestHandlerDeps {
14
+ manifestStore: CfManifestStore
15
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
16
+ }
17
+
18
+ /**
19
+ * Handle `POST /api/manifests`. Body: `{ environment, manifest }`.
20
+ * `manifest.versionId` is the registered key. Returns `{ ok: true, versionId }`.
21
+ */
22
+ export async function handleRegisterManifest(
23
+ req: Request,
24
+ deps: ManifestHandlerDeps,
25
+ ): Promise<Response> {
26
+ let body: unknown
27
+ try {
28
+ body = await req.json()
29
+ } catch (err) {
30
+ return json(400, {
31
+ error: "invalid_json",
32
+ message: err instanceof Error ? err.message : String(err),
33
+ })
34
+ }
35
+ if (typeof body !== "object" || body === null) {
36
+ return json(400, { error: "invalid_body", message: "expected JSON object" })
37
+ }
38
+
39
+ const { environment, manifest } = body as {
40
+ environment?: unknown
41
+ manifest?: unknown
42
+ }
43
+ if (typeof environment !== "string" || !ALLOWED_ENVS.has(environment)) {
44
+ return json(400, {
45
+ error: "invalid_body",
46
+ message: `"environment" must be one of ${[...ALLOWED_ENVS].join(", ")}`,
47
+ })
48
+ }
49
+ if (typeof manifest !== "object" || manifest === null) {
50
+ return json(400, { error: "invalid_body", message: '"manifest" must be an object' })
51
+ }
52
+ const versionId = (manifest as { versionId?: unknown }).versionId
53
+ if (typeof versionId !== "string" || versionId.length === 0) {
54
+ return json(400, {
55
+ error: "invalid_body",
56
+ message: '"manifest.versionId" must be a non-empty string',
57
+ })
58
+ }
59
+
60
+ try {
61
+ const result = await deps.manifestStore.registerManifest({
62
+ environment,
63
+ versionId,
64
+ manifest: manifest as Record<string, unknown>,
65
+ })
66
+ return json(200, { ok: true, versionId: result.versionId })
67
+ } catch (err) {
68
+ deps.logger?.("error", "manifest registration failed", {
69
+ environment,
70
+ versionId,
71
+ error: err instanceof Error ? err.message : String(err),
72
+ })
73
+ return json(500, {
74
+ error: "register_failed",
75
+ message: err instanceof Error ? err.message : String(err),
76
+ })
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Handle `GET /api/manifests/:env`. Returns the current manifest envelope
82
+ * or 404 if no manifest is registered.
83
+ */
84
+ export async function handleGetManifest(
85
+ environment: string,
86
+ deps: ManifestHandlerDeps,
87
+ ): Promise<Response> {
88
+ if (!ALLOWED_ENVS.has(environment)) {
89
+ return json(400, {
90
+ error: "invalid_environment",
91
+ message: `environment must be one of ${[...ALLOWED_ENVS].join(", ")}`,
92
+ })
93
+ }
94
+ const envelope = await deps.manifestStore.getCurrent(environment)
95
+ if (!envelope) {
96
+ return json(404, { error: "not_found", environment })
97
+ }
98
+ return json(200, envelope)
99
+ }
100
+
101
+ // ---- Internal ----
102
+
103
+ function json(status: number, body: unknown): Response {
104
+ return new Response(JSON.stringify(body), {
105
+ status,
106
+ headers: {
107
+ "content-type": "application/json; charset=utf-8",
108
+ "access-control-allow-origin": "*",
109
+ "access-control-allow-methods": "GET,POST,OPTIONS",
110
+ "access-control-allow-headers": "content-type, x-voyant-protocol",
111
+ },
112
+ })
113
+ }
@@ -0,0 +1,186 @@
1
+ // KV-backed manifest store for the Cloudflare orchestrator.
2
+ //
3
+ // Mirrors the Mode 2 ManifestStore contract from
4
+ // `@voyantjs/workflows-orchestrator-node`'s `createPostgresManifestStore`,
5
+ // just against KV instead of Postgres. Both are consumed by their
6
+ // respective driver factories — the orchestrator's `WorkflowDriver`
7
+ // shape is identical.
8
+ //
9
+ // Layout in KV:
10
+ //
11
+ // manifest:<environment>:<versionId> → JSON-serialized manifest
12
+ // manifest:<environment>:current → versionId of the active manifest
13
+ //
14
+ // Idempotent: same `(environment, versionId)` overwrite is fine.
15
+ // Latest N versions retained via `pruneToVersions(env, n)`. KV is
16
+ // eventually consistent (~60s globally), which is acceptable for the
17
+ // manifest read path (manifests change at deploy boundaries, not per
18
+ // event).
19
+
20
+ // ---- Public types ----
21
+
22
+ /**
23
+ * Structural view of a `WorkflowManifest` envelope. Mirrors the shape
24
+ * `@voyantjs/workflows-orchestrator-node`'s manifest store uses, declared
25
+ * locally so this package stays free of the Mode 2 dep.
26
+ */
27
+ export interface CfManifestEnvelope {
28
+ environment: string
29
+ versionId: string
30
+ manifest: Record<string, unknown>
31
+ }
32
+
33
+ export interface CfManifestStore {
34
+ registerManifest(envelope: CfManifestEnvelope): Promise<{ versionId: string }>
35
+ getCurrent(environment: string): Promise<CfManifestEnvelope | null>
36
+ pruneToVersions(environment: string, keep: number): Promise<{ deleted: number }>
37
+ }
38
+
39
+ /**
40
+ * Subset of the CF KV namespace API we need. Declared structurally so
41
+ * tests can pass an in-memory fake without depending on
42
+ * `@cloudflare/workers-types`.
43
+ */
44
+ export interface KvNamespaceLike {
45
+ get(key: string): Promise<string | null>
46
+ put(key: string, value: string, opts?: { metadata?: unknown }): Promise<void>
47
+ delete(key: string): Promise<void>
48
+ list(opts?: { prefix?: string; limit?: number; cursor?: string }): Promise<{
49
+ keys: Array<{ name: string; metadata?: unknown }>
50
+ list_complete?: boolean
51
+ cursor?: string
52
+ }>
53
+ }
54
+
55
+ export interface CreateKvManifestStoreOptions {
56
+ /** KV namespace binding from the worker's env. */
57
+ kv: KvNamespaceLike
58
+ }
59
+
60
+ // ---- Public factory ----
61
+
62
+ /**
63
+ * Build a KV-backed `CfManifestStore`. Stateless — every call hits KV.
64
+ */
65
+ export function createKvManifestStore(opts: CreateKvManifestStoreOptions): CfManifestStore {
66
+ const kv = opts.kv
67
+
68
+ return {
69
+ async registerManifest(envelope) {
70
+ const versionKey = manifestVersionKey(envelope.environment, envelope.versionId)
71
+ const currentKey = manifestCurrentKey(envelope.environment)
72
+
73
+ // Idempotent overwrite — same body produces the same byte content
74
+ // because manifests are content-addressed (versionId derives from
75
+ // a sha256 of the canonicalized manifest in the SDK).
76
+ await kv.put(versionKey, JSON.stringify(envelope.manifest))
77
+ await kv.put(currentKey, envelope.versionId)
78
+
79
+ return { versionId: envelope.versionId }
80
+ },
81
+
82
+ async getCurrent(environment) {
83
+ const currentKey = manifestCurrentKey(environment)
84
+ const versionId = await kv.get(currentKey)
85
+ if (!versionId) return null
86
+
87
+ const versionKey = manifestVersionKey(environment, versionId)
88
+ const raw = await kv.get(versionKey)
89
+ if (!raw) return null
90
+
91
+ let parsed: Record<string, unknown>
92
+ try {
93
+ parsed = JSON.parse(raw) as Record<string, unknown>
94
+ } catch {
95
+ return null
96
+ }
97
+ return {
98
+ environment,
99
+ versionId,
100
+ manifest: parsed,
101
+ }
102
+ },
103
+
104
+ async pruneToVersions(environment, keep) {
105
+ if (keep < 1) {
106
+ throw new Error(`pruneToVersions: keep must be >= 1, got ${keep}`)
107
+ }
108
+ // List every version key for this environment; sort so we can drop
109
+ // older entries. Lexicographic sort is fine because the SDK's
110
+ // versionId is a hex string of the same length, and KV's natural
111
+ // order is also lexicographic. For deterministic semantics we
112
+ // additionally fetch the `current` pointer and always keep that.
113
+ const prefix = `manifest:${environment}:`
114
+ const list = await kv.list({ prefix, limit: 1000 })
115
+ const versionKeys = list.keys.map((k) => k.name).filter((name) => !name.endsWith(":current"))
116
+
117
+ // Sort newest-first (lexicographic descending).
118
+ versionKeys.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
119
+
120
+ const currentVersion = await kv.get(manifestCurrentKey(environment))
121
+ const currentKey = currentVersion
122
+ ? manifestVersionKey(environment, currentVersion)
123
+ : undefined
124
+
125
+ const keepers = new Set<string>()
126
+ if (currentKey) keepers.add(currentKey)
127
+ for (const k of versionKeys) {
128
+ if (keepers.size >= keep) break
129
+ keepers.add(k)
130
+ }
131
+
132
+ let deleted = 0
133
+ for (const k of versionKeys) {
134
+ if (keepers.has(k)) continue
135
+ await kv.delete(k)
136
+ deleted++
137
+ }
138
+ return { deleted }
139
+ },
140
+ }
141
+ }
142
+
143
+ // ---- Key helpers ----
144
+
145
+ function manifestVersionKey(environment: string, versionId: string): string {
146
+ return `manifest:${environment}:${versionId}`
147
+ }
148
+
149
+ function manifestCurrentKey(environment: string): string {
150
+ return `manifest:${environment}:current`
151
+ }
152
+
153
+ // ---- In-memory KV fake (test-only) ----
154
+
155
+ /**
156
+ * Tiny in-memory implementation of `KvNamespaceLike` for tests + the CF
157
+ * compliance suite run that doesn't go through wrangler. Mirrors the
158
+ * subset of CF KV semantics we use (list returns matching prefix in
159
+ * lexicographic order; get returns null for missing keys).
160
+ */
161
+ export function createInMemoryKv(): KvNamespaceLike {
162
+ const map = new Map<string, string>()
163
+ return {
164
+ async get(key) {
165
+ return map.has(key) ? (map.get(key) as string) : null
166
+ },
167
+ async put(key, value) {
168
+ map.set(key, value)
169
+ },
170
+ async delete(key) {
171
+ map.delete(key)
172
+ },
173
+ async list(opts) {
174
+ const prefix = opts?.prefix ?? ""
175
+ const limit = opts?.limit ?? 1000
176
+ const matching = [...map.keys()]
177
+ .filter((k) => k.startsWith(prefix))
178
+ .sort()
179
+ .slice(0, limit)
180
+ return {
181
+ keys: matching.map((name) => ({ name })),
182
+ list_complete: true,
183
+ }
184
+ },
185
+ }
186
+ }