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/LICENSE +5 -0
- package/README.md +1255 -0
- package/dist/browser.cjs +5828 -0
- package/dist/browser.d.cts +858 -0
- package/dist/browser.d.ts +858 -0
- package/dist/browser.js +5804 -0
- package/dist/codegen.cjs +1325 -0
- package/dist/codegen.d.cts +1 -0
- package/dist/index.cjs +6498 -0
- package/dist/index.d.cts +809 -0
- package/dist/index.d.ts +809 -0
- package/dist/index.js +6447 -0
- package/dist/node.cjs +6482 -0
- package/dist/node.d.cts +894 -0
- package/dist/node.d.ts +894 -0
- package/dist/node.js +6428 -0
- package/package.json +96 -0
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
|