@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/dist/seed.js CHANGED
@@ -11,7 +11,36 @@
11
11
  */
12
12
  import { decodeMessage, encodeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
13
13
  import { SyncError } from './errors.js';
14
+ import { composeEvents } from './events-ring.js';
14
15
  import { handleSyncRequest } from './handler.js';
16
+ /** Structured terminal failure from the real push path used by a seed. */
17
+ export class SeedMutationError extends Error {
18
+ name = 'SeedMutationError';
19
+ clientId;
20
+ clientCommitId;
21
+ opIndex;
22
+ /** Exact protocol or host-validator rejection code. */
23
+ code;
24
+ replayed;
25
+ retryable;
26
+ recordedAtMs;
27
+ cacheIdentity;
28
+ constructor(options) {
29
+ super(options.message);
30
+ this.clientId = options.clientId;
31
+ this.clientCommitId = options.clientCommitId;
32
+ this.opIndex = options.opIndex;
33
+ this.code = options.code;
34
+ this.replayed = options.replayed;
35
+ this.retryable = options.retryable;
36
+ if (options.recordedAtMs !== undefined) {
37
+ this.recordedAtMs = options.recordedAtMs;
38
+ }
39
+ if (options.cacheIdentity !== undefined) {
40
+ this.cacheIdentity = options.cacheIdentity;
41
+ }
42
+ }
43
+ }
15
44
  const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
16
45
  /** Pinned §12 schema alias used by generated row types and every client host. */
17
46
  function snakeToCamel(name) {
@@ -35,8 +64,8 @@ function snakeToCamel(name) {
35
64
  }
36
65
  /**
37
66
  * Seed `mutations` into a partition through the real push path. Throws a
38
- * `SyncError` when the push is rejected or any operation fails, so a broken
39
- * seed fails loud at boot instead of silently serving an empty database.
67
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
68
+ * helper input, so a broken seed fails loud instead of serving an empty store.
40
69
  */
41
70
  export async function seedMutations(config, target, mutations) {
42
71
  const clientId = target.clientId ?? 'seed';
@@ -88,11 +117,28 @@ export async function seedMutations(config, target, mutations) {
88
117
  accept: 0b0011,
89
118
  },
90
119
  ];
120
+ let terminalEvent;
121
+ const capture = {
122
+ emit(event) {
123
+ if ((event.type === 'push.rejected' || event.type === 'push.conflicted') &&
124
+ event.clientId === clientId &&
125
+ event.clientCommitId === clientCommitId) {
126
+ terminalEvent = event;
127
+ }
128
+ },
129
+ };
91
130
  const response = await handleSyncRequest(encodeMessage({
92
131
  wireVersion: PROTOCOL_WIRE_VERSION,
93
132
  msgKind: 'request',
94
133
  frames,
95
- }), { ...config, partition: target.partition, actorId: target.actorId });
134
+ }), {
135
+ ...config,
136
+ partition: target.partition,
137
+ actorId: target.actorId,
138
+ events: config.events === undefined
139
+ ? capture
140
+ : composeEvents(config.events, capture),
141
+ });
96
142
  // Fail loud: surface the first rejected/failed operation.
97
143
  const message = decodeMessage(response);
98
144
  const result = message.frames.find((frame) => frame.type === 'PUSH_RESULT' && frame.clientCommitId === clientCommitId);
@@ -101,9 +147,27 @@ export async function seedMutations(config, target, mutations) {
101
147
  }
102
148
  if (result.status === 'rejected') {
103
149
  const failed = result.results.find((r) => r.status !== 'applied');
104
- const detail = failed !== undefined && 'code' in failed
105
- ? ` (op ${failed.opIndex}: ${failed.code} — ${failed.message})`
106
- : '';
107
- throw new SyncError('sync.invalid_request', `seedMutations: the seed commit was rejected${detail}`);
150
+ const code = failed?.code ?? 'sync.invalid_request';
151
+ const opIndex = failed?.opIndex ?? 0;
152
+ const retryable = failed?.status === 'error' ? failed.retryable : false;
153
+ const detail = failed === undefined
154
+ ? ''
155
+ : ` (op ${opIndex}: ${code} — ${failed.message})`;
156
+ const replayed = terminalEvent?.replay ?? false;
157
+ throw new SeedMutationError({
158
+ clientId,
159
+ clientCommitId,
160
+ opIndex,
161
+ code,
162
+ replayed,
163
+ retryable,
164
+ message: `seedMutations: the seed commit was rejected${replayed ? ' (cached replay)' : ''}${detail}`,
165
+ ...(terminalEvent?.recordedAtMs !== undefined
166
+ ? { recordedAtMs: terminalEvent.recordedAtMs }
167
+ : {}),
168
+ ...(terminalEvent?.cacheIdentity !== undefined
169
+ ? { cacheIdentity: terminalEvent.cacheIdentity }
170
+ : {}),
171
+ });
108
172
  }
