@syncular/server 0.46.3 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -256,7 +256,8 @@ const config: SyncServerConfig = {
256
256
  ```
257
257
 
258
258
  The callback also receives `read.getRow()` and its batch form
259
- `read.getRows(table, rowIds)`, bounded `read.scanRows()`, and
259
+ `read.getRows(table, rowIds)`, bounded `read.scanRows()`,
260
+ `read.scanRowsByIndex()` for an exact declared-index lookup, and
260
261
  `read.queryAuthoritative()` APIs. Those reads see the final candidate state, including staged sibling upserts and
261
262
  deletes. Syncular serializes the partition before any operation read so two
262
263
  aggregate validators cannot both accept mutually invalid candidates. It also
@@ -359,7 +360,9 @@ SQLite, PostgreSQL, and D1.
359
360
 
360
361
  An authoritative command that needs an exact alternate lookup can instead use
361
362
  the optional `storage.scanRowsByIndex(partition, query)` or transactional
362
- `tx.scanRowsByIndex(query)` capability. The query names one declared
363
+ `tx.scanRowsByIndex(query)` capability, or `command.scanRowsByIndex()` on its
364
+ push transaction. The whole-commit validator reaches the same transaction
365
+ lookup as `read.scanRowsByIndex()`; both readers default `limit` to 100. The query names one declared
363
366
  `TableSchema.indexes` entry, supplies one exact value per index column, uses an
364
367
  exclusive `afterRowId`, and has a required limit from 1 through 1,000. All
365
368
  shipped adapters implement it; transaction reads see staged writes and deletes.
package/dist/errors.js CHANGED
@@ -139,6 +139,15 @@ export const ERROR_CATALOG = {
139
139
  recommendedAction: 'fixRequest',
140
140
  httpStatus: 400,
141
141
  },
142
+ // §6.8: the whole-commit validator threw an exception that is not a
143
+ // deliberate rejection. A host fault; the thrown message rides the §6.3.1
144
+ // details and the original reaches the host's `onError`.
145
+ 'sync.validator_error': {
146
+ category: 'internal',
147
+ retryable: false,
148
+ recommendedAction: 'inspectServer',
149
+ httpStatus: 500,
150
+ },
142
151
  'sync.missing_scopes': {
143
152
  category: 'internal',
144
153
  retryable: false,
@@ -2,7 +2,7 @@ import { type AuthoritativeRelationPlan } from './authoritative-query.js';
2
2
  import { type RemoteOperationRequest, type RemoteOperationResponse, type RowValue } from '@syncular/core';
3
3
  import type { ResolveScopes, SyncRequestContext } from './context.js';
4
4
  import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, AuthoritativeQueryValue } from './storage.js';
5
- import { type ValidateRow } from './validate.js';
5
+ import { type IndexScanInput, type ValidateRow } from './validate.js';
6
6
  export interface RemoteQueryDependency {
7
7
  readonly table: string;
8
8
  readonly scopeKeys?: readonly string[];
@@ -76,6 +76,15 @@ export interface RemoteCommandContext {
76
76
  * D1 issue one lookup per id.
77
77
  */
78
78
  getRows(table: string, rowIds: readonly string[]): Promise<ReadonlyMap<string, ValidateRow>>;
79
+ /**
80
+ * Exact lookup through one declared relational index on the command's push
81
+ * transaction. The values form the complete, order-sensitive index tuple
82
+ * and `afterRowId` is an exclusive keyset cursor. Unlike `getRow` and
83
+ * `getRows`, the rows are not filtered by the actor's scopes: `authorize`
84
+ * gates the command, and this is the same trusted server-host lookup
85
+ * `ServerStorage.scanRowsByIndex` performs.
86
+ */
87
+ scanRowsByIndex(input: IndexScanInput): Promise<ValidateRow[]>;
79
88
  /**
80
89
  * Run a generated registered query on the command's push transaction
81
90
  * (§6.7). The storage binds every relation to the partition; unlike
@@ -314,6 +314,23 @@ function commandContext(ctx, tx, resolved, schema, clientId, operationId, reques
314
314
  }
315
315
  return rows;
316
316
  },
317
+ scanRowsByIndex: async ({ table: tableName, index, values, afterRowId = null, limit = 100, }) => {
318
+ const table = schema.tables.get(tableName);
319
+ if (table === undefined) {
320
+ throw syncError('operation.invalid_request', 'command read targets an unknown table');
321
+ }
322
+ if (tx.scanRowsByIndex === undefined) {
323
+ throw new Error('storage transaction does not support command index scans');
324
+ }
325
+ const rows = await tx.scanRowsByIndex({
326
+ table: tableName,
327
+ index,
328
+ values,
329
+ afterRowId,
330
+ limit,
331
+ });
332
+ return rows.map((stored) => toValidateRow(table.columns, decodeRow(table.columns, stored.payload)));
333
+ },
317
334
  queryAuthoritative: (query) => transactionQuery(tx, query),
318
335
  };
319
336
  }
package/dist/push.js CHANGED
@@ -20,7 +20,7 @@ import { retainRollbackFailure } from './storage-errors.js';
20
20
  */
21
21
  import { decodeRow, decodeSparseRow, encodeRow, parseBlobRef, } from '@syncular/core';
22
22
  import { clockOf, limitsOf } from './context.js';
23
- import { SyncError } from './errors.js';
23
+ import { reportError, SyncError } from './errors.js';
24
24
  import { emitEvent } from './events.js';
25
25
  import { prepareReactions, toNewReactions, } from './reactions.js';
26
26
  import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
@@ -696,10 +696,27 @@ function commitValidationReader(tx, schema) {
696
696
  serverVersion: stored.serverVersion,
697
697
  }));
698
698
  },
699
+ scanRowsByIndex: async ({ table: tableName, index, values, afterRowId = null, limit = 100, }) => {
700
+ const table = tableFor(tableName);
701
+ if (tx.scanRowsByIndex === undefined) {
702
+ throw new Error('storage transaction does not support commit-validator index scans');
703
+ }
704
+ const rows = await tx.scanRowsByIndex({
705
+ table: tableName,
706
+ index,
707
+ values,
708
+ afterRowId,
709
+ limit,
710
+ });
711
+ return rows.map((stored) => ({
712
+ row: toValidateRow(table.columns, decodeRow(table.columns, stored.payload)),
713
+ serverVersion: stored.serverVersion,
714
+ }));
715
+ },
699
716
  queryAuthoritative: (query) => transactionQuery(tx, query),
700
717
  };
701
718
  }
702
- async function runCommitValidator(validator, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
719
+ async function runCommitValidator(validator, onError, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
703
720
  if (validator === undefined)
704
721
  return undefined;
705
722
  try {
@@ -722,7 +739,30 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
722
739
  if (error instanceof ValidationRejection) {
723
740
  return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
724
741
  }
725
- return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', 'whole-commit validator failed');
742
+ // §6.8: a throw that is not a deliberate rejection is a host fault, not a
743
+ // rule refusal. The host's observation hook receives the original (its
744
+ // stack included); the client gets a distinct code and, in the §6.3.1
745
+ // details, the bounded thrown message. Stacks and thrown values stay off
746
+ // the wire.
747
+ reportError(error, onError, 'sync');
748
+ let text = '';
749
+ let bytes = 0;
750
+ const encoder = new TextEncoder();
751
+ const thrown = error instanceof Error ? error.message : String(error);
752
+ for (const char of thrown.replace(/\p{Cc}+/gu, ' ').trim()) {
753
+ const size = encoder.encode(char).length;
754
+ if (bytes + size > 256)
755
+ break;
756
+ bytes += size;
757
+ text += char;
758
+ }
759
+ text = text.trimEnd();
760
+ return errorRecord(operations[0]?.opIndex ?? 0, 'sync.validator_error', 'whole-commit validator failed', false, {
761
+ reason: 'validator_threw',
762
+ ...(text === ''
763
+ ? {}
764
+ : { references: { validator: 'commit', message: text } }),
765
+ });
726
766
  }
727
767
  return undefined;
728
768
  }
@@ -768,6 +808,32 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
768
808
  last: write,
769
809
  });
770
810
  }
811
+ // One candidate-state read per parent table, not one per reference: a
812
+ // commit that points several rows at the same parent (a fight's
813
+ // combatants, its chronicle) reads that parent once. Postgres binds the
814
+ // ids as one array per table; SQLite and D1 answer with one lookup per id.
815
+ const parentIds = new Map();
816
+ for (const { last: write } of finalByRow.values()) {
817
+ if (write.op !== 'upsert' || write.values === undefined)
818
+ continue;
819
+ for (const reference of write.table.references) {
820
+ const value = write.values[reference.columnIndex];
821
+ if (value === null || value === undefined)
822
+ continue;
823
+ const parentRowId = renderScopeValue(value);
824
+ if (parentRowId === undefined)
825
+ continue;
826
+ const ids = parentIds.get(reference.parentTable);
827
+ if (ids === undefined)
828
+ parentIds.set(reference.parentTable, new Set([parentRowId]));
829
+ else
830
+ ids.add(parentRowId);
831
+ }
832
+ }
833
+ const parents = new Map();
834
+ for (const [parentTable, ids] of parentIds) {
835
+ parents.set(parentTable, await readCandidateRows(tx, parentTable, [...ids]));
836
+ }
771
837
  for (const { last: write } of finalByRow.values()) {
772
838
  if (write.op !== 'upsert' || write.values === undefined)
773
839
  continue;
@@ -778,7 +844,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
778
844
  const parentRowId = renderScopeValue(value);
779
845
  if (parentRowId === undefined)
780
846
  continue;
781
- if ((await tx.getRow(reference.parentTable, parentRowId)) !== undefined) {
847
+ if (parents.get(reference.parentTable)?.has(parentRowId) === true) {
782
848
  continue;
783
849
  }
784
850
  return {
@@ -1129,7 +1195,7 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1129
1195
  }
1130
1196
  }
1131
1197
  if (terminated === undefined) {
1132
- const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
1198
+ const commitReject = await runCommitValidator(commitValidator, ctx.onError, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
1133
1199
  if (commitReject?.kind === 'terminate') {
1134
1200
  // An appended §6.11 cascade operation carries a synthetic opIndex;
1135
1201
  // surface the originating client operation instead.
@@ -74,6 +74,21 @@ export interface CommitValidationScanInput {
74
74
  /** Bounded per call by the server to 1..1,000; defaults to 100. */
75
75
  readonly limit?: number;
76
76
  }
77
+ /**
78
+ * One exact declared-index lookup a push-transaction reader runs (§6.8): the
79
+ * whole-commit reader and a registered command share this shape.
80
+ */
81
+ export interface IndexScanInput {
82
+ readonly table: string;
83
+ /** `TableSchema.indexes[].name`; never exposed as a client subscription. */
84
+ readonly index: string;
85
+ /** Exact values in the index declaration's column order. */
86
+ readonly values: readonly RowValue[];
87
+ /** Resume after this rowId (exclusive); `null` = start of the match set. */
88
+ readonly afterRowId?: string | null;
89
+ /** Bounded per call by the server to 1..1,000; defaults to 100. */
90
+ readonly limit?: number;
91
+ }
77
92
  /**
78
93
  * Candidate-state reads bound to the same storage transaction as the commit.
79
94
  * Reads observe every staged sibling operation and no uncommitted competing
@@ -91,6 +106,16 @@ export interface CommitValidationReader {
91
106
  */
92
107
  getRows(table: string, rowIds: readonly string[]): Promise<ReadonlyMap<string, CommitValidationRow>>;
93
108
  scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
109
+ /**
110
+ * Candidate-state exact lookup through one declared relational index, with
111
+ * the same read-your-own-writes guarantee as `scanRows`: staged sibling
112
+ * upserts are present and staged deletes are absent. The values form the
113
+ * complete, order-sensitive index tuple and `afterRowId` is an exclusive
114
+ * keyset cursor, so a caller pages without a scope declaration. There is no
115
+ * scope filter and no actor scope authorization: the caller already holds
116
+ * partition authority, exactly as with `scanRows`.
117
+ */
118
+ scanRowsByIndex(input: IndexScanInput): Promise<CommitValidationRow[]>;
94
119
  /**
95
120
  * Run a generated registered query on the commit transaction (§6.8). The
96
121
  * request has the shape `ServerStorage.queryAuthoritative` takes; the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.46.3",
3
+ "version": "0.48.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.46.3"
71
+ "@syncular/core": "0.48.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/errors.ts CHANGED
@@ -147,6 +147,15 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
147
147
  recommendedAction: 'fixRequest',
148
148
  httpStatus: 400,
149
149
  },
150
+ // §6.8: the whole-commit validator threw an exception that is not a
151
+ // deliberate rejection. A host fault; the thrown message rides the §6.3.1
152
+ // details and the original reaches the host's `onError`.
153
+ 'sync.validator_error': {
154
+ category: 'internal',
155
+ retryable: false,
156
+ recommendedAction: 'inspectServer',
157
+ httpStatus: 500,
158
+ },
150
159
  'sync.missing_scopes': {
151
160
  category: 'internal',
152
161
  retryable: false,
package/src/operations.ts CHANGED
@@ -37,6 +37,7 @@ import { transactionQuery } from './storage-query';
37
37
  import {
38
38
  toValidateRow,
39
39
  ValidationRejection,
40
+ type IndexScanInput,
40
41
  type ValidateRow,
41
42
  } from './validate';
42
43
 
@@ -138,6 +139,15 @@ export interface RemoteCommandContext {
138
139
  table: string,
139
140
  rowIds: readonly string[],
140
141
  ): Promise<ReadonlyMap<string, ValidateRow>>;
142
+ /**
143
+ * Exact lookup through one declared relational index on the command's push
144
+ * transaction. The values form the complete, order-sensitive index tuple
145
+ * and `afterRowId` is an exclusive keyset cursor. Unlike `getRow` and
146
+ * `getRows`, the rows are not filtered by the actor's scopes: `authorize`
147
+ * gates the command, and this is the same trusted server-host lookup
148
+ * `ServerStorage.scanRowsByIndex` performs.
149
+ */
150
+ scanRowsByIndex(input: IndexScanInput): Promise<ValidateRow[]>;
141
151
  /**
142
152
  * Run a generated registered query on the command's push transaction
143
153
  * (§6.7). The storage binds every relation to the partition; unlike
@@ -639,6 +649,36 @@ function commandContext(
639
649
  }
640
650
  return rows;
641
651
  },
652
+ scanRowsByIndex: async ({
653
+ table: tableName,
654
+ index,
655
+ values,
656
+ afterRowId = null,
657
+ limit = 100,
658
+ }) => {
659
+ const table = schema.tables.get(tableName);
660
+ if (table === undefined) {
661
+ throw syncError(
662
+ 'operation.invalid_request',
663
+ 'command read targets an unknown table',
664
+ );
665
+ }
666
+ if (tx.scanRowsByIndex === undefined) {
667
+ throw new Error(
668
+ 'storage transaction does not support command index scans',
669
+ );
670
+ }
671
+ const rows = await tx.scanRowsByIndex({
672
+ table: tableName,
673
+ index,
674
+ values,
675
+ afterRowId,
676
+ limit,
677
+ });
678
+ return rows.map((stored) =>
679
+ toValidateRow(table.columns, decodeRow(table.columns, stored.payload)),
680
+ );
681
+ },
642
682
  queryAuthoritative: (query) => transactionQuery(tx, query),
643
683
  };
644
684
  }
package/src/push.ts CHANGED
@@ -35,7 +35,7 @@ import type { BlobStore } from './blob-store';
35
35
  import type { SyncRequestContext } from './context';
36
36
  import { clockOf, limitsOf } from './context';
37
37
  import type { CrdtMergerRegistry } from './crdt-merger';
38
- import { SyncError } from './errors';
38
+ import { reportError, SyncError, type SyncularErrorHandler } from './errors';
39
39
  import { emitEvent } from './events';
40
40
  import {
41
41
  prepareReactions,
@@ -1110,12 +1110,41 @@ function commitValidationReader(
1110
1110
  serverVersion: stored.serverVersion,
1111
1111
  }));
1112
1112
  },
1113
+ scanRowsByIndex: async ({
1114
+ table: tableName,
1115
+ index,
1116
+ values,
1117
+ afterRowId = null,
1118
+ limit = 100,
1119
+ }) => {
1120
+ const table = tableFor(tableName);
1121
+ if (tx.scanRowsByIndex === undefined) {
1122
+ throw new Error(
1123
+ 'storage transaction does not support commit-validator index scans',
1124
+ );
1125
+ }
1126
+ const rows = await tx.scanRowsByIndex({
1127
+ table: tableName,
1128
+ index,
1129
+ values,
1130
+ afterRowId,
1131
+ limit,
1132
+ });
1133
+ return rows.map((stored) => ({
1134
+ row: toValidateRow(
1135
+ table.columns,
1136
+ decodeRow(table.columns, stored.payload),
1137
+ ),
1138
+ serverVersion: stored.serverVersion,
1139
+ }));
1140
+ },
1113
1141
  queryAuthoritative: (query) => transactionQuery(tx, query),
1114
1142
  };
1115
1143
  }
1116
1144
 
1117
1145
  async function runCommitValidator(
1118
1146
  validator: CommitValidator | undefined,
1147
+ onError: SyncularErrorHandler | undefined,
1119
1148
  tx: StorageTransaction,
1120
1149
  schema: CompiledSchema,
1121
1150
  clientId: string,
@@ -1160,10 +1189,34 @@ async function runCommitValidator(
1160
1189
  error.details,
1161
1190
  );
1162
1191
  }
1192
+ // §6.8: a throw that is not a deliberate rejection is a host fault, not a
1193
+ // rule refusal. The host's observation hook receives the original (its
1194
+ // stack included); the client gets a distinct code and, in the §6.3.1
1195
+ // details, the bounded thrown message. Stacks and thrown values stay off
1196
+ // the wire.
1197
+ reportError(error, onError, 'sync');
1198
+ let text = '';
1199
+ let bytes = 0;
1200
+ const encoder = new TextEncoder();
1201
+ const thrown = error instanceof Error ? error.message : String(error);
1202
+ for (const char of thrown.replace(/\p{Cc}+/gu, ' ').trim()) {
1203
+ const size = encoder.encode(char).length;
1204
+ if (bytes + size > 256) break;
1205
+ bytes += size;
1206
+ text += char;
1207
+ }
1208
+ text = text.trimEnd();
1163
1209
  return errorRecord(
1164
1210
  operations[0]?.opIndex ?? 0,
1165
- 'sync.constraint_violation',
1211
+ 'sync.validator_error',
1166
1212
  'whole-commit validator failed',
1213
+ false,
1214
+ {
1215
+ reason: 'validator_threw',
1216
+ ...(text === ''
1217
+ ? {}
1218
+ : { references: { validator: 'commit', message: text } }),
1219
+ },
1167
1220
  );
1168
1221
  }
1169
1222
  return undefined;
@@ -1243,6 +1296,32 @@ async function enforceReferences(
1243
1296
  });
1244
1297
  }
1245
1298
 
1299
+ // One candidate-state read per parent table, not one per reference: a
1300
+ // commit that points several rows at the same parent (a fight's
1301
+ // combatants, its chronicle) reads that parent once. Postgres binds the
1302
+ // ids as one array per table; SQLite and D1 answer with one lookup per id.
1303
+ const parentIds = new Map<string, Set<string>>();
1304
+ for (const { last: write } of finalByRow.values()) {
1305
+ if (write.op !== 'upsert' || write.values === undefined) continue;
1306
+ for (const reference of write.table.references) {
1307
+ const value = write.values[reference.columnIndex];
1308
+ if (value === null || value === undefined) continue;
1309
+ const parentRowId = renderScopeValue(value);
1310
+ if (parentRowId === undefined) continue;
1311
+ const ids = parentIds.get(reference.parentTable);
1312
+ if (ids === undefined)
1313
+ parentIds.set(reference.parentTable, new Set([parentRowId]));
1314
+ else ids.add(parentRowId);
1315
+ }
1316
+ }
1317
+ const parents = new Map<string, ReadonlyMap<string, StoredRow>>();
1318
+ for (const [parentTable, ids] of parentIds) {
1319
+ parents.set(
1320
+ parentTable,
1321
+ await readCandidateRows(tx, parentTable, [...ids]),
1322
+ );
1323
+ }
1324
+
1246
1325
  for (const { last: write } of finalByRow.values()) {
1247
1326
  if (write.op !== 'upsert' || write.values === undefined) continue;
1248
1327
  for (const reference of write.table.references) {
@@ -1250,7 +1329,7 @@ async function enforceReferences(
1250
1329
  if (value === null || value === undefined) continue;
1251
1330
  const parentRowId = renderScopeValue(value);
1252
1331
  if (parentRowId === undefined) continue;
1253
- if ((await tx.getRow(reference.parentTable, parentRowId)) !== undefined) {
1332
+ if (parents.get(reference.parentTable)?.has(parentRowId) === true) {
1254
1333
  continue;
1255
1334
  }
1256
1335
  return {
@@ -1769,6 +1848,7 @@ export async function processPushOperationsWithTrace(
1769
1848
  if (terminated === undefined) {
1770
1849
  const commitReject = await runCommitValidator(
1771
1850
  commitValidator,
1851
+ ctx.onError,
1772
1852
  tx,
1773
1853
  schema,
1774
1854
  clientId,
package/src/validate.ts CHANGED
@@ -97,6 +97,22 @@ export interface CommitValidationScanInput {
97
97
  readonly limit?: number;
98
98
  }
99
99
 
100
+ /**
101
+ * One exact declared-index lookup a push-transaction reader runs (§6.8): the
102
+ * whole-commit reader and a registered command share this shape.
103
+ */
104
+ export interface IndexScanInput {
105
+ readonly table: string;
106
+ /** `TableSchema.indexes[].name`; never exposed as a client subscription. */
107
+ readonly index: string;
108
+ /** Exact values in the index declaration's column order. */
109
+ readonly values: readonly RowValue[];
110
+ /** Resume after this rowId (exclusive); `null` = start of the match set. */
111
+ readonly afterRowId?: string | null;
112
+ /** Bounded per call by the server to 1..1,000; defaults to 100. */
113
+ readonly limit?: number;
114
+ }
115
+
100
116
  /**
101
117
  * Candidate-state reads bound to the same storage transaction as the commit.
102
118
  * Reads observe every staged sibling operation and no uncommitted competing
@@ -120,6 +136,16 @@ export interface CommitValidationReader {
120
136
  rowIds: readonly string[],
121
137
  ): Promise<ReadonlyMap<string, CommitValidationRow>>;
122
138
  scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
139
+ /**
140
+ * Candidate-state exact lookup through one declared relational index, with
141
+ * the same read-your-own-writes guarantee as `scanRows`: staged sibling
142
+ * upserts are present and staged deletes are absent. The values form the
143
+ * complete, order-sensitive index tuple and `afterRowId` is an exclusive
144
+ * keyset cursor, so a caller pages without a scope declaration. There is no
145
+ * scope filter and no actor scope authorization: the caller already holds
146
+ * partition authority, exactly as with `scanRows`.
147
+ */
148
+ scanRowsByIndex(input: IndexScanInput): Promise<CommitValidationRow[]>;
123
149
  /**
124
150
  * Run a generated registered query on the commit transaction (§6.8). The
125
151
  * request has the shape `ServerStorage.queryAuthoritative` takes; the