@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.cjs CHANGED
@@ -20,6 +20,91 @@ function isObjectId(value) {
20
20
  return typeof value === "string" && /^[0-9a-f]{24}$/i.test(value);
21
21
  }
22
22
 
23
+ // src/reactive.ts
24
+ var RELEVANCE_PROBE_LIMIT = 500;
25
+ var LiveQuery = class {
26
+ constructor(source, args, cb) {
27
+ this.source = source;
28
+ this.args = args;
29
+ this.cb = cb;
30
+ this.recompute("init");
31
+ }
32
+ source;
33
+ args;
34
+ cb;
35
+ results = [];
36
+ stopped = false;
37
+ ids = /* @__PURE__ */ new Set();
38
+ /** Called by the Reactor with the ids that changed this tick. */
39
+ notify(changedIds) {
40
+ if (this.stopped) return;
41
+ if (!this.isRelevant(changedIds)) return;
42
+ this.recompute("change", changedIds);
43
+ }
44
+ isRelevant(changedIds) {
45
+ for (const id of changedIds) {
46
+ if (this.ids.has(id)) return true;
47
+ }
48
+ if (changedIds.size > RELEVANCE_PROBE_LIMIT) return true;
49
+ const idIn = {
50
+ _id: { in: [...changedIds] }
51
+ };
52
+ const where = this.args.where ? { AND: [this.args.where, idIn] } : idIn;
53
+ return this.source.existsCore(where);
54
+ }
55
+ recompute(type, changedIds) {
56
+ const next = this.source.findManyCore(this.args);
57
+ const nextById = new Map(next.map((d) => [d._id, d]));
58
+ const added = next.filter((d) => !this.ids.has(d._id));
59
+ const removed = this.results.filter((d) => !nextById.has(d._id));
60
+ const changed = changedIds ? next.filter((d) => this.ids.has(d._id) && changedIds.has(d._id)) : [];
61
+ this.results = next;
62
+ this.ids = new Set(nextById.keys());
63
+ this.cb({ type, results: next, added, removed, changed });
64
+ }
65
+ };
66
+ var Reactor = class {
67
+ byCollection = /* @__PURE__ */ new Map();
68
+ pending = /* @__PURE__ */ new Map();
69
+ flushScheduled = false;
70
+ hasWatchers(collection) {
71
+ return this.byCollection.has(collection);
72
+ }
73
+ register(collection, lq) {
74
+ let set = this.byCollection.get(collection);
75
+ if (!set) this.byCollection.set(collection, set = /* @__PURE__ */ new Set());
76
+ set.add(lq);
77
+ }
78
+ unregister(collection, lq) {
79
+ const set = this.byCollection.get(collection);
80
+ if (!set) return;
81
+ set.delete(lq);
82
+ if (set.size === 0) this.byCollection.delete(collection);
83
+ }
84
+ /** Record that documents changed; schedule a notification flush. */
85
+ emit(collection, ids) {
86
+ const set = this.byCollection.get(collection);
87
+ if (!set || set.size === 0 || ids.length === 0) return;
88
+ let p = this.pending.get(collection);
89
+ if (!p) this.pending.set(collection, p = /* @__PURE__ */ new Set());
90
+ for (const id of ids) p.add(id);
91
+ if (!this.flushScheduled) {
92
+ this.flushScheduled = true;
93
+ queueMicrotask(() => this.flush());
94
+ }
95
+ }
96
+ flush() {
97
+ this.flushScheduled = false;
98
+ const work = [...this.pending];
99
+ this.pending.clear();
100
+ for (const [collection, ids] of work) {
101
+ const set = this.byCollection.get(collection);
102
+ if (!set) continue;
103
+ for (const lq of [...set]) lq.notify(ids);
104
+ }
105
+ }
106
+ };
107
+
23
108
  // src/errors.ts
