taladb 0.9.2 → 0.9.4
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/dist/index.browser.mjs +626 -65
- package/dist/index.d.mts +651 -14
- package/dist/index.d.ts +651 -14
- package/dist/index.js +637 -65
- package/dist/index.mjs +626 -65
- package/dist/index.react-native.mjs +626 -65
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -97,7 +97,11 @@ function unsupportedSync(runtime) {
|
|
|
97
97
|
}
|
|
98
98
|
async function readCursor(cursorCol, target) {
|
|
99
99
|
const doc = await cursorCol.findOne({ target });
|
|
100
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
pushMs: doc?.pushMs ?? 0,
|
|
102
|
+
pullMs: doc?.pullMs ?? 0,
|
|
103
|
+
pullCursor: doc?.pullCursor
|
|
104
|
+
};
|
|
101
105
|
}
|
|
102
106
|
async function writeCursor(cursorCol, target, cursor) {
|
|
103
107
|
const updated = await cursorCol.updateOne({ target }, { $set: { ...cursor } });
|
|
@@ -105,13 +109,19 @@ async function writeCursor(cursorCol, target, cursor) {
|
|
|
105
109
|
await cursorCol.insert({ target, ...cursor });
|
|
106
110
|
}
|
|
107
111
|
}
|
|
112
|
+
function isCursorAdapter(adapter) {
|
|
113
|
+
return typeof adapter.pullWithCursor === "function";
|
|
114
|
+
}
|
|
115
|
+
var MAX_PULL_PAGES = 1e4;
|
|
108
116
|
async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
109
117
|
const direction = options.direction ?? "both";
|
|
110
118
|
const target = options.target ?? "default";
|
|
111
119
|
const doPush = direction === "push" || direction === "both";
|
|
112
120
|
const doPull = direction === "pull" || direction === "both";
|
|
113
|
-
if (doPull && !adapter.pull) {
|
|
114
|
-
throw new Error(
|
|
121
|
+
if (doPull && !adapter.pull && !isCursorAdapter(adapter)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`sync direction '${direction}' requires adapter.pull() or adapter.pullWithCursor()`
|
|
124
|
+
);
|
|
115
125
|
}
|
|
116
126
|
if (doPush && !adapter.push) {
|
|
117
127
|
throw new Error(`sync direction '${direction}' requires adapter.push()`);
|
|
@@ -128,17 +138,35 @@ async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
|
128
138
|
let pulled = 0;
|
|
129
139
|
let skipped = 0;
|
|
130
140
|
let quarantined = 0;
|
|
141
|
+
let pullCursor = cursor.pullCursor;
|
|
142
|
+
async function importOne(changeset) {
|
|
143
|
+
if (!changeset || changeset === "[]") return;
|
|
144
|
+
if (useValidated) {
|
|
145
|
+
const report = await handle.importChangesValidated(changeset, JSON.stringify(scopedSchemas));
|
|
146
|
+
pulled += report.applied;
|
|
147
|
+
skipped += report.skipped;
|
|
148
|
+
quarantined += report.quarantined;
|
|
149
|
+
} else {
|
|
150
|
+
pulled += await handle.importChanges(changeset);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
131
153
|
if (doPull) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
154
|
+
if (isCursorAdapter(adapter)) {
|
|
155
|
+
let pages = 0;
|
|
156
|
+
for (; ; ) {
|
|
157
|
+
const result = await adapter.pullWithCursor(pullCursor ?? null);
|
|
158
|
+
await importOne(result.changeset);
|
|
159
|
+
pullCursor = result.cursor;
|
|
160
|
+
await writeCursor(cursorCol, target, { ...cursor, pullCursor });
|
|
161
|
+
if (!result.hasMore) break;
|
|
162
|
+
if (++pages >= MAX_PULL_PAGES) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`sync: origin returned hasMore after ${MAX_PULL_PAGES} pages for target '${target}' \u2014 it is probably not advancing its cursor.`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
141
167
|
}
|
|
168
|
+
} else {
|
|
169
|
+
await importOne(await adapter.pull(0));
|
|
142
170
|
}
|
|
143
171
|
}
|
|
144
172
|
let pushed = 0;
|
|
@@ -148,7 +176,8 @@ async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
|
148
176
|
}
|
|
149
177
|
await writeCursor(cursorCol, target, {
|
|
150
178
|
pushMs: cursor.pushMs,
|
|
151
|
-
pullMs: cursor.pullMs
|
|
179
|
+
pullMs: cursor.pullMs,
|
|
180
|
+
...pullCursor !== void 0 ? { pullCursor } : {}
|
|
152
181
|
});
|
|
153
182
|
return { pushed, pulled, skipped, quarantined, cursor: 0 };
|
|
154
183
|
}
|
|
@@ -189,6 +218,478 @@ var HttpSyncAdapter = class {
|
|
|
189
218
|
}
|
|
190
219
|
};
|
|
191
220
|
|
|
221
|
+
// src/derive-id.ts
|
|
222
|
+
var FNV1A128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dn;
|
|
223
|
+
var FNV1A128_PRIME = 0x0000000001000000000000000000013bn;
|
|
224
|
+
var MASK_128 = (1n << 128n) - 1n;
|
|
225
|
+
var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
226
|
+
var UTF8 = new TextEncoder();
|
|
227
|
+
function encodeUlid(value) {
|
|
228
|
+
let out = "";
|
|
229
|
+
for (let i = 25; i >= 0; i--) {
|
|
230
|
+
out += CROCKFORD[Number(value >> BigInt(i * 5) & 31n)];
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
function deriveDocId(collection, key) {
|
|
235
|
+
const bytes = [...UTF8.encode(collection), 0, ...UTF8.encode(key)];
|
|
236
|
+
let hash = FNV1A128_OFFSET_BASIS;
|
|
237
|
+
for (const byte of bytes) {
|
|
238
|
+
hash ^= BigInt(byte);
|
|
239
|
+
hash = hash * FNV1A128_PRIME & MASK_128;
|
|
240
|
+
}
|
|
241
|
+
return encodeUlid(hash);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/replication/coverage.ts
|
|
245
|
+
var COVERAGE_COLLECTION = "__taladb_replica";
|
|
246
|
+
function coverageKey(key) {
|
|
247
|
+
return [
|
|
248
|
+
key.origin,
|
|
249
|
+
key.collection,
|
|
250
|
+
key.scope,
|
|
251
|
+
`p${key.projectionVersion}`,
|
|
252
|
+
`s${key.schemaVersion}`
|
|
253
|
+
].map(encodeURIComponent).join("|");
|
|
254
|
+
}
|
|
255
|
+
var CoverageStore = class {
|
|
256
|
+
constructor(db) {
|
|
257
|
+
this.col = db.collection(COVERAGE_COLLECTION);
|
|
258
|
+
}
|
|
259
|
+
async read(key) {
|
|
260
|
+
const doc = await this.col.findOne({ key: coverageKey(key) });
|
|
261
|
+
if (!doc?.state) return { status: "empty" };
|
|
262
|
+
try {
|
|
263
|
+
return JSON.parse(doc.state);
|
|
264
|
+
} catch {
|
|
265
|
+
return { status: "empty" };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async write(key, state) {
|
|
269
|
+
const k = coverageKey(key);
|
|
270
|
+
const state_json = JSON.stringify(state);
|
|
271
|
+
await this.col.replaceManyWithIds(
|
|
272
|
+
[{ _id: deriveDocId(COVERAGE_COLLECTION, k), key: k, state: state_json }],
|
|
273
|
+
"local"
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
/** Drop a scope's coverage, forcing a fresh bootstrap on next use. */
|
|
277
|
+
async clear(key) {
|
|
278
|
+
const k = coverageKey(key);
|
|
279
|
+
await this.col.deleteManyWithIds([deriveDocId(COVERAGE_COLLECTION, k)], "local");
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
function isAuthoritative(state) {
|
|
283
|
+
return state.status === "complete";
|
|
284
|
+
}
|
|
285
|
+
function rowsApplied(state) {
|
|
286
|
+
switch (state.status) {
|
|
287
|
+
case "hydrating":
|
|
288
|
+
case "complete":
|
|
289
|
+
case "best-effort":
|
|
290
|
+
return state.rowsApplied;
|
|
291
|
+
default:
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function progress(state) {
|
|
296
|
+
if (state.status === "complete") return 1;
|
|
297
|
+
if (state.status !== "hydrating" || !state.total) return void 0;
|
|
298
|
+
return Math.min(1, state.rowsApplied / state.total);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/replication/coordinator.ts
|
|
302
|
+
var DEFAULT_PAGE_SIZE = 500;
|
|
303
|
+
var defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
304
|
+
var MAX_BOOTSTRAP_PAGES = 1e5;
|
|
305
|
+
var inflightByDatabase = /* @__PURE__ */ new WeakMap();
|
|
306
|
+
var REPLICA_SCOPE_FIELD = "_replica_scope";
|
|
307
|
+
var REPLICA_REVISION_FIELD = "_remote_rev";
|
|
308
|
+
var ReplicationCoordinator = class {
|
|
309
|
+
constructor(db, source, options = {}) {
|
|
310
|
+
this.db = db;
|
|
311
|
+
this.source = source;
|
|
312
|
+
this.coverage = new CoverageStore(db);
|
|
313
|
+
this.key = {
|
|
314
|
+
origin: source.origin,
|
|
315
|
+
collection: source.collection,
|
|
316
|
+
scope: source.scope,
|
|
317
|
+
projectionVersion: source.projectionVersion,
|
|
318
|
+
schemaVersion: source.schemaVersion
|
|
319
|
+
};
|
|
320
|
+
this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
321
|
+
this.yieldFn = options.yieldFn ?? defaultYield;
|
|
322
|
+
this.onProgress = options.onProgress;
|
|
323
|
+
this.collectionOptions = options.collectionOptions;
|
|
324
|
+
let shared = inflightByDatabase.get(db);
|
|
325
|
+
if (!shared) {
|
|
326
|
+
shared = /* @__PURE__ */ new Map();
|
|
327
|
+
inflightByDatabase.set(db, shared);
|
|
328
|
+
}
|
|
329
|
+
this.inflight = shared;
|
|
330
|
+
}
|
|
331
|
+
get replicaScope() {
|
|
332
|
+
return coverageKey(this.key);
|
|
333
|
+
}
|
|
334
|
+
get identityNamespace() {
|
|
335
|
+
return `${this.source.origin}\0${this.source.scope}\0${this.source.collection}`;
|
|
336
|
+
}
|
|
337
|
+
getCoverage() {
|
|
338
|
+
return this.coverage.read(this.key);
|
|
339
|
+
}
|
|
340
|
+
/** Whether a purely local read is authorized right now. */
|
|
341
|
+
async isReady() {
|
|
342
|
+
return isAuthoritative(await this.getCoverage());
|
|
343
|
+
}
|
|
344
|
+
/** Dedup by intent: identical concurrent work joins rather than duplicating. */
|
|
345
|
+
dedup(key, run) {
|
|
346
|
+
const existing = this.inflight.get(key);
|
|
347
|
+
if (existing) return existing;
|
|
348
|
+
const pass = run().finally(() => this.inflight.delete(key));
|
|
349
|
+
this.inflight.set(key, pass);
|
|
350
|
+
return pass;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Write a batch of remote rows into the local collection.
|
|
354
|
+
*
|
|
355
|
+
* One commit for the whole batch, ids derived from the origin's primary key, and
|
|
356
|
+
* `origin: 'remote'` so the rows can never replicate back out at the origin they
|
|
357
|
+
* came from. This is the *only* write path in the coordinator — bootstrap, delta
|
|
358
|
+
* and bridge all funnel through it, which is precisely why they converge instead
|
|
359
|
+
* of conflicting.
|
|
360
|
+
*/
|
|
361
|
+
async applyRows(rows) {
|
|
362
|
+
if (rows.length === 0) return [];
|
|
363
|
+
const col = this.db.collection(this.source.collection, this.collectionOptions);
|
|
364
|
+
const docs = rows.map(
|
|
365
|
+
(row) => {
|
|
366
|
+
const revision = this.source.revisionOf(row);
|
|
367
|
+
return {
|
|
368
|
+
...this.source.mapRow(row),
|
|
369
|
+
_id: deriveDocId(this.identityNamespace, String(this.source.keyOf(row))),
|
|
370
|
+
[REPLICA_SCOPE_FIELD]: this.replicaScope,
|
|
371
|
+
[REPLICA_REVISION_FIELD]: revision
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
await col.replaceManyWithIds(docs, "remote");
|
|
376
|
+
return docs.map((doc) => doc._id);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Hydrate the scope: walk the origin page by page until the whole collection is
|
|
380
|
+
* local, then mark it complete.
|
|
381
|
+
*
|
|
382
|
+
* Resumable and idempotent. If the walk is interrupted — a reload, a crash, a
|
|
383
|
+
* dead network — the next call picks up from the last committed page, and
|
|
384
|
+
* re-applying a page it already wrote is a no-op because the ids are derived.
|
|
385
|
+
*/
|
|
386
|
+
hydrate() {
|
|
387
|
+
return this.dedup(`${this.replicaScope}:hydrate`, () => this.runHydrate());
|
|
388
|
+
}
|
|
389
|
+
async runHydrate() {
|
|
390
|
+
let state = await this.coverage.read(this.key);
|
|
391
|
+
if (state.status === "complete") return state;
|
|
392
|
+
let page = null;
|
|
393
|
+
let snapshot = null;
|
|
394
|
+
let rowsApplied2 = 0;
|
|
395
|
+
let total;
|
|
396
|
+
let deltaCursor;
|
|
397
|
+
if (state.status === "hydrating") {
|
|
398
|
+
page = state.nextPage;
|
|
399
|
+
snapshot = state.snapshot;
|
|
400
|
+
rowsApplied2 = state.rowsApplied;
|
|
401
|
+
total = state.total;
|
|
402
|
+
deltaCursor = state.deltaCursor;
|
|
403
|
+
} else if (state.status === "error" && state.snapshot) {
|
|
404
|
+
page = state.resumeFrom;
|
|
405
|
+
snapshot = state.snapshot;
|
|
406
|
+
rowsApplied2 = state.rowsApplied ?? 0;
|
|
407
|
+
total = state.total;
|
|
408
|
+
deltaCursor = state.deltaCursor;
|
|
409
|
+
}
|
|
410
|
+
let snapshotSupported = true;
|
|
411
|
+
let pages = 0;
|
|
412
|
+
try {
|
|
413
|
+
for (; ; ) {
|
|
414
|
+
const result = await this.source.bootstrap({ page, snapshot, limit: this.pageSize });
|
|
415
|
+
if (snapshot !== null && result.snapshot !== void 0 && result.snapshot !== snapshot) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`replication: origin '${this.source.origin}' changed snapshot token mid-walk`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (snapshot === null && result.snapshot) snapshot = result.snapshot;
|
|
421
|
+
if (result.snapshot === void 0 && page === null) snapshotSupported = false;
|
|
422
|
+
if (result.deltaCursor && !deltaCursor) deltaCursor = result.deltaCursor;
|
|
423
|
+
if (result.total !== void 0) total = result.total;
|
|
424
|
+
rowsApplied2 += (await this.applyRows(result.rows)).length;
|
|
425
|
+
page = result.nextPage;
|
|
426
|
+
if (page !== null) {
|
|
427
|
+
const next = {
|
|
428
|
+
status: "hydrating",
|
|
429
|
+
snapshot: snapshot ?? "",
|
|
430
|
+
nextPage: page,
|
|
431
|
+
rowsApplied: rowsApplied2,
|
|
432
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
433
|
+
...total !== void 0 ? { total } : {}
|
|
434
|
+
};
|
|
435
|
+
await this.coverage.write(this.key, next);
|
|
436
|
+
this.onProgress?.(next);
|
|
437
|
+
if (++pages >= MAX_BOOTSTRAP_PAGES) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`replication: origin '${this.source.origin}' offered more than ${MAX_BOOTSTRAP_PAGES} bootstrap pages for '${this.source.collection}' \u2014 it is probably not advancing nextPage.`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
await this.yieldFn();
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (snapshotSupported && this.source.delta && deltaCursor === void 0) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`replication: origin '${this.source.origin}' supports delta refresh but did not issue deltaCursor on the first bootstrap page`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
state = snapshotSupported ? {
|
|
451
|
+
status: "complete",
|
|
452
|
+
cursor: deltaCursor ?? "",
|
|
453
|
+
completedAt: Date.now(),
|
|
454
|
+
rowsApplied: rowsApplied2,
|
|
455
|
+
...total !== void 0 ? { total } : {}
|
|
456
|
+
} : {
|
|
457
|
+
// Every row the origin offered was applied — but without a snapshot
|
|
458
|
+
// we cannot prove we saw a consistent view of it, so we must not
|
|
459
|
+
// claim completeness. Reads keep going to the network.
|
|
460
|
+
status: "best-effort",
|
|
461
|
+
cursor: deltaCursor ?? "",
|
|
462
|
+
reason: "the origin did not return a snapshot token, so a row that moved between pages during the walk may have been missed",
|
|
463
|
+
rowsApplied: rowsApplied2,
|
|
464
|
+
...total !== void 0 ? { total } : {}
|
|
465
|
+
};
|
|
466
|
+
await this.coverage.write(this.key, state);
|
|
467
|
+
this.onProgress?.(state);
|
|
468
|
+
return state;
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
const failed = {
|
|
472
|
+
status: "error",
|
|
473
|
+
resumeFrom: page ?? 0,
|
|
474
|
+
...snapshot ? { snapshot } : {},
|
|
475
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
476
|
+
rowsApplied: rowsApplied2,
|
|
477
|
+
...total !== void 0 ? { total } : {},
|
|
478
|
+
error: error instanceof Error ? error.message : String(error)
|
|
479
|
+
};
|
|
480
|
+
await this.coverage.write(this.key, failed);
|
|
481
|
+
this.onProgress?.(failed);
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Apply incremental changes since the stored cursor.
|
|
487
|
+
*
|
|
488
|
+
* Deletions are applied by mapping the origin's primary keys through the same
|
|
489
|
+
* `deriveDocId`, and are written with `origin: 'remote'` so they leave no
|
|
490
|
+
* tombstone — the origin already knows it deleted these, and a tombstone would
|
|
491
|
+
* push its own deletion back at it.
|
|
492
|
+
*/
|
|
493
|
+
refresh() {
|
|
494
|
+
return this.dedup(`${this.replicaScope}:refresh`, () => this.runRefresh());
|
|
495
|
+
}
|
|
496
|
+
async runRefresh() {
|
|
497
|
+
const state = await this.coverage.read(this.key);
|
|
498
|
+
if (state.status !== "complete") return state;
|
|
499
|
+
if (!this.source.delta) return state;
|
|
500
|
+
const col = this.db.collection(this.source.collection);
|
|
501
|
+
let cursor = state.cursor;
|
|
502
|
+
let rowsApplied2 = state.rowsApplied;
|
|
503
|
+
for (; ; ) {
|
|
504
|
+
const page = await this.source.delta(cursor);
|
|
505
|
+
rowsApplied2 += (await this.applyRows(page.changed)).length;
|
|
506
|
+
if (page.deleted.length > 0) {
|
|
507
|
+
const ids = page.deleted.map(
|
|
508
|
+
(k) => deriveDocId(this.identityNamespace, String(k))
|
|
509
|
+
);
|
|
510
|
+
await col.deleteManyWithIds(ids, "remote");
|
|
511
|
+
}
|
|
512
|
+
cursor = page.cursor;
|
|
513
|
+
const next = { ...state, cursor, rowsApplied: rowsApplied2 };
|
|
514
|
+
await this.coverage.write(this.key, next);
|
|
515
|
+
if (!page.hasMore) {
|
|
516
|
+
this.onProgress?.(next);
|
|
517
|
+
return next;
|
|
518
|
+
}
|
|
519
|
+
await this.yieldFn();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Cold-start bridge: fetch exactly the rows one query needs, right now.
|
|
524
|
+
*
|
|
525
|
+
* Needed because a SPA or React Native app has no server render to paint behind
|
|
526
|
+
* while the replica fills. The rows land in the same collection under the same
|
|
527
|
+
* derived ids as the walk's, so this is not a cache — it is the replica, arriving
|
|
528
|
+
* early.
|
|
529
|
+
*
|
|
530
|
+
* **Does not advance coverage.** These rows did not come from the bootstrap
|
|
531
|
+
* snapshot and prove nothing about completeness; treating them as progress would
|
|
532
|
+
* let a page-1 fetch masquerade as a hydrated catalog.
|
|
533
|
+
*/
|
|
534
|
+
bridge(query) {
|
|
535
|
+
if (!this.source.fetchQuery) return Promise.resolve({ count: 0, ids: [] });
|
|
536
|
+
const key = `bridge:${coverageKey(this.key)}:${JSON.stringify(query)}`;
|
|
537
|
+
return this.dedup(key, async () => {
|
|
538
|
+
const rows = await this.source.fetchQuery(query);
|
|
539
|
+
const ids = await this.applyRows(rows);
|
|
540
|
+
return { count: ids.length, ids };
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/** Drop coverage and force a fresh bootstrap. Local rows are left alone. */
|
|
544
|
+
async reset() {
|
|
545
|
+
await this.coverage.clear(this.key);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// src/replication/rest.ts
|
|
550
|
+
function parseRows(body, endpoint) {
|
|
551
|
+
if (Array.isArray(body)) return body;
|
|
552
|
+
if (body && typeof body === "object") {
|
|
553
|
+
const env = body;
|
|
554
|
+
for (const field of ["data", "items", "rows"]) {
|
|
555
|
+
const value = env[field];
|
|
556
|
+
if (Array.isArray(value)) return value;
|
|
557
|
+
}
|
|
558
|
+
throw new Error(
|
|
559
|
+
`taladb: could not find a row array in the response from ${endpoint}. Expected a bare array or a { data | items | rows } envelope, but got an object with keys: ${Object.keys(env).join(", ") || "(none)"}. Pass { parse } to extract them yourself.`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
throw new Error(
|
|
563
|
+
`taladb: expected an array or object from ${endpoint}, got ${typeof body}.`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
function pick(body, names) {
|
|
567
|
+
if (!body || typeof body !== "object") return void 0;
|
|
568
|
+
const rec = body;
|
|
569
|
+
for (const n of names) {
|
|
570
|
+
if (rec[n] !== void 0) return rec[n];
|
|
571
|
+
const meta = rec.meta;
|
|
572
|
+
if (meta && meta[n] !== void 0) return meta[n];
|
|
573
|
+
}
|
|
574
|
+
return void 0;
|
|
575
|
+
}
|
|
576
|
+
function createRestSource(options) {
|
|
577
|
+
const {
|
|
578
|
+
endpoint,
|
|
579
|
+
collection,
|
|
580
|
+
origin = endpoint,
|
|
581
|
+
scope = "global",
|
|
582
|
+
projectionVersion = 1,
|
|
583
|
+
schemaVersion = 1,
|
|
584
|
+
key = "id",
|
|
585
|
+
revision = "rev",
|
|
586
|
+
mapRow,
|
|
587
|
+
getAuth,
|
|
588
|
+
paths,
|
|
589
|
+
toParams,
|
|
590
|
+
parse,
|
|
591
|
+
pagination = "page"
|
|
592
|
+
} = options;
|
|
593
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
594
|
+
async function get(path, params) {
|
|
595
|
+
const url = new URL(path, globalThis.location?.origin ?? "http://localhost");
|
|
596
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
597
|
+
const headers = getAuth ? await getAuth() : void 0;
|
|
598
|
+
const response = await doFetch(url.href, { headers });
|
|
599
|
+
if (!response.ok) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`taladb: ${path} responded ${response.status} ${response.statusText}`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
return response.json();
|
|
605
|
+
}
|
|
606
|
+
const rowsFrom = (body) => parse ? parse(body) : parseRows(body, endpoint);
|
|
607
|
+
return {
|
|
608
|
+
origin,
|
|
609
|
+
collection,
|
|
610
|
+
scope,
|
|
611
|
+
projectionVersion,
|
|
612
|
+
schemaVersion,
|
|
613
|
+
keyOf: (row) => {
|
|
614
|
+
const value = row[key];
|
|
615
|
+
if (value === void 0 || value === null) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`taladb: row from ${endpoint} has no '${key}' field to use as its primary key. Pass { key } to name the right one. Without a stable key, repeated fetches of the same row cannot be recognized as the same row.`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return String(value);
|
|
621
|
+
},
|
|
622
|
+
revisionOf: (row) => {
|
|
623
|
+
const value = typeof revision === "function" ? revision(row) : row[revision];
|
|
624
|
+
if (value === void 0 || value === null) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`taladb: row from ${endpoint} has no authoritative revision. Pass { revision } to name the monotonic revision field.`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const n = Number(value);
|
|
630
|
+
if (!Number.isSafeInteger(n)) {
|
|
631
|
+
throw new Error(`taladb: authoritative revision must be a safe integer, got ${String(value)}`);
|
|
632
|
+
}
|
|
633
|
+
return n;
|
|
634
|
+
},
|
|
635
|
+
mapRow: (row) => {
|
|
636
|
+
if (mapRow) return mapRow(row);
|
|
637
|
+
const { _id, ...rest } = row;
|
|
638
|
+
void _id;
|
|
639
|
+
return rest;
|
|
640
|
+
},
|
|
641
|
+
bootstrap: async (request) => {
|
|
642
|
+
const params = { limit: String(request.limit) };
|
|
643
|
+
if (request.page !== null) params.page = String(request.page);
|
|
644
|
+
if (request.snapshot !== null) params.snapshot = request.snapshot;
|
|
645
|
+
const body = await get(endpoint + (paths?.bootstrap ?? ""), params);
|
|
646
|
+
const rows = rowsFrom(body);
|
|
647
|
+
const nextPage = pick(body, ["nextPage", "next_page", "next"]);
|
|
648
|
+
const snapshot = pick(body, ["snapshot"]);
|
|
649
|
+
const deltaCursor = pick(body, ["deltaCursor", "delta_cursor", "cursor"]);
|
|
650
|
+
const total = pick(body, ["total", "totalCount", "count"]);
|
|
651
|
+
return {
|
|
652
|
+
rows,
|
|
653
|
+
// An origin that reports no explicit `nextPage` is treated as exhausted
|
|
654
|
+
// once it returns a short page — the conventional REST behavior.
|
|
655
|
+
nextPage: nextPage !== void 0 ? nextPage : rows.length < request.limit ? null : pagination === "offset" ? Number(request.page ?? 0) + request.limit : Number(request.page ?? 1) + 1,
|
|
656
|
+
...snapshot !== void 0 ? { snapshot } : {},
|
|
657
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
658
|
+
...total !== void 0 ? { total } : {}
|
|
659
|
+
};
|
|
660
|
+
},
|
|
661
|
+
...options.delta === true || paths?.delta ? { delta: async (cursor) => {
|
|
662
|
+
const body = await get(endpoint + (paths?.delta ?? ""), { since: cursor });
|
|
663
|
+
const changed = rowsFrom(body);
|
|
664
|
+
const deleted = pick(body, ["deleted", "removed"]) ?? [];
|
|
665
|
+
const next = pick(body, ["cursor", "now", "nextCursor"]);
|
|
666
|
+
return {
|
|
667
|
+
changed,
|
|
668
|
+
deleted: deleted.map(String),
|
|
669
|
+
cursor: next ?? cursor,
|
|
670
|
+
hasMore: Boolean(pick(body, ["hasMore", "has_more"]))
|
|
671
|
+
};
|
|
672
|
+
} } : {},
|
|
673
|
+
fetchQuery: async (query) => {
|
|
674
|
+
if (!toParams && Object.values(query.filter ?? {}).some((v) => typeof v === "object" && v !== null)) {
|
|
675
|
+
throw new Error(
|
|
676
|
+
"taladb: bridge filters with operators require RestSourceOptions.toParams; the default translator only supports scalar equality fields."
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
const sortEntry = Object.entries(query.sort ?? {})[0];
|
|
680
|
+
const params = toParams ? toParams(query) : {
|
|
681
|
+
...query.page !== void 0 ? { page: String(query.page) } : {},
|
|
682
|
+
...query.limit !== void 0 ? { limit: String(query.limit) } : {},
|
|
683
|
+
...Object.fromEntries(
|
|
684
|
+
Object.entries(query.filter ?? {}).map(([k, v]) => [k, String(v)])
|
|
685
|
+
),
|
|
686
|
+
...sortEntry ? { sort: sortEntry[0], order: sortEntry[1] === -1 ? "desc" : "asc" } : {}
|
|
687
|
+
};
|
|
688
|
+
return rowsFrom(await get(endpoint, params));
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
192
693
|
// src/index.ts
|
|
193
694
|
var TalaDbValidationError = class extends Error {
|
|
194
695
|
constructor(cause, context) {
|
|
@@ -238,6 +739,10 @@ function applySchema(col, options) {
|
|
|
238
739
|
if (!stampVersion || doc._v !== void 0) return doc;
|
|
239
740
|
return { ...doc, _v: targetVersion };
|
|
240
741
|
}
|
|
742
|
+
function stampDoc(doc) {
|
|
743
|
+
if (!stampVersion || doc._v !== void 0) return doc;
|
|
744
|
+
return { ...doc, _v: targetVersion };
|
|
745
|
+
}
|
|
241
746
|
function diffUpdate(original, migrated) {
|
|
242
747
|
const $set = {};
|
|
243
748
|
const $unset = {};
|
|
@@ -292,6 +797,19 @@ function applySchema(col, options) {
|
|
|
292
797
|
if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
|
|
293
798
|
return col.insertMany(docs.map(stamp));
|
|
294
799
|
} : col.insertMany.bind(col),
|
|
800
|
+
// Rows arriving from a remote origin are validated like any other write. This
|
|
801
|
+
// is the "parse, don't assert" boundary: the compile-time generic and the
|
|
802
|
+
// runtime schema check have to be the same seam, or a malformed server
|
|
803
|
+
// response walks straight into a typed collection.
|
|
804
|
+
replaceManyWithIds: wrapWrites ? async (docs, origin) => {
|
|
805
|
+
if (schema) docs.forEach((doc, i) => {
|
|
806
|
+
const { _replica_scope, _remote_rev, ...schemaDoc } = doc;
|
|
807
|
+
void _replica_scope;
|
|
808
|
+
void _remote_rev;
|
|
809
|
+
parseWrite(schemaDoc, `replaceManyWithIds[${i}]`);
|
|
810
|
+
});
|
|
811
|
+
return col.replaceManyWithIds(docs.map((d) => stampDoc(d)), origin);
|
|
812
|
+
} : col.replaceManyWithIds.bind(col),
|
|
295
813
|
find: wrapReads ? async (filter) => {
|
|
296
814
|
const docs = await col.find(filter);
|
|
297
815
|
const migrated = docs.map(migrateRead);
|
|
@@ -449,6 +967,19 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
449
967
|
});
|
|
450
968
|
return JSON.parse(json);
|
|
451
969
|
},
|
|
970
|
+
replaceManyWithIds: async (docs, origin = "local") => {
|
|
971
|
+
const json = await proxy.send("replaceManyWithIds", {
|
|
972
|
+
collection: name,
|
|
973
|
+
docsJson: s(docs),
|
|
974
|
+
origin
|
|
975
|
+
});
|
|
976
|
+
return JSON.parse(json);
|
|
977
|
+
},
|
|
978
|
+
deleteManyWithIds: (ids, origin = "local") => proxy.send("deleteManyWithIds", {
|
|
979
|
+
collection: name,
|
|
980
|
+
idsJson: s(ids),
|
|
981
|
+
origin
|
|
982
|
+
}),
|
|
452
983
|
find: async (filter) => {
|
|
453
984
|
const json = await proxy.send("find", {
|
|
454
985
|
collection: name,
|
|
@@ -520,59 +1051,72 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
520
1051
|
});
|
|
521
1052
|
return JSON.parse(json);
|
|
522
1053
|
},
|
|
523
|
-
subscribe: (filter, callback, onError) =>
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
try {
|
|
541
|
-
const json = await proxy.send("find", {
|
|
542
|
-
collection: name,
|
|
543
|
-
filterJson: filter ? s(filter) : "null"
|
|
544
|
-
});
|
|
545
|
-
if (!active) return;
|
|
546
|
-
if (json !== lastJson) {
|
|
547
|
-
lastJson = json;
|
|
548
|
-
callback(JSON.parse(json));
|
|
549
|
-
}
|
|
550
|
-
} catch (error) {
|
|
551
|
-
if (active) onError?.(error);
|
|
552
|
-
} finally {
|
|
553
|
-
running = false;
|
|
554
|
-
}
|
|
555
|
-
if (active) {
|
|
556
|
-
if (rerun) {
|
|
557
|
-
rerun = false;
|
|
558
|
-
void poll();
|
|
559
|
-
} else timer = setTimeout(poll, 300);
|
|
560
|
-
}
|
|
561
|
-
};
|
|
562
|
-
nudgeCallbacks.add(poll);
|
|
563
|
-
poll();
|
|
564
|
-
return () => {
|
|
565
|
-
active = false;
|
|
566
|
-
nudgeCallbacks.delete(poll);
|
|
567
|
-
if (timer !== null) {
|
|
568
|
-
clearTimeout(timer);
|
|
569
|
-
timer = null;
|
|
570
|
-
}
|
|
571
|
-
};
|
|
572
|
-
}
|
|
1054
|
+
subscribe: (filter, callback, onError) => nudgedPoller(
|
|
1055
|
+
() => proxy.send("find", {
|
|
1056
|
+
collection: name,
|
|
1057
|
+
filterJson: filter ? s(filter) : "null"
|
|
1058
|
+
}),
|
|
1059
|
+
callback,
|
|
1060
|
+
onError
|
|
1061
|
+
),
|
|
1062
|
+
subscribeAggregate: (pipeline, callback, onError) => nudgedPoller(
|
|
1063
|
+
() => proxy.send("aggregate", {
|
|
1064
|
+
collection: name,
|
|
1065
|
+
pipelineJson: s(pipeline)
|
|
1066
|
+
}),
|
|
1067
|
+
callback,
|
|
1068
|
+
onError
|
|
1069
|
+
)
|
|
573
1070
|
};
|
|
574
1071
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
575
1072
|
}
|
|
1073
|
+
function nudgedPoller(fetchJson, callback, onError) {
|
|
1074
|
+
let active = true;
|
|
1075
|
+
let lastJson = "";
|
|
1076
|
+
let timer = null;
|
|
1077
|
+
let running = false;
|
|
1078
|
+
let rerun = false;
|
|
1079
|
+
const poll = async () => {
|
|
1080
|
+
if (!active) return;
|
|
1081
|
+
if (running) {
|
|
1082
|
+
rerun = true;
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
running = true;
|
|
1086
|
+
if (timer !== null) {
|
|
1087
|
+
clearTimeout(timer);
|
|
1088
|
+
timer = null;
|
|
1089
|
+
}
|
|
1090
|
+
try {
|
|
1091
|
+
const json = await fetchJson();
|
|
1092
|
+
if (!active) return;
|
|
1093
|
+
if (json !== lastJson) {
|
|
1094
|
+
lastJson = json;
|
|
1095
|
+
callback(JSON.parse(json));
|
|
1096
|
+
}
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
if (active) onError?.(error);
|
|
1099
|
+
} finally {
|
|
1100
|
+
running = false;
|
|
1101
|
+
}
|
|
1102
|
+
if (active) {
|
|
1103
|
+
if (rerun) {
|
|
1104
|
+
rerun = false;
|
|
1105
|
+
void poll();
|
|
1106
|
+
} else timer = setTimeout(poll, 300);
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
nudgeCallbacks.add(poll);
|
|
1110
|
+
poll();
|
|
1111
|
+
return () => {
|
|
1112
|
+
active = false;
|
|
1113
|
+
nudgeCallbacks.delete(poll);
|
|
1114
|
+
if (timer !== null) {
|
|
1115
|
+
clearTimeout(timer);
|
|
1116
|
+
timer = null;
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
576
1120
|
const handle = {
|
|
577
1121
|
collection: (name, opts) => wrapCollection(name, opts),
|
|
578
1122
|
compact: () => proxy.send("compact"),
|
|
@@ -624,6 +1168,8 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
624
1168
|
const wrapped = {
|
|
625
1169
|
insert: async (doc) => col.insertAsync ? col.insertAsync(doc) : col.insert(doc),
|
|
626
1170
|
insertMany: async (docs) => col.insertManyAsync ? col.insertManyAsync(docs) : col.insertMany(docs),
|
|
1171
|
+
replaceManyWithIds: async (docs, origin = "local") => col.replaceManyWithIdsAsync ? col.replaceManyWithIdsAsync(docs, origin) : col.replaceManyWithIds(docs, origin),
|
|
1172
|
+
deleteManyWithIds: async (ids, origin = "local") => col.deleteManyWithIdsAsync ? col.deleteManyWithIdsAsync(ids, origin) : col.deleteManyWithIds(ids, origin),
|
|
627
1173
|
find: async (filter) => col.findAsync ? col.findAsync(filter ?? null) : col.find(filter ?? null),
|
|
628
1174
|
findOne: async (filter) => col.findOne(filter) ?? null,
|
|
629
1175
|
updateOne: async (filter, update) => col.updateOneAsync ? col.updateOneAsync(filter, update) : col.updateOne(filter, update),
|
|
@@ -649,7 +1195,8 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
649
1195
|
const raw = await col.findNearest(field, vector, topK, filter ?? null);
|
|
650
1196
|
return raw;
|
|
651
1197
|
},
|
|
652
|
-
subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError)
|
|
1198
|
+
subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
|
|
1199
|
+
subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
|
|
653
1200
|
};
|
|
654
1201
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
655
1202
|
}
|
|
@@ -697,6 +1244,8 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
697
1244
|
const wrapped = {
|
|
698
1245
|
insert: async (doc) => native.insert(name, doc),
|
|
699
1246
|
insertMany: async (docs) => native.insertMany(name, docs),
|
|
1247
|
+
replaceManyWithIds: async (docs, origin = "local") => native.replaceManyWithIds(name, docs, origin),
|
|
1248
|
+
deleteManyWithIds: async (ids, origin = "local") => native.deleteManyWithIds(name, ids, origin),
|
|
700
1249
|
find: async (filter) => native.find(name, filter ?? {}),
|
|
701
1250
|
findOne: async (filter) => native.findOne(name, filter ?? {}),
|
|
702
1251
|
updateOne: async (filter, update) => native.updateOne(name, filter, update),
|
|
@@ -728,7 +1277,8 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
728
1277
|
const raw = native.findNearest(name, field, vector, topK, filter ?? null);
|
|
729
1278
|
return raw;
|
|
730
1279
|
},
|
|
731
|
-
subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError)
|
|
1280
|
+
subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
|
|
1281
|
+
subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
|
|
732
1282
|
};
|
|
733
1283
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
734
1284
|
}
|
|
@@ -824,9 +1374,20 @@ async function openDB(dbName = "taladb.db", options) {
|
|
|
824
1374
|
}
|
|
825
1375
|
}
|
|
826
1376
|
export {
|
|
1377
|
+
COVERAGE_COLLECTION,
|
|
1378
|
+
CoverageStore,
|
|
827
1379
|
HttpSyncAdapter,
|
|
1380
|
+
REPLICA_REVISION_FIELD,
|
|
1381
|
+
REPLICA_SCOPE_FIELD,
|
|
1382
|
+
ReplicationCoordinator,
|
|
828
1383
|
TalaDbValidationError,
|
|
829
1384
|
applySchema,
|
|
1385
|
+
coverageKey,
|
|
1386
|
+
createRestSource,
|
|
1387
|
+
deriveDocId,
|
|
1388
|
+
isAuthoritative,
|
|
830
1389
|
openDB,
|
|
1390
|
+
progress,
|
|
1391
|
+
rowsApplied,
|
|
831
1392
|
runMigrations
|
|
832
1393
|
};
|