@syncular/server 0.39.2 → 0.41.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,
@@ -100,6 +105,7 @@ import type {
100
105
  ClientCursorInfo,
101
106
  ClientPushResult,
102
107
  ClientRecord,
108
+ ClientRecordWriteOptions,
103
109
  ClientSubscription,
104
110
  CommitChangeSummary,
105
111
  CommitDetail,
@@ -241,10 +247,16 @@ CREATE TABLE IF NOT EXISTS sync_clients(
241
247
  wire_version INTEGER NOT NULL DEFAULT 1,
242
248
  cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
243
249
  updated_at_ms BIGINT NOT NULL,
250
+ last_round_at_ms BIGINT, last_error_code TEXT,
251
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
244
252
  PRIMARY KEY(partition, client_id)
245
253
  );
246
254
  ALTER TABLE sync_clients
247
255
  ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
256
+ ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_round_at_ms BIGINT;
257
+ ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_error_code TEXT;
258
+ ALTER TABLE sync_clients
259
+ ADD COLUMN IF NOT EXISTS consecutive_failures INTEGER NOT NULL DEFAULT 0;
248
260
  CREATE TABLE IF NOT EXISTS sync_blob_refs(
249
261
  partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
250
262
  blob_id TEXT NOT NULL,
@@ -339,6 +351,76 @@ async function installCheckpointOn(
339
351
  );
340
352
  }
341
353
 
354
+ /**
355
+ * A scalar subquery over the table names in the JSON array bound to
356
+ * `namesParam`: the free-standing indexes of each table as a JSON array of
357
+ * `{tbl, name, unique, partial, columns}`. Parity with the SQLite/D1
358
+ * `origin === 'c'` filter: constraint-owned indexes (PRIMARY KEY, UNIQUE
359
+ * constraints) can only be removed through their constraint, so DROP INDEX on
360
+ * them would abort the transaction. Tables resolve through `to_regclass`,
361
+ * which follows the session's `search_path` like the row queries do.
362
+ */
363
+ function indexesSubquery(namesParam: string): string {
364
+ return `(SELECT coalesce(json_agg(json_build_object(
365
+ 'tbl', tables.name,
366
+ 'name', index_class.relname,
367
+ 'unique', index_meta.indisunique,
368
+ 'partial', index_meta.indpred IS NOT NULL,
369
+ 'columns', ARRAY(
370
+ SELECT attribute.attname::text
371
+ FROM unnest(index_meta.indkey::int2[])
372
+ WITH ORDINALITY AS key(attnum, ord)
373
+ LEFT JOIN pg_catalog.pg_attribute AS attribute
374
+ ON attribute.attrelid = index_meta.indrelid
375
+ AND attribute.attnum = key.attnum
376
+ WHERE key.ord <= index_meta.indnkeyatts
377
+ ORDER BY key.ord))), '[]'::json)::text
378
+ FROM jsonb_array_elements_text(${namesParam}::text::jsonb) AS tables(name)
379
+ JOIN pg_catalog.pg_index AS index_meta
380
+ ON index_meta.indrelid = to_regclass(tables.name)
381
+ JOIN pg_catalog.pg_class AS index_class
382
+ ON index_class.oid = index_meta.indexrelid
383
+ WHERE NOT index_meta.indisprimary
384
+ AND NOT EXISTS (
385
+ SELECT 1
386
+ FROM pg_catalog.pg_constraint AS owning_constraint
387
+ WHERE owning_constraint.conindid = index_meta.indexrelid
388
+ ))`;
389
+ }
390
+
391
+ /** Table name to index name to {@link indexSignature}, from {@link indexesSubquery} JSON. */
392
+ function parseIndexSignatures(
393
+ json: string,
394
+ ): Map<string, ReadonlyMap<string, string>> {
395
+ const byTable = new Map<string, Map<string, string>>();
396
+ for (const index of JSON.parse(json) as {
397
+ tbl: string;
398
+ name: string;
399
+ unique: boolean;
400
+ partial: boolean;
401
+ columns: (string | null)[];
402
+ }[]) {
403
+ const indexes = byTable.get(index.tbl) ?? new Map<string, string>();
404
+ indexes.set(
405
+ index.name,
406
+ indexSignature(index.columns, index.unique, index.partial),
407
+ );
408
+ byTable.set(index.tbl, indexes);
409
+ }
410
+ return byTable;
411
+ }
412
+
413
+ async function readIndexSignatures(
414
+ client: PgQueryable,
415
+ tableNames: readonly string[],
416
+ ): Promise<Map<string, ReadonlyMap<string, string>>> {
417
+ const { rows } = await client.query<{ indexes: string }>(
418
+ `SELECT ${indexesSubquery('$1')} AS indexes`,
419
+ [JSON.stringify(tableNames)],
420
+ );
421
+ return parseIndexSignatures(rows[0]?.indexes ?? '[]');
422
+ }
423
+
342
424
  /** RFC 0007 serve gate on either the pool or a transaction's client. */
343
425
  async function readServeGateOn(
344
426
  client: PgQueryable,
@@ -1584,6 +1666,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1584
1666
  layouts: unknown;
1585
1667
  incomplete: string;
1586
1668
  columns: string;
1669
+ indexes: string;
1587
1670
  }>(
1588
1671
  `SELECT meta.schema_version AS schema_version, meta.layouts AS layouts,
1589
1672
  (SELECT coalesce(json_agg(json_build_object(
@@ -1609,7 +1692,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1609
1692
  JOIN pg_attribute attribute
1610
1693
  ON attribute.attrelid = to_regclass(tables.name)
1611
1694
  WHERE attribute.attnum > 0
1612
- AND NOT attribute.attisdropped) AS columns
1695
+ AND NOT attribute.attisdropped) AS columns,
1696
+ ${indexesSubquery('$2')} AS indexes
1613
1697
  FROM (SELECT 1) AS one
1614
1698
  LEFT JOIN sync_schema_meta meta ON meta.id=1`,
1615
1699
  [schema.version, JSON.stringify([...schema.tables.keys()])],
@@ -1674,6 +1758,25 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1674
1758
  'postgres',
1675
1759
  );
1676
1760
  }
