js-bao 0.2.8

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 ADDED
@@ -0,0 +1,1255 @@
1
+ # js-bao
2
+
3
+ A lightweight, reactive ODM (Object-Document Mapper) built on top of [Yjs](https://github.com/yjs/yjs) for collaborative, offline-first applications. It allows you to define data models, persist them in Yjs shared types, and query them using a modern document-style API through pluggable database engines like SQL.js.
4
+
5
+ ## Features
6
+
7
+ - **Schema-First Models**: Define your data schema with `defineModelSchema`
8
+ and attach it to plain `BaseModel` subclasses (via `attachAndRegisterModel`)
9
+ for a single source of truth and native property accessors.
10
+ - **Yjs Integration**: Data is stored in Yjs `Y.Map`s, enabling real-time collaboration and automatic data synchronization.
11
+ - **Multi-Document Support**: Connect and manage multiple Y.Doc instances with flexible document permissions.
12
+ - **Document-Style Queries**: Modern, MongoDB-like query API with filtering, projection, and aggregation.
13
+ - **Cursor-Based Pagination**: Efficient pagination with forward/backward navigation and stable cursors.
14
+ - **Advanced Aggregation**: Group, count, sum, average, and perform statistical operations on your data.
15
+ - **StringSet Support**: Special field type for tag-like data with efficient membership queries and faceting.
16
+ - **Pluggable Database Engines**:
17
+ - Currently supports **SQL.js** (SQLite compiled to WebAssembly).
18
+ - **Transactional Operations**: Ensures atomicity for database modifications.
19
+ - **TypeScript First**: Written in TypeScript with strong type safety.
20
+ - **Multi-platform**: Supports both browser and Node.js environments
21
+ - **Type-safe**: Full TypeScript support (constructor + instance attrs inferred)
22
+ - **Proxy-free runtime**: Native getters/setters wired per schema field
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install js-bao yjs
28
+ # or
29
+ yarn add js-bao yjs
30
+ # or
31
+ pnpm add js-bao yjs
32
+
33
+ # For SQL.js engine:
34
+ npm install sql.js
35
+ ```
36
+
37
+ ### Optional Dependencies
38
+
39
+ For Node.js environments, you can install native database engines:
40
+
41
+ ```bash
42
+ # For SQLite support in Node.js
43
+ npm install better-sqlite3
44
+ ```
45
+
46
+ ## Platform Support
47
+
48
+ ### Browser
49
+
50
+ - **SQL.js (SQLite WASM)**: In-memory SQLite database
51
+
52
+ ### Node.js
53
+
54
+ - **better-sqlite3**: Native SQLite with file system support
55
+ - **SQL.js**: SQLite WASM (fallback option)
56
+
57
+ ## Core Concepts
58
+
59
+ - **Models**: Plain classes that extend `BaseModel`, defined alongside a
60
+ `defineModelSchema` object. `attachAndRegisterModel` wires the schema and
61
+ registers the class (e.g., `User`, `Product`).
62
+ - **Fields**: Properties declared inside `defineModelSchema` with full metadata (`type`, `default`, `indexed`, etc.).
63
+ - **Database Engines**: In-memory databases (like SQL.js) that mirror the data from Yjs for querying.
64
+ - **Document Queries**: MongoDB-style queries using filters, projections, and aggregations instead of SQL.
65
+ - **Multi-Document Management**: Connect and manage multiple Y.Doc instances with read/read-write permissions.
66
+ - **`initJsBao`**: The main function to set up the library and initialize the database engine and models.
67
+
68
+ ## Defining Models (Schema-First, No Decorators)
69
+
70
+ Each model file keeps the schema, class, and registration together:
71
+
72
+ ```ts
73
+ import {
74
+ BaseModel,
75
+ defineModelSchema,
76
+ attachAndRegisterModel,
77
+ InferAttrs,
78
+ } from "js-bao";
79
+
80
+ const statementSchema = defineModelSchema({
81
+ name: "statements",
82
+ fields: {
83
+ id: { type: "id", autoAssign: true, indexed: true },
84
+ accountName: { type: "string", indexed: true, default: "" },
85
+ currency: { type: "string", default: "USD" },
86
+ startDate: { type: "string", indexed: true },
87
+ endDate: { type: "string", indexed: true },
88
+ endingValue: { type: "number", indexed: true, default: 0 },
89
+ holdingsIncluded: { type: "boolean", default: false },
90
+ },
91
+ options: {
92
+ uniqueConstraints: [
93
+ {
94
+ name: "statement_period_per_account",
95
+ fields: ["accountName", "startDate", "endDate"],
96
+ },
97
+ ],
98
+ },
99
+ });
100
+
101
+ export type StatementAttrs = InferAttrs<typeof statementSchema>;
102
+ export interface Statement extends StatementAttrs, BaseModel {}
103
+
104
+ export class Statement extends BaseModel {
105
+ get durationDays() {
106
+ return (
107
+ (Date.parse(this.endDate ?? "") - Date.parse(this.startDate ?? "")) /
108
+ (1000 * 60 * 60 * 24)
109
+ );
110
+ }
111
+
112
+ static async findByAccount(accountName: string) {
113
+ return Statement.queryOne({ accountName });
114
+ }
115
+ }
116
+
117
+ attachAndRegisterModel(Statement, statementSchema);
118
+ ```
119
+
120
+ - `defineModelSchema` is the single source of truth for fields/indexing/defaults.
121
+ - `InferAttrs<typeof statementSchema>` produces constructor/instance typings automatically.
122
+ - `attachAndRegisterModel` sets `modelName`, wires property accessors, and registers
123
+ the class with `ModelRegistry`.
124
+ - Property access uses native getters/setters installed per schema field—no proxies required.
125
+
126
+ > Migrating legacy models? See
127
+ > [`docs/model-migration-guide.md`](./docs/model-migration-guide.md) and
128
+ > [`docs/model-autogen-plan.md`](./docs/model-autogen-plan.md) for step-by-step
129
+ > guidance.
130
+
131
+ ### Codegen Workflow
132
+
133
+ `js-bao-codegen` keeps model files consistent by owning two firecracker-marked
134
+ sections in every file:
135
+
136
+ - **🔥🔥 BEGIN/END AUTO HEADER 🔥🔥** – imports `InferAttrs`, emits the
137
+ `export type …Attrs`/`export interface …` declarations, and adds lint pragmas.
138
+ - **🔥🔥 BEGIN/END AUTO FOOTER 🔥🔥** – imports `./generated/<Model>.relationships.d`
139
+ and calls `attachAndRegisterModel`.
140
+
141
+ Everything between those markers (schema + class body) remains developer-owned.
142
+ To regenerate the header/footer blocks _and_ the per-model relationship d.ts files:
143
+
144
+ ```bash
145
+ # From the library workspace
146
+ npm run build:cli # compiles the CLI once
147
+ npm run codegen # or: npx js-bao-codegen --config js-bao.config.cjs
148
+
149
+ # From a consuming app (e.g., demos/test-app)
150
+ npm run codegen # points to ../../dist/codegen.cjs in this repo
151
+ ```
152
+
153
+ Projects typically wire codegen into `postinstall`/`build` scripts so that
154
+ models stay in sync automatically (see `demos/test-app/package.json`).
155
+
156
+ ### Runtime / Programmatic Models
157
+
158
+ For dynamic scenarios (Scenario 16, plugin systems, etc.) you can define
159
+ runtime models entirely in code:
160
+
161
+ ```ts
162
+ import {
163
+ BaseModel,
164
+ defineModelSchema,
165
+ attachSchemaToClass,
166
+ autoRegisterModel,
167
+ } from "js-bao";
168
+
169
+ const runtimeItemSchema = defineModelSchema({
170
+ name: "runtime_items",
171
+ fields: {
172
+ id: { type: "id", autoAssign: true, indexed: true },
173
+ name: { type: "string", indexed: true },
174
+ quantity: { type: "number" },
175
+ },
176
+ });
177
+
178
+ class RuntimeItem extends BaseModel {}
179
+
180
+ const runtimeShape = attachSchemaToClass(RuntimeItem, runtimeItemSchema);
181
+ autoRegisterModel(RuntimeItem, runtimeShape);
182
+
183
+ const item = new RuntimeItem({ name: "Dynamic", quantity: 5 });
184
+ await item.save({ targetDocument: "doc-123" });
185
+ ```
186
+
187
+ `attachSchemaToClass` + `autoRegisterModel` remain available when you need to
188
+ control registration (e.g., multiple registries, conditionally skipping
189
+ registration). For typical model files, stick with the single-call
190
+ `attachAndRegisterModel`.
191
+
192
+ ## Setup & Initialization
193
+
194
+ ### Multi-Document Approach
195
+
196
+ js-bao now uses a multi-document approach, providing better flexibility for complex applications that need to work with multiple Y.Doc instances.
197
+
198
+ ```typescript
199
+ // src/store/doc.ts (or your Yjs setup file)
200
+ import * as Y from "yjs";
201
+ export const doc = new Y.Doc();
202
+
203
+ // Example: src/store/StoreContext.tsx (for React)
204
+ import React, { createContext, useContext, useEffect, useState } from "react";
205
+ import { initJsBao, DatabaseConfig, DatabaseEngine } from "js-bao";
206
+ import { doc } from "../store/doc"; // Your Y.Doc instance
207
+
208
+ // --- Import your defined models (after defining them as shown below) ---
209
+ // import { Statement } from '../models/Statement';
210
+ // import { Account } from '../models/Account';
211
+
212
+ interface StoreContextType {
213
+ db: DatabaseEngine | null;
214
+ isReady: boolean;
215
+ error?: Error;
216
+ // Multi-document functions
217
+ connectDocument:
218
+ | ((
219
+ docId: string,
220
+ yDoc: any,
221
+ permission: "read" | "read-write"
222
+ ) => Promise<void>)
223
+ | null;
224
+ disconnectDocument: ((docId: string) => Promise<void>) | null;
225
+ getConnectedDocuments: (() => Map<string, any>) | null;
226
+ isDocumentConnected: ((docId: string) => boolean) | null;
227
+ // Helper for the main document
228
+ mainDocumentId: string;
229
+ }
230
+
231
+ const StoreContext = createContext<StoreContextType | null>(null);
232
+
233
+ export function StoreProvider({ children }: { children: React.ReactNode }) {
234
+ const [state, setState] = useState<StoreContextType>({
235
+ db: null,
236
+ isReady: false,
237
+ connectDocument: null,
238
+ disconnectDocument: null,
239
+ getConnectedDocuments: null,
240
+ isDocumentConnected: null,
241
+ mainDocumentId: "main-document", // Default document ID
242
+ });
243
+
244
+ useEffect(() => {
245
+ async function setupJsBao() {
246
+ try {
247
+ // 1. Define Database Configuration
248
+ const dbConfig: DatabaseConfig = {
249
+ type: "sqljs",
250
+ options: {
251
+ // --- SQL.js specific options ---
252
+ // wasmURL: '/sql-wasm.wasm', // If not at default /sql-wasm.wasm
253
+ },
254
+ };
255
+
256
+ // 2. Initialize the ODM (no yDoc parameter in new API!)
257
+ const {
258
+ dbEngine,
259
+ connectDocument,
260
+ disconnectDocument,
261
+ getConnectedDocuments,
262
+ isDocumentConnected,
263
+ // Default doc mapping APIs also available:
264
+ addDocumentModelMapping,
265
+ removeDocumentModelMapping,
266
+ clearDocumentModelMappings,
267
+ setDefaultDocumentId,
268
+ clearDefaultDocumentId,
269
+ getDocumentModelMapping,
270
+ getDocumentIdForModel,
271
+ getDefaultDocumentId,
272
+ } = await initJsBao({
273
+ databaseConfig: dbConfig,
274
+ // models: [Statement, Account] // Optional: if models are not auto-detected
275
+ });
276
+
277
+ // 3. Connect your main document
278
+ const mainDocumentId = "main-document";
279
+ await connectDocument(mainDocumentId, doc, "read-write");
280
+
281
+ setState({
282
+ db: dbEngine,
283
+ isReady: true,
284
+ connectDocument,
285
+ disconnectDocument,
286
+ getConnectedDocuments,
287
+ isDocumentConnected,
288
+ mainDocumentId,
289
+ });
290
+ } catch (error) {
291
+ console.error("Error initializing js-bao:", error);
292
+ setState({
293
+ db: null,
294
+ isReady: false,
295
+ error: error as Error,
296
+ connectDocument: null,
297
+ disconnectDocument: null,
298
+ getConnectedDocuments: null,
299
+ isDocumentConnected: null,
300
+ mainDocumentId: "main-document",
301
+ });
302
+ }
303
+ }
304
+ setupJsBao();
305
+ }, []);
306
+
307
+ if (state.error) {
308
+ return <div>Error loading store: {state.error.message}</div>;
309
+ }
310
+ if (!state.isReady || !state.db) {
311
+ return <div>Loading js-bao...</div>;
312
+ }
313
+ return (
314
+ <StoreContext.Provider value={state as StoreContextType}>
315
+ {children}
316
+ </StoreContext.Provider>
317
+ );
318
+ }
319
+
320
+ export function useStore() {
321
+ const context = useContext(StoreContext);
322
+ if (!context || !context.db) {
323
+ throw new Error(
324
+ "useStore must be used within a StoreProvider, and js-bao must be initialized."
325
+ );
326
+ }
327
+ return context;
328
+ }
329
+
330
+ // Helper hook for easier document operations
331
+ export function useDocumentOperations() {
332
+ const { mainDocumentId } = useStore();
333
+
334
+ const saveToMainDocument = async (model: any) => {
335
+ return await model.save({ targetDocument: mainDocumentId });
336
+ };
337
+
338
+ const upsertInMainDocument = async (
339
+ ModelClass: any,
340
+ constraintName: string,
341
+ lookupValue: any,
342
+ data: any
343
+ ) => {
344
+ return await ModelClass.upsertByUnique(constraintName, lookupValue, data, {
345
+ targetDocument: mainDocumentId,
346
+ });
347
+ };
348
+
349
+ return {
350
+ saveToMainDocument,
351
+ upsertInMainDocument,
352
+ mainDocumentId,
353
+ };
354
+ }
355
+ ```
356
+
357
+ ## Defining Models
358
+
359
+ Create a schema + class pair for each model (usually in `src/models`). The
360
+ schema is the single source of truth; the class adds business logic.
361
+
362
+ ```ts
363
+ // src/models/Product.ts
364
+ import { BaseModel, defineModelSchema, attachAndRegisterModel } from "js-bao";
365
+ import type { InferAttrs } from "js-bao";
366
+
367
+ const productSchema = defineModelSchema({
368
+ name: "products",
369
+ fields: {
370
+ id: { type: "id", autoAssign: true, indexed: true },
371
+ name: { type: "string", indexed: true },
372
+ sku: { type: "string", indexed: true, default: "" },
373
+ price: { type: "number", default: 0 },
374
+ category: { type: "string", default: "" },
375
+ inStock: { type: "boolean", default: true },
376
+ },
377
+ });
378
+
379
+ export type ProductAttrs = InferAttrs<typeof productSchema>;
380
+ export interface Product extends ProductAttrs, BaseModel {}
381
+
382
+ export class Product extends BaseModel {
383
+ constructor(data?: Partial<Product>) {
384
+ super(data ?? {});
385
+ if (!this.sku) {
386
+ this.sku = crypto.randomUUID();
387
+ }
388
+ }
389
+
390
+ get isPremium() {
391
+ return this.price > 1000;
392
+ }
393
+
394
+ static async findBySku(sku: string) {
395
+ return Product.queryOne({ sku });
396
+ }
397
+ }
398
+
399
+ attachAndRegisterModel(Product, productSchema);
400
+ ```
401
+
402
+ **Constructor notes:**
403
+
404
+ - `defineModelSchema` handles defaults, indexing, and inference. Only add
405
+ constructor logic for custom behaviors (e.g., generating a SKU) before or
406
+ after calling `super()`.
407
+ - `attachAndRegisterModel` mutates the class by setting `modelName`, wiring
408
+ field accessors, and registering it with `ModelRegistry`—no proxies required.
409
+
410
+ ## Using Models
411
+
412
+ Once initialized, you can interact with your models using the modern document-style API. **Note**: With the multi-document API, saving new records requires specifying a `targetDocument`.
413
+
414
+ ```typescript
415
+ import { Product } from "./models/Product";
416
+ import { useDocumentOperations } from "./store/StoreContext"; // If using React
417
+
418
+ async function main() {
419
+ // Wait for js-bao initialization if not using a context/provider
420
+
421
+ // Create a new product
422
+ const newProduct = new Product({
423
+ name: "Laptop Pro",
424
+ price: 1200.99,
425
+ category: "Electronics",
426
+ });
427
+
428
+ // NEW API: Must specify targetDocument for new records
429
+ await newProduct.save({ targetDocument: "main-document" });
430
+ console.log("Saved Product:", newProduct.id);
431
+
432
+ // Find a product by ID
433
+ const foundProduct = await Product.find(newProduct.id);
434
+ if (foundProduct) {
435
+ console.log("Found Product:", foundProduct.name);
436
+ }
437
+
438
+ // Document-style queries with filters (searches across ALL connected documents)
439
+ const expensiveProducts = await Product.query({ price: { $gt: 1000 } });
440
+ console.log("Expensive Products:", expensiveProducts.data.length);
441
+
442
+ // Query with projection (only return specific fields)
443
+ const productSummary = await Product.query(
444
+ { category: "Electronics" },
445
+ { projection: { name: 1, price: 1 } }
446
+ );
447
+ console.log("Product summaries:", productSummary.data);
448
+
449
+ // Restrict queries to one or more documents
450
+ const mainDocProducts = await Product.query(
451
+ {},
452
+ { documents: "main-document" }
453
+ );
454
+ console.log("Products in main document:", mainDocProducts.data.length);
455
+
456
+ const activeDocCount = await Product.count(
457
+ {},
458
+ { documents: ["main-document", "archive-doc"] }
459
+ );
460
+ console.log("Products in main/archived documents:", activeDocCount);
461
+
462
+ // Pagination with cursor-based navigation
463
+ const firstPage = await Product.query({}, { limit: 10, sort: { price: -1 } });
464
+ console.log("First page:", firstPage.data.length);
465
+ console.log("Has more:", firstPage.hasMore);
466
+
467
+ if (firstPage.nextCursor) {
468
+ const secondPage = await Product.query(
469
+ {},
470
+ {
471
+ limit: 10,
472
+ sort: { price: -1 },
473
+ uniqueStartKey: firstPage.nextCursor,
474
+ }
475
+ );
476
+ console.log("Second page:", secondPage.data.length);
477
+ }
478
+
479
+ // Count documents
480
+ const totalProducts = await Product.count({ category: "Electronics" });
481
+ console.log("Total electronics:", totalProducts);
482
+
483
+ // Find single document
484
+ const cheapestLaptop = await Product.queryOne(
485
+ { category: "Electronics", name: { $containsText: "Laptop" } },
486
+ { sort: { price: 1 } }
487
+ );
488
+
489
+ // Update a product (existing records don't need targetDocument unless moving to different doc)
490
+ if (foundProduct) {
491
+ foundProduct.price = 1150.0;
492
+ foundProduct.inStock = false;
493
+ await foundProduct.save(); // No targetDocument needed for existing records
494
+ console.log(
495
+ "Updated Product Price:",
496
+ (await Product.find(newProduct.id))?.price
497
+ );
498
+ }
499
+
500
+ // Upsert operation with new API
501
+ const upsertedProduct = await Product.upsertByUnique(
502
+ "name",
503
+ "Laptop Pro",
504
+ { price: 1100.0, category: "Electronics" },
505
+ { targetDocument: "main-document" } // Required for new records
506
+ );
507
+
508
+ // Subscribe to changes for all Products
509
+ const unsubscribe = Product.subscribe(() => {
510
+ console.log("Product data changed!");
511
+ Product.findAll().then((allProducts) => {
512
+ console.log("Current products count:", allProducts.length);
513
+ });
514
+ });
515
+ // Call unsubscribe() when done listening
516
+
517
+ // Delete a product
518
+ // await foundProduct?.delete();
519
+ }
520
+
521
+ main();
522
+ ```
523
+
524
+ Pass a `documents` option (string or array of IDs) to scope `query`, `queryOne`, and `count` calls to specific connected documents when you do not want the default cross-document behaviour.
525
+
526
+ ## Date Fields
527
+
528
+ js-bao supports a `date` field type. Because Yjs serializes nested data with `JSON.stringify`, date values end up stored as ISO-8601 strings inside the model’s backing `Y.Map`. Reading the field returns that string—wrap it with `new Date(...)` if you need native date helpers. Query filters accept either `Date` instances or any string that `Date.parse` can understand.
529
+
530
+ ### Defining and saving date fields
531
+
532
+ ```typescript
533
+ import { BaseModel, defineModelSchema, attachAndRegisterModel } from "js-bao";
534
+
535
+ const postSchema = defineModelSchema({
536
+ name: "posts",
537
+ fields: {
538
+ id: { type: "id", autoAssign: true, indexed: true },
539
+ title: { type: "string" },
540
+ publishedAt: { type: "date" },
541
+ },
542
+ });
543
+
544
+ export class Post extends BaseModel {
545
+ get publishedAtDate(): Date | undefined {
546
+ return this.publishedAt ? new Date(this.publishedAt) : undefined;
547
+ }
548
+
549
+ set publishedAtDate(value: Date | undefined) {
550
+ this.publishedAt = value ? value.toISOString() : undefined;
551
+ }
552
+ }
553
+
554
+ attachAndRegisterModel(Post, postSchema);
555
+
556
+ const post = new Post({
557
+ title: "Working with js-bao dates",
558
+ publishedAt: new Date().toISOString(),
559
+ });
560
+
561
+ await post.save({ targetDocument: "main-document" });
562
+ console.log("Saved post:", post.id);
563
+ ```
564
+
565
+ ### Loading and querying by dates
566
+
567
+ ```typescript
568
+ const loaded = await Post.find(post.id);
569
+ if (loaded?.publishedAt) {
570
+ const published = new Date(loaded.publishedAt);
571
+ console.log("Published at:", published.toLocaleString());
572
+ }
573
+
574
+ // Queries accept Date objects or ISO strings
575
+ const recentPosts = await Post.query({
576
+ publishedAt: { $gte: new Date("2024-01-01") },
577
+ });
578
+
579
+ // Sorting by date uses the stored ISO strings
580
+ const ordered = await Post.query({}, { sort: { publishedAt: -1 } });
581
+ ```
582
+
583
+ ### Working with Multiple Documents
584
+
585
+ The new multi-document API shines when you need to work with multiple documents:
586
+
587
+ ```typescript
588
+ function useMultiDocumentOperations() {
589
+ const { connectDocument, disconnectDocument, isDocumentConnected } =
590
+ useStore();
591
+
592
+ const handleConnectUserDocument = async (userId: string, userDoc: Y.Doc) => {
593
+ const docId = `user-${userId}`;
594
+
595
+ if (!isDocumentConnected(docId)) {
596
+ await connectDocument(docId, userDoc, "read-write");
597
+ console.log(`Connected document for user ${userId}`);
598
+ }
599
+ };
600
+
601
+ const handleSaveToUserDocument = async (userId: string, product: Product) => {
602
+ const docId = `user-${userId}`;
603
+
604
+ if (isDocumentConnected(docId)) {
605
+ await product.save({ targetDocument: docId });
606
+ } else {
607
+ throw new Error(`Document for user ${userId} is not connected`);
608
+ }
609
+ };
610
+
611
+ const handleDisconnectUserDocument = async (userId: string) => {
612
+ const docId = `user-${userId}`;
613
+ await disconnectDocument(docId);
614
+ console.log(`Disconnected document for user ${userId}`);
615
+ };
616
+
617
+ return {
618
+ handleConnectUserDocument,
619
+ handleSaveToUserDocument,
620
+ handleDisconnectUserDocument,
621
+ };
622
+ }
623
+ ```
624
+
625
+ ## Migration from Single-Document API
626
+
627
+ If you're upgrading from an earlier version of js-bao that used the single-document approach, here are the key changes:
628
+
629
+ ### Old API vs New API
630
+
631
+ ```typescript
632
+ // ❌ Old single-document approach
633
+ const { dbEngine } = await initJsBao({
634
+ yDoc: doc, // Single document passed directly
635
+ databaseConfig: dbConfig,
636
+ models: [Statement, Account],
637
+ });
638
+
639
+ // ✅ New multi-document approach
640
+ const {
641
+ dbEngine,
642
+ connectDocument,
643
+ disconnectDocument,
644
+ getConnectedDocuments,
645
+ isDocumentConnected,
646
+ // New client-level defaults API
647
+ addDocumentModelMapping,
648
+ removeDocumentModelMapping,
649
+ clearDocumentModelMappings,
650
+ setDefaultDocumentId,
651
+ clearDefaultDocumentId,
652
+ getDocumentModelMapping,
653
+ getDocumentIdForModel,
654
+ getDefaultDocumentId,
655
+ } = await initJsBao({
656
+ databaseConfig: dbConfig, // No yDoc parameter!
657
+ models: [Statement, Account],
658
+ });
659
+
660
+ // Connect documents explicitly
661
+ await connectDocument("main-doc", doc, "read-write");
662
+ ```
663
+
664
+ ### Model Operations Changes
665
+
666
+ ```typescript
667
+ // ❌ Old way - automatic document targeting
668
+ const product = new Product({ name: "Item", price: 100 });
669
+ await product.save(); // Automatically saved to the single document
670
+
671
+ // ✅ New way - explicit document targeting for new records (or use defaults mapping)
672
+ const product = new Product({ name: "Item", price: 100 });
673
+ // Option A: supply explicit target
674
+ await product.save({ targetDocument: "main-document" });
675
+ // Option B: rely on defaults (see below)
676
+
677
+ // ❌ Old way - upsert without document specification
678
+ const account = await Account.upsertByUnique("email", "user@example.com", {
679
+ name: "John Doe",
680
+ });
681
+
682
+ // ✅ New way - upsert requires targetDocument for new records
683
+ const account = await Account.upsertByUnique(
684
+ "email",
685
+ "user@example.com",
686
+ { name: "John Doe" },
687
+ { targetDocument: "main-document" }
688
+ );
689
+ ```
690
+
691
+ ### Default Document ID Mapping
692
+
693
+ You can set default document ids so new instances can `save()` without specifying a `targetDocument`:
694
+
695
+ ```typescript
696
+ const {
697
+ connectDocument,
698
+ addDocumentModelMapping,
699
+ setDefaultDocumentId,
700
+ onDefaultDocChanged,
701
+ onModelDocMappingChanged,
702
+ } = await initJsBao({ databaseConfig: dbConfig, models: [Product] });
703
+
704
+ await connectDocument("main-doc", doc, "read-write");
705
+ await connectDocument("archive-doc", new Y.Doc(), "read-write");
706
+
707
+ // Global default (used when no model-specific mapping exists)
708
+ setDefaultDocumentId("main-doc");
709
+
710
+ // Model-specific default overrides global
711
+ addDocumentModelMapping("products", "archive-doc");
712
+
713
+ // Events
714
+ const off1 = onDefaultDocChanged(({ previous, current }) => {
715
+ console.log("Default doc changed:", previous, "->", current);
716
+ });
717
+ const off2 = onModelDocMappingChanged(({ modelName, previous, current }) => {
718
+ console.log(`Mapping for ${modelName}:`, previous, "->", current);
719
+ });
720
+
721
+ const p = new Product({ name: "Mapped Save" });
722
+ await p.save(); // Saves to "archive-doc" via model mapping
723
+
724
+ off1();
725
+ off2();
726
+ ```
727
+
728
+ Precedence (highest to lowest):
729
+
730
+ - Explicit `save({ targetDocument })`
731
+ - Instance remembered document (when loaded from a doc)
732
+ - Model-specific default document mapping
733
+ - Global default document id
734
+
735
+ Closed document behavior:
736
+
737
+ - If the resolved `docId` is closed, `save()` throws `ERR_DOC_CLOSED` and will not fall back.
738
+ - If nothing resolves, `save()` throws `ERR_DOC_UNRESOLVED`.
739
+
740
+ Mappings/defaults are cleared on `disconnectDocument(docId)` and are not automatically restored upon reconnect.
741
+
742
+ ### Benefits of the New Multi-Document API
743
+
744
+ 1. **Multiple Data Contexts**: Work with separate documents for different users, projects, or data sets
745
+ 2. **Dynamic Document Management**: Connect and disconnect documents as needed
746
+ 3. **Permission Control**: Specify read-only or read-write access per document
747
+ 4. **Better Scalability**: Handle complex collaborative scenarios with isolated data
748
+ 5. **Backward Compatible Queries**: Queries automatically search across all connected documents
749
+
750
+ For a complete migration guide, see `MIGRATION_GUIDE_SINGLE_TO_MULTIDOC.md` in the project repository.
751
+
752
+ ## Query API
753
+
754
+ js-bao provides a modern, MongoDB-inspired query API for filtering, sorting, and paginating your data.
755
+
756
+ ### Basic Queries
757
+
758
+ ```typescript
759
+ // Find all products
760
+ const allProducts = await Product.query();
761
+
762
+ // Filter by exact match
763
+ const electronicProducts = await Product.query({
764
+ category: "Electronics",
765
+ });
766
+
767
+ // Filter with operators
768
+ const expensiveProducts = await Product.query({
769
+ price: { $gt: 1000 },
770
+ inStock: true,
771
+ });
772
+
773
+ // Complex queries with multiple conditions
774
+ const results = await Product.query({
775
+ $and: [
776
+ { price: { $gte: 100, $lte: 500 } },
777
+ { category: { $in: ["Electronics", "Books"] } },
778
+ ],
779
+ });
780
+ ```
781
+
782
+ ### Supported Query Operators
783
+
784
+ - **Comparison**: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`
785
+ - **Array/Set**: `$in`, `$nin`
786
+ - **Logical**: `$and`, `$or`, `$not`
787
+ - **Text**: `$startsWith`, `$endsWith`, `$containsText` (case-insensitive by default)
788
+ - **Existence**: `$exists`
789
+
790
+ ### Sorting and Pagination
791
+
792
+ ```typescript
793
+ // Sort by price (descending)
794
+ const sortedProducts = await Product.query({}, { sort: { price: -1 } });
795
+
796
+ // Paginated results with cursor-based navigation
797
+ const firstPage = await Product.query(
798
+ { category: "Electronics" },
799
+ {
800
+ limit: 20,
801
+ sort: { createdAt: -1 },
802
+ }
803
+ );
804
+
805
+ console.log("Results:", firstPage.data);
806
+ console.log("Has more:", firstPage.hasMore);
807
+ console.log("Next cursor:", firstPage.nextCursor);
808
+
809
+ // Get next page
810
+ if (firstPage.nextCursor) {
811
+ const nextPage = await Product.query(
812
+ { category: "Electronics" },
813
+ {
814
+ limit: 20,
815
+ sort: { createdAt: -1 },
816
+ uniqueStartKey: firstPage.nextCursor,
817
+ }
818
+ );
819
+ }
820
+
821
+ // Navigate backwards
822
+ if (nextPage.prevCursor) {
823
+ const prevPage = await Product.query(
824
+ { category: "Electronics" },
825
+ {
826
+ limit: 20,
827
+ sort: { createdAt: -1 },
828
+ uniqueStartKey: nextPage.prevCursor,
829
+ direction: -1, // Go backwards
830
+ }
831
+ );
832
+ }
833
+ ```
834
+
835
+ ### Projections
836
+
837
+ Control which fields are returned to optimize performance:
838
+
839
+ ```typescript
840
+ // Only return name and price fields
841
+ const productSummary = await Product.query(
842
+ { inStock: true },
843
+ {
844
+ projection: { name: 1, price: 1 },
845
+ }
846
+ );
847
+
848
+ // Returns: [{ id: "...", name: "...", price: ... }, ...]
849
+ ```
850
+
851
+ ### Single Document Queries
852
+
853
+ ```typescript
854
+ // Find one document matching criteria
855
+ const featuredProduct = await Product.queryOne({
856
+ featured: true,
857
+ inStock: true,
858
+ });
859
+
860
+ // Count documents
861
+ const electronicsCount = await Product.count({
862
+ category: "Electronics",
863
+ });
864
+ ```
865
+
866
+ ## Aggregation API
867
+
868
+ Perform complex data analysis with grouping, statistical operations, and faceting.
869
+
870
+ ### Basic Aggregation
871
+
872
+ ```typescript
873
+ // Count products by category
874
+ const categoryCounts = await Product.aggregate({
875
+ groupBy: ["category"],
876
+ operations: [{ type: "count" }],
877
+ });
878
+ // Result: { "Electronics": 25, "Books": 18, "Clothing": 12 }
879
+
880
+ // Multiple statistical operations
881
+ const categoryStats = await Product.aggregate({
882
+ groupBy: ["category"],
883
+ operations: [
884
+ { type: "count" },
885
+ { type: "avg", field: "price" },
886
+ { type: "sum", field: "price" },
887
+ { type: "min", field: "price" },
888
+ { type: "max", field: "price" },
889
+ ],
890
+ });
891
+ // Result: {
892
+ // "Electronics": {
893
+ // count: 25,
894
+ // avg_price: 299.99,
895
+ // sum_price: 7499.75,
896
+ // min_price: 29.99,
897
+ // max_price: 1299.99
898
+ // },
899
+ // ...
900
+ // }
901
+ ```
902
+
903
+ ### Multi-Dimensional Grouping
904
+
905
+ ```typescript
906
+ // Group by multiple fields
907
+ const salesData = await Product.aggregate({
908
+ groupBy: ["category", "brand"],
909
+ operations: [{ type: "count" }, { type: "sum", field: "revenue" }],
910
+ });
911
+ // Result: {
912
+ // "Electronics": {
913
+ // "Apple": { count: 12, sum_revenue: 15000 },
914
+ // "Samsung": { count: 8, sum_revenue: 9500 }
915
+ // },
916
+ // "Books": {
917
+ // "Penguin": { count: 25, sum_revenue: 450 }
918
+ // }
919
+ // }
920
+ ```
921
+
922
+ ### StringSet Aggregation
923
+
924
+ For StringSet fields (like tags), js-bao provides special aggregation capabilities:
925
+
926
+ ```typescript
927
+ import {
928
+ BaseModel,
929
+ defineModelSchema,
930
+ attachAndRegisterModel,
931
+ StringSet,
932
+ } from "js-bao";
933
+
934
+ const articleSchema = defineModelSchema({
935
+ name: "articles",
936
+ fields: {
937
+ id: { type: "id", autoAssign: true, indexed: true },
938
+ title: { type: "string" },
939
+ tags: { type: "stringset" },
940
+ },
941
+ });
942
+
943
+ class Article extends BaseModel {}
944
+
945
+ attachAndRegisterModel(Article, articleSchema);
946
+
947
+ // Tag facet counts (how many articles have each tag)
948
+ const tagCounts = await Article.aggregate({
949
+ groupBy: ["tags"], // StringSet faceting
950
+ operations: [{ type: "count" }],
951
+ });
952
+ // Result: { "javascript": 45, "react": 32, "tutorial": 28 }
953
+
954
+ // Membership-based grouping (articles that have specific tag vs don't)
955
+ const urgentCounts = await Article.aggregate({
956
+ groupBy: [{ field: "tags", contains: "urgent" }],
957
+ operations: [{ type: "count" }],
958
+ });
959
+ // Result: { "true": 5, "false": 120 }
960
+
961
+ // Complex aggregation with filtering and sorting
962
+ const topTags = await Article.aggregate({
963
+ groupBy: ["tags"],
964
+ operations: [{ type: "count" }],
965
+ filter: { publishedAt: { $gte: "2024-01-01" } },
966
+ sort: { field: "count", direction: -1 },
967
+ limit: 10,
968
+ });
969
+ ```
970
+
971
+ ### Aggregation Options
972
+
973
+ ```typescript
974
+ interface AggregationOptions {
975
+ groupBy: (string | { field: string; contains: string })[];
976
+ operations: {
977
+ type: "count" | "sum" | "avg" | "min" | "max";
978
+ field?: string; // Required for sum, avg, min, max
979
+ }[];
980
+ filter?: DocumentFilter; // Filter documents before aggregation
981
+ limit?: number; // Limit number of groups returned
982
+ sort?: {
983
+ // Sort aggregation results
984
+ field: string; // Field name or operation result
985
+ direction: 1 | -1; // 1 for ascending, -1 for descending
986
+ };
987
+ }
988
+ ```
989
+
990
+ ## StringSet Fields
991
+
992
+ StringSet is a special field type optimized for tag-like data, providing efficient membership queries and faceting capabilities.
993
+
994
+ ### Defining StringSet Fields
995
+
996
+ ```typescript
997
+ import {
998
+ BaseModel,
999
+ StringSet,
1000
+ defineModelSchema,
1001
+ attachAndRegisterModel,
1002
+ } from "js-bao";
1003
+
1004
+ const articleSchema = defineModelSchema({
1005
+ name: "articles",
1006
+ fields: {
1007
+ id: { type: "id", autoAssign: true, indexed: true },
1008
+ title: { type: "string" },
1009
+ tags: {
1010
+ type: "stringset",
1011
+ maxCount: 10,
1012
+ maxLength: 50,
1013
+ },
1014
+ },
1015
+ });
1016
+
1017
+ class Article extends BaseModel {}
1018
+
1019
+ attachAndRegisterModel(Article, articleSchema);
1020
+ ```
1021
+
1022
+ ### Working with StringSets
1023
+
1024
+ ```typescript
1025
+ const article = new Article({ title: "Getting Started with js-bao" });
1026
+
1027
+ // Add tags
1028
+ article.tags.add("javascript");
1029
+ article.tags.add("tutorial");
1030
+ article.tags.add("yjs");
1031
+
1032
+ // Check membership
1033
+ if (article.tags.has("tutorial")) {
1034
+ console.log("This is a tutorial");
1035
+ }
1036
+
1037
+ // Remove tags
1038
+ article.tags.remove("draft");
1039
+
1040
+ // Clear all tags
1041
+ article.tags.clear();
1042
+
1043
+ // Iterate over tags
1044
+ for (const tag of article.tags) {
1045
+ console.log(tag);
1046
+ }
1047
+
1048
+ // Get size
1049
+ console.log(`Article has ${article.tags.size} tags`);
1050
+
1051
+ // Convert to array
1052
+ const tagArray = article.tags.toArray();
1053
+
1054
+ await article.save({ targetDocument: "main-document" });
1055
+ ```
1056
+
1057
+ ### Querying StringSets
1058
+
1059
+ ```typescript
1060
+ // Find articles with specific tag
1061
+ const tutorials = await Article.query({
1062
+ tags: { $contains: "tutorial" },
1063
+ });
1064
+
1065
+ // Find articles with any of multiple tags
1066
+ const techArticles = await Article.query({
1067
+ tags: { $containsAny: ["javascript", "python", "react"] },
1068
+ });
1069
+
1070
+ // Find articles with all specified tags
1071
+ const advancedTutorials = await Article.query({
1072
+ tags: { $containsAll: ["tutorial", "advanced"] },
1073
+ });
1074
+
1075
+ // Count by tag membership
1076
+ const tagStats = await Article.aggregate({
1077
+ groupBy: [{ field: "tags", contains: "tutorial" }],
1078
+ operations: [{ type: "count" }],
1079
+ });
1080
+ // Result: { "true": 25, "false": 75 }
1081
+ ```
1082
+
1083
+ ## Database Engine Specifics
1084
+
1085
+ ### SQL.js (`type: 'sqljs'`)
1086
+
1087
+ - **WASM File**: `sql-wasm.wasm` (from the `sql.js` package) must be publicly accessible in your application.
1088
+ - By default, the library expects it at `/sql-wasm.wasm` (root of your public server path).
1089
+ - **Vite/Create React App**: Place `sql-wasm.wasm` in your project's `public` directory.
1090
+ - **Custom Path**: If the WASM file is located elsewhere, configure it in `DatabaseConfig`:
1091
+ ```typescript
1092
+ const dbConfig: DatabaseConfig = {
1093
+ type: "sqljs",
1094
+ options: {
1095
+ wasmURL: "/path/to/your/sql-wasm.wasm",
1096
+ // or use locateFile for more complex scenarios:
1097
+ // locateFile: (file) => `/assets/wasm/${file}`
1098
+ },
1099
+ };
1100
+ ```
1101
+
1102
+ ## Building the Library (for Contributors)
1103
+
1104
+ 1. Clone the repository.
1105
+ 2. Install dependencies: `npm install`
1106
+ 3. Build: `npm run build` (uses `tsup`)
1107
+ - Development watch mode: `npm run dev`
1108
+
1109
+ ## License
1110
+
1111
+ This library is licensed under the ISC License. (Assuming ISC from your package.json, you might want to add a LICENSE file).
1112
+
1113
+ ## Quick Start
1114
+
1115
+ ### Browser Usage
1116
+
1117
+ ```typescript
1118
+ import {
1119
+ initJsBao,
1120
+ BaseModel,
1121
+ defineModelSchema,
1122
+ attachAndRegisterModel,
1123
+ } from "js-bao";
1124
+ import * as Y from "yjs";
1125
+
1126
+ const userSchema = defineModelSchema({
1127
+ name: "users",
1128
+ fields: {
1129
+ id: { type: "id", autoAssign: true, indexed: true },
1130
+ name: { type: "string" },
1131
+ email: { type: "string" },
1132
+ },
1133
+ });
1134
+
1135
+ class User extends BaseModel {}
1136
+
1137
+ attachAndRegisterModel(User, userSchema);
1138
+
1139
+ const doc = new Y.Doc();
1140
+ const { dbEngine, connectDocument } = await initJsBao({
1141
+ databaseConfig: {
1142
+ type: "sqljs",
1143
+ options: {},
1144
+ },
1145
+ models: [User],
1146
+ });
1147
+
1148
+ // Connect the document
1149
+ await connectDocument("main-doc", doc, "read-write");
1150
+
1151
+ // Create and save a user
1152
+ const user = new User({ name: "John Doe", email: "john@example.com" });
1153
+ await user.save({ targetDocument: "main-doc" });
1154
+
1155
+ // Query users
1156
+ const users = await User.query({ name: { $containsText: "John" } });
1157
+ console.log("Found users:", users.data);
1158
+ ```
1159
+
1160
+ ### Node.js Usage
1161
+
1162
+ ```typescript
1163
+ import {
1164
+ initJsBao,
1165
+ BaseModel,
1166
+ defineModelSchema,
1167
+ attachAndRegisterModel,
1168
+ getRecommendedNodeEngine,
1169
+ } from "js-bao/node";
1170
+ import * as Y from "yjs";
1171
+
1172
+ const userSchema = defineModelSchema({
1173
+ name: "users",
1174
+ fields: {
1175
+ id: { type: "id", autoAssign: true, indexed: true },
1176
+ name: { type: "string" },
1177
+ email: { type: "string" },
1178
+ },
1179
+ });
1180
+
1181
+ class User extends BaseModel {}
1182
+
1183
+ attachAndRegisterModel(User, userSchema);
1184
+
1185
+ const doc = new Y.Doc();
1186
+ const engineType = await getRecommendedNodeEngine();
1187
+
1188
+ const { connectDocument } = await initJsBao({
1189
+ databaseConfig: {
1190
+ type: engineType,
1191
+ options: { filePath: ":memory:" },
1192
+ },
1193
+ models: [User],
1194
+ });
1195
+
1196
+ await connectDocument("main-doc", doc, "read-write");
1197
+
1198
+ const user = new User({ name: "John Doe", email: "john@example.com" });
1199
+ await user.save({ targetDocument: "main-doc" });
1200
+ ```
1201
+
1202
+ ## Database Configuration
1203
+
1204
+ ### SQLite (Node.js)
1205
+
1206
+ ```javascript
1207
+ {
1208
+ type: 'node-sqlite',
1209
+ options: {
1210
+ filePath: ':memory:' // or '/path/to/database.db'
1211
+ }
1212
+ }
1213
+ ```
1214
+
1215
+ ### SQL.js (Browser/Node.js)
1216
+
1217
+ ```javascript
1218
+ {
1219
+ type: 'sqljs',
1220
+ options: {
1221
+ // Browser: automatically loads WASM
1222
+ // Node.js: fallback option
1223
+ }
1224
+ }
1225
+ ```
1226
+
1227
+ ## Engine Detection
1228
+
1229
+ Check available engines in your environment:
1230
+
1231
+ ```javascript
1232
+ import { DatabaseFactory } from "js-bao/node"; // or 'js-bao' for browser
1233
+
1234
+ const engines = await DatabaseFactory.getAvailableEngines();
1235
+ engines.forEach((engine) => {
1236
+ console.log(
1237
+ `${engine.type}: ${engine.available ? "✅" : "❌"} ${engine.reason || ""}`
1238
+ );
1239
+ });
1240
+ ```
1241
+
1242
+ ## Debug Inspector
1243
+
1244
+ Run the bundled Y.Doc debugger to inspect saved updates or dump JSONs:
1245
+
1246
+ 1. From the repo root, serve static files so `dist/` is reachable (e.g., `python -m http.server 8000` or `npx serve demos/debug-inspector`).
1247
+ 2. Open `http://localhost:8000/demos/debug-inspector/` (or the `index.html` in that folder) in your browser.
1248
+ 3. Load your model module and Y.Doc update/dump, then click **Reset & Connect** to reindex and start querying.
1249
+
1250
+ ## Examples
1251
+
1252
+ See the `examples/` directory for complete working examples:
1253
+
1254
+ - `examples/simple-node-test.mjs` - Basic Node.js usage without models
1255
+ - `examples/node-example.mjs` - Complete Node.js example with models