@syncular/server 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pull.ts CHANGED
@@ -484,8 +484,8 @@ async function* bootstrapSegments(
484
484
  /**
485
485
  * The storage reads a section needs before its first frame. A pull starts
486
486
  * them for every subscription before awaiting any, so a storage that batches
487
- * concurrent page reads (PostgreSQL, D1) answers all subscriptions of one
488
- * table with one statement, and the horizon re-check runs once per pull.
487
+ * concurrent page reads (PostgreSQL, D1) answers the first pages of every
488
+ * subscription in one statement, and the horizon re-check runs once per pull.
489
489
  */
490
490
  export interface PreparedSection {
491
491
  /** §4.5 window read for an incremental cursor; empty when none was read. */
@@ -499,7 +499,11 @@ export interface PreparedSection {
499
499
  readonly firstPage: StoredRow[] | undefined;
500
500
  /** §4.7 pin of a fresh bootstrap; absent ⇒ `maxCommitSeq`. */
501
501
  readonly pin: number | undefined;
502
- /** §5.3 stored image for a sqlite-lane bootstrap's reuse key, if any. */
502
+ /**
503
+ * §5.3 stored image for a sqlite-lane bootstrap's reuse key. Read only when
504
+ * the eligibility probe shows a snapshot larger than one rows page, since a
505
+ * smaller table can never match a stored image.
506
+ */
503
507
  readonly image: SegmentRecord | undefined;
504
508
  }
505
509
 
@@ -574,21 +578,30 @@ export async function prepareSections(
574
578
  const pin = Math.min(maxSeq, Math.max(latest, horizonSeq));
575
579
  return { asOf: pin, pin };
576
580
  });
577
- const lane = pinned.then(async ({ asOf, pin }) => ({
578
- pin,
579
- image: await ctx.segments.find(
580
- {
581
- partition: ctx.partition,
582
- logEpoch,
583
- table: plan.table.name,
584
- schemaVersion: schema.version,
585
- mediaType: 'sqlite',
586
- scopeDigest: await scopeDigest(plan.effective),
587
- asOfCommitSeq: asOf,
588
- },
589
- clockOf(ctx)(),
590
- ),
591
- }));
581
+ const lane = pinned.then(async ({ asOf, pin }) => {
582
+ // §5.3 eligibility: a stored image only matches a snapshot larger than
583
+ // one rows page, so the probe decides before the reuse lookup; a remote
584
+ // `find` on a small table would be a wasted round trip.
585
+ const probe = await firstPage;
586
+ if (probe.length <= limits.limitSnapshotRows) {
587
+ return { pin, image: undefined };
588
+ }
589
+ return {
590
+ pin,
591
+ image: await ctx.segments.find(
592
+ {
593
+ partition: ctx.partition,
594
+ logEpoch,
595
+ table: plan.table.name,
596
+ schemaVersion: schema.version,
597
+ mediaType: 'sqlite',
598
+ scopeDigest: await scopeDigest(plan.effective),
599
+ asOfCommitSeq: asOf,
600
+ },
601
+ clockOf(ctx)(),
602
+ ),
603
+ };
604
+ });
592
605
  return { firstPage, lane };
593
606
  });
