orez-sync-cf-host 0.11.0 → 0.11.1

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/dist/host.js CHANGED
@@ -1,13 +1,19 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
+ import { createSocketHost } from 'orez-lite/realtime';
2
3
  import { createSyncExecutor } from 'orez-sync-executor/core';
3
4
  import { validatePullCaps, validateSyncHostConfig } from './config.js';
4
5
  import { createQueryCompiler } from './query-compiler.js';
5
6
  import { resolveQueryPatch } from './query-patch.js';
6
7
  import { decodeSqlParams, SqlStorageDirect, SqlStorageMutatorTransaction, SqlStorageSyncDb, } from './sql-storage-adapter.js';
7
- import { engine_apply_snapshot_changes, engine_apply_snapshot_page, engine_apply_upstream, engine_begin_snapshot_generation, engine_finalize, engine_finalize_snapshot_generation, engine_handle_pull, engine_handle_query_pull, engine_init_query_schema, engine_init_schema, engine_invalidate, engine_memory_bytes, engine_preflight, engine_prune, engine_push_validate, engine_read_snapshot_progress, engine_state, engine_version, } from './wasm.js';
8
+ import { engine_authorize_realtime_subscription, engine_apply_snapshot_changes, engine_apply_snapshot_page, engine_apply_upstream, engine_begin_snapshot_generation, engine_finalize, engine_finalize_snapshot_generation, engine_handle_pull, engine_handle_query_pull, engine_init_query_schema, engine_init_schema, engine_invalidate, engine_memory_bytes, engine_preflight, engine_prune, engine_push_validate, engine_read_snapshot_progress, engine_state, engine_version, } from './wasm.js';
8
9
  import { IngestBreakerError, IngestCircuitBreaker, retryDelayMs, shouldRetryDelegatedPush, } from './write-safeguards.js';
9
10
  const NAMESPACE_HEADER = 'x-orez-sync-namespace';
10
11
  const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path';
12
+ // A websocket upgrade is a GET, so the authenticated identity cannot ride the
13
+ // body the way /pull and /push carry their claims. It rides a private header
14
+ // the worker always deletes from the incoming request before setting its own,
15
+ // so a client cannot present one.
16
+ const IDENTITY_HEADER = 'x-orez-sync-identity';
11
17
  const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000;
12
18
  const MIN_SNAPSHOT_PAGE_ROWS = 100;
