@monlite/core 0.3.0 β†’ 0.6.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
@@ -1,12 +1,7 @@
1
1
  # πŸŒ™ monlite
2
2
 
3
- > An embedded document database for TypeScript apps.
4
- > MongoDB-like API. Prisma-like DX. SQLite under the hood. Zero config.
5
-
6
- monlite is a local-first document database that lives inside your app as a
7
- single `.db` file. No server to run, no schema to define, no migrations to
8
- manage. You get the flexibility of MongoDB, the familiarity of a Prisma-style
9
- API, and the reliability of SQLite β€” all in one `npm install`.
3
+ > Your app's local database. A MongoDB-like API and Prisma-style DX in a single
4
+ > file. Zero config, zero migrations, zero server.
10
5
 
11
6
  ```ts
12
7
  import { createDb } from "@monlite/core";
@@ -18,7 +13,36 @@ await users.create({ data: { name: "Ali", age: 28 } });
18
13
  await users.findMany({ where: { age: { gte: 18 } } });
19
14
  ```
20
15
 
21
- That's it. No setup. No config. Your data is in `app.db`.
16
+ That's the whole setup. Your data is in `app.db`.
17
+
18
+ ---
19
+
20
+ ## Mental model (read this first)
21
+
22
+ monlite is **one database with one query API.** You never have to choose "SQL or
23
+ NoSQL." You only choose, per collection, **where each field is stored:**
24
+
25
+ - **Document mode** (default) β€” the whole document is stored as JSON. Flexible
26
+ and schema-free, like MongoDB.
27
+ - **Structured mode** (`db.collection(name, { schema })`) β€” the fields you
28
+ declare become real SQL columns (typed, indexed, joinable). Anything else
29
+ overflows into JSON automatically.
30
+
31
+ > **A schema changes the _storage_, never the _syntax_.**
32
+ > `create`, `find`, `where`, `orderBy`, `groupBy` are identical in both modes and
33
+ > return identical results β€” structured mode is just faster and SQL-native underneath.
34
+
35
+ Raw SQL is the one optional place SQL becomes visible: the `$queryRaw` escape
36
+ hatch, for joins/CTEs/window functions the document API doesn't cover.
37
+
38
+ | You decide… | Document (default) | Structured (`{ schema }`) |
39
+ | --- | --- | --- |
40
+ | **How you query** | `find` / `where` / `orderBy` / `groupBy` | **identical** |
41
+ | **Where a field lives** | JSON `data` blob | a native column (declared) β€” the rest overflow to JSON |
42
+ | **Pick it when** | the shape is unknown or varies per record | the shape is stable and you want joins, FKs, reporting, or fast native indexes |
43
+
44
+ You can mix both in the same `.db`, and move a collection from document to
45
+ structured later without changing a single query.
22
46
 
23
47
  ---
24
48
 
@@ -57,6 +81,20 @@ If your data is structured and you already know your schema, plain SQLite adds
57
81
  nothing on top of monlite β€” use it directly. monlite earns its keep when your
58
82
  documents are dynamic, schema-free, or mirror a cloud NoSQL store.
59
83
 
84
+ ### How monlite compares
85
+
86
+ | | monlite | MongoDB | better-sqlite3 | Prisma + SQLite |
87
+ |---|---|---|---|---|
88
+ | Schema-free documents | βœ… | βœ… | ⚠️ manual JSON | ❌ |
89
+ | Native typed columns | βœ… (opt-in) | ❌ | βœ… | βœ… |
90
+ | Same API for both | βœ… | β€” | β€” | β€” |
91
+ | Raw SQL escape hatch | βœ… | ❌ | βœ… | βœ… (`$queryRaw`) |
92
+ | No server / single file | βœ… | ❌ | βœ… | βœ… |
93
+ | No migrations / codegen | βœ… | βœ… | βœ… | ❌ |
94
+ | Aggregation API | βœ… | βœ… | ⚠️ manual | ⚠️ limited |
95
+ | Local-first sync | βœ… (`@monlite/sync`) | ⚠️ Atlas/Realm | ❌ | ❌ |
96
+ | Runtime dependencies | **0** (Node 22.5+) | server | 1 (native) | several |
97
+
60
98
  ---
61
99
 
62
100
  ## Setup
@@ -124,6 +162,9 @@ await users.createMany({ data: [{ name: "Sara" }, { name: "Omar" }] });
124
162
  // read
125
163
  await users.findById("…"); // doc | null
126
164
  await users.findFirst({ where: { name: "Ali" } }); // doc | null
