@syncular/server 0.30.16 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/context.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import type { ScopeMap } from '@syncular/core';
8
8
  import type { BlobStore } from './blob-store.js';
9
9
  import type { CrdtMergerRegistry } from './crdt-merger.js';
10
- import type { SyncularErrorHandler } from './errors.js';
10
+ import type { SyncularErrorHandler, SyncularErrorMapper } from './errors.js';
11
11
  import type { SyncularServerEvents } from './events.js';
12
12
  import type { LeaseStore } from './lease-store.js';
13
13
  import type { AnyReactionPlanner } from './reactions.js';
@@ -157,11 +157,10 @@ export interface SyncServerConfig {
157
157
  */
158
158
  readonly blobUploadUrls?: BlobUploadPresignConfig;
159
159
  /**
160
- * §5.3 sqlite-image builder, injected so the pull path never
161
- * statically imports `bun:sqlite`. Absent ⇒ the sqlite-image lane is off
162
- * (bit-2 clients are served the rows lane) — the Workers/edge posture. A
163
- * Bun or Node host wires `buildSqliteImage` from
164
- * `@syncular/server/sqlite`.
160
+ * §5.3 sqlite-image builder. The host explicitly imports and supplies
161
+ * `buildSqliteImage` from `@syncular/server/sqlite` on Bun or Node.
162
+ * Absent ⇒ serve matching stored images or negotiate the rows lane.
163
+ * The neutral core never imports a runtime-specific builder.
165
164
  */
166
165
  readonly sqliteImageBuilder?: SqliteImageBuilder;
167
166
  readonly realtime?: RealtimeNotifier;
@@ -178,6 +177,17 @@ export interface SyncServerConfig {
178
177
  * handler never changes the response.
179
178
  */
180
179
  readonly onError?: SyncularErrorHandler;
180
+ /**
181
+ * Optional synchronous adapter error mapper (§10.2). After `onError`
182
+ * observes an adapter exception, `mapError` may return a catalog
183
+ * `SyncError` (including structured `details`, e.g. `retryAfterMs`) that
184
+ * the adapter answers instead of `sync.internal_error`; `undefined` keeps
185
+ * the default. A throw, a non-`SyncError` return, or malformed `details`
186
+ * JSON is contained as `sync.internal_error`. Unlike `onError`, this hook
187
+ * may affect the response. It does not change the remote-operation
188
+ * `operation.*` envelope.
189
+ */
190
+ readonly mapError?: SyncularErrorMapper;
181
191
  }
182
192
  /** Per-request context: the config plus host-authenticated identity. */
183
193
  export interface SyncRequestContext extends SyncServerConfig {
@@ -42,7 +42,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
42
42
  */
43
43
  import { type ScopeMap } from '@syncular/core';
44
44
  import type { CompiledSchema, CompiledTable } from './schema.js';
45
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
45
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
46
46
  export interface D1PreparedStatement {
47
47
  bind(...values: unknown[]): D1PreparedStatement;
48
48
  first<T = Record<string, unknown>>(): Promise<T | null>;
@@ -136,10 +136,11 @@ export declare class D1ServerStorage implements ServerStorage {
136
136
  listReferencedBlobIds(partition: string): Promise<string[]>;
137
137
  listClientRecords(partition: string): Promise<ClientRecord[]>;
138
138
  listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
139
+ getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
140
+ listRowChanges(partition: string, query: RowChangesQuery): Promise<RowChangeRecord[]>;
141
+ listRows(partition: string, query: RowListQuery): Promise<StoredRow[]>;
142
+ countRows(partition: string, table: string): Promise<number>;
143
+ listPushResults(partition: string, clientCommitId: string): Promise<ClientPushResult[]>;
139
144
  scopeActivity(partition: string, query: ScopeActivityQuery): Promise<ScopeCommitActivity[]>;
140
- getRowScopes(partition: string, table: string, rowId: string): Promise<{
141
- serverVersion: number;
142
- scopes: Record<string, string>;
143
- } | undefined>;
144
145
  listPartitions(): Promise<string[]>;
145
146
  }
@@ -45,9 +45,9 @@ import { StorageQueryError } from './storage-errors.js';
45
45
  import { decodeRow } from '@syncular/core';
46
46
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
47
47
  import { syncError } from './errors.js';
48
- import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
48
+ import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, 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, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
49
49
  import { matchesEffective } from './scopes.js';
50
- import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
50
+ import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
51
51
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
52
52
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
53
53
  function toStoredReaction(record) {
@@ -410,13 +410,16 @@ class D1Transaction {
410
410
  row,
411
411
  });
412
412
  const p = this.#partition;
413
- this.#buffer_(deleteSqliteRowScopesSql(compiled), [
414
- p,
415
- table,
416
- row.rowId,
417
- p,
418
- row.rowId,
419
- ]);
413
+ // Scope index maintenance: delete only the old keys the new scope map no
414
+ // longer carries, then insert the new keys. Keys present in both are left
415
+ // alone, so the `INSERT OR IGNORE` below finds them and writes nothing.
416
+ // This DELETE precedes the row upsert in the same atomic batch, and its
417
+ // subquery reads the row's pre-upsert scope map.
418
+ const scopesJson = JSON.stringify(row.scopes);
419
+ this.#buffer_(`${deleteSqliteRowScopesSql(compiled)}
420
+ AND (var, value) NOT IN (
421
+ SELECT key, value FROM json_each(?)
422
+ )`, [p, table, row.rowId, p, row.rowId, scopesJson]);
420
423
  this.#buffer_(upsertSql(compiled, 'sqlite'), upsertValues(compiled, p, row, 'sqlite'));
421
424
  for (const [variable, value] of Object.entries(row.scopes)) {
422
425
  this.#buffer_('INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)', [p, table, variable, value, row.rowId]);
@@ -1563,8 +1566,17 @@ export class D1ServerStorage {
1563
1566
  };
1564
1567
  }
1565
1568
  async putClientRecord(partition, record) {
1569
+ // Update the existing record in place, so the table keeps one row per
1570
+ // (partition, client_id). Every non-key field is written from the new
1571
+ // record; a delete-and-insert would drop and recreate the row instead.
1566
1572
  await this.#db
1567
- .prepare('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
1573
+ .prepare(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)
1574
+ ON CONFLICT(partition, client_id) DO UPDATE SET
1575
+ actor_id=excluded.actor_id,
1576
+ wire_version=excluded.wire_version,
1577
+ cursor=excluded.cursor,
1578
+ subscriptions=excluded.subscriptions,
1579
+ updated_at_ms=excluded.updated_at_ms`)
1568
1580
  .bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