1761
+ // Server-only indexes reconcile at every open of a database already at
1762
+ // this version: they never reach a client, so no version bump is needed.
1763
+ // The unlocked read finds the common no-op case; the diff is recomputed
1764
+ // under the migration barrier before any DDL runs.
1765
+ const reconcile = serverIndexDdl(
1766
+ schema,
1767
+ parseIndexSignatures(marker.indexes),
1768
+ );
1769
+ if (reconcile.length > 0) {
1770
+ await this.#exec.transaction(async (client) => {
1771
+ await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
1772
+ for (const statement of serverIndexDdl(
1773
+ schema,
1774
+ await readIndexSignatures(client, [...schema.tables.keys()]),
1775
+ )) {
1776
+ await client.query(statement);
1777
+ }
1778
+ });
1779
+ }
1677
1780
  }
1678
1781
  if (stored === undefined || stored < schema.version) {
1679
1782
  // Introspect existing app tables, apply the migration subset
@@ -1721,6 +1824,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1721
1824
  const retiredTables = retiredTableNames(schema, layouts);
1722
1825
  const existing = new Map<string, ReadonlySet<string>>();
1723
1826
  const existingIndexes = new Map<string, ReadonlyMap<string, string>>();
1827
+ const tableIndexes = await readIndexSignatures(client, [
1828
+ ...schema.tables.keys(),
1829
+ ]);
1724
1830
  for (const table of schema.tables.values()) {
1725
1831
  const { rows } = await client.query<{ column_name: string }>(
1726
1832
  `SELECT column_name FROM information_schema.columns
@@ -1729,59 +1835,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1729
1835
  );
1730
1836
  if (rows.length > 0) {
1731
1837
  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
1838
  existingIndexes.set(
1774
1839
  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
- ),
1840
+ tableIndexes.get(table.name) ?? new Map(),
1785
1841
  );
1786
1842
  }
1787
1843
  }
@@ -2694,8 +2750,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2694
2750
  cursor: unknown;
2695
2751
  subscriptions: unknown;
2696
2752
  updated_at_ms: unknown;
2753
+ last_round_at_ms: unknown;
2754
+ last_error_code: string | null;
2755
+ consecutive_failures: unknown;
2697
2756
  }>(
2698
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2',
2757
+ '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
2758
  [partition, clientId],
2700
2759
  );
2701
2760
  const record = rows[0];
@@ -2707,21 +2766,34 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2707
2766
  cursor: asNumber(record.cursor),
2708
2767
  updatedAtMs: asNumber(record.updated_at_ms),
2709
2768
  subscriptions: asJson<ClientSubscription[]>(record.subscriptions),
2769
+ ...clientRoundState(
2770
+ record.last_round_at_ms === null
2771
+ ? null
2772
+ : asNumber(record.last_round_at_ms),
2773
+ record.last_error_code,
2774
+ asNumber(record.consecutive_failures),
2775
+ ),
2710
2776
  };
2711
2777
  }
2712
2778
 
2713
2779
  async putClientRecord(
2714
2780
  partition: string,
2715
2781
  record: ClientRecord,
2782
+ options?: ClientRecordWriteOptions,
2716
2783
  ): Promise<void> {
2717
2784
  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)
2785
+ `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
2786
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$7)
2720
2787
  ON CONFLICT (partition, client_id) DO UPDATE
2721
2788
  SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
2722
2789
  cursor=EXCLUDED.cursor,
2723
2790
  subscriptions=EXCLUDED.subscriptions,
2724
- updated_at_ms=EXCLUDED.updated_at_ms`,
2791
+ updated_at_ms=EXCLUDED.updated_at_ms,
2792
+ last_round_at_ms=EXCLUDED.last_round_at_ms${
2793
+ options?.keepRoundOutcome === true
2794
+ ? ''
2795
+ : ',\n last_error_code=NULL, consecutive_failures=0'
2796
+ }`,
2725
2797
  [
2726
2798
  partition,
2727
2799
  record.clientId,
@@ -2734,6 +2806,22 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2734
2806
  );
2735
2807
  }
2736
2808
 
2809
+ async recordClientRoundFailure(
2810
+ partition: string,
2811
+ clientId: string,
2812
+ actorId: string,
2813
+ errorCode: string,
2814
+ atMs: number,
2815
+ ): Promise<void> {
2816
+ await this.#exec.query(
2817
+ `UPDATE sync_clients
2818
+ SET last_round_at_ms=$1, last_error_code=$2,
2819
+ consecutive_failures=consecutive_failures+1
2820
+ WHERE partition=$3 AND client_id=$4 AND actor_id=$5`,
2821
+ [atMs, errorCode, partition, clientId, actorId],
2822
+ );
2823
+ }
2824
+
2737
2825
  async advanceClientCursor(
2738
2826
  partition: string,
2739
2827
  clientId: string,
@@ -2910,8 +2998,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2910
2998
  cursor: unknown;
2911
2999
  subscriptions: unknown;
2912
3000
  updated_at_ms: unknown;
3001
+ last_round_at_ms: unknown;
3002
+ last_error_code: string | null;
3003
+ consecutive_failures: unknown;
2913
3004
  }>(
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',
3005
+ '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
3006
  [partition],
2916
3007
  );
