@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.
@@ -36,7 +36,13 @@ import type { ScopeValueHistoryQuery, StoredChange, StoredCommit, StoredPushResu
36
36
  * inverted index. Declared here so every SQLite-family storage (and D1,
37
37
  * which applies this same DDL) keeps the same plan shape as Postgres.
38
38
  */
39
- export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n writer_version INTEGER,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark INTEGER NOT NULL DEFAULT 0,\n owner_epoch INTEGER NOT NULL DEFAULT 0,\n observed_rows INTEGER NOT NULL DEFAULT 0,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
39
+ export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n writer_version INTEGER,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n last_round_at_ms INTEGER, last_error_code TEXT,\n consecutive_failures INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark INTEGER NOT NULL DEFAULT 0,\n owner_epoch INTEGER NOT NULL DEFAULT 0,\n observed_rows INTEGER NOT NULL DEFAULT 0,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
40
+ /** Columns added to `sync_clients` after its first release, by column name. */
41
+ export declare const SYNC_CLIENT_ROUND_COLUMNS: {
42
+ readonly last_round_at_ms: "INTEGER";
43
+ readonly last_error_code: "TEXT";
44
+ readonly consecutive_failures: "INTEGER NOT NULL DEFAULT 0";
45
+ };
40
46
  /** Split the DDL into individual statements (D1 applies them one by one). */
41
47
  export declare function sqliteDdlStatements(): string[];
42
48
  /** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
@@ -89,6 +89,8 @@ CREATE TABLE IF NOT EXISTS sync_clients(
89
89
  wire_version INTEGER NOT NULL DEFAULT 1,
90
90
  cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
91
91
  updated_at_ms INTEGER NOT NULL,
92
+ last_round_at_ms INTEGER, last_error_code TEXT,
93
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
92
94
  PRIMARY KEY(partition, client_id)
93
95
  );
94
96
  CREATE TABLE IF NOT EXISTS sync_blob_refs(
@@ -118,6 +120,12 @@ CREATE TABLE IF NOT EXISTS sync_writer_fence(
118
120
  required_writer_version INTEGER NOT NULL
119
121
  );
120
122
  `;
123
+ /** Columns added to `sync_clients` after its first release, by column name. */
124
+ export const SYNC_CLIENT_ROUND_COLUMNS = {
125
+ last_round_at_ms: 'INTEGER',
126
+ last_error_code: 'TEXT',
127
+ consecutive_failures: 'INTEGER NOT NULL DEFAULT 0',
128
+ };
121
129
  /** Split the DDL into individual statements (D1 applies them one by one). */
122
130
  export function sqliteDdlStatements() {
123
131
  return SQLITE_DDL.split(';')
@@ -57,6 +57,7 @@ export declare class SqliteServerStorage implements ServerStorage {
57
57
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
58
58
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
59
59
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
60
+ recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
60
61
  advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
61
62
  updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
62
63
  getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
@@ -1,7 +1,7 @@
1
1
  import { retainRollbackFailure, rollbackAfterError } from './storage-errors.js';
2
2
  import { validateCommitPruneQuery } from './prune.js';
3
3
  import { StorageQueryError } from './storage-errors.js';
4
- import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage.js';
4
+ import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal, } from './storage.js';
5
5
  import { serveNotReadyError } from './readiness.js';
6
6
  /**
7
7
  * SQLite server storage over the shared synchronous driver.
@@ -13,9 +13,9 @@ import { serveNotReadyError } from './readiness.js';
13
13
  */
14
14
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
15
15
  import { syncError } from './errors.js';
16
- import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, SQLITE_TABLE_INDEXES_SQL, sqliteIndexSignatures, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
16
+ import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, serverIndexDdl, SQLITE_TABLE_INDEXES_SQL, sqliteIndexSignatures, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
17
17
  import { matchesEffective } from './scopes.js';
18
- import { collectCommitWindowPage, deserializePushResult, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, placeholders, SQLITE_DDL, serializePushResult, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
18
+ import { collectCommitWindowPage, deserializePushResult, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, placeholders, SQLITE_DDL, SYNC_CLIENT_ROUND_COLUMNS, serializePushResult, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
19
19
  import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
20
20
  import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
21
21
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -285,6 +285,11 @@ export class SqliteServerStorage {
285
285
  if (!clientColumns.some((column) => column.name === 'wire_version')) {
286
286
  this.db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1');
287
287
  }
288
+ for (const [name, definition] of Object.entries(SYNC_CLIENT_ROUND_COLUMNS)) {
289
+ if (!clientColumns.some((column) => column.name === name)) {
290
+ this.db.exec(`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`);
291
+ }
292
+ }
288
293
  const commitColumns = this.db
289
294
  .query('PRAGMA table_info("sync_commits")')
290
295
  .all();
@@ -357,6 +362,29 @@ END`);
357
362
  primaryKeyPosition: column.pk,
358
363
  })), 'sqlite');
359
364
  }
365
+ // Server-only indexes reconcile at every open of a database already at
366
+ // this version: they never reach a client, so no version bump is needed.
367
+ const reconcile = serverIndexDdl(schema, new Map([...schema.tables.values()].map((table) => [
368
+ table.name,
369
+ new Map(Object.entries(sqliteIndexSignatures(this.db
370
+ .query(SQLITE_TABLE_INDEXES_SQL)
371
+ .all(table.name)))),
372
+ ])));
373
+ if (reconcile.length > 0) {
374
+ await this.#serializeWrite(() => {
375
+ this.db.exec('BEGIN IMMEDIATE');
376
+ try {
377
+ for (const statement of reconcile)
378
+ this.db.exec(statement);
379
+ this.db.exec('COMMIT');
380
+ }
381
+ catch (error) {
382
+ rollbackAfterError(() => {
383
+ this.db.exec('ROLLBACK');
384
+ }, error);
385
+ }
386
+ });
387
+ }
360
388
  }
361
389
  if (marker === null || marker.schema_version < schema.version) {
362
390
  // Introspect existing app tables, then apply the migration subset
@@ -1133,7 +1161,7 @@ END`);
1133
1161
  }
1134
1162
  async getClientRecord(partition, clientId) {
1135
1163
  const record = this.db
1136
- .query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
1164
+ .query('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=?')
1137
1165
  .get(partition, clientId);
1138
1166
  if (record === null)
1139
1167
  return undefined;
@@ -1144,12 +1172,21 @@ END`);
1144
1172
  cursor: record.cursor,
1145
1173
  updatedAtMs: record.updated_at_ms,
1146
1174
  subscriptions: JSON.parse(record.subscriptions),
1175
+ ...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
1147
1176
  };
1148
1177
  }
1149
1178
  async putClientRecord(partition, record) {
1150
1179
  this.db
1151
- .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
1152
- .run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
1180
+ .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)')
1181
+ .run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs, record.updatedAtMs);
1182
+ }
1183
+ async recordClientRoundFailure(partition, clientId, actorId, errorCode, atMs) {
1184
+ this.db
1185
+ .query(`UPDATE sync_clients
1186
+ SET last_round_at_ms=?, last_error_code=?,
1187
+ consecutive_failures=consecutive_failures+1
1188
+ WHERE partition=? AND client_id=? AND actor_id=?`)
1189
+ .run(atMs, errorCode, partition, clientId, actorId);
1153
1190
  }
1154
1191
  async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
1155
1192
  this.db
@@ -1241,7 +1278,7 @@ END`);
1241
1278
  // -- admin/console read surface --------------------------------------------
1242
1279
  async listClientRecords(partition) {
1243
1280
  const records = this.db
1244
- .query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
1281
+ .query('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')
1245
1282
  .all(partition);
1246
1283
  return records.map((record) => ({
1247
1284
  clientId: record.client_id,
@@ -1250,6 +1287,7 @@ END`);
1250
1287
  cursor: record.cursor,
1251
1288
  updatedAtMs: record.updated_at_ms,
1252
1289
  subscriptions: JSON.parse(record.subscriptions),
1290
+ ...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
1253
1291
  }));
1254
1292
  }
1255
1293
  async listCommitMetadata(partition, query) {
package/dist/storage.d.ts CHANGED
@@ -235,7 +235,15 @@ export interface ClientRecord {
235
235
  readonly cursor: number;
236
236
  readonly updatedAtMs: number;
237
237
  readonly subscriptions: readonly ClientSubscription[];
238
- }
238
+ /** When this client's last sync round finished, successfully or not. */
239
+ readonly lastRoundAtMs?: number;
240
+ /** Code of the last round when it failed; absent after a successful round. */
241
+ readonly lastErrorCode?: string;
242
+ /** Rounds that failed in a row since the last successful round. */
243
+ readonly consecutiveFailures?: number;
244
+ }
245
+ /** The round-state fields of a stored `sync_clients` row. */
246
+ export declare function clientRoundState(lastRoundAtMs: number | null, lastErrorCode: string | null, consecutiveFailures: number): Pick<ClientRecord, 'lastRoundAtMs' | 'lastErrorCode' | 'consecutiveFailures'>;
239
247
  export interface CommitWindowQuery {
240
248
  readonly table: string;
241
249
  /**
@@ -680,7 +688,18 @@ export interface ServerStorage {
680
688
  */
681
689
  scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
682
690
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
691
+ /**
692
+ * Write the record for a completed round. The round-state fields reset: the
693
+ * stored last round is a success at `updatedAtMs` with no failures.
694
+ */
683
695
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
696
+ /**
697
+ * Record a failed round on an existing record: set `lastRoundAtMs` and
698
+ * `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching
699
+ * actor; a missing record or another actor's record is unchanged, and
700
+ * `updatedAtMs` (the pruning liveness signal) is never touched.
701
+ */
702
+ recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
684
703
  /**
685
704
  * Atomically advance an existing record's cursor and activity timestamp.
686
705
  * Preserve registration fields; require matching actor and current log epoch.
package/dist/storage.js CHANGED
@@ -38,6 +38,14 @@ export function serveGateTokenChanged(before, after) {
38
38
  }
39
39
  return undefined;
40
40
  }
41
+ /** The round-state fields of a stored `sync_clients` row. */
42
+ export function clientRoundState(lastRoundAtMs, lastErrorCode, consecutiveFailures) {
43
+ return {
44
+ ...(lastRoundAtMs !== null ? { lastRoundAtMs } : {}),
45
+ ...(lastErrorCode !== null ? { lastErrorCode } : {}),
46
+ ...(consecutiveFailures > 0 ? { consecutiveFailures } : {}),
47
+ };
48
+ }
41
49
  /** Durable partition identity refreshed after host authentication (§2.1). */
42
50
  /** Longest age of `lastAuthenticatedAtMs` that `touchPartition` leaves unwritten. */
43
51
  export const PARTITION_TOUCH_INTERVAL_MS = 60_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.39.2",
3
+ "version": "0.40.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.39.2"
71
+ "@syncular/core": "0.40.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/admin.ts CHANGED
@@ -90,6 +90,12 @@ export interface AdminClient {
90
90
  readonly connected?: boolean;
91
91
  /** When the client's oldest open socket opened. */
92
92
  readonly connectedSinceMs?: number;
93
+ /** When its last sync round finished, successfully or not. */
94
+ readonly lastRoundAtMs?: number;
95
+ /** Catalog code of its last round; present only while that round failed. */
96
+ readonly lastErrorCode?: string;
97
+ /** Rounds failed in a row since its last success; absent at zero. */
98
+ readonly consecutiveFailures?: number;
93
99
  }
94
100
 
95
101
  export interface AdminListClientsOptions {
@@ -1633,6 +1639,15 @@ export class SyncularAdmin {
1633
1639
  ? { connected: connectedSinceMs !== undefined }
1634
1640
  : {}),
1635
1641
  ...(connectedSinceMs !== undefined ? { connectedSinceMs } : {}),
1642
+ ...(record.lastRoundAtMs !== undefined
1643
+ ? { lastRoundAtMs: record.lastRoundAtMs }
1644
+ : {}),
1645
+ ...(record.lastErrorCode !== undefined
1646
+ ? { lastErrorCode: record.lastErrorCode }
1647
+ : {}),
1648
+ ...(record.consecutiveFailures !== undefined
1649
+ ? { consecutiveFailures: record.consecutiveFailures }
1650
+ : {}),
1636
1651
  };
1637
1652
  };
1638
1653
  }
package/src/d1-storage.ts CHANGED
@@ -1,7 +1,7 @@
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 } from './storage';
4
+ import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage';
5
5
  /**
6
6
  * Cloudflare D1 server storage for Workers deployments.
7
7
  *
@@ -78,6 +78,7 @@ import {
78
78
  type StoredColumnLayout,
79
79
  scanRowPageSql,
80
80
  schemaDdl,
81
+ serverIndexDdl,
81
82
  SQLITE_TABLE_INDEXES_SQL,
82
83
  type SqliteTableIndexRow,
83
84
  sqliteIndexSignatures,
@@ -104,6 +105,7 @@ import {
104
105
  type SqliteRowRecord,
105
106
  serializePushResult,
106
107
  sqliteDdlStatements,
108
+ SYNC_CLIENT_ROUND_COLUMNS,
107
109
  toStoredChange,
108
110
  toStoredRow,
109
111
  } from './sqlite-dialect';
@@ -999,6 +1001,15 @@ export class D1ServerStorage implements ServerStorage {
999
1001
  'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;',
1000
1002
  );
1001
1003
  }
1004
+ for (const [name, definition] of Object.entries(
1005
+ SYNC_CLIENT_ROUND_COLUMNS,
1006
+ )) {
1007
+ if (!results.some((column) => column.name === name)) {
1008
+ await this.#db.exec(
1009
+ `ALTER TABLE sync_clients ADD COLUMN ${name} ${definition};`,
1010
+ );
1011
+ }
1012
+ }
1002
1013
  }
1003
1014
 
1004
1015
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
@@ -1129,6 +1140,44 @@ export class D1ServerStorage implements ServerStorage {
1129
1140
  'sqlite',
1130
1141
  );
1131
1142
  }
1143
+ // A database from before the round-state columns gains them in place.
1144
+ statementsExecuted++;
1145
+ const clientColumns = await this.#db
1146
+ .prepare('PRAGMA table_info("sync_clients")')
1147
+ .all<{ name: string }>();
1148
+ const missingRoundColumns = Object.entries(
1149
+ SYNC_CLIENT_ROUND_COLUMNS,
1150
+ ).filter(
1151
+ ([name]) =>
1152
+ !clientColumns.results.some((column) => column.name === name),
1153
+ );
1154
+ if (missingRoundColumns.length > 0) {
1155
+ await batch(
1156
+ missingRoundColumns.map(([name, definition]) =>
1157
+ this.#db.prepare(
1158
+ `ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
1159
+ ),
1160
+ ),
1161
+ );
1162
+ }
1163
+ // Server-only indexes reconcile at every open of a database already at
1164
+ // this version: they never reach a client, so no version bump is needed.
1165
+ const indexes = new Map<string, ReadonlyMap<string, string>>();
1166
+ for (const table of tables) {
1167
+ statementsExecuted++;
1168
+ const { results } = await this.#db
1169
+ .prepare(SQLITE_TABLE_INDEXES_SQL)
1170
+ .bind(table.name)
1171
+ .all<SqliteTableIndexRow>();
1172
+ indexes.set(
1173
+ table.name,
1174
+ new Map(Object.entries(sqliteIndexSignatures(results))),
1175
+ );
1176
+ }
1177
+ const reconcile = serverIndexDdl(schema, indexes);
1178
+ if (reconcile.length > 0) {
1179
+ await batch(reconcile.map((sql) => this.#db.prepare(sql)));
1180
+ }
1132
1181
  this.#tables = schema.tables;
1133
1182
  this.#schemaVersion = schema.version;
1134
1183
  return { complete: true, statementsExecuted };
@@ -1248,19 +1297,20 @@ export class D1ServerStorage implements ServerStorage {
1248
1297
  const columns = await this.#db
1249
1298
  .prepare('PRAGMA table_info("sync_clients")')
1250
1299
  .all<{ name: string }>();
1251
- const needsWireVersion = !columns.results.some(
1252
- (column) => column.name === 'wire_version',
1300
+ const missingColumns = Object.entries({
1301
+ wire_version: 'INTEGER NOT NULL DEFAULT 1',
1302
+ ...SYNC_CLIENT_ROUND_COLUMNS,
1303
+ }).filter(
1304
+ ([name]) => !columns.results.some((column) => column.name === name),
1253
1305
  );
1254
1306
  if (
1255
1307
  !(await save(
1256
1308
  { ...state, phase: 'prepare', offset: 0 },
1257
- needsWireVersion
1258
- ? [
1259
- this.#db.prepare(
1260
- 'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
1261
- ),
1262
- ]
1263
- : [],
1309
+ missingColumns.map(([name, definition]) =>
1310
+ this.#db.prepare(
1311
+ `ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
1312
+ ),
1313
+ ),
1264
1314
  ))
1265
1315
  )
1266
1316
  break;
@@ -2333,7 +2383,7 @@ export class D1ServerStorage implements ServerStorage {
2333
2383
  ): Promise<ClientRecord | undefined> {
2334
2384
  const record = await this.#db
2335
2385
  .prepare(
2336
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
2386
+ '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=?',
2337
2387
  )
2338
2388
  .bind(partition, clientId)
2339
2389
  .first<{
@@ -2343,6 +2393,9 @@ export class D1ServerStorage implements ServerStorage {
2343
2393
  cursor: number;
2344
2394
  subscriptions: string;
2345
2395
  updated_at_ms: number;
2396
+ last_round_at_ms: number | null;
2397
+ last_error_code: string | null;
2398
+ consecutive_failures: number;
2346
2399
  }>();
2347
2400
  if (record === null) return undefined;
2348
2401
  return {
@@ -2352,6 +2405,11 @@ export class D1ServerStorage implements ServerStorage {
2352
2405
  cursor: record.cursor,
2353
2406
  updatedAtMs: record.updated_at_ms,
2354
2407
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
2408
+ ...clientRoundState(
2409
+ record.last_round_at_ms,
2410
+ record.last_error_code,
2411
+ record.consecutive_failures,
2412
+ ),
2355
2413
  };
2356
2414
  }
2357
2415
 
@@ -2364,13 +2422,16 @@ export class D1ServerStorage implements ServerStorage {
2364
2422
  // record; a delete-and-insert would drop and recreate the row instead.
2365
2423
  await this.#db
2366
2424
  .prepare(
2367
- `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)
2425
+ `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
2368
2426
  ON CONFLICT(partition, client_id) DO UPDATE SET
2369
2427
  actor_id=excluded.actor_id,
2370
2428
  wire_version=excluded.wire_version,
2371
2429
  cursor=excluded.cursor,
2372
2430
  subscriptions=excluded.subscriptions,
2373
- updated_at_ms=excluded.updated_at_ms`,
2431
+ updated_at_ms=excluded.updated_at_ms,
2432
+ last_round_at_ms=excluded.last_round_at_ms,
2433
+ last_error_code=NULL,
2434
+ consecutive_failures=0`,
2374
2435
  )
2375
2436
  .bind(
2376
2437
  partition,
@@ -2380,7 +2441,26 @@ export class D1ServerStorage implements ServerStorage {
2380
2441
  record.cursor,
2381
2442
  JSON.stringify(record.subscriptions),
2382
2443
  record.updatedAtMs,
2444
+ record.updatedAtMs,
2445
+ )
2446
+ .run();
2447
+ }
2448
+
2449
+ async recordClientRoundFailure(
2450
+ partition: string,
2451
+ clientId: string,
2452
+ actorId: string,
2453
+ errorCode: string,
2454
+ atMs: number,
2455
+ ): Promise<void> {
2456
+ await this.#db
2457
+ .prepare(
2458
+ `UPDATE sync_clients
2459
+ SET last_round_at_ms=?, last_error_code=?,
2460
+ consecutive_failures=consecutive_failures+1
2461
+ WHERE partition=? AND client_id=? AND actor_id=?`,
2383
2462
  )
2463
+ .bind(atMs, errorCode, partition, clientId, actorId)
2384
2464
  .run();
2385
2465
  }
2386
2466
 
@@ -2537,7 +2617,7 @@ export class D1ServerStorage implements ServerStorage {
2537
2617
  async listClientRecords(partition: string): Promise<ClientRecord[]> {
2538
2618
  const { results } = await this.#db
2539
2619
  .prepare(
2540
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
2620
+ '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',
2541
2621
  )
2542
2622
  .bind(partition)
2543
2623
  .all<{
@@ -2547,6 +2627,9 @@ export class D1ServerStorage implements ServerStorage {
2547
2627
  cursor: number;
2548
2628
  subscriptions: string;
2549
2629
  updated_at_ms: number;
2630
+ last_round_at_ms: number | null;
2631
+ last_error_code: string | null;
2632
+ consecutive_failures: number;
2550
2633
  }>();
2551
2634
  return results.map((record) => ({
2552
2635
  clientId: record.client_id,
@@ -2555,6 +2638,11 @@ export class D1ServerStorage implements ServerStorage {
2555
2638
  cursor: record.cursor,
2556
2639
  updatedAtMs: record.updated_at_ms,
2557
2640
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
2641
+ ...clientRoundState(
2642
+ record.last_round_at_ms,
2643
+ record.last_error_code,
2644
+ record.consecutive_failures,
2645
+ ),
2558
2646
  }));
2559
2647
  }
2560
2648
 
package/src/handler.ts CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  RESOLVER_OUTAGE,
31
31
  touchAuthenticatedPartition,
32
32
  } from './context';
33
- import { SyncError, syncError } from './errors';
33
+ import { reportError, SyncError, syncError } from './errors';
34
34
  import {
35
35
  emitEvent,
36
36
  type PullSubscriptionSummary,
@@ -734,6 +734,7 @@ async function* streamResponse(
734
734
  report.outcome = 'error';
735
735
  report.errorCode = error.code;
736
736
  }
737
+ await recordRoundFailure(ctx, plan.header.clientId, error);
737
738
  // §1.6: in-band ERROR, then END, nothing else.
738
739
  yield encodeResponseFrame(
739
740
  {
@@ -765,7 +766,14 @@ export async function createSyncResponseStream(
765
766
  ctx: SyncRequestContext,
766
767
  ): Promise<AsyncIterable<Uint8Array>> {
767
768
  const sink = ctx.events;
768
- if (sink === undefined) return createStreamCore(bytes, ctx, undefined);
769
+ if (sink === undefined) {
770
+ try {
771
+ return await createStreamCore(bytes, ctx, undefined);
772
+ } catch (error) {
773
+ await recordRoundFailure(ctx, requestClientId(bytes), error);
774
+ throw error;
775
+ }
776
+ }
769
777
  // Instrumented path: measure with the ctx clock, count bytes at the
770
778
  // seam, emit exactly one `request.handled` per request. Every event of
771
779
  // this request carries one `requestId`.
@@ -779,6 +787,7 @@ export async function createSyncResponseStream(
779
787
  try {
780
788
  return await createStreamCore(bytes, requestCtx, events, startedAtMs);
781
789
  } catch (error) {
790
+ const clientId = requestClientId(bytes);
782
791
  emitEvent(events, {
783
792
  type: 'request.handled',
784
793
  kind: 'sync',
@@ -789,11 +798,64 @@ export async function createSyncResponseStream(
789
798
  bytesIn: bytes.length,
790
799
  bytesOut: 0,
791
800
  outcome: 'rejected',
801
+ ...(clientId !== undefined ? { clientId } : {}),
792
802
  errorCode: error instanceof SyncError ? error.code : 'internal',
793
803
  pushCommits: 0,
794
804
  pulled: false,
795
805
  subscriptions: 0,
796
806
  });
807
+ await recordRoundFailure(ctx, clientId, error);
808
+ throw error;
809
+ }
810
+ }
811
+
812
+ /** The `REQ_HEADER` clientId of a request that failed before planning. */
813
+ function requestClientId(bytes: Uint8Array): string | undefined {
814
+ try {
815
+ const message = decodeMessage(bytes);
816
+ const header = message.frames[0];
817
+ return message.msgKind === 'request' && header?.type === 'REQ_HEADER'
818
+ ? header.clientId
819
+ : undefined;
820
+ } catch {
821
+ return undefined;
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Record a failed round on the client's stored record so an operator can see
827
+ * a stuck client and why. A storage failure here is reported through
828
+ * `onError` and never replaces the round's own outcome.
829
+ */
830
+ async function recordRoundFailure(
831
+ ctx: SyncRequestContext,
832
+ clientId: string | undefined,
833
+ error: unknown,
834
+ ): Promise<void> {
835
+ if (clientId === undefined) return;
836
+ try {
837
+ await ctx.storage.recordClientRoundFailure(
838
+ ctx.partition,
839
+ clientId,
840
+ ctx.actorId,
841
+ error instanceof SyncError ? error.code : 'sync.internal_error',
842
+ clockOf(ctx)(),
843
+ );
844
+ } catch (failure) {
845
+ reportError(failure, ctx.onError, 'sync');
846
+ }
847
+ }
848
+
849
+ /** Record a failure thrown while the response streams (after planning). */
850
+ async function* recordedStream(
851
+ stream: AsyncIterable<Uint8Array>,
852
+ ctx: SyncRequestContext,
853
+ clientId: string,
854
+ ): AsyncGenerator<Uint8Array> {
855
+ try {
856
+ yield* stream;
857
+ } catch (error) {
858
+ await recordRoundFailure(ctx, clientId, error);
797
859
  throw error;
798
860
  }
799
861
  }
@@ -883,15 +945,21 @@ async function createStreamCore(
883
945
  if (changed !== undefined) throw serveNotReadyError(changed);
884
946
  for (const chunk of buffered) yield chunk;
885
947
  };
886
- if (events === undefined) return verified();
887
- return instrumentedStream(
888
- verified(),
948
+ if (events === undefined) {
949
+ return recordedStream(verified(), ctx, plan.header.clientId);
950
+ }
951
+ return recordedStream(
952
+ instrumentedStream(
953
+ verified(),
954
+ ctx,
955
+ events,
956
+ plan,
957
+ report,
958
+ bytes.length,
959
+ startedAtMs,
960
+ ),
889
961
  ctx,
890
- events,
891
- plan,
892
- report,
893
- bytes.length,
894
- startedAtMs,
962
+ plan.header.clientId,
895
963
  );
896
964
  }
897
965