orez-sync-cf-host 0.5.18 → 0.5.20

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 (46) hide show
  1. package/README.md +117 -7
  2. package/dist/bun-wasm-loader.d.ts +1 -0
  3. package/dist/bun-wasm-loader.js +20 -0
  4. package/dist/config.d.ts +3 -0
  5. package/dist/config.js +93 -0
  6. package/{src → dist}/generated/sync_wasm.d.ts +2 -2
  7. package/{src → dist}/generated/sync_wasm.js +3 -2
  8. package/dist/generated/sync_wasm_bg.wasm +0 -0
  9. package/dist/host.d.ts +12 -0
  10. package/dist/host.js +1512 -0
  11. package/dist/index.d.ts +8 -0
  12. package/dist/index.js +4 -0
  13. package/dist/mutation-error.d.ts +9 -0
  14. package/dist/mutation-error.js +23 -0
  15. package/dist/node-wasm-loader.d.ts +1 -0
  16. package/dist/node-wasm-loader.js +22 -0
  17. package/dist/post-commit.d.ts +13 -0
  18. package/dist/post-commit.js +31 -0
  19. package/dist/query-compiler.d.ts +4 -0
  20. package/dist/query-compiler.js +4 -0
  21. package/dist/sql-storage-adapter.d.ts +69 -0
  22. package/dist/sql-storage-adapter.js +192 -0
  23. package/dist/transaction-query.d.ts +72 -0
  24. package/dist/transaction-query.js +379 -0
  25. package/dist/types.d.ts +144 -0
  26. package/dist/types.js +5 -0
  27. package/dist/vite-wasm-loader.d.ts +3 -0
  28. package/dist/vite-wasm-loader.js +24 -0
  29. package/dist/wasm.d.ts +1 -0
  30. package/dist/wasm.js +7 -0
  31. package/dist/write-safeguards.d.ts +53 -0
  32. package/dist/write-safeguards.js +186 -0
  33. package/package.json +63 -12
  34. package/src/config.ts +0 -93
  35. package/src/generated/sync_wasm_bg.wasm +0 -0
  36. package/src/harness-config.ts +0 -487
  37. package/src/harness-worker.ts +0 -33
  38. package/src/host.ts +0 -1948
  39. package/src/index.ts +0 -22
  40. package/src/ingest-harness-worker.ts +0 -440
  41. package/src/platform-probe-worker.ts +0 -305
  42. package/src/sql-storage-adapter.ts +0 -185
  43. package/src/types.ts +0 -181
  44. package/src/write-safeguards.ts +0 -248
  45. /package/{src → dist}/generated/package.json +0 -0
  46. /package/{src → dist}/generated/sync_wasm_bg.wasm.d.ts +0 -0