1569
1581
  .run();
1570
1582
  }
@@ -1652,24 +1664,61 @@ export class D1ServerStorage {
1652
1664
  }));
1653
1665
  }
1654
1666
  async listCommitMetadata(partition, query) {
1667
+ // Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
1668
+ // ended by `beforeSeq` (exclusive upper bound) when paging older. An
1669
+ // omitted bound is the largest safe commitSeq, so one statement serves
1670
+ // both the bounded and unbounded read.
1671
+ const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
1655
1672
  const { results: rows } = query.table
1656
1673
  ? await this.#db
1657
1674
  .prepare(`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
1658
1675
  FROM sync_commits c
1659
- WHERE c.partition=? AND c.commit_seq>?
1676
+ WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
1660
1677
  AND EXISTS (SELECT 1 FROM sync_changes ch
1661
1678
  WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
1662
1679
  ORDER BY c.commit_seq DESC LIMIT ?`)
1663
- .bind(partition, query.afterSeq, query.table, query.limit)
1680
+ .bind(partition, query.afterSeq, beforeSeq, query.table, query.limit)
1664
1681
  .all()
1665
1682
  : await this.#db
1666
1683
  .prepare(`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
1667
1684
  FROM sync_commits
1668
- WHERE partition=? AND commit_seq>?
1685
+ WHERE partition=? AND commit_seq>? AND commit_seq<?
1669
1686
  ORDER BY commit_seq DESC LIMIT ?`)
1670
- .bind(partition, query.afterSeq, query.limit)
1687
+ .bind(partition, query.afterSeq, beforeSeq, query.limit)
1671
1688
  .all();
1672
1689
  const out = [];
1690
+ // `changeSample`: extra statement(s) for the whole page — the first
1691
+ // `sample` changes (change-index order) of every listed commit.
1692
+ const sampleCount = query.changeSample !== undefined &&
1693
+ Number.isInteger(query.changeSample) &&
1694
+ query.changeSample > 0
1695
+ ? query.changeSample
1696
+ : 0;
1697
+ const samples = new Map();
1698
+ if (sampleCount > 0 && rows.length > 0) {
1699
+ const seqs = rows.map((row) => row.commit_seq);
1700
+ // D1 caps the bound parameters of one statement, so a page larger than
1701
+ // that cap splits into a few chunk statements, never one per commit.
1702
+ const chunkSize = D1_MAX_BIND_PARAMS - 2;
1703
+ for (let offset = 0; offset < seqs.length; offset += chunkSize) {
1704
+ const chunk = seqs.slice(offset, offset + chunkSize);
1705
+ const { results: changes } = await this.#db
1706
+ .prepare(`SELECT commit_seq, tbl, row_id, op FROM sync_changes
1707
+ WHERE partition=? AND commit_seq IN (${placeholders(chunk.length)}) AND idx<?
1708
+ ORDER BY commit_seq, idx`)
1709
+ .bind(partition, ...chunk, sampleCount)
1710
+ .all();
1711
+ for (const change of changes) {
1712
+ const sample = samples.get(change.commit_seq) ?? [];
1713
+ sample.push({
1714
+ table: change.tbl,
1715
+ rowId: change.row_id,
1716
+ op: change.op === 1 ? 'upsert' : 'delete',
1717
+ });
1718
+ samples.set(change.commit_seq, sample);
1719
+ }
1720
+ }
1721
+ }
1673
1722
  for (const row of rows) {
1674
1723
  const { results: changes } = await this.#db
1675
1724
  .prepare('SELECT tbl, count(*) AS n FROM sync_changes WHERE partition=? AND commit_seq=? GROUP BY tbl')
@@ -1683,10 +1732,95 @@ export class D1ServerStorage {
1683
1732
  createdAtMs: row.created_at_ms,
1684
1733
  changeCount: changes.reduce((sum, c) => sum + c.n, 0),
1685
1734
  tables: changes.map((c) => c.tbl),
1735
+ ...(sampleCount > 0
1736
+ ? { sample: samples.get(row.commit_seq) ?? [] }
1737
+ : {}),
1686
1738
  });
1687
1739
  }
