@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/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,13 @@ 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 and plugins that documents changed. */
953
+ afterWrite(ids) {
954
+ if (ids.length === 0) return;
955
+ this.mon.reactor.emit(this.name, ids);
956
+ this.mon.firePluginAfterWrite(this.name, ids);
837
957
  }
838
958
  /* ----------------------------- create ----------------------------- */
839
959
  async create(args) {
@@ -845,26 +965,29 @@ var Collection = class {
845
965
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
846
966
  };
847
967
  this.guard(() => recorder ? this.db.transaction(write) : write());
968
+ this.afterWrite([row._id]);
848
969
  return row.returned;
849
970
  }
850
971
  async createMany(args) {
851
972
  this.ensureTable();
852
973
  const stmt = this.db.prepare(this.insertSql());
853
974
  const recorder = this.recorder;
975
+ const ids = [];
854
976
  this.guard(
855
977
  () => this.db.transaction(() => {
856
978
  for (const item of args.data) {
857
979
  const row = this.buildInsert(item);
858
980
  stmt.run(...row.values);
859
981
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
982
+ ids.push(row._id);
860
983
  }
861
984
  })
862
985
  );
986
+ this.afterWrite(ids);
863
987
  return { count: args.data.length };
864
988
  }
865
989
  /* ------------------------------ read ------------------------------ */
