@syncular/server 0.37.0 → 0.38.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/d1-storage.js +23 -18
- package/dist/postgres-storage.js +31 -11
- package/dist/relational-rows.d.ts +33 -13
- package/dist/relational-rows.js +85 -17
- 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 +20 -18
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +3 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +35 -21
- package/src/postgres-storage.ts +58 -16
- package/src/relational-rows.ts +129 -19
- 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 +44 -31
- package/src/storage.ts +9 -1
package/dist/d1-storage.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
var _a;
|
|
2
2
|
import { validateCommitPruneQuery } from './prune.js';
|
|
3
3
|
import { StorageQueryError } from './storage-errors.js';
|
|
4
|
+
import { PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
|
|
4
5
|
/**
|
|
5
6
|
* Cloudflare D1 server storage for Workers deployments.
|
|
6
7
|
*
|
|
@@ -45,7 +46,7 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
45
46
|
import { decodeRow } from '@syncular/core';
|
|
46
47
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
47
48
|
import { syncError } from './errors.js';
|
|
48
|
-
import { assertAppendOnlyMigration, 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, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
49
|
+
import { assertAppendOnlyMigration, 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, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
49
50
|
import { matchesEffective } from './scopes.js';
|
|
50
51
|
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
51
52
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
@@ -833,7 +834,8 @@ export class D1ServerStorage {
|
|
|
833
834
|
.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
|
|
834
835
|
.all();
|
|
835
836
|
const indexes = await this.#db
|
|
836
|
-
.prepare(
|
|
837
|
+
.prepare(SQLITE_TABLE_INDEXES_SQL)
|
|
838
|
+
.bind(table.name)
|
|
837
839
|
.all();
|
|
838
840
|
// Validate the old codec before any application DDL or row rewrite.
|
|
839
841
|
rewritePlan(table, sourceLayouts[table.name], columns.results.length > 0
|
|
@@ -852,9 +854,7 @@ export class D1ServerStorage {
|
|
|
852
854
|
},
|
|
853
855
|
indexes: {
|
|
854
856
|
...state.indexes,
|
|
855
|
-
[table.name]: indexes.results
|
|
856
|
-
.filter((index) => index.origin === 'c')
|
|
857
|
-
.map((index) => index.name),
|
|
857
|
+
[table.name]: sqliteIndexSignatures(indexes.results),
|
|
858
858
|
},
|
|
859
859
|
}, [])))
|
|
860
860
|
break;
|
|
@@ -864,9 +864,9 @@ export class D1ServerStorage {
|
|
|
864
864
|
name,
|
|
865
865
|
new Set(names),
|
|
866
866
|
]));
|
|
867
|
-
const indexes = new Map(Object.entries(state.indexes).map(([name,
|
|
867
|
+
const indexes = new Map(Object.entries(state.indexes).map(([name, signatures]) => [
|
|
868
868
|
name,
|
|
869
|
-
new
|
|
869
|
+
new Map(Object.entries(signatures)),
|
|
870
870
|
]));
|
|
871
871
|
const rewrites = tables.flatMap((table) => {
|
|
872
872
|
const plan = rewritePlan(table, sourceLayouts[table.name], columns.get(table.name));
|
|
@@ -1068,19 +1068,24 @@ export class D1ServerStorage {
|
|
|
1068
1068
|
if (initialLogEpoch.length === 0) {
|
|
1069
1069
|
throw new Error('initial log epoch must be non-empty');
|
|
1070
1070
|
}
|
|
1071
|
-
|
|
1072
|
-
.prepare(`INSERT INTO sync_partition_registry(
|
|
1073
|
-
partition, log_epoch, last_authenticated_at_ms
|
|
1074
|
-
) VALUES (?,?,?)
|
|
1075
|
-
ON CONFLICT(partition) DO UPDATE SET
|
|
1076
|
-
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
1077
|
-
.bind(partition, initialLogEpoch, authenticatedAtMs)
|
|
1078
|
-
.run();
|
|
1079
|
-
const row = await this.#db
|
|
1071
|
+
const select = this.#db
|
|
1080
1072
|
.prepare(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
1081
1073
|
FROM sync_partition_registry WHERE partition=?`)
|
|
1082
|
-
.bind(partition)
|
|
1083
|
-
|
|
1074
|
+
.bind(partition);
|
|
1075
|
+
let row = await select.first();
|
|
1076
|
+
if (row === null ||
|
|
1077
|
+
authenticatedAtMs - row.last_authenticated_at_ms >=
|
|
1078
|
+
PARTITION_TOUCH_INTERVAL_MS) {
|
|
1079
|
+
await this.#db
|
|
1080
|
+
.prepare(`INSERT INTO sync_partition_registry(
|
|
1081
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
1082
|
+
) VALUES (?,?,?)
|
|
1083
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
1084
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
1085
|
+
.bind(partition, initialLogEpoch, authenticatedAtMs)
|
|
1086
|
+
.run();
|
|
1087
|
+
row = await select.first();
|
|
1088
|
+
}
|
|
1084
1089
|
if (row === null)
|
|
1085
1090
|
throw new Error('partition registry write did not persist');
|
|
1086
1091
|
return {
|
package/dist/postgres-storage.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune.js';
|
|
2
2
|
import { StorageQueryError } from './storage-errors.js';
|
|
3
|
-
import { serveGateRefusal } from './storage.js';
|
|
3
|
+
import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage.js';
|
|
4
4
|
import { serveNotReadyError } from './readiness.js';
|
|
5
5
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
6
6
|
import { syncError } from './errors.js';
|
|
7
7
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
8
|
-
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
8
|
+
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, indexSignature, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
9
9
|
import { matchesEffective, sha256Hex } from './scopes.js';
|
|
10
10
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
11
11
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -1013,7 +1013,19 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1013
1013
|
// indexes (PRIMARY KEY, UNIQUE constraints) can only be removed
|
|
1014
1014
|
// through their constraint, so DROP INDEX on them would abort
|
|
1015
1015
|
// the migration transaction.
|
|
1016
|
-
const indexes = await client.query(`SELECT index_class.relname AS index_name
|
|
1016
|
+
const indexes = await client.query(`SELECT index_class.relname AS index_name,
|
|
1017
|
+
index_meta.indisunique AS is_unique,
|
|
1018
|
+
index_meta.indpred IS NOT NULL AS is_partial,
|
|
1019
|
+
ARRAY(
|
|
1020
|
+
SELECT attribute.attname::text
|
|
1021
|
+
FROM unnest(index_meta.indkey::int2[])
|
|
1022
|
+
WITH ORDINALITY AS key(attnum, ord)
|
|
1023
|
+
LEFT JOIN pg_catalog.pg_attribute AS attribute
|
|
1024
|
+
ON attribute.attrelid = table_class.oid
|
|
1025
|
+
AND attribute.attnum = key.attnum
|
|
1026
|
+
WHERE key.ord <= index_meta.indnkeyatts
|
|
1027
|
+
ORDER BY key.ord
|
|
1028
|
+
) AS columns
|
|
1017
1029
|
FROM pg_catalog.pg_class AS table_class
|
|
1018
1030
|
JOIN pg_catalog.pg_namespace AS namespace
|
|
1019
1031
|
ON namespace.oid = table_class.relnamespace
|
|
@@ -1029,7 +1041,10 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1029
1041
|
FROM pg_catalog.pg_constraint AS owning_constraint
|
|
1030
1042
|
WHERE owning_constraint.conindid = index_meta.indexrelid
|
|
1031
1043
|
)`, [table.name]);
|
|
1032
|
-
existingIndexes.set(table.name, new
|
|
1044
|
+
existingIndexes.set(table.name, new Map(indexes.rows.map((index) => [
|
|
1045
|
+
index.index_name,
|
|
1046
|
+
indexSignature(index.columns, index.is_unique, index.is_partial),
|
|
1047
|
+
])));
|
|
1033
1048
|
}
|
|
1034
1049
|
}
|
|
1035
1050
|
for (const tableName of retiredTables) {
|
|
@@ -1081,13 +1096,18 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1081
1096
|
if (initialLogEpoch.length === 0) {
|
|
1082
1097
|
throw new Error('initial log epoch must be non-empty');
|
|
1083
1098
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1099
|
+
let row = (await this.#exec.query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
1100
|
+
FROM sync_partition_registry WHERE partition=$1`, [partition])).rows[0];
|
|
1101
|
+
if (row === undefined ||
|
|
1102
|
+
authenticatedAtMs - asNumber(row.last_authenticated_at_ms) >=
|
|
1103
|
+
PARTITION_TOUCH_INTERVAL_MS) {
|
|
1104
|
+
row = (await this.#exec.query(`INSERT INTO sync_partition_registry(
|
|
1105
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
1106
|
+
) VALUES ($1,$2,$3)
|
|
1107
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
1108
|
+
last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
|
|
1109
|
+
RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs])).rows[0];
|
|
1110
|
+
}
|
|
1091
1111
|
if (row === undefined)
|
|
1092
1112
|
throw new Error('partition registry write did not persist');
|
|
1093
1113
|
return {
|
|
@@ -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
|
/**
|
|
@@ -367,9 +386,10 @@ export declare function rewritePlan(table: CompiledTable, oldLayout: readonly St
|
|
|
367
386
|
/**
|
|
368
387
|
* Every DDL statement to bring a database from the introspected relational
|
|
369
388
|
* projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for
|
|
370
|
-
* missing columns, and
|
|
371
|
-
*
|
|
372
|
-
*
|
|
389
|
+
* missing columns, and the secondary-index diff. An existing Syncular-owned
|
|
390
|
+
* index stays when the declaration still wants its physical name with the
|
|
391
|
+
* same {@link indexSignature}; every other owned index is dropped and every
|
|
392
|
+
* missing one is created, so a bump rebuilds only changed or new indexes.
|
|
373
393
|
*
|
|
374
394
|
* The drop set is limited to Syncular-owned indexes: physical names carrying
|
|
375
395
|
* {@link SYNC_INDEX_PREFIX}, plus bare declared names (databases whose
|
|
@@ -377,4 +397,4 @@ export declare function rewritePlan(table: CompiledTable, oldLayout: readonly St
|
|
|
377
397
|
* scheme through this rule). Operator-added tuning indexes keep their own
|
|
378
398
|
* names and survive every bump.
|
|
379
399
|
*/
|
|
380
|
-
export declare function schemaDdl(schema: CompiledSchema, existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>, dialect: RelationalDialect, existingIndexesByTable?: ReadonlyMap<string,
|
|
400
|
+
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) {
|
|
@@ -860,9 +921,10 @@ export function rewritePlan(table, oldLayout, physicalColumnsBefore) {
|
|
|
860
921
|
/**
|
|
861
922
|
* Every DDL statement to bring a database from the introspected relational
|
|
862
923
|
* projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for
|
|
863
|
-
* missing columns, and
|
|
864
|
-
*
|
|
865
|
-
*
|
|
924
|
+
* missing columns, and the secondary-index diff. An existing Syncular-owned
|
|
925
|
+
* index stays when the declaration still wants its physical name with the
|
|
926
|
+
* same {@link indexSignature}; every other owned index is dropped and every
|
|
927
|
+
* missing one is created, so a bump rebuilds only changed or new indexes.
|
|
866
928
|
*
|
|
867
929
|
* The drop set is limited to Syncular-owned indexes: physical names carrying
|
|
868
930
|
* {@link SYNC_INDEX_PREFIX}, plus bare declared names (databases whose
|
|
@@ -875,12 +937,18 @@ export function schemaDdl(schema, existingColumnsByTable, dialect, existingIndex
|
|
|
875
937
|
const creates = [];
|
|
876
938
|
for (const table of schema.tables.values()) {
|
|
877
939
|
const existing = existingColumnsByTable.get(table.name);
|
|
940
|
+
const desired = desiredIndexes(table);
|
|
878
941
|
if (existing === undefined) {
|
|
879
942
|
creates.push(createTableDdl(table, dialect));
|
|
880
943
|
}
|
|
881
944
|
else {
|
|
882
945
|
const declared = new Set(table.indexes.map((index) => index.name));
|
|
883
|
-
for (const indexName of existingIndexesByTable.get(table.name) ?? []) {
|
|
946
|
+
for (const [indexName, signature] of existingIndexesByTable.get(table.name) ?? []) {
|
|
947
|
+
const want = desired.find((index) => index.name === indexName);
|
|
948
|
+
if (want !== undefined &&
|
|
949
|
+
(want.signature === undefined || want.signature === signature)) {
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
884
952
|
if (indexName.startsWith(SYNC_INDEX_PREFIX) ||
|
|
885
953
|
declared.has(indexName)) {
|
|
886
954
|
drops.push(dropIndexDdl(indexName));
|
|
@@ -888,7 +956,7 @@ export function schemaDdl(schema, existingColumnsByTable, dialect, existingIndex
|
|
|
888
956
|
}
|
|
889
957
|
creates.push(...addColumnDdl(table, existing, dialect));
|
|
890
958
|
}
|
|
891
|
-
creates.push(...
|
|
959
|
+
creates.push(...desired.map((index) => index.ddl));
|
|
892
960
|
}
|
|
893
961
|
// Every drop precedes every create: an index name moving between tables in
|
|
894
962
|
// 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';
|
|
@@ -375,11 +375,9 @@ END`);
|
|
|
375
375
|
.all();
|
|
376
376
|
if (columns.length > 0) {
|
|
377
377
|
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)));
|
|
378
|
+
existingIndexes.set(table.name, new Map(Object.entries(sqliteIndexSignatures(this.db
|
|
379
|
+
.query(SQLITE_TABLE_INDEXES_SQL)
|
|
380
|
+
.all(table.name)))));
|
|
383
381
|
}
|
|
384
382
|
}
|
|
385
383
|
// The migration rewrites application rows (`#rewriteRows`) with no
|
|
@@ -446,17 +444,21 @@ END`);
|
|
|
446
444
|
if (initialLogEpoch.length === 0) {
|
|
447
445
|
throw new Error('initial log epoch must be non-empty');
|
|
448
446
|
}
|
|
449
|
-
this.db
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
447
|
+
const select = this.db.query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
448
|
+
FROM sync_partition_registry WHERE partition=?`);
|
|
449
|
+
let row = select.get(partition);
|
|
450
|
+
if (row === null ||
|
|
451
|
+
authenticatedAtMs - row.last_authenticated_at_ms >=
|
|
452
|
+
PARTITION_TOUCH_INTERVAL_MS) {
|
|
453
|
+
this.db
|
|
454
|
+
.query(`INSERT INTO sync_partition_registry(
|
|
455
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
456
|
+
) VALUES (?,?,?)
|
|
457
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
458
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
459
|
+
.run(partition, initialLogEpoch, authenticatedAtMs);
|
|
460
|
+
row = select.get(partition);
|
|
461
|
+
}
|
|
460
462
|
if (row === null)
|
|
461
463
|
throw new Error('partition registry write did not persist');
|
|
462
464
|
return {
|
package/dist/storage.d.ts
CHANGED
|
@@ -283,6 +283,8 @@ export interface ClientCursorInfo {
|
|
|
283
283
|
readonly updatedAtMs: number;
|
|
284
284
|
}
|
|
285
285
|
/** Durable partition identity refreshed after host authentication (§2.1). */
|
|
286
|
+
/** Longest age of `lastAuthenticatedAtMs` that `touchPartition` leaves unwritten. */
|
|
287
|
+
export declare const PARTITION_TOUCH_INTERVAL_MS = 60000;
|
|
286
288
|
export interface PartitionRegistryEntry {
|
|
287
289
|
readonly partition: string;
|
|
288
290
|
readonly logEpoch: string;
|
|
@@ -526,7 +528,12 @@ export interface ServerStorage {
|
|
|
526
528
|
* lazily as a defensive backstop.
|
|
527
529
|
*/
|
|
528
530
|
ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
|
|
529
|
-
/**
|
|
531
|
+
/**
|
|
532
|
+
* Create or refresh the authenticated partition registry row (§2.1). A
|
|
533
|
+
* row whose `lastAuthenticatedAtMs` is less than
|
|
534
|
+
* {@link PARTITION_TOUCH_INTERVAL_MS} behind `authenticatedAtMs` is read
|
|
535
|
+
* back without a write, so a request that only reads takes no write lock.
|
|
536
|
+
*/
|
|
530
537
|
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
531
538
|
/** Rotate log continuity after restore and discard stale cursor records. */
|
|
532
539
|
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
package/dist/storage.js
CHANGED
|
@@ -38,3 +38,6 @@ export function serveGateTokenChanged(before, after) {
|
|
|
38
38
|
}
|
|
39
39
|
return undefined;
|
|
40
40
|
}
|
|
41
|
+
/** Durable partition identity refreshed after host authentication (§2.1). */
|
|
42
|
+
/** Longest age of `lastAuthenticatedAtMs` that `touchPartition` leaves unwritten. */
|
|
43
|
+
export const PARTITION_TOUCH_INTERVAL_MS = 60_000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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.38.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|