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 +140 -10
- package/package.json +15 -4
- package/src/bun-wasm-loader.ts +16 -0
- package/src/config.ts +32 -0
- package/src/generated/sync_wasm.d.ts +37 -2
- package/src/generated/sync_wasm.js +100 -2
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/harness-config.ts +105 -1
- package/src/harness-worker.ts +22 -2
- package/src/host.ts +498 -179
- package/src/index.ts +26 -1
- package/src/ingest-harness-worker.ts +172 -6
- package/src/mutation-error.ts +28 -0
- package/src/platform-probe-worker.ts +122 -9
- package/src/post-commit.ts +40 -0
- package/src/query-compiler.ts +16 -0
- package/src/sql-storage-adapter.ts +88 -19
- package/src/transaction-query.ts +494 -0
- package/src/types.ts +39 -17
- package/src/wasm-platform.ts +10 -0
- package/src/wasm.ts +34 -0
- package/src/write-safeguards.ts +14 -15
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
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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](
|
|
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.
|
|
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
|
|
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
|
package/src/harness-config.ts
CHANGED
|
@@ -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) {
|