@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 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. Destructive
393
- changes (rename/drop/type-change) still need a manual migration.
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;