2917
3008
  return rows.map((r) => ({
@@ -2923,6 +3014,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2923
3014
  subscriptions: (typeof r.subscriptions === 'string'
2924
3015
  ? JSON.parse(r.subscriptions)
2925
3016
  : r.subscriptions) as ClientSubscription[],
3017
+ ...clientRoundState(
3018
+ r.last_round_at_ms === null ? null : asNumber(r.last_round_at_ms),
3019
+ r.last_error_code,
3020
+ asNumber(r.consecutive_failures),
3021
+ ),
2926
3022
  }));
2927
3023
  }
2928
3024
 
@@ -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,
@@ -83,6 +89,7 @@ import type {
83
89
  ClientCursorInfo,
84
90
  ClientPushResult,
85
91
  ClientRecord,
92
+ ClientRecordWriteOptions,
86
93
  ClientSubscription,
87
94
  CommitChangeSummary,
88
95
  CommitDetail,
@@ -550,6 +557,15 @@ export class SqliteServerStorage implements ServerStorage {
550
557
  'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
551
558
  );
552
559
  }
560
+ for (const [name, definition] of Object.entries(
561
+ SYNC_CLIENT_ROUND_COLUMNS,
562
+ )) {
563
+ if (!clientColumns.some((column) => column.name === name)) {
564
+ this.db.exec(
565
+ `ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
566
+ );
567
+ }
568
+ }
553
569
  const commitColumns = this.db
554
570
  .query<{ name: string }, []>('PRAGMA table_info("sync_commits")')
555
571
  .all();
@@ -649,6 +665,40 @@ END`);
649
665
  'sqlite',
650
666
  );
651
667
  }