13
19
  const DEFAULT_CAPS = {
@@ -176,9 +182,19 @@ export function createSyncWorker(config) {
176
182
  return new Response('orez sync-cf-host', { status: 200 });
177
183
  const route = routeAfterNamespace(new URL(request.url).pathname);
178
184
  const isAdmin = route.startsWith('/admin/');
185
+ let wakeUserID = null;
179
186
  if (route === '/wake') {
180
- if (!(await config.authorizeWake(request, env))) {
187
+ const wake = await config.authorizeWake(request, env);
188
+ if (!wake)
181
189
  return json({ error: 'missing wake capability' }, 401);
190
+ if (typeof wake === 'object')
191
+ wakeUserID = wake.userID;
192
+ // A namespace that streams fields authorizes every subscription against
193
+ // this userID, so a capability that does not carry one cannot open the
194
+ // socket. Failing here names the cause; accepting it would produce a
195
+ // wake-only socket whose subscriptions silently never deliver.
196
+ if (config.streamingManifest && !wakeUserID) {
197
+ return json({ error: 'wake capability must identify a user' }, 401);
182
198
  }
183
199
  }
184
200
  else if (route === '/notify') {
@@ -196,8 +212,16 @@ export function createSyncWorker(config) {
196
212
  const headers = new Headers(request.headers);
197
213
  headers.delete(NAMESPACE_HEADER);
198
214
  headers.delete(UPSTREAM_PATH_HEADER);
215
+ headers.delete(IDENTITY_HEADER);
199
216
  let forwardedBody = null;
200
- if (!isAdmin && route !== '/wake' && route !== '/notify') {
217
+ // /wake and /realtime/produce are both websocket upgrades, which cannot
218
+ // carry an Authorization header from a browser and have no body to put
219
+ // claims in. Each has its own capability check above and in the DO, so
220
+ // neither passes through the bearer-token gate below.
221
+ if (!isAdmin &&
222
+ route !== '/wake' &&
223
+ route !== '/notify' &&
224
+ route !== '/realtime/produce') {
201
225
  const claims = await config.authenticate(request, env);
202
226
  if (!claims || typeof claims.userID !== 'string' || claims.userID.length === 0) {
203
227
  return json({ error: 'missing authentication' }, 401);
@@ -215,6 +239,13 @@ export function createSyncWorker(config) {
215
239
  }
216
240
  }
217
241
  }
242
+ // Only the userID travels, because it is the only part the DO must be
243
+ // unable to doubt. The socket also carries a clientID and clientGroupID,
244
+ // but those are the client's own assertion and the engine checks the
245
+ // group against this userID before it will read a single row, so there is
246
+ // nothing gained by moving them here.
247
+ if (wakeUserID)
248
+ headers.set(IDENTITY_HEADER, encodeURIComponent(wakeUserID));
218
249
  headers.set(NAMESPACE_HEADER, await namespaceHash(namespace));
219
250
  if (config.upstream) {
220
251
  const namespacePath = typeof config.upstream.namespacePath === 'function'
@@ -268,6 +299,14 @@ export function createSyncDurableObject(config) {
268
299
  #wakeOrigins = new Set();
269
300
  #wakeRecipients = new Set();
270
301
  #wakePromise = null;
302
+ // Streaming fields. Null when the namespace configures no manifest, which
303
+ // is every wake-only deployment: no hub is built and nothing below runs.
304
+ #realtime = null;
305
+ #realtimeConnections = new Map();
306
+ // Resolves once the sockets from a previous incarnation have been replayed
307
+ // into this hub. Held as a promise rather than a flag so frames arriving
308
+ // during the replay wait for it instead of racing past into an empty hub.
309
+ #realtimeReady = null;
271
310
  #ingestPromise = null;
272
311
  #queryPullLocks = new Map();
273
312
  #recordingIngestBillable = false;
@@ -388,6 +427,14 @@ export function createSyncDurableObject(config) {
388
427
  this.#wakeOrigins.clear();
389
428
  this.#wakeRecipients.clear();
390
429
  this.#wakePromise = null;
430
+ // Real hibernation reconstructs the object, so the hub and every
431
+ // connection handle are gone while the sockets stay open. Modelling
432
+ // that here is what makes rehydration reachable from a test: leaving
433
+ // the hub in place would make the simulation pass for a reason the
434
+ // real runtime never gives it.
435
+ this.#realtime = null;
436
+ this.#realtimeConnections.clear();
437
+ this.#realtimeReady = null;
391
438
  }
392
439
  this.#lastRequestAt = now;
393
440
  }
@@ -1268,17 +1315,117 @@ export function createSyncDurableObject(config) {
1268
1315
  return json({ error: errorMessage(error) }, status);
1269
1316
  }
1270
1317
  }
1318
+ // The hub for this incarnation, built on first use. Null unless the
1319
+ // namespace configures a manifest, so a wake-only deployment never pays for
1320
+ // one and every realtime path below is skipped.
1321
+ #realtimeHost() {
1322
+ const manifest = config.streamingManifest;
1323
+ if (!manifest)
1324
+ return null;
1325
+ if (this.#realtime)
1326
+ return this.#realtime;
1327
+ this.#realtime = createSocketHost({
1328
+ manifest,
1329
+ // Answered from this object's own SQLite, which is the same durable
1330
+ // membership the client's query pull reads. A field is streamed to a
1331
+ // client exactly when the row carrying it is already being synced to
1332
+ // that client, so streaming can never widen what somebody can see.
1333
+ authorizeSubscribe: (identity, topic) => {
1334
+ const result = this.#wasm(() => engine_authorize_realtime_subscription(this.#engineDb, config.schema, identity.clientGroupID, identity.userID, topic.table, topic.key));
1335
+ if (result.authorized)
1336
+ return { status: 'active' };
1337
+ // the group is this user's, but the row is not in its membership
1338
+ // yet. That is the optimistic-row race, not a denial: the client
1339
+ // holds a row from its own unacked mutation, and retries after the
1340
+ // pull that records it.
1341
+ if (result.ownsGroup)
1342
+ return { status: 'pending' };
1343
+ return { status: 'denied', reason: 'row is not in this client group' };
1344
+ },
1345
+ });
1346
+ return this.#realtime;
1347
+ }
1348
+ // Replay the sockets that outlived the previous incarnation. Every open
1349
+ // socket is replayed, not just the one that woke us: a producer's frames
1350
+ // must reach every subscriber of a topic, so restoring only the socket that
1351
+ // happened to send first would silently drop the rest.
1352
+ #realtimeRehydrate(host) {
1353
+ if (this.#realtimeReady)
1354
+ return this.#realtimeReady;
1355
+ this.#realtimeReady = (async () => {
1356
+ const subscribers = [];
1357
+ for (const socket of this.ctx.getWebSockets()) {
1358
+ if (this.#realtimeConnections.has(socket))
1359
+ continue;
1360
+ const attachment = socketAttachment(socket);
1361
+ if (!attachment)
1362
+ continue;
1363
+ if (attachment.producerID) {
1364
+ // Generations are ephemeral by design, so nothing is restored here
1365
+ // beyond the channel itself; the producer opens a new generation
1366
+ // and the durable row covers the gap either way.
1367
+ this.#realtimeConnections.set(socket, host.acceptProducer(socket, attachment.producerID));
1368
+ continue;
1369
+ }
1370
+ if (!attachment.identity)
1371
+ continue;
1372
+ subscribers.push({
1373
+ socket,
1374
+ identity: attachment.identity,
1375
+ connectionID: attachment.identity.clientID,
1376
+ topics: attachment.topics ?? [],
1377
+ });
1378
+ }
1379
+ const restored = await host.rehydrate(subscribers);
1380
+ subscribers.forEach((entry, index) => {
1381
+ this.#realtimeConnections.set(entry.socket, restored[index]);
1382
+ });
1383
+ })();
1384
+ return this.#realtimeReady;
1385
+ }
1386
+ // A socket's topics are the only realtime state that must survive an
1387
+ // eviction, so they are rewritten whenever they change rather than on a
1388
+ // timer: an eviction is not announced.
1389
+ #realtimePersist(socket, connection) {
1390
+ const attachment = socketAttachment(socket);
1391
+ if (!attachment)
1392
+ return;
1393
+ socket.serializeAttachment({
1394
+ ...attachment,
1395
+ topics: [...connection.topics()],
1396
+ });
1397
+ }
1271
1398
  #wake(request) {
1272
1399
  if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
1273
1400
  return json({ error: 'websocket upgrade required' }, 426);
1274
1401
  }
1275
- const clientID = new URL(request.url).searchParams.get('clientID');
1402
+ const params = new URL(request.url).searchParams;
1403
+ const clientID = params.get('clientID');
1276
1404
  if (!clientID)
1277
1405
  return json({ error: 'clientID is required' }, 400);
1278
1406
  const pair = new WebSocketPair();
1279
1407
  const [client, server] = Object.values(pair);
1280
- server.serializeAttachment({ clientID });
1408
+ // The userID is the worker's, taken from the authenticated request. The
1409
+ // group is the client's own claim, and stays a claim: every subscription
1410
+ // is checked against this userID before a row is read, so asserting
1411
+ // someone else's group buys nothing.
1412
+ const encodedUserID = request.headers.get(IDENTITY_HEADER);
1413
+ const clientGroupID = params.get('clientGroupID');
1414
+ const identity = encodedUserID && clientGroupID
1415
+ ? {
1416
+ userID: decodeURIComponent(encodedUserID),
1417
+ clientID,
1418
+ clientGroupID,
1419
+ }
1420
+ : undefined;
1421
+ server.serializeAttachment({ clientID, identity });
1281
1422
  this.ctx.acceptWebSocket(server, [`client:${clientID}`]);
1423
+ if (identity) {
1424
+ const host = this.#realtimeHost();
1425
+ if (host) {
1426
+ this.#realtimeConnections.set(server, host.acceptSubscriber(server, identity, clientID));
1427
+ }
1428
+ }
1282
1429
  // The alarm is only a safety net for an actively connected consumer.
1283
1430
  // A namespace with no wake socket has nobody to notify; its next pull or
1284
1431
  // push ingests synchronously. Arming from construction made every
@@ -1453,6 +1600,9 @@ export function createSyncDurableObject(config) {
1453
1600
  return this.#admin(route, request, upstreamPath);
1454
1601
  if (route === '/wake' && request.method === 'GET')
1455
1602
  return this.#wake(request);
1603
+ if (route === '/realtime/produce' && request.method === 'GET') {
1604
+ return this.#realtimeProduce(request, this.env);
1605
+ }
1456
1606
  if (route === '/notify' && request.method === 'POST') {
1457
1607
  try {
1458
1608
  const applied = await this.#ingest(upstreamPath);
@@ -1504,9 +1654,49 @@ export function createSyncDurableObject(config) {
1504
1654
  }
1505
1655
  }
1506
1656
  }
1507
- webSocketMessage(socket, message) {
1508
- if (message === 'ping')
1657
+ // A producer channel. Publishing is a far stronger capability than waking,
1658
+ // so it needs its own authorization and gets no default: a namespace that
1659
+ // configures no authorizeProduce cannot be published to at all.
1660
+ async #realtimeProduce(request, env) {
1661
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
1662
+ return json({ error: 'websocket upgrade required' }, 426);
1663
+ }
1664
+ const host = this.#realtimeHost();
1665
+ if (!host)
1666
+ return json({ error: 'namespace streams no fields' }, 404);
1667
+ if (!config.authorizeProduce || !(await config.authorizeProduce(request, env))) {
1668
+ return json({ error: 'forbidden' }, 403);
1669
+ }
1670
+ const producerID = new URL(request.url).searchParams.get('producerID') ?? crypto.randomUUID();
1671
+ const pair = new WebSocketPair();
1672
+ const [client, server] = Object.values(pair);
1673
+ server.serializeAttachment({
1674
+ clientID: producerID,
1675
+ producerID,
1676
+ });
1677
+ this.ctx.acceptWebSocket(server, [`producer:${producerID}`]);
1678
+ this.#realtimeConnections.set(server, host.acceptProducer(server, producerID));
1679
+ return new Response(null, { status: 101, webSocket: client });
1680
+ }
1681
+ async webSocketMessage(socket, message) {
1682
+ if (message === 'ping') {
1509
1683
  socket.send('pong');
1684
+ return;
1685
+ }
1686
+ const host = this.#realtimeHost();
1687
+ if (!host || typeof message !== 'string')
1688
+ return;
1689
+ // A cold start left the hub empty while the socket stayed open, so the
1690
+ // subscriptions have to be replayed before this frame is applied.
1691
+ await this.#realtimeRehydrate(host);
1692
+ const connection = this.#realtimeConnections.get(socket);
1693
+ if (!connection)
1694
+ return;
1695
+ connection.handleMessage(message);
1696
+ // The hub may authorize asynchronously, so the topic set this frame
1697
+ // changed is not final until that settles.
1698
+ await Promise.resolve();
1699
+ this.#realtimePersist(socket, connection);
1510
1700
  }
1511
1701
  webSocketClose(socket, code, reason, _wasClean) {
1512
1702
  // The peer already closed, so echo the close to release the socket — but
@@ -1515,10 +1705,22 @@ export function createSyncDurableObject(config) {
1515
1705
  // closes with 1001/1005. An uncaught throw here aborts the DO, so only
1516
1706
  // echo an application-permitted code and otherwise close cleanly.
1517
1707
  const echoable = code === 1000 || (code >= 3000 && code <= 4999);
1708
+ this.#realtimeDrop(socket);
1518
1709
  socketCloseQuietly(socket, echoable ? code : 1000, echoable ? reason : '');
1519
1710
  }
1520
1711
  webSocketError(socket, _error) {
1712
+ this.#realtimeDrop(socket);
1521
1713
  socketCloseQuietly(socket, 1011, 'wake socket error');
1522
1714
  }
1715
+ // Release whatever the socket held: a subscriber's topics, or a producer's
1716
+ // generations. Dropping a producer reveals the durable row to everyone
1717
+ // watching it, so a crashed producer cannot strand a stale overlay.
1718
+ #realtimeDrop(socket) {
1719
+ const connection = this.#realtimeConnections.get(socket);
1720
+ if (!connection)
1721
+ return;
1722
+ this.#realtimeConnections.delete(socket);
1723
+ connection.close();
1724
+ }
1523
1725
  };
