@syncular/server 0.37.0 → 0.39.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/dist/content-encoding.d.ts +24 -23
- package/dist/content-encoding.js +77 -22
- package/dist/d1-storage.js +24 -19
- package/dist/postgres-storage.d.ts +1 -1
- package/dist/postgres-storage.js +240 -74
- package/dist/pull.d.ts +7 -3
- package/dist/pull.js +21 -12
- package/dist/relational-rows.d.ts +49 -23
- package/dist/relational-rows.js +133 -59
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +22 -0
- package/dist/sqlite-bun-driver.js +1 -1
- package/dist/sqlite-node-driver.d.ts +1 -0
- package/dist/sqlite-node-driver.js +13 -3
- package/dist/sqlite-storage.js +36 -21
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +3 -0
- package/package.json +2 -2
- package/src/content-encoding.ts +99 -31
- package/src/d1-storage.ts +36 -22
- package/src/postgres-storage.ts +338 -109
- package/src/pull.ts +31 -18
- package/src/relational-rows.ts +182 -66
- package/src/schema.ts +55 -0
- package/src/sqlite-bun-driver.ts +1 -1
- package/src/sqlite-node-driver.ts +18 -3
- package/src/sqlite-storage.ts +64 -34
- package/src/storage.ts +9 -1
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* NOT NULL per the schema.
|
|
49
49
|
*/
|
|
50
50
|
import { type RowColumn, type RowValue } from '@syncular/core';
|
|
51
|
-
import type { CompiledSchema, CompiledTable, IndexSchema } from './schema.js';
|
|
51
|
+
import type { CompiledSchema, CompiledTable, IndexSchema, ServerIndexSchema } from './schema.js';
|
|
52
52
|
import type { StoredRow } from './storage.js';
|
|
53
53
|
export type RelationalDialect = 'sqlite' | 'postgres';
|
|
54
54
|
export declare const SYNC_PARTITION_COLUMN = "_sync_partition";
|
|
@@ -119,8 +119,8 @@ export declare function createTableDdl(table: CompiledTable, dialect: Relational
|
|
|
119
119
|
export declare function addColumnDdl(table: CompiledTable, existingColumns: ReadonlySet<string>, dialect: RelationalDialect): string[];
|
|
120
120
|
/**
|
|
121
121
|
* Ownership marker for server-created projection indexes. A version bump
|
|
122
|
-
*
|
|
123
|
-
*
|
|
122
|
+
* drops every index Syncular owns whose definition changed or was removed
|
|
123
|
+
* and creates the missing ones; this prefix is what marks an index as
|
|
124
124
|
* Syncular-owned, so operator-added tuning indexes survive bumps. Portable
|
|
125
125
|
* identifier validation reserves the `sync_` prefix for the server storage
|
|
126
126
|
* namespace, which keeps user and operator index names out of it.
|
|
@@ -134,13 +134,32 @@ export declare const SYNC_INDEX_PREFIX = "sync_ix_";
|
|
|
134
134
|
*/
|
|
135
135
|
export declare function physicalIndexName(declaredName: string): string;
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
138
|
-
* the
|
|
139
|
-
*
|
|
140
|
-
* Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
|
|
141
|
-
* ownership marker.
|
|
137
|
+
* Physical name of one server-only index. The name carries a fingerprint of
|
|
138
|
+
* the definition, so a changed definition is a different index and the bump
|
|
139
|
+
* diff needs no catalog comparison of predicates.
|
|
142
140
|
*/
|
|
143
|
-
export declare function
|
|
141
|
+
export declare function physicalServerIndexName(index: ServerIndexSchema): string;
|
|
142
|
+
/**
|
|
143
|
+
* Canonical shape of one physical index, as read from the catalog and as
|
|
144
|
+
* declared: kind, then the key columns in order (`null` for an expression).
|
|
145
|
+
* Two indexes with one signature are interchangeable.
|
|
146
|
+
*/
|
|
147
|
+
export declare function indexSignature(columns: readonly (string | null)[], unique: boolean, partial: boolean): string;
|
|
148
|
+
/**
|
|
149
|
+
* One statement that lists a table's free-standing SQLite indexes with their
|
|
150
|
+
* key columns in order (bind the table name). `origin='c'` excludes
|
|
151
|
+
* constraint-owned indexes, which only their constraint can remove. Feed the
|
|
152
|
+
* rows to {@link sqliteIndexSignatures}.
|
|
153
|
+
*/
|
|
154
|
+
export declare const SQLITE_TABLE_INDEXES_SQL = "SELECT il.name AS name, il.\"unique\" AS is_unique, il.partial AS is_partial, ii.name AS column_name\n FROM pragma_index_list(?) AS il\n JOIN pragma_index_info(il.name) AS ii\n WHERE il.origin='c'\n ORDER BY il.name, ii.seqno";
|
|
155
|
+
export interface SqliteTableIndexRow {
|
|
156
|
+
readonly name: string;
|
|
157
|
+
readonly is_unique: number;
|
|
158
|
+
readonly is_partial: number;
|
|
159
|
+
readonly column_name: string | null;
|
|
160
|
+
}
|
|
161
|
+
/** Index name to {@link indexSignature} for the rows of one table. */
|
|
162
|
+
export declare function sqliteIndexSignatures(rows: readonly SqliteTableIndexRow[]): Record<string, string>;
|
|
144
163
|
/** Idempotent removal of one Syncular-owned relational projection index. */
|
|
145
164
|
export declare function dropIndexDdl(indexName: string): string;
|
|
146
165
|
/**
|
|
@@ -157,8 +176,12 @@ export declare function toSqlValue(column: RowColumn, value: RowValue, dialect:
|
|
|
157
176
|
* statement, so they cannot drift.
|
|
158
177
|
*/
|
|
159
178
|
export declare function upsertValues(table: CompiledTable, partition: string, row: StoredRow, dialect: RelationalDialect): unknown[];
|
|
160
|
-
/**
|
|
161
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Upsert statement text (bind with `upsertValues`). PostgreSQL takes `rows`
|
|
181
|
+
* rows in one statement, bound as consecutive `upsertValues` lists; their
|
|
182
|
+
* row ids must be distinct.
|
|
183
|
+
*/
|
|
184
|
+
export declare function upsertSql(table: CompiledTable, dialect: RelationalDialect, rows?: number): string;
|
|
162
185
|
/**
|
|
163
186
|
* SELECT one stored row. Aliased to the legacy record shape
|
|
164
187
|
* (`row_id`/`server_version`/`scopes`/`payload`) so the existing
|
|
@@ -227,17 +250,19 @@ export declare function scanRowPageSql(table: CompiledTable, valueCount: number,
|
|
|
227
250
|
export declare function commitWindowPageSql(valueCount: number, dialect: RelationalDialect): string;
|
|
228
251
|
/**
|
|
229
252
|
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
230
|
-
* page of every subscription scanning
|
|
231
|
-
* of `{i, tbl, var, vals, after, lim}` records; each record runs the
|
|
232
|
-
* index-first candidate subquery through a LATERAL join, so every
|
|
233
|
-
* subscription keeps its own keyset cursor and LIMIT.
|
|
234
|
-
*
|
|
253
|
+
* page of every subscription scanning any table in a pull. `$2` is a JSON
|
|
254
|
+
* array of `{i, tbl, var, vals, after, lim}` records; each record runs the
|
|
255
|
+
* same index-first candidate subquery through a LATERAL join, so every
|
|
256
|
+
* subscription keeps its own keyset cursor and LIMIT. The candidate side is
|
|
257
|
+
* table-agnostic (a spec names its table), so the statement joins the row
|
|
258
|
+
* table once per table present in the batch through a UNION ALL branch that
|
|
259
|
+
* keeps only its own candidates. Rows arrive ordered by (i, row_id).
|
|
235
260
|
*
|
|
236
261
|
* Params: [partition, specs (JSON text)]. The specs bind as text before
|
|
237
262
|
* the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
|
|
238
263
|
* parameter (Bun.sql) cannot turn the array into a JSON string.
|
|
239
264
|
*/
|
|
240
|
-
export declare function scanRowBatchSql(
|
|
265
|
+
export declare function scanRowBatchSql(tables: readonly CompiledTable[]): string;
|
|
241
266
|
/**
|
|
242
267
|
* PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
|
|
243
268
|
* the window page of every subscription in a pull, across all tables. `$2`
|
|
@@ -344,10 +369,10 @@ export declare function migratePayload(oldLayout: readonly StoredColumnLayout[],
|
|
|
344
369
|
*/
|
|
345
370
|
export declare function selectRowsForRewriteSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
346
371
|
/**
|
|
347
|
-
*
|
|
372
|
+
* SQLite/D1: rewrite one row during a migration: refresh the projection columns (when
|
|
348
373
|
* materialized) and the payload. Bind with `rewriteValues`.
|
|
349
374
|
*/
|
|
350
|
-
export declare function rewriteRowSql(table: CompiledTable
|
|
375
|
+
export declare function rewriteRowSql(table: CompiledTable): string;
|
|
351
376
|
/** Bind values for `rewriteRowSql` from the (already migrated) payload. */
|
|
352
377
|
export declare function rewriteValues(table: CompiledTable, partition: string, rowId: string, payload: Uint8Array, dialect: RelationalDialect): unknown[];
|
|
353
378
|
/**
|
|
@@ -367,9 +392,10 @@ export declare function rewritePlan(table: CompiledTable, oldLayout: readonly St
|
|
|
367
392
|
/**
|
|
368
393
|
* Every DDL statement to bring a database from the introspected relational
|
|
369
394
|
* projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for
|
|
370
|
-
* missing columns, and
|
|
371
|
-
*
|
|
372
|
-
*
|
|
395
|
+
* missing columns, and the secondary-index diff. An existing Syncular-owned
|
|
396
|
+
* index stays when the declaration still wants its physical name with the
|
|
397
|
+
* same {@link indexSignature}; every other owned index is dropped and every
|
|
398
|
+
* missing one is created, so a bump rebuilds only changed or new indexes.
|
|
373
399
|
*
|
|
374
400
|
* The drop set is limited to Syncular-owned indexes: physical names carrying
|
|
375
401
|
* {@link SYNC_INDEX_PREFIX}, plus bare declared names (databases whose
|
|
@@ -377,4 +403,4 @@ export declare function rewritePlan(table: CompiledTable, oldLayout: readonly St
|
|
|
377
403
|
* scheme through this rule). Operator-added tuning indexes keep their own
|
|
378
404
|
* names and survive every bump.
|
|
379
405
|
*/
|
|
380
|
-
export declare function schemaDdl(schema: CompiledSchema, existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>, dialect: RelationalDialect, existingIndexesByTable?: ReadonlyMap<string,
|
|
406
|
+
export declare function schemaDdl(schema: CompiledSchema, existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>, dialect: RelationalDialect, existingIndexesByTable?: ReadonlyMap<string, ReadonlyMap<string, string>>): string[];
|
package/dist/relational-rows.js
CHANGED
|
@@ -302,8 +302,8 @@ export function addColumnDdl(table, existingColumns, dialect) {
|
|
|
302
302
|
}
|
|
303
303
|
/**
|
|
304
304
|
* Ownership marker for server-created projection indexes. A version bump
|
|
305
|
-
*
|
|
306
|
-
*
|
|
305
|
+
* drops every index Syncular owns whose definition changed or was removed
|
|
306
|
+
* and creates the missing ones; this prefix is what marks an index as
|
|
307
307
|
* Syncular-owned, so operator-added tuning indexes survive bumps. Portable
|
|
308
308
|
* identifier validation reserves the `sync_` prefix for the server storage
|
|
309
309
|
* namespace, which keeps user and operator index names out of it.
|
|
@@ -332,23 +332,84 @@ export function physicalIndexName(declaredName) {
|
|
|
332
332
|
return `${SYNC_INDEX_PREFIX}${fnv1a64Hex(declaredName)}`;
|
|
333
333
|
}
|
|
334
334
|
/**
|
|
335
|
-
*
|
|
336
|
-
* the
|
|
335
|
+
* Physical name of one server-only index. The name carries a fingerprint of
|
|
336
|
+
* the definition, so a changed definition is a different index and the bump
|
|
337
|
+
* diff needs no catalog comparison of predicates.
|
|
338
|
+
*/
|
|
339
|
+
export function physicalServerIndexName(index) {
|
|
340
|
+
const fingerprint = fnv1a64Hex(JSON.stringify([index.columns, index.where ?? null])).slice(0, 8);
|
|
341
|
+
const readable = `${SYNC_INDEX_PREFIX}${index.name}_${fingerprint}`;
|
|
342
|
+
if (new TextEncoder().encode(readable).length <= 63)
|
|
343
|
+
return readable;
|
|
344
|
+
return `${SYNC_INDEX_PREFIX}${fnv1a64Hex(index.name)}_${fingerprint}`;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Canonical shape of one physical index, as read from the catalog and as
|
|
348
|
+
* declared: kind, then the key columns in order (`null` for an expression).
|
|
349
|
+
* Two indexes with one signature are interchangeable.
|
|
350
|
+
*/
|
|
351
|
+
export function indexSignature(columns, unique, partial) {
|
|
352
|
+
return `${unique ? 'unique' : 'index'}${partial ? ' partial' : ''}(${columns.map((column) => column ?? '?').join(',')})`;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* One statement that lists a table's free-standing SQLite indexes with their
|
|
356
|
+
* key columns in order (bind the table name). `origin='c'` excludes
|
|
357
|
+
* constraint-owned indexes, which only their constraint can remove. Feed the
|
|
358
|
+
* rows to {@link sqliteIndexSignatures}.
|
|
359
|
+
*/
|
|
360
|
+
export const SQLITE_TABLE_INDEXES_SQL = `SELECT il.name AS name, il."unique" AS is_unique, il.partial AS is_partial, ii.name AS column_name
|
|
361
|
+
FROM pragma_index_list(?) AS il
|
|
362
|
+
JOIN pragma_index_info(il.name) AS ii
|
|
363
|
+
WHERE il.origin='c'
|
|
364
|
+
ORDER BY il.name, ii.seqno`;
|
|
365
|
+
/** Index name to {@link indexSignature} for the rows of one table. */
|
|
366
|
+
export function sqliteIndexSignatures(rows) {
|
|
367
|
+
const shapes = new Map();
|
|
368
|
+
for (const row of rows) {
|
|
369
|
+
const shape = shapes.get(row.name) ?? {
|
|
370
|
+
unique: row.is_unique === 1,
|
|
371
|
+
partial: row.is_partial === 1,
|
|
372
|
+
columns: [],
|
|
373
|
+
};
|
|
374
|
+
shape.columns.push(row.column_name);
|
|
375
|
+
shapes.set(row.name, shape);
|
|
376
|
+
}
|
|
377
|
+
return Object.fromEntries([...shapes].map(([name, shape]) => [
|
|
378
|
+
name,
|
|
379
|
+
indexSignature(shape.columns, shape.unique, shape.partial),
|
|
380
|
+
]));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The table's declared and server-only indexes. Declared indexes use the
|
|
384
|
+
* declared columns with a leading server partition column. Cross-table
|
|
337
385
|
* index-name uniqueness is the user's schema concern, as it is client-side.
|
|
338
386
|
* Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
|
|
339
387
|
* ownership marker.
|
|
340
388
|
*/
|
|
341
|
-
|
|
389
|
+
function desiredIndexes(table) {
|
|
342
390
|
// User indexes name app columns — nothing to index without the projection.
|
|
343
391
|
if (!table.materialize)
|
|
344
392
|
return [];
|
|
345
|
-
return
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
.
|
|
350
|
-
|
|
351
|
-
|
|
393
|
+
return [
|
|
394
|
+
...table.indexes.map((index) => {
|
|
395
|
+
const unique = index.unique === true;
|
|
396
|
+
const columns = [SYNC_PARTITION_COLUMN, ...index.columns];
|
|
397
|
+
const name = physicalIndexName(index.name);
|
|
398
|
+
return {
|
|
399
|
+
name,
|
|
400
|
+
signature: indexSignature(columns, unique, false),
|
|
401
|
+
ddl: `CREATE ${unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quoteIdent(name)} ON ${quoteIdent(table.name)} (${columns.map((column) => quoteIdent(column)).join(', ')})`,
|
|
402
|
+
};
|
|
403
|
+
}),
|
|
404
|
+
...table.serverIndexes.map((index) => {
|
|
405
|
+
const name = physicalServerIndexName(index);
|
|
406
|
+
return {
|
|
407
|
+
name,
|
|
408
|
+
signature: undefined,
|
|
409
|
+
ddl: `CREATE INDEX IF NOT EXISTS ${quoteIdent(name)} ON ${quoteIdent(table.name)} (${index.columns.map((column) => quoteIdent(column)).join(', ')})${index.where === undefined ? '' : ` WHERE ${index.where}`}`,
|
|
410
|
+
};
|
|
411
|
+
}),
|
|
412
|
+
];
|
|
352
413
|
}
|
|
353
414
|
/** Idempotent removal of one Syncular-owned relational projection index. */
|
|
354
415
|
export function dropIndexDdl(indexName) {
|
|
@@ -403,24 +464,22 @@ export function upsertValues(table, partition, row, dialect) {
|
|
|
403
464
|
row.columnVersions ?? null,
|
|
404
465
|
];
|
|
405
466
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return out.join(',');
|
|
413
|
-
}
|
|
414
|
-
/** Upsert statement text (bind with `upsertValues`). */
|
|
415
|
-
export function upsertSql(table, dialect) {
|
|
467
|
+
/**
|
|
468
|
+
* Upsert statement text (bind with `upsertValues`). PostgreSQL takes `rows`
|
|
469
|
+
* rows in one statement, bound as consecutive `upsertValues` lists; their
|
|
470
|
+
* row ids must be distinct.
|
|
471
|
+
*/
|
|
472
|
+
export function upsertSql(table, dialect, rows = 1) {
|
|
416
473
|
const names = tableColumnNames(table);
|
|
417
474
|
const quoted = names.map((name) => quoteIdent(name)).join(', ');
|
|
418
|
-
const values =
|
|
475
|
+
const values = dialect === 'sqlite'
|
|
476
|
+
? `(${new Array(names.length).fill('?').join(',')})`
|
|
477
|
+
: Array.from({ length: rows }, (_, row) => `(${names.map((_, column) => `$${row * names.length + column + 1}`).join(',')})`).join(',');
|
|
419
478
|
const updates = names
|
|
420
479
|
.slice(2) // partition + row id are the conflict key
|
|
421
480
|
.map((name) => `${quoteIdent(name)}=excluded.${quoteIdent(name)}`)
|
|
422
481
|
.join(', ');
|
|
423
|
-
return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES
|
|
482
|
+
return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES ${values}
|
|
424
483
|
ON CONFLICT (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)}) DO UPDATE SET ${updates}`;
|
|
425
484
|
}
|
|
426
485
|
/**
|
|
@@ -550,26 +609,40 @@ export function commitWindowPageSql(valueCount, dialect) {
|
|
|
550
609
|
}
|
|
551
610
|
/**
|
|
552
611
|
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
553
|
-
* page of every subscription scanning
|
|
554
|
-
* of `{i, tbl, var, vals, after, lim}` records; each record runs the
|
|
555
|
-
* index-first candidate subquery through a LATERAL join, so every
|
|
556
|
-
* subscription keeps its own keyset cursor and LIMIT.
|
|
557
|
-
*
|
|
612
|
+
* page of every subscription scanning any table in a pull. `$2` is a JSON
|
|
613
|
+
* array of `{i, tbl, var, vals, after, lim}` records; each record runs the
|
|
614
|
+
* same index-first candidate subquery through a LATERAL join, so every
|
|
615
|
+
* subscription keeps its own keyset cursor and LIMIT. The candidate side is
|
|
616
|
+
* table-agnostic (a spec names its table), so the statement joins the row
|
|
617
|
+
* table once per table present in the batch through a UNION ALL branch that
|
|
618
|
+
* keeps only its own candidates. Rows arrive ordered by (i, row_id).
|
|
558
619
|
*
|
|
559
620
|
* Params: [partition, specs (JSON text)]. The specs bind as text before
|
|
560
621
|
* the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
|
|
561
622
|
* parameter (Bun.sql) cannot turn the array into a JSON string.
|
|
562
623
|
*/
|
|
563
|
-
export function scanRowBatchSql(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
624
|
+
export function scanRowBatchSql(tables) {
|
|
625
|
+
// One branch per table, each keeping only its own candidates and selecting
|
|
626
|
+
// the same meta columns so the caller decodes one row shape.
|
|
627
|
+
const projection = tables
|
|
628
|
+
.map((table) => {
|
|
629
|
+
const quoted = quoteIdent(table.name);
|
|
630
|
+
return `SELECT c.sub AS sub, c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
|
|
631
|
+
FROM cand c
|
|
632
|
+
LEFT JOIN ${quoted} r
|
|
633
|
+
ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
|
|
634
|
+
WHERE c.tbl='${table.name.replaceAll("'", "''")}'`;
|
|
635
|
+
})
|
|
636
|
+
.join('\n UNION ALL ');
|
|
637
|
+
return `WITH cand AS (
|
|
638
|
+
SELECT s.i AS sub, c.row_id AS row_id, s.tbl AS tbl
|
|
639
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
|
|
640
|
+
CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
|
|
641
|
+
WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
|
|
642
|
+
AND row_id>s.after
|
|
643
|
+
ORDER BY row_id LIMIT s.lim) c)
|
|
644
|
+
${projection}
|
|
645
|
+
ORDER BY sub, row_id`;
|
|
573
646
|
}
|
|
574
647
|
/**
|
|
575
648
|
* PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
|
|
@@ -802,23 +875,17 @@ export function selectRowsForRewriteSql(table, dialect) {
|
|
|
802
875
|
LIMIT ${p[2]}`;
|
|
803
876
|
}
|
|
804
877
|
/**
|
|
805
|
-
*
|
|
878
|
+
* SQLite/D1: rewrite one row during a migration: refresh the projection columns (when
|
|
806
879
|
* materialized) and the payload. Bind with `rewriteValues`.
|
|
807
880
|
*/
|
|
808
|
-
export function rewriteRowSql(table
|
|
809
|
-
const sets = [
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
}
|
|
817
|
-
sets.push(`${quoteIdent(SYNC_PAYLOAD_COLUMN)}=${dialect === 'sqlite' ? '?' : `$${i}`}`);
|
|
818
|
-
i++;
|
|
819
|
-
const wherePartition = dialect === 'sqlite' ? '?' : `$${i}`;
|
|
820
|
-
const whereRowId = dialect === 'sqlite' ? '?' : `$${i + 1}`;
|
|
821
|
-
return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${wherePartition} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${whereRowId}`;
|
|
881
|
+
export function rewriteRowSql(table) {
|
|
882
|
+
const sets = [
|
|
883
|
+
...(table.materialize
|
|
884
|
+
? table.columns.map((column) => `${quoteIdent(column.name)}=?`)
|
|
885
|
+
: []),
|
|
886
|
+
`${quoteIdent(SYNC_PAYLOAD_COLUMN)}=?`,
|
|
887
|
+
];
|
|
888
|
+
return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?`;
|
|
822
889
|
}
|
|
823
890
|
/** Bind values for `rewriteRowSql` from the (already migrated) payload. */
|
|
824
891
|
export function rewriteValues(table, partition, rowId, payload, dialect) {
|
|
@@ -860,9 +927,10 @@ export function rewritePlan(table, oldLayout, physicalColumnsBefore) {
|
|
|
860
927
|
/**
|
|
861
928
|
* Every DDL statement to bring a database from the introspected relational
|
|
862
929
|
* projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for
|
|
863
|
-
* missing columns, and
|
|
864
|
-
*
|
|
865
|
-
*
|
|
930
|
+
* missing columns, and the secondary-index diff. An existing Syncular-owned
|
|
931
|
+
* index stays when the declaration still wants its physical name with the
|
|
932
|
+
* same {@link indexSignature}; every other owned index is dropped and every
|
|
933
|
+
* missing one is created, so a bump rebuilds only changed or new indexes.
|
|
866
934
|
*
|
|
867
935
|
* The drop set is limited to Syncular-owned indexes: physical names carrying
|
|
868
936
|
* {@link SYNC_INDEX_PREFIX}, plus bare declared names (databases whose
|
|
@@ -875,12 +943,18 @@ export function schemaDdl(schema, existingColumnsByTable, dialect, existingIndex
|
|
|
875
943
|
const creates = [];
|
|
876
944
|
for (const table of schema.tables.values()) {
|
|
877
945
|
const existing = existingColumnsByTable.get(table.name);
|
|
946
|
+
const desired = desiredIndexes(table);
|
|
878
947
|
if (existing === undefined) {
|
|
879
948
|
creates.push(createTableDdl(table, dialect));
|
|
880
949
|
}
|
|
881
950
|
else {
|
|
882
951
|
const declared = new Set(table.indexes.map((index) => index.name));
|
|
883
|
-
for (const indexName of existingIndexesByTable.get(table.name) ?? []) {
|
|
952
|
+
for (const [indexName, signature] of existingIndexesByTable.get(table.name) ?? []) {
|
|
953
|
+
const want = desired.find((index) => index.name === indexName);
|
|
954
|
+
if (want !== undefined &&
|
|
955
|
+
(want.signature === undefined || want.signature === signature)) {
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
884
958
|
if (indexName.startsWith(SYNC_INDEX_PREFIX) ||
|
|
885
959
|
declared.has(indexName)) {
|
|
886
960
|
drops.push(dropIndexDdl(indexName));
|
|
@@ -888,7 +962,7 @@ export function schemaDdl(schema, existingColumnsByTable, dialect, existingIndex
|
|
|
888
962
|
}
|
|
889
963
|
creates.push(...addColumnDdl(table, existing, dialect));
|
|
890
964
|
}
|
|
891
|
-
creates.push(...
|
|
965
|
+
creates.push(...desired.map((index) => index.ddl));
|
|
892
966
|
}
|
|
893
967
|
// Every drop precedes every create: an index name moving between tables in
|
|
894
968
|
// one bump must release the global (SQLite) index namespace before the
|
package/dist/schema.d.ts
CHANGED
|
@@ -20,6 +20,21 @@ export interface IndexSchema {
|
|
|
20
20
|
readonly columns: readonly string[];
|
|
21
21
|
readonly unique?: boolean;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* A server-only index on a table's relational projection. Unlike a declared
|
|
25
|
+
* {@link IndexSchema}, it never reaches a client, and its `columns` are the
|
|
26
|
+
* physical columns as written: Syncular does not prepend `_sync_partition`.
|
|
27
|
+
* Name `_sync_partition` and `_sync_row_id` explicitly where an index should
|
|
28
|
+
* lead with them. The index is never unique, because uniqueness is enforced
|
|
29
|
+
* per partition. `where` is the predicate of a partial index, in SQL that
|
|
30
|
+
* the server's dialect accepts. Creating or changing one requires a schema
|
|
31
|
+
* version bump, like every index.
|
|
32
|
+
*/
|
|
33
|
+
export interface ServerIndexSchema {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly columns: readonly string[];
|
|
36
|
+
readonly where?: string;
|
|
37
|
+
}
|
|
23
38
|
/** One declared reference (§6.11). Absent `onDelete` means `RESTRICT`. */
|
|
24
39
|
export interface ReferenceSchema {
|
|
25
40
|
readonly column: string;
|
|
@@ -54,6 +69,8 @@ export interface TableSchema {
|
|
|
54
69
|
readonly scopes: readonly ScopePatternSpec[];
|
|
55
70
|
/** User indexes (optional) — created on the server's relational tables. */
|
|
56
71
|
readonly indexes?: readonly IndexSchema[];
|
|
72
|
+
/** Server-only indexes (optional); names share the namespace of `indexes`. */
|
|
73
|
+
readonly serverIndexes?: readonly ServerIndexSchema[];
|
|
57
74
|
/** Declared references (§6.11). Optional so pre-reference schemas stay
|
|
58
75
|
* valid; the reference index over each child column is declared in
|
|
59
76
|
* `indexes` (typegen emits it). */
|
|
@@ -94,6 +111,8 @@ export interface CompiledTable {
|
|
|
94
111
|
readonly scopePatterns: readonly CompiledScopePattern[];
|
|
95
112
|
/** User indexes (validated: unique names, existing columns). */
|
|
96
113
|
readonly indexes: readonly IndexSchema[];
|
|
114
|
+
/** Server-only indexes (validated like `indexes`). */
|
|
115
|
+
readonly serverIndexes: readonly ServerIndexSchema[];
|
|
97
116
|
/** Outgoing declared references (§6.11), resolved to column indices. */
|
|
98
117
|
readonly references: readonly CompiledReference[];
|
|
99
118
|
/** Incoming declared references: child rows to reach on a parent delete. */
|
package/dist/schema.js
CHANGED
|
@@ -68,6 +68,27 @@ export function compileSchema(schema) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
+
const serverIndexes = table.serverIndexes ?? [];
|
|
72
|
+
for (const index of serverIndexes) {
|
|
73
|
+
validatePortableRelationalIdentifier(`table ${table.name}: server index`, index.name);
|
|
74
|
+
if (indexNames.has(index.name)) {
|
|
75
|
+
throw new Error(`table ${table.name}: duplicate index ${JSON.stringify(index.name)}`);
|
|
76
|
+
}
|
|
77
|
+
indexNames.add(index.name);
|
|
78
|
+
if (index.columns.length === 0) {
|
|
79
|
+
throw new Error(`table ${table.name}: server index ${JSON.stringify(index.name)} must name at least one column`);
|
|
80
|
+
}
|
|
81
|
+
for (const column of index.columns) {
|
|
82
|
+
if (!columnIndex.has(column) &&
|
|
83
|
+
column !== '_sync_partition' &&
|
|
84
|
+
column !== '_sync_row_id') {
|
|
85
|
+
throw new Error(`table ${table.name}: server index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (index.where !== undefined && index.where.trim().length === 0) {
|
|
89
|
+
throw new Error(`table ${table.name}: server index ${JSON.stringify(index.name)} has an empty where predicate`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
71
92
|
const primaryKeyIndex = columnIndex.get(table.primaryKey);
|
|
72
93
|
if (primaryKeyIndex === undefined) {
|
|
73
94
|
throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
|
|
@@ -142,6 +163,7 @@ export function compileSchema(schema) {
|
|
|
142
163
|
primaryKeyIndex,
|
|
143
164
|
scopePatterns,
|
|
144
165
|
indexes,
|
|
166
|
+
serverIndexes,
|
|
145
167
|
references,
|
|
146
168
|
referencedBy: [],
|
|
147
169
|
materialize,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import type { SqliteDatabase, SqliteRunResult, SqliteStatement, SqliteValue } from './sqlite-driver.js';
|
|
3
3
|
export declare class NodeSqliteDatabase implements SqliteDatabase {
|
|
4
|
+
#private;
|
|
4
5
|
readonly native: DatabaseSync;
|
|
5
6
|
constructor(path?: string);
|
|
6
7
|
exec(sql: string): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
1
|
+
import { DatabaseSync, } from 'node:sqlite';
|
|
2
2
|
function bind(value) {
|
|
3
3
|
return typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
4
4
|
}
|
|
@@ -7,6 +7,8 @@ function row(value) {
|
|
|
7
7
|
}
|
|
8
8
|
export class NodeSqliteDatabase {
|
|
9
9
|
native;
|
|
10
|
+
/** Prepared statements by SQL text, as `bun:sqlite`'s `query` keeps them. */
|
|
11
|
+
#statements = new Map();
|
|
10
12
|
constructor(path = ':memory:') {
|
|
11
13
|
this.native = new DatabaseSync(path);
|
|
12
14
|
}
|
|
@@ -14,10 +16,18 @@ export class NodeSqliteDatabase {
|
|
|
14
16
|
this.native.exec(sql);
|
|
15
17
|
}
|
|
16
18
|
run(sql, bindings = []) {
|
|
17
|
-
return this
|
|
19
|
+
return this.#prepare(sql).run(...bindings.map(bind));
|
|
20
|
+
}
|
|
21
|
+
#prepare(sql) {
|
|
22
|
+
let statement = this.#statements.get(sql);
|
|
23
|
+
if (statement === undefined) {
|
|
24
|
+
statement = this.native.prepare(sql);
|
|
25
|
+
this.#statements.set(sql, statement);
|
|
26
|
+
}
|
|
27
|
+
return statement;
|
|
18
28
|
}
|
|
19
29
|
query(sql) {
|
|
20
|
-
const statement = this
|
|
30
|
+
const statement = this.#prepare(sql);
|
|
21
31
|
return {
|
|
22
32
|
run: (...params) => statement.run(...params.map(bind)),
|
|
23
33
|
get: (...params) => row(statement.get(...params.map(bind))),
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { retainRollbackFailure, rollbackAfterError } from './storage-errors.js';
|
|
2
2
|
import { validateCommitPruneQuery } from './prune.js';
|
|
3
3
|
import { StorageQueryError } from './storage-errors.js';
|
|
4
|
-
import { serveGateRefusal } from './storage.js';
|
|
4
|
+
import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage.js';
|
|
5
5
|
import { serveNotReadyError } from './readiness.js';
|
|
6
6
|
/**
|
|
7
7
|
* SQLite server storage over the shared synchronous driver.
|
|
@@ -13,7 +13,7 @@ import { serveNotReadyError } from './readiness.js';
|
|
|
13
13
|
*/
|
|
14
14
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
15
15
|
import { syncError } from './errors.js';
|
|
16
|
-
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
16
|
+
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, SQLITE_TABLE_INDEXES_SQL, sqliteIndexSignatures, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
17
17
|
import { matchesEffective } from './scopes.js';
|
|
18
18
|
import { collectCommitWindowPage, deserializePushResult, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, placeholders, SQLITE_DDL, serializePushResult, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
19
19
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
@@ -364,8 +364,6 @@ END`);
|
|
|
364
364
|
// rewrite stored rows (payload re-encode for layout changes, and/or
|
|
365
365
|
// projection backfill for flipped-on materialization). One
|
|
366
366
|
// transaction: a failed bump leaves no half-state.
|
|
367
|
-
const layouts = parseLayouts(marker?.layouts);
|
|
368
|
-
const retiredTables = retiredTableNames(schema, layouts);
|
|
369
367
|
const existing = new Map();
|
|
370
368
|
const existingIndexes = new Map();
|
|
371
369
|
for (const table of schema.tables.values()) {
|
|
@@ -375,11 +373,9 @@ END`);
|
|
|
375
373
|
.all();
|
|
376
374
|
if (columns.length > 0) {
|
|
377
375
|
existing.set(table.name, new Set(columns.map((c) => c.name)));
|
|
378
|
-
|
|
379
|
-
.query(
|
|
380
|
-
.all()
|
|
381
|
-
.filter((index) => index.origin === 'c');
|
|
382
|
-
existingIndexes.set(table.name, new Set(indexes.map((index) => index.name)));
|
|
376
|
+
existingIndexes.set(table.name, new Map(Object.entries(sqliteIndexSignatures(this.db
|
|
377
|
+
.query(SQLITE_TABLE_INDEXES_SQL)
|
|
378
|
+
.all(table.name)))));
|
|
383
379
|
}
|
|
384
380
|
}
|
|
385
381
|
// The migration rewrites application rows (`#rewriteRows`) with no
|
|
@@ -390,6 +386,21 @@ END`);
|
|
|
390
386
|
await this.#serializeWrite(() => {
|
|
391
387
|
this.db.exec('BEGIN IMMEDIATE');
|
|
392
388
|
try {
|
|
389
|
+
// Another process sharing the database may have bumped between the
|
|
390
|
+
// marker read above and the writer lock; the locked marker decides
|
|
391
|
+
// (SPEC "Schema upgrade writer fence").
|
|
392
|
+
const locked = this.db
|
|
393
|
+
.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1')
|
|
394
|
+
.get();
|
|
395
|
+
if (locked !== null && locked.schema_version > schema.version) {
|
|
396
|
+
throw new Error(`stored schema version ${locked.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
|
|
397
|
+
}
|
|
398
|
+
if (locked !== null && locked.schema_version === schema.version) {
|
|
399
|
+
this.db.exec('COMMIT');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const layouts = parseLayouts(locked?.layouts);
|
|
403
|
+
const retiredTables = retiredTableNames(schema, layouts);
|
|
393
404
|
for (const tableName of retiredTables) {
|
|
394
405
|
this.db
|
|
395
406
|
.query('DELETE FROM sync_row_scopes WHERE tbl=?')
|
|
@@ -446,17 +457,21 @@ END`);
|
|
|
446
457
|
if (initialLogEpoch.length === 0) {
|
|
447
458
|
throw new Error('initial log epoch must be non-empty');
|
|
448
459
|
}
|
|
449
|
-
this.db
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
+
const select = this.db.query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
461
|
+
FROM sync_partition_registry WHERE partition=?`);
|
|
462
|
+
let row = select.get(partition);
|
|
463
|
+
if (row === null ||
|
|
464
|
+
authenticatedAtMs - row.last_authenticated_at_ms >=
|
|
465
|
+
PARTITION_TOUCH_INTERVAL_MS) {
|
|
466
|
+
this.db
|
|
467
|
+
.query(`INSERT INTO sync_partition_registry(
|
|
468
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
469
|
+
) VALUES (?,?,?)
|
|
470
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
471
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
472
|
+
.run(partition, initialLogEpoch, authenticatedAtMs);
|
|
473
|
+
row = select.get(partition);
|
|
474
|
+
}
|
|
460
475
|
if (row === null)
|
|
461
476
|
throw new Error('partition registry write did not persist');
|
|
462
477
|
return {
|
|
@@ -519,7 +534,7 @@ END`);
|
|
|
519
534
|
*/
|
|
520
535
|
#rewriteRows(table, oldLayout) {
|
|
521
536
|
const select = selectRowsForRewriteSql(table, 'sqlite');
|
|
522
|
-
const update = this.db.query(rewriteRowSql(table
|
|
537
|
+
const update = this.db.query(rewriteRowSql(table));
|
|
523
538
|
const BATCH = 500;
|
|
524
539
|
let afterPartition = '';
|
|
525
540
|
let afterRowId = '';
|