@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.
@@ -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(`PRAGMA index_list(${quoteIdent(table.name)})`)
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, names]) => [
867
+ const indexes = new Map(Object.entries(state.indexes).map(([name, signatures]) => [
868
868
  name,
869
- new Set(names),
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
- await this.#db
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
- .first();
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 {
@@ -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 Set(indexes.rows.map((index) => index.index_name)));
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
- const { rows } = await this.#exec.query(`INSERT INTO sync_partition_registry(
1085
- partition, log_epoch, last_authenticated_at_ms
1086
- ) VALUES ($1,$2,$3)
1087
- ON CONFLICT(partition) DO UPDATE SET
1088
- last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
1089
- RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs]);
1090
- const row = rows[0];
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
- * rebuilds declared indexes by dropping every index Syncular owns and
123
- * re-creating the declared set; this prefix is what marks an index as
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
- * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
138
- * the declared columns with a leading server partition column. Cross-table
139
- * index-name uniqueness is the user's schema concern, as it is client-side.
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 createIndexDdl(table: CompiledTable): string[];
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 rebuild declared secondary indexes. Rebuilding during
371
- * a version bump supports DROP INDEX and same-name index replacement without
372
- * requiring historical index definitions in the stored column-layout marker.
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, ReadonlySet<string>>): string[];
400
+ export declare function schemaDdl(schema: CompiledSchema, existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>, dialect: RelationalDialect, existingIndexesByTable?: ReadonlyMap<string, ReadonlyMap<string, string>>): string[];
@@ -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
- * rebuilds declared indexes by dropping every index Syncular owns and
306
- * re-creating the declared set; this prefix is what marks an index as
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
- * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
336
- * the declared columns with a leading server partition column. Cross-table
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
- export function createIndexDdl(table) {
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 table.indexes.map((index) => {
346
- const unique = index.unique ? 'UNIQUE ' : '';
347
- const columns = [SYNC_PARTITION_COLUMN, ...index.columns]
348
- .map((column) => quoteIdent(column))
349
- .join(', ');
350
- return `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(physicalIndexName(index.name))} ON ${quoteIdent(table.name)} (${columns})`;
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 rebuild declared secondary indexes. Rebuilding during
864
- * a version bump supports DROP INDEX and same-name index replacement without
865
- * requiring historical index definitions in the stored column-layout marker.
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(...createIndexDdl(table));
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,
@@ -11,7 +11,7 @@ export class BunSqliteDatabase {
11
11
  this.native.exec(sql);
12
12
  }
13
13
  run(sql, bindings = []) {
14
- return this.native.run(sql, [...bindings]);
14
+ return this.native.query(sql).run(...bindings);
15
15
  }
16
16
  query(sql) {
17
17
  const statement = this.native.query(sql);
@@ -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.native.prepare(sql).run(...bindings.map(bind));
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.native.prepare(sql);
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))),
@@ -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
- const indexes = this.db
379
- .query(`PRAGMA index_list("${escapedTableName}")`)
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
- .query(`INSERT INTO sync_partition_registry(
451
- partition, log_epoch, last_authenticated_at_ms
452
- ) VALUES (?,?,?)
453
- ON CONFLICT(partition) DO UPDATE SET
454
- last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
455
- .run(partition, initialLogEpoch, authenticatedAtMs);
456
- const row = this.db
457
- .query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
458
- FROM sync_partition_registry WHERE partition=?`)
459
- .get(partition);
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
- /** Create or refresh the authenticated partition registry row (§2.1). */
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.37.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.37.0"
71
+ "@syncular/core": "0.38.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"