@syncular/server 0.15.21 → 0.15.22

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
@@ -177,6 +177,30 @@ The task-oriented [concurrency and conflict-correction guide](https://syncular.d
177
177
  shows version projection, aggregate rollback, corrected replacement commits,
178
178
  explicit acknowledgement, and restart-safe recovery UI together.
179
179
 
180
+ ## Trusted relational-index lookups for authoritative commands
181
+
182
+ `scanRows` is a Syncular scope-index scan, never an unscoped administrative
183
+ query. Passing an empty or omitted `scopeFilter` throws the exported
184
+ `StorageQueryError` with `code: 'sync.storage.scan_requires_scope'` on
185
+ SQLite, PostgreSQL, and D1.
186
+
187
+ An authoritative command that needs an exact alternate lookup can instead use
188
+ the optional `storage.scanRowsByIndex(partition, query)` or transactional
189
+ `tx.scanRowsByIndex(query)` capability. The query names one declared
190
+ `TableSchema.indexes` entry, supplies one exact value per index column, uses an
191
+ exclusive `afterRowId`, and has a required limit from 1 through 1,000. All
192
+ shipped adapters implement it; transaction reads see staged writes and deletes.
193
+ It requires a materialized table.
194
+
195
+ This is a trusted `@syncular/server` storage capability, not SSP2: it creates no
196
+ scope variable, named-query obligation, subscription descriptor, or client
197
+ authority. Never expose table/index/value selection through a client-controlled
198
+ route. Custom adapters may omit the additive method; authoritative commands
199
+ must check for it and fail closed. See the public
200
+ [storage lookup guide](https://syncular.dev/server-storage/#choosing-the-right-row-lookup)
201
+ for a user-scoped key-grant table revoked through a Workspace index and for the
202
+ atomic reverse-index/queue fallback required by ordered or derived lookups.
203
+
180
204
  ## Structured events (the ops seam)
181
205
 
182
206
  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/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
  *
@@ -213,6 +214,12 @@ async function getRowOn(q, compiled, partition, rowId) {
213
214
  const record = rows[0];
214
215
  return record === undefined ? undefined : toStoredRow(record);
215
216
  }
217
+ async function scanRowsByIndexOn(q, compiled, partition, query) {
218
+ const index = resolveIndexRowScan(compiled, query);
219
+ const statement = indexRowPageStatement(compiled, index, query.values, partition, query.afterRowId, query.limit, 'postgres');
220
+ const { rows } = await q.query(statement.sql, statement.params);
221
+ return rows.map(toStoredRow);
222
+ }
216
223
  async function writeRowOn(q, compiled, partition, row) {
217
224
  await q.query(upsertSql(compiled, 'postgres'), upsertValues(compiled, partition, row, 'postgres'));
218
225
  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 +254,7 @@ class PostgresTransaction {
247
254
  }
248
255
  async scanRows(query) {
249
256
  this.#assertOpen();
257
+ assertScopeIndexedScan(query);
250
258
  const variables = Object.keys(query.scopeFilter).sort();
251
259
  const firstVariable = variables[0];
252
260
  if (firstVariable === undefined)
@@ -285,6 +293,10 @@ class PostgresTransaction {
285
293
  }
286
294
  return rows;
287
295
  }
296
+ scanRowsByIndex(query) {
297
+ this.#assertOpen();
298
+ return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
299
+ }
288
300
  async lockPartitionForCommitValidation() {
289
301
  this.#assertOpen();
290
302
  await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
@@ -673,6 +685,7 @@ export class PostgresServerStorage {
673
685
  return commits;
674
686
  }
675
687
  async scanRows(partition, query) {
688
+ assertScopeIndexedScan(query);
676
689
  const variables = Object.keys(query.scopeFilter).sort();
677
690
  const firstVariable = variables[0];
678
691
  if (firstVariable === undefined)
@@ -717,6 +730,9 @@ export class PostgresServerStorage {
717
730
  }
718
731
  return rows;
719
732
  }
733
+ scanRowsByIndex(partition, query) {
734
+ return scanRowsByIndexOn(this.#exec, this.table(query.table), partition, query);
735
+ }
720
736
  async getClientRecord(partition, clientId) {
721
737
  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
738
  const record = rows[0];
@@ -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)}`);
@@ -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 {
@@ -102,6 +102,25 @@ export interface RowScanQuery {
102
102
  readonly afterRowId: string | null;
103
103
  readonly limit: number;
104
104
  }
105
+ /**
106
+ * Exact server-host lookup through one declared relational index.
107
+ *
108
+ * This is deliberately NOT a Syncular scope or client query. It is available
109
+ * only to trusted server code that already owns a `ServerStorage` or
110
+ * `StorageTransaction` capability. Every declared index column must have one
111
+ * exact value, so adapters can keep the lookup bounded and deterministic.
112
+ */
113
+ export interface IndexRowScanQuery {
114
+ readonly table: string;
115
+ /** `TableSchema.indexes[].name`; never exposed as a client subscription. */
116
+ readonly index: string;
117
+ /** Exact values in the index declaration's column order. */
118
+ readonly values: readonly RowValue[];
119
+ /** Resume after this rowId (exclusive); `null` = start of the match set. */
120
+ readonly afterRowId?: string | null;
121
+ /** Integer from 1 through 1,000. */
122
+ readonly limit: number;
123
+ }
105
124
  export interface ClientCursorInfo {
106
125
  readonly clientId: string;
107
126
  readonly cursor: number;
@@ -156,6 +175,12 @@ export interface StorageTransaction {
156
175
  * semantics. A custom backend may omit it until `commitValidator` is used.
157
176
  */
158
177
  scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
178
+ /**
179
+ * Optional additive capability for trusted authoritative commands. In-tree
180
+ * SQLite/PostgreSQL/D1 adapters implement it with transaction-local
181
+ * read-your-own-writes semantics. It is not reachable from SSP2 requests.
182
+ */
183
+ scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
159
184
  /**
160
185
  * Serialize candidate-state validation for this partition before any row
161
186
  * read/write. Required at runtime when `commitValidator` is configured.
@@ -227,6 +252,12 @@ export interface ServerStorage {
227
252
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
228
253
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
229
254
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
255
+ /**
256
+ * Optional trusted-host exact lookup through a declared relational index.
257
+ * This capability is outside client scope/subscription authorization and
258
+ * MUST NOT be re-exported as a client-controlled endpoint.
259
+ */
260
+ scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
230
261
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
231
262
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
232
263
  /** 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.22",
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.22"
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/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';
@@ -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).
@@ -374,6 +377,26 @@ async function getRowOn(
374
377
  return record === undefined ? undefined : toStoredRow(record);
375
378
  }
376
379
 
380
+ async function scanRowsByIndexOn(
381
+ q: PgQueryable,
382
+ compiled: CompiledTable,
383
+ partition: string,
384
+ query: IndexRowScanQuery,
385
+ ): Promise<StoredRow[]> {
386
+ const index = resolveIndexRowScan(compiled, query);
387
+ const statement = indexRowPageStatement(
388
+ compiled,
389
+ index,
390
+ query.values,
391
+ partition,
392
+ query.afterRowId,
393
+ query.limit,
394
+ 'postgres',
395
+ );
396
+ const { rows } = await q.query<RowRecord>(statement.sql, statement.params);
397
+ return rows.map(toStoredRow);
398
+ }
399
+
377
400
  async function writeRowOn(
378
401
  q: PgQueryable,
379
402
  compiled: CompiledTable,
@@ -437,6 +460,7 @@ class PostgresTransaction implements StorageTransaction {
437
460
 
438
461
  async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
439
462
  this.#assertOpen();
463
+ assertScopeIndexedScan(query);
440
464
  const variables = Object.keys(query.scopeFilter).sort();
441
465
  const firstVariable = variables[0];
442
466
  if (firstVariable === undefined) return [];
@@ -473,6 +497,16 @@ class PostgresTransaction implements StorageTransaction {
473
497
  return rows;
474
498
  }
475
499
 
500
+ scanRowsByIndex(query: IndexRowScanQuery): Promise<StoredRow[]> {
501
+ this.#assertOpen();
502
+ return scanRowsByIndexOn(
503
+ this.#client,
504
+ this.#resolveTable(query.table),
505
+ this.#partition,
506
+ query,
507
+ );
508
+ }
509
+
476
510
  async lockPartitionForCommitValidation(): Promise<void> {
477
511
  this.#assertOpen();
478
512
  await this.#client.query(
@@ -1012,6 +1046,7 @@ export class PostgresServerStorage implements ServerStorage {
1012
1046
  }
1013
1047
 
1014
1048
  async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
1049
+ assertScopeIndexedScan(query);
1015
1050
  const variables = Object.keys(query.scopeFilter).sort();
1016
1051
  const firstVariable = variables[0];
1017
1052
  if (firstVariable === undefined) return [];
@@ -1054,6 +1089,18 @@ export class PostgresServerStorage implements ServerStorage {
1054
1089
  return rows;
1055
1090
  }
1056
1091
 
1092
+ scanRowsByIndex(
1093
+ partition: string,
1094
+ query: IndexRowScanQuery,
1095
+ ): Promise<StoredRow[]> {
1096
+ return scanRowsByIndexOn(
1097
+ this.#exec,
1098
+ this.table(query.table),
1099
+ partition,
1100
+ query,
1101
+ );
1102
+ }
1103
+
1057
1104
  async getClientRecord(
1058
1105
  partition: string,
1059
1106
  clientId: string,
@@ -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(
@@ -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,
@@ -13,6 +13,42 @@ export class StorageConstraintError extends Error {
13
13
  }
14
14
  }
15
15
 
16
+ /** Stable, privacy-safe failures for trusted server storage queries. */
17
+ export type StorageQueryErrorCode =
18
+ | 'sync.storage.scan_requires_scope'
19
+ | 'sync.storage.index_not_found'
20
+ | 'sync.storage.index_not_materialized'
21
+ | 'sync.storage.index_value_count_mismatch'
22
+ | 'sync.storage.invalid_limit';
23
+
24
+ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
25
+ {
26
+ 'sync.storage.scan_requires_scope':
27
+ 'scope-indexed row scans require at least one scope variable',
28
+ 'sync.storage.index_not_found':
29
+ 'trusted row lookup requires a declared relational index',
30
+ 'sync.storage.index_not_materialized':
31
+ 'trusted row lookup requires a materialized relational table',
32
+ 'sync.storage.index_value_count_mismatch':
33
+ 'trusted row lookup requires one exact value per index column',
34
+ 'sync.storage.invalid_limit':
35
+ 'trusted row lookup limit must be an integer from 1 through 1,000',
36
+ };
37
+
38
+ /**
39
+ * Host-only query error. Messages never include identifiers, values, SQL,
40
+ * paths, or row data; callers branch on `code`, never message text.
41
+ */
42
+ export class StorageQueryError extends Error {
43
+ override readonly name = 'StorageQueryError';
44
+ readonly code: StorageQueryErrorCode;
45
+
46
+ constructor(code: StorageQueryErrorCode) {
47
+ super(STORAGE_QUERY_MESSAGES[code]);
48
+ this.code = code;
49
+ }
50
+ }
51
+
16
52
  interface DriverError {
17
53
  readonly code?: unknown;
18
54
  readonly errno?: unknown;
@@ -0,0 +1,48 @@
1
+ import type { ScopeMap } from '@syncular/core';
2
+ import type { CompiledTable, IndexSchema } from './schema';
3
+ import type { IndexRowScanQuery, RowScanQuery } from './storage';
4
+ import { StorageQueryError } from './storage-errors';
5
+
6
+ /** Fail loudly instead of making an unsupported unscoped scan look empty. */
7
+ export function assertScopeIndexedScan(query: RowScanQuery): void {
8
+ const scopeFilter = (
9
+ query as RowScanQuery & { readonly scopeFilter?: ScopeMap | null }
10
+ ).scopeFilter;
11
+ if (
12
+ scopeFilter === undefined ||
13
+ scopeFilter === null ||
14
+ Object.keys(scopeFilter).length === 0
15
+ ) {
16
+ throw new StorageQueryError('sync.storage.scan_requires_scope');
17
+ }
18
+ }
19
+
20
+ /** Validate and resolve one exact trusted-host relational index lookup. */
21
+ export function resolveIndexRowScan(
22
+ table: CompiledTable,
23
+ query: IndexRowScanQuery,
24
+ ): IndexSchema {
25
+ if (
26
+ !Number.isInteger(query.limit) ||
27
+ query.limit < 1 ||
28
+ query.limit > 1_000
29
+ ) {
30
+ throw new StorageQueryError('sync.storage.invalid_limit');
31
+ }
32
+ if (!table.materialize) {
33
+ throw new StorageQueryError('sync.storage.index_not_materialized');
34
+ }
35
+ const index = table.indexes.find(
36
+ (candidate) => candidate.name === query.index,
37
+ );
38
+ if (index === undefined) {
39
+ throw new StorageQueryError('sync.storage.index_not_found');
40
+ }
41
+ if (
42
+ !Array.isArray(query.values) ||
43
+ query.values.length !== index.columns.length
44
+ ) {
45
+ throw new StorageQueryError('sync.storage.index_value_count_mismatch');
46
+ }
47
+ return index;
48
+ }
package/src/storage.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';
21
21
 
22
22
  /** The current stored state of a synced row. */
@@ -113,6 +113,26 @@ export interface RowScanQuery {
113
113
  readonly limit: number;
114
114
  }
115
115
 
116
+ /**
117
+ * Exact server-host lookup through one declared relational index.
118
+ *
119
+ * This is deliberately NOT a Syncular scope or client query. It is available
120
+ * only to trusted server code that already owns a `ServerStorage` or
121
+ * `StorageTransaction` capability. Every declared index column must have one
122
+ * exact value, so adapters can keep the lookup bounded and deterministic.
123
+ */
124
+ export interface IndexRowScanQuery {
125
+ readonly table: string;
126
+ /** `TableSchema.indexes[].name`; never exposed as a client subscription. */
127
+ readonly index: string;
128
+ /** Exact values in the index declaration's column order. */
129
+ readonly values: readonly RowValue[];
130
+ /** Resume after this rowId (exclusive); `null` = start of the match set. */
131
+ readonly afterRowId?: string | null;
132
+ /** Integer from 1 through 1,000. */
133
+ readonly limit: number;
134
+ }
135
+
116
136
  export interface ClientCursorInfo {
117
137
  readonly clientId: string;
118
138
  readonly cursor: number;
@@ -172,6 +192,12 @@ export interface StorageTransaction {
172
192
  * semantics. A custom backend may omit it until `commitValidator` is used.
173
193
  */
174
194
  scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
195
+ /**
196
+ * Optional additive capability for trusted authoritative commands. In-tree
197
+ * SQLite/PostgreSQL/D1 adapters implement it with transaction-local
198
+ * read-your-own-writes semantics. It is not reachable from SSP2 requests.
199
+ */
200
+ scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
175
201
  /**
176
202
  * Serialize candidate-state validation for this partition before any row
177
203
  * read/write. Required at runtime when `commitValidator` is configured.
@@ -279,6 +305,16 @@ export interface ServerStorage {
279
305
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
280
306
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
281
307
 
308
+ /**
309
+ * Optional trusted-host exact lookup through a declared relational index.
310
+ * This capability is outside client scope/subscription authorization and
311
+ * MUST NOT be re-exported as a client-controlled endpoint.
312
+ */
313
+ scanRowsByIndex?(
314
+ partition: string,
315
+ query: IndexRowScanQuery,
316
+ ): Promise<StoredRow[]>;
317
+
282
318
  getClientRecord(
283
319
  partition: string,
284
320
  clientId: string,