594
607
  const settled = await Promise.all(
@@ -53,7 +53,12 @@ import {
53
53
  type RowColumn,
54
54
  type RowValue,
55
55
  } from '@syncular/core';
56
- import type { CompiledSchema, CompiledTable, IndexSchema } from './schema';
56
+ import type {
57
+ CompiledSchema,
58
+ CompiledTable,
59
+ IndexSchema,
60
+ ServerIndexSchema,
61
+ } from './schema';
57
62
  import type { StoredRow } from './storage';
58
63
  import { StorageQueryError } from './storage-errors';
59
64
 
@@ -356,8 +361,8 @@ export function addColumnDdl(
356
361
 
357
362
  /**
358
363
  * Ownership marker for server-created projection indexes. A version bump
359
- * rebuilds declared indexes by dropping every index Syncular owns and
360
- * re-creating the declared set; this prefix is what marks an index as
364
+ * drops every index Syncular owns whose definition changed or was removed
365
+ * and creates the missing ones; this prefix is what marks an index as
361
366
  * Syncular-owned, so operator-added tuning indexes survive bumps. Portable
362
367
  * identifier validation reserves the `sync_` prefix for the server storage
363
368
  * namespace, which keeps user and operator index names out of it.
@@ -388,22 +393,113 @@ export function physicalIndexName(declaredName: string): string {
388
393
  }
389
394
 
390
395
  /**
391
- * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
392
- * the declared columns with a leading server partition column. Cross-table
396
+ * Physical name of one server-only index. The name carries a fingerprint of
397
+ * the definition, so a changed definition is a different index and the bump
398
+ * diff needs no catalog comparison of predicates.
399
+ */
400
+ export function physicalServerIndexName(index: ServerIndexSchema): string {
401
+ const fingerprint = fnv1a64Hex(
402
+ JSON.stringify([index.columns, index.where ?? null]),
403
+ ).slice(0, 8);
404
+ const readable = `${SYNC_INDEX_PREFIX}${index.name}_${fingerprint}`;
405
+ if (new TextEncoder().encode(readable).length <= 63) return readable;
406
+ return `${SYNC_INDEX_PREFIX}${fnv1a64Hex(index.name)}_${fingerprint}`;
407
+ }
408
+
409
+ /**
410
+ * Canonical shape of one physical index, as read from the catalog and as
411
+ * declared: kind, then the key columns in order (`null` for an expression).
412
+ * Two indexes with one signature are interchangeable.
413
+ */
414
+ export function indexSignature(
415
+ columns: readonly (string | null)[],
416
+ unique: boolean,
417
+ partial: boolean,
418
+ ): string {
419
+ return `${unique ? 'unique' : 'index'}${partial ? ' partial' : ''}(${columns.map((column) => column ?? '?').join(',')})`;
420
+ }
421
+
422
+ /**
423
+ * One statement that lists a table's free-standing SQLite indexes with their
424
+ * key columns in order (bind the table name). `origin='c'` excludes
425
+ * constraint-owned indexes, which only their constraint can remove. Feed the
426
+ * rows to {@link sqliteIndexSignatures}.
427
+ */
428
+ 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
429
+ FROM pragma_index_list(?) AS il
430
+ JOIN pragma_index_info(il.name) AS ii
431
+ WHERE il.origin='c'
432
+ ORDER BY il.name, ii.seqno`;
433
+
434
+ export interface SqliteTableIndexRow {
435
+ readonly name: string;
436
+ readonly is_unique: number;
437
+ readonly is_partial: number;
438
+ readonly column_name: string | null;
439
+ }
440
+
441
+ /** Index name to {@link indexSignature} for the rows of one table. */
442
+ export function sqliteIndexSignatures(
443
+ rows: readonly SqliteTableIndexRow[],
444
+ ): Record<string, string> {
445
+ const shapes = new Map<
446
+ string,
447
+ { unique: boolean; partial: boolean; columns: (string | null)[] }
448
+ >();
449
+ for (const row of rows) {
450
+ const shape = shapes.get(row.name) ?? {
451
+ unique: row.is_unique === 1,
452
+ partial: row.is_partial === 1,
453
+ columns: [],
454
+ };
455
+ shape.columns.push(row.column_name);
456
+ shapes.set(row.name, shape);
457
+ }
458
+ return Object.fromEntries(
459
+ [...shapes].map(([name, shape]) => [
460
+ name,
461
+ indexSignature(shape.columns, shape.unique, shape.partial),
462
+ ]),
463
+ );
464
+ }
465
+
466
+ interface DesiredIndex {
467
+ readonly name: string;
468
+ /** Absent when the physical name already fingerprints the definition. */
469
+ readonly signature: string | undefined;
470
+ readonly ddl: string;
471
+ }
472
+
473
+ /**
474
+ * The table's declared and server-only indexes. Declared indexes use the
475
+ * declared columns with a leading server partition column. Cross-table
393
476
  * index-name uniqueness is the user's schema concern, as it is client-side.
394
477
  * Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
395
478
  * ownership marker.
396
479
  */
397
- export function createIndexDdl(table: CompiledTable): string[] {
480
+ function desiredIndexes(table: CompiledTable): DesiredIndex[] {
398
481
  // User indexes name app columns — nothing to index without the projection.
399
482
  if (!table.materialize) return [];
400
- return table.indexes.map((index) => {
401
- const unique = index.unique ? 'UNIQUE ' : '';
402
- const columns = [SYNC_PARTITION_COLUMN, ...index.columns]
403
- .map((column) => quoteIdent(column))
404
- .join(', ');
405
- return `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(physicalIndexName(index.name))} ON ${quoteIdent(table.name)} (${columns})`;
406
- });
483
+ return [
484
+ ...table.indexes.map((index) => {
485
+ const unique = index.unique === true;
486
+ const columns = [SYNC_PARTITION_COLUMN, ...index.columns];
487
+ const name = physicalIndexName(index.name);
488
+ return {
489
+ name,
490
+ signature: indexSignature(columns, unique, false),
491
+ ddl: `CREATE ${unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quoteIdent(name)} ON ${quoteIdent(table.name)} (${columns.map((column) => quoteIdent(column)).join(', ')})`,
492
+ };
493
+ }),
494
+ ...table.serverIndexes.map((index) => {
495
+ const name = physicalServerIndexName(index);
496
+ return {
497
+ name,
498
+ signature: undefined,
499
+ 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}`}`,
500
+ };
501
+ }),
502
+ ];
407
503
  }
408
504
 
409
505
  /** Idempotent removal of one Syncular-owned relational projection index. */
@@ -470,26 +566,31 @@ export function upsertValues(
470
566
  ];
471
567
  }
472
568
 
473
- function placeholderList(count: number, dialect: RelationalDialect): string {
474
- if (dialect === 'sqlite') return new Array(count).fill('?').join(',');
475
- const out: string[] = [];
476
- for (let i = 1; i <= count; i++) out.push(`$${i}`);
477
- return out.join(',');
478
- }
479
-
480
- /** Upsert statement text (bind with `upsertValues`). */
569
+ /**
570
+ * Upsert statement text (bind with `upsertValues`). PostgreSQL takes `rows`
571
+ * rows in one statement, bound as consecutive `upsertValues` lists; their
572
+ * row ids must be distinct.
573
+ */
481
574
  export function upsertSql(
482
575
  table: CompiledTable,
483
576
  dialect: RelationalDialect,
577
+ rows = 1,
484
578
  ): string {
485
579
  const names = tableColumnNames(table);
486
580
  const quoted = names.map((name) => quoteIdent(name)).join(', ');
487
- const values = placeholderList(names.length, dialect);
581
+ const values =
582
+ dialect === 'sqlite'
583
+ ? `(${new Array(names.length).fill('?').join(',')})`
584
+ : Array.from(
585
+ { length: rows },
586
+ (_, row) =>
587
+ `(${names.map((_, column) => `$${row * names.length + column + 1}`).join(',')})`,
588
+ ).join(',');
488
589
  const updates = names
489
590
  .slice(2) // partition + row id are the conflict key
490
591
  .map((name) => `${quoteIdent(name)}=excluded.${quoteIdent(name)}`)
491
592
  .join(', ');
492
- return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES (${values})
593
+ return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES ${values}
493
594
  ON CONFLICT (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)}) DO UPDATE SET ${updates}`;