package/README.md CHANGED
@@ -17,6 +17,72 @@ 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
+ ### Query compiler runtimes
28
+
29
+ Every runtime uses the same `orez-sync-cf-host/wasm-module.wasm` import and the same
30
+ `initSync` path. Configure the loader that matches the host:
31
+
32
+ | Host | Configuration | Module value |
33
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
34
+ | Workerd / Wrangler | Map the package's `wasm-module.wasm` export as a compiled Wasm module; do not install the Vite plugin | Cloudflare `CompiledWasm` |
35
+ | Bun | Preload `orez-sync-cf-host/bun-wasm-loader` | `WebAssembly.Module` compiled by the Bun loader |
36
+ | Vite serve / SSR development | Add `orezSyncCfHostWasm()` from `orez-sync-cf-host/vite-wasm-loader` | `WebAssembly.Module` built from package bytes by Vite |
37
+ | Direct Node >= 22.15 | Preload `orez-sync-cf-host/node-wasm-loader` with `NODE_OPTIONS=--import` | `WebAssembly.Module` compiled by the Node loader |
38
+ | Node production bundle | Keep the same Vite plugin active for the production SSR build | `WebAssembly.Module` built from bytes embedded in the bundle |
39
+
40
+ The Vite plugin also keeps `orez-sync-cf-host` inside Vite's SSR pipeline.
41
+
42
+ Wrangler consumers map the bare address rather than a filesystem glob. The
43
+ `.wasm` suffix is part of the public subpath because extension-keyed bundlers
44
+ classify the original import address:
45
+
46
+ ```toml
47
+ [[rules]]
48
+ type = "CompiledWasm"
49
+ globs = ["orez-sync-cf-host/wasm-module.wasm"]
50
+ fallthrough = true
51
+ ```
52
+
53
+ Bun consumers register the preload in `bunfig.toml`:
54
+
55
+ ```toml
56
+ # bunfig.toml
57
+ preload = ["orez-sync-cf-host/bun-wasm-loader"]
58
+ ```
59
+
60
+ For a single command, use
61
+ `bun --preload orez-sync-cf-host/bun-wasm-loader <command>`. The compiler throws
62
+ an error pointing to this runtime matrix if a loader is missing.
63
+
64
+ Direct Node consumers on Node 22.15 or newer preload the synchronous module
65
+ hook. `NODE_OPTIONS` also carries the hook into test-runner child processes:
66
+
67
+ ```sh
68
+ NODE_OPTIONS=--import=orez-sync-cf-host/node-wasm-loader node app.js
69
+ ```
70
+
71
+ Node-targeted Vite consumers add the plugin once and leave it active in serve
72
+ and build:
73
+
74
+ ```ts
75
+ import { orezSyncCfHostWasm } from 'orez-sync-cf-host/vite-wasm-loader'
76
+
77
+ export default {
78
+ plugins: [orezSyncCfHostWasm()],
79
+ }
80
+ ```
81
+
82
+ If one Vite config targets both Node and Workerd, include this plugin only for
83
+ the Node target. The Workerd target uses the `CompiledWasm` mapping above and
84
+ must not run the Vite loader.
85
+
20
86
  ## Wake channel and eviction
21
87
 
22
88
  `GET /<namespace>/wake?clientID=<id>&wakeToken=<capability>` upgrades to a
@@ -50,6 +116,8 @@ every socket attempt, including reconnects, so short-lived tokens are never
50
116
  reused after the wake connection drops:
51
117
 
