@syncular/server 0.31.0 → 0.32.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.
@@ -66,11 +66,18 @@ import {
66
66
  layoutsOf,
67
67
  migratePayload,
68
68
  parseLayouts,
69
+ quoteIdent,
69
70
  retiredTableNames,
70
71
  rewritePlan,
71
72
  rewriteRowSql,
72
73
  rewriteValues,
73
74
  SCHEMA_META_DDL_POSTGRES,
75
+ SYNC_COLUMN_VERSIONS_COLUMN,
76
+ SYNC_PARTITION_COLUMN,
77
+ SYNC_PAYLOAD_COLUMN,
78
+ SYNC_ROW_ID_COLUMN,
79
+ SYNC_SCOPES_COLUMN,
80
+ SYNC_VERSION_COLUMN,
74
81
  type StoredColumnLayout,
75
82
  scanRowBatchSql,
76
83
  scanRowPageSql,
@@ -89,8 +96,11 @@ import type {
89
96
  AuthoritativeQueryResult,
90
97
  CheckpointDeclaration,
91
98
  ClientCursorInfo,
99
+ ClientPushResult,
92
100
  ClientRecord,
93
101
  ClientSubscription,
102
+ CommitChangeSummary,
103
+ CommitDetail,
94
104
  CommitMetadata,
95
105
  CommitMetadataQuery,
96
106
  CommitWindowQuery,
@@ -105,6 +115,9 @@ import type {
105
115
  ReactionFailureUpdate,
106
116
  ReactionListQuery,
107
117
  ReactionPruneQuery,
118
+ RowChangeRecord,
119
+ RowChangesQuery,
120
+ RowListQuery,
108
121
  RowScanQuery,
109
122
  ScopeActivityQuery,
110
123
  ScopeCommitActivity,
@@ -2584,6 +2597,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2584
2597
  partition: string,
2585
2598
  query: CommitMetadataQuery,
2586
2599
  ): Promise<CommitMetadata[]> {
2600
+ // Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
2601
+ // ended by `beforeSeq` (exclusive upper bound) when paging older. An
2602
+ // omitted bound is the largest safe commitSeq, so one statement serves
2603
+ // both the bounded and unbounded read.
2604
+ const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
2587
2605
  const { rows } = query.table
2588
2606
  ? await this.#exec.query<{
2589
2607
  commit_seq: unknown;
@@ -2594,11 +2612,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2594
2612
  }>(
2595
2613
  `SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
2596
2614
  FROM sync_commits c
2597
- WHERE c.partition=$1 AND c.commit_seq>$2
2615
+ WHERE c.partition=$1 AND c.commit_seq>$2 AND c.commit_seq<$3
2598
2616
  AND EXISTS (SELECT 1 FROM sync_changes ch
2599
- WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=$3)
2600
- ORDER BY c.commit_seq DESC LIMIT $4`,
2601
- [partition, query.afterSeq, query.table, query.limit],
2617
+ WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=$4)
2618
+ ORDER BY c.commit_seq DESC LIMIT $5`,
2619
+ [partition, query.afterSeq, beforeSeq, query.table, query.limit],
2602
2620
  )
2603
2621
  : await this.#exec.query<{
2604
2622
  commit_seq: unknown;
@@ -2609,10 +2627,42 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2609
2627
  }>(
2610
2628
  `SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
2611
2629
  FROM sync_commits
2612
- WHERE partition=$1 AND commit_seq>$2
2613
- ORDER BY commit_seq DESC LIMIT $3`,
2614
- [partition, query.afterSeq, query.limit],
2630
+ WHERE partition=$1 AND commit_seq>$2 AND commit_seq<$3
2631
+ ORDER BY commit_seq DESC LIMIT $4`,
2632
+ [partition, query.afterSeq, beforeSeq, query.limit],
2615
2633
  );
2634
+ // `changeSample`: one extra statement for the whole page — the first
2635
+ // `sample` changes (change-index order) of every listed commit.
2636
+ const sampleCount =
2637
+ query.changeSample !== undefined &&
2638
+ Number.isInteger(query.changeSample) &&
2639
+ query.changeSample > 0
2640
+ ? query.changeSample
2641
+ : 0;
2642
+ const samples = new Map<number, CommitChangeSummary[]>();
2643
+ if (sampleCount > 0 && rows.length > 0) {
2644
+ const seqs = rows.map((row) => asNumber(row.commit_seq));
2645
+ const { rows: changeRows } = await this.#exec.query<{
2646
+ commit_seq: unknown;
2647
+ tbl: string;
2648
+ row_id: string;
2649
+ op: unknown;
2650
+ }>(
2651
+ `SELECT commit_seq, tbl, row_id, op FROM sync_changes
2652
+ WHERE partition=$1 AND commit_seq IN (${seqs.map((_, index) => `$${index + 2}`).join(',')}) AND idx<$${seqs.length + 2}
2653
+ ORDER BY commit_seq, idx`,
2654
+ [partition, ...seqs, sampleCount],
2655
+ );
2656
+ for (const change of changeRows) {
2657
+ const sample = samples.get(asNumber(change.commit_seq)) ?? [];
2658
+ sample.push({
2659
+ table: change.tbl,
2660
+ rowId: change.row_id,
2661
+ op: asNumber(change.op) === 1 ? 'upsert' : 'delete',
2662
+ });
2663
+ samples.set(asNumber(change.commit_seq), sample);
2664
+ }
2665
+ }
2616
2666
  const out: CommitMetadata[] = [];
