@syncular/server 0.39.2 → 0.40.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.
@@ -1,7 +1,11 @@
1
1
  import { validateCommitPruneQuery } from './prune';
2
2
  import { StorageQueryError } from './storage-errors';
3
3
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
4
- import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage';
4
+ import {
5
+ clientRoundState,
6
+ PARTITION_TOUCH_INTERVAL_MS,
7
+ serveGateRefusal,
8
+ } from './storage';
5
9
  import { serveNotReadyError } from './readiness';
6
10
  /**
7
11
  * Postgres server storage: the production database path.
@@ -84,6 +88,7 @@ import {
84
88
  scanRowPageSql,
85
89
  indexSignature,
86
90
  schemaDdl,
91
+ serverIndexDdl,
87
92
  selectRowScopesSql,
88
93
  selectRowSql,
89
94
  prefetchRowsSql,
@@ -241,10 +246,16 @@ CREATE TABLE IF NOT EXISTS sync_clients(
241
246
  wire_version INTEGER NOT NULL DEFAULT 1,
242
247
  cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
243
248
  updated_at_ms BIGINT NOT NULL,
249
+ last_round_at_ms BIGINT, last_error_code TEXT,
250
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
244
251
  PRIMARY KEY(partition, client_id)
245
252
  );
246
253
  ALTER TABLE sync_clients
247
254
  ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
255
+ ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_round_at_ms BIGINT;
256
+ ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_error_code TEXT;
257
+ ALTER TABLE sync_clients
258
+ ADD COLUMN IF NOT EXISTS consecutive_failures INTEGER NOT NULL DEFAULT 0;
248
259
  CREATE TABLE IF NOT EXISTS sync_blob_refs(
249
260
  partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
250
261
  blob_id TEXT NOT NULL,
@@ -339,6 +350,76 @@ async function installCheckpointOn(
339
350
  );
340
351
  }
341
352
 
353
+ /**
354
+ * A scalar subquery over the table names in the JSON array bound to
355
+ * `namesParam`: the free-standing indexes of each table as a JSON array of
356
+ * `{tbl, name, unique, partial, columns}`. Parity with the SQLite/D1
357
+ * `origin === 'c'` filter: constraint-owned indexes (PRIMARY KEY, UNIQUE
358
+ * constraints) can only be removed through their constraint, so DROP INDEX on
359
+ * them would abort the transaction. Tables resolve through `to_regclass`,
360
+ * which follows the session's `search_path` like the row queries do.
361
+ */
362
+ function indexesSubquery(namesParam: string): string {
363
+ return `(SELECT coalesce(json_agg(json_build_object(
364
+ 'tbl', tables.name,
365
+ 'name', index_class.relname,
366
+ 'unique', index_meta.indisunique,
367
+ 'partial', index_meta.indpred IS NOT NULL,
368
+ 'columns', ARRAY(
369
+ SELECT attribute.attname::text
370
+ FROM unnest(index_meta.indkey::int2[])
371
+ WITH ORDINALITY AS key(attnum, ord)
372
+ LEFT JOIN pg_catalog.pg_attribute AS attribute
373
+ ON attribute.attrelid = index_meta.indrelid
374
+ AND attribute.attnum = key.attnum
375
+ WHERE key.ord <= index_meta.indnkeyatts
376
+ ORDER BY key.ord))), '[]'::json)::text
377
+ FROM jsonb_array_elements_text(${namesParam}::text::jsonb) AS tables(name)
378
+ JOIN pg_catalog.pg_index AS index_meta
379
+ ON index_meta.indrelid = to_regclass(tables.name)
380
+ JOIN pg_catalog.pg_class AS index_class
381
+ ON index_class.oid = index_meta.indexrelid
382
+ WHERE NOT index_meta.indisprimary
383
+ AND NOT EXISTS (
384
+ SELECT 1
385
+ FROM pg_catalog.pg_constraint AS owning_constraint
386
+ WHERE owning_constraint.conindid = index_meta.indexrelid
387
+ ))`;
388
+ }
389
+
390
+ /** Table name to index name to {@link indexSignature}, from {@link indexesSubquery} JSON. */
391
+ function parseIndexSignatures(
392
+ json: string,
393
+ ): Map<string, ReadonlyMap<string, string>> {
394
+ const byTable = new Map<string, Map<string, string>>();
395
+ for (const index of JSON.parse(json) as {
396
+ tbl: string;
397
+ name: string;
398
+ unique: boolean;
399
+ partial: boolean;
400
+ columns: (string | null)[];
401
+ }[]) {
402
+ const indexes = byTable.get(index.tbl) ?? new Map<string, string>();
403
+ indexes.set(
404
+ index.name,
405
+ indexSignature(index.columns, index.unique, index.partial),
406
+ );
407
+ byTable.set(index.tbl, indexes);
408
+ }
409
+ return byTable;
410
+ }
411
+
412
+ async function readIndexSignatures(
413
+ client: PgQueryable,
414
+ tableNames: readonly string[],
415
+ ): Promise<Map<string, ReadonlyMap<string, string>>> {
416
+ const { rows } = await client.query<{ indexes: string }>(
417
+ `SELECT ${indexesSubquery('$1')} AS indexes`,
418
+ [JSON.stringify(tableNames)],
419
+ );
420
+ return parseIndexSignatures(rows[0]?.indexes ?? '[]');
421
+ }
422
+
342
423
  /** RFC 0007 serve gate on either the pool or a transaction's client. */
