orez-sync-cf-host 0.5.17 → 0.5.19

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/README.md CHANGED
@@ -17,16 +17,99 @@ Every SQL cursor is materialized before an await. Mutators may await only their
17
17
  `context.defer`, which runs only after commit. Application failures use the
18
18
  required second transaction to advance the LMID marker.
19
19
 
20
+ The root `orez/cf-do` executor and this host consume the same `post-commit`
21
+ module, so transaction retries discard effects from abandoned attempts in both
22
+ paths.
23
+
24
+ Transaction-query `ILIKE` folding is ASCII-only on Durable Object SQLite;
25
+ non-ASCII case pairs can diverge from PostgreSQL.
26
+
27
+ ### Bun query compiler
28
+
29
+ Wrangler loads the engine's `.wasm` import as a precompiled
30
+ `WebAssembly.Module`. Bun consumers of `createQueryCompiler` must register the
31
+ package loader so the same import has the same value without changing the
32
+ compiler code:
33
+
34
+ ```toml
35
+ # bunfig.toml
36
+ preload = ["orez-sync-cf-host/bun-wasm-loader"]
37
+ ```
38
+
39
+ For a single command, use
40
+ `bun --preload orez-sync-cf-host/bun-wasm-loader <command>`. The compiler throws
41
+ an error naming this preload when Bun resolves the `.wasm` import as a pathname.
42
+
20
43
  ## Wake channel and eviction
21
44
 
22
- `GET /<namespace>/wake?clientID=<id>` upgrades to a Durable Object hibernating
23
- WebSocket. Socket attachments carry only the client ID. A committed push sends a
24
- text `wake` frame to all connected clients except the pusher; a scheduler window
45
+ `GET /<namespace>/wake?clientID=<id>&wakeToken=<capability>` upgrades to a
46
+ Durable Object hibernating WebSocket after `authorizeWake` accepts the
47
+ capability. Browser consumers should mint a short-lived, namespace-scoped token
48
+ at their authenticated edge because the native WebSocket constructor cannot set
49
+ an authorization header. Socket attachments carry only the client ID. A
50
+ committed push sends a text `wake` frame to all connected clients except the
51
+ pusher; a scheduler window
25
52
  coalesces a burst into one frame per socket. `ping` receives `pong`. The message
26
53
  contains no state and carries no correctness weight: clients pull after a wake
27
54
  and retain their safety poll. `ctx.getWebSockets()` plus serialized attachments
28
55
  means sockets remain discoverable after hibernation/re-instantiation.
29
56
 
57
+ ### Consumer-minted wake capabilities
58
+
59
+ The consumer Worker owns both token minting and verification. Add an
60
+ authenticated edge route that signs the namespace and a short expiry, typically
61
+ 30 to 60 seconds, with a secret that never reaches the browser. Return only the
62
+ signed token:
63
+
64
+ ```ts
65
+ // consumer edge route, after normal session authentication
66
+ const expiresAt = Date.now() + 60_000
67
+ const token = await signWakeToken({ namespace, userID, expiresAt }, env.WAKE_SECRET)
68
+ return Response.json({ token, expiresAt })
69
+ ```
70
+
71
+ Pass a mint callback to the canonical HTTP transport. It calls `getToken()` for
72
+ every socket attempt, including reconnects, so short-lived tokens are never
73
+ reused after the wake connection drops:
74
+
75
+ ```ts
76
+ import { ensureHttpPullTransport } from 'orez/zero-http'
77
+
78
+ ensureHttpPullTransport({
79
+ origin: syncOrigin,
80
+ pullIntervalMs: 5_000,
81
+ wake: {
82
+ async getToken() {
83
+ const response = await fetch(`/api/sync/${namespace}/wake-token`, {
84
+ method: 'POST',
85
+ })
86
+ if (!response.ok) throw new Error('wake token mint failed')
87
+ return (await response.json()).token
88
+ },
89
+ },
90
+ })
91
+ ```
92
+
93
+ The transport appends that value as `wakeToken` because browser WebSockets
94
+ cannot set an authorization header. Verify its signature, expiry, and namespace
95
+ inside the consumer's `authorizeWake` callback. `sync-cf-host` deliberately does
96
+ not prescribe a token format or hold the signing key:
97
+
98
+ ```ts
99
+ authorizeWake(request, env) {
100
+ const url = new URL(request.url)
101
+ return verifyWakeToken(url.searchParams.get('wakeToken'), {
102
+ namespace: namespaceFrom(url),
103
+ secret: env.WAKE_SECRET,
104
+ })
105
+ }
106
+ ```
107
+
108
+ Treat the URL token as a narrowly scoped capability and avoid logging it. If
109
+ minting or wake authorization is unavailable, the wake channel retries in the
110
+ background while HTTP pulls and the safety poll continue to provide
111
+ convergence.
112
+
30
113
  `/admin/status` reports a boot ID, hibernation simulation count, connected wake