668
+ // Server-only indexes reconcile at every open of a database already at
669
+ // this version: they never reach a client, so no version bump is needed.
670
+ const reconcile = serverIndexDdl(
671
+ schema,
672
+ new Map(
673
+ [...schema.tables.values()].map((table) => [
674
+ table.name,
675
+ new Map(
676
+ Object.entries(
677
+ sqliteIndexSignatures(
678
+ this.db
679
+ .query<SqliteTableIndexRow, [string]>(
680
+ SQLITE_TABLE_INDEXES_SQL,
681
+ )
682
+ .all(table.name),
683
+ ),
684
+ ),
685
+ ),
686
+ ]),
687
+ ),
688
+ );
689
+ if (reconcile.length > 0) {
690
+ await this.#serializeWrite(() => {
691
+ this.db.exec('BEGIN IMMEDIATE');
692
+ try {
693
+ for (const statement of reconcile) this.db.exec(statement);
694
+ this.db.exec('COMMIT');
695
+ } catch (error) {
696
+ rollbackAfterError(() => {
697
+ this.db.exec('ROLLBACK');
698
+ }, error);
699
+ }
700
+ });
701
+ }
652
702
  }
653
703
  if (marker === null || marker.schema_version < schema.version) {
654
704
  // Introspect existing app tables, then apply the migration subset
@@ -1815,10 +1865,13 @@ END`);
1815
1865
  cursor: number;
1816
1866
  subscriptions: string;
1817
1867
  updated_at_ms: number;
1868
+ last_round_at_ms: number | null;
1869
+ last_error_code: string | null;
1870
+ consecutive_failures: number;
1818
1871
  },
1819
1872
  [string, string]
1820
1873
  >(
1821
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
1874
+ '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
1875
  )
1823
1876
  .get(partition, clientId);
1824
1877
  if (record === null) return undefined;
@@ -1829,16 +1882,35 @@ END`);
1829
1882
  cursor: record.cursor,
1830
1883
  updatedAtMs: record.updated_at_ms,
1831
1884
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
1885
+ ...clientRoundState(
1886
+ record.last_round_at_ms,
1887
+ record.last_error_code,
1888
+ record.consecutive_failures,
1889
+ ),
1832
1890
  };
1833
1891
  }
1834
1892
 
1835
1893
  async putClientRecord(
1836
1894
  partition: string,
1837
1895
  record: ClientRecord,
1896
+ options?: ClientRecordWriteOptions,
1838
1897
  ): Promise<void> {
1839
1898
  this.db
1840
1899
  .query(
1841
- 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
1900
+ `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
1901
+ ON CONFLICT(partition, client_id) DO UPDATE SET
1902
+ actor_id=excluded.actor_id,
1903
+ wire_version=excluded.wire_version,
1904
+ cursor=excluded.cursor,
1905
+ subscriptions=excluded.subscriptions,
1906
+ updated_at_ms=excluded.updated_at_ms,
1907
+ last_round_at_ms=excluded.last_round_at_ms${
1908
+ options?.keepRoundOutcome === true
1909
+ ? ''
1910
+ : `,
1911
+ last_error_code=NULL,
1912
+ consecutive_failures=0`
1913
+ }`,
1842
1914
  )
1843
1915
  .run(
1844
1916
  partition,
@@ -1848,9 +1920,27 @@ END`);
1848
1920
  record.cursor,
1849
1921
  JSON.stringify(record.subscriptions),
1850
1922
  record.updatedAtMs,
1923
+ record.updatedAtMs,
1851
1924
  );
1852
1925
  }
1853
1926
 
1927
+ async recordClientRoundFailure(
1928
+ partition: string,
1929
+ clientId: string,
1930
+ actorId: string,
1931
+ errorCode: string,
1932
+ atMs: number,
1933
+ ): Promise<void> {
1934
+ this.db
1935
+ .query(
1936
+ `UPDATE sync_clients
1937
+ SET last_round_at_ms=?, last_error_code=?,
1938
+ consecutive_failures=consecutive_failures+1
1939
+ WHERE partition=? AND client_id=? AND actor_id=?`,
1940
+ )
1941
+ .run(atMs, errorCode, partition, clientId, actorId);
1942
+ }
1943
+
1854
1944
  async advanceClientCursor(
1855
1945
  partition: string,
1856
1946
  clientId: string,
@@ -2006,10 +2096,13 @@ END`);
2006
2096
  cursor: number;
2007
2097
  subscriptions: string;
2008
2098
  updated_at_ms: number;
2099
+ last_round_at_ms: number | null;
2100
+ last_error_code: string | null;
2101
+ consecutive_failures: number;
2009
2102
  },
