@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.
package/README.md CHANGED
@@ -135,7 +135,10 @@ the complete [backup and restore runbook](https://syncular.dev/server-operations
135
135
  A `SyncError` answers with its catalog code and status. By default, any other exception an
136
136
  adapter catches answers `sync.internal_error` and is handed to
137
137
  `SyncServerConfig.onError` exactly once; a throwing observer does not change
138
- the response. The same config accepts an optional synchronous `mapError` hook:
138
+ the response. A `SyncError` never reaches `onError`: observe a catalog failure
139
+ through the `request.handled` event (`outcome`, `errorCode`, `clientId`) or the
140
+ client's stored `lastErrorCode` / `consecutiveFailures` / `lastRoundAtMs`, which
141
+ `ServerStorage.recordClientRoundFailure` updates on every failed round. The same config accepts an optional synchronous `mapError` hook:
139
142
  after `onError` observes the original, `mapError` may return a catalog
140
143
  `SyncError` (with structured `details`, for example `retryAfterMs`) that the
141
144
  adapter answers instead. Return `undefined` to keep `sync.internal_error`. A
package/dist/admin.d.ts CHANGED
@@ -51,6 +51,12 @@ export interface AdminClient {
51
51
  readonly connected?: boolean;
52
52
  /** When the client's oldest open socket opened. */
53
53
  readonly connectedSinceMs?: number;
54
+ /** When its last sync round finished, successfully or not. */
55
+ readonly lastRoundAtMs?: number;
56
+ /** Catalog code of its last round; present only while that round failed. */
57
+ readonly lastErrorCode?: string;
58
+ /** Rounds failed in a row since its last success; absent at zero. */
59
+ readonly consecutiveFailures?: number;
54
60
  }
55
61
  export interface AdminListClientsOptions {
56
62
  /** Default 200. */
package/dist/admin.js CHANGED
@@ -926,6 +926,15 @@ export class SyncularAdmin {
926
926
  ? { connected: connectedSinceMs !== undefined }
927
927
  : {}),
928
928
  ...(connectedSinceMs !== undefined ? { connectedSinceMs } : {}),
929
+ ...(record.lastRoundAtMs !== undefined
930
+ ? { lastRoundAtMs: record.lastRoundAtMs }
931
+ : {}),
932
+ ...(record.lastErrorCode !== undefined
933
+ ? { lastErrorCode: record.lastErrorCode }
934
+ : {}),
935
+ ...(record.consecutiveFailures !== undefined
936
+ ? { consecutiveFailures: record.consecutiveFailures }
937
+ : {}),
929
938
  };
930
939
  };
931
940
  }
