@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.
@@ -5,7 +5,7 @@ import { serveNotReadyError } from './readiness.js';
5
5
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
6
6
  import { syncError } from './errors.js';
7
7
  import { asBytes, asNumber, } from './pg-executor.js';
8
- import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
8
+ import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
9
9
  import { matchesEffective } from './scopes.js';
10
10
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
11
11
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -1659,17 +1659,45 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1659
1659
  }));
1660
1660
  }
1661
1661
  async listCommitMetadata(partition, query) {
1662
+ // Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
1663
+ // ended by `beforeSeq` (exclusive upper bound) when paging older. An
1664
+ // omitted bound is the largest safe commitSeq, so one statement serves
1665
+ // both the bounded and unbounded read.
1666
+ const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
1662
1667
  const { rows } = query.table
1663
1668
  ? await this.#exec.query(`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
1664
1669
  FROM sync_commits c
1665
- WHERE c.partition=$1 AND c.commit_seq>$2
1670
+ WHERE c.partition=$1 AND c.commit_seq>$2 AND c.commit_seq<$3
1666
1671
  AND EXISTS (SELECT 1 FROM sync_changes ch
1667
- WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=$3)
1668
- ORDER BY c.commit_seq DESC LIMIT $4`, [partition, query.afterSeq, query.table, query.limit])
1672
+ WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=$4)
1673
+ ORDER BY c.commit_seq DESC LIMIT $5`, [partition, query.afterSeq, beforeSeq, query.table, query.limit])
1669
1674
  : await this.#exec.query(`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
1670
1675
  FROM sync_commits
