@syncular/server 0.15.23 → 0.15.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ The supported set, and what deliberately does **not** get an adapter:
19
19
  | Runtime | Adapter | Transport | Storage | Status |
20
20
  | --- | --- | --- | --- | --- |
21
21
  | **Bun / Node** | `@syncular/server-hono` | HTTP (`POST /sync`, segments, blobs) **+ WS realtime** (§8, host-driven upgrade) | any: `SqliteServerStorage`, `PostgresServerStorage`, memory | **Supported now** — the reference deployment; runs the full conformance catalog on both bindings. |
22
- | **Cloudflare Workers** | `@syncular/server-workers` | HTTP binding via Hono (Workers-native) **+ WS realtime** (§8, Durable Object host with hibernation) | `D1ServerStorage` (D1); R2-as-S3 for segments/blobs (§5.4 delegated presign) | **Supported now** — this rung. Realtime rides a **Durable Object** (`SyncularRealtimeDO`), opt-in; HTTP-only is also fully conformant (below). |
22
+ | **Cloudflare Workers** | `@syncular/server-workers` | HTTP binding via Hono (Workers-native) **+ optional WS realtime** (§8) | `D1ServerStorage` behind one per-partition Durable Object queue; R2-as-S3 for segments/blobs | **Supported now** — D1 sync writes always traverse the DO; WebSocket upgrades remain optional. |
23
23
  | Raw Deno / edge-misc | — | — | — | **Not adapted** (policy below). |
24
24
 
25
25
  **The policy for "not adapted".** Untested ≠ unsupported forever. The core
@@ -30,23 +30,20 @@ HTTP via the fetch-handler round-trip tests), and we do not claim runtimes we
30
30
  do not test. Deno is a plausible future adapter the day someone runs the
31
31
  catalog on it; until then it is neutral-core-friendly, not supported.
32
32
 
33
- **Workers realtime — the Durable Object.** SPEC §1.1's two bindings are two
34
- framings of one handler; an **HTTP-only deployment is fully conformant**
35
- (clients that cannot open the socket sync over `POST /sync`, identical
36
- semantics a smaller complete deployment, not a degraded one), so realtime on
37
- Workers is opt-in. When enabled it rides a **Durable Object**
38
- (`SyncularRealtimeDO`): one DO per partition hosting the `RealtimeHub` (the DO
39
- id derived from the partition, so a partition's sockets and its commit fan-out
40
- are co-located and single-threaded — also the natural per-partition write
41
- serialization point the D1 storage wants); WebSocket **hibernation** so idle
42
- sockets don't bill wall time (the existing `RealtimeSession` is the
33
+ **Workers coordination and realtime — the Durable Object.** HTTP-only is fully
34
+ conformant at the protocol level, but D1 still requires a per-partition Durable
35
+ Object queue for every `/sync` round that may push. WebSocket upgrades are
36
+ optional; the write coordinator is not. `SyncularRealtimeDO` hosts that FIFO
37
+ and, when realtime is enabled, the `RealtimeHub`. The DO id derives from the
38
+ partition, so unrelated partitions remain concurrent. WebSocket
39
+ **hibernation** keeps idle sockets from billing wall time (the existing
40
+ `RealtimeSession` is the
43
41
  per-connection state machine, driven from the hibernation callbacks and
44
42
  rehydrated from a minimal socket attachment + the D1 client record on wake);
45
43
  storage via the same **D1** binding so realtime rounds and `POST /sync` rounds
46
44
  share one commit log and one segment store; commit fan-out (§8.2) runs in-DO
47
- (no LISTEN/NOTIFY needed — writes and sockets are co-located), and an HTTP push
48
- landing in a plain isolate wakes the partition's DO (the in-platform
49
- LISTEN/NOTIFY analogue). Full shape, wiring, hibernation semantics, and the
45
+ (no LISTEN/NOTIFY needed — writes and sockets are co-located). Full shape,
46
+ wiring, hibernation semantics, and the
50
47
  manual real-workerd smoke recipe in `@syncular/server-workers/README.md`.