52
118
  ```ts
119
+ import { ensureHttpPullTransport } from 'orez/zero-http'
120
+
53
121
  ensureHttpPullTransport({
54
122
  origin: syncOrigin,
55
123
  pullIntervalMs: 5_000,
@@ -105,6 +173,24 @@ service or operator capability. Both `authorizeWake` and `authorizeNotify` run
105
173
  before `idFromName`, so rejected requests cannot instantiate namespace Durable
106
174
  Objects.
107
175
 
176
+ ### Consumer routing traps
177
+
178
+ If an outer application router has its own namespace gate, let only `/wake` and
179
+ `/notify` pass that outer gate. The sync worker's required `authorizeWake` and
180
+ `authorizeNotify` callbacks then enforce the real capability checks. Do not
181
+ bypass pull, push, or admin routes, and do not replace either callback with an
182
+ unconditional allow.
183
+
184
+ A delegated push must terminate at the application worker whose registry owns
185
+ the named mutator. Routing it to a sync host, control-plane worker, or another
186
+ application server with a different registry produces an authoritative error
187
+ such as `could not find mutator <name>`. The optimistic client write may appear
188
+ briefly, then disappear on reload and never reach peers. Route client pushes to
189
+ the sync host only when its `mutateBinding` and `mutateUrl` delegate to the
190
+ owning application's mutate endpoint. Preview and browser-local transports must
191
+ stay pointed at their in-process project server unless they provide the same
192
+ delegated route.
193
+
108
194
  `POST /admin/writer` with `{ "enabled": false }` durably stops pushes for that
109
195
  namespace. A stopped writer consumes and discards the request body, returns 503,
110
196
  and performs no engine or application write. Canary rollback drills must stop
@@ -119,12 +205,36 @@ not mutate a production route. These controls are mechanisms, not authorization
119
205
  to perform a production cutover.
120
206
 
121
207
  `POST /admin/resnapshot` is available only when the consumer configured an
122
- upstream data service. It reads that namespace's authoritative `/snapshot`,
123
- atomically replaces the derived application tables, then consumes `/changes`
124
- until caught up. Engine metadata, client last-mutation IDs, operator controls,
125
- and the authoritative upstream database are preserved. The JSON response
126
- includes before/after upstream watermarks and the number of snapshot plus
127
- catch-up rows applied.
208
+ upstream data service. It reads each modeled source table through bounded
209
+ keyset pages and commits them to a staged generation. Progress is durable, so a
210
+ restart resumes at the recorded table and cursor. The host then catches up
211
+ concurrent `/changes`, atomically swaps the generation into place, and bumps the
212
+ engine epoch. Every client performs one expected full resync after cutover.
213
+ Engine metadata, client last-mutation IDs, operator controls, and the
214
+ authoritative upstream database are preserved. The legacy single-response
215
+ `/snapshot` endpoint remains for small datasets and older harnesses.
216
+
217
+ ## Delegated service addresses
218
+
219
+ `mutateBinding` selects the service binding that owns the application's mutate
220
+ endpoint and defaults to `upstream.binding`. Set `mutateOrigin` to an exact
221
+ absolute HTTP(S) origin when that worker's routing depends on the request
222
+ origin. `upstream.namespacePath` may return `/` for a root-mounted data feed;
223
+ internally that root is an empty path, while `null` alone means no upstream path
224
+ is configured.
225
+
226
+ A successful delegated mutation response has a hard causality contract: its
227
+ committed application effects must already be readable from the configured
228
+ upstream `/changes` feed before the application worker returns. The sync host
229
+ then ingests through those effects, journals the acknowledged LMID after them,
230
+ and only then returns the push response. This keeps every capped change-log
231
+ prefix ordered so an acknowledgement cannot reach a client before its effects.
232
+ Chat and Soot use an application-to-data service path that provides this
233
+ ordering today. A future topology that cannot provide it must extend the
234
+ delegated response with an upstream watermark receipt and make the host ingest
235
+ through that receipt before finalization. Do not add a second best-effort path.
236
+ The ordering does not add another ingest round: delegated pushes already waited
237
+ for post-mutation ingest before returning, and now finalize after that wait.
128
238
 
129
239
  ## Counter and HTTP wire representation
130
240
 
@@ -212,4 +322,4 @@ absent when the cleanup command was run (Cloudflare returned error 10090), so no
212
322
  old probe service remains to receive traffic.
213
323
 
214
324
  The rust toolchain is pinned at the workspace root in
215
- [rust-toolchain.toml](/Users/n8/.worktrees/orez-rust-sync/rust-toolchain.toml).
325
+ [`rust-toolchain.toml`](../../rust-toolchain.toml).
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { file, plugin } from 'bun';
4
+ const wasmModuleID = 'orez-sync-cf-host/wasm-module.wasm';
5
+ const wasmModulePath = fileURLToPath(import.meta.resolve(wasmModuleID));
6
+ plugin({
7
+ name: 'orez-sync-cf-host-wasm',
8
+ setup(build) {
9
+ build.onResolve({ filter: /^orez-sync-cf-host\/wasm-module\.wasm$/ }, () => ({
10
+ path: wasmModulePath,
11
+ }));
12
+ build.onLoad({ filter: /sync_wasm_bg\.wasm$/ }, async ({ path }) => {
13
+ const bytes = Buffer.from(await file(path).arrayBuffer()).toString('base64');
14
+ return {
15
+ contents: `export default new WebAssembly.Module(Uint8Array.fromBase64('${bytes}'))`,
16
+ loader: 'js',
17
+ };
18
+ });
19
+ },
20
+ });
@@ -0,0 +1,3 @@
1
+ import type { PullCaps, SyncHostConfig, SyncHostEnv } from './types.js';
2
+ export declare function validatePullCaps(caps: PullCaps): PullCaps;
3
+ export declare function validateSyncHostConfig<Env extends SyncHostEnv>(config: SyncHostConfig<Env>): SyncHostConfig<Env>;
package/dist/config.js ADDED
@@ -0,0 +1,93 @@
1
+ export function validatePullCaps(caps) {
2
+ if (!Number.isSafeInteger(caps.maxChangeRows) || caps.maxChangeRows < 1) {
3
+ throw new TypeError('caps.maxChangeRows must be a positive safe integer');
4
+ }
5
+ return caps;
6
+ }
7
+ export function validateSyncHostConfig(config) {
8
+ if (typeof config.authorizeWake !== 'function') {
9
+ throw new TypeError('sync host config authorizeWake is required');
10
+ }
11
+ if (typeof config.authorizeNotify !== 'function') {
12
+ throw new TypeError('sync host config authorizeNotify is required');
13
+ }
14
+ const hasMutators = config.mutators !== undefined;
15
+ const hasDelegate = config.mutateUrl !== undefined;
16
+ if (hasMutators === hasDelegate) {
17
+ throw new TypeError('sync host config requires exactly one of mutators or mutateUrl');
18
+ }
19
+ if (hasDelegate && !config.upstream) {
20
+ throw new TypeError('sync host config mutateUrl requires upstream');
21
+ }
22
+ if (config.mutateBinding !== undefined && !hasDelegate) {
23
+ throw new TypeError('sync host config mutateBinding requires mutateUrl');
24
+ }
25
+ if (config.mutateOrigin !== undefined && !hasDelegate) {
26
+ throw new TypeError('sync host config mutateOrigin requires mutateUrl');
27
+ }
28
+ if (config.delegatedPushRetry !== undefined && !hasDelegate) {
29
+ throw new TypeError('sync host config delegatedPushRetry requires mutateUrl');
30
+ }
31
+ if (config.mutateBinding !== undefined && !config.mutateBinding) {
32
+ throw new TypeError('sync host config mutateBinding must not be empty');
33
+ }
34
+ if (hasMutators && config.upstream) {
35
+ throw new TypeError('sync host config cannot combine local mutators with upstream ingest');
36
+ }
37
+ if (config.mutateUrl && !config.mutateUrl.startsWith('/')) {
38
+ throw new TypeError('mutateUrl must be an absolute path');
39
+ }
40
+ if (config.mutateOrigin !== undefined) {
41
+ let origin;
42
+ try {
43
+ origin = new URL(config.mutateOrigin);
44
+ }
45
+ catch {
46
+ throw new TypeError('mutateOrigin must be an absolute http(s) origin');
47
+ }
48
+ if ((origin.protocol !== 'http:' && origin.protocol !== 'https:') ||
49
+ origin.origin !== config.mutateOrigin) {
50
+ throw new TypeError('mutateOrigin must be an absolute http(s) origin');
51
+ }
52
+ }
53
+ if (config.upstream) {
54
+ if (!config.upstream.binding)
55
+ throw new TypeError('upstream.binding is required');
56
+ const limit = config.upstream.changeLimit ?? 1_000;
57
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
58
+ throw new TypeError('upstream.changeLimit must be a safe integer in 1..10000');
59
+ }
60
+ const interval = config.upstream.intervalMs ?? 15_000;
61
+ if (!Number.isSafeInteger(interval) || interval < 1_000) {
62
+ throw new TypeError('upstream.intervalMs must be a safe integer >= 1000');
63
+ }
64
+ for (const [name, value] of Object.entries({
65
+ ingestBudgetRows: config.upstream.ingestBudgetRows ?? 150_000,
66
+ ingestBudgetWindowMs: config.upstream.ingestBudgetWindowMs ?? 300_000,
67
+ ingestBackoffMs: config.upstream.ingestBackoffMs ?? 1_000,
68
+ ingestMaxBackoffMs: config.upstream.ingestMaxBackoffMs ?? 60_000,
69
+ })) {
70
+ if (!Number.isSafeInteger(value) || value < 1)
71
+ throw new TypeError(`upstream.${name} must be a positive safe integer`);
72
+ }
73
+ }
74
+ if (config.delegatedPushRetry) {
75
+ for (const [name, value] of Object.entries({
76
+ maxAttempts: config.delegatedPushRetry.maxAttempts ?? 3,
77
+ initialBackoffMs: config.delegatedPushRetry.initialBackoffMs ?? 100,
78
+ maxBackoffMs: config.delegatedPushRetry.maxBackoffMs ?? 1_000,
79
+ timeoutMs: config.delegatedPushRetry.timeoutMs ?? 5_000,
80
+ })) {
81
+ if (!Number.isSafeInteger(value) || value < 1)
82
+ throw new TypeError(`delegatedPushRetry.${name} must be a positive safe integer`);
83
+ }
84
+ }
85
+ if (config.transactionQueryBudget) {
86
+ for (const [name, value] of Object.entries(config.transactionQueryBudget)) {
87
+ if (!Number.isSafeInteger(value) || Number(value) < 1) {
88
+ throw new TypeError(`transactionQueryBudget.${name} must be a positive safe integer`);
89
+ }
90
+ }
91
+ }
92
+ return config;
93
+ }
@@ -37,7 +37,7 @@ export function engine_begin_snapshot_generation(db: any, schema: any, start_wat
37
37
  * Compile a validated Zero query AST for a consumer mutator's transactional
38
38
  * `tx.run(...)`. Execution remains in the host-owned application transaction.
39
39
  */
40
- export function engine_compile_query(schema: any, ast: any): any;
40
+ export function engine_compile_query(schema: any, ast: any, format: any): any;
41
41
 
42
42
  export function engine_finalize(db: any, client_group_id: string, client_id: string, mutation_id: string): void;
43
43
 
@@ -109,7 +109,7 @@ export interface InitOutput {
109
109
  readonly engine_apply_upstream_snapshot: (a: any, b: any, c: any) => [number, number, number];
110
110
  readonly engine_assemble_push_response: (a: any) => [number, number, number];
111
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) => [number, number, number];
112
+ readonly engine_compile_query: (a: any, b: any, c: any) => [number, number, number];
113
113
  readonly engine_finalize: (a: any, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number];
114
114
  readonly engine_finalize_snapshot_generation: (a: any, b: any, c: number, d: number, e: number, f: number) => [number, number, number];
115
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];
@@ -111,10 +111,11 @@ export function engine_begin_snapshot_generation(db, schema, start_watermark) {
111
111
  * `tx.run(...)`. Execution remains in the host-owned application transaction.
112
112
  * @param {any} schema
113
113
  * @param {any} ast
114
+ * @param {any} format
114
115
  * @returns {any}
115
116
  */
116
- export function engine_compile_query(schema, ast) {
117
- 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);
118
119
  if (ret[2]) {
119
120
  throw takeFromExternrefTable0(ret[1]);
120
121
  }
Binary file
package/dist/host.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import type { SyncHostConfig, SyncHostEnv } from './types.js';
3
+ /**
4
+ * Create the consumer-facing Worker router. Authentication happens here; the
5
+ * Durable Object receives only normalized claims over a binding-private header.
6
+ */
7
+ export declare function createSyncWorker<Env extends SyncHostEnv>(config: SyncHostConfig<Env>): ExportedHandler<Env>;
8
+ export interface SyncDurableObjectConstructor<Env extends SyncHostEnv> {
9
+ new (ctx: DurableObjectState, env: Env): DurableObject<Env>;
10
+ }
11
+ /** Create the namespace Durable Object class for one bundled consumer config. */
12
+ export declare function createSyncDurableObject<Env extends SyncHostEnv>(config: SyncHostConfig<Env>): SyncDurableObjectConstructor<Env>;