@monlite/core 0.6.0 → 0.8.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/index.js CHANGED
@@ -17,6 +17,91 @@ function isObjectId(value) {
17
17
  return typeof value === "string" && /^[0-9a-f]{24}$/i.test(value);
18
18
  }
19
19
 
20
+ // src/reactive.ts
21
+ var RELEVANCE_PROBE_LIMIT = 500;
22
+ var LiveQuery = class {
23
+ constructor(source, args, cb) {
24
+ this.source = source;
25
+ this.args = args;
26
+ this.cb = cb;
27
+ this.recompute("init");
28
+ }
29
+ source;
30
+ args;
31
+ cb;
32
+ results = [];
33
+ stopped = false;
34
+ ids = /* @__PURE__ */ new Set();
35
+ /** Called by the Reactor with the ids that changed this tick. */
36
+ notify(changedIds) {
37
+ if (this.stopped) return;
38
+ if (!this.isRelevant(changedIds)) return;
39
+ this.recompute("change", changedIds);
40
+ }
41
+ isRelevant(changedIds) {
42
+ for (const id of changedIds) {
43
+ if (this.ids.has(id)) return true;
44
+ }
45
+ if (changedIds.size > RELEVANCE_PROBE_LIMIT) return true;
46
+ const idIn = {
47
+ _id: { in: [...changedIds] }
48
+ };
49
+ const where = this.args.where ? { AND: [this.args.where, idIn] } : idIn;
50
+ return this.source.existsCore(where);
51
+ }
52
+ recompute(type, changedIds) {
53
+ const next = this.source.findManyCore(this.args);
54
+ const nextById = new Map(next.map((d) => [d._id, d]));
55
+ const added = next.filter((d) => !this.ids.has(d._id));
56
+ const removed = this.results.filter((d) => !nextById.has(d._id));
57
+ const changed = changedIds ? next.filter((d) => this.ids.has(d._id) && changedIds.has(d._id)) : [];
58
+ this.results = next;
59
+ this.ids = new Set(nextById.keys());
60
+ this.cb({ type, results: next, added, removed, changed });
61
+ }
62
+ };
63
+ var Reactor = class {
64
+ byCollection = /* @__PURE__ */ new Map();
65
+ pending = /* @__PURE__ */ new Map();
66
+ flushScheduled = false;
67
+ hasWatchers(collection) {
68
+ return this.byCollection.has(collection);
69
+ }
70
+ register(collection, lq) {
71
+ let set = this.byCollection.get(collection);
72
+ if (!set) this.byCollection.set(collection, set = /* @__PURE__ */ new Set());
73
+ set.add(lq);
74
+ }
75
+ unregister(collection, lq) {
76
+ const set = this.byCollection.get(collection);
77
+ if (!set) return;
78
+ set.delete(lq);
79
+ if (set.size === 0) this.byCollection.delete(collection);
80
+ }
81
+ /** Record that documents changed; schedule a notification flush. */
82
+ emit(collection, ids) {
83
+ const set = this.byCollection.get(collection);
84
+ if (!set || set.size === 0 || ids.length === 0) return;
85
+ let p = this.pending.get(collection);
86
+ if (!p) this.pending.set(collection, p = /* @__PURE__ */ new Set());
87
+ for (const id of ids) p.add(id);
88
+ if (!this.flushScheduled) {
89
+ this.flushScheduled = true;
90
+ queueMicrotask(() => this.flush());
91
+ }
92
+ }
93
+ flush() {
94
+ this.flushScheduled = false;
95
+ const work = [...this.pending];
96
+ this.pending.clear();
97
+ for (const [collection, ids] of work) {
98
+ const set = this.byCollection.get(collection);
99
+ if (!set) continue;
100
+ for (const lq of [...set]) lq.notify(ids);
101
+ }
102
+ }
103
+ };
104
+
20
105
  // src/errors.ts
