@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/dist/d1-storage.js +23 -18
- package/dist/postgres-storage.js +31 -11
- package/dist/relational-rows.d.ts +33 -13
- package/dist/relational-rows.js +85 -17
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +22 -0
- package/dist/sqlite-bun-driver.js +1 -1
- package/dist/sqlite-node-driver.d.ts +1 -0
- package/dist/sqlite-node-driver.js +13 -3
- package/dist/sqlite-storage.js +20 -18
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +3 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +35 -21
- package/src/postgres-storage.ts +58 -16
- package/src/relational-rows.ts +129 -19
- package/src/schema.ts +55 -0
- package/src/sqlite-bun-driver.ts +1 -1
- package/src/sqlite-node-driver.ts +18 -3
- package/src/sqlite-storage.ts +44 -31
- package/src/storage.ts +9 -1
package/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(
|
|
1273
|
-
.
|
|
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,
|
|
1320
|
+
Object.entries(state.indexes).map(([name, signatures]) => [
|
|
1318
1321
|
name,
|
|
1319
|
-
new
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/postgres-storage.ts
CHANGED
|
@@ -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,
|
|
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<{
|
|
1572
|
-
|
|
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
|
|
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
|
-
|
|
1697
|
+
type Registry = {
|
|
1671
1698
|
log_epoch: string;
|
|
1672
1699
|
epoch_required: boolean;
|
|
1673
1700
|
last_authenticated_at_ms: unknown;
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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 {
|
package/src/relational-rows.ts
CHANGED
|
@@ -53,7 +53,12 @@ import {
|
|
|
53
53
|
type RowColumn,
|
|
54
54
|
type RowValue,
|
|
55
55
|
} from '@syncular/core';
|
|
56
|
-
import type {
|
|
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
|
-
*
|
|
360
|
-
*
|
|
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
|
-
*
|
|
392
|
-
* the
|
|
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
|
-
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
.
|
|
405
|
-
|
|
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
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
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<
|
|
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(
|
|
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(...
|
|
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,
|
package/src/sqlite-bun-driver.ts
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
|
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
|
|
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))),
|