866
- async findMany(args = {}) {
867
- this.ensureTable();
990
+ buildFindSql(args) {
868
991
  const params = [];
869
992
  const where = buildWhere(args.where, {
870
993
  params,
@@ -882,9 +1005,29 @@ var Collection = class {
882
1005
  sql += (args.take != null ? "" : " LIMIT -1") + " OFFSET ?";
883
1006
  params.push(args.skip);
884
1007
  }
1008
+ return { sql, params };
1009
+ }
1010
+ /** @internal Synchronous core of findMany (used by reactivity). */
1011
+ findManyCore(args = {}) {
1012
+ this.ensureTable();
1013
+ const { sql, params } = this.buildFindSql(args);
885
1014
  const rows = this.db.prepare(sql).all(...params);
886
1015
  return rows.map((r) => project(this.rowToDoc(r), args.select));
887
1016
  }
1017
+ /** @internal Synchronous core of exists (used by reactivity). */
1018
+ existsCore(where) {
1019
+ this.ensureTable();
1020
+ const params = [];
1021
+ const clause = buildWhere(where, {
1022
+ params,
1023
+ onPath: this.trackPath,
1024
+ columns: this.columns
1025
+ });
1026
+ return this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params) != null;
1027
+ }
1028
+ async findMany(args = {}) {
1029
+ return this.findManyCore(args);
1030
+ }
888
1031
  async findFirst(args = {}) {
889
1032
  const rows = await this.findMany({ ...args, take: 1 });
890
1033
  return rows[0] ?? null;
@@ -901,15 +1044,39 @@ var Collection = class {
901
1044
  }
902
1045
  /** True if at least one document matches. */
903
1046
  async exists(where) {
1047
+ return this.existsCore(where);
1048
+ }
1049
+ /**
1050
+ * Subscribe to a live query. The callback fires immediately with the current
1051
+ * results (`type: "init"`) and again whenever a change affects the result set
1052
+ * (row-level: only relevant changes trigger a recompute). Includes changes
1053
+ * applied by `@monlite/sync`.
1054
+ */
1055
+ watch(args = {}, cb) {
904
1056
  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;
1057
+ const lq = new LiveQuery(this, args, cb);
1058
+ const reactor = this.mon.reactor;
1059
+ const name = this.name;
1060
+ reactor.register(name, lq);
1061
+ return {
1062
+ get results() {
1063
+ return lq.results;
1064
+ },
1065
+ stop() {
1066
+ lq.stopped = true;
1067
+ reactor.unregister(name, lq);
1068
+ }
1069
+ };
1070
+ }
1071
+ /** Show SQLite's query plan for a `findMany`, and whether it uses an index. */
1072
+ async explain(args = {}) {
1073
+ this.ensureTable();
1074
+ const { sql, params } = this.buildFindSql(args);
1075
+ const plan = this.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params);
1076
+ const usesIndex = plan.some(
1077
+ (r) => /USING (COVERING )?INDEX/i.test(r.detail)
1078
+ );
1079
+ return { sql, usesIndex, plan };
913
1080
  }
914
1081
  async findById(id) {
915
1082
  this.ensureTable();
@@ -966,25 +1133,27 @@ var Collection = class {
966
1133
  if (!rows.length) return [];
967
1134
  const now = Date.now();
968
1135
  const recorder = this.recorder;
969
- return this.guard(
1136
+ const out = this.guard(
970
1137
  () => this.db.transaction(() => {
971
- const out = [];
1138
+ const result = [];
972
1139
  for (const row of rows) {
973
1140
  const current = stripSystem(this.rowToDoc(row));
974
1141
  const updated = stripSystem(applyUpdate(current, data));
975
1142
  const { setSql, values } = this.buildUpdateSet(updated, now);
976
1143
  this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
977
1144
  recorder?.recordLocal(this.name, row._id, "upsert", now);
978
- out.push({
1145
+ result.push({
979
1146
  ...updated,
980
1147
  _id: row._id,
981
1148
  created_at: row.created_at,
982
1149
  updated_at: now
983
1150
  });
984
1151
  }
985
- return out;
1152
+ return result;
986
1153
  })
987
1154
  );
1155
+ this.afterWrite(out.map((d) => d._id));
1156
+ return out;
988
1157
  }
989
1158
  async update(args) {
990
1159
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -994,7 +1163,7 @@ var Collection = class {
994
1163
  }
995
1164
  async upsert(args) {
996
1165
  this.ensureTable();
997
- return this.guard(
1166
+ const result = this.guard(
998
1167
  () => this.db.transaction(() => {
999
1168
  const params = [];
1000
1169
  const clause = buildWhere(args.where, {
@@ -1024,6 +1193,8 @@ var Collection = class {
1024
1193
  return ins.returned;
1025
1194
  })
1026
1195
  );
1196
+ this.afterWrite([result._id]);
1197
+ return result;
1027
1198
  }
1028
1199
  /* ----------------------------- delete ----------------------------- */
1029
1200
  runDelete(where, single) {
@@ -1049,6 +1220,7 @@ var Collection = class {
1049
1220
  }
1050
1221
  })
1051
1222
  );
1223
+ this.afterWrite(rows.map((r) => r._id));
1052
1224
  return rows.map((r) => this.rowToDoc(r));
1053
1225
  }
1054
1226
  async delete(args) {
@@ -1710,9 +1882,12 @@ var Monlite = class {
1710
1882
  driver;
1711
1883
  /** @internal */
1712
1884
  autoIndexer;
1885
+ /** @internal Reactivity hub for `collection.watch()`. */
1886
+ reactor = new Reactor();
1713
1887
  /** @internal Sync metadata store; present only when `{ sync: true }`. */
1714
1888
  $sync;
1715
1889
  collections = /* @__PURE__ */ new Map();
1890
+ plugins;
1716
1891
  closed = false;
1717
1892
  constructor(filename, options = {}) {
1718
1893
  this.driver = createDriver(filename, {
@@ -1730,6 +1905,15 @@ var Monlite = class {
1730
1905
  if (options.sync) {
1731
1906
  this.$sync = new SyncStore(this.driver, options.nodeId, this);
1732
1907
  }
1908
+ this.plugins = options.plugins ?? [];
1909
+ for (const plugin of this.plugins) plugin.init?.(this);
1910
+ }
1911
+ /** @internal Notify plugins that documents changed (post-commit). */
1912
+ firePluginAfterWrite(collection, ids) {
1913
+ if (this.plugins.length === 0 || ids.length === 0) return;
1914
+ for (const plugin of this.plugins) {
1915
+ plugin.afterWrite?.(this, { collection, ids });
1916
+ }
1733
1917
  }
1734
1918
  /** Stable node id for LWW tie-breaking (only when sync is enabled). */
1735
1919
  get nodeId() {
@@ -1755,6 +1939,13 @@ var Monlite = class {
1755
1939
  if (!col) {
1756
1940
  col = new Collection(this, name, options);
1757
1941
  this.collections.set(name, col);
1942
+ for (const plugin of this.plugins) {
1943
+ for (const [method, impl] of Object.entries(
1944
+ plugin.collectionMethods ?? {}
1945
+ )) {
1946
+ col[method] = (...args) => impl(col, ...args);
1947
+ }
1948
+ }
1758
1949
  } else if (options?.schema) {
1759
1950
  const requested = Object.keys(options.schema);
1760
1951
  const existing = new Set(col.columnNames);
@@ -1844,6 +2035,15 @@ var Monlite = class {
1844
2035
  async $dropAll() {
1845
2036
  for (const name of await this.$collections()) await this.$drop(name);
1846
2037
  }
2038
+ /**
2039
+ * Write a consistent on-disk snapshot of the database to `path` (via
2040
+ * `VACUUM INTO`). The destination file must not already exist.
2041
+ */
2042
+ backup(path) {
2043
+ this.assertOpen();
2044
+ this.driver.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
2045
+ return Promise.resolve();
2046
+ }
1847
2047
  /** Close the underlying SQLite connection. */
1848
2048
  $disconnect() {
1849
2049
  if (!this.closed) {