21
106
  var MonliteError = class extends Error {
22
107
  constructor(message, options) {
@@ -236,9 +321,7 @@ function cmp(expr, op, v, ctx) {
236
321
  }
237
322
  function inExpr(expr, arr, ctx, negate) {
238
323
  if (!Array.isArray(arr)) {
239
- throw new MonliteQueryError(
240
- `${negate ? "notIn" : "in"} expects an array`
241
- );
324
+ throw new MonliteQueryError(`${negate ? "notIn" : "in"} expects an array`);
242
325
  }
243
326
  if (arr.length === 0) return negate ? "1" : "0";
244
327
  const placeholders = arr.map((v) => {
@@ -368,7 +451,8 @@ function applyUpdate(doc, data) {
368
451
  }
369
452
  const ops = data;
370
453
  if (ops.$set) {
371
- for (const [path, value] of Object.entries(ops.$set)) setPath(next, path, value);
454
+ for (const [path, value] of Object.entries(ops.$set))
455
+ setPath(next, path, value);
372
456
  }
373
457
  if (ops.$inc) {
374
458
  for (const [path, by] of Object.entries(ops.$inc)) {
@@ -489,7 +573,11 @@ function buildHaving(having, params, columns) {
489
573
  if (!selection) continue;
490
574
  for (const field of Object.keys(selection)) {
491
575
  parts.push(
492
- ...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
576
+ ...comparisonSql(
577
+ `${fn}(${fieldExpr(field, columns)})`,
578
+ selection[field],
579
+ params
580
+ )
493
581
  );
494
582
  }
495
583
  }
@@ -517,7 +605,11 @@ function groupBy(ctx, args) {
517
605
  groupCols.push({ alias, field });
518
606
  });
519
607
  selects.push(`COUNT(*) AS agg_count`);
520
- const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
608
+ const { selects: accSelects, cols } = buildAccumulators(
609
+ args,
610
+ ctx.onPath,
611
+ ctx.columns
612
+ );
521
613
  selects.push(...accSelects);
522
614
  let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
523
615
  if (args.having) {
@@ -595,6 +687,7 @@ var Collection = class {
595
687
  this.columns.add(field);
596
688
  if (normalized.type === "JSON") this.jsonColumns.add(field);
597
689
  }
690
+ this.ensureTable();
598
691
  }
599
692
  }
600
693
  mon;
@@ -639,22 +732,15 @@ var Collection = class {
639
732
  `_id TEXT PRIMARY KEY`,
640
733
  `created_at INTEGER NOT NULL`,
641
734
  `updated_at INTEGER NOT NULL`,
642
- `data TEXT NOT NULL DEFAULT '{}'`
735
+ `data TEXT NOT NULL DEFAULT '{}'`,
736
+ ...this.columnOrder.map((f) => this.columnDdl(f, false))
643
737
  ];
644
- for (const field of this.columnOrder) {
645
- const def = this.columnDefs[field];
646
- let line = `"${field}" ${sqliteType(def.type)}`;
647
- if (def.notNull) line += " NOT NULL";
648
- if (def.unique) line += " UNIQUE";
649
- if (def.default !== void 0) line += ` DEFAULT ${formatDefault(def.default)}`;
650
- if (def.references) line += ` REFERENCES ${def.references}`;
651
- lines.push(line);
652
- }
653
738
  this.db.exec(
654
739
  `CREATE TABLE IF NOT EXISTS "${this.name}" (
655
740
  ${lines.join(",\n ")}
656
741
  )`
657
742
  );
743
+ this.migrateColumns();
658
744
  for (const field of this.columnOrder) {
659
745
  if (this.columnDefs[field].index) {
660
746
  this.db.exec(
@@ -665,6 +751,39 @@ var Collection = class {
665
751
  }
666
752
  this.initialized = true;
667
753
  }
754
+ columnDdl(field, forAlter) {
755
+ const def = this.columnDefs[field];
756
+ let line = `"${field}" ${sqliteType(def.type)}`;
757
+ if (def.notNull) line += " NOT NULL";
758
+ if (def.unique && !forAlter) line += " UNIQUE";
759
+ if (def.default !== void 0)
760
+ line += ` DEFAULT ${formatDefault(def.default)}`;
761
+ if (def.references) line += ` REFERENCES ${def.references}`;
762
+ return line;
763
+ }
764
+ /** Auto-additive migration: add declared columns missing from an existing table. */
765
+ migrateColumns() {
766
+ const existing = new Set(
767
+ this.db.prepare(`PRAGMA table_info("${this.name}")`).all().map((r) => r.name)
768
+ );
769
+ for (const field of this.columnOrder) {
770
+ if (existing.has(field)) continue;
771
+ try {
772
+ this.db.exec(
773
+ `ALTER TABLE "${this.name}" ADD COLUMN ${this.columnDdl(field, true)}`
774
+ );
775
+ } catch (err) {
776
+ throw new MonliteError(
777
+ `Failed to add column "${field}" to "${this.name}": ${err.message}. NOT NULL columns need a default when added to an existing table.`
778
+ );
779
+ }
780
+ if (this.columnDefs[field].unique) {
781
+ this.db.exec(
782
+ `CREATE UNIQUE INDEX IF NOT EXISTS "uq_${this.name}_${field}" ON "${this.name}"("${field}")`
783
+ );
784
+ }
785
+ }
786
+ }
668
787
  /* --------------------------- row <-> doc -------------------------- */
669
788
  rowToDoc(row) {
670
789
  const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
@@ -710,7 +829,12 @@ var Collection = class {
710
829
  const now = Date.now();
711
830
  const id = input._id != null ? String(input._id) : objectId();
712
831
  const doc = stripSystem(input);
713
- const returned = { ...doc, _id: id, created_at: now, updated_at: now };
832
+ const returned = {
833
+ ...doc,
834
+ _id: id,
835
+ created_at: now,
836
+ updated_at: now
837
+ };
714
838
  if (this.mode === "document") {
715
839
  return {
716
840
  _id: id,
@@ -762,9 +886,72 @@ var Collection = class {
762
886
  ];
763
887
  return { setSql: setParts.join(", "), values };
764
888
  }
765
- /** Sync store, but only for document collections (structured sync is future work). */
889
+ /** Sync store for recording local changes (both document and structured). */
766
890
  get recorder() {
767
- return this.mode === "document" ? this.mon.$sync : void 0;
891
+ return this.mon.$sync;
892
+ }
893
+ /** @internal Read a full document by id (mode-aware), synchronously. */
894
+ getRaw(id) {
895
+ this.ensureTable();
896
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
897
+ return row ? this.rowToDoc(row) : null;
898
+ }
899
+ /**
900
+ * @internal Apply a remote change to storage WITHOUT recording it to the
901
+ * change feed (the sync store records the `remote` feed row itself). Used by
902
+ * `@monlite/sync` so structured collections sync correctly through the same
903
+ * column/overflow split as local writes.
904
+ */
905
+ applyRemoteWrite(op, id, doc, ts) {
906
+ this.ensureTable();
907
+ if (op === "delete") {
908
+ this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`).run(id);
909
+ this.afterWrite([id]);
910
+ return;
911
+ }
912
+ const clean = stripSystem(doc ?? {});
913
+ const createdAt = typeof doc?.created_at === "number" ? doc.created_at : ts;
914
+ if (this.mode === "document") {
915
+ this.db.prepare(
916
+ `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
917
+ ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
918
+ ).run(id, JSON.stringify(clean), createdAt, ts);
919
+ this.afterWrite([id]);
920
+ return;
921
+ }
922
+ const overflow = {};
923
+ const colValues = {};
924
+ for (const [k, v] of Object.entries(clean)) {
925
+ if (this.columns.has(k)) colValues[k] = v;
926
+ else overflow[k] = v;
927
+ }
928
+ const cols = [
929
+ "_id",
930
+ "created_at",
931
+ "updated_at",
932
+ "data",
933
+ ...this.columnOrder
934
+ ];
935
+ const values = [
936
+ id,
937
+ createdAt,
938
+ ts,
939
+ JSON.stringify(overflow),
940
+ ...this.columnOrder.map(
941
+ (c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
942
+ )
943
+ ];
944
+ const colList = cols.map((c) => `"${c}"`).join(", ");
945
+ const placeholders = cols.map(() => "?").join(", ");
946
+ const updateSet = cols.filter((c) => c !== "_id" && c !== "created_at").map((c) => `"${c}" = excluded."${c}"`).join(", ");
947
+ this.db.prepare(
948
+ `INSERT INTO "${this.name}" (${colList}) VALUES (${placeholders}) ON CONFLICT(_id) DO UPDATE SET ${updateSet}`
949
+ ).run(...values);
950
+ this.afterWrite([id]);
951
+ }
952
+ /** @internal Notify reactivity watchers that documents changed. */
953
+ afterWrite(ids) {
954
+ this.mon.reactor.emit(this.name, ids);
768
955
  }
769
956
  /* ----------------------------- create ----------------------------- */
770
957
  async create(args) {
@@ -776,26 +963,29 @@ var Collection = class {
776
963
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
777
964
  };
778
965
  this.guard(() => recorder ? this.db.transaction(write) : write());
966
+ this.afterWrite([row._id]);
779
967
  return row.returned;
780
968
  }
781
969
  async createMany(args) {
782
970
  this.ensureTable();
783
971
  const stmt = this.db.prepare(this.insertSql());
784
972
  const recorder = this.recorder;
973
+ const ids = [];
785
974
  this.guard(
786
975
  () => this.db.transaction(() => {
787
976
  for (const item of args.data) {
788
977
  const row = this.buildInsert(item);
789
978
  stmt.run(...row.values);
790
979
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
980
+ ids.push(row._id);
791
981
  }
792
982
  })
793
983
  );
984
+ this.afterWrite(ids);
794
985
  return { count: args.data.length };
795
986
  }
796
987
  /* ------------------------------ read ------------------------------ */
797
- async findMany(args = {}) {
798
- this.ensureTable();
988
+ buildFindSql(args) {
799
989
  const params = [];
800
990
  const where = buildWhere(args.where, {
801
991
  params,
@@ -813,9 +1003,29 @@ var Collection = class {
813
1003
  sql += (args.take != null ? "" : " LIMIT -1") + " OFFSET ?";
814
1004
  params.push(args.skip);
815
1005
  }
1006
+ return { sql, params };
1007
+ }
1008
+ /** @internal Synchronous core of findMany (used by reactivity). */
1009
+ findManyCore(args = {}) {
1010
+ this.ensureTable();
1011
+ const { sql, params } = this.buildFindSql(args);
816
1012
  const rows = this.db.prepare(sql).all(...params);
817
1013
  return rows.map((r) => project(this.rowToDoc(r), args.select));
818
1014
  }
1015
+ /** @internal Synchronous core of exists (used by reactivity). */
1016
+ existsCore(where) {
1017
+ this.ensureTable();
1018
+ const params = [];
1019
+ const clause = buildWhere(where, {
1020
+ params,
1021
+ onPath: this.trackPath,
1022
+ columns: this.columns
1023
+ });
1024
+ return this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params) != null;
1025
+ }
1026
+ async findMany(args = {}) {
1027
+ return this.findManyCore(args);
1028
+ }
819
1029
  async findFirst(args = {}) {
820
1030
  const rows = await this.findMany({ ...args, take: 1 });
821
1031
  return rows[0] ?? null;
@@ -832,15 +1042,39 @@ var Collection = class {
832
1042
  }
833
1043
  /** True if at least one document matches. */
834
1044
  async exists(where) {
1045
+ return this.existsCore(where);
1046
+ }
1047
+ /**
1048
+ * Subscribe to a live query. The callback fires immediately with the current
1049
+ * results (`type: "init"`) and again whenever a change affects the result set
1050
+ * (row-level: only relevant changes trigger a recompute). Includes changes
1051
+ * applied by `@monlite/sync`.
1052
+ */
1053
+ watch(args = {}, cb) {
835
1054
  this.ensureTable();
836
- const params = [];
837
- const clause = buildWhere(where, {
838
- params,
839
- onPath: this.trackPath,
840
- columns: this.columns
841
- });
842
- const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
843
- return row != null;
1055
+ const lq = new LiveQuery(this, args, cb);
1056
+ const reactor = this.mon.reactor;
1057
+ const name = this.name;
1058
+ reactor.register(name, lq);
1059
+ return {
1060
+ get results() {
1061
+ return lq.results;
1062
+ },
1063
+ stop() {
1064
+ lq.stopped = true;
1065
+ reactor.unregister(name, lq);
1066
+ }
1067
+ };
1068
+ }
1069
+ /** Show SQLite's query plan for a `findMany`, and whether it uses an index. */
1070
+ async explain(args = {}) {
1071
+ this.ensureTable();
1072
+ const { sql, params } = this.buildFindSql(args);
1073
+ const plan = this.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params);
1074
+ const usesIndex = plan.some(
1075
+ (r) => /USING (COVERING )?INDEX/i.test(r.detail)
1076
+ );
1077
+ return { sql, usesIndex, plan };
844
1078
  }
845
1079
  async findById(id) {
846
1080
  this.ensureTable();
@@ -897,25 +1131,27 @@ var Collection = class {
897
1131
  if (!rows.length) return [];
898
1132
  const now = Date.now();
899
1133
  const recorder = this.recorder;
900
- return this.guard(
1134
+ const out = this.guard(
901
1135
  () => this.db.transaction(() => {
902
- const out = [];
1136
+ const result = [];
903
1137
  for (const row of rows) {
904
1138
  const current = stripSystem(this.rowToDoc(row));
905
1139
  const updated = stripSystem(applyUpdate(current, data));
906
1140
  const { setSql, values } = this.buildUpdateSet(updated, now);
907
1141
  this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
908
1142
  recorder?.recordLocal(this.name, row._id, "upsert", now);
909
- out.push({
1143
+ result.push({
910
1144
  ...updated,
911
1145
  _id: row._id,
912
1146
  created_at: row.created_at,
913
1147
  updated_at: now
914
1148
  });
915
1149
  }
916
- return out;
1150
+ return result;
917
1151
  })
918
1152
  );
1153
+ this.afterWrite(out.map((d) => d._id));
1154
+ return out;
919
1155
  }
920
1156
  async update(args) {
921
1157
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -925,7 +1161,7 @@ var Collection = class {
925
1161
  }
926
1162
  async upsert(args) {
927
1163
  this.ensureTable();
928
- return this.guard(
1164
+ const result = this.guard(
929
1165
  () => this.db.transaction(() => {
930
1166
  const params = [];
931
1167
  const clause = buildWhere(args.where, {
@@ -955,6 +1191,8 @@ var Collection = class {
955
1191
  return ins.returned;
956
1192
  })
957
1193
  );
1194
+ this.afterWrite([result._id]);
1195
+ return result;
958
1196
  }
959
1197
  /* ----------------------------- delete ----------------------------- */
960
1198
  runDelete(where, single) {
@@ -980,6 +1218,7 @@ var Collection = class {
980
1218
  }
981
1219
  })
982
1220
  );
1221
+ this.afterWrite(rows.map((r) => r._id));
983
1222
  return rows.map((r) => this.rowToDoc(r));
984
1223
  }
985
1224
  async delete(args) {
@@ -993,7 +1232,12 @@ var Collection = class {
993
1232
  this.ensureTable();
994
1233
  return this.guard(
995
1234
  () => aggregate(
996
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1235
+ {
1236
+ db: this.db,
1237
+ table: this.name,
1238
+ onPath: this.trackPath,
1239
+ columns: this.columns
1240
+ },
997
1241
  args
998
1242
  )
999
1243
  );
@@ -1002,7 +1246,12 @@ var Collection = class {
1002
1246
  this.ensureTable();
1003
1247
  return this.guard(
1004
1248
  () => groupBy(
1005
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1249
+ {
1250
+ db: this.db,
1251
+ table: this.name,
1252
+ onPath: this.trackPath,
1253
+ columns: this.columns
1254
+ },
1006
1255
  args
1007
1256
  )
1008
1257
  );
@@ -1250,12 +1499,14 @@ function stripSystem2(obj) {
1250
1499
  return rest;
1251
1500
  }
1252
1501
  var SyncStore = class {
1253
- constructor(db, nodeId) {
1502
+ constructor(db, nodeId, mon) {
1254
1503
  this.db = db;
1504
+ this.mon = mon;
1255
1505
  this.init();
1256
1506
  this.nodeId = this.resolveNodeId(nodeId);
1257
1507
  }
1258
1508
  db;
1509
+ mon;
1259
1510
  nodeId;
1260
1511
  versionSeq = 0;
1261
1512
  init() {
@@ -1290,7 +1541,9 @@ var SyncStore = class {
1290
1541
  }
1291
1542
  resolveNodeId(explicit) {
1292
1543
  if (explicit) {
1293
- this.db.prepare(`INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(explicit);
1544
+ this.db.prepare(
1545
+ `INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`
1546
+ ).run(explicit);
1294
1547
  return explicit;
1295
1548
  }
1296
1549
  const row = this.db.prepare(`SELECT value FROM _monlite_meta WHERE key = 'nodeId'`).get();
@@ -1405,6 +1658,9 @@ var SyncStore = class {
1405
1658
  );
1406
1659
  }
1407
1660
  if (winner !== "remote") {
1661
+ if (localVersion !== null) {
1662
+ this.recordLocal(change.collection, change._id, "upsert", Date.now());
1663
+ }
1408
1664
  return { applied: false, conflict: localVersion !== null, winner };
1409
1665
  }
1410
1666
  this.applyData(change);
@@ -1418,24 +1674,31 @@ var SyncStore = class {
1418
1674
  change.version,
1419
1675
  versionTs(change.version)
1420
1676
  );
1421
- return { applied: true, conflict: localVersion !== null, winner: "remote" };
1677
+ return {
1678
+ applied: true,
1679
+ conflict: localVersion !== null,
1680
+ winner: "remote"
1681
+ };
1422
1682
  });
1423
1683
  }
1424
1684
  applyData(change) {
1425
- const { collection: coll, _id, op } = change;
1685
+ const ts = versionTs(change.version);
1686
+ if (this.mon) {
1687
+ this.mon.collection(change.collection).applyRemoteWrite(change.op, change._id, change.doc, ts);
1688
+ return;
1689
+ }
1690
+ const coll = change.collection;
1426
1691
  this.ensureCollTable(coll);
1427
- if (op === "delete") {
1428
- this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(_id);
1692
+ if (change.op === "delete") {
1693
+ this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(change._id);
1429
1694
  return;
1430
1695
  }
1431
1696
  const doc = change.doc ?? {};
1432
- const data = JSON.stringify(stripSystem2(doc));
1433
- const ts = versionTs(change.version);
1434
1697
  const createdAt = typeof doc.created_at === "number" ? doc.created_at : ts;
1435
1698
  this.db.prepare(
1436
1699
  `INSERT INTO "${coll}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
1437
1700
  ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
1438
- ).run(_id, data, createdAt, ts);
1701
+ ).run(change._id, JSON.stringify(stripSystem2(doc)), createdAt, ts);
1439
1702
  }
1440
1703
  /**
1441
1704
  * Latest change per document with `seq` greater than the given watermark,
@@ -1492,6 +1755,7 @@ var SyncStore = class {
1492
1755
  this.db.transaction(() => {
1493
1756
  for (const coll of collections) {
1494
1757
  assertName(coll);
1758
+ if (!this.tableExists(coll)) continue;
1495
1759
  const docs = this.db.prepare(`SELECT _id, updated_at FROM "${coll}"`).all();
1496
1760
  for (const d of docs) {
1497
1761
  if (this.currentVersion(coll, d._id) !== null) continue;
@@ -1537,7 +1801,14 @@ var SyncStore = class {
1537
1801
  this.db.prepare(
1538
1802
  `INSERT INTO _monlite_conflicts (coll, doc_id, local_version, remote_version, winner, ts)
1539
1803
  VALUES (?, ?, ?, ?, ?, ?)`
1540
- ).run(coll, id, localVersion, remoteVersion, winner, versionTs(remoteVersion));
1804
+ ).run(
1805
+ coll,
1806
+ id,
1807
+ localVersion,
1808
+ remoteVersion,
1809
+ winner,
1810
+ versionTs(remoteVersion)
1811
+ );
1541
1812
  }
1542
1813
  conflicts() {
1543
1814
  const rows = this.db.prepare(
@@ -1556,6 +1827,8 @@ var SyncStore = class {
1556
1827
  /* ------------------------------ helpers ------------------------------- */
1557
1828
  readDoc(coll, id) {
1558
1829
  assertName(coll);
1830
+ if (this.mon) return this.mon.collection(coll).getRaw(id);
1831
+ if (!this.tableExists(coll)) return null;
1559
1832
  const row = this.db.prepare(
1560
1833
  `SELECT _id, data, created_at, updated_at FROM "${coll}" WHERE _id = ?`
1561
1834
  ).get(id);
@@ -1566,6 +1839,9 @@ var SyncStore = class {
1566
1839
  doc.updated_at = row.updated_at;
1567
1840
  return doc;
1568
1841
  }
1842
+ tableExists(name) {
1843
+ return this.db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?`).get(name) != null;
1844
+ }
1569
1845
  ensureCollTable(coll) {
1570
1846
  assertName(coll);
1571
1847
  this.db.exec(
@@ -1604,6 +1880,8 @@ var Monlite = class {
1604
1880
  driver;
1605
1881
  /** @internal */
1606
1882
  autoIndexer;
1883
+ /** @internal Reactivity hub for `collection.watch()`. */
1884
+ reactor = new Reactor();
1607
1885
  /** @internal Sync metadata store; present only when `{ sync: true }`. */
1608
1886
  $sync;
1609
1887
  collections = /* @__PURE__ */ new Map();
@@ -1622,7 +1900,7 @@ var Monlite = class {
1622
1900
  options.autoIndexAfter ?? 10
1623
1901
  );
1624
1902
  if (options.sync) {
1625
- this.$sync = new SyncStore(this.driver, options.nodeId);
1903
+ this.$sync = new SyncStore(this.driver, options.nodeId, this);
1626
1904
  }
1627
1905
  }
1628
1906
  /** Stable node id for LWW tie-breaking (only when sync is enabled). */
@@ -1738,6 +2016,15 @@ var Monlite = class {
1738
2016
  async $dropAll() {
1739
2017
  for (const name of await this.$collections()) await this.$drop(name);
1740
2018
  }
2019
+ /**
2020
+ * Write a consistent on-disk snapshot of the database to `path` (via
2021
+ * `VACUUM INTO`). The destination file must not already exist.
2022
+ */
2023
+ backup(path) {
2024
+ this.assertOpen();
2025
+ this.driver.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
2026
+ return Promise.resolve();
2027
+ }
1741
2028
  /** Close the underlying SQLite connection. */
1742
2029
  $disconnect() {
1743
2030
  if (!this.closed) {