2617
2667
  for (const row of rows) {
2618
2668
  const commitSeq = asNumber(row.commit_seq);
@@ -2628,11 +2678,127 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2628
2678
  createdAtMs: asNumber(row.created_at_ms),
2629
2679
  changeCount: changes.rows.reduce((sum, c) => sum + asNumber(c.n), 0),
2630
2680
  tables: changes.rows.map((c) => c.tbl),
2681
+ ...(sampleCount > 0 ? { sample: samples.get(commitSeq) ?? [] } : {}),
2631
2682
  });
2632
2683
  }
2633
2684
  return out;
2634
2685
  }
2635
2686
 
2687
+ async getCommit(
2688
+ partition: string,
2689
+ commitSeq: number,
2690
+ ): Promise<CommitDetail | undefined> {
2691
+ const { rows } = await this.#exec.query<{
2692
+ client_id: string;
2693
+ client_commit_id: string;
2694
+ actor_id: string;
2695
+ created_at_ms: unknown;
2696
+ }>(
2697
+ 'SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=$1 AND commit_seq=$2',
2698
+ [partition, commitSeq],
2699
+ );
2700
+ const meta = rows[0];
2701
+ if (meta === undefined) return undefined;
2702
+ const { rows: changeRows } = await this.#exec.query<ChangeRecord>(
2703
+ 'SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=$1 AND commit_seq=$2 ORDER BY idx',
2704
+ [partition, commitSeq],
2705
+ );
2706
+ const changes = changeRows.map(toStoredChange);
2707
+ return {
2708
+ commitSeq,
2709
+ clientId: meta.client_id,
2710
+ clientCommitId: meta.client_commit_id,
2711
+ actorId: meta.actor_id,
2712
+ createdAtMs: asNumber(meta.created_at_ms),
2713
+ changeCount: changes.length,
2714
+ tables: [...new Set(changes.map((change) => change.table))].sort(),
2715
+ changes,
2716
+ };
2717
+ }
2718
+
2719
+ async listRowChanges(
2720
+ partition: string,
2721
+ query: RowChangesQuery,
2722
+ ): Promise<RowChangeRecord[]> {
2723
+ // Retained-log read for one row: no dedicated index (the write path pays
2724
+ // nothing for it), bounded by retention (§4.6).
2725
+ const { rows } = await this.#exec.query<{
2726
+ commit_seq: unknown;
2727
+ op: unknown;
2728
+ row_version: unknown;
2729
+ actor_id: string;
2730
+ client_id: string;
2731
+ created_at_ms: unknown;
2732
+ }>(
2733
+ `SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
2734
+ FROM sync_changes ch
2735
+ JOIN sync_commits c
2736
+ ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
2737
+ WHERE ch.partition=$1 AND ch.tbl=$2 AND ch.row_id=$3
2738
+ ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT $4`,
2739
+ [partition, query.table, query.rowId, query.limit],
2740
+ );
2741
+ return rows.map((row) => ({
2742
+ commitSeq: asNumber(row.commit_seq),
2743
+ op: asNumber(row.op) === 1 ? 'upsert' : 'delete',
2744
+ ...(row.row_version === null || row.row_version === undefined
2745
+ ? {}
2746
+ : { rowVersion: asNumber(row.row_version) }),
2747
+ actorId: row.actor_id,
2748
+ clientId: row.client_id,
2749
+ createdAtMs: asNumber(row.created_at_ms),
2750
+ }));
2751
+ }
2752
+
2753
+ async listRows(partition: string, query: RowListQuery): Promise<StoredRow[]> {
2754
+ // Keyset page over the row table's (partition, rowId) primary key: an
2755
+ // index range read, no scope filter, no per-row lookup.
2756
+ const { rows } = await this.#exec.query<RowRecord>(
2757
+ `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
2758
+ FROM ${quoteIdent(this.table(query.table).name)}
2759
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>$2
2760
+ ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT $3`,
2761
+ [partition, query.afterRowId ?? '', query.limit],
2762
+ );
2763
+ return rows.map(toStoredRow);
2764
+ }
2765
+
2766
+ async countRows(partition: string, table: string): Promise<number> {
2767
+ const { rows } = await this.#exec.query<{ n: unknown }>(
2768
+ `SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=$1`,
2769
+ [partition],
2770
+ );
2771
+ return rows[0] === undefined ? 0 : asNumber(rows[0].n);
2772
+ }
2773
+
2774
+ async listPushResults(
2775
+ partition: string,
2776
+ clientCommitId: string,
2777
+ ): Promise<ClientPushResult[]> {
2778
+ // Retained outcomes for one clientCommitId: no dedicated index, ordered
2779
+ // by client so the read is deterministic.
2780
+ const { rows } = await this.#exec.query<{
2781
+ client_id: string;
2782
+ result: unknown;
2783
+ }>(
2784
+ 'SELECT client_id, result FROM sync_push_results WHERE partition=$1 AND client_commit_id=$2 ORDER BY client_id',
2785
+ [partition, clientCommitId],
2786
+ );
2787
+ return rows.map((row) => {
2788
+ try {
2789
+ return {
2790
+ clientId: row.client_id,
2791
+ result: deserializePushResult(asJson(row.result)),
2792
+ };
2793
+ } catch {
2794
+ throw syncError(
2795
+ 'sync.idempotency_cache_miss',
2796
+ 'persisted push result unreadable (§6.3)',
2797
+ );
2798
+ }
2799
+ });
2800
+ }
2801
+
2636
2802
  async scopeActivity(
2637
2803
  partition: string,
2638
2804
  query: ScopeActivityQuery,
@@ -2674,27 +2840,6 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2674
2840
  return out;
2675
2841
  }