31
114
  sockets, durable database size, engine watermark/floor, and aggregate counters.
32
115
  After the configured idle gap (5 seconds in the harness deployment), the local
@@ -42,6 +125,29 @@ callback succeeds or the request presents the deployment's `ADMIN_KEY` through
42
125
  includes the persisted writer-enabled state and wasm linear-memory byte length
43
126
  in addition to the engine/counter fields above.
44
127
 
128
+ `POST /<namespace>/notify` is rejected unless `authorizeNotify` accepts a
129
+ service or operator capability. Both `authorizeWake` and `authorizeNotify` run
130
+ before `idFromName`, so rejected requests cannot instantiate namespace Durable
131
+ Objects.
132
+
133
+ ### Consumer routing traps
134
+
135
+ If an outer application router has its own namespace gate, let only `/wake` and
136
+ `/notify` pass that outer gate. The sync worker's required `authorizeWake` and
137
+ `authorizeNotify` callbacks then enforce the real capability checks. Do not
138
+ bypass pull, push, or admin routes, and do not replace either callback with an
139
+ unconditional allow.
140
+
141
+ A delegated push must terminate at the application worker whose registry owns
142
+ the named mutator. Routing it to a sync host, control-plane worker, or another
143
+ application server with a different registry produces an authoritative error
144
+ such as `could not find mutator <name>`. The optimistic client write may appear
145
+ briefly, then disappear on reload and never reach peers. Route client pushes to
146
+ the sync host only when its `mutateBinding` and `mutateUrl` delegate to the
147
+ owning application's mutate endpoint. Preview and browser-local transports must
148
+ stay pointed at their in-process project server unless they provide the same
149
+ delegated route.
150
+
45
151
  `POST /admin/writer` with `{ "enabled": false }` durably stops pushes for that
46
152
  namespace. A stopped writer consumes and discards the request body, returns 503,
47
153
  and performs no engine or application write. Canary rollback drills must stop
@@ -56,12 +162,36 @@ not mutate a production route. These controls are mechanisms, not authorization
56
162
  to perform a production cutover.
57
163
 
58
164
  `POST /admin/resnapshot` is available only when the consumer configured an
59
- upstream data service. It reads that namespace's authoritative `/snapshot`,
60
- atomically replaces the derived application tables, then consumes `/changes`
61
- until caught up. Engine metadata, client last-mutation IDs, operator controls,
62
- and the authoritative upstream database are preserved. The JSON response
63
- includes before/after upstream watermarks and the number of snapshot plus
64
- catch-up rows applied.
165
+ upstream data service. It reads each modeled source table through bounded
166
+ keyset pages and commits them to a staged generation. Progress is durable, so a
167
+ restart resumes at the recorded table and cursor. The host then catches up
168
+ concurrent `/changes`, atomically swaps the generation into place, and bumps the
169
+ engine epoch. Every client performs one expected full resync after cutover.
170
+ Engine metadata, client last-mutation IDs, operator controls, and the
171
+ authoritative upstream database are preserved. The legacy single-response
172
+ `/snapshot` endpoint remains for small datasets and older harnesses.
173
+
174
+ ## Delegated service addresses
175
+
176
+ `mutateBinding` selects the service binding that owns the application's mutate
177
+ endpoint and defaults to `upstream.binding`. Set `mutateOrigin` to an exact
178
+ absolute HTTP(S) origin when that worker's routing depends on the request
179
+ origin. `upstream.namespacePath` may return `/` for a root-mounted data feed;
180
+ internally that root is an empty path, while `null` alone means no upstream path
181
+ is configured.
182
+
183
+ A successful delegated mutation response has a hard causality contract: its
184
+ committed application effects must already be readable from the configured
185
+ upstream `/changes` feed before the application worker returns. The sync host
186
+ then ingests through those effects, journals the acknowledged LMID after them,
187
+ and only then returns the push response. This keeps every capped change-log
188
+ prefix ordered so an acknowledgement cannot reach a client before its effects.
189
+ Chat and Soot use an application-to-data service path that provides this
190
+ ordering today. A future topology that cannot provide it must extend the
191
+ delegated response with an upstream watermark receipt and make the host ingest
192
+ through that receipt before finalization. Do not add a second best-effort path.
193
+ The ordering does not add another ingest round: delegated pushes already waited
194
+ for post-mutation ingest before returning, and now finalize after that wait.
65
195
 
