@syncular/server 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/d1-storage.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { validateCommitPruneQuery } from './prune';
2
2
  import { StorageQueryError } from './storage-errors';
3
3
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
4
+ import { PARTITION_TOUCH_INTERVAL_MS } from './storage';
4
5
  /**
5
6
  * Cloudflare D1 server storage for Workers deployments.
6
7
  *
@@ -77,6 +78,9 @@ import {
77
78
  type StoredColumnLayout,
78
79
  scanRowPageSql,
79
80
  schemaDdl,
81
+ SQLITE_TABLE_INDEXES_SQL,
82
+ type SqliteTableIndexRow,
83
+ sqliteIndexSignatures,
80
84
  selectRowScopesSql,
81
85
  selectRowSql,
82
86
  selectRowsForRewriteSql,
@@ -938,7 +942,7 @@ interface D1MigrationState {
938
942
  phase: 'core' | 'prepare' | 'ddl' | 'rewrite';
939
943
  offset: number;
940
944
  columns: Record<string, string[]>;
941
- indexes: Record<string, string[]>;
945
+ indexes: Record<string, Record<string, string>>;
942
946
  ddl: string[];
943
947
  rewrites: Array<{ table: string; oldLayout?: readonly StoredColumnLayout[] }>;
944
948
  afterPartition: string;
@@ -1269,8 +1273,9 @@ export class D1ServerStorage implements ServerStorage {
1269
1273
  .prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
1270
1274
  .all<{ name: string }>();
1271
1275
  const indexes = await this.#db
1272
- .prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
1273
- .all<{ name: string; origin: string }>();
1276
+ .prepare(SQLITE_TABLE_INDEXES_SQL)
1277
+ .bind(table.name)
1278
+ .all<SqliteTableIndexRow>();
1274
1279
  // Validate the old codec before any application DDL or row rewrite.
1275
1280
  rewritePlan(
1276
1281
  table,
@@ -1296,9 +1301,7 @@ export class D1ServerStorage implements ServerStorage {
1296
1301
  },
1297
1302
  indexes: {
1298
1303
  ...state.indexes,
1299
- [table.name]: indexes.results
1300
- .filter((index) => index.origin === 'c')
1301
- .map((index) => index.name),
1304
+ [table.name]: sqliteIndexSignatures(indexes.results),
1302
1305
  },
1303
1306
  },
1304
1307
  [],
@@ -1314,9 +1317,9 @@ export class D1ServerStorage implements ServerStorage {
1314
1317
  ]),
1315
1318
  );
1316
1319
  const indexes = new Map(
1317
- Object.entries(state.indexes).map(([name, names]) => [
1320
+ Object.entries(state.indexes).map(([name, signatures]) => [
1318
1321
  name,
1319
- new Set(names),
1322
+ new Map(Object.entries(signatures)),
1320
1323
  ]),
1321
1324
  );
1322
1325
  const rewrites = tables.flatMap((table) => {
@@ -1581,27 +1584,38 @@ export class D1ServerStorage implements ServerStorage {
1581
1584
  if (initialLogEpoch.length === 0) {
1582
1585
  throw new Error('initial log epoch must be non-empty');
1583
1586
  }
1584
- await this.#db
1585
- .prepare(
1586
- `INSERT INTO sync_partition_registry(
1587
- partition, log_epoch, last_authenticated_at_ms
1588
- ) VALUES (?,?,?)
1589
- ON CONFLICT(partition) DO UPDATE SET
1590
- last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
1591
- )
1592
- .bind(partition, initialLogEpoch, authenticatedAtMs)
1593
- .run();
1594
- const row = await this.#db
1587
+ const select = this.#db
1595
1588
  .prepare(
1596
1589
  `SELECT log_epoch, epoch_required, last_authenticated_at_ms
1597
1590
  FROM sync_partition_registry WHERE partition=?`,
1598
1591
  )
1599
- .bind(partition)
1600
- .first<{
1592
+ .bind(partition);
1593
+ let row = await select.first<{
1594
+ log_epoch: string;
1595
+ epoch_required: number;
1596
+ last_authenticated_at_ms: number;
1597
+ }>();
1598
+ if (
1599
+ row === null ||
1600
+ authenticatedAtMs - row.last_authenticated_at_ms >=
1601
+ PARTITION_TOUCH_INTERVAL_MS
1602
+ ) {
1603
+ await this.#db
1604
+ .prepare(
1605
+ `INSERT INTO sync_partition_registry(
1606
+ partition, log_epoch, last_authenticated_at_ms
1607
+ ) VALUES (?,?,?)
1608
+ ON CONFLICT(partition) DO UPDATE SET
1609
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
1610
+ )
1611
+ .bind(partition, initialLogEpoch, authenticatedAtMs)
1612
+ .run();
1613
+ row = await select.first<{
1601
1614
  log_epoch: string;
1602
1615
  epoch_required: number;
1603
1616
  last_authenticated_at_ms: number;
1604
1617
  }>();
1618
+ }
1605
1619
  if (row === null)
1606
1620
  throw new Error('partition registry write did not persist');
1607
1621
  return {
@@ -1,7 +1,7 @@
1
1
  import { validateCommitPruneQuery } from './prune';
2
2
  import { StorageQueryError } from './storage-errors';
3
3
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
4
- import { serveGateRefusal } from './storage';
4
+ import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage';
5
5
  import { serveNotReadyError } from './readiness';
6
6
  /**
7
7
  * Postgres server storage: the production database path.
@@ -81,6 +81,7 @@ import {
81
81
  type StoredColumnLayout,
82
82
  scanRowBatchSql,
83
83
  scanRowPageSql,
84
+ indexSignature,
84
85
  schemaDdl,
85
86
  selectRowScopesSql,
86
87
  selectRowSql,
@@ -1554,7 +1555,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1554
1555
  // the migration waits.
1555
1556
  await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
1556
1557
  const existing = new Map<string, ReadonlySet<string>>();
1557
- const existingIndexes = new Map<string, ReadonlySet<string>>();
1558
+ const existingIndexes = new Map<string, ReadonlyMap<string, string>>();
1558
1559
  for (const table of schema.tables.values()) {
1559
1560
  const { rows } = await client.query<{ column_name: string }>(
1560
1561
  `SELECT column_name FROM information_schema.columns
@@ -1568,8 +1569,25 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1568
1569
  // indexes (PRIMARY KEY, UNIQUE constraints) can only be removed
1569
1570
  // through their constraint, so DROP INDEX on them would abort
1570
1571
  // the migration transaction.
1571
- const indexes = await client.query<{ index_name: string }>(
1572
- `SELECT index_class.relname AS index_name
1572
+ const indexes = await client.query<{
1573
+ index_name: string;
1574
+ is_unique: boolean;
1575
+ is_partial: boolean;
1576
+ columns: (string | null)[];
1577
+ }>(
1578
+ `SELECT index_class.relname AS index_name,
1579
+ index_meta.indisunique AS is_unique,
1580
+ index_meta.indpred IS NOT NULL AS is_partial,
1581
+ ARRAY(
1582
+ SELECT attribute.attname::text
1583
+ FROM unnest(index_meta.indkey::int2[])
1584
+ WITH ORDINALITY AS key(attnum, ord)
1585
+ LEFT JOIN pg_catalog.pg_attribute AS attribute
1586
+ ON attribute.attrelid = table_class.oid
1587
+ AND attribute.attnum = key.attnum
1588
+ WHERE key.ord <= index_meta.indnkeyatts
1589
+ ORDER BY key.ord
1590
+ ) AS columns
1573
1591
  FROM pg_catalog.pg_class AS table_class
1574
1592
  JOIN pg_catalog.pg_namespace AS namespace
1575
1593
  ON namespace.oid = table_class.relnamespace
@@ -1589,7 +1607,16 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1589
1607
  );
1590
1608
  existingIndexes.set(
1591
1609
  table.name,
1592
- new Set(indexes.rows.map((index) => index.index_name)),
1610
+ new Map(
1611
+ indexes.rows.map((index) => [
1612
+ index.index_name,
1613
+ indexSignature(
1614
+ index.columns,
1615
+ index.is_unique,
1616
+ index.is_partial,
1617
+ ),
1618
+ ]),
1619
+ ),
1593
1620
  );
1594
1621
  }
1595
1622
  }
@@ -1667,20 +1694,35 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1667
1694
  if (initialLogEpoch.length === 0) {
1668
1695
  throw new Error('initial log epoch must be non-empty');
1669
1696
  }
1670
- const { rows } = await this.#exec.query<{
1697
+ type Registry = {
1671
1698
  log_epoch: string;
1672
1699
  epoch_required: boolean;
1673
1700
  last_authenticated_at_ms: unknown;
1674
- }>(
1675
- `INSERT INTO sync_partition_registry(
1676
- partition, log_epoch, last_authenticated_at_ms
1677
- ) VALUES ($1,$2,$3)
1678
- ON CONFLICT(partition) DO UPDATE SET
1679
- last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
1680
- RETURNING log_epoch, epoch_required, last_authenticated_at_ms`,
1681
- [partition, initialLogEpoch, authenticatedAtMs],
1682
- );
1683
- const row = rows[0];
1701
+ };
1702
+ let row = (
1703
+ await this.#exec.query<Registry>(
1704
+ `SELECT log_epoch, epoch_required, last_authenticated_at_ms
1705
+ FROM sync_partition_registry WHERE partition=$1`,
1706
+ [partition],
1707
+ )
1708
+ ).rows[0];
1709
+ if (
1710
+ row === undefined ||
1711
+ authenticatedAtMs - asNumber(row.last_authenticated_at_ms) >=
1712
+ PARTITION_TOUCH_INTERVAL_MS
1713
+ ) {
1714
+ row = (
1715
+ await this.#exec.query<Registry>(
1716
+ `INSERT INTO sync_partition_registry(
1717
+ partition, log_epoch, last_authenticated_at_ms
1718
+ ) VALUES ($1,$2,$3)
1719
+ ON CONFLICT(partition) DO UPDATE SET
1720
+ last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
1721
+ RETURNING log_epoch, epoch_required, last_authenticated_at_ms`,
1722
+ [partition, initialLogEpoch, authenticatedAtMs],
1723
+ )
1724
+ ).rows[0];
1725
+ }
1684
1726
  if (row === undefined)
1685
1727
  throw new Error('partition registry write did not persist');
1686
1728
  return {
@@ -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. */
@@ -1039,9 +1135,10 @@ export function rewritePlan(
1039
1135
  /**
1040
1136
  * Every DDL statement to bring a database from the introspected relational
1041
1137
  * 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.
1138
+ * missing columns, and the secondary-index diff. An existing Syncular-owned
1139
+ * index stays when the declaration still wants its physical name with the
1140
+ * same {@link indexSignature}; every other owned index is dropped and every
1141
+ * missing one is created, so a bump rebuilds only changed or new indexes.
1045
1142
  *
1046
1143
  * The drop set is limited to Syncular-owned indexes: physical names carrying
1047
1144
  * {@link SYNC_INDEX_PREFIX}, plus bare declared names (databases whose
@@ -1053,17 +1150,30 @@ export function schemaDdl(
1053
1150
  schema: CompiledSchema,
1054
1151
  existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>,
1055
1152
  dialect: RelationalDialect,
1056
- existingIndexesByTable: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
1153
+ existingIndexesByTable: ReadonlyMap<
1154
+ string,
1155
+ ReadonlyMap<string, string>
1156
+ > = new Map(),
1057
1157
  ): string[] {
1058
1158
  const drops: string[] = [];
1059
1159
  const creates: string[] = [];
1060
1160
  for (const table of schema.tables.values()) {
1061
1161
  const existing = existingColumnsByTable.get(table.name);
1162
+ const desired = desiredIndexes(table);
1062
1163
  if (existing === undefined) {
1063
1164
  creates.push(createTableDdl(table, dialect));
1064
1165
  } else {
1065
1166
  const declared = new Set(table.indexes.map((index) => index.name));
1066
- for (const indexName of existingIndexesByTable.get(table.name) ?? []) {
1167
+ for (const [indexName, signature] of existingIndexesByTable.get(
1168
+ table.name,
1169
+ ) ?? []) {
1170
+ const want = desired.find((index) => index.name === indexName);
1171
+ if (
1172
+ want !== undefined &&
1173
+ (want.signature === undefined || want.signature === signature)
1174
+ ) {
1175
+ continue;
1176
+ }
1067
1177
  if (
1068
1178
  indexName.startsWith(SYNC_INDEX_PREFIX) ||
1069
1179
  declared.has(indexName)
@@ -1073,7 +1183,7 @@ export function schemaDdl(
1073
1183
  }
1074
1184
  creates.push(...addColumnDdl(table, existing, dialect));
1075
1185
  }
1076
- creates.push(...createIndexDdl(table));
1186
+ creates.push(...desired.map((index) => index.ddl));
1077
1187
  }
1078
1188
  // Every drop precedes every create: an index name moving between tables in
1079
1189
  // 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))),