494
595
  }
495
596
 
@@ -646,26 +747,40 @@ export function commitWindowPageSql(
646
747
 
647
748
  /**
648
749
  * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
649
- * page of every subscription scanning `table` in a pull. `$2` is a JSON array
650
- * of `{i, tbl, var, vals, after, lim}` records; each record runs the same
651
- * index-first candidate subquery through a LATERAL join, so every
652
- * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
653
- * by (i, row_id).
750
+ * page of every subscription scanning any table in a pull. `$2` is a JSON
751
+ * array of `{i, tbl, var, vals, after, lim}` records; each record runs the
752
+ * same index-first candidate subquery through a LATERAL join, so every
753
+ * subscription keeps its own keyset cursor and LIMIT. The candidate side is
754
+ * table-agnostic (a spec names its table), so the statement joins the row
755
+ * table once per table present in the batch through a UNION ALL branch that
756
+ * keeps only its own candidates. Rows arrive ordered by (i, row_id).
654
757
  *
655
758
  * Params: [partition, specs (JSON text)]. The specs bind as text before
656
759
  * the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
657
760
  * parameter (Bun.sql) cannot turn the array into a JSON string.
658
761
  */
659
- export function scanRowBatchSql(table: CompiledTable): string {
660
- 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
661
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
662
- CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
663
- WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
664
- AND row_id>s.after
665
- ORDER BY row_id LIMIT s.lim) c
666
- LEFT JOIN ${quoteIdent(table.name)} r
667
- ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
668
- ORDER BY s.i, c.row_id`;
762
+ export function scanRowBatchSql(tables: readonly CompiledTable[]): string {
763
+ // One branch per table, each keeping only its own candidates and selecting
764
+ // the same meta columns so the caller decodes one row shape.
765
+ const projection = tables
766
+ .map((table) => {
767
+ const quoted = quoteIdent(table.name);
768
+ 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
769
+ FROM cand c
770
+ LEFT JOIN ${quoted} r
771
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
772
+ WHERE c.tbl='${table.name.replaceAll("'", "''")}'`;
773
+ })
774
+ .join('\n UNION ALL ');
775
+ return `WITH cand AS (
776
+ SELECT s.i AS sub, c.row_id AS row_id, s.tbl AS tbl
777
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
778
+ CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
779
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
780
+ AND row_id>s.after
781
+ ORDER BY row_id LIMIT s.lim) c)
782
+ ${projection}
783
+ ORDER BY sub, row_id`;
669
784
  }
