@syncular/server 0.31.0 → 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/realtime.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  syncError,
49
49
  } from './errors';
50
50
  import { emitEvent, type SyncularServerEvents } from './events';
51
+ import { composeEvents } from './events-ring';
51
52
  import { createSyncResponseStream } from './handler';
52
53
  import {
53
54
  type CompiledSchema,
@@ -907,8 +908,16 @@ export class RealtimeSession {
907
908
  }
908
909
  }
909
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
+
910
919
  export class RealtimeHub {
911
- readonly #config: RealtimeHubConfig;
920
+ #config: RealtimeHubConfig;
912
921
  readonly #schema: CompiledSchema;
913
922
  readonly #sessions = new Set<RealtimeSession>();
914
923
  /** §8.6 presence registry — ephemeral, in-memory, never persisted. */
@@ -928,10 +937,35 @@ export class RealtimeHub {
928
937
  );
929
938
  }
930
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
+
931
953
  get sessionCount(): number {
932
954
  return this.#sessions.size;
933
955
  }
934
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
+
935
969
  /** §8.6.2 published-document size cap (bytes). */
936
970
  get maxPresenceBytes(): number {
937
971
  return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
@@ -30,11 +30,18 @@ import {
30
30
  layoutsOf,
31
31
  migratePayload,
32
32
  parseLayouts,
33
+ quoteIdent,
33
34
  retiredTableNames,
34
35
  rewritePlan,
35
36
  rewriteRowSql,
36
37
  rewriteValues,
37
38
  SCHEMA_META_DDL_SQLITE,
39
+ SYNC_COLUMN_VERSIONS_COLUMN,
40
+ SYNC_PARTITION_COLUMN,
41
+ SYNC_PAYLOAD_COLUMN,
42
+ SYNC_ROW_ID_COLUMN,
43
+ SYNC_SCOPES_COLUMN,
44
+ SYNC_VERSION_COLUMN,
38
45
  type StoredColumnLayout,
39
46
  scanRowPageSql,
40
47
  schemaDdl,
@@ -51,9 +58,11 @@ import {
51
58
  deserializePushResult,
52
59
  placeholders,
53
60
  SQLITE_DDL,
61
+ type SqliteChangeRecord,
54
62
  type SqliteCommitWindowRecord,
55
63
  type SqliteRowRecord,
56
64
  serializePushResult,
65
+ toStoredChange,
57
66
  toStoredRow,
58
67
  } from './sqlite-dialect';
59
68
  import {
@@ -66,8 +75,11 @@ import type {
66
75
  AuthoritativeQueryValue,
67
76
  CheckpointDeclaration,
68
77
  ClientCursorInfo,
78
+ ClientPushResult,
69
79
  ClientRecord,
70
80
  ClientSubscription,
81
+ CommitChangeSummary,
82
+ CommitDetail,
71
83
  CommitMetadata,
72
84
  CommitMetadataQuery,
73
85
  CommitWindowQuery,
@@ -82,6 +94,9 @@ import type {
82
94
  ReactionFailureUpdate,
83
95
  ReactionListQuery,
84
96
  ReactionPruneQuery,
97
+ RowChangeRecord,
98
+ RowChangesQuery,
99
+ RowListQuery,
85
100
  RowScanQuery,
86
101
  ScopeActivityQuery,
87
102
  ScopeCommitActivity,
@@ -1949,7 +1964,11 @@ END`);
1949
1964
  partition: string,
1950
1965
  query: CommitMetadataQuery,
1951
1966
  ): Promise<CommitMetadata[]> {
1952
- // Newest-first window, resumed by `afterSeq` (exclusive lower bound).
1967
+ // Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
1968
+ // ended by `beforeSeq` (exclusive upper bound) when paging older. An
1969
+ // omitted bound is the largest safe commitSeq, so one statement serves
1970
+ // both the bounded and unbounded read.
1971
+ const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
1953
1972
  const rows = query.table
1954
1973
  ? this.db
1955
1974
  .query<
@@ -1960,16 +1979,16 @@ END`);
1960
1979
  actor_id: string;
1961
1980
  created_at_ms: number;
1962
1981
  },
1963
- [string, number, string, number]
1982
+ [string, number, number, string, number]
1964
1983
  >(
1965
1984
  `SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
1966
1985
  FROM sync_commits c
1967
- WHERE c.partition=? AND c.commit_seq>?
1986
+ WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
1968
1987
  AND EXISTS (SELECT 1 FROM sync_changes ch
1969
1988
  WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
1970
1989
  ORDER BY c.commit_seq DESC LIMIT ?`,
1971
1990
  )
1972
- .all(partition, query.afterSeq, query.table, query.limit)
1991
+ .all(partition, query.afterSeq, beforeSeq, query.table, query.limit)
1973
1992
  : this.db
1974
1993
  .query<
1975
1994
  {
@@ -1979,14 +1998,45 @@ END`);
1979
1998
  actor_id: string;
1980
1999
  created_at_ms: number;
1981
2000
  },
1982
- [string, number, number]
2001
+ [string, number, number, number]
1983
2002
  >(
1984
2003
  `SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
1985
2004
  FROM sync_commits
1986
- WHERE partition=? AND commit_seq>?
2005
+ WHERE partition=? AND commit_seq>? AND commit_seq<?
1987
2006
  ORDER BY commit_seq DESC LIMIT ?`,
1988
2007
  )
1989
- .all(partition, query.afterSeq, query.limit);
2008
+ .all(partition, query.afterSeq, beforeSeq, query.limit);
2009
+ // `changeSample`: one extra statement for the whole page — the first
2010
+ // `sample` changes (change-index order) of every listed commit.
2011
+ const sampleCount =
2012
+ query.changeSample !== undefined &&
2013
+ Number.isInteger(query.changeSample) &&
2014
+ query.changeSample > 0
2015
+ ? query.changeSample
2016
+ : 0;
2017
+ const samples = new Map<number, CommitChangeSummary[]>();
2018
+ if (sampleCount > 0 && rows.length > 0) {
2019
+ const seqs = rows.map((row) => row.commit_seq);
2020
+ const records = this.db
2021
+ .query<
2022
+ { commit_seq: number; tbl: string; row_id: string; op: number },
2023
+ (string | number)[]
2024
+ >(
2025
+ `SELECT commit_seq, tbl, row_id, op FROM sync_changes
2026
+ WHERE partition=? AND commit_seq IN (${placeholders(seqs.length)}) AND idx<?
2027
+ ORDER BY commit_seq, idx`,
2028
+ )
2029
+ .all(partition, ...seqs, sampleCount);
2030
+ for (const record of records) {
2031
+ const sample = samples.get(record.commit_seq) ?? [];
2032
+ sample.push({
2033
+ table: record.tbl,
2034
+ rowId: record.row_id,
2035
+ op: record.op === 1 ? 'upsert' : 'delete',
2036
+ });
2037
+ samples.set(record.commit_seq, sample);
2038
+ }
2039
+ }
1990
2040
  return rows.map((row) => {
1991
2041
  const changes = this.db
1992
2042
  .query<{ tbl: string; n: number }, [string, number]>(
@@ -2001,10 +2051,134 @@ END`);
2001
2051
  createdAtMs: row.created_at_ms,
2002
2052
  changeCount: changes.reduce((sum, c) => sum + c.n, 0),
2003
2053
  tables: changes.map((c) => c.tbl),
2054
+ ...(sampleCount > 0
2055
+ ? { sample: samples.get(row.commit_seq) ?? [] }
2056
+ : {}),
2004
2057
  };
2005
2058
  });