@@ -124,6 +124,7 @@ export declare class D1ServerStorage implements ServerStorage {
124
124
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
125
125
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
126
126
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
127
+ recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
127
128
  advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
128
129
  updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
129
130
  getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
@@ -1,7 +1,7 @@
1
1
  var _a;
2
2
  import { validateCommitPruneQuery } from './prune.js';
3
3
  import { StorageQueryError } from './storage-errors.js';
4
- import { PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
4
+ import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
5
5
  /**
6
6
  * Cloudflare D1 server storage for Workers deployments.
7
7
  *
@@ -46,9 +46,9 @@ import { PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
46
46
  import { decodeRow } from '@syncular/core';
47
47
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
48
48
  import { syncError } from './errors.js';
49
- import { assertAppendOnlyMigration, 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, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
49
+ import { assertAppendOnlyMigration, 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, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
50
50
  import { matchesEffective } from './scopes.js';
51
- import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
51
+ import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, serializePushResult, sqliteDdlStatements, SYNC_CLIENT_ROUND_COLUMNS, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
52
52
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
53
53
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
54
54
  function toStoredReaction(record) {
@@ -617,6 +617,11 @@ export class D1ServerStorage {
617
617
  if (!results.some((column) => column.name === 'wire_version')) {
618
618
  await this.#db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;');
619
619
  }
620
+ for (const [name, definition] of Object.entries(SYNC_CLIENT_ROUND_COLUMNS)) {
621
+ if (!results.some((column) => column.name === name)) {
622
+ await this.#db.exec(`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition};`);
623
+ }
624
+ }
620
625
  }
621
626
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
622
627
  table(name) {
@@ -713,6 +718,30 @@ export class D1ServerStorage {
713
718
  primaryKeyPosition: column.pk,
714
719
  })), 'sqlite');
715
720
  }
721
+ // A database from before the round-state columns gains them in place.
722
+ statementsExecuted++;
723
+ const clientColumns = await this.#db
724
+ .prepare('PRAGMA table_info("sync_clients")')
725
+ .all();
726
+ const missingRoundColumns = Object.entries(SYNC_CLIENT_ROUND_COLUMNS).filter(([name]) => !clientColumns.results.some((column) => column.name === name));
727
+ if (missingRoundColumns.length > 0) {
728
+ await batch(missingRoundColumns.map(([name, definition]) => this.#db.prepare(`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`)));
729
+ }
730
+ // Server-only indexes reconcile at every open of a database already at
731
+ // this version: they never reach a client, so no version bump is needed.
732
+ const indexes = new Map();
733
+ for (const table of tables) {
734
+ statementsExecuted++;
735
+ const { results } = await this.#db
736
+ .prepare(SQLITE_TABLE_INDEXES_SQL)
737
+ .bind(table.name)
738
+ .all();
739
+ indexes.set(table.name, new Map(Object.entries(sqliteIndexSignatures(results))));
740
+ }
741
+ const reconcile = serverIndexDdl(schema, indexes);
742
+ if (reconcile.length > 0) {
743
+ await batch(reconcile.map((sql) => this.#db.prepare(sql)));
744
+ }
716
745
  this.#tables = schema.tables;
717
746
  this.#schemaVersion = schema.version;
718
747
  return { complete: true, statementsExecuted };
@@ -816,12 +845,11 @@ export class D1ServerStorage {
816
845
  const columns = await this.#db
817
846
  .prepare('PRAGMA table_info("sync_clients")')
818
847
  .all();
819
- const needsWireVersion = !columns.results.some((column) => column.name === 'wire_version');
820
- if (!(await save({ ...state, phase: 'prepare', offset: 0 }, needsWireVersion
821
- ? [
822
- this.#db.prepare('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1'),
823
- ]
824
- : [])))
848
+ const missingColumns = Object.entries({
849
+ wire_version: 'INTEGER NOT NULL DEFAULT 1',
850
+ ...SYNC_CLIENT_ROUND_COLUMNS,
851
+ }).filter(([name]) => !columns.results.some((column) => column.name === name));
852
+ if (!(await save({ ...state, phase: 'prepare', offset: 0 }, missingColumns.map(([name, definition]) => this.#db.prepare(`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`)))))
825
853
  break;
826
854
  }
827
855
  else if (state.phase === 'prepare') {
@@ -1562,7 +1590,7 @@ export class D1ServerStorage {
1562
1590
  }
1563
1591
  async getClientRecord(partition, clientId) {
1564
1592
  const record = await this.#db
1565
- .prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
1593
+ .prepare('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=?')
1566
1594
  .bind(partition, clientId)
1567
1595
  .first();
1568
1596
  if (record === null)
@@ -1574,6 +1602,7 @@ export class D1ServerStorage {
1574
1602
  cursor: record.cursor,
1575
1603
  updatedAtMs: record.updated_at_ms,
1576
1604
  subscriptions: JSON.parse(record.subscriptions),
1605
+ ...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
1577
1606
  };
1578
1607
  }
1579
1608
  async putClientRecord(partition, record) {
@@ -1581,14 +1610,26 @@ export class D1ServerStorage {
1581
1610
  // (partition, client_id). Every non-key field is written from the new
1582
1611
  // record; a delete-and-insert would drop and recreate the row instead.
1583
1612
  await this.#db
1584
- .prepare(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)
1613
+ .prepare(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
1585
1614
  ON CONFLICT(partition, client_id) DO UPDATE SET
1586
1615
  actor_id=excluded.actor_id,
1587
1616
  wire_version=excluded.wire_version,
1588
1617
  cursor=excluded.cursor,
1589
1618
  subscriptions=excluded.subscriptions,
1590
- updated_at_ms=excluded.updated_at_ms`)
1591
- .bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
1619
+ updated_at_ms=excluded.updated_at_ms,
1620
+ last_round_at_ms=excluded.last_round_at_ms,
1621
+ last_error_code=NULL,
1622
+ consecutive_failures=0`)
1623
+ .bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs, record.updatedAtMs)
1624
+ .run();
1625
+ }
1626
+ async recordClientRoundFailure(partition, clientId, actorId, errorCode, atMs) {
1627
+ await this.#db
1628
+ .prepare(`UPDATE sync_clients
1629
+ SET last_round_at_ms=?, last_error_code=?,
1630
+ consecutive_failures=consecutive_failures+1
1631
+ WHERE partition=? AND client_id=? AND actor_id=?`)
1632
+ .bind(atMs, errorCode, partition, clientId, actorId)
1592
1633
  .run();
1593
1634
  }
1594
1635
  async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
@@ -1686,7 +1727,7 @@ export class D1ServerStorage {
1686
1727
  // -- admin/console read surface --------------------------------------------
1687
1728
  async listClientRecords(partition) {
1688
1729
  const { results } = await this.#db
1689
- .prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
1730
+ .prepare('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')
1690
1731
  .bind(partition)
1691
1732
  .all();
1692
1733
  return results.map((record) => ({
@@ -1696,6 +1737,7 @@ export class D1ServerStorage {
1696
1737
  cursor: record.cursor,
1697
1738
  updatedAtMs: record.updated_at_ms,
1698
1739
  subscriptions: JSON.parse(record.subscriptions),
1740
+ ...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
1699
1741
  }));
1700
1742
  }
1701
1743
  async listCommitMetadata(partition, query) {
package/dist/handler.js CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { DecodeError, decodeMessage, } from '@syncular/core';
13
13
  import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
14
- import { SyncError, syncError } from './errors.js';
14
+ import { reportError, SyncError, syncError } from './errors.js';
15
15
  import { emitEvent, } from './events.js';
16
16
  import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
17
17
  import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, prepareSections, preparedSection, } from './pull.js';
@@ -495,6 +495,7 @@ async function* streamResponse(plan, ctx, schema, report) {
495
495
  report.outcome = 'error';
496
496
  report.errorCode = error.code;
497
497
  }
498
+ await recordRoundFailure(ctx, plan.header.clientId, error);
498
499
  // §1.6: in-band ERROR, then END, nothing else.
499
500
  yield encodeResponseFrame({
500
501
  type: 'ERROR',
@@ -519,8 +520,15 @@ async function* streamResponse(plan, ctx, schema, report) {
519
520
  */
520
521
  export async function createSyncResponseStream(bytes, ctx) {
521
522
  const sink = ctx.events;
522
- if (sink === undefined)
523
- return createStreamCore(bytes, ctx, undefined);
523
+ if (sink === undefined) {
524
+ try {
525
+ return await createStreamCore(bytes, ctx, undefined);
526
+ }
527
+ catch (error) {
528
+ await recordRoundFailure(ctx, requestClientId(bytes), error);
529
+ throw error;
530
+ }
531
+ }
524
532
  // Instrumented path: measure with the ctx clock, count bytes at the
525
533
  // seam, emit exactly one `request.handled` per request. Every event of
526
534
  // this request carries one `requestId`.
@@ -535,6 +543,7 @@ export async function createSyncResponseStream(bytes, ctx) {
535
543
  return await createStreamCore(bytes, requestCtx, events, startedAtMs);
536
544
  }
537
545
  catch (error) {
546
+ const clientId = requestClientId(bytes);
538
547
  emitEvent(events, {
539
548
  type: 'request.handled',
540
549
  kind: 'sync',
@@ -545,11 +554,51 @@ export async function createSyncResponseStream(bytes, ctx) {
545
554
  bytesIn: bytes.length,
546
555
  bytesOut: 0,
547
556
  outcome: 'rejected',
557
+ ...(clientId !== undefined ? { clientId } : {}),
548
558
  errorCode: error instanceof SyncError ? error.code : 'internal',
549
559
  pushCommits: 0,
550
560
  pulled: false,
551
561
  subscriptions: 0,
552
562
  });
563
+ await recordRoundFailure(ctx, clientId, error);
564
+ throw error;
565
+ }
566
+ }
567
+ /** The `REQ_HEADER` clientId of a request that failed before planning. */
568
+ function requestClientId(bytes) {
569
+ try {
570
+ const message = decodeMessage(bytes);
571
+ const header = message.frames[0];
572
+ return message.msgKind === 'request' && header?.type === 'REQ_HEADER'
573
+ ? header.clientId
574
+ : undefined;
575
+ }
576
+ catch {
577
+ return undefined;
578
+ }
579
+ }
580
+ /**
581
+ * Record a failed round on the client's stored record so an operator can see
582
+ * a stuck client and why. A storage failure here is reported through
583
+ * `onError` and never replaces the round's own outcome.
584
+ */
585
+ async function recordRoundFailure(ctx, clientId, error) {
586
+ if (clientId === undefined)
587
+ return;
588
+ try {
589
+ await ctx.storage.recordClientRoundFailure(ctx.partition, clientId, ctx.actorId, error instanceof SyncError ? error.code : 'sync.internal_error', clockOf(ctx)());
590
+ }
591
+ catch (failure) {
592
+ reportError(failure, ctx.onError, 'sync');
593
+ }
594
+ }
595
+ /** Record a failure thrown while the response streams (after planning). */
596
+ async function* recordedStream(stream, ctx, clientId) {
597
+ try {
598
+ yield* stream;
599
+ }
600
+ catch (error) {
601
+ await recordRoundFailure(ctx, clientId, error);
553
602
  throw error;
554
603
  }
555
604
  }
@@ -623,9 +672,10 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
623
672
  for (const chunk of buffered)
624
673
  yield chunk;
625
674
  };
626
- if (events === undefined)
627
- return verified();
628
- return instrumentedStream(verified(), ctx, events, plan, report, bytes.length, startedAtMs);
675
+ if (events === undefined) {
676
+ return recordedStream(verified(), ctx, plan.header.clientId);
677
+ }
678
+ return recordedStream(instrumentedStream(verified(), ctx, events, plan, report, bytes.length, startedAtMs), ctx, plan.header.clientId);
629
679
  }
630
680
  /** Counts response bytes and emits `request.handled` when the stream
631
681
  * finishes — including a thrown host failure mid-stream (fail loud). */
@@ -69,7 +69,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDec
69
69
  * set, §5.9.5) as an index range, never a scan. `postgres-explain
70
70
  * .test.ts` asserts an `Index` node here too.
