@syncular/server 0.23.0 → 0.25.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/dist/relational-rows.js
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* NOT NULL per the schema.
|
|
49
49
|
*/
|
|
50
50
|
import { decodeRow, encodeRow, } from '@syncular/core';
|
|
51
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
51
52
|
export const SYNC_PARTITION_COLUMN = '_sync_partition';
|
|
52
53
|
export const SYNC_ROW_ID_COLUMN = '_sync_row_id';
|
|
53
54
|
export const SYNC_VERSION_COLUMN = '_sync_server_version';
|
|
@@ -170,52 +171,110 @@ export function tableColumnNames(table) {
|
|
|
170
171
|
];
|
|
171
172
|
}
|
|
172
173
|
/**
|
|
173
|
-
* The
|
|
174
|
-
*
|
|
174
|
+
* The storage-internal columns of every synced table, in `createTableDdl`
|
|
175
|
+
* order (app columns sit between the row id and the version). The same-version
|
|
176
|
+
* physical check compares a table against exactly these declarations.
|
|
177
|
+
*/
|
|
178
|
+
function syncMetaColumns(dialect) {
|
|
179
|
+
const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
|
|
180
|
+
return [
|
|
181
|
+
{ name: SYNC_PARTITION_COLUMN, type: 'TEXT', notNull: true },
|
|
182
|
+
{ name: SYNC_ROW_ID_COLUMN, type: 'TEXT', notNull: true },
|
|
183
|
+
{
|
|
184
|
+
name: SYNC_VERSION_COLUMN,
|
|
185
|
+
type: dialect === 'postgres' ? 'BIGINT' : 'INTEGER',
|
|
186
|
+
notNull: true,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: SYNC_SCOPES_COLUMN,
|
|
190
|
+
type: dialect === 'postgres' ? 'JSONB' : 'TEXT',
|
|
191
|
+
notNull: true,
|
|
192
|
+
},
|
|
193
|
+
{ name: SYNC_PAYLOAD_COLUMN, type: payloadType, notNull: true },
|
|
194
|
+
{ name: SYNC_COLUMN_VERSIONS_COLUMN, type: payloadType, notNull: false },
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Refuse a same-version database whose physical table the row store cannot
|
|
199
|
+
* read or write. The persisted marker's layouts describe the codec's app
|
|
175
200
|
* columns only and its version number is the schema's, not the database's, so
|
|
176
|
-
* a same-version database
|
|
177
|
-
* version-only bump whose ALTER never ran
|
|
178
|
-
*
|
|
201
|
+
* a same-version database whose storage-internal meta columns drifted (a
|
|
202
|
+
* version-only bump whose ALTER never ran, or a table written by another
|
|
203
|
+
* build) is only visible in the catalog. Every configured column must exist;
|
|
204
|
+
* the meta columns must also carry the type, nullability, and primary-key
|
|
205
|
+
* position `createTableDdl` declares. App-column types and nullability stay
|
|
206
|
+
* with the stored layouts: migration-added app columns are nullable by design.
|
|
207
|
+
* Fails closed without writing a migration.
|
|
179
208
|
*
|
|
180
|
-
* `
|
|
181
|
-
*
|
|
209
|
+
* `columns` must be resolved the way the unqualified row queries resolve the
|
|
210
|
+
* table: on PostgreSQL, through the session `search_path`, not
|
|
182
211
|
* `current_schema()`. A search path whose first schema is not where the
|
|
183
212
|
* configured tables live is outside the supported contract: DDL is created in
|
|
184
213
|
* that first schema, so `ensureSchema` already writes shadow tables there.
|
|
185
214
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* persisted layouts answer for app columns.
|
|
215
|
+
* Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
|
|
216
|
+
* this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
|
|
217
|
+
* and column additions, independently of the version marker.
|
|
190
218
|
*/
|
|
191
|
-
export function assertPhysicalColumns(table,
|
|
192
|
-
|
|
193
|
-
throw new
|
|
194
|
-
|
|
219
|
+
export function assertPhysicalColumns(table, columns, dialect) {
|
|
220
|
+
const refuse = (details) => {
|
|
221
|
+
throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
|
|
222
|
+
table: table.name,
|
|
223
|
+
...details,
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
if (columns.length === 0)
|
|
227
|
+
refuse({ reason: 'missing_table' });
|
|
228
|
+
const byName = new Map(columns.map((column) => [column.name, column]));
|
|
195
229
|
for (const name of tableColumnNames(table)) {
|
|
196
|
-
if (
|
|
230
|
+
if (!byName.has(name))
|
|
231
|
+
refuse({ column: name, reason: 'missing_column' });
|
|
232
|
+
}
|
|
233
|
+
for (const expected of syncMetaColumns(dialect)) {
|
|
234
|
+
const actual = byName.get(expected.name);
|
|
235
|
+
if (actual === undefined)
|
|
197
236
|
continue;
|
|
198
|
-
|
|
237
|
+
if (actual.type.toUpperCase() !== expected.type) {
|
|
238
|
+
refuse({
|
|
239
|
+
column: expected.name,
|
|
240
|
+
reason: 'type',
|
|
241
|
+
expected: expected.type,
|
|
242
|
+
actual: actual.type,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (actual.notNull !== expected.notNull) {
|
|
246
|
+
refuse({
|
|
247
|
+
column: expected.name,
|
|
248
|
+
reason: 'nullability',
|
|
249
|
+
expected: expected.notNull ? 'not_null' : 'nullable',
|
|
250
|
+
actual: actual.notNull ? 'not_null' : 'nullable',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const column of columns) {
|
|
255
|
+
const expected = column.name === SYNC_PARTITION_COLUMN
|
|
256
|
+
? 1
|
|
257
|
+
: column.name === SYNC_ROW_ID_COLUMN
|
|
258
|
+
? 2
|
|
259
|
+
: 0;
|
|
260
|
+
if (column.primaryKeyPosition !== expected) {
|
|
261
|
+
refuse({ column: column.name, reason: 'primary_key' });
|
|
262
|
+
}
|
|
199
263
|
}
|
|
200
264
|
}
|
|
201
265
|
/** CREATE TABLE IF NOT EXISTS for one app table. */
|
|
202
266
|
export function createTableDdl(table, dialect) {
|
|
203
|
-
const
|
|
204
|
-
const scopesType = dialect === 'postgres' ? 'JSONB' : 'TEXT';
|
|
205
|
-
const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
|
|
267
|
+
const [partition, rowId, ...trailing] = syncMetaColumns(dialect).map((column) => `${quoteIdent(column.name)} ${column.type}${column.notNull ? ' NOT NULL' : ''}`);
|
|
206
268
|
const defs = [
|
|
207
|
-
|
|
208
|
-
|
|
269
|
+
partition,
|
|
270
|
+
rowId,
|
|
209
271
|
...(table.materialize
|
|
210
272
|
? table.columns.map((column) => {
|
|
211
273
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
212
274
|
return `${quoteIdent(column.name)} ${columnSqlType(column, dialect)}${notNull}`;
|
|
213
275
|
})
|
|
214
276
|
: []),
|
|
215
|
-
|
|
216
|
-
`${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
|
|
217
|
-
`${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
|
|
218
|
-
`${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
|
|
277
|
+
...trailing,
|
|
219
278
|
`PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
|
|
220
279
|
];
|
|
221
280
|
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
|
|
@@ -555,6 +614,42 @@ export function parseLayouts(json) {
|
|
|
555
614
|
return {};
|
|
556
615
|
return JSON.parse(json);
|
|
557
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Version equality is not layout equality: a marker written by another build
|
|
619
|
+
* at the same version describes rows the running codec cannot decode. Refuse
|
|
620
|
+
* when the stored layouts disagree with the configured schema, naming the
|
|
621
|
+
* first table (and column) that differs.
|
|
622
|
+
*/
|
|
623
|
+
export function assertStoredLayouts(schema, storedJson) {
|
|
624
|
+
const storedLayouts = parseLayouts(storedJson);
|
|
625
|
+
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
626
|
+
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
627
|
+
const stored = storedLayouts[tableName];
|
|
628
|
+
const columnCount = Math.max(columns.length, stored?.length ?? 0);
|
|
629
|
+
for (let index = 0; index < columnCount; index++) {
|
|
630
|
+
const expected = columns[index];
|
|
631
|
+
const actual = stored?.[index];
|
|
632
|
+
if (expected !== undefined &&
|
|
633
|
+
actual !== undefined &&
|
|
634
|
+
actual.name === expected.name &&
|
|
635
|
+
actual.type === expected.type &&
|
|
636
|
+
actual.nullable === expected.nullable) {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
|
|
640
|
+
table: tableName,
|
|
641
|
+
column: expected?.name ?? actual?.name ?? '',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
for (const tableName of Object.keys(storedLayouts)) {
|
|
646
|
+
if (!(tableName in configuredLayouts)) {
|
|
647
|
+
throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
|
|
648
|
+
table: tableName,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
558
653
|
/**
|
|
559
654
|
* Tables present in the stored layout but absent from the configured head
|
|
560
655
|
* schema. A schema-version bump retires their relational current-row tables;
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
16
16
|
/** Internal: write a row + refresh its scope-index entries. */
|
|
17
17
|
writeRow(partition: string, table: string, row: StoredRow): void;
|
|
18
18
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
19
|
+
/**
|
|
20
|
+
* Internal: rewrite and run one registered query on the shared connection.
|
|
21
|
+
* The caller owns the transaction: `queryAuthoritative` opens a read
|
|
22
|
+
* transaction behind the FIFO; `SqliteTransaction.queryAuthoritative` runs
|
|
23
|
+
* inside the push transaction's `BEGIN IMMEDIATE`.
|
|
24
|
+
*/
|
|
25
|
+
runAuthoritativeQuery(partition: string, query: AuthoritativeQueryRequest): AuthoritativeQueryResult;
|
|
19
26
|
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
|
|
20
27
|
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
21
28
|
getHorizonSeq(partition: string): Promise<number>;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -12,7 +12,7 @@ import { serveNotReadyError } from './readiness.js';
|
|
|
12
12
|
*/
|
|
13
13
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
14
14
|
import { syncError } from './errors.js';
|
|
15
|
-
import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
15
|
+
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
16
16
|
import { matchesEffective } from './scopes.js';
|
|
17
17
|
import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
18
18
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
@@ -106,6 +106,12 @@ class SqliteTransaction {
|
|
|
106
106
|
this.#assertOpen();
|
|
107
107
|
return this.#storage.scanRowsByIndex(this.#partition, query);
|
|
108
108
|
}
|
|
109
|
+
async queryAuthoritative(query) {
|
|
110
|
+
this.#assertOpen();
|
|
111
|
+
// Same shared connection: the query runs inside this transaction's
|
|
112
|
+
// BEGIN IMMEDIATE and observes every row staged so far.
|
|
113
|
+
return this.#storage.runAuthoritativeQuery(this.#partition, query);
|
|
114
|
+
}
|
|
109
115
|
async lockPartitionForPush() {
|
|
110
116
|
this.#assertOpen();
|
|
111
117
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
@@ -338,52 +344,18 @@ END`);
|
|
|
338
344
|
throw new Error(`stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
|
|
339
345
|
}
|
|
340
346
|
if (marker !== null && marker.schema_version === schema.version) {
|
|
341
|
-
|
|
342
|
-
// build at the same version describes rows the running codec cannot
|
|
343
|
-
// decode. Compare the stored layouts instead of trusting the number.
|
|
344
|
-
const storedLayouts = parseLayouts(marker.layouts);
|
|
345
|
-
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
346
|
-
let mismatch;
|
|
347
|
-
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
348
|
-
const stored = storedLayouts[tableName];
|
|
349
|
-
const columnCount = Math.max(columns.length, stored?.length ?? 0);
|
|
350
|
-
for (let index = 0; index < columnCount; index++) {
|
|
351
|
-
const expected = columns[index];
|
|
352
|
-
const actual = stored?.[index];
|
|
353
|
-
if (expected !== undefined &&
|
|
354
|
-
actual !== undefined &&
|
|
355
|
-
actual.name === expected.name &&
|
|
356
|
-
actual.type === expected.type &&
|
|
357
|
-
actual.nullable === expected.nullable) {
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
361
|
-
break;
|
|
362
|
-
}
|
|
363
|
-
if (mismatch !== undefined)
|
|
364
|
-
break;
|
|
365
|
-
}
|
|
366
|
-
if (mismatch === undefined) {
|
|
367
|
-
for (const tableName of Object.keys(storedLayouts)) {
|
|
368
|
-
if (!(tableName in configuredLayouts)) {
|
|
369
|
-
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
370
|
-
break;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
if (mismatch !== undefined) {
|
|
375
|
-
throw new Error(`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`);
|
|
376
|
-
}
|
|
377
|
-
// The persisted layouts describe the codec's app columns only: read the
|
|
378
|
-
// physical tables so a same-version database missing a
|
|
379
|
-
// storage-internal column is refused at startup instead of failing at
|
|
380
|
-
// the first write.
|
|
347
|
+
assertStoredLayouts(schema, marker.layouts);
|
|
381
348
|
for (const table of schema.tables.values()) {
|
|
382
349
|
const escapedTableName = table.name.replaceAll('"', '""');
|
|
383
350
|
const columns = this.db
|
|
384
351
|
.query(`PRAGMA table_info("${escapedTableName}")`)
|
|
385
352
|
.all();
|
|
386
|
-
assertPhysicalColumns(table,
|
|
353
|
+
assertPhysicalColumns(table, columns.map((column) => ({
|
|
354
|
+
name: column.name,
|
|
355
|
+
type: column.type,
|
|
356
|
+
notNull: column.notnull === 1,
|
|
357
|
+
primaryKeyPosition: column.pk,
|
|
358
|
+
})), 'sqlite');
|
|
387
359
|
}
|
|
388
360
|
}
|
|
389
361
|
if (marker === null || marker.schema_version < schema.version) {
|
|
@@ -597,11 +569,26 @@ END`);
|
|
|
597
569
|
.get(partition);
|
|
598
570
|
return row?.max_commit_seq ?? 0;
|
|
599
571
|
}
|
|
600
|
-
|
|
572
|
+
/**
|
|
573
|
+
* Internal: rewrite and run one registered query on the shared connection.
|
|
574
|
+
* The caller owns the transaction: `queryAuthoritative` opens a read
|
|
575
|
+
* transaction behind the FIFO; `SqliteTransaction.queryAuthoritative` runs
|
|
576
|
+
* inside the push transaction's `BEGIN IMMEDIATE`.
|
|
577
|
+
*/
|
|
578
|
+
runAuthoritativeQuery(partition, query) {
|
|
601
579
|
if (this.#tables === undefined) {
|
|
602
580
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
603
581
|
}
|
|
604
582
|
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
583
|
+
const rows = this.db
|
|
584
|
+
.query(prepared.sql)
|
|
585
|
+
.all(...prepared.params);
|
|
586
|
+
const cursor = this.db
|
|
587
|
+
.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
588
|
+
.get(partition);
|
|
589
|
+
return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
|
|
590
|
+
}
|
|
591
|
+
async queryAuthoritative(partition, query, checkpoints) {
|
|
605
592
|
const previous = this.#transactionTail;
|
|
606
593
|
let release;
|
|
607
594
|
this.#transactionTail = new Promise((resolve) => {
|
|
@@ -620,15 +607,10 @@ END`);
|
|
|
620
607
|
const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
|
|
621
608
|
if (refusal !== undefined)
|
|
622
609
|
throw serveNotReadyError(refusal);
|
|
623
|
-
const
|
|
624
|
-
.query(prepared.sql)
|
|
625
|
-
.all(...prepared.params);
|
|
626
|
-
const cursor = this.db
|
|
627
|
-
.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
628
|
-
.get(partition);
|
|
610
|
+
const result = this.runAuthoritativeQuery(partition, query);
|
|
629
611
|
this.db.exec('COMMIT');
|
|
630
612
|
open = false;
|
|
631
|
-
return
|
|
613
|
+
return result;
|
|
632
614
|
}
|
|
633
615
|
catch (error) {
|
|
634
616
|
if (open)
|
package/dist/storage-errors.d.ts
CHANGED
|
@@ -9,15 +9,17 @@ export declare class StorageConstraintError extends Error {
|
|
|
9
9
|
constructor(cause: unknown, opIndex?: number);
|
|
10
10
|
}
|
|
11
11
|
/** Stable, privacy-safe failures for trusted server storage queries. */
|
|
12
|
-
export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | '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' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor' | 'sync.storage.checkpoint_not_declared' | 'sync.storage.checkpoint_unsupported' | 'sync.storage.checkpoint_fence_missing' | 'sync.storage.checkpoint_incomplete';
|
|
12
|
+
export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | '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' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor' | 'sync.storage.checkpoint_not_declared' | 'sync.storage.checkpoint_unsupported' | 'sync.storage.checkpoint_fence_missing' | 'sync.storage.checkpoint_incomplete' | 'sync.storage.stored_layout_mismatch' | 'sync.storage.physical_layout_mismatch' | 'sync.storage.transaction_query_unsupported' | 'sync.storage.query_over_staged_writes';
|
|
13
13
|
/**
|
|
14
14
|
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
15
|
-
* paths, or row data; callers branch on `code`, never message text.
|
|
15
|
+
* paths, or row data; callers branch on `code`, never message text. The
|
|
16
|
+
* schema-readiness codes name the offending table and column in `details`.
|
|
16
17
|
*/
|
|
17
18
|
export declare class StorageQueryError extends Error {
|
|
18
19
|
readonly name = "StorageQueryError";
|
|
19
20
|
readonly code: StorageQueryErrorCode;
|
|
20
|
-
|
|
21
|
+
readonly details: Readonly<Record<string, string>> | undefined;
|
|
22
|
+
constructor(code: StorageQueryErrorCode, details?: Readonly<Record<string, string>>);
|
|
21
23
|
}
|
|
22
24
|
/** SQLite primary/extended constraint result codes (`SQLITE_CONSTRAINT*`). */
|
|
23
25
|
export declare function isSqliteConstraintError(error: unknown): boolean;
|
package/dist/storage-errors.js
CHANGED
|
@@ -23,6 +23,10 @@ const STORAGE_QUERY_MESSAGES = {
|
|
|
23
23
|
'sync.storage.checkpoint_unsupported': 'this storage backend cannot declare backfill checkpoints',
|
|
24
24
|
'sync.storage.checkpoint_fence_missing': 'checkpoint activation requires the writer fence raised at declaration',
|
|
25
25
|
'sync.storage.checkpoint_incomplete': 'a declared backfill checkpoint is not activated and this caller did not declare it',
|
|
26
|
+
'sync.storage.stored_layout_mismatch': 'stored schema layouts disagree with the configured schema at the same version',
|
|
27
|
+
'sync.storage.physical_layout_mismatch': 'a synced table does not match the storage layout the running code reads and writes',
|
|
28
|
+
'sync.storage.transaction_query_unsupported': 'this storage transaction cannot run registered queries',
|
|
29
|
+
'sync.storage.query_over_staged_writes': 'registered query reads a table this transaction has buffered writes for',
|
|
26
30
|
'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
|
|
27
31
|
'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
|
|
28
32
|
'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',
|
|
@@ -31,14 +35,17 @@ const STORAGE_QUERY_MESSAGES = {
|
|
|
31
35
|
};
|
|
32
36
|
/**
|
|
33
37
|
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
34
|
-
* paths, or row data; callers branch on `code`, never message text.
|
|
38
|
+
* paths, or row data; callers branch on `code`, never message text. The
|
|
39
|
+
* schema-readiness codes name the offending table and column in `details`.
|
|
35
40
|
*/
|
|
36
41
|
export class StorageQueryError extends Error {
|
|
37
42
|
name = 'StorageQueryError';
|
|
38
43
|
code;
|
|
39
|
-
|
|
44
|
+
details;
|
|
45
|
+
constructor(code, details) {
|
|
40
46
|
super(STORAGE_QUERY_MESSAGES[code]);
|
|
41
47
|
this.code = code;
|
|
48
|
+
this.details = details;
|
|
42
49
|
}
|
|
43
50
|
}
|
|
44
51
|
function driverError(error) {
|
package/dist/storage-query.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CompiledTable, IndexSchema } from './schema.js';
|
|
2
|
-
import type { IndexRowScanQuery, RowScanQuery } from './storage.js';
|
|
2
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, IndexRowScanQuery, RowScanQuery, StorageTransaction } from './storage.js';
|
|
3
3
|
/**
|
|
4
4
|
* Fail loudly instead of making an unsupported unscoped scan look empty.
|
|
5
5
|
* Returns the first scope variable in sorted order — the one every adapter
|
|
@@ -8,3 +8,10 @@ import type { IndexRowScanQuery, RowScanQuery } from './storage.js';
|
|
|
8
8
|
export declare function assertScopeIndexedScan(query: RowScanQuery): string;
|
|
9
9
|
/** Validate and resolve one exact trusted-host relational index lookup. */
|
|
10
10
|
export declare function resolveIndexRowScan(table: CompiledTable, query: IndexRowScanQuery): IndexSchema;
|
|
11
|
+
/**
|
|
12
|
+
* §6.7/§6.8 transaction-bound registered query for every host hook that runs
|
|
13
|
+
* inside a push transaction. A storage transaction without the capability
|
|
14
|
+
* fails; the call never falls back to `ServerStorage.queryAuthoritative`,
|
|
15
|
+
* which reads outside the transaction.
|
|
16
|
+
*/
|
|
17
|
+
export declare function transactionQuery(tx: StorageTransaction, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
package/dist/storage-query.js
CHANGED
|
@@ -34,3 +34,15 @@ export function resolveIndexRowScan(table, query) {
|
|
|
34
34
|
}
|
|
35
35
|
return index;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* §6.7/§6.8 transaction-bound registered query for every host hook that runs
|
|
39
|
+
* inside a push transaction. A storage transaction without the capability
|
|
40
|
+
* fails; the call never falls back to `ServerStorage.queryAuthoritative`,
|
|
41
|
+
* which reads outside the transaction.
|
|
42
|
+
*/
|
|
43
|
+
export async function transactionQuery(tx, query) {
|
|
44
|
+
if (tx.queryAuthoritative === undefined) {
|
|
45
|
+
throw new StorageQueryError('sync.storage.transaction_query_unsupported');
|
|
46
|
+
}
|
|
47
|
+
return tx.queryAuthoritative(query);
|
|
48
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -381,6 +381,19 @@ export interface StorageTransaction {
|
|
|
381
381
|
* read-your-own-writes semantics. It is not reachable from SSP2 requests.
|
|
382
382
|
*/
|
|
383
383
|
scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
384
|
+
/**
|
|
385
|
+
* Optional transaction-bound registered query (§6.7, §6.8): the relation
|
|
386
|
+
* rewrite of `ServerStorage.queryAuthoritative`, bound to this
|
|
387
|
+
* transaction's partition and executed on this transaction's connection.
|
|
388
|
+
* A hook inside the push transaction therefore reads the transaction's
|
|
389
|
+
* snapshot and never waits for a second connection. The push layer has
|
|
390
|
+
* already evaluated the serve gate on this transaction, so the call takes
|
|
391
|
+
* no checkpoint declarations. SQLite and PostgreSQL return the staged
|
|
392
|
+
* writes of this transaction. D1 buffers writes until commit and fails
|
|
393
|
+
* with `sync.storage.query_over_staged_writes` when the query reads a
|
|
394
|
+
* table this transaction has written.
|
|
395
|
+
*/
|
|
396
|
+
queryAuthoritative?(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
384
397
|
/**
|
|
385
398
|
* Serialize every push apply for this partition before any operation read,
|
|
386
399
|
* validation, merge, or write. The push layer re-checks idempotency only
|
|
@@ -575,17 +588,18 @@ export interface ServerStorage {
|
|
|
575
588
|
* every generated app-table relation with a partition-filtered relation and
|
|
576
589
|
* return rows plus maxCommitSeq from one consistent database snapshot.
|
|
577
590
|
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* the push never completes. A pool-backed
|
|
583
|
-
* the call a second connection
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
591
|
+
* Do not call this method from a hook that runs inside the push
|
|
592
|
+
* transaction (a row validator, the whole-commit validator, or a reaction
|
|
593
|
+
* planner). On a single-connection executor (SQLite, PGlite) the storage
|
|
594
|
+
* serializes this call behind the open transaction, so the hook waits on
|
|
595
|
+
* the transaction it is inside and the push never completes. A pool-backed
|
|
596
|
+
* executor (`pg`, `Bun.sql`) runs the call on a second connection: it reads
|
|
597
|
+
* committed state without the transaction's staged writes, and it waits
|
|
598
|
+
* forever when the push transactions hold every pool connection. Those
|
|
599
|
+
* hooks call the transaction-bound `queryAuthoritative` on
|
|
600
|
+
* `ValidateContext` or on the candidate-state reader instead (§6.7, §6.8).
|
|
601
|
+
* A call from any other context waits for the open transaction on a
|
|
602
|
+
* single-connection executor and runs concurrently on a pool-backed one.
|
|
589
603
|
*/
|
|
590
604
|
queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
|
|
591
605
|
/**
|
package/dist/validate.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* object — zero cost, the events-seam discipline.
|
|
15
15
|
*/
|
|
16
16
|
import { type RejectionDetails, type RowColumn, type RowValue, type ScopeMap } from '@syncular/core';
|
|
17
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult } from './storage.js';
|
|
17
18
|
/**
|
|
18
19
|
* §6.7 reserved code prefixes. A host validator code MUST NOT start with
|
|
19
20
|
* any of these: they namespace the protocol's own error families (§10.2)
|
|
@@ -81,6 +82,15 @@ export interface CommitValidationScanInput {
|
|
|
81
82
|
export interface CommitValidationReader {
|
|
82
83
|
getRow(table: string, rowId: string): Promise<CommitValidationRow | undefined>;
|
|
83
84
|
scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
|
|
85
|
+
/**
|
|
86
|
+
* Run a generated registered query on the commit transaction (§6.8). The
|
|
87
|
+
* request has the shape `ServerStorage.queryAuthoritative` takes; the
|
|
88
|
+
* storage binds every relation to the commit's partition. SQLite and
|
|
89
|
+
* PostgreSQL return the final candidate state; D1 fails with
|
|
90
|
+
* `sync.storage.query_over_staged_writes` when the query reads a table the
|
|
91
|
+
* commit has written.
|
|
92
|
+
*/
|
|
93
|
+
queryAuthoritative(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
84
94
|
}
|
|
85
95
|
export interface ValidateCommitInput {
|
|
86
96
|
readonly clientId: string;
|
|
@@ -98,21 +108,29 @@ export type CommitValidator = (input: ValidateCommitInput) => void | Promise<voi
|
|
|
98
108
|
/**
|
|
99
109
|
* Ambient context a validator may consult (§6.7).
|
|
100
110
|
*
|
|
101
|
-
* A validator runs inside the push transaction.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* `CommitValidator`, whose reader runs get and scan operations on the same
|
|
109
|
-
* transaction (§6.8).
|
|
111
|
+
* A validator runs inside the push transaction. It MUST NOT call
|
|
112
|
+
* `ServerStorage.queryAuthoritative`: on a single-connection executor
|
|
113
|
+
* (SQLite, PGlite) that call waits for the transaction the validator is
|
|
114
|
+
* inside and the push never completes, and on a pool-backed executor (`pg`,
|
|
115
|
+
* `Bun.sql`) it reads committed state without the staged writes, or waits
|
|
116
|
+
* forever once push transactions hold every pool connection. An authority
|
|
117
|
+
* read calls `queryAuthoritative` on this context instead.
|
|
110
118
|
*/
|
|
111
119
|
export interface ValidateContext {
|
|
112
120
|
/** Host-authenticated actor (§1.1) performing the write. */
|
|
113
121
|
readonly actorId: string;
|
|
114
122
|
/** The partition (§1.1) the commit targets. */
|
|
115
123
|
readonly partition: string;
|
|
124
|
+
/**
|
|
125
|
+
* Run a generated registered query on the push transaction (§6.7). The
|
|
126
|
+
* request has the shape `ServerStorage.queryAuthoritative` takes; the
|
|
127
|
+
* storage binds every relation to the commit's partition. SQLite and
|
|
128
|
+
* PostgreSQL return the transaction's snapshot, including the operations
|
|
129
|
+
* staged before this one; D1 fails with
|
|
130
|
+
* `sync.storage.query_over_staged_writes` when the query reads a table the
|
|
131
|
+
* commit has written.
|
|
132
|
+
*/
|
|
133
|
+
readonly queryAuthoritative: (query: AuthoritativeQueryRequest) => Promise<AuthoritativeQueryResult>;
|
|
116
134
|
}
|
|
117
135
|
/**
|
|
118
136
|
* A per-table validation hook (§6.7). Called once per push operation on
|
|
@@ -123,12 +141,12 @@ export interface ValidateContext {
|
|
|
123
141
|
* `sync.constraint_violation` code (a validator SHOULD throw
|
|
124
142
|
* `ValidationRejection` to control the code). MUST NOT mutate the row.
|
|
125
143
|
*
|
|
126
|
-
* The hook runs inside the push transaction
|
|
127
|
-
*
|
|
128
|
-
* `queryAuthoritative`
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
144
|
+
* The hook runs inside the push transaction. An authority read uses
|
|
145
|
+
* `ctx.queryAuthoritative`, which executes on that transaction; calling
|
|
146
|
+
* `ServerStorage.queryAuthoritative` from here deadlocks on a
|
|
147
|
+
* single-connection executor and reads committed state on a pool-backed one.
|
|
148
|
+
* A rule over sibling operations belongs in the whole-commit
|
|
149
|
+
* `CommitValidator` (§6.8).
|
|
132
150
|
*/
|
|
133
151
|
export type Validator = (op: ValidateOperation, ctx: ValidateContext) => void | Promise<void>;
|
|
134
152
|
/** Table name → validator (§6.7). Absent tables are unvalidated. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.25.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|