2006
2059
  }
2007
2060
 
2061
+ async getCommit(
2062
+ partition: string,
2063
+ commitSeq: number,
2064
+ ): Promise<CommitDetail | undefined> {
2065
+ const meta = this.db
2066
+ .query<
2067
+ {
2068
+ client_id: string;
2069
+ client_commit_id: string;
2070
+ actor_id: string;
2071
+ created_at_ms: number;
2072
+ },
2073
+ [string, number]
2074
+ >(
2075
+ 'SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?',
2076
+ )
2077
+ .get(partition, commitSeq);
2078
+ if (meta === null) return undefined;
2079
+ const changes = this.db
2080
+ .query<SqliteChangeRecord, [string, number]>(
2081
+ 'SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx',
2082
+ )
2083
+ .all(partition, commitSeq)
2084
+ .map(toStoredChange);
2085
+ return {
2086
+ commitSeq,
2087
+ clientId: meta.client_id,
2088
+ clientCommitId: meta.client_commit_id,
2089
+ actorId: meta.actor_id,
2090
+ createdAtMs: meta.created_at_ms,
2091
+ changeCount: changes.length,
2092
+ tables: [...new Set(changes.map((change) => change.table))].sort(),
2093
+ changes,
2094
+ };
2095
+ }
2096
+
2097
+ async listRowChanges(
2098
+ partition: string,
2099
+ query: RowChangesQuery,
2100
+ ): Promise<RowChangeRecord[]> {
2101
+ // Retained-log read for one row: no dedicated index (the write path pays
2102
+ // nothing for it), bounded by retention (§4.6).
2103
+ return this.db
2104
+ .query<
2105
+ {
2106
+ commit_seq: number;
2107
+ op: number;
2108
+ row_version: number | null;
2109
+ actor_id: string;
2110
+ client_id: string;
2111
+ created_at_ms: number;
2112
+ },
2113
+ [string, string, string, number]
2114
+ >(
2115
+ `SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
2116
+ FROM sync_changes ch
2117
+ JOIN sync_commits c
2118
+ ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
2119
+ WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
2120
+ ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`,
2121
+ )
2122
+ .all(partition, query.table, query.rowId, query.limit)
2123
+ .map((row) => ({
2124
+ commitSeq: row.commit_seq,
2125
+ op: row.op === 1 ? 'upsert' : 'delete',
2126
+ ...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
2127
+ actorId: row.actor_id,
2128
+ clientId: row.client_id,
2129
+ createdAtMs: row.created_at_ms,
2130
+ }));
2131
+ }
2132
+
2133
+ async listRows(partition: string, query: RowListQuery): Promise<StoredRow[]> {
2134
+ // Keyset page over the row table's (partition, rowId) primary key: an
2135
+ // index range read, no scope filter, no per-row lookup.
2136
+ return this.db
2137
+ .query<SqliteRowRecord, [string, string, number]>(
2138
+ `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
2139
+ FROM ${quoteIdent(this.table(query.table).name)}
2140
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
2141
+ ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`,
2142
+ )
2143
+ .all(partition, query.afterRowId ?? '', query.limit)
2144
+ .map(toStoredRow);
2145
+ }
2146
+
2147
+ async countRows(partition: string, table: string): Promise<number> {
2148
+ const record = this.db
2149
+ .query<{ n: number }, [string]>(
2150
+ `SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`,
2151
+ )
2152
+ .get(partition);
2153
+ return record?.n ?? 0;
2154
+ }
2155
+
2156
+ async listPushResults(
2157
+ partition: string,
2158
+ clientCommitId: string,
2159
+ ): Promise<ClientPushResult[]> {
2160
+ // Retained outcomes for one clientCommitId: no dedicated index, ordered
2161
+ // by client so the read is deterministic.
2162
+ return this.db
2163
+ .query<{ client_id: string; result: string }, [string, string]>(
2164
+ 'SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id',
2165
+ )
2166
+ .all(partition, clientCommitId)
2167
+ .map((record) => {
2168
+ try {
2169
+ return {
2170
+ clientId: record.client_id,
2171
+ result: deserializePushResult(record.result),
2172
+ };
2173
+ } catch {
2174
+ throw syncError(
2175
+ 'sync.idempotency_cache_miss',
2176
+ 'persisted push result unreadable (§6.3)',
2177
+ );
2178
+ }
2179
+ });
2180
+ }
2181
+
2008
2182
  async scopeActivity(
2009
2183
  partition: string,
2010
2184
  query: ScopeActivityQuery,
@@ -2044,25 +2218,6 @@ END`);
2044
2218
  return out;
2045
2219
  }
2046
2220
 
2047
- async getRowScopes(
2048
- partition: string,
2049
- table: string,
2050
- rowId: string,
2051
- ): Promise<
2052
- { serverVersion: number; scopes: Record<string, string> } | undefined
2053
- > {
2054
- const record = this.db
2055
- .query<{ server_version: number; scopes: string }, [string, string]>(
2056
- selectRowScopesSql(this.table(table), 'sqlite'),
2057
- )
2058
- .get(partition, rowId);
2059
- if (record === null) return undefined;
2060
- return {
2061
- serverVersion: record.server_version,
2062
- scopes: JSON.parse(record.scopes) as Record<string, string>,
2063
- };
2064
- }
2065
-
2066
2221
  async listPartitions(): Promise<string[]> {
2067
2222
  return (await this.listPartitionRegistry()).map((entry) => entry.partition);
2068
2223
  }