@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/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) {
@@ -602,6 +687,7 @@ var Collection = class {
602
687
  this.columns.add(field);
603
688
  if (normalized.type === "JSON") this.jsonColumns.add(field);
604
689
  }
690
+ this.ensureTable();
605
691
  }
606
692
  }
607
693
  mon;
@@ -646,23 +732,15 @@ var Collection = class {
646
732
  `_id TEXT PRIMARY KEY`,
647
733
  `created_at INTEGER NOT NULL`,
648
734
  `updated_at INTEGER NOT NULL`,
649
- `data TEXT NOT NULL DEFAULT '{}'`
735
+ `data TEXT NOT NULL DEFAULT '{}'`,
736
+ ...this.columnOrder.map((f) => this.columnDdl(f, false))
650
737
  ];
651
- for (const field of this.columnOrder) {
652
- const def = this.columnDefs[field];
653
- let line = `"${field}" ${sqliteType(def.type)}`;
654
- if (def.notNull) line += " NOT NULL";
655
- if (def.unique) line += " UNIQUE";
656
- if (def.default !== void 0)
657
- line += ` DEFAULT ${formatDefault(def.default)}`;
658
- if (def.references) line += ` REFERENCES ${def.references}`;
659
- lines.push(line);
660
- }
661
738
  this.db.exec(
662
739
  `CREATE TABLE IF NOT EXISTS "${this.name}" (
663
740
  ${lines.join(",\n ")}
664
741
  )`
665
742
  );
