@syncular/server 0.15.22 → 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/src/handler.ts CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  type SubscriptionPlan,
45
45
  subscriptionSection,
46
46
  } from './pull';
47
- import { processPushCommit } from './push';
47
+ import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
48
48
  import type { CompiledSchema } from './schema';
49
49
  import { compileSchema } from './schema';
50
50
  import { computeEffective, type ResolvedScopes } from './scopes';
@@ -339,8 +339,9 @@ function emitPushEvent(
339
339
  ctx: SyncRequestContext,
340
340
  clientId: string,
341
341
  push: PushCommitFrame,
342
- frame: PushResultFrame,
342
+ processed: ProcessedPushCommit,
343
343
  ): void {
344
+ const { frame } = processed;
344
345
  const base = {
345
346
  atMs: clockOf(ctx)(),
346
347
  partition: ctx.partition,
@@ -354,7 +355,7 @@ function emitPushEvent(
354
355
  type: 'push.applied',
355
356
  ...base,
356
357
  ...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
357
- replay: frame.status === 'cached',
358
+ replay: processed.replayed,
358
359
  });
359
360
  return;
360
361
  }
@@ -365,6 +366,13 @@ function emitPushEvent(
365
366
  type: 'push.conflicted',
366
367
  ...base,
367
368
  opIndex: record.opIndex,
369
+ replay: processed.replayed,
370
+ ...(processed.recordedAtMs !== undefined
371
+ ? { recordedAtMs: processed.recordedAtMs }
372
+ : {}),
373
+ ...(processed.cacheIdentity !== undefined
374
+ ? { cacheIdentity: processed.cacheIdentity }
375
+ : {}),
368
376
  });
369
377
  return;
370
378
  }
@@ -376,6 +384,13 @@ function emitPushEvent(
376
384
  ? record.code
377
385
  : 'sync.invalid_request',
378
386
  opIndex: record?.opIndex ?? 0,
387
+ replay: processed.replayed,
388
+ ...(processed.recordedAtMs !== undefined
389
+ ? { recordedAtMs: processed.recordedAtMs }
390
+ : {}),
391
+ ...(processed.cacheIdentity !== undefined
392
+ ? { cacheIdentity: processed.cacheIdentity }
393
+ : {}),
379
394
  });
380
395
  }
381
396
 