71
71
  */
72
- export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT 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 BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT 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 BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n writer_version BIGINT,\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 BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\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 BIGINT 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 JSONB 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 JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\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 BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\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 BIGINT 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 BIGINT NOT NULL DEFAULT 0,\n owner_epoch BIGINT NOT NULL DEFAULT 0,\n observed_rows BIGINT NOT NULL DEFAULT 0,\n updated_at_ms BIGINT 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";
72
+ export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT 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 BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT 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 BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n writer_version BIGINT,\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 BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\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 BIGINT 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 JSONB 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 JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\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 BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n last_round_at_ms BIGINT, last_error_code TEXT,\n consecutive_failures INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_round_at_ms BIGINT;\nALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_error_code TEXT;\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS consecutive_failures INTEGER NOT NULL DEFAULT 0;\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 BIGINT 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 BIGINT NOT NULL DEFAULT 0,\n owner_epoch BIGINT NOT NULL DEFAULT 0,\n observed_rows BIGINT NOT NULL DEFAULT 0,\n updated_at_ms BIGINT 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";
73
73
  export declare class PostgresServerStorage implements ServerStorage {
74
74
  #private;
75
75
  constructor(exec: PgExecutor);
@@ -130,6 +130,7 @@ export declare class PostgresServerStorage implements ServerStorage {
130
130
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
131
131
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
132
132
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
133
+ recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
133
134
  advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
134
135
  updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
135
136
  getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
@@ -1,11 +1,11 @@
1
1
  import { validateCommitPruneQuery } from './prune.js';
2
2
  import { StorageQueryError } from './storage-errors.js';
3
- import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage.js';
3
+ import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal, } from './storage.js';
4
4
  import { serveNotReadyError } from './readiness.js';
5
5
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
6
6
  import { syncError } from './errors.js';
7
7
  import { asBytes, asNumber, } from './pg-executor.js';
8
- import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, tableColumnNames, retiredTableNames, rewritePlan, columnSqlType, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, indexSignature, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
8
+ import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, tableColumnNames, retiredTableNames, rewritePlan, columnSqlType, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, indexSignature, schemaDdl, serverIndexDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
9
9
  import { matchesEffective, sha256Hex } from './scopes.js';
10
10
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
11
11
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -109,10 +109,16 @@ CREATE TABLE IF NOT EXISTS sync_clients(
109
109
  wire_version INTEGER NOT NULL DEFAULT 1,
110
110
  cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
111
111
  updated_at_ms BIGINT NOT NULL,
112
+ last_round_at_ms BIGINT, last_error_code TEXT,
113
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
112
114
  PRIMARY KEY(partition, client_id)
113
115
  );
114
116
  ALTER TABLE sync_clients
115
117
  ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
118
+ ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_round_at_ms BIGINT;
119
+ ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_error_code TEXT;
120
+ ALTER TABLE sync_clients
121
+ ADD COLUMN IF NOT EXISTS consecutive_failures INTEGER NOT NULL DEFAULT 0;
116
122
  CREATE TABLE IF NOT EXISTS sync_blob_refs(
117
123
  partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
118
124
  blob_id TEXT NOT NULL,
@@ -169,6 +175,56 @@ async function installCheckpointOn(client, partition, name, schemaVersion, nowMs
169
175
  sync_writer_fence.required_writer_version,
170
176
  EXCLUDED.required_writer_version)`, [partition, schemaVersion]);
171
177
  }
178
+ /**
179
+ * A scalar subquery over the table names in the JSON array bound to
180
+ * `namesParam`: the free-standing indexes of each table as a JSON array of
181
+ * `{tbl, name, unique, partial, columns}`. Parity with the SQLite/D1
182
+ * `origin === 'c'` filter: constraint-owned indexes (PRIMARY KEY, UNIQUE
183
+ * constraints) can only be removed through their constraint, so DROP INDEX on
184
+ * them would abort the transaction. Tables resolve through `to_regclass`,
185
+ * which follows the session's `search_path` like the row queries do.
186
+ */
187
+ function indexesSubquery(namesParam) {
188
+ return `(SELECT coalesce(json_agg(json_build_object(
189
+ 'tbl', tables.name,
190
+ 'name', index_class.relname,
191
+ 'unique', index_meta.indisunique,
192
+ 'partial', index_meta.indpred IS NOT NULL,
193
+ 'columns', ARRAY(
194
+ SELECT attribute.attname::text
195
+ FROM unnest(index_meta.indkey::int2[])
196
+ WITH ORDINALITY AS key(attnum, ord)
197
+ LEFT JOIN pg_catalog.pg_attribute AS attribute
198
+ ON attribute.attrelid = index_meta.indrelid
199
+ AND attribute.attnum = key.attnum
200
+ WHERE key.ord <= index_meta.indnkeyatts
201
+ ORDER BY key.ord))), '[]'::json)::text
202
+ FROM jsonb_array_elements_text(${namesParam}::text::jsonb) AS tables(name)
203
+ JOIN pg_catalog.pg_index AS index_meta
204
+ ON index_meta.indrelid = to_regclass(tables.name)
205
+ JOIN pg_catalog.pg_class AS index_class
206
+ ON index_class.oid = index_meta.indexrelid
207
+ WHERE NOT index_meta.indisprimary
208
+ AND NOT EXISTS (
209
+ SELECT 1
210
+ FROM pg_catalog.pg_constraint AS owning_constraint
211
+ WHERE owning_constraint.conindid = index_meta.indexrelid
212
+ ))`;
213
+ }
214
+ /** Table name to index name to {@link indexSignature}, from {@link indexesSubquery} JSON. */
215
+ function parseIndexSignatures(json) {
216
+ const byTable = new Map();
217
+ for (const index of JSON.parse(json)) {
218
+ const indexes = byTable.get(index.tbl) ?? new Map();
219
+ indexes.set(index.name, indexSignature(index.columns, index.unique, index.partial));
220
+ byTable.set(index.tbl, indexes);
221
+ }
222
+ return byTable;
223
+ }
224
+ async function readIndexSignatures(client, tableNames) {
225
+ const { rows } = await client.query(`SELECT ${indexesSubquery('$1')} AS indexes`, [JSON.stringify(tableNames)]);
226
+ return parseIndexSignatures(rows[0]?.indexes ?? '[]');
227
+ }
172
228
  /** RFC 0007 serve gate on either the pool or a transaction's client. */
173
229
  async function readServeGateOn(client, partition, runningSchemaVersion) {
174
230
  // One statement: every request reads the gate twice (entry and verify),
@@ -1069,7 +1125,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1069
1125
  JOIN pg_attribute attribute
1070
1126
  ON attribute.attrelid = to_regclass(tables.name)
1071
1127
  WHERE attribute.attnum > 0
1072
- AND NOT attribute.attisdropped) AS columns
1128
+ AND NOT attribute.attisdropped) AS columns,
1129
+ ${indexesSubquery('$2')} AS indexes
1073
1130
  FROM (SELECT 1) AS one
1074
1131
  LEFT JOIN sync_schema_meta meta ON meta.id=1`, [schema.version, JSON.stringify([...schema.tables.keys()])]);
1075
1132
  const marker = probe[0];
@@ -1105,6 +1162,19 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1105
1162
  primaryKeyPosition: column.pk ?? 0,
1106
1163
  })), 'postgres');