743
+ this.migrateColumns();
666
744
  for (const field of this.columnOrder) {
667
745
  if (this.columnDefs[field].index) {
668
746
  this.db.exec(
@@ -673,6 +751,39 @@ var Collection = class {
673
751
  }
674
752
  this.initialized = true;
675
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
+ }
676
787
  /* --------------------------- row <-> doc -------------------------- */
677
788
  rowToDoc(row) {
678
789
  const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
@@ -795,6 +906,7 @@ var Collection = class {
795
906
  this.ensureTable();
796
907
  if (op === "delete") {
797
908
  this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`).run(id);
909
+ this.afterWrite([id]);
798
910
  return;
799
911
  }
800
912
  const clean = stripSystem(doc ?? {});
@@ -804,6 +916,7 @@ var Collection = class {
804
916
  `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
805
917
  ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
806
918
  ).run(id, JSON.stringify(clean), createdAt, ts);
919
+ this.afterWrite([id]);
807
920
  return;
808
921
  }
809
922
  const overflow = {};
@@ -834,6 +947,11 @@ var Collection = class {
834
947
  this.db.prepare(
835
948
  `INSERT INTO "${this.name}" (${colList}) VALUES (${placeholders}) ON CONFLICT(_id) DO UPDATE SET ${updateSet}`
836
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);
837
955
  }
838
956
  /* ----------------------------- create ----------------------------- */
839
957
  async create(args) {
@@ -845,26 +963,29 @@ var Collection = class {
845
963
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
846
964
  };
847
965
  this.guard(() => recorder ? this.db.transaction(write) : write());
966
+ this.afterWrite([row._id]);
848
967
  return row.returned;
849
968
  }
850
969
  async createMany(args) {
851
970
  this.ensureTable();
852
971
  const stmt = this.db.prepare(this.insertSql());
853
972
  const recorder = this.recorder;
973
+ const ids = [];
854
974
  this.guard(
855
975
  () => this.db.transaction(() => {
856
976
  for (const item of args.data) {
857
977
  const row = this.buildInsert(item);
858
978
  stmt.run(...row.values);
859
979
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
980
+ ids.push(row._id);
860
981
  }
861
982
  })
862
983
  );
984
+ this.afterWrite(ids);
863
985
  return { count: args.data.length };
864
986
  }
865
987
  /* ------------------------------ read ------------------------------ */
866
- async findMany(args = {}) {
867
- this.ensureTable();
988
+ buildFindSql(args) {
868
989
  const params = [];
869
990
  const where = buildWhere(args.where, {
870
991
  params,
@@ -882,9 +1003,29 @@ var Collection = class {
882
1003
  sql += (args.take != null ? "" : " LIMIT -1") + " OFFSET ?";
883
1004
  params.push(args.skip);
884
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);
885
1012
  const rows = this.db.prepare(sql).all(...params);
886
1013
  return rows.map((r) => project(this.rowToDoc(r), args.select));
887
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
+ }
888
1029
  async findFirst(args = {}) {
889
1030
  const rows = await this.findMany({ ...args, take: 1 });
890
1031
  return rows[0] ?? null;
@@ -901,15 +1042,39 @@ var Collection = class {
901
1042
  }
902
1043
  /** True if at least one document matches. */
903
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) {
904
1054
  this.ensureTable();
905
- const params = [];
906
- const clause = buildWhere(where, {
907
- params,
908
- onPath: this.trackPath,
909
- columns: this.columns
910
- });
911
- const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
912
- 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 };
913
1078
  }
914
1079
  async findById(id) {
915
1080
  this.ensureTable();
@@ -966,25 +1131,27 @@ var Collection = class {
966
1131
  if (!rows.length) return [];
967
1132
  const now = Date.now();
968
1133
  const recorder = this.recorder;
969
- return this.guard(
1134
+ const out = this.guard(
970
1135
  () => this.db.transaction(() => {
971
- const out = [];
1136
+ const result = [];
972
1137
  for (const row of rows) {
973
1138
  const current = stripSystem(this.rowToDoc(row));
974
1139
  const updated = stripSystem(applyUpdate(current, data));
975
1140
  const { setSql, values } = this.buildUpdateSet(updated, now);
976
1141
  this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
977
1142
  recorder?.recordLocal(this.name, row._id, "upsert", now);
978
- out.push({
1143
+ result.push({
979
1144
  ...updated,
980
1145
  _id: row._id,
981
1146
  created_at: row.created_at,
982
1147
  updated_at: now
983
1148
  });
984
1149
  }
985
- return out;
1150
+ return result;
986
1151
  })
987
1152
  );
1153
+ this.afterWrite(out.map((d) => d._id));
1154
+ return out;
988
1155
  }
989
1156
  async update(args) {
990
1157
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -994,7 +1161,7 @@ var Collection = class {
994
1161
  }
995
1162
  async upsert(args) {
996
1163
  this.ensureTable();
997
- return this.guard(
1164
+ const result = this.guard(
998
1165
  () => this.db.transaction(() => {
999
1166
  const params = [];
1000
1167
  const clause = buildWhere(args.where, {
@@ -1024,6 +1191,8 @@ var Collection = class {
1024
1191
  return ins.returned;
1025
1192
  })
1026
1193
  );
1194
+ this.afterWrite([result._id]);
1195
+ return result;
1027
1196
  }
1028
1197
  /* ----------------------------- delete ----------------------------- */
1029
1198
  runDelete(where, single) {
@@ -1049,6 +1218,7 @@ var Collection = class {
1049
1218
  }
1050
1219
  })
1051
1220
  );
1221
+ this.afterWrite(rows.map((r) => r._id));
1052
1222
  return rows.map((r) => this.rowToDoc(r));
1053
1223
  }
1054
1224
  async delete(args) {
@@ -1710,6 +1880,8 @@ var Monlite = class {
1710
1880
  driver;
1711
1881
  /** @internal */
1712
1882
  autoIndexer;
1883
+ /** @internal Reactivity hub for `collection.watch()`. */
1884
+ reactor = new Reactor();
1713
1885
  /** @internal Sync metadata store; present only when `{ sync: true }`. */
1714
1886
  $sync;
1715
1887
  collections = /* @__PURE__ */ new Map();
@@ -1844,6 +2016,15 @@ var Monlite = class {
1844
2016
  async $dropAll() {
1845
2017
  for (const name of await this.$collections()) await this.$drop(name);
1846
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
+ }
1847
2028
  /** Close the underlying SQLite connection. */
1848
2029
  $disconnect() {
1849
2030
  if (!this.closed) {