1688
1740
  return out;
1689
1741
  }
1742
+ async getCommit(partition, commitSeq) {
1743
+ const meta = await this.#db
1744
+ .prepare('SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?')
1745
+ .bind(partition, commitSeq)
1746
+ .first();
1747
+ if (meta === null)
1748
+ return undefined;
1749
+ const { results: changeRows } = await this.#db
1750
+ .prepare('SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx')
1751
+ .bind(partition, commitSeq)
1752
+ .all();
1753
+ const changes = changeRows.map(toStoredChange);
1754
+ return {
1755
+ commitSeq,
1756
+ clientId: meta.client_id,
1757
+ clientCommitId: meta.client_commit_id,
1758
+ actorId: meta.actor_id,
1759
+ createdAtMs: meta.created_at_ms,
1760
+ changeCount: changes.length,
1761
+ tables: [...new Set(changes.map((change) => change.table))].sort(),
1762
+ changes,
1763
+ };
1764
+ }
1765
+ async listRowChanges(partition, query) {
1766
+ // Retained-log read for one row: no dedicated index (the write path pays
1767
+ // nothing for it), bounded by retention (§4.6).
1768
+ const { results: rows } = await this.#db
1769
+ .prepare(`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
1770
+ FROM sync_changes ch
1771
+ JOIN sync_commits c
1772
+ ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
1773
+ WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
1774
+ ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`)
1775
+ .bind(partition, query.table, query.rowId, query.limit)
1776
+ .all();
1777
+ return rows.map((row) => ({
1778
+ commitSeq: row.commit_seq,
1779
+ op: row.op === 1 ? 'upsert' : 'delete',
1780
+ ...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
1781
+ actorId: row.actor_id,
1782
+ clientId: row.client_id,
1783
+ createdAtMs: row.created_at_ms,
1784
+ }));
1785
+ }
1786
+ async listRows(partition, query) {
1787
+ // Keyset page over the row table's (partition, rowId) primary key: an
1788
+ // index range read, no scope filter, no per-row lookup.
1789
+ const { results } = await this.#schemaDatabase()
1790
+ .prepare(`SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
1791
+ FROM ${quoteIdent(this.table(query.table).name)}
1792
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
1793
+ ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`)
1794
+ .bind(partition, query.afterRowId ?? '', query.limit)
1795
+ .all();
1796
+ return results.map(toStoredRow);
1797
+ }
1798
+ async countRows(partition, table) {
1799
+ const record = await this.#schemaDatabase()
1800
+ .prepare(`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`)
1801
+ .bind(partition)
1802
+ .first();
1803
+ return record?.n ?? 0;
1804
+ }
1805
+ async listPushResults(partition, clientCommitId) {
1806
+ // Retained outcomes for one clientCommitId: no dedicated index, ordered
1807
+ // by client so the read is deterministic.
1808
+ const { results } = await this.#schemaDatabase()
1809
+ .prepare('SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id')
1810
+ .bind(partition, clientCommitId)
1811
+ .all();
1812
+ return results.map((record) => {
1813
+ try {
1814
+ return {
1815
+ clientId: record.client_id,
1816
+ result: deserializePushResult(record.result),
1817
+ };
1818
+ }
1819
+ catch {
1820
+ throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
1821
+ }
1822
+ });
1823
+ }
1690
1824
  async scopeActivity(partition, query) {
1691
1825
  const { results: rows } = await this.#db
1692
1826
  .prepare(`SELECT DISTINCT commit_seq, tbl FROM sync_change_scopes
@@ -1716,19 +1850,6 @@ export class D1ServerStorage {
1716
1850
  }
1717
1851
  return out;
1718
1852
  }
1719
- async getRowScopes(partition, table, rowId) {
1720
- const db = this.#schemaDatabase();
1721
- const record = await db
1722
- .prepare(selectRowScopesSql(this.table(table), 'sqlite'))
1723
- .bind(partition, rowId)
1724
- .first();
1725
- if (record === null)
1726
- return undefined;
1727
- return {
1728
- serverVersion: record.server_version,
1729
- scopes: JSON.parse(record.scopes),
1730
- };
1731
- }
1732
1853
  async listPartitions() {
1733
1854
  return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1734
1855
  }
package/dist/errors.d.ts CHANGED
@@ -44,12 +44,25 @@ export type SyncularErrorRoute = 'sync' | 'operations' | 'segments' | 'blobs' |
44
44
  export type SyncularErrorHandler = (error: unknown, context: {
45
45
  readonly route: SyncularErrorRoute;
46
46
  }) => void;
47
+ /**
48
+ * Optional synchronous error mapper. After `onError` has observed the
49
+ * original, `mapError` may translate an adapter exception to a catalog
50
+ * `SyncError` (with `details` metadata such as `retryAfterMs`) that the
51
+ * adapter answers instead of `sync.internal_error`. Return `undefined` to
52
+ * keep the default. A throw, a non-`SyncError` return, or `details` that is
53
+ * not a JSON document is contained as `sync.internal_error`.
54
+ */
55
+ export type SyncularErrorMapper = (error: unknown, context: {
56
+ readonly route: SyncularErrorRoute;
57
+ }) => SyncError | undefined;
47
58
  /**
48
59
  * The protocol error for an exception an adapter caught. A `SyncError`
49
- * passes through unchanged. Anything else goes to `onError` and becomes
50
- * `sync.internal_error` (§10.2), whose message never carries the exception
51
- * text.
60
+ * passes through unchanged. Anything else goes to `onError` and answers
61
+ * `sync.internal_error` (§10.2) unless the optional `mapError` hook returns a
62
+ * catalog `SyncError` to answer instead. The default `sync.internal_error`
63
+ * message never carries the exception text; a mapped error's message is the
64
+ * host's choice.
52
65
  */
53
- export declare function adapterSyncError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute): SyncError;
66
+ export declare function adapterSyncError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute, mapError?: SyncularErrorMapper): SyncError;
54
67
  /** Hand `error` to the host's `onError`; a throwing handler is contained. */
55
68
  export declare function reportError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute): void;
package/dist/errors.js CHANGED
@@ -272,10 +272,10 @@ export class SyncError extends Error {
272
272
  httpStatus;
273
273
  details;
274
274
  constructor(code, message, details) {
275
- const entry = ERROR_CATALOG[code];
276
- if (entry === undefined) {
275
+ if (!Object.hasOwn(ERROR_CATALOG, code)) {
277
276
  throw new Error(`unknown error code ${code} (not in the §10.2 catalog)`);
278
277
  }
278
+ const entry = ERROR_CATALOG[code];
279
279
  super(message ?? code);
280
280
  this.code = code;
281
281
  this.category = entry.category;
@@ -304,14 +304,39 @@ export function errorBody(error) {
304
304
  }
305
305
  /**
306
306
  * The protocol error for an exception an adapter caught. A `SyncError`
307
- * passes through unchanged. Anything else goes to `onError` and becomes
308
- * `sync.internal_error` (§10.2), whose message never carries the exception
309
- * text.
307
+ * passes through unchanged. Anything else goes to `onError` and answers
308
+ * `sync.internal_error` (§10.2) unless the optional `mapError` hook returns a
309
+ * catalog `SyncError` to answer instead. The default `sync.internal_error`
310
+ * message never carries the exception text; a mapped error's message is the
311
+ * host's choice.
310
312
  */
311
- export function adapterSyncError(error, onError, route) {
313
+ export function adapterSyncError(error, onError, route, mapError) {
312
314
  if (error instanceof SyncError)
313
315
  return error;
314
316
  reportError(error, onError, route);
317
+ try {
318
+ const mapped = mapError?.(error, { route });
319
+ if (mapped instanceof SyncError) {
320
+ const { code, message, details } = mapped;
321
+ let detailsValid = details === undefined;
322
+ if (!detailsValid && typeof details === 'string') {
323
+ JSON.parse(details);
324
+ detailsValid = true;
325
+ }
326
+ if (typeof code === 'string' &&
327
+ typeof message === 'string' &&
328
+ Object.hasOwn(ERROR_CATALOG, code) &&
329
+ detailsValid) {
330
+ // Reconstruct from the validated fields: the catalog owns
331
+ // retryability and status, so a mutated `SyncError` cannot smuggle a
332
+ // different entry onto the wire.
333
+ return new SyncError(code, message, details);
334
+ }
335
+ }
336
+ }
337
+ catch {
338
+ // A throwing mapper or getter, or malformed details, keeps the default.
339
+ }
315
340
  return new SyncError('sync.internal_error', 'internal server error');
316
341
  }
317
342
  /** Hand `error` to the host's `onError`; a throwing handler is contained. */
@@ -3,8 +3,8 @@
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
6
- * N events in memory and exposes a `query({type?, sinceMs?, clientId?,
7
- * actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
6
+ * N events in memory and exposes a `query({type?, partition?, sinceMs?,
7
+ * clientId?, actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
8
8
  * composes with any other sink through `composeEvents(...sinks)`, so a host
9
9
  * can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
10
10
  * event tail from the same emissions. Fire-and-forget discipline is
@@ -15,6 +15,10 @@ import { type SyncularServerEvent, type SyncularServerEvents } from './events.js
15
15
  export interface RingEventQuery {
16
16
  /** Restrict to one event `type` (e.g. `push.applied`). */
17
17
  readonly type?: SyncularServerEvent['type'];
18
+ /** Restrict to one partition (every event carries one). */
19
+ readonly partition?: string;
20
+ /** Restrict to the events of one sync request. */
21
+ readonly requestId?: string;
18
22
  /** Only events with `atMs >= sinceMs`. */
19
23
  readonly sinceMs?: number;
20
24
  /** Restrict to events carrying this `clientId` (not every type does). */
@@ -56,6 +60,12 @@ export declare class RingBufferEvents implements SyncularServerEvents {
56
60
  get capacity(): number;
57
61
  /** The number of events currently retained. */
58
62
  get size(): number;
63
+ /**
64
+ * The newest `atMs` among events the ring dropped (evicted when full, or
65
+ * cleared); `undefined` while nothing was dropped. A window that starts
66
+ * at or before it is incomplete.
67
+ */
68
+ get evictedThroughMs(): number | undefined;
59
69
  /**
60
70
  * Newest-first slice of the retained events. Filters by `type` and
61
71
  * `sinceMs`, then caps to `limit`. Returns a fresh array — the caller may
@@ -3,8 +3,8 @@
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
6
- * N events in memory and exposes a `query({type?, sinceMs?, clientId?,
7
- * actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
6
+ * N events in memory and exposes a `query({type?, partition?, sinceMs?,
7
+ * clientId?, actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
8
8
  * composes with any other sink through `composeEvents(...sinks)`, so a host
9
9
  * can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
10
10
  * event tail from the same emissions. Fire-and-forget discipline is
@@ -21,6 +21,12 @@ import { emitEvent, } from './events.js';
21
21
  export function matchesRingQuery(event, query) {
22
22
  if (query.type !== undefined && event.type !== query.type)
23
23
  return false;
24
+ if (query.partition !== undefined && event.partition !== query.partition) {
25
+ return false;
26
+ }
27
+ if (query.requestId !== undefined && event.requestId !== query.requestId) {
28
+ return false;
29
+ }
24
30
  if (query.sinceMs !== undefined && event.atMs < query.sinceMs)
25
31
  return false;
26
32
  const carrier = event;
@@ -46,6 +52,7 @@ export class RingBufferEvents {
46
52
  /** Index of the oldest element in the circular buffer. */
47
53
  #head = 0;
48
54
  #size = 0;
55
+ #evictedThroughMs;
49
56
  /** Live subscribers (the SSE tail). Each notify is guarded. */
50
57
  #listeners = new Set();
51
58
  constructor(options) {
@@ -62,6 +69,9 @@ export class RingBufferEvents {
62
69
  }
63
70
  else {
64
71
  // Full: overwrite the oldest and advance the head.
72
+ const evicted = this.#buffer[this.#head];
73
+ if (evicted !== undefined)
74
+ this.#markEvicted(evicted.atMs);
65
75
  this.#buffer[this.#head] = event;
66
76
  this.#head = (this.#head + 1) % this.#capacity;
67
77
  }
@@ -94,6 +104,19 @@ export class RingBufferEvents {
94
104
  get size() {
95
105
  return this.#size;
96
106
  }
107
+ /**
108
+ * The newest `atMs` among events the ring dropped (evicted when full, or
109
+ * cleared); `undefined` while nothing was dropped. A window that starts
110
+ * at or before it is incomplete.
111
+ */
112
+ get evictedThroughMs() {
113
+ return this.#evictedThroughMs;
114
+ }
115
+ #markEvicted(atMs) {
116
+ if (this.#evictedThroughMs === undefined || atMs > this.#evictedThroughMs) {
117
+ this.#evictedThroughMs = atMs;
118
+ }
119
+ }
97
120
  /**
98
121
  * Newest-first slice of the retained events. Filters by `type` and
99
122
  * `sinceMs`, then caps to `limit`. Returns a fresh array — the caller may
@@ -115,6 +138,8 @@ export class RingBufferEvents {
115
138
  }
116
139
  /** Drop all retained events. */
117
140
  clear() {
141
+ for (const event of this.#buffer)
142
+ this.#markEvicted(event.atMs);
118
143
  this.#buffer = [];
119
144
  this.#head = 0;
120
145
  this.#size = 0;
package/dist/events.d.ts CHANGED
@@ -28,11 +28,15 @@ export interface RequestHandledEvent {
28
28
  /**
29
29
  * `ok` — response streamed to END;
30
30
  * `schema_floor` — §2.4 required-schema answer;
31
- * `reset` — §2.1 log-epoch reset answer;
31
+ * `epoch_acquired` — §2.1 answer to a request without `logEpoch` (a new
32
+ * or wiped replica's first round);
33
+ * `reset` — §2.1 answer to a `logEpoch` that no longer matches the log;
32
34
  * `rejected` — request validation failed before any bytes (§1.7);
33
35
  * `error` — in-band ERROR frame (§1.6) or a thrown host failure.
34
36
  */
35
- readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
37
+ readonly outcome: 'ok' | 'schema_floor' | 'epoch_acquired' | 'reset' | 'rejected' | 'error';
38
+ /** The REQ_HEADER clientId; absent when the request failed to decode. */
39
+ readonly clientId?: string;
36
40
  /** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
37
41
  readonly errorCode?: string;
38
42
  readonly pushCommits: number;
@@ -60,6 +64,9 @@ export interface PushRejectedEvent extends PushEventBase {
60
64
  readonly type: 'push.rejected';
61
65
  readonly code: string;
62
66
  readonly opIndex: number;
67
+ /** The terminating operation's table and rowId. */
68
+ readonly table?: string;
69
+ readonly rowId?: string;
63
70
  /** True when the rejection was replayed from the idempotency cache. */
64
71
  readonly replay: boolean;
65
72
  /** Original host time for outcomes recorded by a metadata-aware server. */
@@ -71,6 +78,9 @@ export interface PushRejectedEvent extends PushEventBase {
71
78
  export interface PushConflictedEvent extends PushEventBase {
72
79
  readonly type: 'push.conflicted';
73
80
  readonly opIndex: number;
81
+ /** The conflicting operation's table and rowId. */
82
+ readonly table?: string;
83
+ readonly rowId?: string;
74
84
  /** True when the conflict was replayed from the idempotency cache. */
75
85
  readonly replay: boolean;
76
86
  readonly recordedAtMs?: number;
@@ -277,7 +287,15 @@ export interface LeaseRevokedEvent {
277
287
  readonly partition: string;
278
288
  readonly leaseId: string;
279
289
  }
280
- export type SyncularServerEvent = RequestHandledEvent | PushAppliedEvent | PushRejectedEvent | PushConflictedEvent | ReactionQueuedEvent | ReactionStartedEvent | ReactionRetriedEvent | ReactionCompletedEvent | ReactionDeadLetteredEvent | ReactionPruneCompletedEvent | PullServedEvent | SegmentDownloadedEvent | BlobUploadedEvent | BlobDownloadedEvent | BlobSweptEvent | RealtimeOpenedEvent | RealtimeClosedEvent | RealtimeDeltaEvent | RealtimeWakeEvent | PruneCompletedEvent | ScopesResolveFailedEvent | LeaseIssuedEvent | LeaseRevokedEvent;
290
+ /**
291
+ * Every event. `requestId` is present on the events emitted while serving
292
+ * one sync request (`request.handled`, `push.*`, `pull.served`,
293
+ * `lease.issued`, `reaction.queued`, `scopes.resolve_failed`) and shared by
294
+ * all of them, so a sink can group one request's events.
295
+ */
296
+ export type SyncularServerEvent = (RequestHandledEvent | PushAppliedEvent | PushRejectedEvent | PushConflictedEvent | ReactionQueuedEvent | ReactionStartedEvent | ReactionRetriedEvent | ReactionCompletedEvent | ReactionDeadLetteredEvent | ReactionPruneCompletedEvent | PullServedEvent | SegmentDownloadedEvent | BlobUploadedEvent | BlobDownloadedEvent | BlobSweptEvent | RealtimeOpenedEvent | RealtimeClosedEvent | RealtimeDeltaEvent | RealtimeWakeEvent | PruneCompletedEvent | ScopesResolveFailedEvent | LeaseIssuedEvent | LeaseRevokedEvent) & {
297
+ readonly requestId?: string;
298
+ };
281
299
  /**
282
300
  * The seam. Optional on the server config — when absent, no event object
283
301
  * is ever built. Implementations receive every event synchronously and
package/dist/handler.js CHANGED
@@ -253,11 +253,14 @@ function emitPushEvent(events, ctx, clientId, push, processed) {
253
253
  }
254
254
  // Rejected (§6.3): exactly one terminating operation record.
255
255
  const record = frame.results[0];
256
+ const op = record !== undefined ? push.operations[record.opIndex] : undefined;
257
+ const target = op !== undefined ? { table: op.table, rowId: op.rowId } : {};
256
258
  if (record?.status === 'conflict') {
257
259
  emitEvent(events, {
258
260
  type: 'push.conflicted',
259
261
  ...base,
260
262
  opIndex: record.opIndex,
263
+ ...target,
261
264
  replay: processed.replayed,
262
265
  ...(processed.recordedAtMs !== undefined
263
266
  ? { recordedAtMs: processed.recordedAtMs }
@@ -275,6 +278,7 @@ function emitPushEvent(events, ctx, clientId, push, processed) {
275
278
  ? record.code
276
279
  : 'sync.invalid_request',
277
280
  opIndex: record?.opIndex ?? 0,
281
+ ...target,
278
282
  replay: processed.replayed,
279
283
  ...(processed.recordedAtMs !== undefined
280
284
  ? { recordedAtMs: processed.recordedAtMs }
@@ -309,8 +313,10 @@ async function* streamResponse(plan, ctx, schema, report) {
309
313
  : {}),
310
314
  }, plan.wireVersion);
311
315
  if (plan.epochReset) {
312
- if (report !== undefined)
313
- report.outcome = 'reset';
316
+ if (report !== undefined) {
317
+ report.outcome =
318
+ plan.header.logEpoch === undefined ? 'epoch_acquired' : 'reset';
319
+ }
314
320
  yield END_FRAME_BYTES;
315
321
  return;
316
322
  }
@@ -498,15 +504,21 @@ async function* streamResponse(plan, ctx, schema, report) {
498
504
  * bytes are produced; adapters map them to HTTP JSON errors (§1.1).
499
505
  */
500
506
  export async function createSyncResponseStream(bytes, ctx) {
501
- const events = ctx.events;
502
- if (events === undefined)
507
+ const sink = ctx.events;
508
+ if (sink === undefined)
503
509
  return createStreamCore(bytes, ctx, undefined);
504
510
  // Instrumented path: measure with the ctx clock, count bytes at the
505
- // seam, emit exactly one `request.handled` per request.
511
+ // seam, emit exactly one `request.handled` per request. Every event of
512
+ // this request carries one `requestId`.
513
+ const requestId = crypto.randomUUID();
514
+ const events = {
515
+ emit: (event) => sink.emit({ ...event, requestId }),
516
+ };
517
+ const requestCtx = { ...ctx, events };
506
518
  const clock = clockOf(ctx);
507
519
  const startedAtMs = clock();
508
520
  try {
509
- return await createStreamCore(bytes, ctx, events, startedAtMs);
521
+ return await createStreamCore(bytes, requestCtx, events, startedAtMs);
510
522
  }
511
523
  catch (error) {
512
524
  emitEvent(events, {
@@ -617,6 +629,7 @@ async function* instrumentedStream(stream, ctx, events, plan, report, bytesIn, s
617
629
  bytesIn,
618
630
  bytesOut,
619
631
  outcome: report.outcome,
632
+ clientId: plan.header.clientId,
620
633
  ...(report.errorCode !== undefined
621
634
  ? { errorCode: report.errorCode }
622
635
  : {}),
@@ -39,7 +39,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
39
39
  import type { ScopeMap } from '@syncular/core';
40
40
  import { type PgExecutor } from './pg-executor.js';
41
41
  import type { CompiledSchema, CompiledTable } from './schema.js';
42
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
42
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
43
43
  /**
44
44
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
45
45
  *
@@ -136,10 +136,11 @@ export declare class PostgresServerStorage implements ServerStorage {
136
136
  listReferencedBlobIds(partition: string): Promise<string[]>;
137
137
  listClientRecords(partition: string): Promise<ClientRecord[]>;
138
138
  listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
139
+ getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
140
+ listRowChanges(partition: string, query: RowChangesQuery): Promise<RowChangeRecord[]>;
141
+ listRows(partition: string, query: RowListQuery): Promise<StoredRow[]>;
142
+ countRows(partition: string, table: string): Promise<number>;
143
+ listPushResults(partition: string, clientCommitId: string): Promise<ClientPushResult[]>;
139
144
  scopeActivity(partition: string, query: ScopeActivityQuery): Promise<ScopeCommitActivity[]>;
140
- getRowScopes(partition: string, table: string, rowId: string): Promise<{
141
- serverVersion: number;
142
- scopes: Record<string, string>;
143
- } | undefined>;
144
145
  listPartitions(): Promise<string[]>;
145
146
  }