1107
1164
  }
1165
+ // Server-only indexes reconcile at every open of a database already at
1166
+ // this version: they never reach a client, so no version bump is needed.
1167
+ // The unlocked read finds the common no-op case; the diff is recomputed
1168
+ // under the migration barrier before any DDL runs.
1169
+ const reconcile = serverIndexDdl(schema, parseIndexSignatures(marker.indexes));
1170
+ if (reconcile.length > 0) {
1171
+ await this.#exec.transaction(async (client) => {
1172
+ await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
1173
+ for (const statement of serverIndexDdl(schema, await readIndexSignatures(client, [...schema.tables.keys()]))) {
1174
+ await client.query(statement);
1175
+ }
1176
+ });
1177
+ }
1108
1178
  }
1109
1179
  if (stored === undefined || stored < schema.version) {
1110
1180
  // Introspect existing app tables, apply the migration subset
@@ -1145,48 +1215,15 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1145
1215
  const retiredTables = retiredTableNames(schema, layouts);
1146
1216
  const existing = new Map();
1147
1217
  const existingIndexes = new Map();
1218
+ const tableIndexes = await readIndexSignatures(client, [
1219
+ ...schema.tables.keys(),
1220
+ ]);
1148
1221
  for (const table of schema.tables.values()) {
1149
1222
  const { rows } = await client.query(`SELECT column_name FROM information_schema.columns
1150
1223
  WHERE table_schema = current_schema() AND table_name = $1`, [table.name]);
1151
1224
  if (rows.length > 0) {
1152
1225
  existing.set(table.name, new Set(rows.map((r) => r.column_name)));
1153
- // Parity with the SQLite/D1 `origin === 'c'` filter: only
1154
- // free-standing indexes enter the rebuild set. Constraint-owned
1155
- // indexes (PRIMARY KEY, UNIQUE constraints) can only be removed
1156
- // through their constraint, so DROP INDEX on them would abort
1157
- // the migration transaction.
1158
- const indexes = await client.query(`SELECT index_class.relname AS index_name,
1159
- index_meta.indisunique AS is_unique,
1160
- index_meta.indpred IS NOT NULL AS is_partial,
1161
- ARRAY(
1162
- SELECT attribute.attname::text
1163
- FROM unnest(index_meta.indkey::int2[])
1164
- WITH ORDINALITY AS key(attnum, ord)
1165
- LEFT JOIN pg_catalog.pg_attribute AS attribute
1166
- ON attribute.attrelid = table_class.oid
1167
- AND attribute.attnum = key.attnum
1168
- WHERE key.ord <= index_meta.indnkeyatts
1169
- ORDER BY key.ord
1170
- ) AS columns
1171
- FROM pg_catalog.pg_class AS table_class
1172
- JOIN pg_catalog.pg_namespace AS namespace
1173
- ON namespace.oid = table_class.relnamespace
1174
- JOIN pg_catalog.pg_index AS index_meta
1175
- ON index_meta.indrelid = table_class.oid
1176
- JOIN pg_catalog.pg_class AS index_class
1177
- ON index_class.oid = index_meta.indexrelid
1178
- WHERE namespace.nspname = current_schema()
1179
- AND table_class.relname = $1
1180
- AND NOT index_meta.indisprimary
1181
- AND NOT EXISTS (
1182
- SELECT 1
1183
- FROM pg_catalog.pg_constraint AS owning_constraint
1184
- WHERE owning_constraint.conindid = index_meta.indexrelid
1185
- )`, [table.name]);
1186
- existingIndexes.set(table.name, new Map(indexes.rows.map((index) => [
1187
- index.index_name,
1188
- indexSignature(index.columns, index.is_unique, index.is_partial),
1189
- ])));
1226
+ existingIndexes.set(table.name, tableIndexes.get(table.name) ?? new Map());
1190
1227
  }
1191
1228
  }
1192
1229
  if (retiredTables.length > 0) {
@@ -1774,7 +1811,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1774
1811
  return scanRowsByIndexOn(this.#exec, this.table(query.table), partition, query);
1775
1812
  }
1776
1813
  async getClientRecord(partition, clientId) {
1777
- const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2', [partition, clientId]);
1814
+ const { rows } = await this.#exec.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=$1 AND client_id=$2', [partition, clientId]);
1778
1815
  const record = rows[0];
1779
1816
  if (record === undefined)
1780
1817
  return undefined;
@@ -1785,16 +1822,21 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1785
1822
  cursor: asNumber(record.cursor),
1786
1823
  updatedAtMs: asNumber(record.updated_at_ms),
1787
1824
  subscriptions: asJson(record.subscriptions),
1825
+ ...clientRoundState(record.last_round_at_ms === null
1826
+ ? null
1827
+ : asNumber(record.last_round_at_ms), record.last_error_code, asNumber(record.consecutive_failures)),
1788
1828
  };
1789
1829
  }
1790
1830
  async putClientRecord(partition, record) {
1791
- await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms)
1792
- VALUES ($1,$2,$3,$4,$5,$6,$7)
1831
+ await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
1832
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$7)
1793
1833
  ON CONFLICT (partition, client_id) DO UPDATE
