@sqlite-sync/core 0.6.0 → 0.7.1

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/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CRDT_EVENT_NO_OP_PAYLOAD,
3
+ CrdtEventValidationError,
3
4
  HLCCounter,
4
5
  RESET_REQUEST_TTL_MS,
5
6
  SQLiteDbWrapper,
@@ -21,6 +22,7 @@ import {
21
22
  createSystemDbConfig,
22
23
  deserializeHLC,
23
24
  dummyKysely,
25
+ formatSchemaVerificationIssues,
24
26
  introspectDb,
25
27
  isNoOpCrdtEventPayload,
26
28
  isWorkerErrorResponseMessage,
@@ -31,8 +33,10 @@ import {
31
33
  serializeHLC,
32
34
  startPerformanceLogger,
33
35
  syncDbClientLockName,
36
+ validateNewCrdtEvent,
37
+ verifySyncSchema,
34
38
  xxhash
35
- } from "./chunk-3L4QG3BZ.js";
39
+ } from "./chunk-LBYL5L4O.js";
36
40
  import {
37
41
  TypedBroadcastChannel,
38
42
  TypedEvent,
@@ -46,6 +50,63 @@ import {
46
50
  tryCatchAsync
47
51
  } from "./chunk-NHT3ELMN.js";
48
52
 
53
+ // src/export-import.ts
54
+ function createExportImport({
55
+ reactiveDb,
56
+ crdtStorage,
57
+ tablesConfig,
58
+ schemaVersion
59
+ }) {
60
+ const crdtTableNames = tablesConfig.map((config) => config.crdtTableName);
61
+ const resolveTables = (requested) => {
62
+ if (!requested) {
63
+ return crdtTableNames;
64
+ }
65
+ for (const name of requested) {
66
+ if (!crdtTableNames.includes(name)) {
67
+ throw new Error(`Unknown table "${name}". Known synced tables: ${crdtTableNames.join(", ")}`);
68
+ }
69
+ }
70
+ return requested;
71
+ };
72
+ const exportData = (opts) => {
73
+ const tables = {};
74
+ for (const crdtTableName of resolveTables(opts?.tables)) {
75
+ const { rows } = reactiveDb.db.execute(`select * from ${quoteId(crdtTableName)}`, {
76
+ loggerLevel: "system"
77
+ });
78
+ tables[crdtTableName] = rows.map(({ tombstone: _tombstone, ...rest }) => rest);
79
+ }
80
+ return {
81
+ schemaVersion,
82
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
83
+ tables
84
+ };
85
+ };
86
+ const importData = (data, opts) => {
87
+ const validate = opts?.validate ?? true;
88
+ if (validate && data.schemaVersion !== schemaVersion) {
89
+ throw new Error(
90
+ `Cannot import data from schema version ${data.schemaVersion} into a database at schema version ${schemaVersion}.`
91
+ );
92
+ }
93
+ const events = [];
94
+ for (const [crdtTableName, rows] of Object.entries(data.tables)) {
95
+ for (const row of rows) {
96
+ events.push({
97
+ type: "item-created",
98
+ dataset: crdtTableName,
99
+ item_id: row.id,
100
+ payload: JSON.stringify(row)
101
+ });
102
+ }
103
+ }
104
+ crdtStorage.applyOwnEvents(events);
105
+ return { imported: events.length };
106
+ };
107
+ return { exportData, importData };
108
+ }
109
+
49
110
  // src/memory-db/sqlite-reactive-db.ts
50
111
  import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
51
112
 
@@ -458,45 +519,202 @@ function parametersAreEqual(a, b) {
458
519
  return true;
459
520
  }
460
521
 
461
- // src/sqlite-crdt/crdt-schema.ts
462
- function createSyncDbSchema({ migrations }) {
463
- return new CrdtSchemaBuilder({ tables: [], migrations });
522
+ // src/schema/define-sync-schema.ts
523
+ function defineSyncSchema({
524
+ tables,
525
+ migrations
526
+ }) {
527
+ const tablesConfig = Object.entries(tables).map(([crdtTableName, table]) => ({
528
+ crdtTableName,
529
+ baseTableName: table.baseName ?? `_${crdtTableName}`
530
+ }));
531
+ const seenNames = /* @__PURE__ */ new Set();
532
+ for (const { crdtTableName, baseTableName } of tablesConfig) {
533
+ for (const name of [crdtTableName, baseTableName]) {
534
+ if (seenNames.has(name)) {
535
+ throw new Error(`Duplicate table name "${name}" in sync schema`);
536
+ }
537
+ seenNames.add(name);
538
+ }
539
+ }
540
+ return {
541
+ tables,
542
+ get tablesConfig() {
543
+ return tablesConfig;
544
+ },
545
+ get migrations() {
546
+ return migrations;
547
+ },
548
+ get "~clientSchema"() {
549
+ throw new Error("~clientSchema is type-only and cannot be accessed at runtime");
550
+ },
551
+ get "~serverSchema"() {
552
+ throw new Error("~serverSchema is type-only and cannot be accessed at runtime");
553
+ },
554
+ get "~mutationsSchema"() {
555
+ throw new Error("~mutationsSchema is type-only and cannot be accessed at runtime");
556
+ }
557
+ };
464
558
  }
465
- var CrdtSchemaBuilder = class _CrdtSchemaBuilder {
466
- constructor(config) {
467
- this.config = config;
559
+
560
+ // src/schema/table-builder.ts
561
+ var ColumnBuilder = class _ColumnBuilder {
562
+ constructor(meta) {
563
+ this.meta = meta;
468
564
  }
469
- get tablesConfig() {
470
- return this.config.tables;
565
+ /** Allow SQL NULL; widens the value type with `null`. */
566
+ nullable() {
567
+ return new _ColumnBuilder({ ...this.meta, nullable: true });
471
568
  }
472
- get migrations() {
473
- return this.config.migrations;
569
+ /** Declare a column default. The value is used both in DDL and when migrating older events. */
570
+ default(value) {
571
+ return new _ColumnBuilder({ ...this.meta, hasDefault: true, defaultValue: value });
474
572
  }
475
- get "~clientSchema"() {
476
- console.warn("~clientSchema should not be accessed on the client");
477
- return null;
573
+ /** Narrow the TypeScript value type without changing storage, e.g. `t.text().$type<"movie" | "tv">()`. */
574
+ $type() {
575
+ return this;
478
576
  }
479
- get "~serverSchema"() {
480
- console.warn("~serverSchema should not be accessed on the server");
481
- return null;
577
+ /** Human/AI-readable description, surfaced in generated schema docs. */
578
+ describe(description) {
579
+ return new _ColumnBuilder({ ...this.meta, description });
580
+ }
581
+ };
582
+ var RESERVED_COLUMNS = ["id", "tombstone"];
583
+ var idColumnMeta = {
584
+ kind: "text",
585
+ sqlType: "text",
586
+ nullable: false,
587
+ hasDefault: false,
588
+ description: "Unique immutable item id"
589
+ };
590
+ var tombstoneColumnMeta = {
591
+ kind: "boolean",
592
+ sqlType: "integer",
593
+ nullable: false,
594
+ hasDefault: true,
595
+ defaultValue: false,
596
+ description: "Soft-delete marker"
597
+ };
598
+ var TableBuilder = class _TableBuilder {
599
+ constructor(userColumns, options) {
600
+ this.userColumns = userColumns;
601
+ for (const reserved of RESERVED_COLUMNS) {
602
+ if (reserved in userColumns) {
603
+ throw new Error(`Column "${reserved}" is added automatically to every synced table and cannot be declared`);
604
+ }
605
+ }
606
+ this.columns = {
607
+ id: idColumnMeta,
608
+ ...Object.fromEntries(Object.entries(userColumns).map(([name, column2]) => [name, column2.meta])),
609
+ tombstone: tombstoneColumnMeta
610
+ };
611
+ this.baseName = options?.baseName;
612
+ this.description = options?.description;
613
+ }
614
+ /** All columns in DDL order, including the auto-injected `id` and `tombstone`. */
615
+ columns;
616
+ baseName;
617
+ description;
618
+ describe(description) {
619
+ return new _TableBuilder(this.userColumns, { baseName: this.baseName, description });
482
620
  }
483
- get "~mutationsSchema"() {
484
- console.warn("~mutationsSchema should not be accessed on the client");
621
+ /** Type-only: the row shape returned by queries. Do not access at runtime. */
622
+ get $row() {
485
623
  return null;
486
624
  }
487
- addTable() {
488
- const withConfig = ({
489
- baseTableName,
490
- crdtTableName
491
- }) => {
492
- this.config.tables.push({ baseTableName, crdtTableName });
493
- return new _CrdtSchemaBuilder(this.config);
494
- };
495
- return { withConfig };
625
+ /**
626
+ * Validate a CRDT event payload against the declared columns.
627
+ * `item-created` payloads must contain every column without a default (nullable columns may be omitted);
628
+ * `item-updated` payloads are partial and must not touch `id` or set `tombstone` to a deleted value.
629
+ */
630
+ validatePayload(payload, options) {
631
+ const event = options?.event ?? "item-created";
632
+ const errors = [];
633
+ for (const key of Object.keys(payload)) {
634
+ if (!(key in this.columns)) {
635
+ errors.push(`Unknown column "${key}"`);
636
+ }
637
+ }
638
+ if (event === "item-updated") {
639
+ if ("id" in payload) {
640
+ errors.push(`Column "id" is immutable and cannot appear in an update payload`);
641
+ }
642
+ if (payload.tombstone === true || payload.tombstone === 1) {
643
+ errors.push(`Column "tombstone" cannot be set to true/1 in an update payload; use an item-deleted event`);
644
+ }
645
+ }
646
+ if (event === "item-created") {
647
+ for (const [name, meta] of Object.entries(this.columns)) {
648
+ if (name in payload || name === "tombstone" || meta.nullable || meta.hasDefault) {
649
+ continue;
650
+ }
651
+ errors.push(`Missing required column "${name}"`);
652
+ }
653
+ }
654
+ for (const [name, value] of Object.entries(payload)) {
655
+ const meta = this.columns[name];
656
+ if (!meta || event === "item-updated" && name === "id") {
657
+ continue;
658
+ }
659
+ const error = validateValue(name, value, meta);
660
+ if (error) {
661
+ errors.push(error);
662
+ }
663
+ }
664
+ return errors.length === 0 ? { success: true } : { success: false, errors };
496
665
  }
497
- build() {
498
- return this;
666
+ };
667
+ function describeValue(value) {
668
+ if (value === null) return "null";
669
+ if (Array.isArray(value)) return "array";
670
+ return typeof value;
671
+ }
672
+ function validateValue(name, value, meta) {
673
+ if (value === null) {
674
+ return meta.nullable ? null : `Column "${name}" is not nullable, got null`;
675
+ }
676
+ switch (meta.kind) {
677
+ case "text":
678
+ return typeof value === "string" ? null : `Column "${name}" expects text, got ${describeValue(value)}`;
679
+ case "integer":
680
+ return typeof value === "number" && Number.isInteger(value) ? null : `Column "${name}" expects an integer, got ${describeValue(value)}`;
681
+ case "real":
682
+ return typeof value === "number" && Number.isFinite(value) ? null : `Column "${name}" expects a number, got ${describeValue(value)}`;
683
+ case "boolean":
684
+ return typeof value === "boolean" || value === 0 || value === 1 ? null : `Column "${name}" expects a boolean (true/false or 0/1), got ${describeValue(value)}`;
685
+ case "enum": {
686
+ if (typeof value !== "string") {
687
+ return `Column "${name}" expects one of ${formatEnumValues(meta)}, got ${describeValue(value)}`;
688
+ }
689
+ return meta.enumValues?.includes(value) ? null : `Column "${name}" expects one of ${formatEnumValues(meta)}, got "${value}"`;
690
+ }
499
691
  }
692
+ }
693
+ function formatEnumValues(meta) {
694
+ return (meta.enumValues ?? []).map((value) => `"${value}"`).join(" | ");
695
+ }
696
+ function column(kind, sqlType) {
697
+ return new ColumnBuilder({ kind, sqlType, nullable: false, hasDefault: false });
698
+ }
699
+ var t = {
700
+ text: () => column("text", "text"),
701
+ integer: () => column("integer", "integer"),
702
+ real: () => column("real", "real"),
703
+ /** Stored as INTEGER 0/1; always bind booleans as 0/1 when writing raw SQL. */
704
+ boolean: () => column("boolean", "integer"),
705
+ /**
706
+ * Union of string literals stored as TEXT, e.g. `t.enum(["movie", "tv"])`. Unlike `$type`, the
707
+ * allowed values are kept as runtime metadata, so payloads are validated against them.
708
+ * For an open-ended set, use `t.text().$type<"idle" | (string & {})>()` instead.
709
+ */
710
+ enum: (values) => new ColumnBuilder({
711
+ kind: "enum",
712
+ sqlType: "text",
713
+ nullable: false,
714
+ hasDefault: false,
715
+ enumValues: values
716
+ }),
717
+ table: (columns, options) => new TableBuilder(columns, options)
500
718
  };
501
719
 
502
720
  // src/sqlite-crdt/crdt-storage-mutator.ts
@@ -583,7 +801,7 @@ select * from ${quoteId(baseTableName)}
583
801
  where tombstone = 0;`,
584
802
  { loggerLevel: "system" }
585
803
  );
586
- const allColumnNames = tableSchema.columns.map((column) => column.name);
804
+ const allColumnNames = tableSchema.columns.map((column2) => column2.name);
587
805
  const jsonPayload = (from) => `'{'||${allColumnNames.map((col) => `'"${col}":'||json_quote(${from}.${quoteId(col)})`).join("||','||")}||'}'`;
588
806
  db.execute(
589
807
  `
@@ -636,17 +854,12 @@ function registerCrdtFunctions({
636
854
  innocuous: false,
637
855
  callback: (dataset, payloadRaw) => {
638
856
  const payload = JSON.parse(payloadRaw);
639
- storage.applyOwnEvent(
640
- {
641
- type: "item-created",
642
- dataset,
643
- item_id: payload.id,
644
- payload: payloadRaw
645
- },
646
- {
647
- wrapInTransaction: false
648
- }
649
- );
857
+ storage.internal.applyOwnEventFromTransaction(reactiveDb.db, {
858
+ type: "item-created",
859
+ dataset,
860
+ item_id: payload.id,
861
+ payload: payloadRaw
862
+ });
650
863
  eventApplied = true;
651
864
  return void 0;
652
865
  }
@@ -668,34 +881,29 @@ function registerCrdtFunctions({
668
881
  const newPayload = JSON.parse(newPayloadRaw);
669
882
  let hasDiff = false;
670
883
  const updatePayload = {};
671
- for (const column of tableSchema.columns) {
672
- const oldValue = oldPayload[column.name];
673
- const newValue = newPayload[column.name];
884
+ for (const column2 of tableSchema.columns) {
885
+ const oldValue = oldPayload[column2.name];
886
+ const newValue = newPayload[column2.name];
674
887
  if (oldValue === newValue) {
675
888
  continue;
676
889
  }
677
- if (column.name === "id") {
890
+ if (column2.name === "id") {
678
891
  throw new Error(
679
892
  `Cannot update the "id" column of an item. It is used to identify the item and must be immutable.`
680
893
  );
681
894
  }
682
895
  hasDiff = true;
683
- updatePayload[column.name] = newValue;
896
+ updatePayload[column2.name] = newValue;
684
897
  }
685
898
  if (!hasDiff) {
686
899
  return;
687
900
  }
688
- storage.applyOwnEvent(
689
- {
690
- type: "item-updated",
691
- dataset,
692
- item_id: oldPayload.id,
693
- payload: JSON.stringify(updatePayload)
694
- },
695
- {
696
- wrapInTransaction: false
697
- }
698
- );
901
+ storage.internal.applyOwnEventFromTransaction(reactiveDb.db, {
902
+ type: "item-updated",
903
+ dataset,
904
+ item_id: oldPayload.id,
905
+ payload: JSON.stringify(updatePayload)
906
+ });
699
907
  eventApplied = true;
700
908
  return void 0;
701
909
  }
@@ -706,17 +914,12 @@ function registerCrdtFunctions({
706
914
  directOnly: false,
707
915
  innocuous: false,
708
916
  callback: (dataset, itemId) => {
709
- storage.applyOwnEvent(
710
- {
711
- type: "item-deleted",
712
- dataset,
713
- item_id: itemId,
714
- payload: "{}"
715
- },
716
- {
717
- wrapInTransaction: false
718
- }
719
- );
917
+ storage.internal.applyOwnEventFromTransaction(reactiveDb.db, {
918
+ type: "item-deleted",
919
+ dataset,
920
+ item_id: itemId,
921
+ payload: "{}"
922
+ });
720
923
  eventApplied = true;
721
924
  return void 0;
722
925
  }
@@ -724,7 +927,7 @@ function registerCrdtFunctions({
724
927
  reactiveDb.addEventListener("transaction-committed", () => {
725
928
  if (eventApplied) {
726
929
  eventApplied = false;
727
- storage.dispatchEventsApplied();
930
+ void storage.internal.processEnqueuedEvents();
728
931
  }
729
932
  });
730
933
  reactiveDb.addEventListener("transaction-rolled-back", () => {
@@ -799,7 +1002,8 @@ async function createMemoryDb({
799
1002
  crdtTables,
800
1003
  initializeSchema = true,
801
1004
  initialSyncId,
802
- eventHlcAccumulator
1005
+ eventHlcAccumulator,
1006
+ syncDbSchema
803
1007
  }) {
804
1008
  await xxhash.ensureLoaded();
805
1009
  const reactiveDb = _reactiveDb;
@@ -821,7 +1025,8 @@ async function createMemoryDb({
821
1025
  migrator,
822
1026
  db,
823
1027
  dbConfig: memoryDbConfig,
824
- eventHlcAccumulator
1028
+ eventHlcAccumulator,
1029
+ schema: syncDbSchema
825
1030
  });
826
1031
  registerCrdtFunctions({
827
1032
  reactiveDb,
@@ -1004,7 +1209,8 @@ async function createSyncedDb(options) {
1004
1209
  migrator: memoryDbMigrator,
1005
1210
  reactiveDb,
1006
1211
  hlcCounter,
1007
- crdtTables: options.syncDbSchema.tablesConfig
1212
+ crdtTables: options.syncDbSchema.tablesConfig,
1213
+ syncDbSchema: options.syncDbSchema
1008
1214
  });
1009
1215
  const pullSyncId = createStoredValue({
1010
1216
  initialValue: workerClientSnapshot.syncId
@@ -1037,6 +1243,12 @@ async function createSyncedDb(options) {
1037
1243
  globalThis.location?.reload();
1038
1244
  });
1039
1245
  perf.logEnd("createSyncedDb", "initialized", "info");
1246
+ const { exportData, importData } = createExportImport({
1247
+ reactiveDb,
1248
+ crdtStorage,
1249
+ tablesConfig: options.syncDbSchema.tablesConfig,
1250
+ schemaVersion: workerClientSnapshot.schemaVersion
1251
+ });
1040
1252
  let isDisposed = false;
1041
1253
  let unregisterDevtools;
1042
1254
  const dispose = async () => {
@@ -1084,6 +1296,8 @@ async function createSyncedDb(options) {
1084
1296
  setTimeout(() => globalThis.location?.reload(), 250);
1085
1297
  },
1086
1298
  subscribe: workerClient.subscribe,
1299
+ exportData,
1300
+ importData,
1087
1301
  dispose,
1088
1302
  _internal: {
1089
1303
  executeAsync: workerClient.execute.bind(workerClient),
@@ -1105,10 +1319,13 @@ async function createSyncedDb(options) {
1105
1319
  }
1106
1320
  export {
1107
1321
  CRDT_EVENT_NO_OP_PAYLOAD,
1322
+ ColumnBuilder,
1323
+ CrdtEventValidationError,
1108
1324
  HLCCounter,
1109
1325
  RESET_REQUEST_TTL_MS,
1110
1326
  SQLiteDbWrapper,
1111
1327
  SQLiteReactiveDb,
1328
+ TableBuilder,
1112
1329
  TypedBroadcastChannel,
1113
1330
  TypedEvent,
1114
1331
  applyMemoryDbSchema,
@@ -1121,18 +1338,20 @@ export {
1121
1338
  createCrdtSyncProducer,
1122
1339
  createCrdtSyncRemoteSource,
1123
1340
  createDeferredPromise,
1341
+ createExportImport,
1124
1342
  createKvStoreTableQuery,
1125
1343
  createMigrations,
1126
1344
  createMigrator,
1127
1345
  createSQLiteCrdtApplyFunction,
1128
1346
  createSQLiteKvStore,
1129
1347
  createStoredValue,
1130
- createSyncDbSchema,
1131
1348
  createSyncedDb,
1132
1349
  createSystemDbConfig,
1133
1350
  createTypedEventTarget,
1351
+ defineSyncSchema,
1134
1352
  deserializeHLC,
1135
1353
  dummyKysely,
1354
+ formatSchemaVerificationIssues,
1136
1355
  generateId,
1137
1356
  introspectDb,
1138
1357
  isNoOpCrdtEventPayload,
@@ -1143,8 +1362,11 @@ export {
1143
1362
  runSystemMigrations,
1144
1363
  serializeHLC,
1145
1364
  startPerformanceLogger,
1365
+ t,
1146
1366
  tryCatch,
1147
1367
  tryCatchAsync,
1368
+ validateNewCrdtEvent,
1369
+ verifySyncSchema,
1148
1370
  xxhash
1149
1371
  };
1150
1372
  //# sourceMappingURL=index.js.map