51
48
 
52
49
  **No relay (decision).** There is deliberately no *relay* — no bridge that
@@ -162,12 +159,13 @@ re-checks idempotency after taking that lock and persists a rejected outcome
162
159
  while the lock is retained, so overlapping duplicate deliveries cannot rerun
163
160
  the callback.
164
161
 
165
- SQLite and PostgreSQL provide that serialization directly. D1 does not expose
166
- an interactive lock: `D1ServerStorage` fails closed unless it is constructed
167
- inside an external per-partition coordinator with
168
- `{ commitValidationSerialized: true }` (normally one Durable Object per
169
- partition). Do not set that assertion on a stateless Worker. Custom storages
170
- must implement both the transaction lock and candidate scan seam.
162
+ SQLite and PostgreSQL provide that serialization directly for every push. D1
163
+ does not expose an interactive lock: `D1ServerStorage` fails closed unless it
164
+ is constructed inside an explicit per-partition coordinator with
165
+ `{ pushApplySerialized: true }` (normally the packaged Durable Object FIFO).
166
+ Do not set that assertion on a stateless Worker. Custom storages must implement
167
+ the pre-operation partition lock, locked idempotency re-check, atomic rejection
168
+ finalization, and—when `commitValidator` is used—candidate scans.
171
169
 
172
170
  Whole-commit validation checks a client-proposed commit; it does not grant
173
171
  authority. Privileged operations such as connecting facilities still belong in
