@syncular/server 0.30.16 → 0.32.0

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/pull.ts CHANGED
@@ -23,7 +23,6 @@ import {
23
23
  import { scopeDigest } from './scopes';
24
24
  import type { SegmentRecord, SegmentStore } from './segment-store';
25
25
  import { issueSegmentUrl } from './signed-url';
26
- import type { SqliteImageBuilder } from './sqlite-image';
27
26
  import type { ServerStorage, StoredCommit, StoredRow } from './storage';
28
27
  import { projectRowPayload, projectRowValues } from './schema-window';
29
28
 
@@ -33,26 +32,6 @@ const imageBuilds = new WeakMap<
33
32
  WeakMap<SegmentStore, Map<string, Promise<SegmentRecord>>>
34
33
  >();
35
34
 
36
- /**
37
- * Resolve the §5.3 image builder: the host-injected one if present, else the
38
- * in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
39
- * is never a static dep of the neutral core), else `undefined` (rows lane).
40
- * Memoized so the dynamic import happens at most once per process.
41
- */
42
- let cachedDefaultBuilder: SqliteImageBuilder | null | undefined;
43
- async function resolveImageBuilder(
44
- ctx: SyncRequestContext,
45
- ): Promise<SqliteImageBuilder | undefined> {
46
- if (ctx.sqliteImageBuilder !== undefined) return ctx.sqliteImageBuilder;
47
- if (cachedDefaultBuilder === undefined) {
48
- const hasBun = (globalThis as { Bun?: unknown }).Bun !== undefined;
49
- cachedDefaultBuilder = hasBun
50
- ? (await import('./sqlite-bun')).buildSqliteImage
51
- : null;
52
- }
53
- return cachedDefaultBuilder ?? undefined;
54
- }
55
-
56
35
  /** §4.2 accept bitmask. */
57
36
  export const ACCEPT_INLINE_ROWS = 1 << 0;
58
37
  export const ACCEPT_EXTERNAL_ROWS = 1 << 1;
@@ -280,14 +259,9 @@ async function* sqliteImageSegment(
280
259
  // rows page — smaller tables stay on the (typically inline) rows lane.
281
260
  // The probe is the rows lane's first page, read by `prepareSections`.
282
261
  if (probe.length <= limits.limitSnapshotRows) return false;
283
- // §5.3: building an image needs a SQLite engine. The host injects the
284
- // builder through `sqliteImageBuilder`; when omitted we default to the
285
- // in-tree `buildSqliteImage` ONLY on a Bun runtime, reached by a *dynamic*
286
- // import so `bun:sqlite` is never a static dependency of the pull path
287
- // (runtime neutrality is enforced by test/runtime-neutrality.test.ts). On
288
- // Workers/edge (no `Bun`) this yields the rows lane for a bit-2 client — a
289
- // support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
290
- const buildImage = await resolveImageBuilder(ctx);
262
+ // §5.3: hosts opt into image construction with a runtime-specific builder.
263
+ // Without one, matching stored images remain usable and cold pulls use rows.
264
+ const buildImage = ctx.sqliteImageBuilder;
291
265
  if (buildImage === undefined) return false;
292
266
  let stores = imageBuilds.get(storage);
293
267
  if (stores === undefined) {
package/src/realtime.ts CHANGED
@@ -44,9 +44,11 @@ import {
44
44
  reportError,
45
45
  SyncError,
46
46
  type SyncularErrorHandler,
47
+ type SyncularErrorMapper,
47
48
  syncError,
48
49
  } from './errors';
49
50
  import { emitEvent, type SyncularServerEvents } from './events';
51
+ import { composeEvents } from './events-ring';
50
52
  import { createSyncResponseStream } from './handler';
51
53
  import {
52
54
  type CompiledSchema,
@@ -599,7 +601,12 @@ export class RealtimeSession {
599
601
  const sync =
600
602
  error instanceof DecodeError
601
603
  ? syncError(error.code, error.message)
602
- : adapterSyncError(error, this.#hub.onError, 'realtime');
604
+ : adapterSyncError(
605
+ error,
606
+ (error, context) => this.#hub.onError?.(error, context),
607
+ 'realtime',
608
+ (error, context) => this.#hub.mapError?.(error, context),
609
+ );
603
610
  finishRound(); // END is in this one chunk
604
611
  await this.#sendRoundChunk(
605
612
  errorResponseBytes(sync, this.wireVersion, this.logEpoch),
@@ -625,7 +632,12 @@ export class RealtimeSession {
625
632
  const sync =
626
633
  error instanceof DecodeError
627
634
  ? syncError(error.code, error.message)
628
- : adapterSyncError(error, this.#hub.onError, 'realtime');
635
+ : adapterSyncError(
636
+ error,
637
+ (error, context) => this.#hub.onError?.(error, context),
638
+ 'realtime',
639
+ (error, context) => this.#hub.mapError?.(error, context),
640
+ );
629
641
  finishRound();
630
642
  await this.#sendRoundChunk(
631
643
  errorResponseBytes(sync, this.wireVersion, this.logEpoch),
@@ -641,7 +653,11 @@ export class RealtimeSession {
641
653
  } catch (error) {
642
654
  // A host failure mid-stream leaves the byte stream unfinishable —
643
655
  // fail loud, drop the connection (§8.7 / §1.4 abort rule).
644
- reportError(error, this.#hub.onError, 'realtime');
656
+ reportError(
657
+ error,
658
+ (error, context) => this.#hub.onError?.(error, context),
659
+ 'realtime',
660
+ );
645
661
  this.#violation('sync round failed mid-stream');
646
662
  } finally {
647
663
  finishRound();
@@ -892,8 +908,16 @@ export class RealtimeSession {
892
908
  }
893
909
  }
894
910
 
911
+ /** One open realtime socket. */
912
+ export interface RealtimeConnection {
913
+ readonly sessionId: string;
914
+ readonly clientId: string;
915
+ readonly actorId: string;
916
+ readonly openedAtMs: number;
917
+ }
918
+
895
919
  export class RealtimeHub {
896
- readonly #config: RealtimeHubConfig;
920
+ #config: RealtimeHubConfig;
897
921
  readonly #schema: CompiledSchema;
898
922
  readonly #sessions = new Set<RealtimeSession>();
899
923
  /** §8.6 presence registry — ephemeral, in-memory, never persisted. */
@@ -913,10 +937,35 @@ export class RealtimeHub {
913
937
  );
914
938
  }
915
939
 
940
+ /**
941
+ * Compose `sink` into the hub's `events`, so socket rounds and sessions
942
+ * opened afterwards report to it as well. `createSyncularHono` uses it to
943
+ * feed the admin console's event ring.
944
+ */
945
+ addEventSink(sink: SyncularServerEvents): void {
946
+ const events = this.#config.events;
947
+ this.#config = {
948
+ ...this.#config,
949
+ events: events === undefined ? sink : composeEvents(events, sink),
950
+ };
951
+ }
952
+
916
953
  get sessionCount(): number {
917
954
  return this.#sessions.size;
918
955
  }
919
956
 
957
+ /** The partition's open sockets, for the admin console. */
958
+ connections(partition: string): RealtimeConnection[] {
959
+ return [...this.#sessions]
960
+ .filter((session) => session.partition === partition)
961
+ .map((session) => ({
962
+ sessionId: session.sessionId,
963
+ clientId: session.clientId,
964
+ actorId: session.actorId,
965
+ openedAtMs: session.openedAtMs,
966
+ }));
967
+ }
968
+
920
969
  /** §8.6.2 published-document size cap (bytes). */
921
970
  get maxPresenceBytes(): number {
922
971
  return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
@@ -1108,6 +1157,11 @@ export class RealtimeHub {
1108
1157
  return this.#config.onError;
1109
1158
  }
1110
1159
 
1160
+ /** The host's adapter error mapper (`SyncServerConfig.mapError`), if any. */
1161
+ get mapError(): SyncularErrorMapper | undefined {
1162
+ return this.#config.mapError;
1163
+ }
1164
+
1111
1165
  requestContext(session: RealtimeSession): SyncRequestContext {
1112
1166
  return this.requestContextFor(session);
1113
1167
  }
@@ -69,7 +69,7 @@ export interface SqliteImageInput {
69
69
  * a real SQLite engine, which is not available on every runtime. The core
70
70
  * takes the builder as an optional capability rather than importing a driver
71
71
  * on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
72
- * host omits it and serves the rows lane.
72
+ * host omits it and serves matching stored images or the rows lane.
73
73
  */
74
74
  export type SqliteImageBuilder = (
75
75
  input: SqliteImageInput,