@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.
@@ -47,6 +47,7 @@ import {
47
47
  commitWindowPageSql,
48
48
  deleteRowSql,
49
49
  dropTableDdl,
50
+ indexRowPageStatement,
50
51
  layoutsOf,
51
52
  migratePayload,
52
53
  parseLayouts,
@@ -73,6 +74,7 @@ import type {
73
74
  CommitMetadata,
74
75
  CommitMetadataQuery,
75
76
  CommitWindowQuery,
77
+ IndexRowScanQuery,
76
78
  NewCommit,
77
79
  RowScanQuery,
78
80
  ScopeActivityQuery,
@@ -88,6 +90,7 @@ import {
88
90
  isPostgresConstraintError,
89
91
  StorageConstraintError,
90
92
  } from './storage-errors';
93
+ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
91
94
 
92
95
  /**
93
96
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
@@ -190,6 +193,12 @@ function serializePushResult(result: StoredPushResult): unknown {
190
193
  return {
191
194
  status: result.status,
192
195
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
196
+ ...(result.recordedAtMs !== undefined
197
+ ? { recordedAtMs: result.recordedAtMs }
198
+ : {}),
199
+ ...(result.cacheIdentity !== undefined
200
+ ? { cacheIdentity: result.cacheIdentity }
201
+ : {}),
193
202
  results: result.results.map((record) => {
194
203
  if (record.status === 'conflict') {
195
204
  return {
@@ -220,6 +229,8 @@ function deserializePushResult(value: unknown): StoredPushResult {
220
229
  const parsed = value as {
221
230
  status: 'applied' | 'rejected';
222
231
  commitSeq?: number;
232
+ recordedAtMs?: number;
233
+ cacheIdentity?: string;
223
234
  results: SerializedResult[];
224
235
  };
225
236
  const results: PushOperationResult[] = parsed.results.map((record) => {
@@ -248,6 +259,12 @@ function deserializePushResult(value: unknown): StoredPushResult {
248
259
  return {
249
260
  status: parsed.status,
250
261
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
262
+ ...(parsed.recordedAtMs !== undefined
263
+ ? { recordedAtMs: parsed.recordedAtMs }
264
+ : {}),
265
+ ...(parsed.cacheIdentity !== undefined
266
+ ? { cacheIdentity: parsed.cacheIdentity }
267
+ : {}),
251
268
  results,
252
269
  };
253
270
  }
@@ -374,6 +391,26 @@ async function getRowOn(
374
391
  return record === undefined ? undefined : toStoredRow(record);
375
392
  }
376
393
 
394
+ async function scanRowsByIndexOn(
395
+ q: PgQueryable,
396
+ compiled: CompiledTable,
397
+ partition: string,
398
+ query: IndexRowScanQuery,
399
+ ): Promise<StoredRow[]> {
400
+ const index = resolveIndexRowScan(compiled, query);
401
+ const statement = indexRowPageStatement(
402
+ compiled,
403
+ index,
404
+ query.values,
405
+ partition,
406
+ query.afterRowId,
407
+ query.limit,
408
+ 'postgres',
409
+ );
410
+ const { rows } = await q.query<RowRecord>(statement.sql, statement.params);
411
+ return rows.map(toStoredRow);
412
+ }
413
+
377
414
  async function writeRowOn(
378
415
  q: PgQueryable,
379
416
  compiled: CompiledTable,
@@ -437,6 +474,7 @@ class PostgresTransaction implements StorageTransaction {
437
474
 
438
475
  async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
439
476
  this.#assertOpen();
477
+ assertScopeIndexedScan(query);
440
478
  const variables = Object.keys(query.scopeFilter).sort();
441
479
  const firstVariable = variables[0];
442
480
  if (firstVariable === undefined) return [];
@@ -473,6 +511,16 @@ class PostgresTransaction implements StorageTransaction {
473
511
  return rows;
474
512
  }
475
513
 
514
+ scanRowsByIndex(query: IndexRowScanQuery): Promise<StoredRow[]> {
515
+ this.#assertOpen();
516
+ return scanRowsByIndexOn(
517
+ this.#client,
518
+ this.#resolveTable(query.table),
519
+ this.#partition,
520
+ query,
521
+ );
522
+ }
523
+
476
524
  async lockPartitionForCommitValidation(): Promise<void> {
477
525
  this.#assertOpen();
478
526
  await this.#client.query(
@@ -1012,6 +1060,7 @@ export class PostgresServerStorage implements ServerStorage {
1012
1060
  }
1013
1061
 
1014
1062
  async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
1063
+ assertScopeIndexedScan(query);
1015
1064
  const variables = Object.keys(query.scopeFilter).sort();
1016
1065
  const firstVariable = variables[0];
1017
1066
  if (firstVariable === undefined) return [];
@@ -1054,6 +1103,18 @@ export class PostgresServerStorage implements ServerStorage {
1054
1103
  return rows;
1055
1104
  }
1056
1105
 
1106
+ scanRowsByIndex(
1107
+ partition: string,
1108
+ query: IndexRowScanQuery,
1109
+ ): Promise<StoredRow[]> {
1110
+ return scanRowsByIndexOn(
1111
+ this.#exec,
1112
+ this.table(query.table),
1113
+ partition,
1114
+ query,
1115
+ );
1116
+ }
1117
+
1057
1118
  async getClientRecord(
1058
1119
  partition: string,
1059
1120
  clientId: string,
package/src/push.ts CHANGED
@@ -750,6 +750,42 @@ function resultFrame(
750
750
  };
751
751
  }
752
752
 
753
+ export interface ProcessedPushCommit {
754
+ readonly frame: PushResultFrame;
755
+ /** True when this request observed an already-recorded idempotency outcome. */
756
+ readonly replayed: boolean;
757
+ readonly recordedAtMs?: number;
758
+ readonly cacheIdentity?: string;
759
+ }
760
+
761
+ function processedPushCommit(
762
+ clientCommitId: string,
763
+ stored: StoredPushResult,
764
+ replayed: boolean,
765
+ ): ProcessedPushCommit {
766
+ return {
767
+ frame: resultFrame(clientCommitId, stored, replayed),
768
+ replayed,
769
+ ...(stored.recordedAtMs !== undefined
770
+ ? { recordedAtMs: stored.recordedAtMs }
771
+ : {}),
772
+ ...(stored.cacheIdentity !== undefined
773
+ ? { cacheIdentity: stored.cacheIdentity }
774
+ : {}),
775
+ };
776
+ }
777
+
778
+ function newStoredPushResult(
779
+ recordedAtMs: number,
780
+ result: Omit<StoredPushResult, 'recordedAtMs' | 'cacheIdentity'>,
781
+ ): StoredPushResult {
782
+ return {
783
+ ...result,
784
+ recordedAtMs,
785
+ cacheIdentity: crypto.randomUUID(),
786
+ };
787
+ }
788
+
753
789
  function idempotencyCacheMissFrame(
754
790
  clientCommitId: string,
755
791
  error: SyncError,
@@ -776,7 +812,7 @@ async function persistRejectedPushResult(
776
812
  clientId: string,
777
813
  clientCommitId: string,
778
814
  stored: StoredPushResult,
779
- ): Promise<StoredPushResult> {
815
+ ): Promise<{ readonly stored: StoredPushResult; readonly replayed: boolean }> {
780
816
  const rejectionTx = await storage.begin(partition);
781
817
  try {
782
818
  await rejectionTx.putPushResult(clientId, clientCommitId, stored);
@@ -793,7 +829,10 @@ async function persistRejectedPushResult(
793
829
  if (canonical === undefined) {
794
830
  throw new Error('push rejection finalization did not persist an outcome');
795
831
  }
796
- return canonical;
832
+ return {
833
+ stored: canonical,
834
+ replayed: canonical.cacheIdentity !== stored.cacheIdentity,
835
+ };
797
836
  }
798
837
 
799
838
  export interface AppliedCommitEvent {
@@ -811,6 +850,23 @@ export async function processPushCommit(
811
850
  clientId: string,
812
851
  frame: PushCommitFrame,
813
852
  ): Promise<PushResultFrame> {
853
+ return (
854
+ await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)
855
+ ).frame;
856
+ }
857
+
858
+ /**
859
+ * Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
860
+ * rejected replays as `status: rejected`; this companion result preserves the
861
+ * cache provenance needed by structured events and server helpers.
862
+ */
863
+ export async function processPushCommitWithTrace(
864
+ ctx: SyncRequestContext,
865
+ schema: CompiledSchema,
866
+ resolved: ResolvedScopes,
867
+ clientId: string,
868
+ frame: PushCommitFrame,
869
+ ): Promise<ProcessedPushCommit> {
814
870
  const { storage, partition } = ctx;
815
871
  let persisted: StoredPushResult | undefined;
816
872
  try {
@@ -826,12 +882,15 @@ export async function processPushCommit(
826
882
  ) {
827
883
  // §6.3: answer the retryable cache-miss for this commit rather than
828
884
  // re-applying. Not persisted — a retry may find a readable record.
829
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
885
+ return {
886
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
887
+ replayed: false,
888
+ };
830
889
  }
831
890
  throw error;
832
891
  }
833
892
  if (persisted !== undefined) {
834
- return resultFrame(frame.clientCommitId, persisted, true);
893
+ return processedPushCommit(frame.clientCommitId, persisted, true);
835
894
  }
836
895
 
837
896
  const createdAtMs = clockOf(ctx)();
@@ -863,7 +922,11 @@ export async function processPushCommit(
863
922
  );
864
923
  if (serializedPersisted !== undefined) {
865
924
  await tx.rollback();
866
- return resultFrame(frame.clientCommitId, serializedPersisted, true);
925
+ return processedPushCommit(
926
+ frame.clientCommitId,
927
+ serializedPersisted,
928
+ true,
929
+ );
867
930
  }
868
931
  } catch (error) {
869
932
  if (
@@ -871,7 +934,10 @@ export async function processPushCommit(
871
934
  error.code === 'sync.idempotency_cache_miss'
872
935
  ) {
873
936
  await tx.rollback();
874
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
937
+ return {
938
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
939
+ replayed: false,
940
+ };
875
941
  }
876
942
  throw error;
877
943
  }
@@ -923,10 +989,10 @@ export async function processPushCommit(
923
989
  if (terminated !== undefined) {
924
990
  // §6.3 rejected: only the terminating operation's record; §6.4:
925
991
  // every write of the commit rolls back.
926
- const stored: StoredPushResult = {
992
+ const stored = newStoredPushResult(createdAtMs, {
927
993
  status: 'rejected',
928
994
  results: [terminated],
929
- };
995
+ });
930
996
  if (commitValidator !== undefined) {
931
997
  // Discard candidate rows and persist the rejection while retaining the
932
998
  // same partition lock. This closes the duplicate-request race between
@@ -946,13 +1012,13 @@ export async function processPushCommit(
946
1012
  frame.clientCommitId,
947
1013
  stored,
948
1014
  );
949
- return resultFrame(
1015
+ return processedPushCommit(
950
1016
  frame.clientCommitId,
951
- canonical,
952
- canonical !== stored,
1017
+ canonical.stored,
1018
+ canonical.replayed,
953
1019
  );
954
1020
  }
955
- return resultFrame(frame.clientCommitId, stored, false);
1021
+ return processedPushCommit(frame.clientCommitId, stored, false);
956
1022
  }
957
1023
 
958
1024
  const commitSeq = await tx.appendCommit({
@@ -962,7 +1028,11 @@ export async function processPushCommit(
962
1028
  createdAtMs,
963
1029
  changes,
964
1030
  });
965
- const stored: StoredPushResult = { status: 'applied', commitSeq, results };
1031
+ const stored = newStoredPushResult(createdAtMs, {
1032
+ status: 'applied',
1033
+ commitSeq,
1034
+ results,
1035
+ });
966
1036
  await tx.putPushResult(clientId, frame.clientCommitId, stored);
967
1037
  await tx.commit();
968
1038
  if (ctx.realtime !== undefined && changes.length > 0) {
@@ -973,11 +1043,11 @@ export async function processPushCommit(
973
1043
  changes,
974
1044
  });
975
1045
  }
976
- return resultFrame(frame.clientCommitId, stored, false);
1046
+ return processedPushCommit(frame.clientCommitId, stored, false);
977
1047
  } catch (error) {
978
1048
  await tx.rollback();
979
1049
  if (error instanceof StorageConstraintError) {
980
- const stored: StoredPushResult = {
1050
+ const stored = newStoredPushResult(createdAtMs, {
981
1051
  status: 'rejected',
982
1052
  results: [
983
1053
  {
@@ -988,7 +1058,7 @@ export async function processPushCommit(
988
1058
  retryable: false,
989
1059
  },
990
1060
  ],
991
- };
1061
+ });
992
1062
  const canonical = await persistRejectedPushResult(
993
1063
  storage,
994
1064
  partition,
@@ -996,7 +1066,11 @@ export async function processPushCommit(
996
1066
  frame.clientCommitId,
997
1067
  stored,
998
1068
  );
999
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
1069
+ return processedPushCommit(
1070
+ frame.clientCommitId,
1071
+ canonical.stored,
1072
+ canonical.replayed,
1073
+ );
1000
1074
  }
1001
1075
  throw error;
1002
1076
  }
@@ -49,7 +49,7 @@ import {
49
49
  type RowColumn,
50
50
  type RowValue,
51
51
  } from '@syncular/core';
52
- import type { CompiledSchema, CompiledTable } from './schema';
52
+ import type { CompiledSchema, CompiledTable, IndexSchema } from './schema';
53
53
  import type { StoredRow } from './storage';
54
54
 
55
55
  export type RelationalDialect = 'sqlite' | 'postgres';
@@ -287,6 +287,50 @@ export function selectRowSql(
287
287
  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]}`;
288
288
  }
289
289
 
290
+ export interface IndexRowPageStatement {
291
+ readonly sql: string;
292
+ readonly params: readonly unknown[];
293
+ }
294
+
295
+ /**
296
+ * Bounded exact lookup through one declared relational index. Unlike
297
+ * `scanRowPageSql`, this is a trusted server-host query: it never reads or
298
+ * creates Syncular scope-index entries and is not reachable from SSP2.
299
+ */
300
+ export function indexRowPageStatement(
301
+ table: CompiledTable,
302
+ index: IndexSchema,
303
+ values: readonly RowValue[],
304
+ partition: string,
305
+ afterRowId: string | null | undefined,
306
+ limit: number,
307
+ dialect: RelationalDialect,
308
+ ): IndexRowPageStatement {
309
+ const params: unknown[] = [partition];
310
+ const placeholder = (): string =>
311
+ dialect === 'sqlite' ? '?' : `$${params.length}`;
312
+ const predicates = index.columns.map((columnName, valueIndex) => {
313
+ const columnPosition = table.columnIndex.get(columnName);
314
+ const column =
315
+ columnPosition === undefined ? undefined : table.columns[columnPosition];
316
+ if (column === undefined) {
317
+ throw new Error('compiled relational index references unknown column');
318
+ }
319
+ const value = values[valueIndex] ?? null;
320
+ if (value === null) return `${quoteIdent(columnName)} IS NULL`;
321
+ params.push(toSqlValue(column, value, dialect));
322
+ return `${quoteIdent(columnName)}=${placeholder()}`;
323
+ });
324
+ params.push(afterRowId ?? '');
325
+ const after = placeholder();
326
+ params.push(limit);
327
+ const pageLimit = placeholder();
328
+ return {
329
+ 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}`,
330
+ params,
331
+ };
332
+ }
333
+
290
334
  /**
291
335
  * One-round-trip page scan for `scanRows`: candidates from the inverted
292
336
  * scope index (ordered + LIMITed at the covering `sync_row_scopes` PK —
package/src/schema.ts CHANGED
@@ -180,6 +180,11 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
180
180
  );
181
181
  }
182
182
  indexNames.add(index.name);
183
+ if (index.columns.length === 0) {
184
+ throw new Error(
185
+ `table ${table.name}: index ${JSON.stringify(index.name)} must name at least one column`,
186
+ );
187
+ }
183
188
  for (const column of index.columns) {
184
189
  if (!columnIndex.has(column)) {
185
190
  throw new Error(
package/src/seed.ts CHANGED
@@ -21,6 +21,8 @@ import {
21
21
  } from '@syncular/core';
22
22
  import type { SyncServerConfig } from './context';
23
23
  import { SyncError } from './errors';
24
+ import type { SyncularServerEvent, SyncularServerEvents } from './events';
25
+ import { composeEvents } from './events-ring';
24
26
  import { handleSyncRequest } from './handler';
25
27
 
26
28
  /** One app-shaped seed mutation — the same vocabulary as client mutations. */
@@ -54,6 +56,48 @@ export interface SeedTarget {
54
56
  readonly commitId?: string;
55
57
  }
56
58
 
59
+ export interface SeedMutationErrorOptions {
60
+ readonly clientId: string;
61
+ readonly clientCommitId: string;
62
+ readonly opIndex: number;
63
+ readonly code: string;
64
+ readonly replayed: boolean;
65
+ readonly retryable: boolean;
66
+ readonly message: string;
67
+ readonly recordedAtMs?: number;
68
+ readonly cacheIdentity?: string;
69
+ }
70
+
71
+ /** Structured terminal failure from the real push path used by a seed. */
72
+ export class SeedMutationError extends Error {
73
+ override readonly name = 'SeedMutationError';
74
+ readonly clientId: string;
75
+ readonly clientCommitId: string;
76
+ readonly opIndex: number;
77
+ /** Exact protocol or host-validator rejection code. */
78
+ readonly code: string;
79
+ readonly replayed: boolean;
80
+ readonly retryable: boolean;
81
+ readonly recordedAtMs?: number;
82
+ readonly cacheIdentity?: string;
83
+
84
+ constructor(options: SeedMutationErrorOptions) {
85
+ super(options.message);
86
+ this.clientId = options.clientId;
87
+ this.clientCommitId = options.clientCommitId;
88
+ this.opIndex = options.opIndex;
89
+ this.code = options.code;
90
+ this.replayed = options.replayed;
91
+ this.retryable = options.retryable;
92
+ if (options.recordedAtMs !== undefined) {
93
+ this.recordedAtMs = options.recordedAtMs;
94
+ }
95
+ if (options.cacheIdentity !== undefined) {
96
+ this.cacheIdentity = options.cacheIdentity;
97
+ }
98
+ }
99
+ }
100
+
57
101
  const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
58
102
 
59
103
  /** Pinned §12 schema alias used by generated row types and every client host. */
@@ -79,8 +123,8 @@ function snakeToCamel(name: string): string {
79
123
 
80
124
  /**
81
125
  * Seed `mutations` into a partition through the real push path. Throws a
82
- * `SyncError` when the push is rejected or any operation fails, so a broken
83
- * seed fails loud at boot instead of silently serving an empty database.
126
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
127
+ * helper input, so a broken seed fails loud instead of serving an empty store.
84
128
  */
85
129
  export async function seedMutations(
86
130
  config: SyncServerConfig,
@@ -154,13 +198,37 @@ export async function seedMutations(
154
198
  accept: 0b0011,
155
199
  },
156
200
  ];
201
+ type SeedTerminalEvent = Extract<
202
+ SyncularServerEvent,
203
+ { type: 'push.rejected' | 'push.conflicted' }
204
+ >;
205
+ let terminalEvent: SeedTerminalEvent | undefined;
206
+ const capture: SyncularServerEvents = {
207
+ emit(event) {
208
+ if (
209
+ (event.type === 'push.rejected' || event.type === 'push.conflicted') &&
210
+ event.clientId === clientId &&
211
+ event.clientCommitId === clientCommitId
212
+ ) {
213
+ terminalEvent = event;
214
+ }
215
+ },
216
+ };
157
217
  const response = await handleSyncRequest(
158
218
  encodeMessage({
159
219
  wireVersion: PROTOCOL_WIRE_VERSION,
160
220
  msgKind: 'request',
161
221
  frames,
162
222
  }),
163
- { ...config, partition: target.partition, actorId: target.actorId },
223
+ {
224
+ ...config,
225
+ partition: target.partition,
226
+ actorId: target.actorId,
227
+ events:
228
+ config.events === undefined
229
+ ? capture
230
+ : composeEvents(config.events, capture),
231
+ },
164
232
  );
165
233
 
166
234
  // Fail loud: surface the first rejected/failed operation.
@@ -177,13 +245,30 @@ export async function seedMutations(
177
245
  }
178
246
  if (result.status === 'rejected') {
179
247
  const failed = result.results.find((r) => r.status !== 'applied');
248
+ const code = failed?.code ?? 'sync.invalid_request';
249
+ const opIndex = failed?.opIndex ?? 0;
250
+ const retryable = failed?.status === 'error' ? failed.retryable : false;
180
251
  const detail =
181
- failed !== undefined && 'code' in failed
182
- ? ` (op ${failed.opIndex}: ${failed.code} — ${failed.message})`
183
- : '';
184
- throw new SyncError(
185
- 'sync.invalid_request',
186
- `seedMutations: the seed commit was rejected${detail}`,
187
- );
252
+ failed === undefined
253
+ ? ''
254
+ : ` (op ${opIndex}: ${code} — ${failed.message})`;
255
+ const replayed = terminalEvent?.replay ?? false;
256
+ throw new SeedMutationError({
257
+ clientId,
258
+ clientCommitId,
259
+ opIndex,
260
+ code,
261
+ replayed,
262
+ retryable,
263
+ message: `seedMutations: the seed commit was rejected${
264
+ replayed ? ' (cached replay)' : ''
265
+ }${detail}`,
266
+ ...(terminalEvent?.recordedAtMs !== undefined
267
+ ? { recordedAtMs: terminalEvent.recordedAtMs }
268
+ : {}),
269
+ ...(terminalEvent?.cacheIdentity !== undefined
270
+ ? { cacheIdentity: terminalEvent.cacheIdentity }
271
+ : {}),
272
+ });
188
273
  }
189
274
  }
@@ -134,6 +134,12 @@ export function serializePushResult(result: StoredPushResult): string {
134
134
  return JSON.stringify({
135
135
  status: result.status,
136
136
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
137
+ ...(result.recordedAtMs !== undefined
138
+ ? { recordedAtMs: result.recordedAtMs }
139
+ : {}),
140
+ ...(result.cacheIdentity !== undefined
141
+ ? { cacheIdentity: result.cacheIdentity }
142
+ : {}),
137
143
  results: result.results.map((record) => {
138
144
  if (record.status === 'conflict') {
139
145
  return {
@@ -164,6 +170,8 @@ export function deserializePushResult(text: string): StoredPushResult {
164
170
  const parsed = JSON.parse(text) as {
165
171
  status: 'applied' | 'rejected';
166
172
  commitSeq?: number;
173
+ recordedAtMs?: number;
174
+ cacheIdentity?: string;
167
175
  results: SerializedResult[];
168
176
  };
169
177
  const results: PushOperationResult[] = parsed.results.map((record) => {
@@ -192,6 +200,12 @@ export function deserializePushResult(text: string): StoredPushResult {
192
200
  return {
193
201
  status: parsed.status,
194
202
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
203
+ ...(parsed.recordedAtMs !== undefined
204
+ ? { recordedAtMs: parsed.recordedAtMs }
205
+ : {}),
206
+ ...(parsed.cacheIdentity !== undefined
207
+ ? { cacheIdentity: parsed.cacheIdentity }
208
+ : {}),
195
209
  results,
196
210
  };
197
211
  }
@@ -12,6 +12,7 @@ import {
12
12
  commitWindowPageSql,
13
13
  deleteRowSql,
14
14
  dropTableDdl,
15
+ indexRowPageStatement,
15
16
  layoutsOf,
16
17
  migratePayload,
17
18
  parseLayouts,
@@ -47,6 +48,7 @@ import type {
47
48
  CommitMetadata,
48
49
  CommitMetadataQuery,
49
50
  CommitWindowQuery,
51
+ IndexRowScanQuery,
50
52
  NewCommit,
51
53
  RowScanQuery,
52
54
  ScopeActivityQuery,
@@ -61,6 +63,7 @@ import {
61
63
  isSqliteConstraintError,
62
64
  StorageConstraintError,
63
65
  } from './storage-errors';
66
+ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
64
67
 
65
68
  class SqliteTransaction implements StorageTransaction {
66
69
  #storage: SqliteServerStorage;
@@ -88,6 +91,11 @@ class SqliteTransaction implements StorageTransaction {
88
91
  return this.#storage.scanRows(this.#partition, query);
89
92
  }
90
93
 
94
+ scanRowsByIndex(query: IndexRowScanQuery): Promise<StoredRow[]> {
95
+ this.#assertOpen();
96
+ return this.#storage.scanRowsByIndex(this.#partition, query);
97
+ }
98
+
91
99
  async lockPartitionForCommitValidation(): Promise<void> {
92
100
  this.#assertOpen();
93
101
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
@@ -564,6 +572,7 @@ export class SqliteServerStorage implements ServerStorage {
564
572
  }
565
573
 
566
574
  async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
575
+ assertScopeIndexedScan(query);
567
576
  const variables = Object.keys(query.scopeFilter).sort();
568
577
  const firstVariable = variables[0];
569
578
  if (firstVariable === undefined) return [];
@@ -611,6 +620,35 @@ export class SqliteServerStorage implements ServerStorage {
611
620
  return rows;
612
621
  }
613
622
 
623
+ async scanRowsByIndex(
624
+ partition: string,
625
+ query: IndexRowScanQuery,
626
+ ): Promise<StoredRow[]> {
627
+ const table = this.table(query.table);
628
+ const index = resolveIndexRowScan(table, query);
629
+ const statement = indexRowPageStatement(
630
+ table,
631
+ index,
632
+ query.values,
633
+ partition,
634
+ query.afterRowId,
635
+ query.limit,
636
+ 'sqlite',
637
+ );
638
+ const params = statement.params as readonly (
639
+ | string
640
+ | number
641
+ | Uint8Array
642
+ | null
643
+ )[];
644
+ const records = this.db
645
+ .query<SqliteRowRecord, (string | number | Uint8Array | null)[]>(
646
+ statement.sql,
647
+ )
648
+ .all(...params);
649
+ return records.map(toStoredRow);
650
+ }
651
+
614
652
  async getClientRecord(
615
653
  partition: string,
616
654
  clientId: string,