1671
- WHERE partition=$1 AND commit_seq>$2
1672
- ORDER BY commit_seq DESC LIMIT $3`, [partition, query.afterSeq, query.limit]);
1676
+ WHERE partition=$1 AND commit_seq>$2 AND commit_seq<$3
1677
+ ORDER BY commit_seq DESC LIMIT $4`, [partition, query.afterSeq, beforeSeq, query.limit]);
1678
+ // `changeSample`: one extra statement for the whole page — the first
1679
+ // `sample` changes (change-index order) of every listed commit.
1680
+ const sampleCount = query.changeSample !== undefined &&
1681
+ Number.isInteger(query.changeSample) &&
1682
+ query.changeSample > 0
1683
+ ? query.changeSample
1684
+ : 0;
1685
+ const samples = new Map();
1686
+ if (sampleCount > 0 && rows.length > 0) {
1687
+ const seqs = rows.map((row) => asNumber(row.commit_seq));
1688
+ const { rows: changeRows } = await this.#exec.query(`SELECT commit_seq, tbl, row_id, op FROM sync_changes
1689
+ WHERE partition=$1 AND commit_seq IN (${seqs.map((_, index) => `$${index + 2}`).join(',')}) AND idx<$${seqs.length + 2}
1690
+ ORDER BY commit_seq, idx`, [partition, ...seqs, sampleCount]);
1691
+ for (const change of changeRows) {
1692
+ const sample = samples.get(asNumber(change.commit_seq)) ?? [];
1693
+ sample.push({
1694
+ table: change.tbl,
1695
+ rowId: change.row_id,
1696
+ op: asNumber(change.op) === 1 ? 'upsert' : 'delete',
1697
+ });
1698
+ samples.set(asNumber(change.commit_seq), sample);
1699
+ }
1700
+ }
1673
1701
  const out = [];
1674
1702
  for (const row of rows) {
1675
1703
  const commitSeq = asNumber(row.commit_seq);
@@ -1682,10 +1710,78 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1682
1710
  createdAtMs: asNumber(row.created_at_ms),
1683
1711
  changeCount: changes.rows.reduce((sum, c) => sum + asNumber(c.n), 0),
1684
1712
  tables: changes.rows.map((c) => c.tbl),
1713
+ ...(sampleCount > 0 ? { sample: samples.get(commitSeq) ?? [] } : {}),
1685
1714
  });
1686
1715
  }
1687
1716
  return out;
1688
1717
  }
1718
+ async getCommit(partition, commitSeq) {
1719
+ const { rows } = await this.#exec.query('SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=$1 AND commit_seq=$2', [partition, commitSeq]);
1720
+ const meta = rows[0];
1721
+ if (meta === undefined)
1722
+ return undefined;
1723
+ const { rows: changeRows } = await this.#exec.query('SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=$1 AND commit_seq=$2 ORDER BY idx', [partition, commitSeq]);
1724
+ const changes = changeRows.map(toStoredChange);
1725
+ return {
1726
+ commitSeq,
1727
+ clientId: meta.client_id,
1728
+ clientCommitId: meta.client_commit_id,
1729
+ actorId: meta.actor_id,
1730
+ createdAtMs: asNumber(meta.created_at_ms),
1731
+ changeCount: changes.length,
1732
+ tables: [...new Set(changes.map((change) => change.table))].sort(),
1733
+ changes,
1734
+ };
1735
+ }
1736
+ async listRowChanges(partition, query) {
1737
+ // Retained-log read for one row: no dedicated index (the write path pays
1738
+ // nothing for it), bounded by retention (§4.6).
1739
+ const { rows } = await this.#exec.query(`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
1740
+ FROM sync_changes ch
1741
+ JOIN sync_commits c
1742
+ ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
1743
+ WHERE ch.partition=$1 AND ch.tbl=$2 AND ch.row_id=$3
1744
+ ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT $4`, [partition, query.table, query.rowId, query.limit]);
1745
+ return rows.map((row) => ({
1746
+ commitSeq: asNumber(row.commit_seq),
1747
+ op: asNumber(row.op) === 1 ? 'upsert' : 'delete',
1748
+ ...(row.row_version === null || row.row_version === undefined
1749
+ ? {}
1750
+ : { rowVersion: asNumber(row.row_version) }),
1751
+ actorId: row.actor_id,
1752
+ clientId: row.client_id,
1753
+ createdAtMs: asNumber(row.created_at_ms),
1754
+ }));
1755
+ }
1756
+ async listRows(partition, query) {
1757
+ // Keyset page over the row table's (partition, rowId) primary key: an
1758
+ // index range read, no scope filter, no per-row lookup.
1759
+ const { rows } = await this.#exec.query(`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
1760
+ FROM ${quoteIdent(this.table(query.table).name)}
1761
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>$2
1762
+ ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT $3`, [partition, query.afterRowId ?? '', query.limit]);
1763
+ return rows.map(toStoredRow);
1764
+ }
1765
+ async countRows(partition, table) {
1766
+ const { rows } = await this.#exec.query(`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=$1`, [partition]);
1767
+ return rows[0] === undefined ? 0 : asNumber(rows[0].n);
1768
+ }
1769
+ async listPushResults(partition, clientCommitId) {
1770
+ // Retained outcomes for one clientCommitId: no dedicated index, ordered
1771
+ // by client so the read is deterministic.
1772
+ const { rows } = await this.#exec.query('SELECT client_id, result FROM sync_push_results WHERE partition=$1 AND client_commit_id=$2 ORDER BY client_id', [partition, clientCommitId]);
1773
+ return rows.map((row) => {
1774
+ try {
1775
+ return {
1776
+ clientId: row.client_id,
1777
+ result: deserializePushResult(asJson(row.result)),
1778
+ };
1779
+ }
1780
+ catch {
1781
+ throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
1782
+ }
1783
+ });
1784
+ }
1689
1785
  async scopeActivity(partition, query) {
1690
1786
  const { rows } = await this.#exec.query(`SELECT DISTINCT commit_seq, tbl FROM sync_change_scopes
1691
1787
  WHERE partition=$1 AND var=$2 AND value=$3
@@ -1708,18 +1804,6 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1708
1804
  }
1709
1805
  return out;
1710
1806
  }
1711
- async getRowScopes(partition, table, rowId) {
1712
- const { rows } = await this.#exec.query(selectRowScopesSql(this.table(table), 'postgres'), [partition, rowId]);
1713
- const row = rows[0];
1714
- if (row === undefined)
1715
- return undefined;
1716
- return {
1717
- serverVersion: asNumber(row.server_version),
1718
- scopes: (typeof row.scopes === 'string'
1719
- ? JSON.parse(row.scopes)
1720
- : row.scopes),
1721
- };
1722
- }
1723
1807
  async listPartitions() {
1724
1808
  return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1725
1809
  }
