@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 +63 -0
- package/dist/index.cjs +226 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +210 -80
- package/dist/index.d.ts +210 -80
- package/dist/index.js +226 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
906
|
-
const
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
1136
|
+
const out = this.guard(
|
|
970
1137
|
() => this.db.transaction(() => {
|
|
971
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|