1524
1726
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { QueryResolution, QueryResolutionRequest } from './query-patch.js';
2
2
  import type { TransactionQueryBudget } from './transaction-query.js';
3
3
  import type { Schema } from '@rocicorp/zero';
4
+ import type { StreamingManifest } from 'orez-lite/realtime';
4
5
  import type { ExecResult, MutatorRegistry, NormalizedClaims, SqlStatementMetadata, VisibilityConfig } from 'orez-sync-executor';
5
6
  export { visibility, type VisibilityExpression, type VisibilityFilter, type VisibilityOperand, type VisibilityValue, } from './visibility.js';
6
7
  export type { VisibilityConfig } from 'orez-sync-executor';
@@ -90,8 +91,20 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv, S extends Sche
90
91
  authorize(request: Request, claims: NormalizedClaims, namespace: string, env: Env): boolean | Promise<boolean>;
91
92
  /** Authorize the advisory wake socket before selecting a namespace DO.
92
93
  * Browser clients should present a short-lived, namespace-scoped capability
93
- * in the query string because WebSocket cannot set request headers. */
94
- authorizeWake(request: Request, env: Env): boolean | Promise<boolean>;
94
+ * in the query string because WebSocket cannot set request headers.
95
+ *
96
+ * Return `{ userID }` instead of `true` to also identify the socket, which is
97
+ * what a namespace serving `streamingManifest` must do: field subscriptions
98
+ * ride this socket and are authorized against that userID. Returning bare
99
+ * `true` there is refused rather than quietly downgraded to a wake-only
100
+ * socket, because the failure would otherwise look like streaming that just
101
+ * never arrives. The capability is the only credential available here, so the
102
+ * userID belongs inside it. */
103
+ authorizeWake(request: Request, env: Env): boolean | {
104
+ userID: string;
105
+ } | Promise<boolean | {
106
+ userID: string;
107
+ }>;
95
108
  /** Authorize upstream service notifications before selecting a namespace DO. */