1794
1834
  SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
1795
1835
  cursor=EXCLUDED.cursor,
1796
1836
  subscriptions=EXCLUDED.subscriptions,
1797
- updated_at_ms=EXCLUDED.updated_at_ms`, [
1837
+ updated_at_ms=EXCLUDED.updated_at_ms,
1838
+ last_round_at_ms=EXCLUDED.last_round_at_ms,
1839
+ last_error_code=NULL, consecutive_failures=0`, [
1798
1840
  partition,
1799
1841
  record.clientId,
1800
1842
  record.actorId,
@@ -1804,6 +1846,12 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1804
1846
  record.updatedAtMs,
1805
1847
  ]);
1806
1848
  }
1849
+ async recordClientRoundFailure(partition, clientId, actorId, errorCode, atMs) {
1850
+ await this.#exec.query(`UPDATE sync_clients
1851
+ SET last_round_at_ms=$1, last_error_code=$2,
1852
+ consecutive_failures=consecutive_failures+1
1853
+ WHERE partition=$3 AND client_id=$4 AND actor_id=$5`, [atMs, errorCode, partition, clientId, actorId]);
1854
+ }
1807
1855
  async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
1808
1856
  await this.#exec.query(`UPDATE sync_clients
1809
1857
  SET cursor=GREATEST(cursor, $1), updated_at_ms=GREATEST(updated_at_ms, $2)
@@ -1895,7 +1943,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1895
1943
  }
