@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.
@@ -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
  /**
@@ -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
- /** Upsert statement text (bind with `upsertValues`). */
161
- export declare function upsertSql(table: CompiledTable, dialect: RelationalDialect): string;
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 `table` in a pull. `$2` is a JSON array
231
- * of `{i, tbl, var, vals, after, lim}` records; each record runs the same
232
- * index-first candidate subquery through a LATERAL join, so every
233
- * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
234
- * by (i, row_id).
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(table: CompiledTable): string;
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
- * Rewrite one row during a migration: refresh the projection columns (when
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, dialect: RelationalDialect): string;
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 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.
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, ReadonlySet<string>>): string[];
406
+ 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) {
@@ -403,24 +464,22 @@ export function upsertValues(table, partition, row, dialect) {
403
464
  row.columnVersions ?? null,
404
465
  ];
405
466
  }
406
- function placeholderList(count, dialect) {
407
- if (dialect === 'sqlite')
408
- return new Array(count).fill('?').join(',');
409
- const out = [];
410
- for (let i = 1; i <= count; i++)
411
- out.push(`$${i}`);
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 = placeholderList(names.length, dialect);
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 (${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 `table` in a pull. `$2` is a JSON array
554
- * of `{i, tbl, var, vals, after, lim}` records; each record runs the same
555
- * index-first candidate subquery through a LATERAL join, so every
556
- * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
557
- * by (i, row_id).
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(table) {
564
- return `SELECT s.i 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
565
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
566
- CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
567
- WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
568
- AND row_id>s.after
569
- ORDER BY row_id LIMIT s.lim) c
570
- LEFT JOIN ${quoteIdent(table.name)} r
571
- ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
572
- ORDER BY s.i, c.row_id`;
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
- * Rewrite one row during a migration: refresh the projection columns (when
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, dialect) {
809
- const sets = [];
810
- let i = 1;
811
- if (table.materialize) {
812
- for (const column of table.columns) {
813
- sets.push(`${quoteIdent(column.name)}=${dialect === 'sqlite' ? '?' : `$${i}`}`);
814
- i++;
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 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.
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(...createIndexDdl(table));
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,
@@ -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';
@@ -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
- 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)));
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
- .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);
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, 'sqlite'));
537
+ const update = this.db.query(rewriteRowSql(table));
523
538
  const BATCH = 500;
524
539
  let afterPartition = '';
525
540
  let afterRowId = '';