670
785
 
671
786
  /**
@@ -958,30 +1073,17 @@ export function selectRowsForRewriteSql(
958
1073
  }
959
1074
 
960
1075
  /**
961
- * Rewrite one row during a migration: refresh the projection columns (when
1076
+ * SQLite/D1: rewrite one row during a migration: refresh the projection columns (when
962
1077
  * materialized) and the payload. Bind with `rewriteValues`.
963
1078
  */
964
- export function rewriteRowSql(
965
- table: CompiledTable,
966
- dialect: RelationalDialect,
967
- ): string {
968
- const sets: string[] = [];
969
- let i = 1;
970
- if (table.materialize) {
971
- for (const column of table.columns) {
972
- sets.push(
973
- `${quoteIdent(column.name)}=${dialect === 'sqlite' ? '?' : `$${i}`}`,
974
- );
975
- i++;
976
- }
977
- }
978
- sets.push(
979
- `${quoteIdent(SYNC_PAYLOAD_COLUMN)}=${dialect === 'sqlite' ? '?' : `$${i}`}`,
980
- );
981
- i++;
982
- const wherePartition = dialect === 'sqlite' ? '?' : `$${i}`;
983
- const whereRowId = dialect === 'sqlite' ? '?' : `$${i + 1}`;
984
- return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${wherePartition} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${whereRowId}`;
1079
+ export function rewriteRowSql(table: CompiledTable): string {
1080
+ const sets = [
1081
+ ...(table.materialize
1082
+ ? table.columns.map((column) => `${quoteIdent(column.name)}=?`)
1083
+ : []),
1084
+ `${quoteIdent(SYNC_PAYLOAD_COLUMN)}=?`,
1085
+ ];
1086
+ return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?`;
985
1087
  }
986
1088
 
987
1089
  /** Bind values for `rewriteRowSql` from the (already migrated) payload. */
