@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 +38 -0
- package/dist/index.cjs +207 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +99 -1
- package/dist/index.d.ts +99 -1
- package/dist/index.js +207 -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,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
|
-
|
|
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
|
|
906
|
-
const
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
1134
|
+
const out = this.guard(
|
|
970
1135
|
() => this.db.transaction(() => {
|
|
971
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|