@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/dist/content-encoding.d.ts +24 -23
- package/dist/content-encoding.js +77 -22
- package/dist/d1-storage.js +24 -19
- package/dist/postgres-storage.d.ts +1 -1
- package/dist/postgres-storage.js +240 -74
- package/dist/pull.d.ts +7 -3
- package/dist/pull.js +21 -12
- package/dist/relational-rows.d.ts +49 -23
- package/dist/relational-rows.js +133 -59
- 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 +36 -21
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +3 -0
- package/package.json +2 -2
- package/src/content-encoding.ts +99 -31
- package/src/d1-storage.ts +36 -22
- package/src/postgres-storage.ts +338 -109
- package/src/pull.ts +31 -18
- package/src/relational-rows.ts +182 -66
- 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 +64 -34
- package/src/storage.ts +9 -1
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
|
|
488
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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(
|
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. */
|
|
@@ -470,26 +566,31 @@ export function upsertValues(
|
|
|
470
566
|
];
|
|
471
567
|
}
|
|
472
568
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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 =
|
|
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
|
|
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
|
|
650
|
-
* of `{i, tbl, var, vals, after, lim}` records; each record runs the
|
|
651
|
-
* index-first candidate subquery through a LATERAL join, so every
|
|
652
|
-
* subscription keeps its own keyset cursor and LIMIT.
|
|
653
|
-
*
|
|
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(
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
)
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
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<
|
|
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(
|
|
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(...
|
|
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,
|
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))),
|