2676
2842
 
2677
- async getRowScopes(
2678
- partition: string,
2679
- table: string,
2680
- rowId: string,
2681
- ): Promise<
2682
- { serverVersion: number; scopes: Record<string, string> } | undefined
2683
- > {
2684
- const { rows } = await this.#exec.query<{
2685
- server_version: unknown;
2686
- scopes: unknown;
2687
- }>(selectRowScopesSql(this.table(table), 'postgres'), [partition, rowId]);
2688
- const row = rows[0];
2689
- if (row === undefined) return undefined;
2690
- return {
2691
- serverVersion: asNumber(row.server_version),
2692
- scopes: (typeof row.scopes === 'string'
2693
- ? JSON.parse(row.scopes)
2694
- : row.scopes) as Record<string, string>,
2695
- };
2696
- }
2697
-
2698
2843
  async listPartitions(): Promise<string[]> {
2699
2844
  return (await this.listPartitionRegistry()).map((entry) => entry.partition);
2700
2845
  }
package/src/push.ts CHANGED
@@ -503,6 +503,7 @@ async function applyOperation(
503
503
  if (
504
504
  present[i] &&
505
505
  !crdtIndexes.has(i) &&
506
+ i !== table.primaryKeyIndex &&
506
507
  effectiveVersion(i) > op.baseVersion
507
508
  ) {
508
509
  marked.push(i);
@@ -978,7 +979,20 @@ async function enforceReferences(
978
979
  const operations: ValidateCommitOperation[] = [];
979
980
  const originByIndex: number[] = [];
980
981
 
982
+ const finalByRow = new Map<
983
+ string,
984
+ { readonly first: StagedWrite; readonly last: StagedWrite }
985
+ >();
981
986
  for (const write of staged) {
987
+ const rowKey = `${write.table.name}\u0000${write.rowId}`;
988
+ const existing = finalByRow.get(rowKey);
989
+ finalByRow.set(rowKey, {
990
+ first: existing?.first ?? write,
991
+ last: write,
992
+ });
993
+ }
994
+
995
+ for (const { last: write } of finalByRow.values()) {
982
996
  if (write.op !== 'upsert' || write.values === undefined) continue;
983
997
  for (const reference of write.table.references) {
984
998
  const value = write.values[reference.columnIndex];
@@ -1028,9 +1042,9 @@ async function enforceReferences(
1028
1042
  readonly rowId: string;
1029
1043
  readonly originOpIndex: number;
1030
1044
  }> = [];
1031
- for (const write of staged) {
1032
- if (write.op === 'delete' && write.stored !== undefined) {
1033
- schedule(write.table, write.rowId, write.opIndex, scheduled, queue);
1045
+ for (const { first, last } of finalByRow.values()) {
1046
+ if (last.op === 'delete' && first.stored !== undefined) {
1047
+ schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
1034
1048
  }
1035
1049
  }
1036
1050
 
@@ -1040,6 +1054,13 @@ async function enforceReferences(
1040
1054
  const key = `${item.table.name}\u0000${item.rowId}`;
1041
1055
  if (processed.has(key)) continue;
1042
1056
  processed.add(key);
1057
+ const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
1058
+ const parentKeyValue: RowValue =
1059
+ parentPkColumn?.type === 'integer'
1060
+ ? Number(item.rowId)
1061
+ : parentPkColumn?.type === 'boolean'
1062
+ ? item.rowId === 'true'
1063
+ : item.rowId;
1043
1064
  for (const reference of item.table.referencedBy) {
1044
1065
  if (tx.scanRowsByIndex === undefined) {
1045
1066
  throw new Error(
@@ -1052,7 +1073,7 @@ async function enforceReferences(
1052
1073
  const page = await tx.scanRowsByIndex({
1053
1074
  table: reference.table,
1054
1075
  index: reference.index,
1055
- values: [item.rowId],
1076
+ values: [parentKeyValue],
1056
1077
  afterRowId,
1057
1078
  limit: 1_000,
1058
1079
  });
@@ -1081,6 +1102,21 @@ async function enforceReferences(
1081
1102
  );
1082
1103
  }
1083
1104
  for (const childRow of children) {
1105
+ if (reference.onDelete === 'CASCADE') {
1106
+ if (
1107
+ !schedule(
1108
+ child,
1109
+ childRow.rowId,
1110
+ item.originOpIndex,
1111
+ scheduled,
1112
+ queue,
1113
+ )
1114
+ ) {
1115
+ continue;
1116
+ }
1117
+ } else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
1118
+ continue;
1119
+ }
1084
1120
  if (originByIndex.length >= cascadeLimit) {
1085
1121
  return {
1086
1122
  kind: 'terminate',
@@ -1091,11 +1127,6 @@ async function enforceReferences(
1091
1127
  ),
1092
1128
  };
1093
1129
  }
1094
- if (
1095
- !schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)
1096
- ) {
1097
- continue;
1098
- }
1099
1130
  const syntheticIndex = clientOpCount + originByIndex.length;
1100
1131
  const storedValues = decodeRow(child.columns, childRow.payload);
1101
1132
  if (reference.onDelete === 'CASCADE') {
package/src/realtime.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  encodeMessage,
24
24
  encodePresenceError,
25
25
  encodePresenceFanout,
26
+ isSupportedProtocolWireVersion,
26
27
  MessageStreamScanner,
27
28
  PROTOCOL_WIRE_VERSION,
28
29
  type PresenceKind,
@@ -48,6 +49,7 @@ import {
48
49
  syncError,
49
50
  } from './errors';
50
51
  import { emitEvent, type SyncularServerEvents } from './events';
52
+ import { composeEvents } from './events-ring';
51
53
  import { createSyncResponseStream } from './handler';
52
54
  import {
53
55
  type CompiledSchema,
@@ -277,6 +279,8 @@ export class RealtimeSession {
277
279
  #registrationGeneration = 0;
278
280
  /** Generation of the most recent refresh whose result was applied. */
279
281
  #registrationApplied = 0;
282
+ /** Highest sequence that required a catch-up wake for this session. */
283
+ #wakeRequiredThroughSeq: number;
280
284
 
281
285
  constructor(
282
286
  hub: RealtimeHub,
@@ -300,6 +304,7 @@ export class RealtimeSession {
300
304
  this.cursor = cursor;
301
305
  this.lastKnownSeq = latestSeq;
302
306
  this.wakePending = cursor < latestSeq;
307
+ this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
303
308
  this.registrations = registrations;
304
309
  this.openedAtMs = clock();
305
310
  this.#send = options.send;
@@ -335,9 +340,9 @@ export class RealtimeSession {
335
340
  // proves those commits no longer need socket notifications, so the next
336
341
  // notification must be adjacent to the acknowledged cursor.
337
342
  this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
338
- if (this.cursor >= this.lastKnownSeq) this.wakePending = false;
343
+ if (this.cursor >= this.#wakeRequiredThroughSeq) this.wakePending = false;
339
344
  // §8.2: acks update the client cursor record without an HTTP pull.
340
- this.#persistCursor();
345
+ this.#persistCursor(cursor);
341
346
  }
342
347
 
343
348
  /**
@@ -570,7 +575,7 @@ export class RealtimeSession {
570
575
  try {
571
576
  const requestedWireVersion =
572
577
  (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
573
- if (requestedWireVersion === 1 || requestedWireVersion === 2) {
578
+ if (isSupportedProtocolWireVersion(requestedWireVersion)) {
574
579
  this.wireVersion = requestedWireVersion;
575
580
  }
576
581
  let stream: AsyncIterable<Uint8Array> | undefined;
@@ -720,7 +725,7 @@ export class RealtimeSession {
720
725
  this.#hub.disconnect(this);
721
726
  }
722
727
 
723
- #persistCursor(): void {
728
+ #persistCursor(cursor: number): void {
724
729
  // §8.2: start the write now and keep it on the control tail so `drain()`
725
730
  // can await it. Two acks may write concurrently: storage folds them with
726
731
  // `MAX(cursor, ?)`, so neither order loses the later cursor.
@@ -730,7 +735,7 @@ export class RealtimeSession {
730
735
  this.clientId,
731
736
  this.actorId,
732
737
  this.logEpoch,
733
- this.cursor,
738
+ cursor,
734
739
  this.#clock(),
735
740
  );
736
741
  })();
@@ -766,6 +771,10 @@ export class RealtimeSession {
766
771
 
767
772
  sendWake(reason: WakeReason): void {
768
773
  this.wakePending = true;
774
+ this.#wakeRequiredThroughSeq = Math.max(
775
+ this.#wakeRequiredThroughSeq,
776
+ this.lastKnownSeq,
777
+ );
769
778
  this.#sendSafe(
770
779
  JSON.stringify({
771
780
  event: 'sync',
@@ -907,8 +916,16 @@ export class RealtimeSession {
907
916
  }
908
917
  }
909
918
 
919
+ /** One open realtime socket. */
920
+ export interface RealtimeConnection {
921
+ readonly sessionId: string;
922
+ readonly clientId: string;
923
+ readonly actorId: string;
924
+ readonly openedAtMs: number;
925
+ }
926
+
910
927
  export class RealtimeHub {
911
- readonly #config: RealtimeHubConfig;
928
+ #config: RealtimeHubConfig;
912
929
  readonly #schema: CompiledSchema;
913
930
  readonly #sessions = new Set<RealtimeSession>();
914
931
  /** §8.6 presence registry — ephemeral, in-memory, never persisted. */
@@ -928,10 +945,35 @@ export class RealtimeHub {
928
945
  );
929
946
  }
930
947
 
948
+ /**
949
+ * Compose `sink` into the hub's `events`, so socket rounds and sessions
950
+ * opened afterwards report to it as well. `createSyncularHono` uses it to
951
+ * feed the admin console's event ring.
952
+ */
953
+ addEventSink(sink: SyncularServerEvents): void {
954
+ const events = this.#config.events;
955
+ this.#config = {
956
+ ...this.#config,
957
+ events: events === undefined ? sink : composeEvents(events, sink),
958
+ };
959
+ }
960
+
931
961
  get sessionCount(): number {
932
962
  return this.#sessions.size;
933
963
  }
934
964
 
965
+ /** The partition's open sockets, for the admin console. */
966
+ connections(partition: string): RealtimeConnection[] {
967
+ return [...this.#sessions]
968
+ .filter((session) => session.partition === partition)
969
+ .map((session) => ({
970
+ sessionId: session.sessionId,
971
+ clientId: session.clientId,
972
+ actorId: session.actorId,
973
+ openedAtMs: session.openedAtMs,
974
+ }));
975
+ }
976
+
935
977
  /** §8.6.2 published-document size cap (bytes). */
936
978
  get maxPresenceBytes(): number {
937
979
  return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
@@ -1087,6 +1129,12 @@ export class RealtimeHub {
1087
1129
  ...(this.#config.commitValidator !== undefined
1088
1130
  ? { commitValidator: this.#config.commitValidator }
1089
1131
  : {}),
1132
+ ...(this.#config.reactionPlanner !== undefined
1133
+ ? { reactionPlanner: this.#config.reactionPlanner }
1134
+ : {}),
1135
+ ...(this.#config.checkpoints !== undefined
1136
+ ? { checkpoints: this.#config.checkpoints }
1137
+ : {}),
1090
1138
  ...(this.#config.clock !== undefined
1091
1139
  ? { clock: this.#config.clock }
1092
1140
  : {}),
@@ -1114,6 +1162,9 @@ export class RealtimeHub {
1114
1162
  ...(this.#config.onError !== undefined
1115
1163
  ? { onError: this.#config.onError }
1116
1164
  : {}),
1165
+ ...(this.#config.mapError !== undefined
1166
+ ? { mapError: this.#config.mapError }
1167
+ : {}),
1117
1168
  realtime: this,
1118
1169
  };
1119
1170
  }
@@ -1177,7 +1228,9 @@ export class RealtimeHub {
1177
1228
  this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES,
1178
1229
  storage,
1179
1230
  registry.logEpoch,
1180
- record?.wireVersion ?? PROTOCOL_WIRE_VERSION,
1231
+ record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
1232
+ ? record.wireVersion
1233
+ : PROTOCOL_WIRE_VERSION,
1181
1234
  this.#config.events,
1182
1235
  );
1183
1236
  this.#sessions.add(session);
package/src/seed.ts CHANGED
@@ -24,6 +24,7 @@ import { SyncError } from './errors';
24
24
  import type { SyncularServerEvent, SyncularServerEvents } from './events';
25
25
  import { composeEvents } from './events-ring';
26
26
  import { handleSyncRequest } from './handler';
27
+ import { renderScopeValue } from './scopes';
27
28
 
28
29
  /** One app-shaped seed mutation — the same vocabulary as client mutations. */
29
30
  export type SeedMutation =
@@ -171,11 +172,11 @@ export async function seedMutations(
171
172
  (column) => (byColumn.get(column.name) ?? null) as RowValue,
172
173
  );
173
174
  const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
174
- const rowId = values[pkIndex];
175
- if (typeof rowId !== 'string' || rowId.length === 0) {
175
+ const rowId = renderScopeValue(values[pkIndex]);
176
+ if (rowId === undefined) {
176
177
  throw new SyncError(
177
178
  'sync.invalid_request',
178
- `seedMutations: table ${table.name}: upsert requires a non-empty string primary key`,
179
+ `seedMutations: table ${table.name}: upsert requires a non-empty primary key`,
179
180
  );
180
181
  }
181
182
  return {