@moltendb-web/query 0.1.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/src/index.ts ADDED
@@ -0,0 +1,583 @@
1
+ // ─── MoltenDB Query Builder ───────────────────────────────────────────────────
2
+ // Chainable, type-safe query builder for MoltenDB.
3
+ //
4
+ // Each operation has its own builder class that only exposes the methods
5
+ // that are valid for that operation — matching the server's allowed-property
6
+ // lists exactly:
7
+ //
8
+ // GET_ALLOWED: collection, keys, where, fields, excludedFields,
9
+ // joins, sort, count, offset
10
+ // SET_ALLOWED: collection, data, extends
11
+ // UPDATE_ALLOWED: collection, data
12
+ // DELETE_ALLOWED: collection, keys, drop
13
+ //
14
+ // Usage (vanilla JS or TypeScript):
15
+ //
16
+ // const db = new MoltenDBClient(worker);
17
+ //
18
+ // // GET — chainable query
19
+ // const results = await db.collection('laptops')
20
+ // .get()
21
+ // .where({ brand: 'Apple' })
22
+ // .fields(['brand', 'model', 'price'])
23
+ // .sort([{ field: 'price', order: 'asc' }])
24
+ // .count(10)
25
+ // .exec();
26
+ //
27
+ // // SET — insert/upsert
28
+ // await db.collection('laptops')
29
+ // .set({ lp1: { brand: 'Lenovo', price: 1499 } })
30
+ // .exec();
31
+ //
32
+ // // UPDATE — partial patch
33
+ // await db.collection('laptops')
34
+ // .update({ lp4: { price: 1749, in_stock: true } })
35
+ // .exec();
36
+ //
37
+ // // DELETE — single key, batch, or drop
38
+ // await db.collection('laptops').delete().keys('lp6').exec();
39
+ // await db.collection('laptops').delete().keys(['lp4', 'lp5']).exec();
40
+ // await db.collection('laptops').delete().drop().exec();
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ // ─── Types ────────────────────────────────────────────────────────────────────
44
+
45
+ /** A plain JSON-serialisable value. */
46
+ export type JsonValue =
47
+ | string
48
+ | number
49
+ | boolean
50
+ | null
51
+ | JsonValue[]
52
+ | { [key: string]: JsonValue };
53
+
54
+ /** A document stored in MoltenDB — any object with string keys. */
55
+ export type Document = { [key: string]: JsonValue };
56
+
57
+ /** A map of document key → document body used in set/update payloads. */
58
+ export type DataMap = { [key: string]: Document };
59
+
60
+ // ── WHERE operators ───────────────────────────────────────────────────────────
61
+
62
+ /** Comparison operators supported in a WHERE clause. */
63
+ export interface WhereOperators {
64
+ $eq?: JsonValue;
65
+ $ne?: JsonValue;
66
+ $gt?: number;
67
+ $gte?: number;
68
+ $lt?: number;
69
+ $lte?: number;
70
+ $in?: JsonValue[];
71
+ $nin?: JsonValue[];
72
+ $contains?: JsonValue;
73
+ }
74
+
75
+ /**
76
+ * A WHERE clause: each key is a field path (dot-notation supported),
77
+ * and the value is either a plain value (implicit equality) or an operator object.
78
+ */
79
+ export type WhereClause = {
80
+ [field: string]: JsonValue | WhereOperators;
81
+ };
82
+
83
+ // ── Sort ──────────────────────────────────────────────────────────────────────
84
+
85
+ /** A single sort specification. */
86
+ export interface SortSpec {
87
+ field: string;
88
+ order?: 'asc' | 'desc';
89
+ }
90
+
91
+ // ── Join ──────────────────────────────────────────────────────────────────────
92
+
93
+ /** A single join specification. */
94
+ export interface JoinSpec {
95
+ /** The alias under which the joined document is embedded. */
96
+ alias: string;
97
+ /** The collection to join from. */
98
+ from: string;
99
+ /** The foreign-key field path on the main document. */
100
+ on: string;
101
+ /** Optional field projection on the joined document. */
102
+ fields?: string[];
103
+ }
104
+
105
+ // ── Extends ───────────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Inline reference embedding at insert time.
109
+ * Format: { alias: "collection.key" }
110
+ * Example: { ram: "memory.mem4", screen: "display.dsp3" }
111
+ */
112
+ export type ExtendsMap = { [alias: string]: string };
113
+
114
+ // ── Transport interface ───────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * The transport layer used by MoltenDBClient to send messages.
118
+ * Implement this interface to connect the query builder to any backend:
119
+ * - A Web Worker (WASM in-browser)
120
+ * - A fetch-based HTTP client
121
+ * - A WebSocket connection
122
+ * - A mock for testing
123
+ */
124
+ export interface MoltenTransport {
125
+ send(action: 'get' | 'set' | 'update' | 'delete', payload: Document): Promise<JsonValue>;
126
+ }
127
+
128
+ // ─── WorkerTransport ──────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Default transport that communicates with a MoltenDB Web Worker.
132
+ *
133
+ * The worker must follow the moltendb-worker.js message protocol:
134
+ * postMessage({ id, action, ...payload })
135
+ * onmessage → { id, result } | { id, error }
136
+ */
137
+ export class WorkerTransport implements MoltenTransport {
138
+ private worker: Worker;
139
+ private messageId: number;
140
+ private pending = new Map<number, { resolve: (v: JsonValue) => void; reject: (e: Error) => void }>();
141
+
142
+ constructor(worker: Worker, startId = 0) {
143
+ this.messageId = startId;
144
+ this.worker = worker;
145
+ this.worker.addEventListener('message', (event: MessageEvent) => {
146
+ const { id, result, error } = event.data as { id: number; result?: JsonValue; error?: string };
147
+ const p = this.pending.get(id);
148
+ if (!p) return;
149
+ this.pending.delete(id);
150
+ if (error) p.reject(new Error(error));
151
+ else p.resolve(result ?? null);
152
+ });
153
+ }
154
+
155
+ send(action: 'get' | 'set' | 'update' | 'delete', payload: Document): Promise<JsonValue> {
156
+ return new Promise((resolve, reject) => {
157
+ const id = this.messageId++;
158
+ this.pending.set(id, { resolve, reject });
159
+ this.worker.postMessage({ id, action, ...payload });
160
+ });
161
+ }
162
+ }
163
+
164
+ // ─── GetQuery ─────────────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Builder for GET (read/query) operations.
168
+ *
169
+ * Allowed fields: collection, keys, where, fields, excludedFields,
170
+ * joins, sort, count, offset
171
+ *
172
+ * @example
173
+ * const rows = await db.collection('laptops')
174
+ * .get()
175
+ * .where({ brand: 'Apple' })
176
+ * .fields(['brand', 'model', 'price'])
177
+ * .sort([{ field: 'price', order: 'asc' }])
178
+ * .count(5)
179
+ * .exec();
180
+ */
181
+ export class GetQuery {
182
+ private payload: Document;
183
+ private transport: MoltenTransport;
184
+
185
+ /** @internal */
186
+ constructor(transport: MoltenTransport, collection: string) {
187
+ this.transport = transport;
188
+ this.payload = { collection };
189
+ }
190
+
191
+ /**
192
+ * Fetch a single document by key, or a batch of documents by key array.
193
+ *
194
+ * @param keys - A single key string or an array of key strings.
195
+ *
196
+ * @example
197
+ * // Single key
198
+ * .keys('lp2')
199
+ * // Batch
200
+ * .keys(['lp1', 'lp3', 'lp5'])
201
+ */
202
+ keys(keys: string | string[]): this {
203
+ this.payload['keys'] = keys as JsonValue;
204
+ return this;
205
+ }
206
+
207
+ /**
208
+ * Filter documents using a WHERE clause.
209
+ * Multiple conditions are combined with implicit AND.
210
+ *
211
+ * Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains
212
+ * Dot-notation is supported for nested fields.
213
+ *
214
+ * @example
215
+ * .where({ brand: 'Apple' })
216
+ * .where({ price: { $gt: 1000, $lt: 3000 } })
217
+ * .where({ 'specs.cpu.brand': 'Intel', in_stock: true })
218
+ */
219
+ where(clause: WhereClause): this {
220
+ this.payload['where'] = clause as unknown as JsonValue;
221
+ return this;
222
+ }
223
+
224
+ /**
225
+ * Project the response to only the specified fields (dot-notation supported).
226
+ * Cannot be combined with {@link excludedFields}.
227
+ *
228
+ * @example
229
+ * .fields(['brand', 'model', 'specs.cpu.ghz'])
230
+ */
231
+ fields(fields: string[]): this {
232
+ this.payload['fields'] = fields as JsonValue;
233
+ return this;
234
+ }
235
+
236
+ /**
237
+ * Return all fields except the specified ones.
238
+ * Cannot be combined with {@link fields}.
239
+ *
240
+ * @example
241
+ * .excludedFields(['price', 'memory_id', 'display_id'])
242
+ */
243
+ excludedFields(fields: string[]): this {
244
+ this.payload['excludedFields'] = fields as JsonValue;
245
+ return this;
246
+ }
247
+
248
+ /**
249
+ * Join related documents from other collections.
250
+ * Each join embeds the related document under the given alias.
251
+ *
252
+ * @example
253
+ * .joins([
254
+ * { alias: 'ram', from: 'memory', on: 'memory_id', fields: ['capacity_gb', 'type'] },
255
+ * { alias: 'screen', from: 'display', on: 'display_id', fields: ['refresh_hz', 'panel'] },
256
+ * ])
257
+ */
258
+ joins(specs: JoinSpec[]): this {
259
+ this.payload['joins'] = specs.map(({ alias, from, on, fields }) => ({
260
+ [alias]: fields ? { from, on, fields } : { from, on },
261
+ })) as unknown as JsonValue;
262
+ return this;
263
+ }
264
+
265
+ /**
266
+ * Sort the results.
267
+ * Multiple specs are applied in priority order (first = primary sort key).
268
+ *
269
+ * @example
270
+ * .sort([{ field: 'price', order: 'asc' }])
271
+ * .sort([{ field: 'brand', order: 'asc' }, { field: 'price', order: 'desc' }])
272
+ */
273
+ sort(specs: SortSpec[]): this {
274
+ this.payload['sort'] = specs as unknown as JsonValue;
275
+ return this;
276
+ }
277
+
278
+ /**
279
+ * Limit the number of results returned (applied after filtering and sorting).
280
+ *
281
+ * @example
282
+ * .count(10)
283
+ */
284
+ count(n: number): this {
285
+ this.payload['count'] = n;
286
+ return this;
287
+ }
288
+
289
+ /**
290
+ * Skip the first N results (for pagination, applied after sorting).
291
+ *
292
+ * @example
293
+ * .offset(20).count(10) // page 3 of 10
294
+ */
295
+ offset(n: number): this {
296
+ this.payload['offset'] = n;
297
+ return this;
298
+ }
299
+
300
+ /**
301
+ * Build and return the raw JSON payload without sending it.
302
+ * Useful for debugging or passing to a custom transport.
303
+ */
304
+ build(): Document {
305
+ return { ...this.payload };
306
+ }
307
+
308
+ /**
309
+ * Execute the query and return the result.
310
+ * Returns a single document for single-key lookups, or an array for all others.
311
+ */
312
+ exec(): Promise<JsonValue> {
313
+ return this.transport.send('get', this.payload);
314
+ }
315
+ }
316
+
317
+ // ─── SetQuery ─────────────────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * Builder for SET (insert / upsert) operations.
321
+ *
322
+ * Allowed fields: collection, data, extends
323
+ *
324
+ * @example
325
+ * await db.collection('laptops')
326
+ * .set({
327
+ * lp1: { brand: 'Lenovo', model: 'ThinkPad X1', price: 1499 },
328
+ * lp2: { brand: 'Apple', model: 'MacBook Pro', price: 3499 },
329
+ * })
330
+ * .exec();
331
+ */
332
+ export class SetQuery {
333
+ private payload: Document;
334
+ private transport: MoltenTransport;
335
+
336
+ /** @internal */
337
+ constructor(transport: MoltenTransport, collection: string, data: DataMap | Document[]) {
338
+ this.transport = transport;
339
+ this.payload = { collection, data: data as unknown as JsonValue };
340
+ }
341
+
342
+ /**
343
+ * Embed data from other collections into each document at insert time.
344
+ * The referenced document is fetched once and stored as a snapshot.
345
+ *
346
+ * Format: `{ alias: "collection.key" }`
347
+ *
348
+ * @example
349
+ * .extends({ ram: 'memory.mem4', screen: 'display.dsp3' })
350
+ */
351
+ extends(map: ExtendsMap): this {
352
+ // The extends map is applied to every document in data.
353
+ // We store it at the top level; the server resolves it per-document.
354
+ const data = this.payload['data'];
355
+ if (Array.isArray(data)) {
356
+ // Array format — inject extends into each item
357
+ this.payload['data'] = (data as Document[]).map((doc) => ({
358
+ ...doc,
359
+ extends: map as unknown as JsonValue,
360
+ })) as unknown as JsonValue;
361
+ } else if (data && typeof data === 'object') {
362
+ // Object format — inject extends into each document value
363
+ const updated: DataMap = {};
364
+ for (const [key, doc] of Object.entries(data as DataMap)) {
365
+ updated[key] = { ...(doc as Document), extends: map as unknown as JsonValue };
366
+ }
367
+ this.payload['data'] = updated as unknown as JsonValue;
368
+ }
369
+ return this;
370
+ }
371
+
372
+ /** Build and return the raw JSON payload without sending it. */
373
+ build(): Document {
374
+ return { ...this.payload };
375
+ }
376
+
377
+ /** Execute the insert/upsert and return `{ count, status }`. */
378
+ exec(): Promise<JsonValue> {
379
+ return this.transport.send('set', this.payload);
380
+ }
381
+ }
382
+
383
+ // ─── UpdateQuery ──────────────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * Builder for UPDATE (partial patch / merge) operations.
387
+ *
388
+ * Allowed fields: collection, data
389
+ *
390
+ * Only the fields present in each patch object are updated —
391
+ * all other fields on the existing document are left unchanged.
392
+ *
393
+ * @example
394
+ * await db.collection('laptops')
395
+ * .update({ lp4: { price: 1749, in_stock: true } })
396
+ * .exec();
397
+ */
398
+ export class UpdateQuery {
399
+ private payload: Document;
400
+ private transport: MoltenTransport;
401
+
402
+ /** @internal */
403
+ constructor(transport: MoltenTransport, collection: string, data: DataMap) {
404
+ this.transport = transport;
405
+ this.payload = { collection, data: data as unknown as JsonValue };
406
+ }
407
+
408
+ /** Build and return the raw JSON payload without sending it. */
409
+ build(): Document {
410
+ return { ...this.payload };
411
+ }
412
+
413
+ /** Execute the patch and return `{ count, status }`. */
414
+ exec(): Promise<JsonValue> {
415
+ return this.transport.send('update', this.payload);
416
+ }
417
+ }
418
+
419
+ // ─── DeleteQuery ──────────────────────────────────────────────────────────────
420
+
421
+ /**
422
+ * Builder for DELETE operations.
423
+ *
424
+ * Allowed fields: collection, keys, drop
425
+ *
426
+ * @example
427
+ * // Delete a single document
428
+ * await db.collection('laptops').delete().keys('lp6').exec();
429
+ *
430
+ * // Delete multiple documents
431
+ * await db.collection('laptops').delete().keys(['lp4', 'lp5']).exec();
432
+ *
433
+ * // Drop the entire collection
434
+ * await db.collection('laptops').delete().drop().exec();
435
+ */
436
+ export class DeleteQuery {
437
+ private payload: Document;
438
+ private transport: MoltenTransport;
439
+
440
+ /** @internal */
441
+ constructor(transport: MoltenTransport, collection: string) {
442
+ this.transport = transport;
443
+ this.payload = { collection };
444
+ }
445
+
446
+ /**
447
+ * Delete a single document by key, or multiple documents by key array.
448
+ *
449
+ * @example
450
+ * .keys('lp6')
451
+ * .keys(['lp4', 'lp5'])
452
+ */
453
+ keys(keys: string | string[]): this {
454
+ this.payload['keys'] = keys as JsonValue;
455
+ return this;
456
+ }
457
+
458
+ /**
459
+ * Drop the entire collection (deletes all documents).
460
+ * Cannot be combined with {@link keys}.
461
+ *
462
+ * @example
463
+ * .drop()
464
+ */
465
+ drop(): this {
466
+ this.payload['drop'] = true;
467
+ return this;
468
+ }
469
+
470
+ /** Build and return the raw JSON payload without sending it. */
471
+ build(): Document {
472
+ return { ...this.payload };
473
+ }
474
+
475
+ /** Execute the delete and return `{ count, status }`. */
476
+ exec(): Promise<JsonValue> {
477
+ return this.transport.send('delete', this.payload);
478
+ }
479
+ }
480
+
481
+ // ─── CollectionHandle ─────────────────────────────────────────────────────────
482
+
483
+ /**
484
+ * A handle to a specific collection.
485
+ * Returned by `MoltenDBClient.collection(name)`.
486
+ * Use it to start any of the four operation builders.
487
+ */
488
+ export class CollectionHandle {
489
+ private transport: MoltenTransport;
490
+ private collectionName: string;
491
+
492
+ /** @internal */
493
+ constructor(transport: MoltenTransport, collectionName: string) {
494
+ this.transport = transport;
495
+ this.collectionName = collectionName;
496
+ }
497
+
498
+ /**
499
+ * Start a GET (read/query) builder for this collection.
500
+ *
501
+ * @example
502
+ * db.collection('laptops').get().where({ brand: 'Apple' }).exec()
503
+ */
504
+ get(): GetQuery {
505
+ return new GetQuery(this.transport, this.collectionName);
506
+ }
507
+
508
+ /**
509
+ * Start a SET (insert/upsert) builder for this collection.
510
+ *
511
+ * @param data - A map of `{ key: document }` pairs, or an array of documents
512
+ * (keys are auto-generated as UUIDv7 when using array format).
513
+ *
514
+ * @example
515
+ * db.collection('laptops').set({ lp1: { brand: 'Lenovo', price: 1499 } }).exec()
516
+ */
517
+ set(data: DataMap | Document[]): SetQuery {
518
+ return new SetQuery(this.transport, this.collectionName, data);
519
+ }
520
+
521
+ /**
522
+ * Start an UPDATE (partial patch) builder for this collection.
523
+ *
524
+ * @param data - A map of `{ key: patch }` pairs. Only the provided fields are updated.
525
+ *
526
+ * @example
527
+ * db.collection('laptops').update({ lp4: { price: 1749 } }).exec()
528
+ */
529
+ update(data: DataMap): UpdateQuery {
530
+ return new UpdateQuery(this.transport, this.collectionName, data);
531
+ }
532
+
533
+ /**
534
+ * Start a DELETE builder for this collection.
535
+ * Chain `.keys(...)` or `.drop()` to specify what to delete.
536
+ *
537
+ * @example
538
+ * db.collection('laptops').delete().keys('lp6').exec()
539
+ */
540
+ delete(): DeleteQuery {
541
+ return new DeleteQuery(this.transport, this.collectionName);
542
+ }
543
+ }
544
+
545
+ // ─── MoltenDBClient ───────────────────────────────────────────────────────────
546
+
547
+ /**
548
+ * The main entry point for the MoltenDB query builder.
549
+ *
550
+ * Accepts any {@link MoltenTransport} implementation — use {@link WorkerTransport}
551
+ * to connect to a MoltenDB WASM Web Worker, or provide your own transport
552
+ * for HTTP, WebSocket, or testing.
553
+ *
554
+ * @example
555
+ * // Browser + WASM Web Worker
556
+ * const worker = new Worker('./moltendb-worker.js', { type: 'module' });
557
+ * const transport = new WorkerTransport(worker);
558
+ * const db = new MoltenDBClient(transport);
559
+ *
560
+ * const results = await db.collection('laptops')
561
+ * .get()
562
+ * .where({ in_stock: true })
563
+ * .sort([{ field: 'price', order: 'asc' }])
564
+ * .count(5)
565
+ * .exec();
566
+ */
567
+ export class MoltenDBQueryBuilder {
568
+ private transport: MoltenTransport;
569
+
570
+ constructor(transport: MoltenTransport) {
571
+ this.transport = transport;
572
+ }
573
+
574
+ /**
575
+ * Select a collection to operate on.
576
+ * Returns a {@link CollectionHandle} from which you can start any query builder.
577
+ *
578
+ * @param name - The collection name (e.g. `'laptops'`).
579
+ */
580
+ collection(name: string): CollectionHandle {
581
+ return new CollectionHandle(this.transport, name);
582
+ }
583
+ }