@monlite/core 0.7.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/README.md CHANGED
@@ -319,6 +319,31 @@ await users.distinct("tags"); // ["a", "b", "c"]
319
319
 
320
320
  ---
321
321
 
322
+ ## Live queries (reactivity)
323
+
324
+ `collection.watch()` keeps a query result live. The callback fires once
325
+ immediately (`type: "init"`) and again whenever a change **affects this query** —
326
+ matching is **row-level**, so unrelated writes don't trigger a recompute. It also
327
+ fires for changes applied by `@monlite/sync`, so the UI updates when cloud data
328
+ arrives.
329
+
330
+ ```ts
331
+ const handle = users.watch({ where: { role: "admin" } }, (event) => {
332
+ event.results; // full current result set
333
+ event.added; // docs that just entered the set
334
+ event.removed; // docs that just left
335
+ event.changed; // docs still in the set whose contents changed
336
+ });
337
+
338
+ handle.results; // current results, kept up to date
339
+ handle.stop(); // unsubscribe
340
+ ```
341
+
342
+ Perfect for Electron/Tauri UIs: bind `handle.results` to your view and it stays
343
+ in sync with every write (local or synced).
344
+
345
+ ---
346
+
322
347
  ## Structured collections (native SQL columns)
323
348
 
324
349
  By default a collection is **document mode** — schema-free, every field stored
@@ -362,6 +387,11 @@ await db.$queryRaw`
362
387
  Column types: `"TEXT" | "INTEGER" | "REAL" | "BLOB" | "JSON"`. A full column
363
388
  definition supports `index`, `unique`, `notNull`, `default`, and `references`.
364
389
 
390
+ **Migrations are automatic for additive changes.** Re-opening a collection with a
391
+ new declared column adds it (`ALTER TABLE ADD COLUMN`) on declaration — give
392
+ `NOT NULL` columns a `default` so existing rows can be backfilled. Destructive
393
+ changes (rename/drop/type-change) still need a manual migration.
394
+
365
395
  ### Do I have to care: JSON vs native columns?
366
396
 
367
397
  - **For correctness — no.** Both modes return identical results through the same API.
@@ -472,6 +502,13 @@ ON users(json_extract(data, '$.address.city'));
472
502
 
473
503
  You never think about indexes. Disable with `createDb("./app.db", { autoIndex: false })`.
474
504
 
505
+ Want to see whether a query uses an index? Ask:
506
+
507
+ ```ts
508
+ await users.explain({ where: { "address.city": "Riyadh" } });
509
+ // { sql, usesIndex: true, plan: [{ id, parent, detail }, …] }
510
+ ```
511
+
475
512
  ---
476
513
 
477
514
  ## Database management
@@ -480,6 +517,7 @@ You never think about indexes. Disable with `createDb("./app.db", { autoIndex: f
480
517
  await db.$collections(); // string[] of collection names
481
518
  await db.$drop("users"); // drop a collection and its data
482
519
  await db.$dropAll(); // drop everything
520
+ await db.backup("./snapshot.db"); // consistent on-disk snapshot
483
521
  await db.$disconnect(); // close the connection
484
522
  db.sqlite; // the underlying native driver handle
485
523
  db.driverName; // "better-sqlite3" | "node:sqlite"
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) {
@@ -605,6 +690,7 @@ var Collection = class {
605
690
  this.columns.add(field);
606
691
  if (normalized.type === "JSON") this.jsonColumns.add(field);
607
692
  }
693
+ this.ensureTable();
608
694
  }
609
695
  }
610
696
  mon;
@@ -649,23 +735,15 @@ var Collection = class {
649
735
  `_id TEXT PRIMARY KEY`,
650
736
  `created_at INTEGER NOT NULL`,
651
737
  `updated_at INTEGER NOT NULL`,
652
- `data TEXT NOT NULL DEFAULT '{}'`
738
+ `data TEXT NOT NULL DEFAULT '{}'`,
739
+ ...this.columnOrder.map((f) => this.columnDdl(f, false))
653
740
  ];
654
- for (const field of this.columnOrder) {
655
- const def = this.columnDefs[field];
656
- let line = `"${field}" ${sqliteType(def.type)}`;
657
- if (def.notNull) line += " NOT NULL";
658
- if (def.unique) line += " UNIQUE";
659
- if (def.default !== void 0)
660
- line += ` DEFAULT ${formatDefault(def.default)}`;
661
- if (def.references) line += ` REFERENCES ${def.references}`;
662
- lines.push(line);
663
- }
664
741
  this.db.exec(
665
742
  `CREATE TABLE IF NOT EXISTS "${this.name}" (
666
743
  ${lines.join(",\n ")}
667
744
  )`
668
745
  );
