taladb 0.8.4 → 0.9.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.
@@ -39,15 +39,13 @@ function unsupportedSync(runtime) {
39
39
  };
40
40
  }
41
41
  async function readCursor(cursorCol, target) {
42
- const doc = await cursorCol.findOne({ _id: target });
43
- return doc?.sinceMs ?? 0;
42
+ const doc = await cursorCol.findOne({ target });
43
+ return { pushMs: doc?.pushMs ?? 0, pullMs: doc?.pullMs ?? 0 };
44
44
  }
45
- async function writeCursor(cursorCol, target, sinceMs) {
46
- const existing = await cursorCol.findOne({ _id: target });
47
- if (existing) {
48
- await cursorCol.updateOne({ _id: target }, { $set: { sinceMs } });
49
- } else {
50
- await cursorCol.insert({ _id: target, sinceMs });
45
+ async function writeCursor(cursorCol, target, cursor) {
46
+ const updated = await cursorCol.updateOne({ target }, { $set: { ...cursor } });
47
+ if (!updated) {
48
+ await cursorCol.insert({ target, ...cursor });
51
49
  }
52
50
  }
53
51
  async function runSync(handle, adapter, options) {
@@ -63,12 +61,11 @@ async function runSync(handle, adapter, options) {
63
61
  }
64
62
  const collections = await resolveCollections(handle, options);
65
63
  const cursorCol = handle.collection(CURSOR_COLLECTION);
66
- const sinceMs = await readCursor(cursorCol, target);
67
- const startedAt = Date.now();
68
- const local = doPush ? await handle.exportChanges(collections, sinceMs) : "[]";
64
+ const cursor = await readCursor(cursorCol, target);
65
+ const local = doPush ? await handle.exportChanges(collections, 0) : "[]";
69
66
  let pulled = 0;
70
67
  if (doPull) {
71
- const remote = await adapter.pull(sinceMs);
68
+ const remote = await adapter.pull(0);
72
69
  if (remote && remote !== "[]") {
73
70
  pulled = await handle.importChanges(remote);
74
71
  }
@@ -78,8 +75,11 @@ async function runSync(handle, adapter, options) {
78
75
  pushed = JSON.parse(local).length;
79
76
  await adapter.push(local);
80
77
  }
81
- await writeCursor(cursorCol, target, startedAt);
82
- return { pushed, pulled, cursor: startedAt };
78
+ await writeCursor(cursorCol, target, {
79
+ pushMs: cursor.pushMs,
80
+ pullMs: cursor.pullMs
81
+ });
82
+ return { pushed, pulled, cursor: 0 };
83
83
  }
84
84
 
85
85
  // src/http-adapter.ts
@@ -87,7 +87,7 @@ var HttpSyncAdapter = class {
87
87
  constructor(options) {
88
88
  this.endpoint = options.endpoint.replace(/\/$/, "");
89
89
  this.headers = options.headers ?? {};
90
- const f = options.fetch ?? globalThis.fetch;
90
+ const f = options.fetch ?? globalThis.fetch?.bind(globalThis);
91
91
  if (!f) {
92
92
  throw new Error(
93
93
  "HttpSyncAdapter: no fetch available. Pass options.fetch on runtimes without a global fetch."
@@ -213,28 +213,44 @@ var WorkerProxy = class {
213
213
  this.pending.clear();
214
214
  }
215
215
  };
216
- function makePoller(findFn, callback) {
216
+ function makePoller(findFn, callback, onError) {
217
217
  let active = true;
218
218
  let lastJson = "";
219
+ let running = false;
220
+ let rerun = false;
219
221
  const poll = async () => {
220
222
  if (!active) return;
223
+ if (running) {
224
+ rerun = true;
225
+ return;
226
+ }
227
+ running = true;
221
228
  try {
222
229
  const docs = await findFn();
230
+ if (!active) return;
223
231
  const json = JSON.stringify(docs);
224
232
  if (json !== lastJson) {
225
233
  lastJson = json;
226
234
  callback(docs);
227
235
  }
228
- } catch {
236
+ } catch (error) {
237
+ if (active) onError?.(error);
238
+ } finally {
239
+ running = false;
240
+ if (active) {
241
+ if (rerun) {
242
+ rerun = false;
243
+ void poll();
244
+ } else setTimeout(poll, 300);
245
+ }
229
246
  }
230
- if (active) setTimeout(poll, 300);
231
247
  };
232
248
  poll();
233
249
  return () => {
234
250
  active = false;
235
251
  };
236
252
  }
237
- async function createBrowserDB(dbName, config) {
253
+ async function createBrowserDB(dbName, config, passphrase) {
238
254
  const workerUrl = new URL("@taladb/web/worker/taladb.worker.js", import.meta.url);
239
255
  const worker = new Worker(workerUrl, { type: "module", name: "taladb" });
240
256
  const proxy = new WorkerProxy(worker);
@@ -242,7 +258,13 @@ async function createBrowserDB(dbName, config) {
242
258
  proxy.abort(new Error(`taladb worker error: ${e.message ?? "unknown"}`));
243
259
  };
244
260
  const configJson = config !== void 0 ? JSON.stringify(config) : void 0;
245
- await proxy.send("init", { dbName, configJson });
261
+ try {
262
+ await proxy.send("init", { dbName, configJson, passphrase });
263
+ } catch (e) {
264
+ proxy.abort(e instanceof Error ? e : new Error(String(e)));
265
+ worker.terminate();
266
+ throw e;
267
+ }
246
268
  const nudgeCallbacks = /* @__PURE__ */ new Set();
247
269
  let channel = null;
248
270
  if (typeof BroadcastChannel !== "undefined") {
@@ -303,6 +325,8 @@ async function createBrowserDB(dbName, config) {
303
325
  },
304
326
  createIndex: (field) => proxy.send("createIndex", { collection: name, field }),
305
327
  dropIndex: (field) => proxy.send("dropIndex", { collection: name, field }),
328
+ createCompoundIndex: (fields) => proxy.send("createCompoundIndex", { collection: name, fieldsJson: JSON.stringify(fields) }),
329
+ dropCompoundIndex: (fields) => proxy.send("dropCompoundIndex", { collection: name, fieldsJson: JSON.stringify(fields) }),
306
330
  createFtsIndex: (field) => proxy.send("createFtsIndex", { collection: name, field }),
307
331
  dropFtsIndex: (field) => proxy.send("dropFtsIndex", { collection: name, field }),
308
332
  createVectorIndex: (field, options) => {
@@ -333,12 +357,19 @@ async function createBrowserDB(dbName, config) {
333
357
  });
334
358
  return JSON.parse(json);
335
359
  },
336
- subscribe: (filter, callback) => {
360
+ subscribe: (filter, callback, onError) => {
337
361
  let active = true;
338
362
  let lastJson = "";
339
363
  let timer = null;
364
+ let running = false;
365
+ let rerun = false;
340
366
  const poll = async () => {
341
367
  if (!active) return;
368
+ if (running) {
369
+ rerun = true;
370
+ return;
371
+ }
372
+ running = true;
342
373
  if (timer !== null) {
343
374
  clearTimeout(timer);
344
375
  timer = null;
@@ -348,13 +379,22 @@ async function createBrowserDB(dbName, config) {
348
379
  collection: name,
349
380
  filterJson: filter ? s(filter) : "null"
350
381
  });
382
+ if (!active) return;
351
383
  if (json !== lastJson) {
352
384
  lastJson = json;
353
385
  callback(JSON.parse(json));
354
386
  }
355
- } catch {
387
+ } catch (error) {
388
+ if (active) onError?.(error);
389
+ } finally {
390
+ running = false;
391
+ }
392
+ if (active) {
393
+ if (rerun) {
394
+ rerun = false;
395
+ void poll();
396
+ } else timer = setTimeout(poll, 300);
356
397
  }
357
- if (active) timer = setTimeout(poll, 300);
358
398
  };
359
399
  nudgeCallbacks.add(poll);
360
400
  poll();
@@ -370,9 +410,11 @@ async function createBrowserDB(dbName, config) {
370
410
  };
371
411
  return opts ? applySchema(wrapped, opts) : wrapped;
372
412
  }
373
- return {
413
+ const handle = {
374
414
  collection: (name, opts) => wrapCollection(name, opts),
375
415
  compact: () => proxy.send("compact"),
416
+ syncStatus: async () => JSON.parse(await proxy.send("syncStatus")),
417
+ flushSync: (timeoutMs = 5e3) => proxy.send("flushSync", { timeoutMs }),
376
418
  close: async () => {
377
419
  channel?.close();
378
420
  try {
@@ -382,13 +424,21 @@ async function createBrowserDB(dbName, config) {
382
424
  proxy.abort(new Error("taladb worker closed"));
383
425
  }
384
426
  },
385
- ...unsupportedSync("browser (OPFS worker)")
427
+ // All engine work (export scan, LWW merge) runs inside the worker, off the
428
+ // main thread — a sync pass never blocks rendering, whatever its size.
429
+ exportChanges: (collections, sinceMs) => proxy.send("exportChangeset", { collectionsJson: JSON.stringify(collections), sinceMs }),
430
+ importChanges: (changeset) => proxy.send("importChangeset", { changesetJson: changeset }),
431
+ listCollectionNames: async () => JSON.parse(await proxy.send("listCollections")),
432
+ sync: (adapter, options) => runSync(handle, adapter, options)
386
433
  };
434
+ return handle;
387
435
  }
388
- async function createNodeDB(dbName, config) {
389
- const { TalaDBNode } = await import("@taladb/node");
436
+ async function createNodeDB(dbName, config, passphrase) {
437
+ const native = await import("./node-A4LKRSW5.mjs");
438
+ const TalaDBNode = native.TalaDbNode ?? native.TalaDBNode;
439
+ if (!TalaDBNode) throw new Error("@taladb/node loaded but exports no TalaDbNode class \u2014 rebuild the native module");
390
440
  const configJson = config !== void 0 ? JSON.stringify(config) : null;
391
- const db = TalaDBNode.open(dbName, configJson);
441
+ const db = TalaDBNode.open(dbName, configJson, passphrase ?? null);
392
442
  function wrapCollection(name, opts) {
393
443
  const col = db.collection(name);
394
444
  const wrapped = {
@@ -404,6 +454,8 @@ async function createNodeDB(dbName, config) {
404
454
  aggregate: async (pipeline) => col.aggregate(pipeline),
405
455
  createIndex: async (field) => col.createIndex(field),
406
456
  dropIndex: async (field) => col.dropIndex(field),
457
+ createCompoundIndex: async (fields) => col.createCompoundIndex(fields),
458
+ dropCompoundIndex: async (fields) => col.dropCompoundIndex(fields),
407
459
  createFtsIndex: async (field) => col.createFtsIndex(field),
408
460
  dropFtsIndex: async (field) => col.dropFtsIndex(field),
409
461
  createVectorIndex: async (field, options) => col.createVectorIndex(field, options.dimensions, options.metric ?? null, options.indexType ?? null, options.hnswM ?? null, options.hnswEfConstruction ?? null),
@@ -417,7 +469,7 @@ async function createNodeDB(dbName, config) {
417
469
  const raw = await col.findNearest(field, vector, topK, filter ?? null);
418
470
  return raw;
419
471
  },
420
- subscribe: (filter, callback) => makePoller(async () => col.find(filter ?? null), callback)
472
+ subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError)
421
473
  };
422
474
  return opts ? applySchema(wrapped, opts) : wrapped;
423
475
  }
@@ -455,6 +507,8 @@ async function createNativeDB(_dbName) {
455
507
  aggregate: async (pipeline) => native.aggregate(name, pipeline),
456
508
  createIndex: async (field) => native.createIndex(name, field),
457
509
  dropIndex: async (field) => native.dropIndex(name, field),
510
+ createCompoundIndex: async (fields) => native.createCompoundIndex(name, fields),
511
+ dropCompoundIndex: async (fields) => native.dropCompoundIndex(name, fields),
458
512
  createFtsIndex: async (field) => native.createFtsIndex(name, field),
459
513
  dropFtsIndex: async (field) => native.dropFtsIndex(name, field),
460
514
  createVectorIndex: async (field, options) => {
@@ -474,18 +528,35 @@ async function createNativeDB(_dbName) {
474
528
  const raw = native.findNearest(name, field, vector, topK, filter ?? null);
475
529
  return raw;
476
530
  },
477
- subscribe: (filter, callback) => makePoller(async () => native.find(name, filter ?? {}), callback)
531
+ subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError)
478
532
  };
479
533
  return opts ? applySchema(wrapped, opts) : wrapped;
480
534
  }
535
+ const syncSurface = typeof native.exportChanges === "function" && typeof native.importChanges === "function" && typeof native.listCollectionNames === "function" ? (() => {
536
+ const handle = {
537
+ collection: (name, opts) => wrapCollection(name, opts),
538
+ exportChanges: async (collections, sinceMs) => native.exportChanges(collections, sinceMs),
539
+ importChanges: async (changeset) => native.importChanges(changeset),
540
+ listCollectionNames: async () => native.listCollectionNames(),
541
+ sync: (adapter, options) => runSync(handle, adapter, options)
542
+ };
543
+ return {
544
+ exportChanges: handle.exportChanges,
545
+ importChanges: handle.importChanges,
546
+ sync: handle.sync
547
+ };
548
+ })() : unsupportedSync("react-native");
481
549
  return {
482
550
  collection: (name, opts) => wrapCollection(name, opts),
483
551
  compact: async () => native.compact(),
484
552
  close: async () => native.close(),
485
- ...unsupportedSync("react-native")
553
+ ...syncSurface
486
554
  };
487
555
  }
488
556
  async function openDB(dbName = "taladb.db", options) {
557
+ if (options?.passphrase !== void 0 && options.passphrase.length === 0) {
558
+ throw new Error("TalaDB encryption passphrase must not be empty");
559
+ }
489
560
  let resolvedConfig;
490
561
  if (options?.config !== void 0) {
491
562
  validateConfig(options.config);
@@ -496,11 +567,14 @@ async function openDB(dbName = "taladb.db", options) {
496
567
  const platform = detectPlatform();
497
568
  switch (platform) {
498
569
  case "browser":
499
- return createBrowserDB(dbName, resolvedConfig);
570
+ return createBrowserDB(dbName, resolvedConfig, options?.passphrase);
500
571
  case "react-native":
572
+ if (options?.passphrase !== void 0) {
573
+ throw new Error("On React Native, pass the passphrase in the config JSON to TalaDBModule.initialize(); refusing to assume the already-open native database is encrypted");
574
+ }
501
575
  return createNativeDB(dbName);
502
576
  case "node":
503
- return createNodeDB(dbName, resolvedConfig);
577
+ return createNodeDB(dbName, resolvedConfig, options?.passphrase);
504
578
  }
505
579
  }
506
580
  export {
package/dist/index.d.mts CHANGED
@@ -57,6 +57,7 @@ type FieldOps<T> = T extends null | undefined ? {
57
57
  $exists?: boolean;
58
58
  /** Full-text search: matches documents where this string field contains the given token. */
59
59
  $contains?: string;
60
+ $regex?: string;
60
61
  };
61
62
  type Filter<T extends Document = Document> = {
62
63
  [K in keyof T]?: T[K] | FieldOps<T[K]>;
@@ -157,6 +158,21 @@ interface Collection<T extends Document = Document> {
157
158
  aggregate<R extends Document = Document>(pipeline: AggregatePipeline<T>): Promise<R[]>;
158
159
  createIndex(field: keyof Omit<T, '_id'> & string): Promise<void>;
159
160
  dropIndex(field: keyof Omit<T, '_id'> & string): Promise<void>;
161
+ /**
162
+ * Create a compound (multi-field) index over an ordered list of fields.
163
+ *
164
+ * The query planner uses it to accelerate an `$and` where **every** field of
165
+ * the index is constrained by equality — e.g. an index on
166
+ * `['userId', 'status']` serves `find({ userId, status })` with a single
167
+ * index scan instead of a full-collection scan. Fields are ascending; a
168
+ * partial-prefix or trailing-range match is not used yet (planned).
169
+ *
170
+ * @example
171
+ * await orders.createCompoundIndex(['userId', 'status'])
172
+ */
173
+ createCompoundIndex(fields: (keyof Omit<T, '_id'> & string)[]): Promise<void>;
174
+ /** Drop a compound index by its ordered field list. */
175
+ dropCompoundIndex(fields: (keyof Omit<T, '_id'> & string)[]): Promise<void>;
160
176
  /**
161
177
  * Create a full-text search index on a string field.
162
178
  *
@@ -228,7 +244,7 @@ interface Collection<T extends Document = Document> {
228
244
  * // later…
229
245
  * unsub();
230
246
  */
231
- subscribe(filter: Filter<T>, callback: (docs: T[]) => void): () => void;
247
+ subscribe(filter: Filter<T>, callback: (docs: T[]) => void, onError?: (error: unknown) => void): () => void;
232
248
  }
233
249
  /**
234
250
  * A JSON-encoded changeset — the opaque payload exchanged between peers. Produced
@@ -269,8 +285,9 @@ interface SyncOptions {
269
285
  /** Direction of the pass. Default `'both'` (bidirectional). */
270
286
  direction?: SyncDirection;
271
287
  /**
272
- * Names this sync target for cursor persistence, so multiple remotes each
273
- * keep their own watermark. Default `'default'`.
288
+ * Names this sync target. Reserved cursor state remains isolated per target
289
+ * for forward compatibility with monotonic server cursors. Default
290
+ * `'default'`.
274
291
  */
275
292
  target?: string;
276
293
  }
@@ -279,7 +296,7 @@ interface SyncResult {
279
296
  pushed: number;
280
297
  /** Number of documents changed locally by the pulled remote changeset. */
281
298
  pulled: number;
282
- /** New sync cursor (ms epoch) persisted for the next pass. */
299
+ /** Active sync cursor. Currently `0` because timestamp adapters replay safely. */
283
300
  cursor: number;
284
301
  }
285
302
  interface TalaDB {
@@ -317,6 +334,14 @@ interface TalaDB {
317
334
  * await db.compact();
318
335
  */
319
336
  compact(): Promise<void>;
337
+ /** Browser HTTP-push queue health, when supported by the active binding. */
338
+ syncStatus?(): Promise<{
339
+ pending: number;
340
+ dropped: number;
341
+ failed: number;
342
+ }>;
343
+ /** Wait for accepted browser HTTP-push events, returning false on timeout. */
344
+ flushSync?(timeoutMs?: number): Promise<boolean>;
320
345
  close(): Promise<void>;
321
346
  }
322
347
 
@@ -408,6 +433,8 @@ declare class TalaDbValidationError extends Error {
408
433
 
409
434
  /** Options for `openDB`. */
410
435
  interface OpenDBOptions {
436
+ /** Encrypt native database values at rest. Never hard-code this value. */
437
+ passphrase?: string;
411
438
  /**
412
439
  * Explicit path to a `taladb.config.yml` / `taladb.config.json` file.
413
440
  * If omitted, TalaDB auto-discovers the file from `process.cwd()` on Node.js.
package/dist/index.d.ts CHANGED
@@ -57,6 +57,7 @@ type FieldOps<T> = T extends null | undefined ? {
57
57
  $exists?: boolean;
58
58
  /** Full-text search: matches documents where this string field contains the given token. */
59
59
  $contains?: string;
60
+ $regex?: string;
60
61
  };
61
62
  type Filter<T extends Document = Document> = {
62
63
  [K in keyof T]?: T[K] | FieldOps<T[K]>;
@@ -157,6 +158,21 @@ interface Collection<T extends Document = Document> {
157
158
  aggregate<R extends Document = Document>(pipeline: AggregatePipeline<T>): Promise<R[]>;
158
159
  createIndex(field: keyof Omit<T, '_id'> & string): Promise<void>;
159
160
  dropIndex(field: keyof Omit<T, '_id'> & string): Promise<void>;
161
+ /**
162
+ * Create a compound (multi-field) index over an ordered list of fields.
163
+ *
164
+ * The query planner uses it to accelerate an `$and` where **every** field of
165
+ * the index is constrained by equality — e.g. an index on
166
+ * `['userId', 'status']` serves `find({ userId, status })` with a single
167
+ * index scan instead of a full-collection scan. Fields are ascending; a
168
+ * partial-prefix or trailing-range match is not used yet (planned).
169
+ *
170
+ * @example
171
+ * await orders.createCompoundIndex(['userId', 'status'])
172
+ */
173
+ createCompoundIndex(fields: (keyof Omit<T, '_id'> & string)[]): Promise<void>;
174
+ /** Drop a compound index by its ordered field list. */
175
+ dropCompoundIndex(fields: (keyof Omit<T, '_id'> & string)[]): Promise<void>;
160
176
  /**
161
177
  * Create a full-text search index on a string field.
162
178
  *
@@ -228,7 +244,7 @@ interface Collection<T extends Document = Document> {
228
244
  * // later…
229
245
  * unsub();
230
246
  */
231
- subscribe(filter: Filter<T>, callback: (docs: T[]) => void): () => void;
247
+ subscribe(filter: Filter<T>, callback: (docs: T[]) => void, onError?: (error: unknown) => void): () => void;
232
248
  }
233
249
  /**
234
250
  * A JSON-encoded changeset — the opaque payload exchanged between peers. Produced
@@ -269,8 +285,9 @@ interface SyncOptions {
269
285
  /** Direction of the pass. Default `'both'` (bidirectional). */
270
286
  direction?: SyncDirection;
271
287
  /**
272
- * Names this sync target for cursor persistence, so multiple remotes each
273
- * keep their own watermark. Default `'default'`.
288
+ * Names this sync target. Reserved cursor state remains isolated per target
289
+ * for forward compatibility with monotonic server cursors. Default
290
+ * `'default'`.
274
291
  */
275
292
  target?: string;
276
293
  }
@@ -279,7 +296,7 @@ interface SyncResult {
279
296
  pushed: number;
280
297
  /** Number of documents changed locally by the pulled remote changeset. */
281
298
  pulled: number;
282
- /** New sync cursor (ms epoch) persisted for the next pass. */
299
+ /** Active sync cursor. Currently `0` because timestamp adapters replay safely. */
283
300
  cursor: number;
284
301
  }
285
302
  interface TalaDB {
@@ -317,6 +334,14 @@ interface TalaDB {
317
334
  * await db.compact();
318
335
  */
319
336
  compact(): Promise<void>;
337
+ /** Browser HTTP-push queue health, when supported by the active binding. */
338
+ syncStatus?(): Promise<{
339
+ pending: number;
340
+ dropped: number;
341
+ failed: number;
342
+ }>;
343
+ /** Wait for accepted browser HTTP-push events, returning false on timeout. */
344
+ flushSync?(timeoutMs?: number): Promise<boolean>;
320
345
  close(): Promise<void>;
321
346
  }
322
347
 
@@ -408,6 +433,8 @@ declare class TalaDbValidationError extends Error {
408
433
 
409
434
  /** Options for `openDB`. */
410
435
  interface OpenDBOptions {
436
+ /** Encrypt native database values at rest. Never hard-code this value. */
437
+ passphrase?: string;
411
438
  /**
412
439
  * Explicit path to a `taladb.config.yml` / `taladb.config.json` file.
413
440
  * If omitted, TalaDB auto-discovers the file from `process.cwd()` on Node.js.