165
+ await users.findUnique({ where: { email: "a@x.com" } }); // alias of findFirst
166
+ await users.findFirstOrThrow({ where: { name: "Ali" } }); // throws if missing
167
+ await users.exists({ role: "admin" }); // boolean
127
168
  await users.findMany({
128
169
  where: { age: { gte: 18 } },
129
170
  orderBy: { age: "desc" },
@@ -167,10 +208,11 @@ where: { age: { gt: 18 } } // gt, gte, lt, lte
167
208
  where: { role: { in: ["admin", "editor"] } }
168
209
  where: { role: { notIn: ["guest"] } }
169
210
 
170
- // String (case-sensitive; wildcards are matched literally)
211
+ // String (case-sensitive by default; wildcards are matched literally)
171
212
  where: { name: { contains: "li" } }
172
213
  where: { name: { startsWith: "A" } }
173
214
  where: { name: { endsWith: "i" } }
215
+ where: { name: { contains: "ALI", mode: "insensitive" } } // case-insensitive (ASCII)
174
216
 
175
217
  // Arrays
176
218
  where: { tags: { contains: "admin" } } // element membership
@@ -277,6 +319,108 @@ await users.distinct("tags"); // ["a", "b", "c"]
277
319
 
278
320
  ---
279
321
 
322
+ ## Structured collections (native SQL columns)
323
+
324
+ By default a collection is **document mode** β€” schema-free, every field stored
325
+ as JSON. Pass a `schema` to make it a **structured collection**: the declared
326
+ fields become real, typed SQL columns (fast, indexable, joinable, constrainable)
327
+ and any *other* fields overflow into a JSON column. **The CRUD/query API is
328
+ identical** β€” `find`, `where`, `orderBy`, `groupBy`, `distinct`, updates. As the
329
+ [mental model](#mental-model-read-this-first) says: a schema changes the storage,
330
+ not the syntax.
331
+
332
+ ```ts
333
+ const orders = db.collection("orders", {
334
+ schema: {
335
+ user_id: { type: "TEXT", index: true, references: "users(_id)" },
336
+ amount: "REAL",
337
+ status: { type: "TEXT", notNull: true, default: "pending" },
338
+ meta: "JSON", // objects/arrays, transparently (de)serialized
339
+ },
340
+ });
341
+
342
+ // Same API as document collections β€” but `amount`/`status` are real columns:
343
+ await orders.create({ data: { user_id: "u1", amount: 100, status: "paid", note: "rush" } });
344
+ await orders.findMany({ where: { amount: { gte: 50 }, status: "paid" } });
345
+ await orders.groupBy({ by: ["status"], _sum: { amount: true } });
346
+
347
+ // Undeclared fields (like `note`) still work β€” they overflow into JSON.
348
+ await orders.findMany({ where: { note: { contains: "rush" } } });
349
+ ```
350
+
351
+ Because the columns are native, they join, constrain, and index like any SQL
352
+ table β€” including from the raw SQL hatch with no `json_extract`:
353
+
354
+ ```ts
355
+ await db.$queryRaw`
356
+ SELECT u.name, SUM(o.amount) AS revenue
357
+ FROM users u JOIN orders o ON o.user_id = u._id
358
+ GROUP BY u._id
359
+ `;
360
+ ```
361
+
362
+ Column types: `"TEXT" | "INTEGER" | "REAL" | "BLOB" | "JSON"`. A full column
363
+ definition supports `index`, `unique`, `notNull`, `default`, and `references`.
364
+
365
+ ### Do I have to care: JSON vs native columns?
366
+
367
+ - **For correctness β€” no.** Both modes return identical results through the same API.
368
+ - **For performance & SQL interop β€” a little.** Native columns + native indexes are
369
+ faster and join/constrain cleanly; JSON is for when the shape is unknown or varies.
370
+
371
+ monlite never hides which is which:
372
+
373
+ ```ts
374
+ orders.mode; // "structured" | "document"
375
+ await db.$schema("orders"); // physical columns: [{ name, type, notNull, primaryKey }, …]
376
+ createDb("./app.db", { verbose: (sql) => console.log(sql) }); // see json_extract vs bare columns
377
+ ```
378
+
379
+ > **Rule of thumb:** unknown/flexible shape β†’ document (JSON); known/stable shape
380
+ > with heavy joins, reporting, or external SQL tooling β†’ structured (native columns).
381
+
382
+ > Note: structured collections are not yet covered by `@monlite/sync` (document
383
+ > collections are) β€” that's planned follow-up work.
384
+
385
+ ---
386
+
387
+ ## Sync & local-first
388
+
389
+ The companion package [`@monlite/sync`](https://www.npmjs.com/package/@monlite/sync)
390
+ replicates a local monlite database with a remote source of truth β€” MongoDB
391
+ first β€” so apps can work offline and converge when reconnected.
392
+
393
+ Opt in with `{ sync: true }` (adds a change feed + tombstones + versioning; zero
394
+ overhead when off), then drive an engine:
395
+
396
+ ```ts
397
+ import { createDb } from "@monlite/core";
398
+ import { sync, MongoAdapter } from "@monlite/sync";
399
+ import { MongoClient } from "mongodb";
400
+
401
+ const db = createDb("./app.db", { sync: true });
402
+ const mongo = new MongoClient(uri);
403
+ await mongo.connect();
404
+
405
+ const engine = sync(db, {
406
+ adapter: new MongoAdapter({ client: mongo, db: "app" }),
407
+ collections: "*",
408
+ mode: "two-way", // "pull" | "push" | "two-way"
409
+ conflict: "lww", // or a custom resolver
410
+ interval: 5000,
411
+ });
412
+
413
+ await engine.start();
414
+ ```
415
+
416
+ Pull / push / two-way replication, last-write-wins (or custom) conflict
417
+ resolution, and pluggable adapters (`MongoAdapter`, `MonliteAdapter` for
418
+ monlite-to-monlite, `MemoryAdapter` for tests). monlite's ObjectId-compatible
419
+ `_id`s map 1:1 to Mongo `_id`s. See the
420
+ [`@monlite/sync` README](https://www.npmjs.com/package/@monlite/sync) for details.
421
+
422
+ ---
423
+
280
424
  ## SQL escape hatch
281
425
 
282
426
  When you need full SQL power β€” complex joins, analytics, cross-collection