96
109
  authorizeNotify(request: Request, env: Env): boolean | Promise<boolean>;
97
110
  /** Resolve the first path component or another consumer-defined namespace. */
@@ -102,6 +115,27 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv, S extends Sche
102
115
  queryAware?: boolean | ((claims: NormalizedClaims) => boolean);
103
116
  /** Resolve every named query in one desired-query patch, in one call. */
104
117
  resolveQueries?: QueryResolver;
118
+ /**
119
+ * Streaming fields for this namespace: which columns may carry a live,
120
+ * uncommitted value, and their publish mode and rate bounds.
121
+ *
122
+ * Supplied as a live object rather than data because a field's `validate` is
123
+ * a function; there is nothing to serialize into a deploy bundle, and the
124
+ * application's own module is where it belongs. Absent means the namespace
125
+ * serves no field subscriptions and rejects producer frames.
126
+ *
127
+ * See docs/streaming-fields.md.
128
+ */
129
+ streamingManifest?: StreamingManifest;
130
+ /**
131
+ * Authorize a producer socket, which may publish a value into any streaming
132
+ * field for any row. That is a much stronger capability than the wake socket
133
+ * or an ordinary client's, so it has no default: leaving this unset means the
134
+ * namespace accepts no producers, and every `/realtime/produce` upgrade is
135
+ * refused. Producers are server-side callers (an AI generation worker, a job
136
+ * runner), so a service binding or a shared secret is the usual check.
137
+ */
138
+ authorizeProduce?: (request: Request, env: Env) => boolean | Promise<boolean>;
105
139
  /** Server-owned invalidation epoch for permission/schema transforms. */
