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.
- package/README.md +117 -7
- package/dist/bun-wasm-loader.d.ts +1 -0
- package/dist/bun-wasm-loader.js +20 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +93 -0
- package/{src → dist}/generated/sync_wasm.d.ts +2 -2
- package/{src → dist}/generated/sync_wasm.js +3 -2
- package/dist/generated/sync_wasm_bg.wasm +0 -0
- package/dist/host.d.ts +12 -0
- package/dist/host.js +1512 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/mutation-error.d.ts +9 -0
- package/dist/mutation-error.js +23 -0
- package/dist/node-wasm-loader.d.ts +1 -0
- package/dist/node-wasm-loader.js +22 -0
- package/dist/post-commit.d.ts +13 -0
- package/dist/post-commit.js +31 -0
- package/dist/query-compiler.d.ts +4 -0
- package/dist/query-compiler.js +4 -0
- package/dist/sql-storage-adapter.d.ts +69 -0
- package/dist/sql-storage-adapter.js +192 -0
- package/dist/transaction-query.d.ts +72 -0
- package/dist/transaction-query.js +379 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.js +5 -0
- package/dist/vite-wasm-loader.d.ts +3 -0
- package/dist/vite-wasm-loader.js +24 -0
- package/dist/wasm.d.ts +1 -0
- package/dist/wasm.js +7 -0
- package/dist/write-safeguards.d.ts +53 -0
- package/dist/write-safeguards.js +186 -0
- package/package.json +63 -12
- package/src/config.ts +0 -93
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/harness-config.ts +0 -487
- package/src/harness-worker.ts +0 -33
- package/src/host.ts +0 -1948
- package/src/index.ts +0 -22
- package/src/ingest-harness-worker.ts +0 -440
- package/src/platform-probe-worker.ts +0 -305
- package/src/sql-storage-adapter.ts +0 -185
- package/src/types.ts +0 -181
- package/src/write-safeguards.ts +0 -248
- /package/{src → dist}/generated/package.json +0 -0
- /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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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](
|
|
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
|
+
});
|
package/dist/config.d.ts
ADDED
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>;
|