@monlite/core 1.0.0 → 1.2.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 +89 -2
- package/dist/index.cjs +194 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -1
- package/dist/index.d.ts +56 -1
- package/dist/index.js +194 -1
- package/dist/index.js.map +1 -1
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -389,8 +389,21 @@ definition supports `index`, `unique`, `notNull`, `default`, and `references`.
|
|
|
389
389
|
|
|
390
390
|
**Migrations are automatic for additive changes.** Re-opening a collection with a
|
|
391
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.
|
|
393
|
-
|
|
392
|
+
`NOT NULL` columns a `default` so existing rows can be backfilled.
|
|
393
|
+
|
|
394
|
+
For **destructive changes** — dropping, renaming, or changing a column's
|
|
395
|
+
type/constraints — call `$migrate()`. It safely rebuilds the table (in a
|
|
396
|
+
transaction, preserving data and indexes) to match the new declared schema:
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
// v2 schema: `fullname` → `name`, `age` is now INTEGER, `legacy` removed.
|
|
400
|
+
const users = db.collection("users", { schema: { name: "TEXT", age: "INTEGER" } });
|
|
401
|
+
await users.$migrate({ rename: { fullname: "name" }, drop: ["legacy"] });
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
A column that exists on disk but isn't in the new schema (and isn't listed in
|
|
405
|
+
`drop`) throws — so you never lose data by accident. Run migrations at startup,
|
|
406
|
+
before using the collection.
|
|
394
407
|
|
|
395
408
|
### Do I have to care: JSON vs native columns?
|
|
396
409
|
|
|
@@ -551,6 +564,40 @@ Write your own against the `MonlitePlugin` interface (`init` / `afterWrite` /
|
|
|
551
564
|
|
|
552
565
|
---
|
|
553
566
|
|
|
567
|
+
## The local backend for AI agents
|
|
568
|
+
|
|
569
|
+
monlite aims to be your **entire local data layer** — one embedded `.db`, one
|
|
570
|
+
install — collapsing the services you'd otherwise run (Mongo, Qdrant, Redis) for
|
|
571
|
+
a local/edge/desktop agent. Documents and vectors are core + a plugin; the
|
|
572
|
+
Redis-style primitives are small companion packages:
|
|
573
|
+
|
|
574
|
+
| Package | Replaces (locally) | Provides |
|
|
575
|
+
|---|---|---|
|
|
576
|
+
| [`@monlite/kv`](https://www.npmjs.com/package/@monlite/kv) | Redis cache | Synchronous `get/set/incr` KV with TTLs |
|
|
577
|
+
| [`@monlite/queue`](https://www.npmjs.com/package/@monlite/queue) | Redis / BullMQ | Durable job queue — retries, backoff, delays, priorities, concurrency |
|
|
578
|
+
| [`@monlite/cron`](https://www.npmjs.com/package/@monlite/cron) | cron / scheduler | Persisted cron schedules; composes with the queue |
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
import { kv } from "@monlite/kv";
|
|
582
|
+
import { createQueue } from "@monlite/queue";
|
|
583
|
+
import { createCron } from "@monlite/cron";
|
|
584
|
+
|
|
585
|
+
const cache = kv(db);
|
|
586
|
+
cache.set("session:42", { user: "ali" }, { ttl: 60_000 });
|
|
587
|
+
|
|
588
|
+
const queue = createQueue(db, { maxAttempts: 3 });
|
|
589
|
+
queue.process("email", async (job) => send(job.payload), { concurrency: 5 });
|
|
590
|
+
queue.add("email", { to: "ali@example.com" });
|
|
591
|
+
|
|
592
|
+
createCron(db).schedule("nightly", "0 0 * * *", () => queue.add("report", {}));
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
These target **local / edge / desktop** runtimes — not a distributed cloud-scale
|
|
596
|
+
Redis/Mongo/Qdrant replacement. For scale, keep the real services and
|
|
597
|
+
[`@monlite/sync`](https://www.npmjs.com/package/@monlite/sync) to them.
|
|
598
|
+
|
|
599
|
+
---
|
|
600
|
+
|
|
554
601
|
## Drivers & zero dependencies
|
|
555
602
|
|
|
556
603
|
monlite talks to SQLite through a tiny driver adapter, so it runs on two
|
|
@@ -578,6 +625,30 @@ based on your Node version and whether you want the extra dependency.
|
|
|
578
625
|
|
|
579
626
|
---
|
|
580
627
|
|
|
628
|
+
## Encryption at rest
|
|
629
|
+
|
|
630
|
+
Encrypt the whole database file with a key. Install the drop-in cipher driver
|
|
631
|
+
and pass an `encryption` option:
|
|
632
|
+
|
|
633
|
+
```bash
|
|
634
|
+
npm install better-sqlite3-multiple-ciphers
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
const db = createDb("./secure.db", { encryption: { key: process.env.DB_KEY } });
|
|
639
|
+
// ...use db exactly as normal — everything on disk is encrypted.
|
|
640
|
+
|
|
641
|
+
db.rekey(newKey); // rotate the key
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
- A **wrong or missing key throws `MonliteEncryptionError`** when opening.
|
|
645
|
+
- Optional `cipher` selects the scheme (`"sqlcipher"`, `"chacha20"`,
|
|
646
|
+
`"aes256cbc"`, …); the default is ChaCha20-Poly1305.
|
|
647
|
+
- Encryption requires `better-sqlite3-multiple-ciphers` (a drop-in for
|
|
648
|
+
`better-sqlite3`) and is **not** available on the `node:sqlite` backend.
|
|
649
|
+
|
|
650
|
+
---
|
|
651
|
+
|
|
581
652
|
## How it works
|
|
582
653
|
|
|
583
654
|
Every collection is a single SQLite table:
|
|
@@ -610,6 +681,22 @@ future-proofing.
|
|
|
610
681
|
|
|
611
682
|
---
|
|
612
683
|
|
|
684
|
+
## Examples
|
|
685
|
+
|
|
686
|
+
Runnable demos live in [`examples/`](examples/): a notes app (CRUD + full-text
|
|
687
|
+
search + live queries), AI-agent memory (vector + hybrid search), and local-first
|
|
688
|
+
sync. `cd examples && npm install && node notes.mjs`.
|
|
689
|
+
|
|
690
|
+
## Benchmarks
|
|
691
|
+
|
|
692
|
+
[`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) compares monlite to the raw SQLite
|
|
693
|
+
driver, NeDB, and lowdb (`pnpm bench` to reproduce). In short: ~150k–250k
|
|
694
|
+
ops/sec, roughly 2× the raw-driver overhead for the full document API, and it
|
|
695
|
+
**stays flat on indexed reads where JSON-file stores degrade** (lowdb point reads
|
|
696
|
+
are ~15× slower at 10k docs).
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
613
700
|
## License
|
|
614
701
|
|
|
615
702
|
MIT 🌙
|
package/dist/index.cjs
CHANGED
|
@@ -119,6 +119,12 @@ var MonliteQueryError = class extends MonliteError {
|
|
|
119
119
|
this.name = "MonliteQueryError";
|
|
120
120
|
}
|
|
121
121
|
};
|
|
122
|
+
var MonliteEncryptionError = class extends MonliteError {
|
|
123
|
+
constructor(message, options) {
|
|
124
|
+
super(message, options);
|
|
125
|
+
this.name = "MonliteEncryptionError";
|
|
126
|
+
}
|
|
127
|
+
};
|
|
122
128
|
var MonliteConstraintError = class extends MonliteError {
|
|
123
129
|
collection;
|
|
124
130
|
constructor(message, options) {
|
|
@@ -787,6 +793,124 @@ var Collection = class {
|
|
|
787
793
|
}
|
|
788
794
|
}
|
|
789
795
|
}
|
|
796
|
+
foreignKeysOn() {
|
|
797
|
+
try {
|
|
798
|
+
const row = this.db.prepare(`PRAGMA foreign_keys`).get();
|
|
799
|
+
return !!row?.foreign_keys;
|
|
800
|
+
} catch {
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
declaredIndexDdl() {
|
|
805
|
+
const out = [];
|
|
806
|
+
for (const field of this.columnOrder) {
|
|
807
|
+
if (this.columnDefs[field].index) {
|
|
808
|
+
out.push(
|
|
809
|
+
`CREATE INDEX IF NOT EXISTS "idx_${this.name}_${field}" ON "${this.name}"("${field}")`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Reconcile the physical table to the declared schema, performing the changes
|
|
817
|
+
* the auto-additive path can't: **dropping** columns, **renaming** them, and
|
|
818
|
+
* **changing a column's type/constraints** — via a safe, transactional table
|
|
819
|
+
* rebuild that preserves data. Structured collections only.
|
|
820
|
+
*
|
|
821
|
+
* Pass `rename` to map an existing physical column to a new declared name, and
|
|
822
|
+
* `drop` to acknowledge columns that the new schema removes (an unacknowledged
|
|
823
|
+
* column drop throws, so data is never lost by accident).
|
|
824
|
+
*
|
|
825
|
+
* ```ts
|
|
826
|
+
* const users = db.collection("users", { schema: { name: "TEXT", age: "INTEGER" } });
|
|
827
|
+
* await users.$migrate({ rename: { fullname: "name" }, drop: ["legacy"] });
|
|
828
|
+
* ```
|
|
829
|
+
*/
|
|
830
|
+
async $migrate(options = {}) {
|
|
831
|
+
if (this.mode !== "structured") {
|
|
832
|
+
throw new MonliteError(
|
|
833
|
+
`$migrate() is only available on structured collections (declare a schema).`
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
this.ensureTable();
|
|
837
|
+
const rename = options.rename ?? {};
|
|
838
|
+
const drop = new Set(options.drop ?? []);
|
|
839
|
+
const SYSTEM = /* @__PURE__ */ new Set(["_id", "created_at", "updated_at", "data"]);
|
|
840
|
+
const physical = this.db.prepare(`PRAGMA table_info("${this.name}")`).all().map((r) => r.name).filter((n) => !SYSTEM.has(n));
|
|
841
|
+
const physicalSet = new Set(physical);
|
|
842
|
+
const targetSet = new Set(this.columnOrder);
|
|
843
|
+
const renamedFrom = {};
|
|
844
|
+
for (const [from, to] of Object.entries(rename)) {
|
|
845
|
+
if (!physicalSet.has(from)) {
|
|
846
|
+
throw new MonliteError(
|
|
847
|
+
`Cannot rename "${from}": no such column in "${this.name}".`
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
if (!targetSet.has(to)) {
|
|
851
|
+
throw new MonliteError(
|
|
852
|
+
`Cannot rename "${from}" to "${to}": "${to}" is not in the schema.`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
renamedFrom[to] = from;
|
|
856
|
+
}
|
|
857
|
+
const renameSources = new Set(Object.keys(rename));
|
|
858
|
+
for (const col of physical) {
|
|
859
|
+
if (targetSet.has(col) || renameSources.has(col) || drop.has(col))
|
|
860
|
+
continue;
|
|
861
|
+
throw new MonliteError(
|
|
862
|
+
`Column "${col}" exists in "${this.name}" but isn't in the schema. Add it to the schema, or list it in \`drop\` to remove it.`
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
const tmp = `__mon_migrate_${this.name}`;
|
|
866
|
+
const newCols = [
|
|
867
|
+
`_id TEXT PRIMARY KEY`,
|
|
868
|
+
`created_at INTEGER NOT NULL`,
|
|
869
|
+
`updated_at INTEGER NOT NULL`,
|
|
870
|
+
`data TEXT NOT NULL DEFAULT '{}'`,
|
|
871
|
+
...this.columnOrder.map((f) => this.columnDdl(f, false))
|
|
872
|
+
];
|
|
873
|
+
const destCols = [
|
|
874
|
+
"_id",
|
|
875
|
+
"created_at",
|
|
876
|
+
"updated_at",
|
|
877
|
+
"data",
|
|
878
|
+
...this.columnOrder.map((f) => `"${f}"`)
|
|
879
|
+
].join(", ");
|
|
880
|
+
const srcCols = [
|
|
881
|
+
"_id",
|
|
882
|
+
"created_at",
|
|
883
|
+
"updated_at",
|
|
884
|
+
"data",
|
|
885
|
+
...this.columnOrder.map((t) => {
|
|
886
|
+
const src = renamedFrom[t] ?? (physicalSet.has(t) ? t : null);
|
|
887
|
+
return src ? `"${src}"` : "NULL";
|
|
888
|
+
})
|
|
889
|
+
].join(", ");
|
|
890
|
+
const fkOn = this.foreignKeysOn();
|
|
891
|
+
if (fkOn) this.db.exec(`PRAGMA foreign_keys = OFF`);
|
|
892
|
+
try {
|
|
893
|
+
this.guard(
|
|
894
|
+
() => this.db.transaction(() => {
|
|
895
|
+
this.db.exec(`DROP TABLE IF EXISTS "${tmp}"`);
|
|
896
|
+
this.db.exec(
|
|
897
|
+
`CREATE TABLE "${tmp}" (
|
|
898
|
+
${newCols.join(",\n ")}
|
|
899
|
+
)`
|
|
900
|
+
);
|
|
901
|
+
this.db.exec(
|
|
902
|
+
`INSERT INTO "${tmp}" (${destCols}) SELECT ${srcCols} FROM "${this.name}"`
|
|
903
|
+
);
|
|
904
|
+
this.db.exec(`DROP TABLE "${this.name}"`);
|
|
905
|
+
this.db.exec(`ALTER TABLE "${tmp}" RENAME TO "${this.name}"`);
|
|
906
|
+
for (const ddl of this.declaredIndexDdl()) this.db.exec(ddl);
|
|
907
|
+
})
|
|
908
|
+
);
|
|
909
|
+
} finally {
|
|
910
|
+
if (fkOn) this.db.exec(`PRAGMA foreign_keys = ON`);
|
|
911
|
+
}
|
|
912
|
+
this.insertSqlCache = void 0;
|
|
913
|
+
}
|
|
790
914
|
/* --------------------------- row <-> doc -------------------------- */
|
|
791
915
|
rowToDoc(row) {
|
|
792
916
|
const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
|
|
@@ -1317,6 +1441,7 @@ var AutoIndexer = class {
|
|
|
1317
1441
|
|
|
1318
1442
|
// src/driver/better-sqlite3.ts
|
|
1319
1443
|
var STMT_CACHE_MAX = 256;
|
|
1444
|
+
var quote = (s) => s.replace(/'/g, "''");
|
|
1320
1445
|
var BetterSqlite3Driver = class {
|
|
1321
1446
|
name = "better-sqlite3";
|
|
1322
1447
|
raw;
|
|
@@ -1327,6 +1452,9 @@ var BetterSqlite3Driver = class {
|
|
|
1327
1452
|
this.raw = new BetterSqlite3(filename, {
|
|
1328
1453
|
readonly: options.readonly ?? false
|
|
1329
1454
|
});
|
|
1455
|
+
if (options.encryption) {
|
|
1456
|
+
this.applyKey(options.encryption.key, options.encryption.cipher);
|
|
1457
|
+
}
|
|
1330
1458
|
this.raw.pragma("foreign_keys = ON");
|
|
1331
1459
|
this.raw.pragma(`busy_timeout = ${options.busyTimeout ?? 5e3}`);
|
|
1332
1460
|
if (!options.readonly && (options.wal ?? true)) {
|
|
@@ -1355,6 +1483,28 @@ var BetterSqlite3Driver = class {
|
|
|
1355
1483
|
transaction(fn) {
|
|
1356
1484
|
return this.raw.transaction(fn)();
|
|
1357
1485
|
}
|
|
1486
|
+
/** Apply the encryption key and verify it by reading the schema. */
|
|
1487
|
+
applyKey(key, cipher) {
|
|
1488
|
+
if (cipher) this.raw.pragma(`cipher='${quote(cipher)}'`);
|
|
1489
|
+
this.raw.pragma(`key='${quote(key)}'`);
|
|
1490
|
+
try {
|
|
1491
|
+
this.raw.exec("SELECT count(*) FROM sqlite_master");
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
this.raw.close();
|
|
1494
|
+
throw new MonliteEncryptionError(
|
|
1495
|
+
"Failed to open the encrypted database: the key is incorrect, or the file is not encrypted.",
|
|
1496
|
+
{ cause: err }
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
rekey(key, cipher) {
|
|
1501
|
+
if (cipher) this.raw.pragma(`cipher='${quote(cipher)}'`);
|
|
1502
|
+
const mode = String(this.raw.pragma("journal_mode", { simple: true }));
|
|
1503
|
+
const wasWal = mode.toLowerCase() === "wal";
|
|
1504
|
+
if (wasWal) this.raw.pragma("journal_mode = DELETE");
|
|
1505
|
+
this.raw.pragma(`rekey='${quote(key)}'`);
|
|
1506
|
+
if (wasWal) this.raw.pragma("journal_mode = WAL");
|
|
1507
|
+
}
|
|
1358
1508
|
close() {
|
|
1359
1509
|
this.cache.clear();
|
|
1360
1510
|
this.raw.close();
|
|
@@ -1370,6 +1520,11 @@ var NodeSqliteDriver = class {
|
|
|
1370
1520
|
cache = /* @__PURE__ */ new Map();
|
|
1371
1521
|
depth = 0;
|
|
1372
1522
|
constructor(nodeSqlite, filename, options) {
|
|
1523
|
+
if (options.encryption) {
|
|
1524
|
+
throw new MonliteError(
|
|
1525
|
+
"Encryption is not supported on the node:sqlite backend. Use better-sqlite3 with the better-sqlite3-multiple-ciphers package."
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1373
1528
|
this.verbose = options.verbose;
|
|
1374
1529
|
const { DatabaseSync } = nodeSqlite;
|
|
1375
1530
|
this.raw = new DatabaseSync(filename, {
|
|
@@ -1445,6 +1600,14 @@ function loadBetterSqlite3() {
|
|
|
1445
1600
|
return null;
|
|
1446
1601
|
}
|
|
1447
1602
|
}
|
|
1603
|
+
function loadCipherSqlite3() {
|
|
1604
|
+
try {
|
|
1605
|
+
const mod = req("better-sqlite3-multiple-ciphers");
|
|
1606
|
+
return mod?.default ?? mod;
|
|
1607
|
+
} catch {
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1448
1611
|
function loadNodeSqlite() {
|
|
1449
1612
|
try {
|
|
1450
1613
|
return req("node:sqlite");
|
|
@@ -1454,6 +1617,20 @@ function loadNodeSqlite() {
|
|
|
1454
1617
|
}
|
|
1455
1618
|
function createDriver(filename, options = {}) {
|
|
1456
1619
|
const choice = options.driver ?? "auto";
|
|
1620
|
+
if (options.encryption) {
|
|
1621
|
+
if (choice === "node:sqlite") {
|
|
1622
|
+
throw new MonliteError(
|
|
1623
|
+
`Encryption is not supported on the node:sqlite backend. Use better-sqlite3 with the better-sqlite3-multiple-ciphers package.`
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
const cipher = loadCipherSqlite3();
|
|
1627
|
+
if (!cipher) {
|
|
1628
|
+
throw new MonliteError(
|
|
1629
|
+
`Encryption requires the "better-sqlite3-multiple-ciphers" package (a drop-in for better-sqlite3). Run \`npm install better-sqlite3-multiple-ciphers\`.`
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
return new BetterSqlite3Driver(cipher, filename, options);
|
|
1633
|
+
}
|
|
1457
1634
|
if (choice === "better-sqlite3") {
|
|
1458
1635
|
const mod = loadBetterSqlite3();
|
|
1459
1636
|
if (!mod) {
|
|
@@ -1893,6 +2070,7 @@ var Monlite = class {
|
|
|
1893
2070
|
$sync;
|
|
1894
2071
|
collections = /* @__PURE__ */ new Map();
|
|
1895
2072
|
plugins;
|
|
2073
|
+
encrypted;
|
|
1896
2074
|
closed = false;
|
|
1897
2075
|
constructor(filename, options = {}) {
|
|
1898
2076
|
this.driver = createDriver(filename, {
|
|
@@ -1901,8 +2079,10 @@ var Monlite = class {
|
|
|
1901
2079
|
wal: options.wal,
|
|
1902
2080
|
busyTimeout: options.busyTimeout,
|
|
1903
2081
|
allowExtensions: options.allowExtensions,
|
|
2082
|
+
encryption: options.encryption,
|
|
1904
2083
|
verbose: options.verbose
|
|
1905
2084
|
});
|
|
2085
|
+
this.encrypted = options.encryption !== void 0;
|
|
1906
2086
|
this.autoIndexer = new AutoIndexer(
|
|
1907
2087
|
this.driver,
|
|
1908
2088
|
options.autoIndex ?? true,
|
|
@@ -2050,6 +2230,19 @@ var Monlite = class {
|
|
|
2050
2230
|
this.driver.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
|
|
2051
2231
|
return Promise.resolve();
|
|
2052
2232
|
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Rotate the encryption key. Only valid for a database opened with the
|
|
2235
|
+
* `encryption` option; throws otherwise. Pass `cipher` to also change scheme.
|
|
2236
|
+
*/
|
|
2237
|
+
rekey(key, cipher) {
|
|
2238
|
+
this.assertOpen();
|
|
2239
|
+
if (!this.encrypted || !this.driver.rekey) {
|
|
2240
|
+
throw new MonliteError(
|
|
2241
|
+
"rekey() requires a database opened with the `encryption` option."
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
this.driver.rekey(key, cipher);
|
|
2245
|
+
}
|
|
2053
2246
|
/** Close the underlying SQLite connection. */
|
|
2054
2247
|
$disconnect() {
|
|
2055
2248
|
if (!this.closed) {
|
|
@@ -2069,6 +2262,7 @@ function createDb(filename, options) {
|
|
|
2069
2262
|
exports.Collection = Collection;
|
|
2070
2263
|
exports.Monlite = Monlite;
|
|
2071
2264
|
exports.MonliteConstraintError = MonliteConstraintError;
|
|
2265
|
+
exports.MonliteEncryptionError = MonliteEncryptionError;
|
|
2072
2266
|
exports.MonliteError = MonliteError;
|
|
2073
2267
|
exports.MonliteForeignKeyError = MonliteForeignKeyError;
|
|
2074
2268
|
exports.MonliteNotNullError = MonliteNotNullError;
|