@@ -16,9 +16,15 @@ export interface D1Database {
16
16
  export interface D1ServerStorageOptions {
17
17
  /**
18
18
  * Assert that all writes for a partition reach this storage serially.
19
- * Required for §6.8 because D1 exposes no interactive transaction lock.
20
- * Set this only inside a per-partition Durable Object or equivalent
21
- * coordinator; the default fails closed when a commit validator is used.
19
+ * Required for every push because D1 exposes no interactive transaction
20
+ * lock. Set this only inside an explicit per-partition request queue,
21
+ * Durable Object, or equivalent coordinator; the default fails closed.
22
+ */
23
+ readonly pushApplySerialized?: boolean;
24
+ /**
25
+ * @deprecated Use `pushApplySerialized`. This alias remains valid only
26
+ * because the old assertion already promised that every partition write,
27
+ * not merely validator callbacks, was externally serialized.
22
28
  */
23
29
  readonly commitValidationSerialized?: boolean;
24
30
  }
@@ -32,11 +32,11 @@ var _a;
32
32
  * reading `max_commit_seq` live and buffering the `+1` write. Under a single
33
33
  * Worker request this is exact. Two concurrent pushes to one partition need an
34
34
  * external serialization point (normally a per-partition Durable Object); a
35
- * realtime notifier alone does not serialize HTTP writes. A stateless
36
- * HTTP-only deployment SHOULD front same-partition writes with a coordinating
37
- * primitive (a DO or a Queue). For §6.8 whole-commit validation this becomes
38
- * mandatory and fail-closed: the coordinator must explicitly set
39
- * `commitValidationSerialized`, because D1 cannot provide the required lock.
35
+ * realtime notifier alone does not serialize HTTP writes. Every deployment
36
+ * that accepts D1 pushes MUST front same-partition sync rounds with a
37
+ * coordinating primitive (a DO or a Queue). The adapter fails closed unless
38
+ * that coordinator explicitly sets `pushApplySerialized`, because D1 cannot
39
+ * provide the required pre-operation lock.
40
40
  * This mirrors PostgreSQL's per-partition row lock, achieved by placement
41
41
  * rather than a lock D1 does not expose.
42
42
  */
@@ -58,12 +58,12 @@ class D1Transaction {
58
58
  #db;
59
59
  #partition;
60
60
  #resolveTable;
61
- #commitValidationSerialized;
61
+ #pushApplySerialized;
62
62
  #buffer = [];
63
63
  #open = true;
64
64
  /** Live snapshot of `max_commit_seq`, advanced within this transaction. */
65
65
  #maxCommitSeq;
66
- #commitValidationCheckpoint;
66
+ #pushApplyCheckpoint;
67
67
  #lastApplicationOpIndex;
68
68
  /**
69
69
  * Read-your-own-writes overlay (§6.2 needs `getRow` to see buffered writes
@@ -71,11 +71,11 @@ class D1Transaction {
71
71
  * `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
72
72
  */
73
73
  #pending = new Map();
74
- constructor(db, partition, resolveTable, commitValidationSerialized) {
74
+ constructor(db, partition, resolveTable, pushApplySerialized) {
75
75
  this.#db = db;
76
76
  this.#partition = partition;
77
77
  this.#resolveTable = resolveTable;
78
- this.#commitValidationSerialized = commitValidationSerialized;
78
+ this.#pushApplySerialized = pushApplySerialized;
79
79
  }
80
80
  #assertOpen() {
81
81
  if (!this.#open)
@@ -188,20 +188,20 @@ class D1Transaction {
188
188
  .sort((left, right) => left.rowId.localeCompare(right.rowId))
189
189
  .slice(0, query.limit);
190
190
  }
191
- async lockPartitionForCommitValidation() {
191
+ async lockPartitionForPush() {
192
192
  this.#assertOpen();
193
- if (!this.#commitValidationSerialized) {
194
- throw new Error('D1 whole-commit validation requires externally serialized partition writes');
193
+ if (!this.#pushApplySerialized) {
194
+ throw new Error('D1 push apply requires externally serialized partition writes');
195
195
  }
196
196
  // D1 has no interactive lock. The caller explicitly asserted that every
197
197
  // write for this partition is already serialized (normally by its DO).
198
- this.#commitValidationCheckpoint = this.#buffer.length;
198
+ this.#pushApplyCheckpoint = this.#buffer.length;
199
199
  }
200
200
  async commitRejectedPushResult(clientId, clientCommitId, result) {
201
201
  this.#assertOpen();
202
- const checkpoint = this.#commitValidationCheckpoint;
202
+ const checkpoint = this.#pushApplyCheckpoint;
203
203
  if (checkpoint === undefined) {
204
- throw new Error('whole-commit rejection requires its validation checkpoint');
204
+ throw new Error('push rejection requires its apply checkpoint');
205
205
  }
206
206
  this.#buffer.length = checkpoint;
207
207
  this.#pending.clear();
@@ -352,16 +352,21 @@ class D1Transaction {
352
352
  }
353
353
  async commit() {
354
354
  this.#assertOpen();
355
- this.#open = false;
356
- if (this.#buffer.length === 0)
355
+ if (this.#buffer.length === 0) {
356
+ this.#open = false;
357
357
  return;
358
+ }
358
359
  const statements = this.#buffer.map((entry) => this.#db.prepare(entry.sql).bind(...entry.params));
359
360
  // One atomic D1 batch — the §6.4 all-or-nothing commit.
360
361
  try {
361
362
  await this.#db.batch(statements);
363
+ this.#open = false;
362
364
  }
363
365
  catch (error) {
364
366
  if (isD1ConstraintError(error)) {
367
+ // D1 batches are atomic. Keep this logical transaction open so the
368
+ // push layer can discard its buffered candidates and persist the
369
+ // terminal rejection while the external partition queue is retained.
365
370
  throw new StorageConstraintError(error, this.#lastApplicationOpIndex);
366
371
  }
367
372
  throw error;
@@ -382,14 +387,15 @@ _a = D1Transaction;
382
387
  const D1_MAX_BIND_PARAMS = 100;
383
388
  export class D1ServerStorage {
384
389
  #db;
385
- #commitValidationSerialized;
390
+ #pushApplySerialized;
386
391
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
387
392
  #tables;
388
393
  #schemaVersion;
389
394
  constructor(db, options = {}) {
390
395
  this.#db = db;
391
- this.#commitValidationSerialized =
392
- options.commitValidationSerialized === true;
396
+ this.#pushApplySerialized =
397
+ options.pushApplySerialized === true ||
398
+ options.commitValidationSerialized === true;
393
399
  }
394
400
  /** Apply the schema DDL (idempotent). Call once before use. */
395
401
  async migrate() {
@@ -512,7 +518,7 @@ export class D1ServerStorage {
512
518
  }
513
519
  }
514
520
  async begin(partition) {
515
- return new D1Transaction(this.#db, partition, (name) => this.table(name), this.#commitValidationSerialized);
521
+ return new D1Transaction(this.#db, partition, (name) => this.table(name), this.#pushApplySerialized);
516
522
  }
517
523
  async getMaxCommitSeq(partition) {
518
524
  const row = await this.#db
@@ -245,7 +245,7 @@ class PostgresTransaction {
245
245
  #partition;
246
246
  #resolveTable;
247
247
  #open = true;
248
- #commitValidationSavepoint = false;
248
+ #pushApplySavepoint = false;
249
249
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
250
250
  #resolve;
251
251
  #reject;
@@ -309,22 +309,22 @@ class PostgresTransaction {
309
309
  this.#assertOpen();
310
310
  return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
311
311
  }
312
- async lockPartitionForCommitValidation() {
312
+ async lockPartitionForPush() {
313
313
  this.#assertOpen();
314
314
  await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
315
315
  ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
316
316
  await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
317
- await this.#client.query('SAVEPOINT syncular_commit_validation_candidate');
318
- this.#commitValidationSavepoint = true;
317
+ await this.#client.query('SAVEPOINT syncular_push_candidate');
318
+ this.#pushApplySavepoint = true;
319
319
  }
320
320
  async commitRejectedPushResult(clientId, clientCommitId, result) {
321
321
  this.#assertOpen();
322
- if (!this.#commitValidationSavepoint) {
323
- throw new Error('whole-commit rejection requires its validation savepoint');
322
+ if (!this.#pushApplySavepoint) {
323
+ throw new Error('push rejection requires its apply savepoint');
324
324
  }
325
- await this.#client.query('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
326
- await this.#client.query('RELEASE SAVEPOINT syncular_commit_validation_candidate');
327
- this.#commitValidationSavepoint = false;
325
+ await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
326
+ await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
327
+ this.#pushApplySavepoint = false;
328
328
  await this.putPushResult(clientId, clientCommitId, result);
329
329
  await this.commit();
330
330
  }
package/dist/push.js CHANGED
@@ -501,25 +501,6 @@ function idempotencyCacheMissFrame(clientCommitId, error) {
501
501
  ],
502
502
  };
503
503
  }
504
- async function persistRejectedPushResult(storage, partition, clientId, clientCommitId, stored) {
505
- const rejectionTx = await storage.begin(partition);
506
- try {
507
- await rejectionTx.putPushResult(clientId, clientCommitId, stored);
508
- await rejectionTx.commit();
509
- }
510
- catch (error) {
511
- await rejectionTx.rollback();
512
- throw error;
513
- }
514
- const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
515
- if (canonical === undefined) {
516
- throw new Error('push rejection finalization did not persist an outcome');
517
- }
518
- return {
519
- stored: canonical,
520
- replayed: canonical.cacheIdentity !== stored.cacheIdentity,
521
- };
522
- }
523
504
  /**
524
505
  * Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
525
506
  * atomic apply (§6.4), realtime notification for applied commits.
@@ -559,35 +540,35 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
559
540
  const validators = ctx.validators;
560
541
  const commitValidator = ctx.commitValidator;
561
542
  const tx = await storage.begin(partition);
543
+ const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
544
+ tx.lockPartitionForCommitValidation?.bind(tx);
562
545
  const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
563
546
  try {
564
- if (commitValidator !== undefined) {
565
- if (tx.lockPartitionForCommitValidation === undefined ||
566
- commitRejectedPushResult === undefined) {
567
- throw new Error('storage transaction does not support atomic whole-commit validation finalization');
568
- }
569
- await tx.lockPartitionForCommitValidation();
570
- // The optimistic lookup above may have raced another request for the
571
- // same idempotency key. Re-check after acquiring partition serialization
572
- // so a concurrent duplicate never reruns the aggregate validator.
573
- try {
574
- const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
575
- if (serializedPersisted !== undefined) {
576
- await tx.rollback();
577
- return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
578
- }
547
+ if (lockPartitionForPush === undefined ||
548
+ commitRejectedPushResult === undefined) {
549
+ throw new Error('storage transaction does not support serialized push apply and atomic rejection finalization');
550
+ }
551
+ await lockPartitionForPush();
552
+ // The optimistic lookup above may have raced another delivery. Re-check
553
+ // only after acquiring partition serialization and before any operation
554
+ // read, validation, merge, or staged write.
555
+ try {
556
+ const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
557
+ if (serializedPersisted !== undefined) {
558
+ await tx.rollback();
559
+ return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
579
560
  }
580
- catch (error) {
581
- if (error instanceof SyncError &&
582
- error.code === 'sync.idempotency_cache_miss') {
583
- await tx.rollback();
584
- return {
585
- frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
586
- replayed: false,
587
- };
588
- }
589
- throw error;
561
+ }
562
+ catch (error) {
563
+ if (error instanceof SyncError &&
564
+ error.code === 'sync.idempotency_cache_miss') {
565
+ await tx.rollback();
566
+ return {
567
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
568
+ replayed: false,
569
+ };
590
570
  }
571
+ throw error;
591
572
  }
592
573
  const results = [];
593
574
  const changes = [];
@@ -620,21 +601,14 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
620
601
  status: 'rejected',
621
602
  results: [terminated],
622
603
  });
623
- if (commitValidator !== undefined) {
624
- // Discard candidate rows and persist the rejection while retaining the
625
- // same partition lock. This closes the duplicate-request race between
626
- // rollback and the durable idempotency outcome.
627
- if (commitRejectedPushResult === undefined) {
628
- throw new Error('storage transaction lost whole-commit rejection finalization support');
629
- }
630
- await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
631
- }
632
- else {
633
- await tx.rollback();
634
- const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
635
- return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
604
+ // Discard candidates and persist the rejection while retaining the same
605
+ // partition lock. There is no unlock gap in which a duplicate can rerun.
606
+ await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
607
+ const canonical = await storage.getPushResult(partition, clientId, frame.clientCommitId);
608
+ if (canonical === undefined) {
609
+ throw new Error('push rejection finalization did not persist an outcome');
636
610
  }
637
- return processedPushCommit(frame.clientCommitId, stored, false);
611
+ return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
638
612
  }
639
613
  const commitSeq = await tx.appendCommit({
640
614
  clientId,
@@ -661,7 +635,6 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
661
635
  return processedPushCommit(frame.clientCommitId, stored, false);
662
636
  }
663
637
  catch (error) {
664
- await tx.rollback();
665
638
  if (error instanceof StorageConstraintError) {
666
639
  const stored = newStoredPushResult(createdAtMs, {
667
640
  status: 'rejected',
@@ -675,9 +648,18 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
675
648
  },
676
649
  ],
677
650
  });
678
- const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
679
- return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
651
+ if (commitRejectedPushResult === undefined) {
652
+ await tx.rollback();
653
+ throw new Error('storage transaction lost atomic push rejection finalization support');
654
+ }
655
+ await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
656
+ const canonical = await storage.getPushResult(partition, clientId, frame.clientCommitId);
657
+ if (canonical === undefined) {
658
+ throw new Error('push rejection finalization did not persist an outcome');
659
+ }
660
+ return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
680
661
  }
662
+ await tx.rollback();
681
663
  throw error;
682
664
  }
683
665
  }
@@ -14,38 +14,16 @@
14
14
  * the `sync` wake-up (§8.3).
15
15
  */
16
16
  import { type PresenceKind, type ScopeMap, type WakeReason } from '@syncular/core';
17
- import type { LeaseConfig, ResolveScopes, ServerLimits, SyncRequestContext } from './context.js';
17
+ import type { SyncRequestContext, SyncServerConfig } from './context.js';
18
18
  import { type SyncularServerEvents } from './events.js';
19
- import type { ServerSchema } from './schema.js';
20
- import type { SegmentStore } from './segment-store.js';
21
- import type { SegmentUrlConfig } from './signed-url.js';
22
19
  import type { ServerStorage, StoredCommit } from './storage.js';
23
- import type { CommitValidator, ValidatorRegistry } from './validate.js';
24
- export interface RealtimeHubConfig {
25
- readonly schema: ServerSchema;
26
- readonly storage: ServerStorage;
27
- readonly resolveScopes: ResolveScopes;
28
- /** §6.7 validators used by sync rounds carried over this socket. */
29
- readonly validators?: ValidatorRegistry;
30
- /** §6.8 whole-commit validator shared with HTTP sync rounds. */
31
- readonly commitValidator?: CommitValidator;
32
- readonly clock?: () => number;
20
+ /**
21
+ * Realtime adds fanout/presence tuning to the canonical sync-server config;
22
+ * socket rounds must never have a narrower push/pull capability set than HTTP.
23
+ */
24
+ export interface RealtimeHubConfig extends Omit<SyncServerConfig, 'realtime'> {
33
25
  /** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
34
26
  readonly maxDeltaBytes?: number;
35
- /** Optional structured-events sink (`realtime.*` events). */
36
- readonly events?: SyncularServerEvents;
37
- /**
38
- * Segment store for sync rounds over the socket (§8.7). Without it a
39
- * socket round fails loudly with an in-band ERROR — provide the same
40
- * store the HTTP binding uses (one handler, two framings).
41
- */
42
- readonly segments?: SegmentStore;
43
- /** Request limits for socket rounds; defaults match the HTTP binding. */
44
- readonly limits?: Partial<ServerLimits>;
45
- readonly signedUrls?: SegmentUrlConfig;
46
- /** §7.3 auth leases for socket sync rounds (§8.7) — same config the
47
- * HTTP binding uses, so rounds over the socket are lease-aware too. */
48
- readonly leases?: LeaseConfig;
49
27
  /**
50
28
  * §8.6 presence: cap on the serialized size (bytes) of a published
51
29
  * presence document. An over-cap publish is rejected loudly to the
@@ -149,9 +127,11 @@ export declare class RealtimeSession {
149
127
  * round's request byte stream (§8.7). Synchronous entry — assembly and
150
128
  * violation detection happen inline so a pipelined chunk arriving
151
129
  * while a response streams is caught deterministically; the round
152
- * itself runs async once the request is complete.
130
+ * itself runs async once the request is complete. The returned promise, when
131
+ * present, resolves only after response streaming and registration refresh;
132
+ * coordinated hosts await it to retain their partition FIFO through commit.
153
133
  */
154
- handleBinary(bytes: Uint8Array): void;
134
+ handleBinary(bytes: Uint8Array): Promise<void> | undefined;
155
135
  sendHeartbeat(): void;
156
136
  sendWake(reason: WakeReason): void;
157
137
  /** Called by the hub for every applied commit, in commitSeq order. */
@@ -186,6 +166,10 @@ export declare class RealtimeHub {
186
166
  * the same shape the HTTP adapter builds, so the round drives the
187
167
  * SAME handler with zero semantic divergence.
188
168
  */
169
+ requestContextFor(identity: {
170
+ readonly partition: string;
171
+ readonly actorId: string;
172
+ }): SyncRequestContext;
189
173
  requestContext(session: RealtimeSession): SyncRequestContext;
190
174
  /**
191
175
  * Register a connected socket (§8.1): load the client's last pull's
package/dist/realtime.js CHANGED
@@ -327,7 +327,9 @@ export class RealtimeSession {
327
327
  * round's request byte stream (§8.7). Synchronous entry — assembly and
328
328
  * violation detection happen inline so a pipelined chunk arriving
329
329
  * while a response streams is caught deterministically; the round
330
- * itself runs async once the request is complete.
330
+ * itself runs async once the request is complete. The returned promise, when
331
+ * present, resolves only after response streaming and registration refresh;
332
+ * coordinated hosts await it to retain their partition FIFO through commit.
331
333
  */
332
334
  handleBinary(bytes) {
333
335
  if (bytes.length === 0)
@@ -365,7 +367,7 @@ export class RealtimeSession {
365
367
  }
366
368
  const token = Symbol('round');
367
369
  this.#activeRound = token;
368
- void this.#runRound(done.message.slice(), token);
370
+ return this.#runRound(done.message.slice(), token);
369
371
  }
370
372
  /** Drive the shared handler and stream the response back (§8.7). */
371
373
  async #runRound(requestBytes, token) {
@@ -702,7 +704,7 @@ export class RealtimeHub {
702
704
  * the same shape the HTTP adapter builds, so the round drives the
703
705
  * SAME handler with zero semantic divergence.
704
706
  */
705
- requestContext(session) {
707
+ requestContextFor(identity) {
706
708
  const segments = this.#config.segments;
707
709
  if (segments === undefined) {
708
710
  // Fail loud (§8.7): a hub serving socket rounds needs the same
@@ -710,12 +712,21 @@ export class RealtimeHub {
710
712
  throw syncError('sync.invalid_request', 'socket sync rounds require a segment store on the realtime hub (§8.7)');
711
713
  }
712
714
  return {
713
- partition: session.partition,
714
- actorId: session.actorId,
715
+ partition: identity.partition,
716
+ actorId: identity.actorId,
715
717
  schema: this.#config.schema,
716
718
  storage: this.#config.storage,
717
719
  segments,
718
720
  resolveScopes: this.#config.resolveScopes,
721
+ ...(this.#config.blobs !== undefined
722
+ ? { blobs: this.#config.blobs }
723
+ : {}),
724
+ ...(this.#config.maxBlobBytes !== undefined
725
+ ? { maxBlobBytes: this.#config.maxBlobBytes }
726
+ : {}),
727
+ ...(this.#config.crdtMergers !== undefined
728
+ ? { crdtMergers: this.#config.crdtMergers }
729
+ : {}),
719
730
  ...(this.#config.validators !== undefined
720
731
  ? { validators: this.#config.validators }
721
732
  : {}),
@@ -731,6 +742,15 @@ export class RealtimeHub {
731
742
  ...(this.#config.signedUrls !== undefined
732
743
  ? { signedUrls: this.#config.signedUrls }
733
744
  : {}),
745
+ ...(this.#config.blobSignedUrls !== undefined
746
+ ? { blobSignedUrls: this.#config.blobSignedUrls }
747
+ : {}),
748
+ ...(this.#config.blobUploadUrls !== undefined
749
+ ? { blobUploadUrls: this.#config.blobUploadUrls }
750
+ : {}),
751
+ ...(this.#config.sqliteImageBuilder !== undefined
752
+ ? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
753
+ : {}),
734
754
  ...(this.#config.leases !== undefined
735
755
  ? { leases: this.#config.leases }
736
756
  : {}),
@@ -740,6 +760,9 @@ export class RealtimeHub {
740
760
  realtime: this,
741
761
  };
742
762
  }
763
+ requestContext(session) {
764
+ return this.requestContextFor(session);
765
+ }
743
766
  /**
744
767
  * Register a connected socket (§8.1): load the client's last pull's
745
768
  * subscription list, resolve + intersect scopes, send `hello`.
@@ -17,10 +17,12 @@ class SqliteTransaction {
17
17
  #storage;
18
18
  #partition;
19
19
  #open = true;
20
- #commitValidationSavepoint = false;
21
- constructor(storage, partition) {
20
+ #pushApplySavepoint = false;
21
+ #release;
22
+ constructor(storage, partition, release) {
22
23
  this.#storage = storage;
23
24
  this.#partition = partition;
25
+ this.#release = release;
24
26
  storage.db.exec('BEGIN IMMEDIATE');
25
27
  }
26
28
  #assertOpen() {
@@ -39,20 +41,20 @@ class SqliteTransaction {
39
41
  this.#assertOpen();
40
42
  return this.#storage.scanRowsByIndex(this.#partition, query);
41
43
  }
42
- async lockPartitionForCommitValidation() {
44
+ async lockPartitionForPush() {
43
45
  this.#assertOpen();
44
46
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
45
- this.#storage.db.exec('SAVEPOINT syncular_commit_validation_candidate');
46
- this.#commitValidationSavepoint = true;
47
+ this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
48
+ this.#pushApplySavepoint = true;
47
49
  }
48
50
  async commitRejectedPushResult(clientId, clientCommitId, result) {
49
51
  this.#assertOpen();
50
- if (!this.#commitValidationSavepoint) {
51
- throw new Error('whole-commit rejection requires its validation savepoint');
52
+ if (!this.#pushApplySavepoint) {
53
+ throw new Error('push rejection requires its apply savepoint');
52
54
  }
53
- this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
54
- this.#storage.db.exec('RELEASE SAVEPOINT syncular_commit_validation_candidate');
55
- this.#commitValidationSavepoint = false;
55
+ this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_push_candidate');
56
+ this.#storage.db.exec('RELEASE SAVEPOINT syncular_push_candidate');
57
+ this.#pushApplySavepoint = false;
56
58
  await this.putPushResult(clientId, clientCommitId, result);
57
59
  await this.commit();
58
60
  }
@@ -113,17 +115,29 @@ class SqliteTransaction {
113
115
  async commit() {
114
116
  this.#assertOpen();
115
117
  this.#open = false;
116
- this.#storage.db.exec('COMMIT');
118
+ try {
119
+ this.#storage.db.exec('COMMIT');
120
+ }
121
+ finally {
122
+ this.#release();
123
+ }
117
124
  }
118
125
  async rollback() {
119
126
  if (!this.#open)
120
127
  return;
121
128
  this.#open = false;
122
- this.#storage.db.exec('ROLLBACK');
129
+ try {
130
+ this.#storage.db.exec('ROLLBACK');
131
+ }
132
+ finally {
133
+ this.#release();
134
+ }
123
135
  }
124
136
  }
125
137
  export class SqliteServerStorage {
126
138
  db;
139
+ /** One bun:sqlite connection can own only one transaction at a time. */
140
+ #transactionTail = Promise.resolve();
127
141
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
128
142
  #tables;
129
143
  #schemaVersion;
@@ -237,7 +251,19 @@ export class SqliteServerStorage {
237
251
  }
238
252
  }
239
253
  async begin(partition) {
240
- return new SqliteTransaction(this, partition);
254
+ const previous = this.#transactionTail;
255
+ let release;
256
+ this.#transactionTail = new Promise((resolve) => {
257
+ release = resolve;
258
+ });
259
+ await previous;
260
+ try {
261
+ return new SqliteTransaction(this, partition, release);
262
+ }
263
+ catch (error) {
264
+ release();
265
+ throw error;
266
+ }
241
267
  }
242
268
  /** Internal: write a row + refresh its scope-index entries. */
243
269
  writeRow(partition, table, row) {