@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.
@@ -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
  }
package/src/storage.ts CHANGED
@@ -379,14 +379,62 @@ export interface CommitMetadata {
379
379
  readonly createdAtMs: number;
380
380
  readonly changeCount: number;
381
381
  readonly tables: readonly string[];
382
+ /** The first `changeSample` changes in commit order, when requested. */
383
+ readonly sample?: readonly CommitChangeSummary[];
384
+ }
385
+
386
+ /** One change without its payload, for commit lists. */
387
+ export interface CommitChangeSummary {
388
+ readonly table: string;
389
+ readonly rowId: string;
390
+ readonly op: 'upsert' | 'delete';
382
391
  }
383
392
 
384
393
  export interface CommitMetadataQuery {
385
- /** Window `afterSeq < commitSeq`, oldest first. */
394
+ /** Window lower bound, exclusive: `afterSeq < commitSeq`. */
386
395
  readonly afterSeq: number;
396
+ /** Window upper bound, exclusive: `commitSeq < beforeSeq` (older pages). */
397
+ readonly beforeSeq?: number;
398
+ /** Newest first, at most `limit` commits. */
387
399
  readonly limit: number;
388
400
  /** Restrict to commits that touched this table (via the change index). */
389
401
  readonly table?: string;
402
+ /** Attach the first N changes of each commit as `sample`. */
403
+ readonly changeSample?: number;
404
+ }
405
+
406
+ export interface RowListQuery {
407
+ readonly table: string;
408
+ /** Resume after this rowId (exclusive); `null` = start of table. */
409
+ readonly afterRowId: string | null;
410
+ readonly limit: number;
411
+ }
412
+
413
+ /** A stored push outcome with the client that sent it. */
414
+ export interface ClientPushResult {
415
+ readonly clientId: string;
416
+ readonly result: StoredPushResult;
417
+ }
418
+
419
+ /** One retained commit with its changes, for the admin commit drill-down. */
420
+ export interface CommitDetail extends CommitMetadata {
421
+ readonly changes: readonly StoredChange[];
422
+ }
423
+
424
+ /** One retained change to a single row, for the admin row history. */
425
+ export interface RowChangeRecord {
426
+ readonly commitSeq: number;
427
+ readonly op: 'upsert' | 'delete';
428
+ readonly rowVersion?: number;
429
+ readonly actorId: string;
430
+ readonly clientId: string;
431
+ readonly createdAtMs: number;
432
+ }
433
+
434
+ export interface RowChangesQuery {
435
+ readonly table: string;
436
+ readonly rowId: string;
437
+ readonly limit: number;
390
438
  }
391
439
 
392
440
  /** One recent commit touching a scope key, for the admin scope activity view. */
@@ -921,8 +969,16 @@ export interface ServerStorage {
921
969
  * touched tables), the newest-oriented window an operator inspects.
922
970
  * `scopeActivity`: the recent commits touching one scope key, via the
923
971
  * change scope index (never a log scan).
924
- * `getRowScopes`: the (table, rowId) row's current server_version and
925
- * stored scopes without decoding its payload — the row inspector.
972
+ * `getCommit`: one retained commit with its changes and payloads;
973
+ * `undefined` when the commit does not exist or was pruned.
974
+ * `listRowChanges`: the retained changes to one row, newest first. It
975
+ * reads the partition's retained log without a dedicated index, so the
976
+ * write path pays nothing for it and its cost is bounded by retention.
977
+ * `listRows`: one table's rows ordered by rowId, without a scope filter.
978
+ * `countRows`: one table's row count.
979
+ * `listPushResults`: every stored idempotency outcome for one
980
+ * clientCommitId across the partition's clients, scanning the retained
981
+ * outcomes without a dedicated index.
926
982
  * `listPartitions`: the partition-only compatibility view of
927
983
  * `listPartitionRegistry`, sorted.
928
984
  */
@@ -935,13 +991,20 @@ export interface ServerStorage {
935
991
  partition: string,
936
992
  query: ScopeActivityQuery,
937
993
  ): Promise<ScopeCommitActivity[]>;
938
- getRowScopes?(
994
+ getCommit?(
939
995
  partition: string,
940
- table: string,
941
- rowId: string,
942
- ): Promise<
943
- { serverVersion: number; scopes: Record<string, string> } | undefined
944
- >;
996
+ commitSeq: number,
997
+ ): Promise<CommitDetail | undefined>;
998
+ listRowChanges?(
999
+ partition: string,
1000
+ query: RowChangesQuery,
1001
+ ): Promise<RowChangeRecord[]>;
1002
+ listRows?(partition: string, query: RowListQuery): Promise<StoredRow[]>;
1003
+ countRows?(partition: string, table: string): Promise<number>;
1004
+ listPushResults?(
1005
+ partition: string,
1006
+ clientCommitId: string,
1007
+ ): Promise<ClientPushResult[]>;
945
1008
  listPartitions(): Promise<string[]>;
946
1009
  }
947
1010