package/dist/push.js CHANGED
@@ -298,6 +298,7 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
298
298
  for (let i = 0; i < table.columns.length; i++) {
299
299
  if (present[i] &&
300
300
  !crdtIndexes.has(i) &&
301
+ i !== table.primaryKeyIndex &&
301
302
  effectiveVersion(i) > op.baseVersion) {
302
303
  marked.push(i);
303
304
  }
@@ -602,7 +603,16 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
602
603
  const changes = [];
603
604
  const operations = [];
604
605
  const originByIndex = [];
606
+ const finalByRow = new Map();
605
607
  for (const write of staged) {
608
+ const rowKey = `${write.table.name}\u0000${write.rowId}`;
609
+ const existing = finalByRow.get(rowKey);
610
+ finalByRow.set(rowKey, {
611
+ first: existing?.first ?? write,
612
+ last: write,
613
+ });
614
+ }
615
+ for (const { last: write } of finalByRow.values()) {
606
616
  if (write.op !== 'upsert' || write.values === undefined)
607
617
  continue;
608
618
  for (const reference of write.table.references) {
@@ -636,9 +646,9 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
636
646
  const scheduled = new Set();
637
647
  const processed = new Set();
638
648
  const queue = [];
639
- for (const write of staged) {
640
- if (write.op === 'delete' && write.stored !== undefined) {
641
- schedule(write.table, write.rowId, write.opIndex, scheduled, queue);
649
+ for (const { first, last } of finalByRow.values()) {
650
+ if (last.op === 'delete' && first.stored !== undefined) {
651
+ schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
642
652
  }
643
653
  }
644
654
  for (let cursor = 0; cursor < queue.length; cursor++) {
@@ -649,6 +659,12 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
649
659
  if (processed.has(key))
650
660
  continue;
651
661
  processed.add(key);
662
+ const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
663
+ const parentKeyValue = parentPkColumn?.type === 'integer'
664
+ ? Number(item.rowId)
665
+ : parentPkColumn?.type === 'boolean'
666
+ ? item.rowId === 'true'
667
+ : item.rowId;
652
668
  for (const reference of item.table.referencedBy) {
653
669
  if (tx.scanRowsByIndex === undefined) {
654
670
  throw new Error('storage transaction does not support declared-reference enforcement (scanRowsByIndex)');
@@ -659,7 +675,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
659
675
  const page = await tx.scanRowsByIndex({
660
676
  table: reference.table,
661
677
  index: reference.index,
662
- values: [item.rowId],
678
+ values: [parentKeyValue],
663
679
  afterRowId,
664
680
  limit: 1_000,
665
681
  });
@@ -684,15 +700,20 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
684
700
  throw new Error(`unreachable: reference child table ${reference.table} is not compiled`);
685
701
  }
686
702
  for (const childRow of children) {
703
+ if (reference.onDelete === 'CASCADE') {
704
+ if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
705
+ continue;
706
+ }
707
+ }
708
+ else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
709
+ continue;
710
+ }
687
711
  if (originByIndex.length >= cascadeLimit) {
688
712
  return {
689
713
  kind: 'terminate',
690
714
  record: referenceRejection(item.originOpIndex, 'declared-reference cascade exceeded the per-commit operation cap (§6.11)', { reason: 'cascade_limit' }),
691
715
  };
692
716
  }
693
- if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
694
- continue;
695
- }
696
717
  const syntheticIndex = clientOpCount + originByIndex.length;
697
718
  const storedValues = decodeRow(child.columns, childRow.payload);
698
719
  if (reference.onDelete === 'CASCADE') {
@@ -169,13 +169,28 @@ export declare class RealtimeSession {
169
169
  deliverCommit(commit: StoredCommit): void;
170
170
  close(): void;
171
171
  }
172
+ /** One open realtime socket. */
173
+ export interface RealtimeConnection {
174
+ readonly sessionId: string;
175
+ readonly clientId: string;
176
+ readonly actorId: string;
177
+ readonly openedAtMs: number;
178
+ }
172
179
  export declare class RealtimeHub {
173
180
  #private;
174
181
  /** §8.6 presence registry — ephemeral, in-memory, never persisted. */
175
182
  readonly presence: PresenceRegistry;
176
183
  constructor(config: RealtimeHubConfig);
177
184
  projectPayload(registration: Registration, payload: Uint8Array): Uint8Array;
185
+ /**
186
+ * Compose `sink` into the hub's `events`, so socket rounds and sessions
187
+ * opened afterwards report to it as well. `createSyncularHono` uses it to
188
+ * feed the admin console's event ring.
189
+ */
190
+ addEventSink(sink: SyncularServerEvents): void;
178
191
  get sessionCount(): number;
192
+ /** The partition's open sockets, for the admin console. */
193
+ connections(partition: string): RealtimeConnection[];
179
194
  /** §8.6.2 published-document size cap (bytes). */
180
195
  get maxPresenceBytes(): number;
181
196
  /** §8.6.4 presence rate cap (ms); 0 = off (reference default). */
package/dist/realtime.js CHANGED
@@ -16,10 +16,11 @@
16
16
  * one handler, two framings); the only JSON data-plane server event is
17
17
  * the `sync` wake-up (§8.3).
18
18
  */
19
- import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
19
+ import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, isSupportedProtocolWireVersion, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
20
20
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
21
21
  import { adapterSyncError, reportError, SyncError, syncError, } from './errors.js';
22
22
  import { emitEvent } from './events.js';
23
+ import { composeEvents } from './events-ring.js';
23
24
  import { createSyncResponseStream } from './handler.js';
24
25
  import { compileSchema, } from './schema.js';
25
26
  import { schemaWindowOf, projectRowPayload } from './schema-window.js';
@@ -159,6 +160,8 @@ export class RealtimeSession {
159
160
  #registrationGeneration = 0;
160
161
  /** Generation of the most recent refresh whose result was applied. */
161
162
  #registrationApplied = 0;
163
+ /** Highest sequence that required a catch-up wake for this session. */
164
+ #wakeRequiredThroughSeq;
162
165
  constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
163
166
  this.sessionId = crypto.randomUUID();
164
167
  this.partition = options.partition;
@@ -169,6 +172,7 @@ export class RealtimeSession {
169
172
  this.cursor = cursor;
170
173
  this.lastKnownSeq = latestSeq;
171
174
  this.wakePending = cursor < latestSeq;
175
+ this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
172
176
  this.registrations = registrations;
173
177
  this.openedAtMs = clock();
174
178
  this.#send = options.send;
@@ -207,10 +211,10 @@ export class RealtimeSession {
207
211
  // proves those commits no longer need socket notifications, so the next
208
212
  // notification must be adjacent to the acknowledged cursor.
209
213
  this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
210
- if (this.cursor >= this.lastKnownSeq)
214
+ if (this.cursor >= this.#wakeRequiredThroughSeq)
211
215
  this.wakePending = false;
212
216
  // §8.2: acks update the client cursor record without an HTTP pull.
213
- this.#persistCursor();
217
+ this.#persistCursor(cursor);
214
218
  }
215
219
  /**
216
220
  * Resolve once every queued control-plane write has settled (§8.2). A host
@@ -412,7 +416,7 @@ export class RealtimeSession {
412
416
  };
413
417
  try {
414
418
  const requestedWireVersion = (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
415
- if (requestedWireVersion === 1 || requestedWireVersion === 2) {
419
+ if (isSupportedProtocolWireVersion(requestedWireVersion)) {
416
420
  this.wireVersion = requestedWireVersion;
417
421
  }
418
422
  let stream;
@@ -536,12 +540,12 @@ export class RealtimeSession {
536
540
  this.#closeSocket?.();
537
541
  this.#hub.disconnect(this);
538
542
  }
539
- #persistCursor() {
543
+ #persistCursor(cursor) {
540
544
  // §8.2: start the write now and keep it on the control tail so `drain()`
541
545
  // can await it. Two acks may write concurrently: storage folds them with
542
546
  // `MAX(cursor, ?)`, so neither order loses the later cursor.
543
547
  const write = (async () => {
544
- await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, this.cursor, this.#clock());
548
+ await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, cursor, this.#clock());
545
549
  })();
546
550
  write.catch((error) => {
547
551
  // Best-effort for the connection (the next pull repairs the cursor);
@@ -570,6 +574,7 @@ export class RealtimeSession {
570
574
  }
571
575
  sendWake(reason) {
572
576
  this.wakePending = true;
577
+ this.#wakeRequiredThroughSeq = Math.max(this.#wakeRequiredThroughSeq, this.lastKnownSeq);
573
578
  this.#sendSafe(JSON.stringify({
574
579
  event: 'sync',
575
580
  data: {
@@ -714,9 +719,32 @@ export class RealtimeHub {
714
719
  projectPayload(registration, payload) {
715
720
  return projectRowPayload(this.#schema.tables.get(registration.table), registration.codec, payload);
716
721
  }
722
+ /**
723
+ * Compose `sink` into the hub's `events`, so socket rounds and sessions
724
+ * opened afterwards report to it as well. `createSyncularHono` uses it to
725
+ * feed the admin console's event ring.
726
+ */
727
+ addEventSink(sink) {
728
+ const events = this.#config.events;
729
+ this.#config = {
730
+ ...this.#config,
731
+ events: events === undefined ? sink : composeEvents(events, sink),
732
+ };
733
+ }
717
734
  get sessionCount() {
718
735
  return this.#sessions.size;
719
736
  }
737
+ /** The partition's open sockets, for the admin console. */
738
+ connections(partition) {
739
+ return [...this.#sessions]
740
+ .filter((session) => session.partition === partition)
741
+ .map((session) => ({
742
+ sessionId: session.sessionId,
743
+ clientId: session.clientId,
744
+ actorId: session.actorId,
745
+ openedAtMs: session.openedAtMs,
746
+ }));
747
+ }
720
748
  /** §8.6.2 published-document size cap (bytes). */
721
749
  get maxPresenceBytes() {
722
750
  return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
@@ -842,6 +870,12 @@ export class RealtimeHub {
842
870
  ...(this.#config.commitValidator !== undefined
843
871
  ? { commitValidator: this.#config.commitValidator }
844
872
  : {}),
873
+ ...(this.#config.reactionPlanner !== undefined
874
+ ? { reactionPlanner: this.#config.reactionPlanner }
875
+ : {}),
876
+ ...(this.#config.checkpoints !== undefined
877
+ ? { checkpoints: this.#config.checkpoints }
878
+ : {}),
845
879
  ...(this.#config.clock !== undefined
846
880
  ? { clock: this.#config.clock }
847
881
  : {}),
@@ -869,6 +903,9 @@ export class RealtimeHub {
869
903
  ...(this.#config.onError !== undefined
870
904
  ? { onError: this.#config.onError }
871
905
  : {}),
906
+ ...(this.#config.mapError !== undefined
907
+ ? { mapError: this.#config.mapError }
908
+ : {}),
872
909
  realtime: this,
873
910
  };
874
911
  }
@@ -905,7 +942,9 @@ export class RealtimeHub {
905
942
  const registrations = await this.loadRegistrations(options.partition, options.actorId, options.clientId);
906
943
  const latestSeq = await storage.getMaxCommitSeq(options.partition);
907
944
  const cursor = record?.cursor ?? -1;
908
- const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record?.wireVersion ?? PROTOCOL_WIRE_VERSION, this.#config.events);
945
+ const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
946
+ ? record.wireVersion
947
+ : PROTOCOL_WIRE_VERSION, this.#config.events);
909
948
  this.#sessions.add(session);
910
949
  const helloResult = options.send(JSON.stringify({
911
950
  event: 'hello',
package/dist/seed.js CHANGED
@@ -13,6 +13,7 @@ import { decodeMessage, encodeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, }
13
13
  import { SyncError } from './errors.js';
14
14
  import { composeEvents } from './events-ring.js';
15
15
  import { handleSyncRequest } from './handler.js';
16
+ import { renderScopeValue } from './scopes.js';
16
17
  /** Structured terminal failure from the real push path used by a seed. */
17
18
  export class SeedMutationError extends Error {
18
19
  name = 'SeedMutationError';
@@ -94,9 +95,9 @@ export async function seedMutations(config, target, mutations) {
94
95
  }
95
96
  const values = table.columns.map((column) => (byColumn.get(column.name) ?? null));
96
97
  const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
97
- const rowId = values[pkIndex];
98
- if (typeof rowId !== 'string' || rowId.length === 0) {
99
- throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty string primary key`);
98
+ const rowId = renderScopeValue(values[pkIndex]);
99
+ if (rowId === undefined) {
100
+ throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty primary key`);
100
101
  }
101
102
  return {
102
103
  table: mutation.table,
@@ -2,7 +2,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
2
  import type { ScopeMap } from '@syncular/core';
3
3
  import type { CompiledSchema, CompiledTable } from './schema.js';
4
4
  import { type SqliteDatabase } from './sqlite-driver.js';
5
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
5
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
6
6
  export declare class SqliteServerStorage implements ServerStorage {
7
7
  #private;
8
8
  readonly db: SqliteDatabase;
@@ -69,10 +69,11 @@ export declare class SqliteServerStorage implements ServerStorage {
69
69
  listReferencedBlobIds(partition: string): Promise<string[]>;
70
70
  listClientRecords(partition: string): Promise<ClientRecord[]>;
71
71
  listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
72
+ getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
73
+ listRowChanges(partition: string, query: RowChangesQuery): Promise<RowChangeRecord[]>;
74
+ listRows(partition: string, query: RowListQuery): Promise<StoredRow[]>;
75
+ countRows(partition: string, table: string): Promise<number>;
76
+ listPushResults(partition: string, clientCommitId: string): Promise<ClientPushResult[]>;
72
77
  scopeActivity(partition: string, query: ScopeActivityQuery): Promise<ScopeCommitActivity[]>;
73
- getRowScopes(partition: string, table: string, rowId: string): Promise<{
74
- serverVersion: number;
75
- scopes: Record<string, string>;
76
- } | undefined>;
77
78
  listPartitions(): Promise<string[]>;
78
79
  }
@@ -13,9 +13,9 @@ import { serveNotReadyError } from './readiness.js';
13
13
  */
14
14
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
15
15
  import { syncError } from './errors.js';
16
- import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
16
+ import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
17
17
  import { matchesEffective } from './scopes.js';
18
- import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
18
+ import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
19
19
  import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
20
20
  import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
21
21
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -1218,22 +1218,51 @@ END`);
1218
1218
  }));
1219
1219
  }
1220
1220
  async listCommitMetadata(partition, query) {
1221
- // Newest-first window, resumed by `afterSeq` (exclusive lower bound).
1221
+ // Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
1222
+ // ended by `beforeSeq` (exclusive upper bound) when paging older. An
1223
+ // omitted bound is the largest safe commitSeq, so one statement serves
1224
+ // both the bounded and unbounded read.
1225
+ const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
1222
1226
  const rows = query.table
1223
1227
  ? this.db
1224
1228
  .query(`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
1225
1229
  FROM sync_commits c
1226
- WHERE c.partition=? AND c.commit_seq>?
1230
+ WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
1227
1231
  AND EXISTS (SELECT 1 FROM sync_changes ch
1228
1232
  WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
1229
1233
  ORDER BY c.commit_seq DESC LIMIT ?`)
1230
- .all(partition, query.afterSeq, query.table, query.limit)
1234
+ .all(partition, query.afterSeq, beforeSeq, query.table, query.limit)
1231
1235
  : this.db
1232
1236
  .query(`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
1233
1237
  FROM sync_commits
1234
- WHERE partition=? AND commit_seq>?
1238
+ WHERE partition=? AND commit_seq>? AND commit_seq<?
1235
1239
  ORDER BY commit_seq DESC LIMIT ?`)
1236
- .all(partition, query.afterSeq, query.limit);
1240
+ .all(partition, query.afterSeq, beforeSeq, query.limit);
1241
+ // `changeSample`: one extra statement for the whole page — the first
1242
+ // `sample` changes (change-index order) of every listed commit.
1243
+ const sampleCount = query.changeSample !== undefined &&
1244
+ Number.isInteger(query.changeSample) &&
1245
+ query.changeSample > 0
1246
+ ? query.changeSample
1247
+ : 0;
1248
+ const samples = new Map();
1249
+ if (sampleCount > 0 && rows.length > 0) {
1250
+ const seqs = rows.map((row) => row.commit_seq);
1251
+ const records = this.db
1252
+ .query(`SELECT commit_seq, tbl, row_id, op FROM sync_changes
1253
+ WHERE partition=? AND commit_seq IN (${placeholders(seqs.length)}) AND idx<?
1254
+ ORDER BY commit_seq, idx`)
1255
+ .all(partition, ...seqs, sampleCount);
1256
+ for (const record of records) {
1257
+ const sample = samples.get(record.commit_seq) ?? [];
1258
+ sample.push({
1259
+ table: record.tbl,
1260
+ rowId: record.row_id,
1261
+ op: record.op === 1 ? 'upsert' : 'delete',
1262
+ });
1263
+ samples.set(record.commit_seq, sample);
1264
+ }
1265
+ }
1237
1266
  return rows.map((row) => {
1238
1267
  const changes = this.db
1239
1268
  .query('SELECT tbl, count(*) AS n FROM sync_changes WHERE partition=? AND commit_seq=? GROUP BY tbl')
@@ -1246,9 +1275,88 @@ END`);
1246
1275
  createdAtMs: row.created_at_ms,
1247
1276
  changeCount: changes.reduce((sum, c) => sum + c.n, 0),
1248
1277
  tables: changes.map((c) => c.tbl),
1278
+ ...(sampleCount > 0
1279
+ ? { sample: samples.get(row.commit_seq) ?? [] }
1280
+ : {}),
1249
1281
  };
1250
1282
  });
1251
1283
  }
1284
+ async getCommit(partition, commitSeq) {
1285
+ const meta = this.db
1286
+ .query('SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?')
1287
+ .get(partition, commitSeq);
1288
+ if (meta === null)
1289
+ return undefined;
1290
+ const changes = this.db
1291
+ .query('SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx')
1292
+ .all(partition, commitSeq)
1293
+ .map(toStoredChange);
1294
+ return {
1295
+ commitSeq,
1296
+ clientId: meta.client_id,
1297
+ clientCommitId: meta.client_commit_id,
1298
+ actorId: meta.actor_id,
1299
+ createdAtMs: meta.created_at_ms,
1300
+ changeCount: changes.length,
1301
+ tables: [...new Set(changes.map((change) => change.table))].sort(),
1302
+ changes,
1303
+ };
1304
+ }
1305
+ async listRowChanges(partition, query) {
1306
+ // Retained-log read for one row: no dedicated index (the write path pays
1307
+ // nothing for it), bounded by retention (§4.6).
1308
+ return this.db
1309
+ .query(`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
1310
+ FROM sync_changes ch
1311
+ JOIN sync_commits c
1312
+ ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
1313
+ WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
1314
+ ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`)
1315
+ .all(partition, query.table, query.rowId, query.limit)
1316
+ .map((row) => ({
1317
+ commitSeq: row.commit_seq,
1318
+ op: row.op === 1 ? 'upsert' : 'delete',
1319
+ ...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
1320
+ actorId: row.actor_id,
1321
+ clientId: row.client_id,
1322
+ createdAtMs: row.created_at_ms,
1323
+ }));
1324
+ }
1325
+ async listRows(partition, query) {
1326
+ // Keyset page over the row table's (partition, rowId) primary key: an
1327
+ // index range read, no scope filter, no per-row lookup.
1328
+ return this.db
1329
+ .query(`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
1330
+ FROM ${quoteIdent(this.table(query.table).name)}
1331
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
1332
+ ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`)
1333
+ .all(partition, query.afterRowId ?? '', query.limit)
1334
+ .map(toStoredRow);
1335
+ }
1336
+ async countRows(partition, table) {
1337
+ const record = this.db
1338
+ .query(`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`)
1339
+ .get(partition);
1340
+ return record?.n ?? 0;
1341
+ }
1342
+ async listPushResults(partition, clientCommitId) {
1343
+ // Retained outcomes for one clientCommitId: no dedicated index, ordered
1344
+ // by client so the read is deterministic.
1345
+ return this.db
1346
+ .query('SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id')
1347
+ .all(partition, clientCommitId)
1348
+ .map((record) => {
1349
+ try {
1350
+ return {
1351
+ clientId: record.client_id,
1352
+ result: deserializePushResult(record.result),
1353
+ };
1354
+ }
1355
+ catch {
1356
+ throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
1357
+ }
1358
+ });
1359
+ }
1252
1360
  async scopeActivity(partition, query) {
1253
1361
  // Candidate commits via the change-scope index (§3.1) — never a scan.
1254
1362
  const rows = this.db
@@ -1276,17 +1384,6 @@ END`);
1276
1384
  }
1277
1385
  return out;
1278
1386
  }
1279
- async getRowScopes(partition, table, rowId) {
1280
- const record = this.db
1281
- .query(selectRowScopesSql(this.table(table), 'sqlite'))
1282
- .get(partition, rowId);
1283
- if (record === null)
1284
- return undefined;
1285
- return {
1286
- serverVersion: record.server_version,
1287
- scopes: JSON.parse(record.scopes),
1288
- };
1289
- }
1290
1387
  async listPartitions() {
1291
1388
  return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1292
1389
  }