@@ -423,15 +438,16 @@ async function* streamResponse(
423
438
  try {
424
439
  // Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
425
440
  for (const push of plan.pushes) {
426
- const frame = await processPushCommit(
441
+ const processed = await processPushCommitWithTrace(
427
442
  ctx,
428
443
  schema,
429
444
  plan.resolved,
430
445
  plan.header.clientId,
431
446
  push,
432
447
  );
448
+ const { frame } = processed;
433
449
  if (events !== undefined) {
434
- emitPushEvent(events, ctx, plan.header.clientId, push, frame);
450
+ emitPushEvent(events, ctx, plan.header.clientId, push, processed);
435
451
  }
436
452
  yield encodeResponseFrame(frame);
437
453
  const details = pushResultDetailsFrame(frame);
@@ -193,6 +193,12 @@ function serializePushResult(result: StoredPushResult): unknown {
193
193
  return {
194
194
  status: result.status,
195
195
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
196
+ ...(result.recordedAtMs !== undefined
197
+ ? { recordedAtMs: result.recordedAtMs }
198
+ : {}),
199
+ ...(result.cacheIdentity !== undefined
200
+ ? { cacheIdentity: result.cacheIdentity }
201
+ : {}),
196
202
  results: result.results.map((record) => {
197
203
  if (record.status === 'conflict') {
198
204
  return {
@@ -223,6 +229,8 @@ function deserializePushResult(value: unknown): StoredPushResult {
223
229
  const parsed = value as {
224
230
  status: 'applied' | 'rejected';
225
231
  commitSeq?: number;
232
+ recordedAtMs?: number;
233
+ cacheIdentity?: string;
226
234
  results: SerializedResult[];
227
235
  };
228
236
  const results: PushOperationResult[] = parsed.results.map((record) => {
@@ -251,6 +259,12 @@ function deserializePushResult(value: unknown): StoredPushResult {
251
259
  return {
252
260
  status: parsed.status,
253
261
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
262
+ ...(parsed.recordedAtMs !== undefined
263
+ ? { recordedAtMs: parsed.recordedAtMs }
264
+ : {}),
265
+ ...(parsed.cacheIdentity !== undefined
266
+ ? { cacheIdentity: parsed.cacheIdentity }
267
+ : {}),
254
268
  results,
255
269
  };
256
270
  }
@@ -425,7 +439,7 @@ class PostgresTransaction implements StorageTransaction {
425
439
  #partition: string;
426
440
  #resolveTable: (name: string) => CompiledTable;
427
441
  #open = true;
428
- #commitValidationSavepoint = false;
442
+ #pushApplySavepoint = false;
429
443
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
430
444
  #resolve: () => void;
431
445
  #reject: (error: unknown) => void;
@@ -507,7 +521,7 @@ class PostgresTransaction implements StorageTransaction {
507
521
  );
508
522
  }
509
523
 
510
- async lockPartitionForCommitValidation(): Promise<void> {
524
+ async lockPartitionForPush(): Promise<void> {
511
525
  this.#assertOpen();
512
526
  await this.#client.query(
513
527
  `INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
@@ -518,8 +532,8 @@ class PostgresTransaction implements StorageTransaction {
518
532
  'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE',
519
533
  [this.#partition],
520
534
  );
521
- await this.#client.query('SAVEPOINT syncular_commit_validation_candidate');
522
- this.#commitValidationSavepoint = true;
535
+ await this.#client.query('SAVEPOINT syncular_push_candidate');
536
+ this.#pushApplySavepoint = true;
523
537
  }
524
538
 
525
539
  async commitRejectedPushResult(
@@ -528,18 +542,12 @@ class PostgresTransaction implements StorageTransaction {
528
542
  result: StoredPushResult,
529
543
  ): Promise<void> {
530
544
  this.#assertOpen();
531
- if (!this.#commitValidationSavepoint) {
532
- throw new Error(
533
- 'whole-commit rejection requires its validation savepoint',
534
- );
545
+ if (!this.#pushApplySavepoint) {
546
+ throw new Error('push rejection requires its apply savepoint');
535
547
  }
536
- await this.#client.query(
537
- 'ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate',
538
- );
539
- await this.#client.query(
540
- 'RELEASE SAVEPOINT syncular_commit_validation_candidate',
541
- );
542
- 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;
543
551
  await this.putPushResult(clientId, clientCommitId, result);
544
552
  await this.commit();
545
553
  }
package/src/push.ts CHANGED
@@ -750,6 +750,42 @@ function resultFrame(
750
750
  };
751
751
  }
752
752
 
753
+ export interface ProcessedPushCommit {
754
+ readonly frame: PushResultFrame;
755
+ /** True when this request observed an already-recorded idempotency outcome. */
756
+ readonly replayed: boolean;
757
+ readonly recordedAtMs?: number;
758
+ readonly cacheIdentity?: string;
759
+ }
760
+
761
+ function processedPushCommit(
762
+ clientCommitId: string,
763
+ stored: StoredPushResult,
764
+ replayed: boolean,
765
+ ): ProcessedPushCommit {
766
+ return {
767
+ frame: resultFrame(clientCommitId, stored, replayed),
768
+ replayed,
769
+ ...(stored.recordedAtMs !== undefined
770
+ ? { recordedAtMs: stored.recordedAtMs }
771
+ : {}),
772
+ ...(stored.cacheIdentity !== undefined
773
+ ? { cacheIdentity: stored.cacheIdentity }
774
+ : {}),
775
+ };
776
+ }
777
+
778
+ function newStoredPushResult(
779
+ recordedAtMs: number,
780
+ result: Omit<StoredPushResult, 'recordedAtMs' | 'cacheIdentity'>,
781
+ ): StoredPushResult {
782
+ return {
783
+ ...result,
784
+ recordedAtMs,
785
+ cacheIdentity: crypto.randomUUID(),
786
+ };
787
+ }
788
+
753
789
  function idempotencyCacheMissFrame(
754
790
  clientCommitId: string,
755
791
  error: SyncError,
@@ -770,32 +806,6 @@ function idempotencyCacheMissFrame(
770
806
  };
771
807
  }
772
808
 
773
- async function persistRejectedPushResult(
774
- storage: SyncRequestContext['storage'],
775
- partition: string,
776
- clientId: string,
777
- clientCommitId: string,
778
- stored: StoredPushResult,
779
- ): Promise<StoredPushResult> {
780
- const rejectionTx = await storage.begin(partition);
781
- try {
782
- await rejectionTx.putPushResult(clientId, clientCommitId, stored);
783
- await rejectionTx.commit();
784
- } catch (error) {
785
- await rejectionTx.rollback();
786
- throw error;
787
- }
788
- const canonical = await storage.getPushResult(
789
- partition,
790
- clientId,
791
- clientCommitId,
792
- );
793
- if (canonical === undefined) {
794
- throw new Error('push rejection finalization did not persist an outcome');
795
- }
796
- return canonical;
797
- }
798
-
799
809
  export interface AppliedCommitEvent {
800
810
  readonly commit: StoredCommit;
801
811
  }
@@ -811,6 +821,23 @@ export async function processPushCommit(
811
821
  clientId: string,
812
822
  frame: PushCommitFrame,
813
823
  ): Promise<PushResultFrame> {
824
+ return (
825
+ await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)
826
+ ).frame;
827
+ }
828
+
829
+ /**
830
+ * Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
831
+ * rejected replays as `status: rejected`; this companion result preserves the
832
+ * cache provenance needed by structured events and server helpers.
833
+ */
834
+ export async function processPushCommitWithTrace(
835
+ ctx: SyncRequestContext,
836
+ schema: CompiledSchema,
837
+ resolved: ResolvedScopes,
838
+ clientId: string,
839
+ frame: PushCommitFrame,
840
+ ): Promise<ProcessedPushCommit> {
814
841
  const { storage, partition } = ctx;
815
842
  let persisted: StoredPushResult | undefined;
816
843
  try {
@@ -826,12 +853,15 @@ export async function processPushCommit(
826
853
  ) {
827
854
  // §6.3: answer the retryable cache-miss for this commit rather than
828
855
  // re-applying. Not persisted — a retry may find a readable record.
829
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
856
+ return {
857
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
858
+ replayed: false,
859
+ };
830
860
  }
831
861
  throw error;
832
862
  }
833
863
  if (persisted !== undefined) {
834
- return resultFrame(frame.clientCommitId, persisted, true);
864
+ return processedPushCommit(frame.clientCommitId, persisted, true);
835
865
  }
836
866
 
837
867
  const createdAtMs = clockOf(ctx)();
@@ -840,41 +870,49 @@ export async function processPushCommit(
840
870
  const validators = ctx.validators;
841
871
  const commitValidator = ctx.commitValidator;
842
872
  const tx = await storage.begin(partition);
873
+ const lockPartitionForPush =
874
+ tx.lockPartitionForPush?.bind(tx) ??
875
+ tx.lockPartitionForCommitValidation?.bind(tx);
843
876
  const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
844
877
  try {
845
- if (commitValidator !== undefined) {
846
- if (
847
- tx.lockPartitionForCommitValidation === undefined ||
848
- commitRejectedPushResult === undefined
849
- ) {
850
- throw new Error(
851
- 'storage transaction does not support atomic whole-commit validation finalization',
852
- );
853
- }
854
- await tx.lockPartitionForCommitValidation();
855
- // The optimistic lookup above may have raced another request for the
856
- // same idempotency key. Re-check after acquiring partition serialization
857
- // so a concurrent duplicate never reruns the aggregate validator.
858
- try {
859
- const serializedPersisted = await storage.getPushResult(
860
- partition,
861
- 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(
862
899
  frame.clientCommitId,
900
+ serializedPersisted,
901
+ true,
863
902
  );
864
- if (serializedPersisted !== undefined) {
865
- await tx.rollback();
866
- return resultFrame(frame.clientCommitId, serializedPersisted, true);
867
- }
868
- } catch (error) {
869
- if (
870
- error instanceof SyncError &&
871
- error.code === 'sync.idempotency_cache_miss'
872
- ) {
873
- await tx.rollback();
874
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
875
- }
876
- throw error;
877
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;
878
916
  }
879
917
  const results: PushOperationResult[] = [];
880
918
  const changes: NewChange[] = [];
@@ -923,36 +961,28 @@ export async function processPushCommit(
923
961
  if (terminated !== undefined) {
924
962
  // §6.3 rejected: only the terminating operation's record; §6.4:
925
963
  // every write of the commit rolls back.
926
- const stored: StoredPushResult = {
964
+ const stored = newStoredPushResult(createdAtMs, {
927
965
  status: 'rejected',
928
966
  results: [terminated],
929
- };
930
- if (commitValidator !== undefined) {
931
- // Discard candidate rows and persist the rejection while retaining the
932
- // same partition lock. This closes the duplicate-request race between
933
- // rollback and the durable idempotency outcome.
934
- if (commitRejectedPushResult === undefined) {
935
- throw new Error(
936
- 'storage transaction lost whole-commit rejection finalization support',
937
- );
938
- }
939
- await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
940
- } else {
941
- await tx.rollback();
942
- const canonical = await persistRejectedPushResult(
943
- storage,
944
- partition,
945
- clientId,
946
- frame.clientCommitId,
947
- stored,
948
- );
949
- return resultFrame(
950
- frame.clientCommitId,
951
- canonical,
952
- canonical !== stored,
967
+ });
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',
953
979
  );
954
980
  }
955
- return resultFrame(frame.clientCommitId, stored, false);
981
+ return processedPushCommit(
982
+ frame.clientCommitId,
983
+ canonical,
984
+ canonical.cacheIdentity !== stored.cacheIdentity,
985
+ );
956
986
  }
957
987
 
958
988
  const commitSeq = await tx.appendCommit({
@@ -962,7 +992,11 @@ export async function processPushCommit(
962
992
  createdAtMs,
963
993
  changes,
964
994
  });
965
- const stored: StoredPushResult = { status: 'applied', commitSeq, results };
995
+ const stored = newStoredPushResult(createdAtMs, {
996
+ status: 'applied',
997
+ commitSeq,
998
+ results,
999
+ });
966
1000
  await tx.putPushResult(clientId, frame.clientCommitId, stored);
967
1001
  await tx.commit();
968
1002
  if (ctx.realtime !== undefined && changes.length > 0) {
@@ -973,11 +1007,10 @@ export async function processPushCommit(
973
1007
  changes,
974
1008
  });
975
1009
  }
976
- return resultFrame(frame.clientCommitId, stored, false);
1010
+ return processedPushCommit(frame.clientCommitId, stored, false);
977
1011
  } catch (error) {
978
- await tx.rollback();
979
1012
  if (error instanceof StorageConstraintError) {
980
- const stored: StoredPushResult = {
1013
+ const stored = newStoredPushResult(createdAtMs, {
981
1014
  status: 'rejected',
982
1015
  results: [
983
1016
  {
@@ -988,16 +1021,31 @@ export async function processPushCommit(
988
1021
  retryable: false,
989
1022
  },
990
1023
  ],
991
- };
992
- const canonical = await persistRejectedPushResult(
993
- storage,
1024
+ });
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(
994
1033
  partition,
995
1034
  clientId,
996
1035
  frame.clientCommitId,
997
- stored,
998
1036
  );
999
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
1037
+ if (canonical === undefined) {
1038
+ throw new Error(
1039
+ 'push rejection finalization did not persist an outcome',
1040
+ );
1041
+ }
1042
+ return processedPushCommit(
1043
+ frame.clientCommitId,
1044
+ canonical,
1045
+ canonical.cacheIdentity !== stored.cacheIdentity,
1046
+ );
1000
1047
  }
1048
+ await tx.rollback();
1001
1049
  throw error;
1002
1050
  }
1003
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`.