@monlite/core 0.7.0 → 0.9.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"
@@ -487,6 +525,31 @@ db.driverName; // "better-sqlite3" | "node:sqlite"
487
525
 
488
526
  ---
489
527
 
528
+ ## Plugins
529
+
530
+ `@monlite/core` stays lean; heavier or optional capabilities are opt-in plugins
531
+ passed to `createDb`:
532
+
533
+ ```ts
534
+ import { createDb } from "@monlite/core";
535
+ import { fts } from "@monlite/fts";
536
+
537
+ const db = createDb("./app.db", {
538
+ plugins: [fts({ posts: ["title", "body"] })],
539
+ });
540
+
541
+ await db.collection("posts").search("hello world"); // full-text search
542
+ ```
543
+
544
+ | Plugin | Adds |
545
+ |---|---|
546
+ | [`@monlite/fts`](https://www.npmjs.com/package/@monlite/fts) | Full-text search (SQLite FTS5) via `collection.search()` |
547
+
548
+ Write your own against the `MonlitePlugin` interface (`init` / `afterWrite` /
549
+ `collectionMethods` hooks).
550
+
551
+ ---
552
+
490
553
  ## Drivers & zero dependencies
491
554
 
492
555
  monlite talks to SQLite through a tiny driver adapter, so it runs on two
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,13 @@ 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 and plugins that documents changed. */
956
+ afterWrite(ids) {
957
+ if (ids.length === 0) return;
958
+ this.mon.reactor.emit(this.name, ids);
959
+ this.mon.firePluginAfterWrite(this.name, ids);
840
960
  }
841
961
  /* ----------------------------- create ----------------------------- */
842
962
  async create(args) {
@@ -848,26 +968,29 @@ var Collection = class {
848
968
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
849
969
  };
850
970
  this.guard(() => recorder ? this.db.transaction(write) : write());
971
+ this.afterWrite([row._id]);
851
972
  return row.returned;
852
973
  }
853
974
  async createMany(args) {
854
975
  this.ensureTable();
855
976
  const stmt = this.db.prepare(this.insertSql());
856
977
  const recorder = this.recorder;
978
+ const ids = [];
857
979
  this.guard(
858
980
  () => this.db.transaction(() => {
859
981
  for (const item of args.data) {
860
982
  const row = this.buildInsert(item);
861
983
  stmt.run(...row.values);
862
984
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
985
+ ids.push(row._id);
863
986
  }
864
987
  })
865
988
  );
989
+ this.afterWrite(ids);
866
990
  return { count: args.data.length };
867
991
  }
868
992
  /* ------------------------------ read ------------------------------ */
869
- async findMany(args = {}) {
870
- this.ensureTable();
993
+ buildFindSql(args) {
871
994
  const params = [];
872
995
  const where = buildWhere(args.where, {
873
996
  params,
@@ -885,9 +1008,29 @@ var Collection = class {
885
1008
  sql += (args.take != null ? "" : " LIMIT -1") + " OFFSET ?";
886
1009
  params.push(args.skip);
887
1010
  }
1011
+ return { sql, params };
1012
+ }
1013
+ /** @internal Synchronous core of findMany (used by reactivity). */
1014
+ findManyCore(args = {}) {
1015
+ this.ensureTable();
1016
+ const { sql, params } = this.buildFindSql(args);
888
1017
  const rows = this.db.prepare(sql).all(...params);
889
1018
  return rows.map((r) => project(this.rowToDoc(r), args.select));
890
1019
  }
1020
+ /** @internal Synchronous core of exists (used by reactivity). */
1021
+ existsCore(where) {
1022
+ this.ensureTable();
1023
+ const params = [];
1024
+ const clause = buildWhere(where, {
1025
+ params,
1026
+ onPath: this.trackPath,
1027
+ columns: this.columns
1028
+ });
1029
+ return this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params) != null;
1030
+ }
1031
+ async findMany(args = {}) {
1032
+ return this.findManyCore(args);
1033
+ }
891
1034
  async findFirst(args = {}) {
892
1035
  const rows = await this.findMany({ ...args, take: 1 });
893
1036
  return rows[0] ?? null;
@@ -904,15 +1047,39 @@ var Collection = class {
904
1047
  }
905
1048
  /** True if at least one document matches. */
906
1049
  async exists(where) {
1050
+ return this.existsCore(where);
1051
+ }
1052
+ /**
1053
+ * Subscribe to a live query. The callback fires immediately with the current
1054
+ * results (`type: "init"`) and again whenever a change affects the result set
1055
+ * (row-level: only relevant changes trigger a recompute). Includes changes
1056
+ * applied by `@monlite/sync`.
1057
+ */
1058
+ watch(args = {}, cb) {
907
1059
  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;
1060
+ const lq = new LiveQuery(this, args, cb);
1061
+ const reactor = this.mon.reactor;
1062
+ const name = this.name;
1063
+ reactor.register(name, lq);
1064
+ return {
1065
+ get results() {
1066
+ return lq.results;
1067
+ },
1068
+ stop() {
1069
+ lq.stopped = true;
1070
+ reactor.unregister(name, lq);
1071
+ }
1072
+ };
1073
+ }
1074
+ /** Show SQLite's query plan for a `findMany`, and whether it uses an index. */
1075
+ async explain(args = {}) {
1076
+ this.ensureTable();
1077
+ const { sql, params } = this.buildFindSql(args);
1078
+ const plan = this.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params);
1079
+ const usesIndex = plan.some(
1080
+ (r) => /USING (COVERING )?INDEX/i.test(r.detail)
1081
+ );
1082
+ return { sql, usesIndex, plan };
916
1083
  }
917
1084
  async findById(id) {
918
1085
  this.ensureTable();
@@ -969,25 +1136,27 @@ var Collection = class {
969
1136
  if (!rows.length) return [];
970
1137
  const now = Date.now();
971
1138
  const recorder = this.recorder;
972
- return this.guard(
1139
+ const out = this.guard(
973
1140
  () => this.db.transaction(() => {
974
- const out = [];
1141
+ const result = [];
975
1142
  for (const row of rows) {
976
1143
  const current = stripSystem(this.rowToDoc(row));
977
1144
  const updated = stripSystem(applyUpdate(current, data));
978
1145
  const { setSql, values } = this.buildUpdateSet(updated, now);
979
1146
  this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
980
1147
  recorder?.recordLocal(this.name, row._id, "upsert", now);
981
- out.push({
1148
+ result.push({
982
1149
  ...updated,
983
1150
  _id: row._id,
984
1151
  created_at: row.created_at,
985
1152
  updated_at: now
986
1153
  });
987
1154
  }
988
- return out;
1155
+ return result;
989
1156
  })
990
1157
  );
1158
+ this.afterWrite(out.map((d) => d._id));
1159
+ return out;
991
1160
  }
992
1161
  async update(args) {
993
1162
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -997,7 +1166,7 @@ var Collection = class {
997
1166
  }
998
1167
  async upsert(args) {
999
1168
  this.ensureTable();
1000
- return this.guard(
1169
+ const result = this.guard(
1001
1170
  () => this.db.transaction(() => {
1002
1171
  const params = [];
1003
1172
  const clause = buildWhere(args.where, {
@@ -1027,6 +1196,8 @@ var Collection = class {
1027
1196
  return ins.returned;
1028
1197
  })
1029
1198
  );
1199
+ this.afterWrite([result._id]);
1200
+ return result;
1030
1201
  }
1031
1202
  /* ----------------------------- delete ----------------------------- */
1032
1203
  runDelete(where, single) {
@@ -1052,6 +1223,7 @@ var Collection = class {
1052
1223
  }
1053
1224
  })
1054
1225
  );
1226
+ this.afterWrite(rows.map((r) => r._id));
1055
1227
  return rows.map((r) => this.rowToDoc(r));
1056
1228
  }
1057
1229
  async delete(args) {
@@ -1713,9 +1885,12 @@ var Monlite = class {
1713
1885
  driver;
1714
1886
  /** @internal */
1715
1887
  autoIndexer;
1888
+ /** @internal Reactivity hub for `collection.watch()`. */
1889
+ reactor = new Reactor();
1716
1890
  /** @internal Sync metadata store; present only when `{ sync: true }`. */
1717
1891
  $sync;
1718
1892
  collections = /* @__PURE__ */ new Map();
1893
+ plugins;
1719
1894
  closed = false;
1720
1895
  constructor(filename, options = {}) {
1721
1896
  this.driver = createDriver(filename, {
@@ -1733,6 +1908,15 @@ var Monlite = class {
1733
1908
  if (options.sync) {
1734
1909
  this.$sync = new SyncStore(this.driver, options.nodeId, this);
1735
1910
  }
1911
+ this.plugins = options.plugins ?? [];
1912
+ for (const plugin of this.plugins) plugin.init?.(this);
1913
+ }
1914
+ /** @internal Notify plugins that documents changed (post-commit). */
1915
+ firePluginAfterWrite(collection, ids) {
1916
+ if (this.plugins.length === 0 || ids.length === 0) return;
1917
+ for (const plugin of this.plugins) {
1918
+ plugin.afterWrite?.(this, { collection, ids });
1919
+ }
1736
1920
  }
1737
1921
  /** Stable node id for LWW tie-breaking (only when sync is enabled). */
1738
1922
  get nodeId() {
@@ -1758,6 +1942,13 @@ var Monlite = class {
1758
1942
  if (!col) {
1759
1943
  col = new Collection(this, name, options);
1760
1944
  this.collections.set(name, col);
1945
+ for (const plugin of this.plugins) {
1946
+ for (const [method, impl] of Object.entries(
1947
+ plugin.collectionMethods ?? {}
1948
+ )) {
1949
+ col[method] = (...args) => impl(col, ...args);
1950
+ }
1951
+ }
1761
1952
  } else if (options?.schema) {
1762
1953
  const requested = Object.keys(options.schema);
1763
1954
  const existing = new Set(col.columnNames);
@@ -1847,6 +2038,15 @@ var Monlite = class {
1847
2038
  async $dropAll() {
1848
2039
  for (const name of await this.$collections()) await this.$drop(name);
1849
2040
  }
2041
+ /**
2042
+ * Write a consistent on-disk snapshot of the database to `path` (via
2043
+ * `VACUUM INTO`). The destination file must not already exist.
2044
+ */
2045
+ backup(path) {
2046
+ this.assertOpen();
2047
+ this.driver.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
2048
+ return Promise.resolve();
2049
+ }
1850
2050
  /** Close the underlying SQLite connection. */
1851
2051
  $disconnect() {
1852
2052
  if (!this.closed) {