@syncular/server 0.15.23 → 0.15.24

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/storage.d.ts CHANGED
@@ -186,15 +186,23 @@ export interface StorageTransaction {
186
186
  */
187
187
  scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
188
188
  /**
189
- * Serialize candidate-state validation for this partition before any row
190
- * read/write. Required at runtime when `commitValidator` is configured.
189
+ * Serialize every push apply for this partition before any operation read,
190
+ * validation, merge, or write. The push layer re-checks idempotency only
191
+ * after this resolves and retains the lock through terminal-result commit.
192
+ * Missing support fails closed before an app-row mutation.
193
+ */
194
+ lockPartitionForPush?(): Promise<void>;
195
+ /**
196
+ * @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
197
+ * bridge for custom adapters whose existing implementation already locks
198
+ * the complete partition from before candidate reads through commit.
191
199
  */
192
200
  lockPartitionForCommitValidation?(): Promise<void>;
193
201
  /**
194
- * §6.8 rejection finalization while the validation serialization lock is
195
- * still held: discard every candidate write, persist the rejected
196
- * idempotency result, and finish the transaction atomically. Required when
197
- * `commitValidator` is configured so a concurrent duplicate cannot rerun it.
202
+ * Rejection finalization while the push-apply serialization lock is still
203
+ * held: discard every candidate write, persist the rejected idempotency
204
+ * result, and finish the transaction atomically. Required for every push so
205
+ * a concurrent duplicate cannot rerun operations, validators, or merges.
198
206
  */
199
207
  commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
200
208
  upsertRow(table: string, row: StoredRow, context?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.23",
3
+ "version": "0.15.24",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -53,7 +53,7 @@
53
53
  "!dist/**/*.test.d.ts"
54
54
  ],
55
55
  "dependencies": {
56
- "@syncular/core": "0.15.23"
56
+ "@syncular/core": "0.15.24"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -31,11 +31,11 @@
31
31
  * reading `max_commit_seq` live and buffering the `+1` write. Under a single
32
32
  * Worker request this is exact. Two concurrent pushes to one partition need an
33
33
  * external serialization point (normally a per-partition Durable Object); a
34
- * realtime notifier alone does not serialize HTTP writes. A stateless
35
- * HTTP-only deployment SHOULD front same-partition writes with a coordinating
36
- * primitive (a DO or a Queue). For §6.8 whole-commit validation this becomes
37
- * mandatory and fail-closed: the coordinator must explicitly set
38
- * `commitValidationSerialized`, because D1 cannot provide the required lock.
34
+ * realtime notifier alone does not serialize HTTP writes. Every deployment
35
+ * that accepts D1 pushes MUST front same-partition sync rounds with a
36
+ * coordinating primitive (a DO or a Queue). The adapter fails closed unless
37
+ * that coordinator explicitly sets `pushApplySerialized`, because D1 cannot
38
+ * provide the required pre-operation lock.
39
39
  * This mirrors PostgreSQL's per-partition row lock, achieved by placement
40
40
  * rather than a lock D1 does not expose.
41
41
  */
@@ -143,12 +143,12 @@ class D1Transaction implements StorageTransaction {
143
143
  readonly #db: D1Database;
144
144
  readonly #partition: string;
145
145
  readonly #resolveTable: (name: string) => CompiledTable;
146
- readonly #commitValidationSerialized: boolean;
146
+ readonly #pushApplySerialized: boolean;
147
147
  readonly #buffer: BufferedStatement[] = [];
148
148
  #open = true;
149
149
  /** Live snapshot of `max_commit_seq`, advanced within this transaction. */
150
150
  #maxCommitSeq: number | undefined;
151
- #commitValidationCheckpoint: number | undefined;
151
+ #pushApplyCheckpoint: number | undefined;
152
152
  #lastApplicationOpIndex: number | undefined;
153
153
  /**
154
154
  * Read-your-own-writes overlay (§6.2 needs `getRow` to see buffered writes
@@ -161,12 +161,12 @@ class D1Transaction implements StorageTransaction {
161
161
  db: D1Database,
162
162
  partition: string,
163
163
  resolveTable: (name: string) => CompiledTable,
164
- commitValidationSerialized: boolean,
164
+ pushApplySerialized: boolean,
165
165
  ) {
166
166
  this.#db = db;
167
167
  this.#partition = partition;
168
168
  this.#resolveTable = resolveTable;
169
- this.#commitValidationSerialized = commitValidationSerialized;
169
+ this.#pushApplySerialized = pushApplySerialized;
170
170
  }
171
171
 
172
172
  #assertOpen(): void {
@@ -312,16 +312,16 @@ class D1Transaction implements StorageTransaction {
312
312
  .slice(0, query.limit);
313
313
  }
314
314
 
315
- async lockPartitionForCommitValidation(): Promise<void> {
315
+ async lockPartitionForPush(): Promise<void> {
316
316
  this.#assertOpen();
317
- if (!this.#commitValidationSerialized) {
317
+ if (!this.#pushApplySerialized) {
318
318
  throw new Error(
319
- 'D1 whole-commit validation requires externally serialized partition writes',
319
+ 'D1 push apply requires externally serialized partition writes',
320
320
  );
321
321
  }
322
322
  // D1 has no interactive lock. The caller explicitly asserted that every
323
323
  // write for this partition is already serialized (normally by its DO).
324
- this.#commitValidationCheckpoint = this.#buffer.length;
324
+ this.#pushApplyCheckpoint = this.#buffer.length;
325
325
  }
326
326
 
327
327
  async commitRejectedPushResult(
@@ -330,11 +330,9 @@ class D1Transaction implements StorageTransaction {
330
330
  result: StoredPushResult,
331
331
  ): Promise<void> {
332
332
  this.#assertOpen();
333
- const checkpoint = this.#commitValidationCheckpoint;
333
+ const checkpoint = this.#pushApplyCheckpoint;
334
334
  if (checkpoint === undefined) {
335
- throw new Error(
336
- 'whole-commit rejection requires its validation checkpoint',
337
- );
335
+ throw new Error('push rejection requires its apply checkpoint');
338
336
  }
339
337
  this.#buffer.length = checkpoint;
340
338
  this.#pending.clear();
@@ -558,16 +556,22 @@ class D1Transaction implements StorageTransaction {
558
556
 
559
557
  async commit(): Promise<void> {
560
558
  this.#assertOpen();
561
- this.#open = false;
562
- if (this.#buffer.length === 0) return;
559
+ if (this.#buffer.length === 0) {
560
+ this.#open = false;
561
+ return;
562
+ }
563
563
  const statements = this.#buffer.map((entry) =>
564
564
  this.#db.prepare(entry.sql).bind(...entry.params),
565
565
  );
566
566
  // One atomic D1 batch — the §6.4 all-or-nothing commit.
567
567
  try {
568
568
  await this.#db.batch(statements);
569
+ this.#open = false;
569
570
  } catch (error) {
570
571
  if (isD1ConstraintError(error)) {
572
+ // D1 batches are atomic. Keep this logical transaction open so the
573
+ // push layer can discard its buffered candidates and persist the
574
+ // terminal rejection while the external partition queue is retained.
571
575
  throw new StorageConstraintError(error, this.#lastApplicationOpIndex);
572
576
  }
573
577
  throw error;
@@ -590,23 +594,30 @@ const D1_MAX_BIND_PARAMS = 100;
590
594
  export interface D1ServerStorageOptions {
591
595
  /**
592
596
  * Assert that all writes for a partition reach this storage serially.
593
- * Required for §6.8 because D1 exposes no interactive transaction lock.
594
- * Set this only inside a per-partition Durable Object or equivalent
595
- * coordinator; the default fails closed when a commit validator is used.
597
+ * Required for every push because D1 exposes no interactive transaction
598
+ * lock. Set this only inside an explicit per-partition request queue,
599
+ * Durable Object, or equivalent coordinator; the default fails closed.
600
+ */
601
+ readonly pushApplySerialized?: boolean;
602
+ /**
603
+ * @deprecated Use `pushApplySerialized`. This alias remains valid only
604
+ * because the old assertion already promised that every partition write,
605
+ * not merely validator callbacks, was externally serialized.
596
606
  */
597
607
  readonly commitValidationSerialized?: boolean;
598
608
  }
599
609
 
600
610
  export class D1ServerStorage implements ServerStorage {
601
611
  readonly #db: D1Database;
602
- readonly #commitValidationSerialized: boolean;
612
+ readonly #pushApplySerialized: boolean;
603
613
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
604
614
  #tables: ReadonlyMap<string, CompiledTable> | undefined;
605
615
  #schemaVersion: number | undefined;
606
616
 
607
617
  constructor(db: D1Database, options: D1ServerStorageOptions = {}) {
608
618
  this.#db = db;
609
- this.#commitValidationSerialized =
619
+ this.#pushApplySerialized =
620
+ options.pushApplySerialized === true ||
610
621
  options.commitValidationSerialized === true;
611
622
  }
612
623
 
@@ -769,7 +780,7 @@ export class D1ServerStorage implements ServerStorage {
769
780
  this.#db,
770
781
  partition,
771
782
  (name) => this.table(name),
772
- this.#commitValidationSerialized,
783
+ this.#pushApplySerialized,
773
784
  );
774
785
  }
775
786
 
@@ -439,7 +439,7 @@ class PostgresTransaction implements StorageTransaction {
439
439
  #partition: string;
440
440
  #resolveTable: (name: string) => CompiledTable;
441
441
  #open = true;
442
- #commitValidationSavepoint = false;
442
+ #pushApplySavepoint = false;
443
443
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
444
444
  #resolve: () => void;
445
445
  #reject: (error: unknown) => void;
@@ -521,7 +521,7 @@ class PostgresTransaction implements StorageTransaction {
521
521
  );
522
522
  }
523
523
 
524
- async lockPartitionForCommitValidation(): Promise<void> {
524
+ async lockPartitionForPush(): Promise<void> {
525
525
  this.#assertOpen();
526
526
  await this.#client.query(
527
527
  `INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
@@ -532,8 +532,8 @@ class PostgresTransaction implements StorageTransaction {
532
532
  'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE',
533
533
  [this.#partition],
534
534
  );
535
- await this.#client.query('SAVEPOINT syncular_commit_validation_candidate');
536
- this.#commitValidationSavepoint = true;
535
+ await this.#client.query('SAVEPOINT syncular_push_candidate');
536
+ this.#pushApplySavepoint = true;
537
537
  }
538
538
 
539
539
  async commitRejectedPushResult(
@@ -542,18 +542,12 @@ class PostgresTransaction implements StorageTransaction {
542
542
  result: StoredPushResult,
543
543
  ): Promise<void> {
544
544
  this.#assertOpen();
545
- if (!this.#commitValidationSavepoint) {
546
- throw new Error(
547
- 'whole-commit rejection requires its validation savepoint',
548
- );
545
+ if (!this.#pushApplySavepoint) {
546
+ throw new Error('push rejection requires its apply savepoint');
549
547
  }
550
- await this.#client.query(
551
- 'ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate',
552
- );
553
- await this.#client.query(
554
- 'RELEASE SAVEPOINT syncular_commit_validation_candidate',
555
- );
556
- this.#commitValidationSavepoint = false;
548
+ await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
549
+ await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
550
+ this.#pushApplySavepoint = false;
557
551
  await this.putPushResult(clientId, clientCommitId, result);
558
552
  await this.commit();
559
553
  }
package/src/push.ts CHANGED
@@ -806,35 +806,6 @@ function idempotencyCacheMissFrame(
806
806
  };
807
807
  }
808
808
 
809
- async function persistRejectedPushResult(
810
- storage: SyncRequestContext['storage'],
811
- partition: string,
812
- clientId: string,
813
- clientCommitId: string,
814
- stored: StoredPushResult,
815
- ): Promise<{ readonly stored: StoredPushResult; readonly replayed: boolean }> {
816
- const rejectionTx = await storage.begin(partition);
817
- try {
818
- await rejectionTx.putPushResult(clientId, clientCommitId, stored);
819
- await rejectionTx.commit();
820
- } catch (error) {
821
- await rejectionTx.rollback();
822
- throw error;
823
- }
824
- const canonical = await storage.getPushResult(
825
- partition,
826
- clientId,
827
- clientCommitId,
828
- );
829
- if (canonical === undefined) {
830
- throw new Error('push rejection finalization did not persist an outcome');
831
- }
832
- return {
833
- stored: canonical,
834
- replayed: canonical.cacheIdentity !== stored.cacheIdentity,
835
- };
836
- }
837
-
838
809
  export interface AppliedCommitEvent {
839
810
  readonly commit: StoredCommit;
840
811
  }
@@ -899,48 +870,49 @@ export async function processPushCommitWithTrace(
899
870
  const validators = ctx.validators;
900
871
  const commitValidator = ctx.commitValidator;
901
872
  const tx = await storage.begin(partition);
873
+ const lockPartitionForPush =
874
+ tx.lockPartitionForPush?.bind(tx) ??
875
+ tx.lockPartitionForCommitValidation?.bind(tx);
902
876
  const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
903
877
  try {
904
- if (commitValidator !== undefined) {
905
- if (
906
- tx.lockPartitionForCommitValidation === undefined ||
907
- commitRejectedPushResult === undefined
908
- ) {
909
- throw new Error(
910
- 'storage transaction does not support atomic whole-commit validation finalization',
911
- );
912
- }
913
- await tx.lockPartitionForCommitValidation();
914
- // The optimistic lookup above may have raced another request for the
915
- // same idempotency key. Re-check after acquiring partition serialization
916
- // so a concurrent duplicate never reruns the aggregate validator.
917
- try {
918
- const serializedPersisted = await storage.getPushResult(
919
- partition,
920
- clientId,
878
+ if (
879
+ lockPartitionForPush === undefined ||
880
+ commitRejectedPushResult === undefined
881
+ ) {
882
+ throw new Error(
883
+ 'storage transaction does not support serialized push apply and atomic rejection finalization',
884
+ );
885
+ }
886
+ await lockPartitionForPush();
887
+ // The optimistic lookup above may have raced another delivery. Re-check
888
+ // only after acquiring partition serialization and before any operation
889
+ // read, validation, merge, or staged write.
890
+ try {
891
+ const serializedPersisted = await storage.getPushResult(
892
+ partition,
893
+ clientId,
894
+ frame.clientCommitId,
895
+ );
896
+ if (serializedPersisted !== undefined) {
897
+ await tx.rollback();
898
+ return processedPushCommit(
921
899
  frame.clientCommitId,
900
+ serializedPersisted,
901
+ true,
922
902
  );
923
- if (serializedPersisted !== undefined) {
924
- await tx.rollback();
925
- return processedPushCommit(
926
- frame.clientCommitId,
927
- serializedPersisted,
928
- true,
929
- );
930
- }
931
- } catch (error) {
932
- if (
933
- error instanceof SyncError &&
934
- error.code === 'sync.idempotency_cache_miss'
935
- ) {
936
- await tx.rollback();
937
- return {
938
- frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
939
- replayed: false,
940
- };
941
- }
942
- throw error;
943
903
  }
904
+ } catch (error) {
905
+ if (
906
+ error instanceof SyncError &&
907
+ error.code === 'sync.idempotency_cache_miss'
908
+ ) {
909
+ await tx.rollback();
910
+ return {
911
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
912
+ replayed: false,
913
+ };
914
+ }
915
+ throw error;
944
916
  }
945
917
  const results: PushOperationResult[] = [];
946
918
  const changes: NewChange[] = [];
@@ -993,32 +965,24 @@ export async function processPushCommitWithTrace(
993
965
  status: 'rejected',
994
966
  results: [terminated],
995
967
  });
996
- if (commitValidator !== undefined) {
997
- // Discard candidate rows and persist the rejection while retaining the
998
- // same partition lock. This closes the duplicate-request race between
999
- // rollback and the durable idempotency outcome.
1000
- if (commitRejectedPushResult === undefined) {
1001
- throw new Error(
1002
- 'storage transaction lost whole-commit rejection finalization support',
1003
- );
1004
- }
1005
- await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
1006
- } else {
1007
- await tx.rollback();
1008
- const canonical = await persistRejectedPushResult(
1009
- storage,
1010
- partition,
1011
- clientId,
1012
- frame.clientCommitId,
1013
- stored,
1014
- );
1015
- return processedPushCommit(
1016
- frame.clientCommitId,
1017
- canonical.stored,
1018
- canonical.replayed,
968
+ // Discard candidates and persist the rejection while retaining the same
969
+ // partition lock. There is no unlock gap in which a duplicate can rerun.
970
+ await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
971
+ const canonical = await storage.getPushResult(
972
+ partition,
973
+ clientId,
974
+ frame.clientCommitId,
975
+ );
976
+ if (canonical === undefined) {
977
+ throw new Error(
978
+ 'push rejection finalization did not persist an outcome',
1019
979
  );
1020
980
  }
1021
- return processedPushCommit(frame.clientCommitId, stored, false);
981
+ return processedPushCommit(
982
+ frame.clientCommitId,
983
+ canonical,
984
+ canonical.cacheIdentity !== stored.cacheIdentity,
985
+ );
1022
986
  }
1023
987
 
1024
988
  const commitSeq = await tx.appendCommit({
@@ -1045,7 +1009,6 @@ export async function processPushCommitWithTrace(
1045
1009
  }
1046
1010
  return processedPushCommit(frame.clientCommitId, stored, false);
1047
1011
  } catch (error) {
1048
- await tx.rollback();
1049
1012
  if (error instanceof StorageConstraintError) {
1050
1013
  const stored = newStoredPushResult(createdAtMs, {
1051
1014
  status: 'rejected',
@@ -1059,19 +1022,30 @@ export async function processPushCommitWithTrace(
1059
1022
  },
1060
1023
  ],
1061
1024
  });
1062
- const canonical = await persistRejectedPushResult(
1063
- storage,
1025
+ if (commitRejectedPushResult === undefined) {
1026
+ await tx.rollback();
1027
+ throw new Error(
1028
+ 'storage transaction lost atomic push rejection finalization support',
1029
+ );
1030
+ }
1031
+ await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
1032
+ const canonical = await storage.getPushResult(
1064
1033
  partition,
1065
1034
  clientId,
1066
1035
  frame.clientCommitId,
1067
- stored,
1068
1036
  );
1037
+ if (canonical === undefined) {
1038
+ throw new Error(
1039
+ 'push rejection finalization did not persist an outcome',
1040
+ );
1041
+ }
1069
1042
  return processedPushCommit(
1070
1043
  frame.clientCommitId,
1071
- canonical.stored,
1072
- canonical.replayed,
1044
+ canonical,
1045
+ canonical.cacheIdentity !== stored.cacheIdentity,
1073
1046
  );
1074
1047
  }
1048
+ await tx.rollback();
1075
1049
  throw error;
1076
1050
  }
1077
1051
  }
package/src/realtime.ts CHANGED
@@ -30,53 +30,26 @@ import {
30
30
  type ScopeMap,
31
31
  type WakeReason,
32
32
  } from '@syncular/core';
33
- import type {
34
- LeaseConfig,
35
- ResolveScopes,
36
- ServerLimits,
37
- SyncRequestContext,
38
- } from './context';
33
+ import type { SyncRequestContext, SyncServerConfig } from './context';
39
34
  import { RESOLVER_OUTAGE } from './context';
40
35
  import { SyncError, syncError } from './errors';
41
36
  import { emitEvent, type SyncularServerEvents } from './events';
42
37
  import { createSyncResponseStream } from './handler';
43
- import type { ServerSchema } from './schema';
44
38
  import { type CompiledSchema, compileSchema } from './schema';
45
39
  import {
46
40
  computeEffective,
47
41
  matchesEffective,
48
42
  type ResolvedScopes,
49
43
  } from './scopes';
50
- import type { SegmentStore } from './segment-store';
51
- import type { SegmentUrlConfig } from './signed-url';
52
44
  import type { ServerStorage, StoredCommit } from './storage';
53
- import type { CommitValidator, ValidatorRegistry } from './validate';
54
45
 
55
- export interface RealtimeHubConfig {
56
- readonly schema: ServerSchema;
57
- readonly storage: ServerStorage;
58
- readonly resolveScopes: ResolveScopes;
59
- /** §6.7 validators used by sync rounds carried over this socket. */
60
- readonly validators?: ValidatorRegistry;
61
- /** §6.8 whole-commit validator shared with HTTP sync rounds. */
62
- readonly commitValidator?: CommitValidator;
63
- readonly clock?: () => number;
46
+ /**
47
+ * Realtime adds fanout/presence tuning to the canonical sync-server config;
48
+ * socket rounds must never have a narrower push/pull capability set than HTTP.
49
+ */
50
+ export interface RealtimeHubConfig extends Omit<SyncServerConfig, 'realtime'> {
64
51
  /** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
65
52
  readonly maxDeltaBytes?: number;
66
- /** Optional structured-events sink (`realtime.*` events). */
67
- readonly events?: SyncularServerEvents;
68
- /**
69
- * Segment store for sync rounds over the socket (§8.7). Without it a
70
- * socket round fails loudly with an in-band ERROR — provide the same
71
- * store the HTTP binding uses (one handler, two framings).
72
- */
73
- readonly segments?: SegmentStore;
74
- /** Request limits for socket rounds; defaults match the HTTP binding. */
75
- readonly limits?: Partial<ServerLimits>;
76
- readonly signedUrls?: SegmentUrlConfig;
77
- /** §7.3 auth leases for socket sync rounds (§8.7) — same config the
78
- * HTTP binding uses, so rounds over the socket are lease-aware too. */
79
- readonly leases?: LeaseConfig;
80
53
  /**
81
54
  * §8.6 presence: cap on the serialized size (bytes) of a published
82
55
  * presence document. An over-cap publish is rejected loudly to the
@@ -494,9 +467,11 @@ export class RealtimeSession {
494
467
  * round's request byte stream (§8.7). Synchronous entry — assembly and
495
468
  * violation detection happen inline so a pipelined chunk arriving
496
469
  * while a response streams is caught deterministically; the round
497
- * itself runs async once the request is complete.
470
+ * itself runs async once the request is complete. The returned promise, when
471
+ * present, resolves only after response streaming and registration refresh;
472
+ * coordinated hosts await it to retain their partition FIFO through commit.
498
473
  */
499
- handleBinary(bytes: Uint8Array): void {
474
+ handleBinary(bytes: Uint8Array): Promise<void> | undefined {
500
475
  if (bytes.length === 0) return;
501
476
  if (bytes[0] !== REALTIME_TAG_ROUND) {
502
477
  // §8.7: client→server tags other than 0x01 — a broken client.
@@ -531,7 +506,7 @@ export class RealtimeSession {
531
506
  }
532
507
  const token = Symbol('round');
533
508
  this.#activeRound = token;
534
- void this.#runRound(done.message.slice(), token);
509
+ return this.#runRound(done.message.slice(), token);
535
510
  }
536
511
 
537
512
  /** Drive the shared handler and stream the response back (§8.7). */
@@ -914,7 +889,10 @@ export class RealtimeHub {
914
889
  * the same shape the HTTP adapter builds, so the round drives the
915
890
  * SAME handler with zero semantic divergence.
916
891
  */
917
- requestContext(session: RealtimeSession): SyncRequestContext {
892
+ requestContextFor(identity: {
893
+ readonly partition: string;
894
+ readonly actorId: string;
895
+ }): SyncRequestContext {
918
896
  const segments = this.#config.segments;
919
897
  if (segments === undefined) {
920
898
  // Fail loud (§8.7): a hub serving socket rounds needs the same
@@ -925,12 +903,21 @@ export class RealtimeHub {
925
903
  );
926
904
  }
927
905
  return {
928
- partition: session.partition,
929
- actorId: session.actorId,
906
+ partition: identity.partition,
907
+ actorId: identity.actorId,
930
908
  schema: this.#config.schema,
931
909
  storage: this.#config.storage,
932
910
  segments,
933
911
  resolveScopes: this.#config.resolveScopes,
912
+ ...(this.#config.blobs !== undefined
913
+ ? { blobs: this.#config.blobs }
914
+ : {}),
915
+ ...(this.#config.maxBlobBytes !== undefined
916
+ ? { maxBlobBytes: this.#config.maxBlobBytes }
917
+ : {}),
918
+ ...(this.#config.crdtMergers !== undefined
919
+ ? { crdtMergers: this.#config.crdtMergers }
920
+ : {}),
934
921
  ...(this.#config.validators !== undefined
935
922
  ? { validators: this.#config.validators }
936
923
  : {}),
@@ -946,6 +933,15 @@ export class RealtimeHub {
946
933
  ...(this.#config.signedUrls !== undefined
947
934
  ? { signedUrls: this.#config.signedUrls }
948
935
  : {}),
936
+ ...(this.#config.blobSignedUrls !== undefined
937
+ ? { blobSignedUrls: this.#config.blobSignedUrls }
938
+ : {}),
939
+ ...(this.#config.blobUploadUrls !== undefined
940
+ ? { blobUploadUrls: this.#config.blobUploadUrls }
941
+ : {}),
942
+ ...(this.#config.sqliteImageBuilder !== undefined
943
+ ? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
944
+ : {}),
949
945
  ...(this.#config.leases !== undefined
950
946
  ? { leases: this.#config.leases }
951
947
  : {}),
@@ -956,6 +952,10 @@ export class RealtimeHub {
956
952
  };
957
953
  }
958
954
 
955
+ requestContext(session: RealtimeSession): SyncRequestContext {
956
+ return this.requestContextFor(session);
957
+ }
958
+
959
959
  /**
960
960
  * Register a connected socket (§8.1): load the client's last pull's
961
961
  * subscription list, resolve + intersect scopes, send `hello`.