@syncular/server 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/d1-storage.js +102 -34
- package/dist/operations.d.ts +7 -1
- package/dist/operations.js +2 -0
- package/dist/postgres-storage.js +47 -56
- package/dist/push.js +13 -7
- package/dist/relational-rows.d.ts +31 -12
- package/dist/relational-rows.js +121 -26
- package/dist/sqlite-storage.d.ts +7 -0
- package/dist/sqlite-storage.js +32 -50
- package/dist/storage-errors.d.ts +5 -3
- package/dist/storage-errors.js +9 -2
- package/dist/storage-query.d.ts +8 -1
- package/dist/storage-query.js +12 -0
- package/dist/storage.d.ts +25 -11
- package/dist/validate.d.ts +33 -15
- package/package.json +2 -2
- package/src/d1-storage.ts +139 -36
- package/src/operations.ts +15 -1
- package/src/postgres-storage.ts +86 -66
- package/src/push.ts +13 -1
- package/src/relational-rows.ts +145 -30
- package/src/sqlite-storage.ts +53 -61
- package/src/storage-errors.ts +21 -3
- package/src/storage-query.ts +23 -1
- package/src/storage.ts +27 -11
- package/src/validate.ts +40 -15
package/src/relational-rows.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from '@syncular/core';
|
|
56
56
|
import type { CompiledSchema, CompiledTable, IndexSchema } from './schema';
|
|
57
57
|
import type { StoredRow } from './storage';
|
|
58
|
+
import { StorageQueryError } from './storage-errors';
|
|
58
59
|
|
|
59
60
|
export type RelationalDialect = 'sqlite' | 'postgres';
|
|
60
61
|
|
|
@@ -192,38 +193,112 @@ export function tableColumnNames(table: CompiledTable): string[] {
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
/**
|
|
195
|
-
* The
|
|
196
|
-
*
|
|
196
|
+
* The storage-internal columns of every synced table, in `createTableDdl`
|
|
197
|
+
* order (app columns sit between the row id and the version). The same-version
|
|
198
|
+
* physical check compares a table against exactly these declarations.
|
|
199
|
+
*/
|
|
200
|
+
function syncMetaColumns(dialect: RelationalDialect): readonly {
|
|
201
|
+
readonly name: string;
|
|
202
|
+
readonly type: string;
|
|
203
|
+
readonly notNull: boolean;
|
|
204
|
+
}[] {
|
|
205
|
+
const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
|
|
206
|
+
return [
|
|
207
|
+
{ name: SYNC_PARTITION_COLUMN, type: 'TEXT', notNull: true },
|
|
208
|
+
{ name: SYNC_ROW_ID_COLUMN, type: 'TEXT', notNull: true },
|
|
209
|
+
{
|
|
210
|
+
name: SYNC_VERSION_COLUMN,
|
|
211
|
+
type: dialect === 'postgres' ? 'BIGINT' : 'INTEGER',
|
|
212
|
+
notNull: true,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: SYNC_SCOPES_COLUMN,
|
|
216
|
+
type: dialect === 'postgres' ? 'JSONB' : 'TEXT',
|
|
217
|
+
notNull: true,
|
|
218
|
+
},
|
|
219
|
+
{ name: SYNC_PAYLOAD_COLUMN, type: payloadType, notNull: true },
|
|
220
|
+
{ name: SYNC_COLUMN_VERSIONS_COLUMN, type: payloadType, notNull: false },
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** One physical column of a synced table, read from the database catalog. */
|
|
225
|
+
export interface PhysicalColumn {
|
|
226
|
+
readonly name: string;
|
|
227
|
+
/** Declared type (SQLite) or `format_type` (PostgreSQL); compared case-insensitively. */
|
|
228
|
+
readonly type: string;
|
|
229
|
+
readonly notNull: boolean;
|
|
230
|
+
/** 1-based position in the primary key; 0 when the column is not in it. */
|
|
231
|
+
readonly primaryKeyPosition: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Refuse a same-version database whose physical table the row store cannot
|
|
236
|
+
* read or write. The persisted marker's layouts describe the codec's app
|
|
197
237
|
* columns only and its version number is the schema's, not the database's, so
|
|
198
|
-
* a same-version database
|
|
199
|
-
* version-only bump whose ALTER never ran
|
|
200
|
-
*
|
|
238
|
+
* a same-version database whose storage-internal meta columns drifted (a
|
|
239
|
+
* version-only bump whose ALTER never ran, or a table written by another
|
|
240
|
+
* build) is only visible in the catalog. Every configured column must exist;
|
|
241
|
+
* the meta columns must also carry the type, nullability, and primary-key
|
|
242
|
+
* position `createTableDdl` declares. App-column types and nullability stay
|
|
243
|
+
* with the stored layouts: migration-added app columns are nullable by design.
|
|
244
|
+
* Fails closed without writing a migration.
|
|
201
245
|
*
|
|
202
|
-
* `
|
|
203
|
-
*
|
|
246
|
+
* `columns` must be resolved the way the unqualified row queries resolve the
|
|
247
|
+
* table: on PostgreSQL, through the session `search_path`, not
|
|
204
248
|
* `current_schema()`. A search path whose first schema is not where the
|
|
205
249
|
* configured tables live is outside the supported contract: DDL is created in
|
|
206
250
|
* that first schema, so `ensureSchema` already writes shadow tables there.
|
|
207
251
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* persisted layouts answer for app columns.
|
|
252
|
+
* Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
|
|
253
|
+
* this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
|
|
254
|
+
* and column additions, independently of the version marker.
|
|
212
255
|
*/
|
|
213
256
|
export function assertPhysicalColumns(
|
|
214
257
|
table: CompiledTable,
|
|
215
|
-
|
|
258
|
+
columns: readonly PhysicalColumn[],
|
|
259
|
+
dialect: RelationalDialect,
|
|
216
260
|
): void {
|
|
217
|
-
|
|
218
|
-
throw new
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
261
|
+
const refuse = (details: Record<string, string>): never => {
|
|
262
|
+
throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
|
|
263
|
+
table: table.name,
|
|
264
|
+
...details,
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
if (columns.length === 0) refuse({ reason: 'missing_table' });
|
|
268
|
+
const byName = new Map(columns.map((column) => [column.name, column]));
|
|
222
269
|
for (const name of tableColumnNames(table)) {
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
);
|
|
270
|
+
if (!byName.has(name)) refuse({ column: name, reason: 'missing_column' });
|
|
271
|
+
}
|
|
272
|
+
for (const expected of syncMetaColumns(dialect)) {
|
|
273
|
+
const actual = byName.get(expected.name);
|
|
274
|
+
if (actual === undefined) continue;
|
|
275
|
+
if (actual.type.toUpperCase() !== expected.type) {
|
|
276
|
+
refuse({
|
|
277
|
+
column: expected.name,
|
|
278
|
+
reason: 'type',
|
|
279
|
+
expected: expected.type,
|
|
280
|
+
actual: actual.type,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (actual.notNull !== expected.notNull) {
|
|
284
|
+
refuse({
|
|
285
|
+
column: expected.name,
|
|
286
|
+
reason: 'nullability',
|
|
287
|
+
expected: expected.notNull ? 'not_null' : 'nullable',
|
|
288
|
+
actual: actual.notNull ? 'not_null' : 'nullable',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const column of columns) {
|
|
293
|
+
const expected =
|
|
294
|
+
column.name === SYNC_PARTITION_COLUMN
|
|
295
|
+
? 1
|
|
296
|
+
: column.name === SYNC_ROW_ID_COLUMN
|
|
297
|
+
? 2
|
|
298
|
+
: 0;
|
|
299
|
+
if (column.primaryKeyPosition !== expected) {
|
|
300
|
+
refuse({ column: column.name, reason: 'primary_key' });
|
|
301
|
+
}
|
|
227
302
|
}
|
|
228
303
|
}
|
|
229
304
|
|
|
@@ -232,22 +307,20 @@ export function createTableDdl(
|
|
|
232
307
|
table: CompiledTable,
|
|
233
308
|
dialect: RelationalDialect,
|
|
234
309
|
): string {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
310
|
+
const [partition, rowId, ...trailing] = syncMetaColumns(dialect).map(
|
|
311
|
+
(column) =>
|
|
312
|
+
`${quoteIdent(column.name)} ${column.type}${column.notNull ? ' NOT NULL' : ''}`,
|
|
313
|
+
);
|
|
238
314
|
const defs = [
|
|
239
|
-
|
|
240
|
-
|
|
315
|
+
partition,
|
|
316
|
+
rowId,
|
|
241
317
|
...(table.materialize
|
|
242
318
|
? table.columns.map((column) => {
|
|
243
319
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
244
320
|
return `${quoteIdent(column.name)} ${columnSqlType(column, dialect)}${notNull}`;
|
|
245
321
|
})
|
|
246
322
|
: []),
|
|
247
|
-
|
|
248
|
-
`${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
|
|
249
|
-
`${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
|
|
250
|
-
`${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
|
|
323
|
+
...trailing,
|
|
251
324
|
`PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
|
|
252
325
|
];
|
|
253
326
|
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
|
|
@@ -660,6 +733,48 @@ export function parseLayouts(json: string | null | undefined): StoredLayouts {
|
|
|
660
733
|
return JSON.parse(json) as StoredLayouts;
|
|
661
734
|
}
|
|
662
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Version equality is not layout equality: a marker written by another build
|
|
738
|
+
* at the same version describes rows the running codec cannot decode. Refuse
|
|
739
|
+
* when the stored layouts disagree with the configured schema, naming the
|
|
740
|
+
* first table (and column) that differs.
|
|
741
|
+
*/
|
|
742
|
+
export function assertStoredLayouts(
|
|
743
|
+
schema: CompiledSchema,
|
|
744
|
+
storedJson: string | null | undefined,
|
|
745
|
+
): void {
|
|
746
|
+
const storedLayouts = parseLayouts(storedJson);
|
|
747
|
+
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
748
|
+
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
749
|
+
const stored = storedLayouts[tableName];
|
|
750
|
+
const columnCount = Math.max(columns.length, stored?.length ?? 0);
|
|
751
|
+
for (let index = 0; index < columnCount; index++) {
|
|
752
|
+
const expected = columns[index];
|
|
753
|
+
const actual = stored?.[index];
|
|
754
|
+
if (
|
|
755
|
+
expected !== undefined &&
|
|
756
|
+
actual !== undefined &&
|
|
757
|
+
actual.name === expected.name &&
|
|
758
|
+
actual.type === expected.type &&
|
|
759
|
+
actual.nullable === expected.nullable
|
|
760
|
+
) {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
|
|
764
|
+
table: tableName,
|
|
765
|
+
column: expected?.name ?? actual?.name ?? '',
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
for (const tableName of Object.keys(storedLayouts)) {
|
|
770
|
+
if (!(tableName in configuredLayouts)) {
|
|
771
|
+
throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
|
|
772
|
+
table: tableName,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
663
778
|
/**
|
|
664
779
|
* Tables present in the stored layout but absent from the configured head
|
|
665
780
|
* schema. A schema-version bump retires their relational current-row tables;
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { syncError } from './errors';
|
|
19
19
|
import {
|
|
20
20
|
assertPhysicalColumns,
|
|
21
|
+
assertStoredLayouts,
|
|
21
22
|
commitWindowPageSql,
|
|
22
23
|
deleteRowSql,
|
|
23
24
|
deleteSqliteRowScopesSql,
|
|
@@ -244,6 +245,15 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
244
245
|
return this.#storage.scanRowsByIndex(this.#partition, query);
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
async queryAuthoritative(
|
|
249
|
+
query: AuthoritativeQueryRequest,
|
|
250
|
+
): Promise<AuthoritativeQueryResult> {
|
|
251
|
+
this.#assertOpen();
|
|
252
|
+
// Same shared connection: the query runs inside this transaction's
|
|
253
|
+
// BEGIN IMMEDIATE and observes every row staged so far.
|
|
254
|
+
return this.#storage.runAuthoritativeQuery(this.#partition, query);
|
|
255
|
+
}
|
|
256
|
+
|
|
247
257
|
async lockPartitionForPush(): Promise<void> {
|
|
248
258
|
this.#assertOpen();
|
|
249
259
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
@@ -594,57 +604,25 @@ END`);
|
|
|
594
604
|
);
|
|
595
605
|
}
|
|
596
606
|
if (marker !== null && marker.schema_version === schema.version) {
|
|
597
|
-
|
|
598
|
-
// build at the same version describes rows the running codec cannot
|
|
599
|
-
// decode. Compare the stored layouts instead of trusting the number.
|
|
600
|
-
const storedLayouts = parseLayouts(marker.layouts);
|
|
601
|
-
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
602
|
-
let mismatch: string | undefined;
|
|
603
|
-
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
604
|
-
const stored = storedLayouts[tableName];
|
|
605
|
-
const columnCount = Math.max(columns.length, stored?.length ?? 0);
|
|
606
|
-
for (let index = 0; index < columnCount; index++) {
|
|
607
|
-
const expected = columns[index];
|
|
608
|
-
const actual = stored?.[index];
|
|
609
|
-
if (
|
|
610
|
-
expected !== undefined &&
|
|
611
|
-
actual !== undefined &&
|
|
612
|
-
actual.name === expected.name &&
|
|
613
|
-
actual.type === expected.type &&
|
|
614
|
-
actual.nullable === expected.nullable
|
|
615
|
-
) {
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
619
|
-
break;
|
|
620
|
-
}
|
|
621
|
-
if (mismatch !== undefined) break;
|
|
622
|
-
}
|
|
623
|
-
if (mismatch === undefined) {
|
|
624
|
-
for (const tableName of Object.keys(storedLayouts)) {
|
|
625
|
-
if (!(tableName in configuredLayouts)) {
|
|
626
|
-
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
627
|
-
break;
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
if (mismatch !== undefined) {
|
|
632
|
-
throw new Error(
|
|
633
|
-
`stored schema layouts disagree with the configured schema at version ${schema.version} (${mismatch}) — refusing to serve a database whose stored rows the running code cannot decode`,
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
// The persisted layouts describe the codec's app columns only: read the
|
|
637
|
-
// physical tables so a same-version database missing a
|
|
638
|
-
// storage-internal column is refused at startup instead of failing at
|
|
639
|
-
// the first write.
|
|
607
|
+
assertStoredLayouts(schema, marker.layouts);
|
|
640
608
|
for (const table of schema.tables.values()) {
|
|
641
609
|
const escapedTableName = table.name.replaceAll('"', '""');
|
|
642
610
|
const columns = this.db
|
|
643
|
-
.query<
|
|
644
|
-
|
|
645
|
-
|
|
611
|
+
.query<
|
|
612
|
+
{ name: string; type: string; notnull: number; pk: number },
|
|
613
|
+
[]
|
|
614
|
+
>(`PRAGMA table_info("${escapedTableName}")`)
|
|
646
615
|
.all();
|
|
647
|
-
assertPhysicalColumns(
|
|
616
|
+
assertPhysicalColumns(
|
|
617
|
+
table,
|
|
618
|
+
columns.map((column) => ({
|
|
619
|
+
name: column.name,
|
|
620
|
+
type: column.type,
|
|
621
|
+
notNull: column.notnull === 1,
|
|
622
|
+
primaryKeyPosition: column.pk,
|
|
623
|
+
})),
|
|
624
|
+
'sqlite',
|
|
625
|
+
);
|
|
648
626
|
}
|
|
649
627
|
}
|
|
650
628
|
if (marker === null || marker.schema_version < schema.version) {
|
|
@@ -945,11 +923,16 @@ END`);
|
|
|
945
923
|
return row?.max_commit_seq ?? 0;
|
|
946
924
|
}
|
|
947
925
|
|
|
948
|
-
|
|
926
|
+
/**
|
|
927
|
+
* Internal: rewrite and run one registered query on the shared connection.
|
|
928
|
+
* The caller owns the transaction: `queryAuthoritative` opens a read
|
|
929
|
+
* transaction behind the FIFO; `SqliteTransaction.queryAuthoritative` runs
|
|
930
|
+
* inside the push transaction's `BEGIN IMMEDIATE`.
|
|
931
|
+
*/
|
|
932
|
+
runAuthoritativeQuery(
|
|
949
933
|
partition: string,
|
|
950
934
|
query: AuthoritativeQueryRequest,
|
|
951
|
-
|
|
952
|
-
): Promise<AuthoritativeQueryResult> {
|
|
935
|
+
): AuthoritativeQueryResult {
|
|
953
936
|
if (this.#tables === undefined) {
|
|
954
937
|
throw new Error(
|
|
955
938
|
'ensureSchema(schema) must run before registered queries',
|
|
@@ -964,6 +947,24 @@ END`);
|
|
|
964
947
|
),
|
|
965
948
|
partition,
|
|
966
949
|
);
|
|
950
|
+
const rows = this.db
|
|
951
|
+
.query<Readonly<Record<string, unknown>>, AuthoritativeQueryValue[]>(
|
|
952
|
+
prepared.sql,
|
|
953
|
+
)
|
|
954
|
+
.all(...prepared.params);
|
|
955
|
+
const cursor = this.db
|
|
956
|
+
.query<{ max_commit_seq: number }, [string]>(
|
|
957
|
+
'SELECT max_commit_seq FROM sync_partitions WHERE partition=?',
|
|
958
|
+
)
|
|
959
|
+
.get(partition);
|
|
960
|
+
return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async queryAuthoritative(
|
|
964
|
+
partition: string,
|
|
965
|
+
query: AuthoritativeQueryRequest,
|
|
966
|
+
checkpoints?: readonly CheckpointDeclaration[],
|
|
967
|
+
): Promise<AuthoritativeQueryResult> {
|
|
967
968
|
const previous = this.#transactionTail;
|
|
968
969
|
let release!: () => void;
|
|
969
970
|
this.#transactionTail = new Promise<void>((resolve) => {
|
|
@@ -981,19 +982,10 @@ END`);
|
|
|
981
982
|
const gate = await this.readServeGate(partition, runningVersion);
|
|
982
983
|
const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
|
|
983
984
|
if (refusal !== undefined) throw serveNotReadyError(refusal);
|
|
984
|
-
const
|
|
985
|
-
.query<Readonly<Record<string, unknown>>, AuthoritativeQueryValue[]>(
|
|
986
|
-
prepared.sql,
|
|
987
|
-
)
|
|
988
|
-
.all(...prepared.params);
|
|
989
|
-
const cursor = this.db
|
|
990
|
-
.query<{ max_commit_seq: number }, [string]>(
|
|
991
|
-
'SELECT max_commit_seq FROM sync_partitions WHERE partition=?',
|
|
992
|
-
)
|
|
993
|
-
.get(partition);
|
|
985
|
+
const result = this.runAuthoritativeQuery(partition, query);
|
|
994
986
|
this.db.exec('COMMIT');
|
|
995
987
|
open = false;
|
|
996
|
-
return
|
|
988
|
+
return result;
|
|
997
989
|
} catch (error) {
|
|
998
990
|
if (open) this.db.exec('ROLLBACK');
|
|
999
991
|
throw error;
|
package/src/storage-errors.ts
CHANGED
|
@@ -30,7 +30,11 @@ export type StorageQueryErrorCode =
|
|
|
30
30
|
| 'sync.storage.checkpoint_not_declared'
|
|
31
31
|
| 'sync.storage.checkpoint_unsupported'
|
|
32
32
|
| 'sync.storage.checkpoint_fence_missing'
|
|
33
|
-
| 'sync.storage.checkpoint_incomplete'
|
|
33
|
+
| 'sync.storage.checkpoint_incomplete'
|
|
34
|
+
| 'sync.storage.stored_layout_mismatch'
|
|
35
|
+
| 'sync.storage.physical_layout_mismatch'
|
|
36
|
+
| 'sync.storage.transaction_query_unsupported'
|
|
37
|
+
| 'sync.storage.query_over_staged_writes';
|
|
34
38
|
|
|
35
39
|
const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
|
|
36
40
|
{
|
|
@@ -56,6 +60,14 @@ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
|
|
|
56
60
|
'checkpoint activation requires the writer fence raised at declaration',
|
|
57
61
|
'sync.storage.checkpoint_incomplete':
|
|
58
62
|
'a declared backfill checkpoint is not activated and this caller did not declare it',
|
|
63
|
+
'sync.storage.stored_layout_mismatch':
|
|
64
|
+
'stored schema layouts disagree with the configured schema at the same version',
|
|
65
|
+
'sync.storage.physical_layout_mismatch':
|
|
66
|
+
'a synced table does not match the storage layout the running code reads and writes',
|
|
67
|
+
'sync.storage.transaction_query_unsupported':
|
|
68
|
+
'this storage transaction cannot run registered queries',
|
|
69
|
+
'sync.storage.query_over_staged_writes':
|
|
70
|
+
'registered query reads a table this transaction has buffered writes for',
|
|
59
71
|
'sync.storage.scan_requires_scope':
|
|
60
72
|
'scope-indexed row scans require at least one scope variable',
|
|
61
73
|
'sync.storage.index_not_found':
|
|
@@ -70,15 +82,21 @@ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
|
|
|
70
82
|
|
|
71
83
|
/**
|
|
72
84
|
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
73
|
-
* paths, or row data; callers branch on `code`, never message text.
|
|
85
|
+
* paths, or row data; callers branch on `code`, never message text. The
|
|
86
|
+
* schema-readiness codes name the offending table and column in `details`.
|
|
74
87
|
*/
|
|
75
88
|
export class StorageQueryError extends Error {
|
|
76
89
|
override readonly name = 'StorageQueryError';
|
|
77
90
|
readonly code: StorageQueryErrorCode;
|
|
91
|
+
readonly details: Readonly<Record<string, string>> | undefined;
|
|
78
92
|
|
|
79
|
-
constructor(
|
|
93
|
+
constructor(
|
|
94
|
+
code: StorageQueryErrorCode,
|
|
95
|
+
details?: Readonly<Record<string, string>>,
|
|
96
|
+
) {
|
|
80
97
|
super(STORAGE_QUERY_MESSAGES[code]);
|
|
81
98
|
this.code = code;
|
|
99
|
+
this.details = details;
|
|
82
100
|
}
|
|
83
101
|
}
|
|
84
102
|
|
package/src/storage-query.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { ScopeMap } from '@syncular/core';
|
|
2
2
|
import type { CompiledTable, IndexSchema } from './schema';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AuthoritativeQueryRequest,
|
|
5
|
+
AuthoritativeQueryResult,
|
|
6
|
+
IndexRowScanQuery,
|
|
7
|
+
RowScanQuery,
|
|
8
|
+
StorageTransaction,
|
|
9
|
+
} from './storage';
|
|
4
10
|
import { StorageQueryError } from './storage-errors';
|
|
5
11
|
|
|
6
12
|
/**
|
|
@@ -51,3 +57,19 @@ export function resolveIndexRowScan(
|
|
|
51
57
|
}
|
|
52
58
|
return index;
|
|
53
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* §6.7/§6.8 transaction-bound registered query for every host hook that runs
|
|
63
|
+
* inside a push transaction. A storage transaction without the capability
|
|
64
|
+
* fails; the call never falls back to `ServerStorage.queryAuthoritative`,
|
|
65
|
+
* which reads outside the transaction.
|
|
66
|
+
*/
|
|
67
|
+
export async function transactionQuery(
|
|
68
|
+
tx: StorageTransaction,
|
|
69
|
+
query: AuthoritativeQueryRequest,
|
|
70
|
+
): Promise<AuthoritativeQueryResult> {
|
|
71
|
+
if (tx.queryAuthoritative === undefined) {
|
|
72
|
+
throw new StorageQueryError('sync.storage.transaction_query_unsupported');
|
|
73
|
+
}
|
|
74
|
+
return tx.queryAuthoritative(query);
|
|
75
|
+
}
|
package/src/storage.ts
CHANGED
|
@@ -473,6 +473,21 @@ export interface StorageTransaction {
|
|
|
473
473
|
* read-your-own-writes semantics. It is not reachable from SSP2 requests.
|
|
474
474
|
*/
|
|
475
475
|
scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
476
|
+
/**
|
|
477
|
+
* Optional transaction-bound registered query (§6.7, §6.8): the relation
|
|
478
|
+
* rewrite of `ServerStorage.queryAuthoritative`, bound to this
|
|
479
|
+
* transaction's partition and executed on this transaction's connection.
|
|
480
|
+
* A hook inside the push transaction therefore reads the transaction's
|
|
481
|
+
* snapshot and never waits for a second connection. The push layer has
|
|
482
|
+
* already evaluated the serve gate on this transaction, so the call takes
|
|
483
|
+
* no checkpoint declarations. SQLite and PostgreSQL return the staged
|
|
484
|
+
* writes of this transaction. D1 buffers writes until commit and fails
|
|
485
|
+
* with `sync.storage.query_over_staged_writes` when the query reads a
|
|
486
|
+
* table this transaction has written.
|
|
487
|
+
*/
|
|
488
|
+
queryAuthoritative?(
|
|
489
|
+
query: AuthoritativeQueryRequest,
|
|
490
|
+
): Promise<AuthoritativeQueryResult>;
|
|
476
491
|
/**
|
|
477
492
|
* Serialize every push apply for this partition before any operation read,
|
|
478
493
|
* validation, merge, or write. The push layer re-checks idempotency only
|
|
@@ -790,17 +805,18 @@ export interface ServerStorage {
|
|
|
790
805
|
* every generated app-table relation with a partition-filtered relation and
|
|
791
806
|
* return rows plus maxCommitSeq from one consistent database snapshot.
|
|
792
807
|
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
* the push never completes. A pool-backed
|
|
798
|
-
* the call a second connection
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
808
|
+
* Do not call this method from a hook that runs inside the push
|
|
809
|
+
* transaction (a row validator, the whole-commit validator, or a reaction
|
|
810
|
+
* planner). On a single-connection executor (SQLite, PGlite) the storage
|
|
811
|
+
* serializes this call behind the open transaction, so the hook waits on
|
|
812
|
+
* the transaction it is inside and the push never completes. A pool-backed
|
|
813
|
+
* executor (`pg`, `Bun.sql`) runs the call on a second connection: it reads
|
|
814
|
+
* committed state without the transaction's staged writes, and it waits
|
|
815
|
+
* forever when the push transactions hold every pool connection. Those
|
|
816
|
+
* hooks call the transaction-bound `queryAuthoritative` on
|
|
817
|
+
* `ValidateContext` or on the candidate-state reader instead (§6.7, §6.8).
|
|
818
|
+
* A call from any other context waits for the open transaction on a
|
|
819
|
+
* single-connection executor and runs concurrently on a pool-backed one.
|
|
804
820
|
*/
|
|
805
821
|
queryAuthoritative?(
|
|
806
822
|
partition: string,
|
package/src/validate.ts
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
type RowValue,
|
|
21
21
|
type ScopeMap,
|
|
22
22
|
} from '@syncular/core';
|
|
23
|
+
import type {
|
|
24
|
+
AuthoritativeQueryRequest,
|
|
25
|
+
AuthoritativeQueryResult,
|
|
26
|
+
} from './storage';
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* §6.7 reserved code prefixes. A host validator code MUST NOT start with
|
|
@@ -104,6 +108,17 @@ export interface CommitValidationReader {
|
|
|
104
108
|
rowId: string,
|
|
105
109
|
): Promise<CommitValidationRow | undefined>;
|
|
106
110
|
scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
|
|
111
|
+
/**
|
|
112
|
+
* Run a generated registered query on the commit transaction (§6.8). The
|
|
113
|
+
* request has the shape `ServerStorage.queryAuthoritative` takes; the
|
|
114
|
+
* storage binds every relation to the commit's partition. SQLite and
|
|
115
|
+
* PostgreSQL return the final candidate state; D1 fails with
|
|
116
|
+
* `sync.storage.query_over_staged_writes` when the query reads a table the
|
|
117
|
+
* commit has written.
|
|
118
|
+
*/
|
|
119
|
+
queryAuthoritative(
|
|
120
|
+
query: AuthoritativeQueryRequest,
|
|
121
|
+
): Promise<AuthoritativeQueryResult>;
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
export interface ValidateCommitInput {
|
|
@@ -126,21 +141,31 @@ export type CommitValidator = (
|
|
|
126
141
|
/**
|
|
127
142
|
* Ambient context a validator may consult (§6.7).
|
|
128
143
|
*
|
|
129
|
-
* A validator runs inside the push transaction.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* `CommitValidator`, whose reader runs get and scan operations on the same
|
|
137
|
-
* transaction (§6.8).
|
|
144
|
+
* A validator runs inside the push transaction. It MUST NOT call
|
|
145
|
+
* `ServerStorage.queryAuthoritative`: on a single-connection executor
|
|
146
|
+
* (SQLite, PGlite) that call waits for the transaction the validator is
|
|
147
|
+
* inside and the push never completes, and on a pool-backed executor (`pg`,
|
|
148
|
+
* `Bun.sql`) it reads committed state without the staged writes, or waits
|
|
149
|
+
* forever once push transactions hold every pool connection. An authority
|
|
150
|
+
* read calls `queryAuthoritative` on this context instead.
|
|
138
151
|
*/
|
|
139
152
|
export interface ValidateContext {
|
|
140
153
|
/** Host-authenticated actor (§1.1) performing the write. */
|
|
141
154
|
readonly actorId: string;
|
|
142
155
|
/** The partition (§1.1) the commit targets. */
|
|
143
156
|
readonly partition: string;
|
|
157
|
+
/**
|
|
158
|
+
* Run a generated registered query on the push transaction (§6.7). The
|
|
159
|
+
* request has the shape `ServerStorage.queryAuthoritative` takes; the
|
|
160
|
+
* storage binds every relation to the commit's partition. SQLite and
|
|
161
|
+
* PostgreSQL return the transaction's snapshot, including the operations
|
|
162
|
+
* staged before this one; D1 fails with
|
|
163
|
+
* `sync.storage.query_over_staged_writes` when the query reads a table the
|
|
164
|
+
* commit has written.
|
|
165
|
+
*/
|
|
166
|
+
readonly queryAuthoritative: (
|
|
167
|
+
query: AuthoritativeQueryRequest,
|
|
168
|
+
) => Promise<AuthoritativeQueryResult>;
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
/**
|
|
@@ -152,12 +177,12 @@ export interface ValidateContext {
|
|
|
152
177
|
* `sync.constraint_violation` code (a validator SHOULD throw
|
|
153
178
|
* `ValidationRejection` to control the code). MUST NOT mutate the row.
|
|
154
179
|
*
|
|
155
|
-
* The hook runs inside the push transaction
|
|
156
|
-
*
|
|
157
|
-
* `queryAuthoritative`
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
180
|
+
* The hook runs inside the push transaction. An authority read uses
|
|
181
|
+
* `ctx.queryAuthoritative`, which executes on that transaction; calling
|
|
182
|
+
* `ServerStorage.queryAuthoritative` from here deadlocks on a
|
|
183
|
+
* single-connection executor and reads committed state on a pool-backed one.
|
|
184
|
+
* A rule over sibling operations belongs in the whole-commit
|
|
185
|
+
* `CommitValidator` (§6.8).
|
|
161
186
|
*/
|
|
162
187
|
export type Validator = (
|
|
163
188
|
op: ValidateOperation,
|