746
+ this.migrateColumns();
669
747
  for (const field of this.columnOrder) {
670
748
  if (this.columnDefs[field].index) {
671
749
  this.db.exec(
@@ -676,6 +754,39 @@ var Collection = class {
676
754
  }
677
755
  this.initialized = true;
678
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
+ }
679
790
  /* --------------------------- row <-> doc -------------------------- */
680
791
  rowToDoc(row) {
681
792
  const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
@@ -798,6 +909,7 @@ var Collection = class {
798
909
  this.ensureTable();
799
910
  if (op === "delete") {
800
911
  this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`).run(id);
912
+ this.afterWrite([id]);
801
913
  return;
802
914
  }
803
915
  const clean = stripSystem(doc ?? {});
@@ -807,6 +919,7 @@ var Collection = class {
807
919
  `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
808
920
  ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
809
921
  ).run(id, JSON.stringify(clean), createdAt, ts);
922
+ this.afterWrite([id]);
810
923
  return;
811
924
  }
812
925
  const overflow = {};
@@ -837,6 +950,11 @@ var Collection = class {
837
950
  this.db.prepare(
838
951
  `INSERT INTO "${this.name}" (${colList}) VALUES (${placeholders}) ON CONFLICT(_id) DO UPDATE SET ${updateSet}`
839
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);
840
958
  }
841
959
  /* ----------------------------- create ----------------------------- */
842
960
  async create(args) {
@@ -848,26 +966,29 @@ var Collection = class {
848
966
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
849
967
  };
850
968
  this.guard(() => recorder ? this.db.transaction(write) : write());
969
+ this.afterWrite([row._id]);
851
970
  return row.returned;
852
971
  }
853
972
  async createMany(args) {
854
973
  this.ensureTable();
855
974
  const stmt = this.db.prepare(this.insertSql());
856
975
  const recorder = this.recorder;
976
+ const ids = [];
857
977
  this.guard(
858
978
  () => this.db.transaction(() => {
859
979
  for (const item of args.data) {
860
980
  const row = this.buildInsert(item);
861
981
  stmt.run(...row.values);
862
982
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
983
+ ids.push(row._id);
863
984
  }
864
985
  })
865
986
  );
987
+ this.afterWrite(ids);
866
988
  return { count: args.data.length };
867
989
  }
868
990
  /* ------------------------------ read ------------------------------ */
869
- async findMany(args = {}) {
870
- this.ensureTable();
991
+ buildFindSql(args) {
871
992
  const params = [];
872
993
  const where = buildWhere(args.where, {
873
994
  params,
@@ -885,9 +1006,29 @@ var Collection = class {
885
1006
  sql += (args.take != null ? "" : " LIMIT -1") + " OFFSET ?";
886
1007
  params.push(args.skip);
887
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);
888
1015
  const rows = this.db.prepare(sql).all(...params);
889
1016
  return rows.map((r) => project(this.rowToDoc(r), args.select));
890
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
+ }
891
1032
  async findFirst(args = {}) {
892
1033
  const rows = await this.findMany({ ...args, take: 1 });
893
1034
  return rows[0] ?? null;
@@ -904,15 +1045,39 @@ var Collection = class {
904
1045
  }
905
1046
  /** True if at least one document matches. */
906
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) {
907
1057
  this.ensureTable();
908
- const params = [];
909
- const clause = buildWhere(where, {
910
- params,
911
- onPath: this.trackPath,
912
- columns: this.columns
913
- });
914
- const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
915
- 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 };
916
1081
  }
917
1082
  async findById(id) {
918
1083
  this.ensureTable();
@@ -969,25 +1134,27 @@ var Collection = class {
969
1134
  if (!rows.length) return [];
970
1135
  const now = Date.now();
971
1136
  const recorder = this.recorder;
972
- return this.guard(
1137
+ const out = this.guard(
973
1138
  () => this.db.transaction(() => {
974
- const out = [];
1139
+ const result = [];
975
1140
  for (const row of rows) {
976
1141
  const current = stripSystem(this.rowToDoc(row));
977
1142
  const updated = stripSystem(applyUpdate(current, data));
978
1143
  const { setSql, values } = this.buildUpdateSet(updated, now);
979
1144
  this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
980
1145
  recorder?.recordLocal(this.name, row._id, "upsert", now);
981
- out.push({
1146
+ result.push({
982
1147
  ...updated,
983
1148
  _id: row._id,
984
1149
  created_at: row.created_at,
985
1150
  updated_at: now
986
1151
  });
987
1152
  }
988
- return out;
1153
+ return result;
989
1154
  })
990
1155
  );
1156
+ this.afterWrite(out.map((d) => d._id));
1157
+ return out;
991
1158
  }
992
1159
  async update(args) {
993
1160
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -997,7 +1164,7 @@ var Collection = class {
997
1164
  }
998
1165
  async upsert(args) {
999
1166
  this.ensureTable();
1000
- return this.guard(
1167
+ const result = this.guard(
1001
1168
  () => this.db.transaction(() => {
1002
1169
  const params = [];
1003
1170
  const clause = buildWhere(args.where, {
@@ -1027,6 +1194,8 @@ var Collection = class {
1027
1194
  return ins.returned;
1028
1195
  })
1029
1196
  );
1197
+ this.afterWrite([result._id]);
1198
+ return result;
1030
1199
  }
1031
1200
  /* ----------------------------- delete ----------------------------- */
1032
1201
  runDelete(where, single) {
@@ -1052,6 +1221,7 @@ var Collection = class {
1052
1221
  }
1053
1222
  })
1054
1223
  );
1224
+ this.afterWrite(rows.map((r) => r._id));
1055
1225
  return rows.map((r) => this.rowToDoc(r));
1056
1226
  }
1057
1227
  async delete(args) {
@@ -1713,6 +1883,8 @@ var Monlite = class {
1713
1883
  driver;
1714
1884
  /** @internal */
1715
1885
  autoIndexer;
1886
+ /** @internal Reactivity hub for `collection.watch()`. */
1887
+ reactor = new Reactor();
1716
1888
  /** @internal Sync metadata store; present only when `{ sync: true }`. */
1717
1889
  $sync;
1718
1890
  collections = /* @__PURE__ */ new Map();
@@ -1847,6 +2019,15 @@ var Monlite = class {
1847
2019
  async $dropAll() {
1848
2020
  for (const name of await this.$collections()) await this.$drop(name);
1849
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
+ }
1850
2031
  /** Close the underlying SQLite connection. */
1851
2032
  $disconnect() {
1852
2033
  if (!this.closed) {