@syncular/server 0.15.20 → 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 +24 -0
- package/dist/d1-storage.d.ts +2 -1
- package/dist/d1-storage.js +51 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/postgres-storage.d.ts +2 -1
- package/dist/postgres-storage.js +17 -1
- package/dist/relational-rows.d.ts +11 -1
- package/dist/relational-rows.js +29 -0
- package/dist/schema.js +3 -0
- package/dist/sqlite-storage.d.ts +2 -1
- package/dist/sqlite-storage.js +17 -1
- package/dist/storage-errors.d.ts +11 -0
- package/dist/storage-errors.js +19 -0
- package/dist/storage-query.d.ts +6 -0
- package/dist/storage-query.js +30 -0
- package/dist/storage.d.ts +32 -1
- package/package.json +2 -2
- package/src/d1-storage.ts +81 -0
- package/src/index.ts +4 -0
- package/src/postgres-storage.ts +47 -0
- package/src/relational-rows.ts +45 -1
- package/src/schema.ts +5 -0
- package/src/sqlite-storage.ts +38 -0
- package/src/storage-errors.ts +36 -0
- package/src/storage-query.ts +48 -0
- package/src/storage.ts +37 -1
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
|
package/dist/d1-storage.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/d1-storage.js
CHANGED
|
@@ -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
|
@@ -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[]>;
|
package/dist/postgres-storage.js
CHANGED
|
@@ -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 —
|
package/dist/relational-rows.js
CHANGED
|
@@ -243,6 +243,35 @@ export function selectRowSql(table, dialect) {
|
|
|
243
243
|
const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
|
|
244
244
|
return `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Bounded exact lookup through one declared relational index. Unlike
|
|
248
|
+
* `scanRowPageSql`, this is a trusted server-host query: it never reads or
|
|
249
|
+
* creates Syncular scope-index entries and is not reachable from SSP2.
|
|
250
|
+
*/
|
|
251
|
+
export function indexRowPageStatement(table, index, values, partition, afterRowId, limit, dialect) {
|
|
252
|
+
const params = [partition];
|
|
253
|
+
const placeholder = () => dialect === 'sqlite' ? '?' : `$${params.length}`;
|
|
254
|
+
const predicates = index.columns.map((columnName, valueIndex) => {
|
|
255
|
+
const columnPosition = table.columnIndex.get(columnName);
|
|
256
|
+
const column = columnPosition === undefined ? undefined : table.columns[columnPosition];
|
|
257
|
+
if (column === undefined) {
|
|
258
|
+
throw new Error('compiled relational index references unknown column');
|
|
259
|
+
}
|
|
260
|
+
const value = values[valueIndex] ?? null;
|
|
261
|
+
if (value === null)
|
|
262
|
+
return `${quoteIdent(columnName)} IS NULL`;
|
|
263
|
+
params.push(toSqlValue(column, value, dialect));
|
|
264
|
+
return `${quoteIdent(columnName)}=${placeholder()}`;
|
|
265
|
+
});
|
|
266
|
+
params.push(afterRowId ?? '');
|
|
267
|
+
const after = placeholder();
|
|
268
|
+
params.push(limit);
|
|
269
|
+
const pageLimit = placeholder();
|
|
270
|
+
return {
|
|
271
|
+
sql: `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${dialect === 'sqlite' ? '?' : '$1'} AND ${predicates.join(' AND ')} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>${after} ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ${pageLimit}`,
|
|
272
|
+
params,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
246
275
|
/**
|
|
247
276
|
* One-round-trip page scan for `scanRows`: candidates from the inverted
|
|
248
277
|
* scope index (ordered + LIMITed at the covering `sync_row_scopes` PK —
|
package/dist/schema.js
CHANGED
|
@@ -61,6 +61,9 @@ export function compileSchema(schema) {
|
|
|
61
61
|
throw new Error(`table ${table.name}: duplicate index ${JSON.stringify(index.name)}`);
|
|
62
62
|
}
|
|
63
63
|
indexNames.add(index.name);
|
|
64
|
+
if (index.columns.length === 0) {
|
|
65
|
+
throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} must name at least one column`);
|
|
66
|
+
}
|
|
64
67
|
for (const column of index.columns) {
|
|
65
68
|
if (!columnIndex.has(column)) {
|
|
66
69
|
throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -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=?')
|
package/dist/storage-errors.d.ts
CHANGED
|
@@ -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. */
|
package/dist/storage-errors.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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';
|
package/src/postgres-storage.ts
CHANGED
|
@@ -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,
|
package/src/relational-rows.ts
CHANGED
|
@@ -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/sqlite-storage.ts
CHANGED
|
@@ -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,
|
package/src/storage-errors.ts
CHANGED
|
@@ -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,
|