66
196
  ## Counter and HTTP wire representation
67
197
 
@@ -149,4 +279,4 @@ absent when the cleanup command was run (Cloudflare returned error 10090), so no
149
279
  old probe service remains to receive traffic.
150
280
 
151
281
  The rust toolchain is pinned at the workspace root in
152
- [rust-toolchain.toml](/Users/n8/.worktrees/orez-rust-sync/rust-toolchain.toml).
282
+ [`rust-toolchain.toml`](../../rust-toolchain.toml).
package/package.json CHANGED
@@ -1,25 +1,36 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.5.17",
3
+ "version": "0.5.19",
4
4
  "type": "module",
5
5
  "exports": {
6
- ".": "./src/index.ts"
6
+ ".": "./src/index.ts",
7
+ "./post-commit": "./src/post-commit.ts",
8
+ "./mutation-error": {
9
+ "types": "./src/mutation-error.ts",
10
+ "import": "./src/mutation-error.ts"
11
+ },
12
+ "./bun-wasm-loader": "./src/bun-wasm-loader.ts",
13
+ "./query-compiler": "./src/query-compiler.ts",
14
+ "./transaction-query": "./src/transaction-query.ts"
7
15
  },
8
16
  "scripts": {
9
17
  "build:wasm": "wasm-pack build ../../crates/sync-wasm --target web --release --out-dir ../../packages/sync-cf-host/src/generated --out-name sync_wasm && rm -f src/generated/.gitignore && bun scripts/write-wasm-module-type.ts",
10
18
  "build:wasm:platform": "wasm-pack build ../../crates/sync-wasm --target web --release --out-dir ../../packages/sync-cf-host/src/generated --out-name sync_wasm -- --features platform-probes && rm -f src/generated/.gitignore && bun scripts/write-wasm-module-type.ts",
11
19
  "bundle": "bun run build:wasm && wrangler deploy --dry-run --outdir dist",
12
20
  "measure": "bun run build:wasm && bun measure.mjs",
13
- "test": "bun run test:config && bun run test:platform && bun run test:integration && bun run test:ingest && bun run test:restart",
14
- "test:config": "bun test config.test.mjs write-safeguards.test.mjs",
21
+ "test": "bun run test:config && bun run test:query-compiler && bun run test:platform && bun run test:integration && bun run test:ingest && bun run test:restart",
22
+ "test:config": "bun test config.test.mjs write-safeguards.test.mjs mutation-error.test.mjs post-commit.test.mjs",
15
23
  "test:integration": "bun run build:wasm && bun integration-test.mjs",
16
24
  "test:ingest": "bun run build:wasm && bun ingest-test.mjs",
25
+ "test:query-compiler": "bun run build:wasm && bun test --preload orez-sync-cf-host/bun-wasm-loader query-compiler.test.mjs",
17
26
  "test:restart": "bun restart-test.mjs",
18
27
  "test:platform": "bun run build:wasm:platform && bun platform-test.mjs",
19
28
  "typecheck": "bun run build:wasm:platform && tsc --noEmit"
20
29
  },