1896
1944
  // -- admin/console read surface --------------------------------------------
1897
1945
  async listClientRecords(partition) {
1898
- const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
1946
+ const { rows } = await this.#exec.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=$1 ORDER BY updated_at_ms DESC', [partition]);
1899
1947
  return rows.map((r) => ({
1900
1948
  clientId: r.client_id,
1901
1949
  actorId: r.actor_id,
@@ -1905,6 +1953,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1905
1953
  subscriptions: (typeof r.subscriptions === 'string'
1906
1954
  ? JSON.parse(r.subscriptions)
1907
1955
  : r.subscriptions),
1956
+ ...clientRoundState(r.last_round_at_ms === null ? null : asNumber(r.last_round_at_ms), r.last_error_code, asNumber(r.consecutive_failures)),
1908
1957
  }));
1909
1958
  }
1910
1959
  async listCommitMetadata(partition, query) {
@@ -404,3 +404,12 @@ export declare function rewritePlan(table: CompiledTable, oldLayout: readonly St
404
404
  * names and survive every bump.
405
405
  */
406
406
  export declare function schemaDdl(schema: CompiledSchema, existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>, dialect: RelationalDialect, existingIndexesByTable?: ReadonlyMap<string, ReadonlyMap<string, string>>): string[];
407
+ /**
408
+ * DDL that reconciles the server-only indexes of a database already at
409
+ * `schema.version`, so declaring or changing a `serverIndexes` entry needs no
410
+ * schema-version bump (server indexes never reach a client). It creates
411
+ * missing server indexes and drops every Syncular-owned index the schema no
412
+ * longer names. Declared `indexes` belong to the version bump and are never
413
+ * created or rebuilt here.
414
+ */
415
+ export declare function serverIndexDdl(schema: CompiledSchema, existingIndexesByTable: ReadonlyMap<string, ReadonlyMap<string, string>>): string[];
@@ -969,3 +969,33 @@ export function schemaDdl(schema, existingColumnsByTable, dialect, existingIndex
969
969
  // receiving table re-creates it.
970
970
  return [...drops, ...creates];
971
971
  }
972
+ /**
973
+ * DDL that reconciles the server-only indexes of a database already at
974
+ * `schema.version`, so declaring or changing a `serverIndexes` entry needs no
975
+ * schema-version bump (server indexes never reach a client). It creates
976
+ * missing server indexes and drops every Syncular-owned index the schema no
977
+ * longer names. Declared `indexes` belong to the version bump and are never
978
+ * created or rebuilt here.
979
+ */
980
+ export function serverIndexDdl(schema, existingIndexesByTable) {
981
+ const drops = [];
982
+ const creates = [];
983
+ for (const table of schema.tables.values()) {
984
+ const existing = existingIndexesByTable.get(table.name);
985
+ if (existing === undefined)
986
+ continue;
987
+ const desired = desiredIndexes(table);
988
+ for (const indexName of existing.keys()) {
989
+ if (indexName.startsWith(SYNC_INDEX_PREFIX) &&
990
+ !desired.some((index) => index.name === indexName)) {
991
+ drops.push(dropIndexDdl(indexName));
992
+ }
993
+ }
994
+ // `desiredIndexes` lists the declared indexes first, then the server ones.
995
+ for (const index of desired.slice(table.indexes.length)) {
996
+ if (!existing.has(index.name))
997
+ creates.push(index.ddl);
998
+ }
999
+ }
1000
+ return [...drops, ...creates];
1001
+ }