@@ -1039,9 +1141,10 @@ export function rewritePlan(
1039
1141
  /**
1040
1142
  * Every DDL statement to bring a database from the introspected relational
1041
1143
  * projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for
1042
- * missing columns, and rebuild declared secondary indexes. Rebuilding during
1043
- * a version bump supports DROP INDEX and same-name index replacement without
1044
- * requiring historical index definitions in the stored column-layout marker.
1144
+ * missing columns, and the secondary-index diff. An existing Syncular-owned
1145
+ * index stays when the declaration still wants its physical name with the
1146
+ * same {@link indexSignature}; every other owned index is dropped and every
1147
+ * missing one is created, so a bump rebuilds only changed or new indexes.
1045
1148
  *
1046
1149
  * The drop set is limited to Syncular-owned indexes: physical names carrying
1047
1150
  * {@link SYNC_INDEX_PREFIX}, plus bare declared names (databases whose
@@ -1053,17 +1156,30 @@ export function schemaDdl(
1053
1156
  schema: CompiledSchema,
1054
1157
  existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>,
1055
1158
  dialect: RelationalDialect,
1056
- existingIndexesByTable: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
1159
+ existingIndexesByTable: ReadonlyMap<
1160
+ string,
1161
+ ReadonlyMap<string, string>
1162
+ > = new Map(),
1057
1163
  ): string[] {
1058
1164
  const drops: string[] = [];
1059
1165
  const creates: string[] = [];
1060
1166
  for (const table of schema.tables.values()) {
1061
1167
  const existing = existingColumnsByTable.get(table.name);
1168
+ const desired = desiredIndexes(table);
1062
1169
  if (existing === undefined) {
1063
1170
  creates.push(createTableDdl(table, dialect));
1064
1171
  } else {
1065
1172
  const declared = new Set(table.indexes.map((index) => index.name));
1066
- for (const indexName of existingIndexesByTable.get(table.name) ?? []) {
1173
+ for (const [indexName, signature] of existingIndexesByTable.get(
1174
+ table.name,
1175
+ ) ?? []) {
1176
+ const want = desired.find((index) => index.name === indexName);
1177
+ if (
1178
+ want !== undefined &&
1179
+ (want.signature === undefined || want.signature === signature)
1180
+ ) {
1181
+ continue;
1182
+ }
1067
1183
  if (
1068
1184
  indexName.startsWith(SYNC_INDEX_PREFIX) ||
1069
1185
  declared.has(indexName)
@@ -1073,7 +1189,7 @@ export function schemaDdl(
1073
1189
  }
1074
1190
  creates.push(...addColumnDdl(table, existing, dialect));
1075
1191
  }
1076
- creates.push(...createIndexDdl(table));
1192
+ creates.push(...desired.map((index) => index.ddl));
1077
1193
  }
1078
1194
  // Every drop precedes every create: an index name moving between tables in
1079
1195
  // one bump must release the global (SQLite) index namespace before the
package/src/schema.ts CHANGED
@@ -23,6 +23,22 @@ export interface IndexSchema {
23
23
  readonly unique?: boolean;
24
24
  }
25
25
 
26
+ /**
27
+ * A server-only index on a table's relational projection. Unlike a declared
28
+ * {@link IndexSchema}, it never reaches a client, and its `columns` are the
29
+ * physical columns as written: Syncular does not prepend `_sync_partition`.
30
+ * Name `_sync_partition` and `_sync_row_id` explicitly where an index should
31
+ * lead with them. The index is never unique, because uniqueness is enforced
32
+ * per partition. `where` is the predicate of a partial index, in SQL that
33
+ * the server's dialect accepts. Creating or changing one requires a schema
34
+ * version bump, like every index.
35
+ */
36
+ export interface ServerIndexSchema {
37
+ readonly name: string;
38
+ readonly columns: readonly string[];
39
+ readonly where?: string;
40
+ }
41
+
26
42
  /** One declared reference (§6.11). Absent `onDelete` means `RESTRICT`. */
27
43
  export interface ReferenceSchema {
28
44
  readonly column: string;
@@ -61,6 +77,8 @@ export interface TableSchema {
61
77
  readonly scopes: readonly ScopePatternSpec[];
62
78
  /** User indexes (optional) — created on the server's relational tables. */
63
79
  readonly indexes?: readonly IndexSchema[];
80
+ /** Server-only indexes (optional); names share the namespace of `indexes`. */
81
+ readonly serverIndexes?: readonly ServerIndexSchema[];
64
82
  /** Declared references (§6.11). Optional so pre-reference schemas stay
65
83
  * valid; the reference index over each child column is declared in
66
84
  * `indexes` (typegen emits it). */
@@ -104,6 +122,8 @@ export interface CompiledTable {
104
122
  readonly scopePatterns: readonly CompiledScopePattern[];
105
123
  /** User indexes (validated: unique names, existing columns). */
106
124
  readonly indexes: readonly IndexSchema[];
125
+ /** Server-only indexes (validated like `indexes`). */
126
+ readonly serverIndexes: readonly ServerIndexSchema[];
107
127
  /** Outgoing declared references (§6.11), resolved to column indices. */
108
128
  readonly references: readonly CompiledReference[];
109
129
  /** Incoming declared references: child rows to reach on a parent delete. */
@@ -222,6 +242,40 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
222
242
  }
223
243
  }
224
244
  }
245
+ const serverIndexes = table.serverIndexes ?? [];
246
+ for (const index of serverIndexes) {
247
+ validatePortableRelationalIdentifier(
248
+ `table ${table.name}: server index`,
249
+ index.name,
250
+ );
251
+ if (indexNames.has(index.name)) {
252
+ throw new Error(
253
+ `table ${table.name}: duplicate index ${JSON.stringify(index.name)}`,
254
+ );
255
+ }
256
+ indexNames.add(index.name);
257
+ if (index.columns.length === 0) {
258
+ throw new Error(
259
+ `table ${table.name}: server index ${JSON.stringify(index.name)} must name at least one column`,
260
+ );
261
+ }
262
+ for (const column of index.columns) {
263
+ if (
264
+ !columnIndex.has(column) &&
265
+ column !== '_sync_partition' &&
266
+ column !== '_sync_row_id'
267
+ ) {
268
+ throw new Error(
269
+ `table ${table.name}: server index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`,
270
+ );
271
+ }
272
+ }
273
+ if (index.where !== undefined && index.where.trim().length === 0) {
274
+ throw new Error(
275
+ `table ${table.name}: server index ${JSON.stringify(index.name)} has an empty where predicate`,
276
+ );
277
+ }
278
+ }
225
279
  const primaryKeyIndex = columnIndex.get(table.primaryKey);
226
280
  if (primaryKeyIndex === undefined) {
227
281
  throw new Error(
@@ -319,6 +373,7 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
319
373
  primaryKeyIndex,
320
374
  scopePatterns,
321
375
  indexes,
376
+ serverIndexes,
322
377
  references,
323
378
  referencedBy: [],
324
379
  materialize,
@@ -22,7 +22,7 @@ export class BunSqliteDatabase implements SqliteDatabase {
22
22
  }
23
23
 
24
24
  run(sql: string, bindings: readonly SqliteValue[] = []): SqliteRunResult {
25
- return this.native.run(sql, [...bindings]);
25
+ return this.native.query<unknown, SQLQueryBindings[]>(sql).run(...bindings);
26
26
  }
27
27
 
28
28
  query<Row, Params extends readonly SqliteValue[]>(
@@ -1,4 +1,8 @@
1
- import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
1
+ import {
2
+ DatabaseSync,
3
+ type SQLInputValue,
4
+ type StatementSync,
5
+ } from 'node:sqlite';
2
6
  import type {
3
7
  SqliteDatabase,
4
8
  SqliteRunResult,
@@ -16,6 +20,8 @@ function row<Row>(value: Record<string, unknown> | undefined): Row | null {
16
20
 
17
21
  export class NodeSqliteDatabase implements SqliteDatabase {
18
22
  readonly native: DatabaseSync;
23
+ /** Prepared statements by SQL text, as `bun:sqlite`'s `query` keeps them. */
24
+ readonly #statements = new Map<string, StatementSync>();
19
25
 
20
26
  constructor(path = ':memory:') {
21
27
  this.native = new DatabaseSync(path);
@@ -26,13 +32,22 @@ export class NodeSqliteDatabase implements SqliteDatabase {
26
32
  }
27
33
 
28
34
  run(sql: string, bindings: readonly SqliteValue[] = []): SqliteRunResult {
29
- return this.native.prepare(sql).run(...bindings.map(bind));
35
+ return this.#prepare(sql).run(...bindings.map(bind));
36
+ }
37
+
38
+ #prepare(sql: string): StatementSync {
39
+ let statement = this.#statements.get(sql);
40
+ if (statement === undefined) {
41
+ statement = this.native.prepare(sql);
42
+ this.#statements.set(sql, statement);
43
+ }
44
+ return statement;
30
45
  }
31
46
 
32
47
  query<Row, Params extends readonly SqliteValue[]>(
33
48
  sql: string,
34
49
  ): SqliteStatement<Row, Params> {
35
- const statement = this.native.prepare(sql);
50
+ const statement = this.#prepare(sql);
36
51
  return {
37
52
  run: (...params) => statement.run(...params.map(bind)),
38
53
  get: (...params) => row<Row>(statement.get(...params.map(bind))),