@monlite/core 1.1.0 → 1.3.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
 
@@ -446,9 +459,10 @@ await engine.start();
446
459
  ```
447
460
 
448
461
  Pull / push / two-way replication, last-write-wins (or custom) conflict
449
- resolution, and pluggable adapters (`MongoAdapter`, `MonliteAdapter` for
450
- monlite-to-monlite, `MemoryAdapter` for tests). monlite's ObjectId-compatible
451
- `_id`s map 1:1 to Mongo `_id`s. See the
462
+ resolution, and pluggable adapters (`MongoAdapter`, `PostgresAdapter`,
463
+ `MySqlAdapter`, `MonliteAdapter` for monlite-to-monlite, `MemoryAdapter` for
464
+ tests) keep local monlite as the embedded runtime and a server DB as the cloud
465
+ of record. See the
452
466
  [`@monlite/sync` README](https://www.npmjs.com/package/@monlite/sync) for details.
453
467
 
454
468
  ---
@@ -587,13 +601,14 @@ Redis/Mongo/Qdrant replacement. For scale, keep the real services and
587
601
 
588
602
  ## Drivers & zero dependencies
589
603
 
590
- monlite talks to SQLite through a tiny driver adapter, so it runs on two
604
+ monlite talks to SQLite through a tiny driver adapter, so it runs on
591
605
  interchangeable backends:
592
606
 
593
607
  | Backend | When it's used | Notes |
594
608
  |---|---|---|
595
609
  | **`node:sqlite`** | Built into Node **22.5+** | **Zero dependencies.** Still flagged experimental by Node, so it prints a one-time `ExperimentalWarning`. |
596
610
  | **`better-sqlite3`** | When the package is installed | Battle-tested native driver. Works on Node 18/20/22, no warning. Install it yourself: `npm i better-sqlite3`. |
611
+ | **WASM (browser)** | Via [`@monlite/wasm`](https://www.npmjs.com/package/@monlite/wasm) | Runs monlite **in the browser** on SQLite-WASM (sql.js); pass `driver: wasmDriver(SQL)`. Snapshot persistence to IndexedDB/OPFS. |
597
612
 
598
613
  By default (`driver: "auto"`) monlite uses `better-sqlite3` if it's installed,
599
614
  otherwise falls back to the built-in `node:sqlite`. Force one explicitly:
package/dist/index.cjs CHANGED
@@ -793,6 +793,124 @@ var Collection = class {
793
793
  }
794
794
  }
795
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
+ }
796
914
  /* --------------------------- row <-> doc -------------------------- */
797
915
  rowToDoc(row) {
798
916
  const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
@@ -1497,7 +1615,11 @@ function loadNodeSqlite() {
1497
1615
  return null;
1498
1616
  }
1499
1617
  }
1618
+ function isDriverInstance(d) {
1619
+ return typeof d === "object" && d !== null && typeof d.prepare === "function" && typeof d.exec === "function";
1620
+ }
1500
1621
  function createDriver(filename, options = {}) {
1622
+ if (isDriverInstance(options.driver)) return options.driver;
1501
1623
  const choice = options.driver ?? "auto";
1502
1624
  if (options.encryption) {
1503
1625
  if (choice === "node:sqlite") {