2010
2103
  [string]
2011
2104
  >(
2012
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
2105
+ '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
2106
  )
2014
2107
  .all(partition);
2015
2108
  return records.map((record) => ({
@@ -2019,6 +2112,11 @@ END`);
2019
2112
  cursor: record.cursor,
2020
2113
  updatedAtMs: record.updated_at_ms,
2021
2114
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
2115
+ ...clientRoundState(
2116
+ record.last_round_at_ms,
2117
+ record.last_error_code,
2118
+ record.consecutive_failures,
2119
+ ),
2022
2120
  }));
2023
2121
  }
2024
2122
 
package/src/storage.ts CHANGED
@@ -305,6 +305,38 @@ 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
+ /** `putClientRecord` options. */
317
+ export interface ClientRecordWriteOptions {
318
+ /**
319
+ * Leave the stored round outcome (`lastErrorCode`, `consecutiveFailures`)
320
+ * untouched. Set for a completed round that carried a `ROUND_FAILURE` report
321
+ * (§1.5, §4.5). `lastRoundAtMs` still moves to this round's completion.
322
+ */
323
+ readonly keepRoundOutcome?: boolean;
324
+ }
325
+
326
+ /** The round-state fields of a stored `sync_clients` row. */
327
+ export function clientRoundState(
328
+ lastRoundAtMs: number | null,
329
+ lastErrorCode: string | null,
330
+ consecutiveFailures: number,
331
+ ): Pick<
332
+ ClientRecord,
333
+ 'lastRoundAtMs' | 'lastErrorCode' | 'consecutiveFailures'
334
+ > {
335
+ return {
336
+ ...(lastRoundAtMs !== null ? { lastRoundAtMs } : {}),
337
+ ...(lastErrorCode !== null ? { lastErrorCode } : {}),
338
+ ...(consecutiveFailures > 0 ? { consecutiveFailures } : {}),
339
+ };
308
340
  }
309
341
 
310
342
  export interface CommitWindowQuery {
@@ -917,7 +949,30 @@ export interface ServerStorage {
917
949
  partition: string,
918
950
  clientId: string,
919
951
  ): Promise<ClientRecord | undefined>;
920
- putClientRecord(partition: string, record: ClientRecord): Promise<void>;
952
+ /**
953
+ * Write the record for a completed round. The round-state fields reset: the
954
+ * stored last round is a success at `updatedAtMs` with no failures — unless
955
+ * `keepRoundOutcome` is set, which leaves the reported code and streak in
956
+ * place (the round carried a `ROUND_FAILURE` report, §1.5/§4.5).
957
+ */
958
+ putClientRecord(
959
+ partition: string,
960
+ record: ClientRecord,
961
+ options?: ClientRecordWriteOptions,
962
+ ): Promise<void>;
963
+ /**
964
+ * Record a failed round on an existing record: set `lastRoundAtMs` and
965
+ * `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching
966
+ * actor; a missing record or another actor's record is unchanged, and
967
+ * `updatedAtMs` (the pruning liveness signal) is never touched.
968
+ */
969
+ recordClientRoundFailure(
970
+ partition: string,
971
+ clientId: string,
972
+ actorId: string,
973
+ errorCode: string,
974
+ atMs: number,
975
+ ): Promise<void>;
921
976
  /**
922
977
  * Atomically advance an existing record's cursor and activity timestamp.
923
978
  * Preserve registration fields; require matching actor and current log epoch.