109
173
  }
@@ -93,6 +93,12 @@ export function serializePushResult(result) {
93
93
  return JSON.stringify({
94
94
  status: result.status,
95
95
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
96
+ ...(result.recordedAtMs !== undefined
97
+ ? { recordedAtMs: result.recordedAtMs }
98
+ : {}),
99
+ ...(result.cacheIdentity !== undefined
100
+ ? { cacheIdentity: result.cacheIdentity }
101
+ : {}),
96
102
  results: result.results.map((record) => {
97
103
  if (record.status === 'conflict') {
98
104
  return {
@@ -146,6 +152,12 @@ export function deserializePushResult(text) {
146
152
  return {
147
153
  status: parsed.status,
148
154
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
155
+ ...(parsed.recordedAtMs !== undefined
156
+ ? { recordedAtMs: parsed.recordedAtMs }
157
+ : {}),
158
+ ...(parsed.cacheIdentity !== undefined
159
+ ? { cacheIdentity: parsed.cacheIdentity }
160
+ : {}),
149
161
  results,
150
162
  };
151
163
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { Database } from 'bun:sqlite';
10
10
  import type { CompiledSchema, CompiledTable } from './schema.js';
11
- import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
11
+ import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
12
12
  export declare class SqliteServerStorage implements ServerStorage {
13
13
  #private;
14
14
  readonly db: Database;
@@ -28,6 +28,7 @@ export declare class SqliteServerStorage implements ServerStorage {
28
28
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
29
29
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
30
30
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
31
+ scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
31
32
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
32
33
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
33
34
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
@@ -8,10 +8,11 @@
8
8
  */
9
9
  import { Database } from 'bun:sqlite';
10
10
  import { syncError } from './errors.js';
11
- import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
11
+ import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
12
12
  import { matchesEffective } from './scopes.js';
13
13
  import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
14
14
  import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
15
+ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
15
16
  class SqliteTransaction {
16
17
  #storage;
17
18
  #partition;
@@ -34,6 +35,10 @@ class SqliteTransaction {
34
35
  this.#assertOpen();
35
36
  return this.#storage.scanRows(this.#partition, query);
36
37
  }
38
+ scanRowsByIndex(query) {
39
+ this.#assertOpen();
40
+ return this.#storage.scanRowsByIndex(this.#partition, query);
41
+ }
37
42
  async lockPartitionForCommitValidation() {
38
43
  this.#assertOpen();
39
44
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
@@ -339,6 +344,7 @@ export class SqliteServerStorage {
339
344
  return commits;
340
345
  }
341
346
  async scanRows(partition, query) {
347
+ assertScopeIndexedScan(query);
342
348
  const variables = Object.keys(query.scopeFilter).sort();
343
349
  const firstVariable = variables[0];
344
350
  if (firstVariable === undefined)
@@ -377,6 +383,16 @@ export class SqliteServerStorage {
377
383
  }
378
384
  return rows;
379
385
  }
386
+ async scanRowsByIndex(partition, query) {
387
+ const table = this.table(query.table);
388
+ const index = resolveIndexRowScan(table, query);
389
+ const statement = indexRowPageStatement(table, index, query.values, partition, query.afterRowId, query.limit, 'sqlite');
390
+ const params = statement.params;
391
+ const records = this.db
392
+ .query(statement.sql)
393
+ .all(...params);
394
+ return records.map(toStoredRow);
395
+ }
380
396
  async getClientRecord(partition, clientId) {
381
397
  const record = this.db
382
398
  .query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
@@ -8,6 +8,17 @@ export declare class StorageConstraintError extends Error {
8
8
  readonly opIndex: number | undefined;
9
9
  constructor(cause: unknown, opIndex?: number);
10
10
  }
11
+ /** Stable, privacy-safe failures for trusted server storage queries. */
12
+ export type StorageQueryErrorCode = 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit';
13
+ /**
14
+ * Host-only query error. Messages never include identifiers, values, SQL,
15
+ * paths, or row data; callers branch on `code`, never message text.
16
+ */
17
+ export declare class StorageQueryError extends Error {
18
+ readonly name = "StorageQueryError";
19
+ readonly code: StorageQueryErrorCode;
20
+ constructor(code: StorageQueryErrorCode);
21
+ }
11
22
  /** SQLite primary/extended constraint result codes (`SQLITE_CONSTRAINT*`). */
12
23
  export declare function isSqliteConstraintError(error: unknown): boolean;
13
24
  /** PostgreSQL SQLSTATE class 23: integrity constraint violation. */
@@ -11,6 +11,25 @@ export class StorageConstraintError extends Error {
11
11
  this.opIndex = opIndex;
12
12
  }
13
13
  }
14
+ const STORAGE_QUERY_MESSAGES = {
15
+ 'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
16
+ 'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
17
+ 'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',
18
+ 'sync.storage.index_value_count_mismatch': 'trusted row lookup requires one exact value per index column',
19
+ 'sync.storage.invalid_limit': 'trusted row lookup limit must be an integer from 1 through 1,000',
20
+ };
21
+ /**
22
+ * Host-only query error. Messages never include identifiers, values, SQL,
23
+ * paths, or row data; callers branch on `code`, never message text.
24
+ */
25
+ export class StorageQueryError extends Error {
26
+ name = 'StorageQueryError';
27
+ code;
28
+ constructor(code) {
29
+ super(STORAGE_QUERY_MESSAGES[code]);
30
+ this.code = code;
31
+ }
32
+ }
14
33
  function driverError(error) {
15
34
  return typeof error === 'object' && error !== null
16
35
  ? error
@@ -0,0 +1,6 @@
1
+ import type { CompiledTable, IndexSchema } from './schema.js';
2
+ import type { IndexRowScanQuery, RowScanQuery } from './storage.js';
3
+ /** Fail loudly instead of making an unsupported unscoped scan look empty. */
4
+ export declare function assertScopeIndexedScan(query: RowScanQuery): void;
5
+ /** Validate and resolve one exact trusted-host relational index lookup. */
6
+ export declare function resolveIndexRowScan(table: CompiledTable, query: IndexRowScanQuery): IndexSchema;
@@ -0,0 +1,30 @@
1
+ import { StorageQueryError } from './storage-errors.js';
2
+ /** Fail loudly instead of making an unsupported unscoped scan look empty. */
3
+ export function assertScopeIndexedScan(query) {
4
+ const scopeFilter = query.scopeFilter;
5
+ if (scopeFilter === undefined ||
6
+ scopeFilter === null ||
7
+ Object.keys(scopeFilter).length === 0) {
8
+ throw new StorageQueryError('sync.storage.scan_requires_scope');
9
+ }
10
+ }
11
+ /** Validate and resolve one exact trusted-host relational index lookup. */
12
+ export function resolveIndexRowScan(table, query) {
13
+ if (!Number.isInteger(query.limit) ||
14
+ query.limit < 1 ||
15
+ query.limit > 1_000) {
16
+ throw new StorageQueryError('sync.storage.invalid_limit');
17
+ }
18
+ if (!table.materialize) {
19
+ throw new StorageQueryError('sync.storage.index_not_materialized');
20
+ }
21
+ const index = table.indexes.find((candidate) => candidate.name === query.index);
22
+ if (index === undefined) {
23
+ throw new StorageQueryError('sync.storage.index_not_found');
24
+ }
25
+ if (!Array.isArray(query.values) ||
26
+ query.values.length !== index.columns.length) {
27
+ throw new StorageQueryError('sync.storage.index_value_count_mismatch');
28
+ }
29
+ return index;
30
+ }
package/dist/storage.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * The interface is async throughout so a Postgres implementation slots in
17
17
  * without touching the core. All methods are partition-local (§2.1).
18
18
  */
19
- import type { PushOperationResult, ScopeMap } from '@syncular/core';
19
+ import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
20
20
  import type { CompiledSchema } from './schema.js';
21
21
  /** The current stored state of a synced row. */
22
22
  export interface StoredRow {
@@ -64,6 +64,10 @@ export interface StoredPushResult {
64
64
  readonly status: 'applied' | 'rejected';
65
65
  /** Present iff `status` is `applied`. */
66
66
  readonly commitSeq?: number;
67
+ /** Host clock when this terminal idempotency outcome was first recorded. */
68
+ readonly recordedAtMs?: number;
69
+ /** Privacy-safe identity used to distinguish this stored outcome from a race. */
70
+ readonly cacheIdentity?: string;
67
71
  readonly results: readonly PushOperationResult[];
68
72
  }
69
73
  export interface ClientSubscription {
@@ -102,6 +106,25 @@ export interface RowScanQuery {
102
106
  readonly afterRowId: string | null;
103
107
  readonly limit: number;
104
108
  }
109
+ /**
110
+ * Exact server-host lookup through one declared relational index.
111
+ *
112
+ * This is deliberately NOT a Syncular scope or client query. It is available
113
+ * only to trusted server code that already owns a `ServerStorage` or
114
+ * `StorageTransaction` capability. Every declared index column must have one
115
+ * exact value, so adapters can keep the lookup bounded and deterministic.
116
+ */
117
+ export interface IndexRowScanQuery {
118
+ readonly table: string;
119
+ /** `TableSchema.indexes[].name`; never exposed as a client subscription. */
120
+ readonly index: string;
121
+ /** Exact values in the index declaration's column order. */
122
+ readonly values: readonly RowValue[];
123
+ /** Resume after this rowId (exclusive); `null` = start of the match set. */
124
+ readonly afterRowId?: string | null;
125
+ /** Integer from 1 through 1,000. */
126
+ readonly limit: number;
127
+ }
105
128
  export interface ClientCursorInfo {
106
129
  readonly clientId: string;
107
130
  readonly cursor: number;
@@ -156,6 +179,12 @@ export interface StorageTransaction {
156
179
  * semantics. A custom backend may omit it until `commitValidator` is used.
157
180
  */
158
181
  scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
182
+ /**
183
+ * Optional additive capability for trusted authoritative commands. In-tree
184
+ * SQLite/PostgreSQL/D1 adapters implement it with transaction-local
185
+ * read-your-own-writes semantics. It is not reachable from SSP2 requests.
186
+ */
187
+ scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
159
188
  /**
160
189
  * Serialize candidate-state validation for this partition before any row
161
190
  * read/write. Required at runtime when `commitValidator` is configured.
@@ -227,6 +256,12 @@ export interface ServerStorage {
227
256
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
228
257
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
229
258
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
259
+ /**
260
+ * Optional trusted-host exact lookup through a declared relational index.
261
+ * This capability is outside client scope/subscription authorization and
262
+ * MUST NOT be re-exported as a client-controlled endpoint.
263
+ */
264
+ scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
230
265
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
231
266
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
232
267
  /** Cursor records feeding the §4.6 retention watermark. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.21",
3
+ "version": "0.15.23",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -53,7 +53,7 @@
53
53
  "!dist/**/*.test.d.ts"
54
54
  ],
55
55
  "dependencies": {
56
- "@syncular/core": "0.15.21"
56
+ "@syncular/core": "0.15.23"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  commitWindowPageSql,
46
46
  deleteRowSql,
47
47
  dropTableDdl,
48
+ indexRowPageStatement,
48
49
  layoutsOf,
49
50
  migratePayload,
50
51
  parseLayouts,
@@ -84,6 +85,7 @@ import type {
84
85
  CommitMetadata,
85
86
  CommitMetadataQuery,
86
87
  CommitWindowQuery,
88
+ IndexRowScanQuery,
87
89
  NewCommit,
88
90
  RowScanQuery,
89
91
  ScopeActivityQuery,
@@ -95,6 +97,7 @@ import type {
95
97
  StoredRow,
96
98
  } from './storage';
97
99
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors';
100
+ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
98
101
 
99
102
  // -- The subset of the D1 API this storage uses (structural typing) ---------
100
103
  // Declared locally so the package takes no `@cloudflare/workers-types`
@@ -193,6 +196,7 @@ class D1Transaction implements StorageTransaction {
193
196
 
194
197
  async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
195
198
  this.#assertOpen();
199
+ assertScopeIndexedScan(query);
196
200
  const variables = Object.keys(query.scopeFilter).sort();
197
201
  const firstVariable = variables[0];
198
202
  if (firstVariable === undefined) return [];
@@ -254,6 +258,60 @@ class D1Transaction implements StorageTransaction {
254
258
  .slice(0, query.limit);
255
259
  }
256
260
 
261
+ async scanRowsByIndex(query: IndexRowScanQuery): Promise<StoredRow[]> {
262
+ this.#assertOpen();
263
+ const table = this.#resolveTable(query.table);
264
+ const index = resolveIndexRowScan(table, query);
265
+ const pendingForTable = [...this.#pending.entries()].filter(([key]) =>
266
+ key.startsWith(`${query.table}\u0000`),
267
+ );
268
+ const persistedLimit = query.limit + pendingForTable.length;
269
+ const statement = indexRowPageStatement(
270
+ table,
271
+ index,
272
+ query.values,
273
+ this.#partition,
274
+ query.afterRowId,
275
+ persistedLimit,
276
+ 'sqlite',
277
+ );
278
+ const { results: records } = await this.#db
279
+ .prepare(statement.sql)
280
+ .bind(...statement.params)
281
+ .all<SqliteRowRecord>();
282
+
283
+ const rows = new Map(
284
+ records.map((record) => {
285
+ const row = toStoredRow(record);
286
+ return [row.rowId, row] as const;
287
+ }),
288
+ );
289
+ const columnPositions = index.columns.map((column) => {
290
+ const position = table.columnIndex.get(column);
291
+ if (position === undefined) {
292
+ throw new Error('compiled relational index references unknown column');
293
+ }
294
+ return position;
295
+ });
296
+ const lowerBound = query.afterRowId ?? '';
297
+ for (const [key, pending] of pendingForTable) {
298
+ const rowId = key.slice(query.table.length + 1);
299
+ rows.delete(rowId);
300
+ if (pending.kind !== 'row' || rowId <= lowerBound) continue;
301
+ const values = decodeRow(table.columns, pending.row.payload);
302
+ const matches = columnPositions.every((position, valueIndex) =>
303
+ relationalValuesEqual(
304
+ values[position] ?? null,
305
+ query.values[valueIndex] ?? null,
306
+ ),
307
+ );
308
+ if (matches) rows.set(rowId, pending.row);
309
+ }
310
+ return [...rows.values()]
311
+ .sort((left, right) => left.rowId.localeCompare(right.rowId))
312
+ .slice(0, query.limit);
313
+ }
314
+
257
315
  async lockPartitionForCommitValidation(): Promise<void> {
258
316
  this.#assertOpen();
259
317
  if (!this.#commitValidationSerialized) {
@@ -859,6 +917,7 @@ export class D1ServerStorage implements ServerStorage {
859
917
  }
860
918
 
861
919
  async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
920
+ assertScopeIndexedScan(query);
862
921
  const variables = Object.keys(query.scopeFilter).sort();
863
922
  const firstVariable = variables[0];
864
923
  if (firstVariable === undefined) return [];
@@ -904,6 +963,28 @@ export class D1ServerStorage implements ServerStorage {
904
963
  return rows;
905
964
  }
906
965
 
966
+ async scanRowsByIndex(
967
+ partition: string,
968
+ query: IndexRowScanQuery,
969
+ ): Promise<StoredRow[]> {
970
+ const table = this.table(query.table);
971
+ const index = resolveIndexRowScan(table, query);
972
+ const statement = indexRowPageStatement(
973
+ table,
974
+ index,
975
+ query.values,
976
+ partition,
977
+ query.afterRowId,
978
+ query.limit,
979
+ 'sqlite',
980
+ );
981
+ const { results } = await this.#db
982
+ .prepare(statement.sql)
983
+ .bind(...statement.params)
984
+ .all<SqliteRowRecord>();
985
+ return results.map(toStoredRow);
986
+ }
987
+
907
988
  async getClientRecord(
908
989
  partition: string,
909
990
  clientId: string,
package/src/events.ts CHANGED
@@ -63,12 +63,22 @@ export interface PushRejectedEvent extends PushEventBase {
63
63
  readonly type: 'push.rejected';
64
64
  readonly code: string;
65
65
  readonly opIndex: number;
66
+ /** True when the rejection was replayed from the idempotency cache. */
67
+ readonly replay: boolean;
68
+ /** Original host time for outcomes recorded by a metadata-aware server. */
69
+ readonly recordedAtMs?: number;
70
+ /** Privacy-safe identity of the stored outcome, when available. */
71
+ readonly cacheIdentity?: string;
66
72
  }
67
73
 
68
74
  /** A push commit terminated by a version conflict (§6.2). */
69
75
  export interface PushConflictedEvent extends PushEventBase {
70
76
  readonly type: 'push.conflicted';
71
77
  readonly opIndex: number;
78
+ /** True when the conflict was replayed from the idempotency cache. */
79
+ readonly replay: boolean;
80
+ readonly recordedAtMs?: number;
81
+ readonly cacheIdentity?: string;
72
82
  }
73
83
 
74
84
  /** One emitted segment within a pull subscription section. */
package/src/handler.ts CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  type SubscriptionPlan,
45
45
  subscriptionSection,
46
46
  } from './pull';
47
- import { processPushCommit } from './push';
47
+ import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
48
48
  import type { CompiledSchema } from './schema';
49
49
  import { compileSchema } from './schema';
50
50
  import { computeEffective, type ResolvedScopes } from './scopes';
@@ -339,8 +339,9 @@ function emitPushEvent(
339
339
  ctx: SyncRequestContext,
340
340
  clientId: string,
341
341
  push: PushCommitFrame,
342
- frame: PushResultFrame,
342
+ processed: ProcessedPushCommit,
343
343
  ): void {
344
+ const { frame } = processed;
344
345
  const base = {
345
346
  atMs: clockOf(ctx)(),
346
347
  partition: ctx.partition,
@@ -354,7 +355,7 @@ function emitPushEvent(
354
355
  type: 'push.applied',
355
356
  ...base,
356
357
  ...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
357
- replay: frame.status === 'cached',
358
+ replay: processed.replayed,
358
359
  });
359
360
  return;
360
361
  }
@@ -365,6 +366,13 @@ function emitPushEvent(
365
366
  type: 'push.conflicted',
366
367
  ...base,
367
368
  opIndex: record.opIndex,
369
+ replay: processed.replayed,
370
+ ...(processed.recordedAtMs !== undefined
371
+ ? { recordedAtMs: processed.recordedAtMs }
372
+ : {}),
373
+ ...(processed.cacheIdentity !== undefined
374
+ ? { cacheIdentity: processed.cacheIdentity }
375
+ : {}),
368
376
  });
369
377
  return;
370
378
  }
@@ -376,6 +384,13 @@ function emitPushEvent(
376
384
  ? record.code
377
385
  : 'sync.invalid_request',
378
386
  opIndex: record?.opIndex ?? 0,
387
+ replay: processed.replayed,
388
+ ...(processed.recordedAtMs !== undefined
389
+ ? { recordedAtMs: processed.recordedAtMs }
390
+ : {}),
391
+ ...(processed.cacheIdentity !== undefined
392
+ ? { cacheIdentity: processed.cacheIdentity }
393
+ : {}),
379
394
  });
380
395
  }
381
396
 
@@ -423,15 +438,16 @@ async function* streamResponse(
423
438
  try {
424
439
  // Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
425
440
  for (const push of plan.pushes) {
426
- const frame = await processPushCommit(
441
+ const processed = await processPushCommitWithTrace(
427
442
  ctx,
428
443
  schema,
429
444
  plan.resolved,
430
445
  plan.header.clientId,
431
446
  push,
432
447
  );
448
+ const { frame } = processed;
433
449
  if (events !== undefined) {
434
- emitPushEvent(events, ctx, plan.header.clientId, push, frame);
450
+ emitPushEvent(events, ctx, plan.header.clientId, push, processed);
435
451
  }
436
452
  yield encodeResponseFrame(frame);
437
453
  const details = pushResultDetailsFrame(frame);
package/src/index.ts CHANGED
@@ -51,4 +51,8 @@ export * from './sqlite-lease-store';
51
51
  export * from './sqlite-segment-store';
52
52
  export * from './sqlite-storage';
53
53
  export * from './storage';
54
+ export {
55
+ StorageQueryError,
56
+ type StorageQueryErrorCode,
57
+ } from './storage-errors';
54
58
  export * from './validate';