@syncular/server 0.15.21 → 0.15.23

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
@@ -173,10 +173,49 @@ Whole-commit validation checks a client-proposed commit; it does not grant
173
173
  authority. Privileged operations such as connecting facilities still belong in
174
174
  explicit server-authoritative commands.
175
175
 
176
+ ## Seed idempotency and safe revisioning
177
+
178
+ `seedMutations` uses the real push path and a stable `clientId`/`commitId`.
179
+ Both applied and rejected outcomes are terminal for that key. A rejected call
180
+ throws `SeedMutationError`, whose structured `code`, `opIndex`, `replayed`,
181
+ `recordedAtMs`, and `cacheIdentity` fields distinguish a fresh policy failure
182
+ from replay of an older cached rejection.
183
+
184
+ After correcting a development seed definition, advance an explicit seed
185
+ revision (`catalog-v1` to `catalog-v2`) and rerun it; do not delete the database
186
+ or unrelated rows. This does not apply to application commands. After an
187
+ unknown command outcome, reuse the original idempotency key because changing it
188
+ can execute the operation twice. The full inspection and recovery recipe is in
189
+ the public [server guide](https://syncular.dev/guide-server/#seeding-data).
190
+
176
191
  The task-oriented [concurrency and conflict-correction guide](https://syncular.dev/guide-concurrency-correction/)
177
192
  shows version projection, aggregate rollback, corrected replacement commits,
178
193
  explicit acknowledgement, and restart-safe recovery UI together.
179
194
 
195
+ ## Trusted relational-index lookups for authoritative commands
196
+
197
+ `scanRows` is a Syncular scope-index scan, never an unscoped administrative
198
+ query. Passing an empty or omitted `scopeFilter` throws the exported
199
+ `StorageQueryError` with `code: 'sync.storage.scan_requires_scope'` on
200
+ SQLite, PostgreSQL, and D1.
201
+
202
+ An authoritative command that needs an exact alternate lookup can instead use
203
+ the optional `storage.scanRowsByIndex(partition, query)` or transactional
204
+ `tx.scanRowsByIndex(query)` capability. The query names one declared
205
+ `TableSchema.indexes` entry, supplies one exact value per index column, uses an
206
+ exclusive `afterRowId`, and has a required limit from 1 through 1,000. All
207
+ shipped adapters implement it; transaction reads see staged writes and deletes.
208
+ It requires a materialized table.
209
+
210
+ This is a trusted `@syncular/server` storage capability, not SSP2: it creates no
211
+ scope variable, named-query obligation, subscription descriptor, or client
212
+ authority. Never expose table/index/value selection through a client-controlled
213
+ route. Custom adapters may omit the additive method; authoritative commands
214
+ must check for it and fail closed. See the public
215
+ [storage lookup guide](https://syncular.dev/server-storage/#choosing-the-right-row-lookup)
216
+ for a user-scoped key-grant table revoked through a Workspace index and for the
217
+ atomic reverse-index/queue fallback required by ordered or derived lookups.
218
+
180
219
  ## Structured events (the ops seam)
181
220
 
182
221
  One optional interface, `SyncularServerEvents`, carries every
@@ -1,5 +1,5 @@
1
1
  import type { CompiledSchema, CompiledTable } from './schema.js';
2
- import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
2
+ import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
3
3
  export interface D1PreparedStatement {
4
4
  bind(...values: unknown[]): D1PreparedStatement;
5
5
  first<T = Record<string, unknown>>(): Promise<T | null>;
@@ -40,6 +40,7 @@ export declare class D1ServerStorage implements ServerStorage {
40
40
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
41
41
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
42
42
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
43
+ scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
43
44
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
44
45
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
45
46
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
@@ -42,10 +42,11 @@ var _a;
42
42
  */
43
43
  import { decodeRow } from '@syncular/core';
44
44
  import { syncError } from './errors.js';
45
- import { commitWindowPageSql, deleteRowSql, dropTableDdl, 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';
45
+ import { commitWindowPageSql, deleteRowSql, 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';
46
46
  import { matchesEffective } from './scopes.js';
47
47
  import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
48
48
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
49
+ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
49
50
  function relationalValuesEqual(left, right) {
50
51
  if (left instanceof Uint8Array && right instanceof Uint8Array) {
51
52
  return (left.length === right.length &&
@@ -100,6 +101,7 @@ class D1Transaction {
100
101
  }
101
102
  async scanRows(query) {
102
103
  this.#assertOpen();
104
+ assertScopeIndexedScan(query);
103
105
  const variables = Object.keys(query.scopeFilter).sort();
104
106
  const firstVariable = variables[0];
105
107
  if (firstVariable === undefined)
@@ -149,6 +151,43 @@ class D1Transaction {
149
151
  .sort((left, right) => left.rowId.localeCompare(right.rowId))
150
152
  .slice(0, query.limit);
151
153
  }
154
+ async scanRowsByIndex(query) {
155
+ this.#assertOpen();
156
+ const table = this.#resolveTable(query.table);
157
+ const index = resolveIndexRowScan(table, query);
158
+ const pendingForTable = [...this.#pending.entries()].filter(([key]) => key.startsWith(`${query.table}\u0000`));
159
+ const persistedLimit = query.limit + pendingForTable.length;
160
+ const statement = indexRowPageStatement(table, index, query.values, this.#partition, query.afterRowId, persistedLimit, 'sqlite');
161
+ const { results: records } = await this.#db
162
+ .prepare(statement.sql)
163
+ .bind(...statement.params)
164
+ .all();
165
+ const rows = new Map(records.map((record) => {
166
+ const row = toStoredRow(record);
167
+ return [row.rowId, row];
168
+ }));
169
+ const columnPositions = index.columns.map((column) => {
170
+ const position = table.columnIndex.get(column);
171
+ if (position === undefined) {
172
+ throw new Error('compiled relational index references unknown column');
173
+ }
174
+ return position;
175
+ });
176
+ const lowerBound = query.afterRowId ?? '';
177
+ for (const [key, pending] of pendingForTable) {
178
+ const rowId = key.slice(query.table.length + 1);
179
+ rows.delete(rowId);
180
+ if (pending.kind !== 'row' || rowId <= lowerBound)
181
+ continue;
182
+ const values = decodeRow(table.columns, pending.row.payload);
183
+ const matches = columnPositions.every((position, valueIndex) => relationalValuesEqual(values[position] ?? null, query.values[valueIndex] ?? null));
184
+ if (matches)
185
+ rows.set(rowId, pending.row);
186
+ }
187
+ return [...rows.values()]
188
+ .sort((left, right) => left.rowId.localeCompare(right.rowId))
189
+ .slice(0, query.limit);
190
+ }
152
191
  async lockPartitionForCommitValidation() {
153
192
  this.#assertOpen();
154
193
  if (!this.#commitValidationSerialized) {
@@ -575,6 +614,7 @@ export class D1ServerStorage {
575
614
  return commits;
576
615
  }
577
616
  async scanRows(partition, query) {
617
+ assertScopeIndexedScan(query);
578
618
  const variables = Object.keys(query.scopeFilter).sort();
579
619
  const firstVariable = variables[0];
580
620
  if (firstVariable === undefined)
@@ -614,6 +654,16 @@ export class D1ServerStorage {
614
654
  }
615
655
  return rows;
616
656
  }
657
+ async scanRowsByIndex(partition, query) {
658
+ const table = this.table(query.table);
659
+ const index = resolveIndexRowScan(table, query);
660
+ const statement = indexRowPageStatement(table, index, query.values, partition, query.afterRowId, query.limit, 'sqlite');
661
+ const { results } = await this.#db
662
+ .prepare(statement.sql)
663
+ .bind(...statement.params)
664
+ .all();
665
+ return results.map(toStoredRow);
666
+ }
617
667
  async getClientRecord(partition, clientId) {
618
668
  const record = await this.#db
619
669
  .prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
package/dist/events.d.ts CHANGED
@@ -59,11 +59,21 @@ export interface PushRejectedEvent extends PushEventBase {
59
59
  readonly type: 'push.rejected';
60
60
  readonly code: string;
61
61
  readonly opIndex: number;
62
+ /** True when the rejection was replayed from the idempotency cache. */
63
+ readonly replay: boolean;
64
+ /** Original host time for outcomes recorded by a metadata-aware server. */
65
+ readonly recordedAtMs?: number;
66
+ /** Privacy-safe identity of the stored outcome, when available. */
67
+ readonly cacheIdentity?: string;
62
68
  }
63
69
  /** A push commit terminated by a version conflict (§6.2). */
64
70
  export interface PushConflictedEvent extends PushEventBase {
65
71
  readonly type: 'push.conflicted';
66
72
  readonly opIndex: number;
73
+ /** True when the conflict was replayed from the idempotency cache. */
74
+ readonly replay: boolean;
75
+ readonly recordedAtMs?: number;
76
+ readonly cacheIdentity?: string;
67
77
  }
68
78
  /** One emitted segment within a pull subscription section. */
69
79
  export interface PullSegmentSummary {
package/dist/handler.js CHANGED
@@ -15,7 +15,7 @@ import { SyncError, syncError } from './errors.js';
15
15
  import { emitEvent, } from './events.js';
16
16
  import { END_FRAME_BYTES, encodeResponseFrame, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
17
17
  import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
18
- import { processPushCommit } from './push.js';
18
+ import { processPushCommitWithTrace } from './push.js';
19
19
  import { compileSchema } from './schema.js';
20
20
  import { computeEffective } from './scopes.js';
21
21
  function validateResolvedKeys(allowed, schema) {
@@ -199,7 +199,8 @@ function pushResultDetailsFrame(frame) {
199
199
  entries,
200
200
  };
201
201
  }
202
- function emitPushEvent(events, ctx, clientId, push, frame) {
202
+ function emitPushEvent(events, ctx, clientId, push, processed) {
203
+ const { frame } = processed;
203
204
  const base = {
204
205
  atMs: clockOf(ctx)(),
205
206
  partition: ctx.partition,
@@ -213,7 +214,7 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
213
214
  type: 'push.applied',
214
215
  ...base,
215
216
  ...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
216
- replay: frame.status === 'cached',
217
+ replay: processed.replayed,
217
218
  });
218
219
  return;
219
220
  }
@@ -224,6 +225,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
224
225
  type: 'push.conflicted',
225
226
  ...base,
226
227
  opIndex: record.opIndex,
228
+ replay: processed.replayed,
229
+ ...(processed.recordedAtMs !== undefined
230
+ ? { recordedAtMs: processed.recordedAtMs }
231
+ : {}),
232
+ ...(processed.cacheIdentity !== undefined
233
+ ? { cacheIdentity: processed.cacheIdentity }
234
+ : {}),
227
235
  });
228
236
  return;
229
237
  }
@@ -234,6 +242,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
234
242
  ? record.code
235
243
  : 'sync.invalid_request',
236
244
  opIndex: record?.opIndex ?? 0,
245
+ replay: processed.replayed,
246
+ ...(processed.recordedAtMs !== undefined
247
+ ? { recordedAtMs: processed.recordedAtMs }
248
+ : {}),
249
+ ...(processed.cacheIdentity !== undefined
250
+ ? { cacheIdentity: processed.cacheIdentity }
251
+ : {}),
237
252
  });
238
253
  }
239
254
  async function* streamResponse(plan, ctx, schema, report) {
@@ -276,9 +291,10 @@ async function* streamResponse(plan, ctx, schema, report) {
276
291
  try {
277
292
  // Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
278
293
  for (const push of plan.pushes) {
279
- const frame = await processPushCommit(ctx, schema, plan.resolved, plan.header.clientId, push);
294
+ const processed = await processPushCommitWithTrace(ctx, schema, plan.resolved, plan.header.clientId, push);
295
+ const { frame } = processed;
280
296
  if (events !== undefined) {
281
- emitPushEvent(events, ctx, plan.header.clientId, push, frame);
297
+ emitPushEvent(events, ctx, plan.header.clientId, push, processed);
282
298
  }
283
299
  yield encodeResponseFrame(frame);
284
300
  const details = pushResultDetailsFrame(frame);
package/dist/index.d.ts CHANGED
@@ -43,4 +43,5 @@ export * from './sqlite-lease-store.js';
43
43
  export * from './sqlite-segment-store.js';
44
44
  export * from './sqlite-storage.js';
45
45
  export * from './storage.js';
46
+ export { StorageQueryError, type StorageQueryErrorCode, } from './storage-errors.js';
46
47
  export * from './validate.js';
package/dist/index.js CHANGED
@@ -51,4 +51,5 @@ export * from './sqlite-lease-store.js';
51
51
  export * from './sqlite-segment-store.js';
52
52
  export * from './sqlite-storage.js';
53
53
  export * from './storage.js';
54
+ export { StorageQueryError, } from './storage-errors.js';
54
55
  export * from './validate.js';
@@ -1,6 +1,6 @@
1
1
  import { type PgExecutor } from './pg-executor.js';
2
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
- import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
3
+ import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
4
4
  /**
5
5
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
6
6
  *
@@ -50,6 +50,7 @@ export declare class PostgresServerStorage implements ServerStorage {
50
50
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
51
51
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
52
52
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
53
+ scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
53
54
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
54
55
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
55
56
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
@@ -1,8 +1,9 @@
1
1
  import { syncError } from './errors.js';
2
2
  import { asBytes, asNumber, } from './pg-executor.js';
3
- import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
3
+ import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
4
4
  import { matchesEffective } from './scopes.js';
5
5
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
6
+ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
6
7
  /**
7
8
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
8
9
  *
@@ -90,6 +91,12 @@ function serializePushResult(result) {
90
91
  return {
91
92
  status: result.status,
92
93
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
94
+ ...(result.recordedAtMs !== undefined
95
+ ? { recordedAtMs: result.recordedAtMs }
96
+ : {}),
97
+ ...(result.cacheIdentity !== undefined
98
+ ? { cacheIdentity: result.cacheIdentity }
99
+ : {}),
93
100
  results: result.results.map((record) => {
94
101
  if (record.status === 'conflict') {
95
102
  return {
@@ -143,6 +150,12 @@ function deserializePushResult(value) {
143
150
  return {
144
151
  status: parsed.status,
145
152
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
153
+ ...(parsed.recordedAtMs !== undefined
154
+ ? { recordedAtMs: parsed.recordedAtMs }
155
+ : {}),
156
+ ...(parsed.cacheIdentity !== undefined
157
+ ? { cacheIdentity: parsed.cacheIdentity }
158
+ : {}),
146
159
  results,
147
160
  };
148
161
  }
@@ -213,6 +226,12 @@ async function getRowOn(q, compiled, partition, rowId) {
213
226
  const record = rows[0];
214
227
  return record === undefined ? undefined : toStoredRow(record);
215
228
  }
229
+ async function scanRowsByIndexOn(q, compiled, partition, query) {
230
+ const index = resolveIndexRowScan(compiled, query);
231
+ const statement = indexRowPageStatement(compiled, index, query.values, partition, query.afterRowId, query.limit, 'postgres');
232
+ const { rows } = await q.query(statement.sql, statement.params);
233
+ return rows.map(toStoredRow);
234
+ }
216
235
  async function writeRowOn(q, compiled, partition, row) {
217
236
  await q.query(upsertSql(compiled, 'postgres'), upsertValues(compiled, partition, row, 'postgres'));
218
237
  await q.query('DELETE FROM sync_row_scopes WHERE partition=$1 AND tbl=$2 AND row_id=$3', [partition, compiled.name, row.rowId]);
@@ -247,6 +266,7 @@ class PostgresTransaction {
247
266
  }
248
267
  async scanRows(query) {
249
268
  this.#assertOpen();
269
+ assertScopeIndexedScan(query);
250
270
  const variables = Object.keys(query.scopeFilter).sort();
251
271
  const firstVariable = variables[0];
252
272
  if (firstVariable === undefined)
@@ -285,6 +305,10 @@ class PostgresTransaction {
285
305
  }
286
306
  return rows;
287
307
  }
308
+ scanRowsByIndex(query) {
309
+ this.#assertOpen();
310
+ return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
311
+ }
288
312
  async lockPartitionForCommitValidation() {
289
313
  this.#assertOpen();
290
314
  await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
@@ -673,6 +697,7 @@ export class PostgresServerStorage {
673
697
  return commits;
674
698
  }
675
699
  async scanRows(partition, query) {
700
+ assertScopeIndexedScan(query);
676
701
  const variables = Object.keys(query.scopeFilter).sort();
677
702
  const firstVariable = variables[0];
678
703
  if (firstVariable === undefined)
@@ -717,6 +742,9 @@ export class PostgresServerStorage {
717
742
  }
718
743
  return rows;
719
744
  }
745
+ scanRowsByIndex(partition, query) {
746
+ return scanRowsByIndexOn(this.#exec, this.table(query.table), partition, query);
747
+ }
720
748
  async getClientRecord(partition, clientId) {
721
749
  const { rows } = await this.#exec.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2', [partition, clientId]);
722
750
  const record = rows[0];
package/dist/push.d.ts CHANGED
@@ -22,6 +22,13 @@ import type { SyncRequestContext } from './context.js';
22
22
  import type { CompiledSchema } from './schema.js';
23
23
  import type { ResolvedScopes } from './scopes.js';
24
24
  import type { StoredCommit } from './storage.js';
25
+ export interface ProcessedPushCommit {
26
+ readonly frame: PushResultFrame;
27
+ /** True when this request observed an already-recorded idempotency outcome. */
28
+ readonly replayed: boolean;
29
+ readonly recordedAtMs?: number;
30
+ readonly cacheIdentity?: string;
31
+ }
25
32
  export interface AppliedCommitEvent {
26
33
  readonly commit: StoredCommit;
27
34
  }
@@ -30,3 +37,9 @@ export interface AppliedCommitEvent {
30
37
  * atomic apply (§6.4), realtime notification for applied commits.
31
38
  */
32
39
  export declare function processPushCommit(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<PushResultFrame>;
40
+ /**
41
+ * Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
42
+ * rejected replays as `status: rejected`; this companion result preserves the
43
+ * cache provenance needed by structured events and server helpers.
44
+ */
45
+ export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<ProcessedPushCommit>;
package/dist/push.js CHANGED
@@ -466,6 +466,25 @@ function resultFrame(clientCommitId, stored, replay) {
466
466
  results: [...stored.results],
467
467
  };
468
468
  }
469
+ function processedPushCommit(clientCommitId, stored, replayed) {
470
+ return {
471
+ frame: resultFrame(clientCommitId, stored, replayed),
472
+ replayed,
473
+ ...(stored.recordedAtMs !== undefined
474
+ ? { recordedAtMs: stored.recordedAtMs }
475
+ : {}),
476
+ ...(stored.cacheIdentity !== undefined
477
+ ? { cacheIdentity: stored.cacheIdentity }
478
+ : {}),
479
+ };
480
+ }
481
+ function newStoredPushResult(recordedAtMs, result) {
482
+ return {
483
+ ...result,
484
+ recordedAtMs,
485
+ cacheIdentity: crypto.randomUUID(),
486
+ };
487
+ }
469
488
  function idempotencyCacheMissFrame(clientCommitId, error) {
470
489
  return {
471
490
  type: 'PUSH_RESULT',
@@ -496,13 +515,24 @@ async function persistRejectedPushResult(storage, partition, clientId, clientCom
496
515
  if (canonical === undefined) {
497
516
  throw new Error('push rejection finalization did not persist an outcome');
498
517
  }
499
- return canonical;
518
+ return {
519
+ stored: canonical,
520
+ replayed: canonical.cacheIdentity !== stored.cacheIdentity,
521
+ };
500
522
  }
501
523
  /**
502
524
  * Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
503
525
  * atomic apply (§6.4), realtime notification for applied commits.
504
526
  */
505
527
  export async function processPushCommit(ctx, schema, resolved, clientId, frame) {
528
+ return (await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)).frame;
529
+ }
530
+ /**
531
+ * Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
532
+ * rejected replays as `status: rejected`; this companion result preserves the
533
+ * cache provenance needed by structured events and server helpers.
534
+ */
535
+ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
506
536
  const { storage, partition } = ctx;
507
537
  let persisted;
508
538
  try {
@@ -513,12 +543,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
513
543
  error.code === 'sync.idempotency_cache_miss') {
514
544
  // §6.3: answer the retryable cache-miss for this commit rather than
515
545
  // re-applying. Not persisted — a retry may find a readable record.
516
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
546
+ return {
547
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
548
+ replayed: false,
549
+ };
517
550
  }
518
551
  throw error;
519
552
  }
520
553
  if (persisted !== undefined) {
521
- return resultFrame(frame.clientCommitId, persisted, true);
554
+ return processedPushCommit(frame.clientCommitId, persisted, true);
522
555
  }
523
556
  const createdAtMs = clockOf(ctx)();
524
557
  const blobCtx = { store: ctx.blobs, partition };
@@ -541,14 +574,17 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
541
574
  const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
542
575
  if (serializedPersisted !== undefined) {
543
576
  await tx.rollback();
544
- return resultFrame(frame.clientCommitId, serializedPersisted, true);
577
+ return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
545
578
  }
546
579
  }
547
580
  catch (error) {
548
581
  if (error instanceof SyncError &&
549
582
  error.code === 'sync.idempotency_cache_miss') {
550
583
  await tx.rollback();
551
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
584
+ return {
585
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
586
+ replayed: false,
587
+ };
552
588
  }
553
589
  throw error;
554
590
  }
@@ -580,10 +616,10 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
580
616
  if (terminated !== undefined) {
581
617
  // §6.3 rejected: only the terminating operation's record; §6.4:
582
618
  // every write of the commit rolls back.
583
- const stored = {
619
+ const stored = newStoredPushResult(createdAtMs, {
584
620
  status: 'rejected',
585
621
  results: [terminated],
586
- };
622
+ });
587
623
  if (commitValidator !== undefined) {
588
624
  // Discard candidate rows and persist the rejection while retaining the
589
625
  // same partition lock. This closes the duplicate-request race between
@@ -596,9 +632,9 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
596
632
  else {
597
633
  await tx.rollback();
598
634
  const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
599
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
635
+ return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
600
636
  }
601
- return resultFrame(frame.clientCommitId, stored, false);
637
+ return processedPushCommit(frame.clientCommitId, stored, false);
602
638
  }
603
639
  const commitSeq = await tx.appendCommit({
604
640
  clientId,
@@ -607,7 +643,11 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
607
643
  createdAtMs,
608
644
  changes,
609
645
  });
610
- const stored = { status: 'applied', commitSeq, results };
646
+ const stored = newStoredPushResult(createdAtMs, {
647
+ status: 'applied',
648
+ commitSeq,
649
+ results,
650
+ });
611
651
  await tx.putPushResult(clientId, frame.clientCommitId, stored);
612
652
  await tx.commit();
613
653
  if (ctx.realtime !== undefined && changes.length > 0) {
@@ -618,12 +658,12 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
618
658
  changes,
619
659
  });
620
660
  }
621
- return resultFrame(frame.clientCommitId, stored, false);
661
+ return processedPushCommit(frame.clientCommitId, stored, false);
622
662
  }
623
663
  catch (error) {
624
664
  await tx.rollback();
625
665
  if (error instanceof StorageConstraintError) {
626
- const stored = {
666
+ const stored = newStoredPushResult(createdAtMs, {
627
667
  status: 'rejected',
628
668
  results: [
629
669
  {
@@ -634,9 +674,9 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
634
674
  retryable: false,
635
675
  },
636
676
  ],
637
- };
677
+ });
638
678
  const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
639
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
679
+ return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
640
680
  }
641
681
  throw error;
642
682
  }
@@ -44,7 +44,7 @@
44
44
  * NOT NULL per the schema.
45
45
  */
46
46
  import { type RowColumn, type RowValue } from '@syncular/core';
47
- import type { CompiledSchema, CompiledTable } from './schema.js';
47
+ import type { CompiledSchema, CompiledTable, IndexSchema } from './schema.js';
48
48
  import type { StoredRow } from './storage.js';
49
49
  export type RelationalDialect = 'sqlite' | 'postgres';
50
50
  export declare const SYNC_PARTITION_COLUMN = "_sync_partition";
@@ -100,6 +100,16 @@ export declare function upsertSql(table: CompiledTable, dialect: RelationalDiale
100
100
  * `toStoredRow` converters keep working. Params: [partition, rowId].
101
101
  */
102
102
  export declare function selectRowSql(table: CompiledTable, dialect: RelationalDialect): string;
103
+ export interface IndexRowPageStatement {
104
+ readonly sql: string;
105
+ readonly params: readonly unknown[];
106
+ }
107
+ /**
108
+ * Bounded exact lookup through one declared relational index. Unlike
109
+ * `scanRowPageSql`, this is a trusted server-host query: it never reads or
110
+ * creates Syncular scope-index entries and is not reachable from SSP2.
111
+ */
112
+ export declare function indexRowPageStatement(table: CompiledTable, index: IndexSchema, values: readonly RowValue[], partition: string, afterRowId: string | null | undefined, limit: number, dialect: RelationalDialect): IndexRowPageStatement;
103
113
  /**
104
114
  * One-round-trip page scan for `scanRows`: candidates from the inverted
105
115
  * scope index (ordered + LIMITed at the covering `sync_row_scopes` PK —
@@ -243,6 +243,35 @@ export function selectRowSql(table, dialect) {
243
243
  const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
244
244
  return `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 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
245
245
  }
246
+ /**
247
+ * Bounded exact lookup through one declared relational index. Unlike
248
+ * `scanRowPageSql`, this is a trusted server-host query: it never reads or
249
+ * creates Syncular scope-index entries and is not reachable from SSP2.
250
+ */
251
+ export function indexRowPageStatement(table, index, values, partition, afterRowId, limit, dialect) {
252
+ const params = [partition];
253
+ const placeholder = () => dialect === 'sqlite' ? '?' : `$${params.length}`;
254
+ const predicates = index.columns.map((columnName, valueIndex) => {
255
+ const columnPosition = table.columnIndex.get(columnName);
256
+ const column = columnPosition === undefined ? undefined : table.columns[columnPosition];
257
+ if (column === undefined) {
258
+ throw new Error('compiled relational index references unknown column');
259
+ }
260
+ const value = values[valueIndex] ?? null;
261
+ if (value === null)
262
+ return `${quoteIdent(columnName)} IS NULL`;
263
+ params.push(toSqlValue(column, value, dialect));
264
+ return `${quoteIdent(columnName)}=${placeholder()}`;
265
+ });
266
+ params.push(afterRowId ?? '');
267
+ const after = placeholder();
268
+ params.push(limit);
269
+ const pageLimit = placeholder();
270
+ return {
271
+ sql: `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 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${dialect === 'sqlite' ? '?' : '$1'} AND ${predicates.join(' AND ')} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>${after} ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ${pageLimit}`,
272
+ params,
273
+ };
274
+ }
246
275
  /**
247
276
  * One-round-trip page scan for `scanRows`: candidates from the inverted
248
277
  * scope index (ordered + LIMITed at the covering `sync_row_scopes` PK —
package/dist/schema.js CHANGED
@@ -61,6 +61,9 @@ export function compileSchema(schema) {
61
61
  throw new Error(`table ${table.name}: duplicate index ${JSON.stringify(index.name)}`);
62
62
  }
63
63
  indexNames.add(index.name);
64
+ if (index.columns.length === 0) {
65
+ throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} must name at least one column`);
66
+ }
64
67
  for (const column of index.columns) {
65
68
  if (!columnIndex.has(column)) {
66
69
  throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
package/dist/seed.d.ts CHANGED
@@ -26,9 +26,34 @@ export interface SeedTarget {
26
26
  /** The client commit id (default `'seed-commit-1'`). */
27
27
  readonly commitId?: string;
28
28
  }
29
+ export interface SeedMutationErrorOptions {
30
+ readonly clientId: string;
31
+ readonly clientCommitId: string;
32
+ readonly opIndex: number;
33
+ readonly code: string;
34
+ readonly replayed: boolean;
35
+ readonly retryable: boolean;
36
+ readonly message: string;
37
+ readonly recordedAtMs?: number;
38
+ readonly cacheIdentity?: string;
39
+ }
40
+ /** Structured terminal failure from the real push path used by a seed. */
41
+ export declare class SeedMutationError extends Error {
42
+ readonly name = "SeedMutationError";
43
+ readonly clientId: string;
44
+ readonly clientCommitId: string;
45
+ readonly opIndex: number;
46
+ /** Exact protocol or host-validator rejection code. */
47
+ readonly code: string;
48
+ readonly replayed: boolean;
49
+ readonly retryable: boolean;
50
+ readonly recordedAtMs?: number;
51
+ readonly cacheIdentity?: string;
52
+ constructor(options: SeedMutationErrorOptions);
53
+ }
29
54
  /**
30
55
  * Seed `mutations` into a partition through the real push path. Throws a
31
- * `SyncError` when the push is rejected or any operation fails, so a broken
32
- * seed fails loud at boot instead of silently serving an empty database.
56
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
57
+ * helper input, so a broken seed fails loud instead of serving an empty store.
33
58
  */
34
59
  export declare function seedMutations(config: SyncServerConfig, target: SeedTarget, mutations: readonly SeedMutation[]): Promise<void>;