24
109
  var MonliteError = class extends Error {
25
110
  constructor(message, options) {
@@ -239,9 +324,7 @@ function cmp(expr, op, v, ctx) {
239
324
  }
240
325
  function inExpr(expr, arr, ctx, negate) {
241
326
  if (!Array.isArray(arr)) {
242
- throw new MonliteQueryError(
243
- `${negate ? "notIn" : "in"} expects an array`
244
- );
327
+ throw new MonliteQueryError(`${negate ? "notIn" : "in"} expects an array`);
245
328
  }
246
329
  if (arr.length === 0) return negate ? "1" : "0";
247
330
  const placeholders = arr.map((v) => {
@@ -371,7 +454,8 @@ function applyUpdate(doc, data) {
371
454
  }
372
455
  const ops = data;
373
456
  if (ops.$set) {
374
- for (const [path, value] of Object.entries(ops.$set)) setPath(next, path, value);
457
+ for (const [path, value] of Object.entries(ops.$set))
458
+ setPath(next, path, value);
375
459
  }
376
460
  if (ops.$inc) {
377
461
  for (const [path, by] of Object.entries(ops.$inc)) {
@@ -492,7 +576,11 @@ function buildHaving(having, params, columns) {
492
576
  if (!selection) continue;
493
577
  for (const field of Object.keys(selection)) {
494
578
  parts.push(
495
- ...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
579
+ ...comparisonSql(
580
+ `${fn}(${fieldExpr(field, columns)})`,
581
+ selection[field],
582
+ params
583
+ )
496
584
  );
497
585
  }
498
586
  }
@@ -520,7 +608,11 @@ function groupBy(ctx, args) {
520
608
  groupCols.push({ alias, field });
521
609
  });
522
610
  selects.push(`COUNT(*) AS agg_count`);
523
- const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
611
+ const { selects: accSelects, cols } = buildAccumulators(
612
+ args,
613
+ ctx.onPath,
614
+ ctx.columns
615
+ );
524
616
  selects.push(...accSelects);
525
617
  let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
526
618
  if (args.having) {
@@ -598,6 +690,7 @@ var Collection = class {
598
690
  this.columns.add(field);
599
691
  if (normalized.type === "JSON") this.jsonColumns.add(field);
600
692
  }
693
+ this.ensureTable();
601
694
  }
602
695
  }
603
696
  mon;
@@ -642,22 +735,15 @@ var Collection = class {
642
735
  `_id TEXT PRIMARY KEY`,
643
736
  `created_at INTEGER NOT NULL`,
644
737
  `updated_at INTEGER NOT NULL`,
645
- `data TEXT NOT NULL DEFAULT '{}'`
738
+ `data TEXT NOT NULL DEFAULT '{}'`,
739
+ ...this.columnOrder.map((f) => this.columnDdl(f, false))
646
740
  ];
647
- for (const field of this.columnOrder) {
648
- const def = this.columnDefs[field];
649
- let line = `"${field}" ${sqliteType(def.type)}`;
650
- if (def.notNull) line += " NOT NULL";
651
- if (def.unique) line += " UNIQUE";
652
- if (def.default !== void 0) line += ` DEFAULT ${formatDefault(def.default)}`;
653
- if (def.references) line += ` REFERENCES ${def.references}`;
654
- lines.push(line);
655
- }
656
741
  this.db.exec(
657
742
  `CREATE TABLE IF NOT EXISTS "${this.name}" (
658
743
  ${lines.join(",\n ")}
659
744
  )`
660
745
  );
746
+ this.migrateColumns();
661
747
  for (const field of this.columnOrder) {
662
748
  if (this.columnDefs[field].index) {
663
749
  this.db.exec(
@@ -668,6 +754,39 @@ var Collection = class {
668
754
  }
669
755
  this.initialized = true;
670
756
  }
757
+ columnDdl(field, forAlter) {
758
+ const def = this.columnDefs[field];
759
+ let line = `"${field}" ${sqliteType(def.type)}`;
760
+ if (def.notNull) line += " NOT NULL";
761
+ if (def.unique && !forAlter) line += " UNIQUE";
762
+ if (def.default !== void 0)
763
+ line += ` DEFAULT ${formatDefault(def.default)}`;
764
+ if (def.references) line += ` REFERENCES ${def.references}`;
765
+ return line;
766
+ }
767
+ /** Auto-additive migration: add declared columns missing from an existing table. */
768
+ migrateColumns() {
769
+ const existing = new Set(
770
+ this.db.prepare(`PRAGMA table_info("${this.name}")`).all().map((r) => r.name)
771
+ );
772
+ for (const field of this.columnOrder) {
773
+ if (existing.has(field)) continue;
774
+ try {
775
+ this.db.exec(
776
+ `ALTER TABLE "${this.name}" ADD COLUMN ${this.columnDdl(field, true)}`
777
+ );
778
+ } catch (err) {
779
+ throw new MonliteError(
780
+ `Failed to add column "${field}" to "${this.name}": ${err.message}. NOT NULL columns need a default when added to an existing table.`
781
+ );
782
+ }
783
+ if (this.columnDefs[field].unique) {
784
+ this.db.exec(
785
+ `CREATE UNIQUE INDEX IF NOT EXISTS "uq_${this.name}_${field}" ON "${this.name}"("${field}")`
786
+ );
787
+ }
788
+ }
789
+ }
671
790
  /* --------------------------- row <-> doc -------------------------- */
672
791
  rowToDoc(row) {
673
792
  const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
@@ -713,7 +832,12 @@ var Collection = class {
713
832
  const now = Date.now();
714
833
  const id = input._id != null ? String(input._id) : objectId();
715
834
  const doc = stripSystem(input);
716
- const returned = { ...doc, _id: id, created_at: now, updated_at: now };
835
+ const returned = {
836
+ ...doc,
837
+ _id: id,
838
+ created_at: now,
839
+ updated_at: now
840
+ };
717
841
  if (this.mode === "document") {
718
842
  return {
719
843
  _id: id,
@@ -765,9 +889,72 @@ var Collection = class {
765
889
  ];
766
890
  return { setSql: setParts.join(", "), values };
767
891
  }
768
- /** Sync store, but only for document collections (structured sync is future work). */
892
+ /** Sync store for recording local changes (both document and structured). */
769
893
  get recorder() {
770
- return this.mode === "document" ? this.mon.$sync : void 0;
894
+ return this.mon.$sync;
895
+ }
896
+ /** @internal Read a full document by id (mode-aware), synchronously. */
897
+ getRaw(id) {
898
+ this.ensureTable();
899
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
900
+ return row ? this.rowToDoc(row) : null;
901
+ }
902
+ /**
903
+ * @internal Apply a remote change to storage WITHOUT recording it to the
904
+ * change feed (the sync store records the `remote` feed row itself). Used by
905
+ * `@monlite/sync` so structured collections sync correctly through the same
906
+ * column/overflow split as local writes.
907
+ */
908
+ applyRemoteWrite(op, id, doc, ts) {
909
+ this.ensureTable();
910
+ if (op === "delete") {
911
+ this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`).run(id);
912
+ this.afterWrite([id]);
913
+ return;
914
+ }
915
+ const clean = stripSystem(doc ?? {});
916
+ const createdAt = typeof doc?.created_at === "number" ? doc.created_at : ts;
917
+ if (this.mode === "document") {
918
+ this.db.prepare(
919
+ `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
920
+ ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
921
+ ).run(id, JSON.stringify(clean), createdAt, ts);
922
+ this.afterWrite([id]);
923
+ return;
924
+ }
925
+ const overflow = {};
926
+ const colValues = {};
927
+ for (const [k, v] of Object.entries(clean)) {
928
+ if (this.columns.has(k)) colValues[k] = v;
929
+ else overflow[k] = v;
930
+ }
931
+ const cols = [
932
+ "_id",
933
+ "created_at",
934
+ "updated_at",
935
+ "data",
936
+ ...this.columnOrder
937
+ ];
938
+ const values = [
939
+ id,
940
+ createdAt,
941
+ ts,
942
+ JSON.stringify(overflow),
943
+ ...this.columnOrder.map(
944
+ (c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
945
+ )
946
+ ];
947
+ const colList = cols.map((c) => `"${c}"`).join(", ");
948
+ const placeholders = cols.map(() => "?").join(", ");
949
+ const updateSet = cols.filter((c) => c !== "_id" && c !== "created_at").map((c) => `"${c}" = excluded."${c}"`).join(", ");
950
+ this.db.prepare(
951
+ `INSERT INTO "${this.name}" (${colList}) VALUES (${placeholders}) ON CONFLICT(_id) DO UPDATE SET ${updateSet}`
952
+ ).run(...values);
953
+ this.afterWrite([id]);
954
+ }
955
+ /** @internal Notify reactivity watchers that documents changed. */
956
+ afterWrite(ids) {
957
+ this.mon.reactor.emit(this.name, ids);
771
958
  }
772
959
  /* ----------------------------- create ----------------------------- */
773
960
  async create(args) {
@@ -779,26 +966,29 @@ var Collection = class {
779
966
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
780
967
  };
781
968
  this.guard(() => recorder ? this.db.transaction(write) : write());
969
+ this.afterWrite([row._id]);
782
970
  return row.returned;
783
971
  }
784
972
  async createMany(args) {
785
973
  this.ensureTable();
786
974
  const stmt = this.db.prepare(this.insertSql());
787
975
  const recorder = this.recorder;
976
+ const ids = [];
788
977
  this.guard(
789
978
  () => this.db.transaction(() => {
790
979
  for (const item of args.data) {
791
980
  const row = this.buildInsert(item);
792
981
  stmt.run(...row.values);
793
982
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
983
+ ids.push(row._id);
794
984
  }
795
985
  })
796
986
  );
987
+ this.afterWrite(ids);
797
988
  return { count: args.data.length };
798
989
  }
799
990
  /* ------------------------------ read ------------------------------ */
800
- async findMany(args = {}) {
801
- this.ensureTable();
991
+ buildFindSql(args) {
802
992
  const params = [];
803
993
  const where = buildWhere(args.where, {
804
994
  params,
@@ -816,9 +1006,29 @@ var Collection = class {
816
1006
  sql += (args.take != null ? "" : " LIMIT -1") + " OFFSET ?";
817
1007
  params.push(args.skip);
818
1008
  }
1009
+ return { sql, params };
1010
+ }
1011
+ /** @internal Synchronous core of findMany (used by reactivity). */
1012
+ findManyCore(args = {}) {
1013
+ this.ensureTable();
1014
+ const { sql, params } = this.buildFindSql(args);
819
1015
  const rows = this.db.prepare(sql).all(...params);
820
1016
  return rows.map((r) => project(this.rowToDoc(r), args.select));
821
1017
  }
1018
+ /** @internal Synchronous core of exists (used by reactivity). */
1019
+ existsCore(where) {
1020
+ this.ensureTable();
1021
+ const params = [];
1022
+ const clause = buildWhere(where, {
1023
+ params,
1024
+ onPath: this.trackPath,
1025
+ columns: this.columns
1026
+ });
1027
+ return this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params) != null;
1028
+ }
1029
+ async findMany(args = {}) {
1030
+ return this.findManyCore(args);
1031
+ }
822
1032
  async findFirst(args = {}) {
823
1033
  const rows = await this.findMany({ ...args, take: 1 });
824
1034
  return rows[0] ?? null;
@@ -835,15 +1045,39 @@ var Collection = class {
835
1045
  }
836
1046
  /** True if at least one document matches. */
837
1047
  async exists(where) {
1048
+ return this.existsCore(where);
1049
+ }
1050
+ /**
1051
+ * Subscribe to a live query. The callback fires immediately with the current
1052
+ * results (`type: "init"`) and again whenever a change affects the result set
1053
+ * (row-level: only relevant changes trigger a recompute). Includes changes
1054
+ * applied by `@monlite/sync`.
1055
+ */
1056
+ watch(args = {}, cb) {
838
1057
  this.ensureTable();
839
- const params = [];
840
- const clause = buildWhere(where, {
841
- params,
842
- onPath: this.trackPath,
843
- columns: this.columns
844
- });
845
- const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
846
- return row != null;
1058
+ const lq = new LiveQuery(this, args, cb);
1059
+ const reactor = this.mon.reactor;
1060
+ const name = this.name;
1061
+ reactor.register(name, lq);
1062
+ return {
1063
+ get results() {
1064
+ return lq.results;
1065
+ },
1066
+ stop() {
1067
+ lq.stopped = true;
1068
+ reactor.unregister(name, lq);
1069
+ }
1070
+ };
1071
+ }
1072
+ /** Show SQLite's query plan for a `findMany`, and whether it uses an index. */
1073
+ async explain(args = {}) {
1074
+ this.ensureTable();
1075
+ const { sql, params } = this.buildFindSql(args);
1076
+ const plan = this.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params);
1077
+ const usesIndex = plan.some(
1078
+ (r) => /USING (COVERING )?INDEX/i.test(r.detail)
1079
+ );
1080
+ return { sql, usesIndex, plan };
847
1081
  }
848
1082
  async findById(id) {
849
1083
  this.ensureTable();
@@ -900,25 +1134,27 @@ var Collection = class {
900
1134
  if (!rows.length) return [];
901
1135
  const now = Date.now();
902
1136
  const recorder = this.recorder;
903
- return this.guard(
1137
+ const out = this.guard(
904
1138
  () => this.db.transaction(() => {
905
- const out = [];
1139
+ const result = [];
906
1140
  for (const row of rows) {
907
1141
  const current = stripSystem(this.rowToDoc(row));
908
1142
  const updated = stripSystem(applyUpdate(current, data));
909
1143
  const { setSql, values } = this.buildUpdateSet(updated, now);
910
1144
  this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
911
1145
  recorder?.recordLocal(this.name, row._id, "upsert", now);
912
- out.push({
1146
+ result.push({
913
1147
  ...updated,
914
1148
  _id: row._id,
915
1149
  created_at: row.created_at,
916
1150
  updated_at: now
917
1151
  });
918
1152
  }
919
- return out;
1153
+ return result;
920
1154
  })
921
1155
  );
1156
+ this.afterWrite(out.map((d) => d._id));
1157
+ return out;
922
1158
  }
923
1159
  async update(args) {
924
1160
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -928,7 +1164,7 @@ var Collection = class {
928
1164
  }
929
1165
  async upsert(args) {
930
1166
  this.ensureTable();
931
- return this.guard(
1167
+ const result = this.guard(
932
1168
  () => this.db.transaction(() => {
933
1169
  const params = [];
934
1170
  const clause = buildWhere(args.where, {
@@ -958,6 +1194,8 @@ var Collection = class {
958
1194
  return ins.returned;
959
1195
  })
960
1196
  );
1197
+ this.afterWrite([result._id]);
1198
+ return result;
961
1199
  }
962
1200
  /* ----------------------------- delete ----------------------------- */
963
1201
  runDelete(where, single) {
@@ -983,6 +1221,7 @@ var Collection = class {
983
1221
  }
984
1222
  })
985
1223
  );
1224
+ this.afterWrite(rows.map((r) => r._id));
986
1225
  return rows.map((r) => this.rowToDoc(r));
987
1226
  }
988
1227
  async delete(args) {
@@ -996,7 +1235,12 @@ var Collection = class {
996
1235
  this.ensureTable();
997
1236
  return this.guard(
998
1237
  () => aggregate(
999
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1238
+ {
1239
+ db: this.db,
1240
+ table: this.name,
1241
+ onPath: this.trackPath,
1242
+ columns: this.columns
1243
+ },
1000
1244
  args
1001
1245
  )
1002
1246
  );
@@ -1005,7 +1249,12 @@ var Collection = class {
1005
1249
  this.ensureTable();
1006
1250
  return this.guard(
1007
1251
  () => groupBy(
1008
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1252
+ {
1253
+ db: this.db,
1254
+ table: this.name,
1255
+ onPath: this.trackPath,
1256
+ columns: this.columns
1257
+ },
1009
1258
  args
1010
1259
  )
1011
1260
  );
@@ -1253,12 +1502,14 @@ function stripSystem2(obj) {
1253
1502
  return rest;
1254
1503
  }
1255
1504
  var SyncStore = class {
1256
- constructor(db, nodeId) {
1505
+ constructor(db, nodeId, mon) {
1257
1506
  this.db = db;
1507
+ this.mon = mon;
1258
1508
  this.init();
1259
1509
  this.nodeId = this.resolveNodeId(nodeId);
1260
1510
  }
1261
1511
  db;
1512
+ mon;
1262
1513
  nodeId;
1263
1514
  versionSeq = 0;
1264
1515
  init() {
@@ -1293,7 +1544,9 @@ var SyncStore = class {
1293
1544
  }
1294
1545
  resolveNodeId(explicit) {
1295
1546
  if (explicit) {
1296
- this.db.prepare(`INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(explicit);
1547
+ this.db.prepare(
1548
+ `INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`
1549
+ ).run(explicit);
1297
1550
  return explicit;
1298
1551
  }
1299
1552
  const row = this.db.prepare(`SELECT value FROM _monlite_meta WHERE key = 'nodeId'`).get();
@@ -1408,6 +1661,9 @@ var SyncStore = class {
1408
1661
  );
1409
1662
  }
1410
1663
  if (winner !== "remote") {
1664
+ if (localVersion !== null) {
1665
+ this.recordLocal(change.collection, change._id, "upsert", Date.now());
1666
+ }
1411
1667
  return { applied: false, conflict: localVersion !== null, winner };
1412
1668
  }
1413
1669
  this.applyData(change);
@@ -1421,24 +1677,31 @@ var SyncStore = class {
1421
1677
  change.version,
1422
1678
  versionTs(change.version)
1423
1679
  );
1424
- return { applied: true, conflict: localVersion !== null, winner: "remote" };
1680
+ return {
1681
+ applied: true,
1682
+ conflict: localVersion !== null,
1683
+ winner: "remote"
1684
+ };
1425
1685
  });
1426
1686
  }
1427
1687
  applyData(change) {
1428
- const { collection: coll, _id, op } = change;
1688
+ const ts = versionTs(change.version);
1689
+ if (this.mon) {
1690
+ this.mon.collection(change.collection).applyRemoteWrite(change.op, change._id, change.doc, ts);
1691
+ return;
1692
+ }
1693
+ const coll = change.collection;
1429
1694
  this.ensureCollTable(coll);
1430
- if (op === "delete") {
1431
- this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(_id);
1695
+ if (change.op === "delete") {
1696
+ this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(change._id);
1432
1697
  return;
1433
1698
  }
1434
1699
  const doc = change.doc ?? {};
1435
- const data = JSON.stringify(stripSystem2(doc));
1436
- const ts = versionTs(change.version);
1437
1700
  const createdAt = typeof doc.created_at === "number" ? doc.created_at : ts;
1438
1701
  this.db.prepare(
1439
1702
  `INSERT INTO "${coll}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
1440
1703
  ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
1441
- ).run(_id, data, createdAt, ts);
1704
+ ).run(change._id, JSON.stringify(stripSystem2(doc)), createdAt, ts);
1442
1705
  }
1443
1706
  /**
1444
1707
  * Latest change per document with `seq` greater than the given watermark,
@@ -1495,6 +1758,7 @@ var SyncStore = class {
1495
1758
  this.db.transaction(() => {
1496
1759
  for (const coll of collections) {
1497
1760
  assertName(coll);
1761
+ if (!this.tableExists(coll)) continue;
1498
1762
  const docs = this.db.prepare(`SELECT _id, updated_at FROM "${coll}"`).all();
1499
1763
  for (const d of docs) {
1500
1764
  if (this.currentVersion(coll, d._id) !== null) continue;
@@ -1540,7 +1804,14 @@ var SyncStore = class {
1540
1804
  this.db.prepare(
1541
1805
  `INSERT INTO _monlite_conflicts (coll, doc_id, local_version, remote_version, winner, ts)
1542
1806
  VALUES (?, ?, ?, ?, ?, ?)`
1543
- ).run(coll, id, localVersion, remoteVersion, winner, versionTs(remoteVersion));
1807
+ ).run(
1808
+ coll,
1809
+ id,
1810
+ localVersion,
1811
+ remoteVersion,
1812
+ winner,
1813
+ versionTs(remoteVersion)
1814
+ );
1544
1815
  }
1545
1816
  conflicts() {
1546
1817
  const rows = this.db.prepare(
@@ -1559,6 +1830,8 @@ var SyncStore = class {
1559
1830
  /* ------------------------------ helpers ------------------------------- */
1560
1831
  readDoc(coll, id) {
1561
1832
  assertName(coll);
1833
+ if (this.mon) return this.mon.collection(coll).getRaw(id);
1834
+ if (!this.tableExists(coll)) return null;
1562
1835
  const row = this.db.prepare(
1563
1836
  `SELECT _id, data, created_at, updated_at FROM "${coll}" WHERE _id = ?`
1564
1837
  ).get(id);
@@ -1569,6 +1842,9 @@ var SyncStore = class {
1569
1842
  doc.updated_at = row.updated_at;
1570
1843
  return doc;
1571
1844
  }
1845
+ tableExists(name) {
1846
+ return this.db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?`).get(name) != null;
1847
+ }
1572
1848
  ensureCollTable(coll) {
1573
1849
  assertName(coll);
1574
1850
  this.db.exec(
@@ -1607,6 +1883,8 @@ var Monlite = class {
1607
1883
  driver;
1608
1884
  /** @internal */
1609
1885
  autoIndexer;
1886
+ /** @internal Reactivity hub for `collection.watch()`. */
1887
+ reactor = new Reactor();
1610
1888
  /** @internal Sync metadata store; present only when `{ sync: true }`. */
1611
1889
  $sync;
1612
1890
  collections = /* @__PURE__ */ new Map();
@@ -1625,7 +1903,7 @@ var Monlite = class {
1625
1903
  options.autoIndexAfter ?? 10
1626
1904
  );
1627
1905
  if (options.sync) {
1628
- this.$sync = new SyncStore(this.driver, options.nodeId);
1906
+ this.$sync = new SyncStore(this.driver, options.nodeId, this);
1629
1907
  }
1630
1908
  }
1631
1909
  /** Stable node id for LWW tie-breaking (only when sync is enabled). */
@@ -1741,6 +2019,15 @@ var Monlite = class {
1741
2019
  async $dropAll() {
1742
2020
  for (const name of await this.$collections()) await this.$drop(name);
1743
2021
  }
2022
+ /**
2023
+ * Write a consistent on-disk snapshot of the database to `path` (via
2024
+ * `VACUUM INTO`). The destination file must not already exist.
2025
+ */
2026
+ backup(path) {
2027
+ this.assertOpen();
2028
+ this.driver.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
2029
+ return Promise.resolve();
2030
+ }
1744
2031
  /** Close the underlying SQLite connection. */
1745
2032
  $disconnect() {
1746
2033
  if (!this.closed) {