343
424
  async function readServeGateOn(
344
425
  client: PgQueryable,
@@ -1584,6 +1665,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1584
1665
  layouts: unknown;
1585
1666
  incomplete: string;
1586
1667
  columns: string;
1668
+ indexes: string;
1587
1669
  }>(
1588
1670
  `SELECT meta.schema_version AS schema_version, meta.layouts AS layouts,
1589
1671
  (SELECT coalesce(json_agg(json_build_object(
@@ -1609,7 +1691,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1609
1691
  JOIN pg_attribute attribute
1610
1692
  ON attribute.attrelid = to_regclass(tables.name)
1611
1693
  WHERE attribute.attnum > 0
1612
- AND NOT attribute.attisdropped) AS columns
1694
+ AND NOT attribute.attisdropped) AS columns,
1695
+ ${indexesSubquery('$2')} AS indexes
1613
1696
  FROM (SELECT 1) AS one
1614
1697
  LEFT JOIN sync_schema_meta meta ON meta.id=1`,
1615
1698
  [schema.version, JSON.stringify([...schema.tables.keys()])],
@@ -1674,6 +1757,25 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1674
1757
  'postgres',
1675
1758
  );
1676
1759
  }
1760
+ // Server-only indexes reconcile at every open of a database already at
1761
+ // this version: they never reach a client, so no version bump is needed.
1762
+ // The unlocked read finds the common no-op case; the diff is recomputed
1763
+ // under the migration barrier before any DDL runs.
1764
+ const reconcile = serverIndexDdl(
1765
+ schema,
1766
+ parseIndexSignatures(marker.indexes),
1767
+ );
1768
+ if (reconcile.length > 0) {
1769
+ await this.#exec.transaction(async (client) => {
1770
+ await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
1771
+ for (const statement of serverIndexDdl(
1772
+ schema,
1773
+ await readIndexSignatures(client, [...schema.tables.keys()]),
1774
+ )) {
1775
+ await client.query(statement);
1776
+ }
1777
+ });
1778
+ }
1677
1779
  }