21
30
  "devDependencies": {
22
31
  "@cloudflare/workers-types": "4.20260617.1",
32
+ "@types/bun": "1.3.14",
33
+ "@types/node": "22.19.10",
23
34
  "typescript": "5.9.3",
24
35
  "workerd": "1.20260617.1",
25
36
  "wrangler": "4.103.0"
@@ -0,0 +1,16 @@
1
+ import { Buffer } from 'node:buffer'
2
+
3
+ import { file, plugin } from 'bun'
4
+
5
+ plugin({
6
+ name: 'orez-sync-cf-host-wasm',
7
+ setup(build) {
8
+ build.onLoad({ filter: /sync_wasm_bg\.wasm$/ }, async ({ path }) => {
9
+ const bytes = Buffer.from(await file(path).arrayBuffer()).toString('base64')
10
+ return {
11
+ contents: `export default new WebAssembly.Module(Uint8Array.fromBase64('${bytes}'))`,
12
+ loader: 'js',
13
+ }
14
+ })
15
+ },
16
+ })
package/src/config.ts CHANGED
@@ -10,6 +10,12 @@ export function validatePullCaps(caps: PullCaps): PullCaps {
10
10
  export function validateSyncHostConfig<Env extends SyncHostEnv>(
11
11
  config: SyncHostConfig<Env>
12
12
  ): SyncHostConfig<Env> {
13
+ if (typeof config.authorizeWake !== 'function') {
14
+ throw new TypeError('sync host config authorizeWake is required')
15
+ }
16
+ if (typeof config.authorizeNotify !== 'function') {
17
+ throw new TypeError('sync host config authorizeNotify is required')
18
+ }
13
19
  const hasMutators = config.mutators !== undefined
14
20
  const hasDelegate = config.mutateUrl !== undefined
15
21
  if (hasMutators === hasDelegate) {
@@ -21,6 +27,9 @@ export function validateSyncHostConfig<Env extends SyncHostEnv>(
21
27
  if (config.mutateBinding !== undefined && !hasDelegate) {
22
28
  throw new TypeError('sync host config mutateBinding requires mutateUrl')
23
29
  }
30
+ if (config.mutateOrigin !== undefined && !hasDelegate) {
31
+ throw new TypeError('sync host config mutateOrigin requires mutateUrl')
32
+ }
24
33
  if (config.delegatedPushRetry !== undefined && !hasDelegate) {
25
34
  throw new TypeError('sync host config delegatedPushRetry requires mutateUrl')
26
35
  }
@@ -35,6 +44,20 @@ export function validateSyncHostConfig<Env extends SyncHostEnv>(
35
44
  if (config.mutateUrl && !config.mutateUrl.startsWith('/')) {
36
45
  throw new TypeError('mutateUrl must be an absolute path')
37
46
  }
47
+ if (config.mutateOrigin !== undefined) {
48
+ let origin: URL
49
+ try {
50
+ origin = new URL(config.mutateOrigin)
51
+ } catch {
52
+ throw new TypeError('mutateOrigin must be an absolute http(s) origin')
53
+ }
54
+ if (
55
+ (origin.protocol !== 'http:' && origin.protocol !== 'https:') ||
56
+ origin.origin !== config.mutateOrigin
57
+ ) {
58
+ throw new TypeError('mutateOrigin must be an absolute http(s) origin')
59
+ }
60
+ }
38
61
  if (config.upstream) {
39
62
  if (!config.upstream.binding) throw new TypeError('upstream.binding is required')
40
63
  const limit = config.upstream.changeLimit ?? 1_000
@@ -66,5 +89,14 @@ export function validateSyncHostConfig<Env extends SyncHostEnv>(
66
89
  throw new TypeError(`delegatedPushRetry.${name} must be a positive safe integer`)
67
90
  }
68
91
  }
92
+ if (config.transactionQueryBudget) {
93
+ for (const [name, value] of Object.entries(config.transactionQueryBudget)) {
94
+ if (!Number.isSafeInteger(value) || Number(value) < 1) {
95
+ throw new TypeError(
96
+ `transactionQueryBudget.${name} must be a positive safe integer`
97
+ )
98
+ }
99
+ }
100
+ }
69
101
  return config
70
102
  }
@@ -1,6 +1,18 @@
1
1
  /* tslint:disable */
2
2
  /* eslint-disable */
3
3
 
4
+ /**
5
+ * Apply one ordered catch-up page to staging without touching the live table
6
+ * namespace or live upstream watermark.
7
+ */
8
+ export function engine_apply_snapshot_changes(db: any, schema: any, generation: string, batch: any): any;
9
+
10
+ /**
11
+ * Apply one bounded source page to staging and commit its opaque cursor with
12
+ * the rows. The host owns one transaction per call.
13
+ */
14
+ export function engine_apply_snapshot_page(db: any, schema: any, generation: string, table: string, rows: any, next_cursor?: string | null): any;
15
+
4
16
  /**
5
17
  * Apply one ordered page from the upstream ZeroSqlDO change feed. The host
6
18
  * owns one transaction around this call; application triggers append the
@@ -15,14 +27,26 @@ export function engine_apply_upstream_snapshot(db: any, schema: any, snapshot: a
15
27
 
16
28
  export function engine_assemble_push_response(results: any): any;
17
29
 
30
+ /**
31
+ * Create a new staged generation. `start_watermark` is captured by the host
32
+ * from the first source snapshot-page response. The host owns the transaction.
33
+ */
34
+ export function engine_begin_snapshot_generation(db: any, schema: any, start_watermark: string): any;
35
+
18
36
  /**
19
37
  * Compile a validated Zero query AST for a consumer mutator's transactional
20
38
  * `tx.run(...)`. Execution remains in the host-owned application transaction.
21
39
  */
22
- export function engine_compile_query(schema: any, ast: any): any;
40
+ export function engine_compile_query(schema: any, ast: any, format: any): any;
23
41
 
24
42
  export function engine_finalize(db: any, client_group_id: string, client_id: string, mutation_id: string): void;
25
43
 
44
+ /**
45
+ * Atomically rename a drained staged generation into the live namespace,
46
+ * invalidate pre-cutover client cookies, and advance the live source cursor.
47
+ */
48
+ export function engine_finalize_snapshot_generation(db: any, schema: any, generation: string, watermark: string): any;
49
+
26
50
  /**
27
51
  * Production pull entry. The TypeScript host owns `transactionSync`.
28
52
  */
@@ -63,6 +87,12 @@ export function engine_prune(db: any, retain_changes: string): void;
63
87
  */
64
88
  export function engine_push_validate(body: any): any;
65
89
 
90
+ /**
91
+ * Strictly read resumable snapshot progress. Storage and row-shape failures
92
+ * are errors; only a successful query with no active row returns null.
93
+ */
94
+ export function engine_read_snapshot_progress(db: any): any;
95
+
66
96
  export function engine_record_app_error(db: any, client_group_id: string, client_id: string, mutation_id: string, user_id: string): void;
67
97
 
68
98
  export function engine_state(db: any): any;
@@ -73,11 +103,15 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
73
103
 
74
104
  export interface InitOutput {
75
105
  readonly memory: WebAssembly.Memory;
106
+ readonly engine_apply_snapshot_changes: (a: any, b: any, c: number, d: number, e: any) => [number, number, number];
107
+ readonly engine_apply_snapshot_page: (a: any, b: any, c: number, d: number, e: number, f: number, g: any, h: number, i: number) => [number, number, number];
76
108
  readonly engine_apply_upstream: (a: any, b: any, c: any) => [number, number, number];
77
109
  readonly engine_apply_upstream_snapshot: (a: any, b: any, c: any) => [number, number, number];
78
110
  readonly engine_assemble_push_response: (a: any) => [number, number, number];
79
- readonly engine_compile_query: (a: any, b: any) => [number, number, number];
111
+ readonly engine_begin_snapshot_generation: (a: any, b: any, c: number, d: number) => [number, number, number];
112
+ readonly engine_compile_query: (a: any, b: any, c: any) => [number, number, number];
80
113
  readonly engine_finalize: (a: any, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number];
114
+ readonly engine_finalize_snapshot_generation: (a: any, b: any, c: number, d: number, e: number, f: number) => [number, number, number];
81
115
  readonly engine_handle_pull: (a: any, b: any, c: any, d: any, e: number, f: number, g: any, h: number, i: number) => [number, number, number];
82
116
  readonly engine_handle_query_pull: (a: any, b: any, c: number, d: number, e: any, f: number, g: number) => [number, number, number];
83
117
  readonly engine_init_query_schema: (a: any) => [number, number];
@@ -87,6 +121,7 @@ export interface InitOutput {
87
121
  readonly engine_preflight: (a: any, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number, number];
88
122
  readonly engine_prune: (a: any, b: number, c: number) => [number, number];
89
123
  readonly engine_push_validate: (a: any) => [number, number, number];
124
+ readonly engine_read_snapshot_progress: (a: any) => [number, number, number];
90
125
  readonly engine_record_app_error: (a: any, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number];
91
126
  readonly engine_state: (a: any) => [number, number, number];
92
127
  readonly engine_version: () => [number, number];
@@ -1,5 +1,49 @@
1
1
  /* @ts-self-types="./sync_wasm.d.ts" */
2
2
 
3
+ /**
4
+ * Apply one ordered catch-up page to staging without touching the live table
5
+ * namespace or live upstream watermark.
6
+ * @param {any} db
7
+ * @param {any} schema
8
+ * @param {string} generation
9
+ * @param {any} batch
10
+ * @returns {any}
11
+ */
12
+ export function engine_apply_snapshot_changes(db, schema, generation, batch) {
13
+ const ptr0 = passStringToWasm0(generation, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
14
+ const len0 = WASM_VECTOR_LEN;
15
+ const ret = wasm.engine_apply_snapshot_changes(db, schema, ptr0, len0, batch);
16
+ if (ret[2]) {
17
+ throw takeFromExternrefTable0(ret[1]);
18
+ }
19
+ return takeFromExternrefTable0(ret[0]);
20
+ }
21
+
22
+ /**
23
+ * Apply one bounded source page to staging and commit its opaque cursor with
24
+ * the rows. The host owns one transaction per call.
25
+ * @param {any} db
26
+ * @param {any} schema
27
+ * @param {string} generation
28
+ * @param {string} table
29
+ * @param {any} rows
30
+ * @param {string | null} [next_cursor]
31
+ * @returns {any}
32
+ */
33
+ export function engine_apply_snapshot_page(db, schema, generation, table, rows, next_cursor) {
34
+ const ptr0 = passStringToWasm0(generation, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
35
+ const len0 = WASM_VECTOR_LEN;
36
+ const ptr1 = passStringToWasm0(table, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
37
+ const len1 = WASM_VECTOR_LEN;
38
+ var ptr2 = isLikeNone(next_cursor) ? 0 : passStringToWasm0(next_cursor, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
39
+ var len2 = WASM_VECTOR_LEN;
40
+ const ret = wasm.engine_apply_snapshot_page(db, schema, ptr0, len0, ptr1, len1, rows, ptr2, len2);
41
+ if (ret[2]) {
42
+ throw takeFromExternrefTable0(ret[1]);
43
+ }
44
+ return takeFromExternrefTable0(ret[0]);
45
+ }
46
+
3
47
  /**
4
48
  * Apply one ordered page from the upstream ZeroSqlDO change feed. The host
5
49
  * owns one transaction around this call; application triggers append the
@@ -44,15 +88,34 @@ export function engine_assemble_push_response(results) {
44
88
  return takeFromExternrefTable0(ret[0]);
45
89
  }
46
90
 
91
+ /**
92
+ * Create a new staged generation. `start_watermark` is captured by the host
93
+ * from the first source snapshot-page response. The host owns the transaction.
94
+ * @param {any} db
95
+ * @param {any} schema
96
+ * @param {string} start_watermark
97
+ * @returns {any}
98
+ */
99
+ export function engine_begin_snapshot_generation(db, schema, start_watermark) {
100
+ const ptr0 = passStringToWasm0(start_watermark, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
101
+ const len0 = WASM_VECTOR_LEN;
102
+ const ret = wasm.engine_begin_snapshot_generation(db, schema, ptr0, len0);
103
+ if (ret[2]) {
104
+ throw takeFromExternrefTable0(ret[1]);
105
+ }
106
+ return takeFromExternrefTable0(ret[0]);
107
+ }
108
+
47
109
  /**
48
110
  * Compile a validated Zero query AST for a consumer mutator's transactional
49
111
  * `tx.run(...)`. Execution remains in the host-owned application transaction.
50
112
  * @param {any} schema
51
113
  * @param {any} ast
114
+ * @param {any} format
52
115
  * @returns {any}
53
116
  */
54
- export function engine_compile_query(schema, ast) {
55
- const ret = wasm.engine_compile_query(schema, ast);
117
+ export function engine_compile_query(schema, ast, format) {
118
+ const ret = wasm.engine_compile_query(schema, ast, format);
56
119
  if (ret[2]) {
57
120
  throw takeFromExternrefTable0(ret[1]);
58
121
  }
@@ -78,6 +141,27 @@ export function engine_finalize(db, client_group_id, client_id, mutation_id) {
78
141
  }
79
142
  }
80
143
 
144
+ /**
145
+ * Atomically rename a drained staged generation into the live namespace,
146
+ * invalidate pre-cutover client cookies, and advance the live source cursor.
147
+ * @param {any} db
148
+ * @param {any} schema
149
+ * @param {string} generation
150
+ * @param {string} watermark
151
+ * @returns {any}
152
+ */
153
+ export function engine_finalize_snapshot_generation(db, schema, generation, watermark) {
154
+ const ptr0 = passStringToWasm0(generation, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
155
+ const len0 = WASM_VECTOR_LEN;
156
+ const ptr1 = passStringToWasm0(watermark, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
157
+ const len1 = WASM_VECTOR_LEN;
158
+ const ret = wasm.engine_finalize_snapshot_generation(db, schema, ptr0, len0, ptr1, len1);
159
+ if (ret[2]) {
160
+ throw takeFromExternrefTable0(ret[1]);
161
+ }
162
+ return takeFromExternrefTable0(ret[0]);
163
+ }
164
+
81
165
  /**
82
166
  * Production pull entry. The TypeScript host owns `transactionSync`.
83
167
  * @param {any} db
@@ -221,6 +305,20 @@ export function engine_push_validate(body) {
221
305
  return takeFromExternrefTable0(ret[0]);
222
306
  }
223
307
 
308
+ /**
309
+ * Strictly read resumable snapshot progress. Storage and row-shape failures
310
+ * are errors; only a successful query with no active row returns null.
311
+ * @param {any} db
312
+ * @returns {any}
313
+ */
314
+ export function engine_read_snapshot_progress(db) {
315
+ const ret = wasm.engine_read_snapshot_progress(db);
316
+ if (ret[2]) {
317
+ throw takeFromExternrefTable0(ret[1]);
318
+ }
319
+ return takeFromExternrefTable0(ret[0]);
320
+ }
321
+
224
322
  /**
225
323
  * @param {any} db
226
324
  * @param {string} client_group_id
Binary file
@@ -329,6 +329,93 @@ const harnessMutators = registerMutators({
329
329
  },
330
330
  })
331
331
 
332
+ const WAKE_TOKEN_TTL_MS = 60_000
333
+
334
+ type HarnessWakeTokenPayload = {
335
+ namespace: string
336
+ userID: string
337
+ expiresAt: number
338
+ }
339
+
340
+ export async function mintHarnessWakeToken(
341
+ namespace: string,
342
+ userID: string,
343
+ secret: string
344
+ ): Promise<{ token: string; expiresAt: number }> {
345
+ const expiresAt = Date.now() + WAKE_TOKEN_TTL_MS
346
+ const payload = encodeBase64URL(
347
+ new TextEncoder().encode(JSON.stringify({ namespace, userID, expiresAt }))
348
+ )
349
+ return {
350
+ token: `${payload}.${await signWakeToken(payload, secret)}`,
351
+ expiresAt,
352
+ }
353
+ }
354
+
355
+ async function verifyHarnessWakeToken(
356
+ token: string,
357
+ namespace: string,
358
+ secret: string
359
+ ): Promise<boolean> {
360
+ try {
361
+ const [payload, signature, extra] = token.split('.')
362
+ if (!payload || !signature || extra) return false
363
+ const key = await wakeTokenKey(secret, ['verify'])
364
+ const valid = await crypto.subtle.verify(
365
+ 'HMAC',
366
+ key,
367
+ decodeBase64URL(signature),
368
+ new TextEncoder().encode(payload)
369
+ )
370
+ if (!valid) return false
371
+ const claims = JSON.parse(
372
+ new TextDecoder().decode(decodeBase64URL(payload))
373
+ ) as HarnessWakeTokenPayload
374
+ return (
375
+ claims.namespace === namespace &&
376
+ typeof claims.userID === 'string' &&
377
+ claims.userID.length > 0 &&
378
+ Number.isFinite(claims.expiresAt) &&
379
+ claims.expiresAt > Date.now()
380
+ )
381
+ } catch {
382
+ return false
383
+ }
384
+ }
385
+
386
+ async function signWakeToken(payload: string, secret: string): Promise<string> {
387
+ const key = await wakeTokenKey(secret, ['sign'])
388
+ const signature = await crypto.subtle.sign(
389
+ 'HMAC',
390
+ key,
391
+ new TextEncoder().encode(payload)
392
+ )
393
+ return encodeBase64URL(new Uint8Array(signature))
394
+ }
395
+
396
+ function wakeTokenKey(secret: string, usages: KeyUsage[]): Promise<CryptoKey> {
397
+ return crypto.subtle.importKey(
398
+ 'raw',
399
+ new TextEncoder().encode(secret),
400
+ { name: 'HMAC', hash: 'SHA-256' },
401
+ false,
402
+ usages
403
+ )
404
+ }
405
+
406
+ function encodeBase64URL(bytes: Uint8Array): string {
407
+ let binary = ''
408
+ for (const byte of bytes) binary += String.fromCharCode(byte)
409
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
410
+ }
411
+
412
+ function decodeBase64URL(value: string): ArrayBuffer {
413
+ const base64 = value.replaceAll('-', '+').replaceAll('_', '/')
414
+ const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='))
415
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0))
416
+ .buffer as ArrayBuffer
417
+ }
418
+
332
419
  export function harnessConfig<Env extends SyncHostEnv>(): SyncHostConfig<Env> {
333
420
  return {
334
421
  hostVersion: '0.1.0',
@@ -336,7 +423,9 @@ export function harnessConfig<Env extends SyncHostEnv>(): SyncHostConfig<Env> {
336
423
  mutators: harnessMutators,
337
424
  queryAware: false,
338
425
  queryTransformVersion: 1,
339
- resolveQuery(name, args) {
426
+ async resolveQuery(name, args) {
427
+ const delayMs = Number((args[0] as { delayMs?: unknown } | undefined)?.delayMs ?? 0)
428
+ if (delayMs > 0) await scheduler.wait(delayMs)
340
429
  return queryNameToAst(name, args) as never
341
430
  },
342
431
  initialize: initializeHarness,
@@ -349,6 +438,21 @@ export function harnessConfig<Env extends SyncHostEnv>(): SyncHostConfig<Env> {
349
438
  ?.match(/^Bearer token-(.+)$/)?.[1]
350
439
  return userID ? { userID } : null
351
440
  },
441
+ authorizeWake(request, env) {
442
+ const url = new URL(request.url)
443
+ const namespace = url.pathname.split('/')[1]
444
+ if (!namespace || !env.ADMIN_KEY) return false
445
+ return verifyHarnessWakeToken(
446
+ url.searchParams.get('wakeToken') ?? '',
447
+ namespace,
448
+ env.ADMIN_KEY
449
+ )
450
+ },
451
+ authorizeNotify(request, env) {
452
+ return (
453
+ Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
454
+ )
455
+ },
352
456
  visibility: {
353
457
  rowLocal: false,
354
458
  filter(table, claims) {