106
140
  queryTransformVersion?: number | ((claims: NormalizedClaims) => number);
107
141
  /** Enable consumer visibility from the first request. Defaults to false for harnesses. */
package/dist/wasm.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare const engine_apply_snapshot_changes: (db: any, schema: any, gener
3
3
  export declare const engine_apply_snapshot_page: (db: any, schema: any, generation: string, table: string, rows: any, next_cursor?: string | null | undefined) => any;
4
4
  export declare const engine_apply_upstream: (db: any, schema: any, batch: any) => any;
5
5
  export declare const engine_assemble_push_response: (results: any) => any;
6
+ export declare const engine_authorize_realtime_subscription: (db: any, schema: any, client_group_id: string, user_id: string, table: string, pk: any) => any;
6
7
  export declare const engine_begin_snapshot_generation: (db: any, schema: any, start_watermark: string) => any;
7
8
  export declare const engine_compile_query: (schema: any, ast: any, format: any) => any;
8
9
  export declare const engine_finalize: (db: any, client_group_id: string, client_id: string, mutation_id: string) => void;
package/dist/wasm.js CHANGED
@@ -20,6 +20,7 @@ export const engine_apply_snapshot_changes = withWasm(generated.engine_apply_sna
20
20
  export const engine_apply_snapshot_page = withWasm(generated.engine_apply_snapshot_page);
21
21
  export const engine_apply_upstream = withWasm(generated.engine_apply_upstream);
22
22
  export const engine_assemble_push_response = withWasm(generated.engine_assemble_push_response);
23
+ export const engine_authorize_realtime_subscription = withWasm(generated.engine_authorize_realtime_subscription);
23
24
  export const engine_begin_snapshot_generation = withWasm(generated.engine_begin_snapshot_generation);
24
25
  export const engine_compile_query = withWasm(generated.engine_compile_query);
25
26
  export const engine_finalize = withWasm(generated.engine_finalize);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -60,7 +60,7 @@
60
60
  "typecheck": "bun run build:dist:platform && tsc --noEmit"
61
61
  },
62
62
  "dependencies": {
63
- "orez-sync-executor": "0.11.0"
63
+ "orez-sync-executor": "0.11.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@cloudflare/workers-types": "4.20260617.1",