1678
1780
  if (stored === undefined || stored < schema.version) {
1679
1781
  // Introspect existing app tables, apply the migration subset
@@ -1721,6 +1823,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1721
1823
  const retiredTables = retiredTableNames(schema, layouts);
1722
1824
  const existing = new Map<string, ReadonlySet<string>>();
1723
1825
  const existingIndexes = new Map<string, ReadonlyMap<string, string>>();
1826
+ const tableIndexes = await readIndexSignatures(client, [
1827
+ ...schema.tables.keys(),
1828
+ ]);
1724
1829
  for (const table of schema.tables.values()) {
1725
1830
  const { rows } = await client.query<{ column_name: string }>(
1726
1831
  `SELECT column_name FROM information_schema.columns
@@ -1729,59 +1834,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1729
1834
  );
1730
1835
  if (rows.length > 0) {
1731
1836
  existing.set(table.name, new Set(rows.map((r) => r.column_name)));
1732
- // Parity with the SQLite/D1 `origin === 'c'` filter: only
1733
- // free-standing indexes enter the rebuild set. Constraint-owned
1734
- // indexes (PRIMARY KEY, UNIQUE constraints) can only be removed
1735
- // through their constraint, so DROP INDEX on them would abort
1736
- // the migration transaction.
1737
- const indexes = await client.query<{
1738
- index_name: string;
1739
- is_unique: boolean;
1740
- is_partial: boolean;
1741
- columns: (string | null)[];
1742
- }>(
1743
- `SELECT index_class.relname AS index_name,
1744
- index_meta.indisunique AS is_unique,
1745
- index_meta.indpred IS NOT NULL AS is_partial,
1746
- ARRAY(
1747
- SELECT attribute.attname::text
1748
- FROM unnest(index_meta.indkey::int2[])
1749
- WITH ORDINALITY AS key(attnum, ord)
1750
- LEFT JOIN pg_catalog.pg_attribute AS attribute
1751
- ON attribute.attrelid = table_class.oid
1752
- AND attribute.attnum = key.attnum
1753
- WHERE key.ord <= index_meta.indnkeyatts
1754
- ORDER BY key.ord
1755
- ) AS columns
1756
- FROM pg_catalog.pg_class AS table_class
1757
- JOIN pg_catalog.pg_namespace AS namespace
1758
- ON namespace.oid = table_class.relnamespace
1759
- JOIN pg_catalog.pg_index AS index_meta
1760
- ON index_meta.indrelid = table_class.oid
1761
- JOIN pg_catalog.pg_class AS index_class
1762
- ON index_class.oid = index_meta.indexrelid
1763
- WHERE namespace.nspname = current_schema()
1764
- AND table_class.relname = $1
1765
- AND NOT index_meta.indisprimary
1766
- AND NOT EXISTS (
1767
- SELECT 1
1768
- FROM pg_catalog.pg_constraint AS owning_constraint
1769
- WHERE owning_constraint.conindid = index_meta.indexrelid
1770
- )`,
1771
- [table.name],
1772
- );
1773
1837
  existingIndexes.set(
1774
1838
  table.name,
1775
- new Map(
1776
- indexes.rows.map((index) => [
1777
- index.index_name,
1778
- indexSignature(
1779
- index.columns,
1780
- index.is_unique,
1781
- index.is_partial,
1782
- ),
1783
- ]),
1784
- ),
1839
+ tableIndexes.get(table.name) ?? new Map(),
1785
1840
  );
1786
1841
  }
1787
1842
  }
@@ -2694,8 +2749,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2694
2749
  cursor: unknown;
2695
2750
  subscriptions: unknown;
2696
2751
  updated_at_ms: unknown;
2752
+ last_round_at_ms: unknown;
2753
+ last_error_code: string | null;
2754
+ consecutive_failures: unknown;
2697
2755
  }>(
2698
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2',
2756
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=$1 AND client_id=$2',
2699
2757
  [partition, clientId],
2700
2758
  );
2701
2759
  const record = rows[0];
@@ -2707,6 +2765,13 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2707
2765
  cursor: asNumber(record.cursor),
2708
2766
  updatedAtMs: asNumber(record.updated_at_ms),
2709
2767
  subscriptions: asJson<ClientSubscription[]>(record.subscriptions),
2768
+ ...clientRoundState(
2769
+ record.last_round_at_ms === null
2770
+ ? null
2771
+ : asNumber(record.last_round_at_ms),
2772
+ record.last_error_code,
2773
+ asNumber(record.consecutive_failures),
2774
+ ),
2710
2775
  };
2711
2776
  }
2712
2777
 
@@ -2715,13 +2780,15 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2715
2780
  record: ClientRecord,
2716
2781
  ): Promise<void> {
2717
2782
  await this.#exec.query(
2718
- `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms)
2719
- VALUES ($1,$2,$3,$4,$5,$6,$7)
2783
+ `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
2784
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$7)
2720
2785
  ON CONFLICT (partition, client_id) DO UPDATE
2721
2786
  SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
2722
2787
  cursor=EXCLUDED.cursor,
2723
2788
  subscriptions=EXCLUDED.subscriptions,
2724
- updated_at_ms=EXCLUDED.updated_at_ms`,
2789
+ updated_at_ms=EXCLUDED.updated_at_ms,
2790
+ last_round_at_ms=EXCLUDED.last_round_at_ms,
2791
+ last_error_code=NULL, consecutive_failures=0`,
2725
2792
  [
2726
2793
  partition,
2727
2794
  record.clientId,
@@ -2734,6 +2801,22 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2734
2801
  );
2735
2802
  }
2736
2803
 
2804
+ async recordClientRoundFailure(
2805
+ partition: string,
2806
+ clientId: string,
2807
+ actorId: string,
2808
+ errorCode: string,
2809
+ atMs: number,
2810
+ ): Promise<void> {
2811
+ await this.#exec.query(
2812
+ `UPDATE sync_clients
2813
+ SET last_round_at_ms=$1, last_error_code=$2,
2814
+ consecutive_failures=consecutive_failures+1
2815
+ WHERE partition=$3 AND client_id=$4 AND actor_id=$5`,
2816
+ [atMs, errorCode, partition, clientId, actorId],
2817
+ );
2818
+ }
2819
+
2737
2820
  async advanceClientCursor(
2738
2821
  partition: string,
2739
2822
  clientId: string,
@@ -2910,8 +2993,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2910
2993
  cursor: unknown;
2911
2994
  subscriptions: unknown;
2912
2995
  updated_at_ms: unknown;
2996
+ last_round_at_ms: unknown;
2997
+ last_error_code: string | null;
2998
+ consecutive_failures: unknown;
2913
2999
  }>(
2914
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC',
3000
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC',
2915
3001
  [partition],
2916
3002
  );
2917
3003
  return rows.map((r) => ({
@@ -2923,6 +3009,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2923
3009
  subscriptions: (typeof r.subscriptions === 'string'
2924
3010
  ? JSON.parse(r.subscriptions)
2925
3011
  : r.subscriptions) as ClientSubscription[],
3012
+ ...clientRoundState(
3013
+ r.last_round_at_ms === null ? null : asNumber(r.last_round_at_ms),
3014
+ r.last_error_code,
3015
+ asNumber(r.consecutive_failures),
3016
+ ),
2926
3017
  }));
2927
3018
  }
2928
3019
 
@@ -1196,3 +1196,37 @@ export function schemaDdl(
1196
1196
  // receiving table re-creates it.
1197
1197
  return [...drops, ...creates];
1198
1198
  }
1199
+
1200
+ /**
1201
+ * DDL that reconciles the server-only indexes of a database already at
1202
+ * `schema.version`, so declaring or changing a `serverIndexes` entry needs no
1203
+ * schema-version bump (server indexes never reach a client). It creates
1204
+ * missing server indexes and drops every Syncular-owned index the schema no
1205
+ * longer names. Declared `indexes` belong to the version bump and are never
1206
+ * created or rebuilt here.
1207
+ */
1208
+ export function serverIndexDdl(
1209
+ schema: CompiledSchema,
1210
+ existingIndexesByTable: ReadonlyMap<string, ReadonlyMap<string, string>>,
1211
+ ): string[] {
1212
+ const drops: string[] = [];
1213
+ const creates: string[] = [];
1214
+ for (const table of schema.tables.values()) {
1215
+ const existing = existingIndexesByTable.get(table.name);
1216
+ if (existing === undefined) continue;
1217
+ const desired = desiredIndexes(table);
1218
+ for (const indexName of existing.keys()) {
1219
+ if (
1220
+ indexName.startsWith(SYNC_INDEX_PREFIX) &&
1221
+ !desired.some((index) => index.name === indexName)
1222
+ ) {
1223
+ drops.push(dropIndexDdl(indexName));
1224
+ }
1225
+ }
1226
+ // `desiredIndexes` lists the declared indexes first, then the server ones.
1227
+ for (const index of desired.slice(table.indexes.length)) {
1228
+ if (!existing.has(index.name)) creates.push(index.ddl);
1229
+ }
1230
+ }
1231
+ return [...drops, ...creates];
1232
+ }
@@ -115,6 +115,8 @@ CREATE TABLE IF NOT EXISTS sync_clients(
115
115
  wire_version INTEGER NOT NULL DEFAULT 1,
116
116
  cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
117
117
  updated_at_ms INTEGER NOT NULL,
118
+ last_round_at_ms INTEGER, last_error_code TEXT,
119
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
118
120
  PRIMARY KEY(partition, client_id)
119
121
  );
120
122
  CREATE TABLE IF NOT EXISTS sync_blob_refs(
@@ -145,6 +147,13 @@ CREATE TABLE IF NOT EXISTS sync_writer_fence(
145
147
  );
146
148
  `;
147
149
 
150
+ /** Columns added to `sync_clients` after its first release, by column name. */
151
+ export const SYNC_CLIENT_ROUND_COLUMNS = {
152
+ last_round_at_ms: 'INTEGER',
153
+ last_error_code: 'TEXT',
154
+ consecutive_failures: 'INTEGER NOT NULL DEFAULT 0',
155
+ } as const;
156
+
148
157
  /** Split the DDL into individual statements (D1 applies them one by one). */
149
158
  export function sqliteDdlStatements(): string[] {
150
159
  return SQLITE_DDL.split(';')
@@ -2,7 +2,11 @@ import { retainRollbackFailure, rollbackAfterError } from './storage-errors';
2
2
  import { validateCommitPruneQuery } from './prune';
3
3
  import { StorageQueryError } from './storage-errors';
4
4
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
5
- import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage';
5
+ import {
6
+ clientRoundState,
7
+ PARTITION_TOUCH_INTERVAL_MS,
8
+ serveGateRefusal,
9
+ } from './storage';
6
10
  import { serveNotReadyError } from './readiness';
7
11
  /**
8
12
  * SQLite server storage over the shared synchronous driver.
@@ -45,6 +49,7 @@ import {
45
49
  type StoredColumnLayout,
46
50
  scanRowPageSql,
47
51
  schemaDdl,
52
+ serverIndexDdl,
48
53
  SQLITE_TABLE_INDEXES_SQL,
49
54
  type SqliteTableIndexRow,
50
55
  sqliteIndexSignatures,
@@ -64,6 +69,7 @@ import {
64
69
  LIST_SCOPE_VALUE_ROWS_SQLITE_SQL,
65
70
  placeholders,
66
71
  SQLITE_DDL,
72
+ SYNC_CLIENT_ROUND_COLUMNS,
67
73
  type SqliteChangeRecord,
68
74
  type SqliteCommitWindowRecord,
69
75
  type SqliteRowRecord,
@@ -550,6 +556,15 @@ export class SqliteServerStorage implements ServerStorage {
550
556
  'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
551
557
  );
552
558
  }
559
+ for (const [name, definition] of Object.entries(
560
+ SYNC_CLIENT_ROUND_COLUMNS,
561
+ )) {
562
+ if (!clientColumns.some((column) => column.name === name)) {
563
+ this.db.exec(
564
+ `ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
565
+ );
566
+ }
567
+ }
553
568
  const commitColumns = this.db
554
569
  .query<{ name: string }, []>('PRAGMA table_info("sync_commits")')
555
570
  .all();
@@ -649,6 +664,40 @@ END`);
649
664
  'sqlite',
650
665
  );
651
666
  }
667
+ // Server-only indexes reconcile at every open of a database already at
668
+ // this version: they never reach a client, so no version bump is needed.
669
+ const reconcile = serverIndexDdl(
670
+ schema,
671
+ new Map(
672
+ [...schema.tables.values()].map((table) => [
673
+ table.name,
674
+ new Map(
675
+ Object.entries(
676
+ sqliteIndexSignatures(
677
+ this.db
678
+ .query<SqliteTableIndexRow, [string]>(
679
+ SQLITE_TABLE_INDEXES_SQL,
680
+ )
681
+ .all(table.name),
682
+ ),
683
+ ),
684
+ ),
685
+ ]),
686
+ ),
687
+ );
688
+ if (reconcile.length > 0) {
689
+ await this.#serializeWrite(() => {
690
+ this.db.exec('BEGIN IMMEDIATE');
691
+ try {
692
+ for (const statement of reconcile) this.db.exec(statement);
693
+ this.db.exec('COMMIT');
694
+ } catch (error) {
695
+ rollbackAfterError(() => {
696
+ this.db.exec('ROLLBACK');
697
+ }, error);
698
+ }
699
+ });
700
+ }
652
701
  }
653
702
  if (marker === null || marker.schema_version < schema.version) {
654
703
  // Introspect existing app tables, then apply the migration subset
@@ -1815,10 +1864,13 @@ END`);
1815
1864
  cursor: number;
1816
1865
  subscriptions: string;
1817
1866
  updated_at_ms: number;
1867
+ last_round_at_ms: number | null;
1868
+ last_error_code: string | null;
1869
+ consecutive_failures: number;
1818
1870
  },
1819
1871
  [string, string]
1820
1872
  >(
1821
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
1873
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? AND client_id=?',
1822
1874
  )
1823
1875
  .get(partition, clientId);
1824
1876
  if (record === null) return undefined;
@@ -1829,6 +1881,11 @@ END`);
1829
1881
  cursor: record.cursor,
1830
1882
  updatedAtMs: record.updated_at_ms,
1831
1883
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
1884
+ ...clientRoundState(
1885
+ record.last_round_at_ms,
1886
+ record.last_error_code,
1887
+ record.consecutive_failures,
1888
+ ),
1832
1889
  };
1833
1890
  }
1834
1891
 
@@ -1838,7 +1895,7 @@ END`);
1838
1895
  ): Promise<void> {
1839
1896
  this.db
1840
1897
  .query(
1841
- 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
1898
+ 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)',
1842
1899
  )
1843
1900
  .run(
1844
1901
  partition,
@@ -1848,9 +1905,27 @@ END`);
1848
1905
  record.cursor,
1849
1906
  JSON.stringify(record.subscriptions),
1850
1907
  record.updatedAtMs,
1908
+ record.updatedAtMs,
1851
1909
  );
1852
1910
  }
1853
1911
 
1912
+ async recordClientRoundFailure(
1913
+ partition: string,
1914
+ clientId: string,
1915
+ actorId: string,
1916
+ errorCode: string,
1917
+ atMs: number,
1918
+ ): Promise<void> {
1919
+ this.db
1920
+ .query(
1921
+ `UPDATE sync_clients
1922
+ SET last_round_at_ms=?, last_error_code=?,
1923
+ consecutive_failures=consecutive_failures+1
1924
+ WHERE partition=? AND client_id=? AND actor_id=?`,
1925
+ )
1926
+ .run(atMs, errorCode, partition, clientId, actorId);
1927
+ }
1928
+
1854
1929
  async advanceClientCursor(
1855
1930
  partition: string,
1856
1931
  clientId: string,
@@ -2006,10 +2081,13 @@ END`);
2006
2081
  cursor: number;
2007
2082
  subscriptions: string;
2008
2083
  updated_at_ms: number;
2084
+ last_round_at_ms: number | null;
2085
+ last_error_code: string | null;
2086
+ consecutive_failures: number;
2009
2087
  },
2010
2088
  [string]
2011
2089
  >(
2012
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
2090
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
2013
2091
  )
2014
2092
  .all(partition);
2015
2093
  return records.map((record) => ({
@@ -2019,6 +2097,11 @@ END`);
2019
2097
  cursor: record.cursor,
2020
2098
  updatedAtMs: record.updated_at_ms,
2021
2099
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
2100
+ ...clientRoundState(
2101
+ record.last_round_at_ms,
2102
+ record.last_error_code,
2103
+ record.consecutive_failures,
2104
+ ),
2022
2105
  }));
2023
2106
  }
2024
2107
 
package/src/storage.ts CHANGED
@@ -305,6 +305,28 @@ export interface ClientRecord {
305
305
  readonly cursor: number;
306
306
  readonly updatedAtMs: number;
307
307
  readonly subscriptions: readonly ClientSubscription[];
308
+ /** When this client's last sync round finished, successfully or not. */
309
+ readonly lastRoundAtMs?: number;
310
+ /** Code of the last round when it failed; absent after a successful round. */
311
+ readonly lastErrorCode?: string;
312
+ /** Rounds that failed in a row since the last successful round. */
313
+ readonly consecutiveFailures?: number;
314
+ }
315
+
316
+ /** The round-state fields of a stored `sync_clients` row. */
317
+ export function clientRoundState(
318
+ lastRoundAtMs: number | null,
319
+ lastErrorCode: string | null,
320
+ consecutiveFailures: number,
321
+ ): Pick<
322
+ ClientRecord,
323
+ 'lastRoundAtMs' | 'lastErrorCode' | 'consecutiveFailures'
324
+ > {
325
+ return {
326
+ ...(lastRoundAtMs !== null ? { lastRoundAtMs } : {}),
327
+ ...(lastErrorCode !== null ? { lastErrorCode } : {}),
328
+ ...(consecutiveFailures > 0 ? { consecutiveFailures } : {}),
329
+ };
308
330
  }
309
331
 
310
332
  export interface CommitWindowQuery {
@@ -917,7 +939,24 @@ export interface ServerStorage {
917
939
  partition: string,
918
940
  clientId: string,
919
941
  ): Promise<ClientRecord | undefined>;
942
+ /**
943
+ * Write the record for a completed round. The round-state fields reset: the
944
+ * stored last round is a success at `updatedAtMs` with no failures.
945
+ */
920
946
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
947
+ /**
948
+ * Record a failed round on an existing record: set `lastRoundAtMs` and
949
+ * `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching
950
+ * actor; a missing record or another actor's record is unchanged, and
951
+ * `updatedAtMs` (the pruning liveness signal) is never touched.
952
+ */
953
+ recordClientRoundFailure(
954
+ partition: string,
955
+ clientId: string,
956
+ actorId: string,
957
+ errorCode: string,
958
+ atMs: number,
959
+ ): Promise<void>;
921
960
  /**
922
961
  * Atomically advance an existing record's cursor and activity timestamp.
923
962
  * Preserve registration fields; require matching actor and current log epoch.