taladb 0.10.2 → 0.11.1

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.
@@ -21,145 +21,281 @@ async function loadConfig(_configPath) {
21
21
  return {};
22
22
  }
23
23
 
24
- // src/sync.ts
25
- var CURSOR_COLLECTION = "__taladb_sync";
26
- async function resolveCollections(handle, options) {
27
- const base = options.collections ?? await handle.listCollectionNames();
28
- const excluded = new Set(options.exclude ?? []);
29
- return base.filter((c) => !excluded.has(c) && !c.startsWith("_"));
30
- }
31
- function unsupportedSync(runtime) {
32
- const err = () => new Error(
33
- `TalaDB sync is not yet available on the ${runtime} runtime (Node.js is supported today; browser and React Native are in progress). Track it on the roadmap.`
34
- );
35
- return {
36
- sync: () => Promise.reject(err()),
37
- exportChanges: () => Promise.reject(err()),
38
- importChanges: () => Promise.reject(err())
39
- };
40
- }
41
- async function readCursor(cursorCol, target) {
42
- const doc = await cursorCol.findOne({ target });
43
- return {
44
- pushMs: doc?.pushMs ?? 0,
45
- pullMs: doc?.pullMs ?? 0,
46
- pullCursor: doc?.pullCursor
47
- };
24
+ // src/webhook.ts
25
+ var METHOD = {
26
+ insert: "POST",
27
+ update: "PUT",
28
+ delete: "DELETE"
29
+ };
30
+ var ENDPOINT_KEYS = [
31
+ "endpoint",
32
+ "insert_endpoint",
33
+ "update_endpoint",
34
+ "delete_endpoint"
35
+ ];
36
+ var LOCALHOST = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
37
+ function isLocalhost(url) {
38
+ try {
39
+ return LOCALHOST.has(new URL(url).hostname);
40
+ } catch {
41
+ return false;
42
+ }
48
43
  }
49
- async function writeCursor(cursorCol, target, cursor) {
50
- const updated = await cursorCol.updateOne({ target }, { $set: { ...cursor } });
51
- if (!updated) {
52
- await cursorCol.insert({ target, ...cursor });
44
+ function validateWebhookConfig(config) {
45
+ for (const key of ENDPOINT_KEYS) {
46
+ const url = config[key];
47
+ if (url === void 0) continue;
48
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
49
+ throw new Error(
50
+ `TalaDB webhook: invalid ${key} "${url}" \u2014 must start with http:// or https://`
51
+ );
52
+ }
53
+ if (url.startsWith("http://") && !isLocalhost(url)) {
54
+ console.warn(
55
+ `[TalaDB] webhook ${key} "${url}" uses plaintext HTTP \u2014 document bodies will cross the network unencrypted. Use HTTPS in production.`
56
+ );
57
+ }
58
+ }
59
+ if (config.enabled && !config.endpoint) {
60
+ const perOp = ENDPOINT_KEYS.slice(1).every((k) => config[k] !== void 0);
61
+ if (!perOp) {
62
+ throw new Error(
63
+ "TalaDB webhook: `enabled: true` requires `endpoint` (or all three per-op endpoints)"
64
+ );
65
+ }
53
66
  }
54
67
  }
55
- function isCursorAdapter(adapter) {
56
- return typeof adapter.pullWithCursor === "function";
68
+ var DEFAULT_MAX_QUEUE = 512;
69
+ var DEFAULT_RETRIES = 3;
70
+ var BACKOFF_BASE_MS = 200;
71
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
72
+ function stripFields(doc, exclude) {
73
+ if (exclude.size === 0) return doc;
74
+ const out = {};
75
+ for (const [k, v] of Object.entries(doc)) {
76
+ if (!exclude.has(k)) out[k] = v;
77
+ }
78
+ return out;
57
79
  }
58
- var MAX_PULL_PAGES = 1e4;
59
- async function runSync(handle, adapter, options, syncSchemas = {}) {
60
- const direction = options.direction ?? "both";
61
- const target = options.target ?? "default";
62
- const doPush = direction === "push" || direction === "both";
63
- const doPull = direction === "pull" || direction === "both";
64
- if (doPull && !adapter.pull && !isCursorAdapter(adapter)) {
80
+ function createWebhookDispatcher(config) {
81
+ if (!config?.enabled) return null;
82
+ validateWebhookConfig(config);
83
+ const fetchFn = config.fetch ?? globalThis.fetch?.bind(globalThis);
84
+ if (!fetchFn) {
65
85
  throw new Error(
66
- `sync direction '${direction}' requires adapter.pull() or adapter.pullWithCursor()`
86
+ "TalaDB webhook: no global fetch available. Pass `fetch` in the webhook config."
67
87
  );
68
88
  }
69
- if (doPush && !adapter.push) {
70
- throw new Error(`sync direction '${direction}' requires adapter.push()`);
71
- }
72
- const collections = await resolveCollections(handle, options);
73
- const cursorCol = handle.collection(CURSOR_COLLECTION);
74
- const cursor = await readCursor(cursorCol, target);
75
- const local = doPush ? await handle.exportChanges(collections, 0) : "[]";
76
- const scopedSchemas = {};
77
- for (const c of collections) {
78
- if (syncSchemas[c]) scopedSchemas[c] = syncSchemas[c];
79
- }
80
- const useValidated = handle.importChangesValidated && Object.keys(scopedSchemas).length > 0;
81
- let pulled = 0;
82
- let skipped = 0;
83
- let quarantined = 0;
84
- let pullCursor = cursor.pullCursor;
85
- async function importOne(changeset) {
86
- if (!changeset || changeset === "[]") return;
87
- if (useValidated) {
88
- const report = await handle.importChangesValidated(changeset, JSON.stringify(scopedSchemas));
89
- pulled += report.applied;
90
- skipped += report.skipped;
91
- quarantined += report.quarantined;
92
- } else {
93
- pulled += await handle.importChanges(changeset);
94
- }
89
+ const headers = { "content-type": "application/json", ...config.headers ?? {} };
90
+ const exclude = new Set(config.exclude_fields ?? []);
91
+ const only = config.collections ? new Set(config.collections) : null;
92
+ const maxQueue = config.max_queue ?? DEFAULT_MAX_QUEUE;
93
+ const retries = config.retries ?? DEFAULT_RETRIES;
94
+ const stats = { pending: 0, delivered: 0, failed: 0, dropped: 0 };
95
+ const chains = /* @__PURE__ */ new Map();
96
+ let eventSequence = 0;
97
+ function nextEventId() {
98
+ eventSequence++;
99
+ return `${Date.now().toString(36)}-${eventSequence.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
100
+ }
101
+ function endpointFor(op) {
102
+ return config[`${op}_endpoint`] ?? config.endpoint;
103
+ }
104
+ function bodyFor(event, at, eventId) {
105
+ return JSON.stringify({
106
+ event_id: eventId,
107
+ collection: event.collection,
108
+ id: event.id,
109
+ timestamp: at,
110
+ document: event.document ? stripFields(event.document, exclude) : null
111
+ });
95
112
  }
96
- if (doPull) {
97
- if (isCursorAdapter(adapter)) {
98
- let pages = 0;
99
- for (; ; ) {
100
- const result = await adapter.pullWithCursor(pullCursor ?? null);
101
- await importOne(result.changeset);
102
- pullCursor = result.cursor;
103
- await writeCursor(cursorCol, target, { ...cursor, pullCursor });
104
- if (!result.hasMore) break;
105
- if (++pages >= MAX_PULL_PAGES) {
106
- throw new Error(
107
- `sync: origin returned hasMore after ${MAX_PULL_PAGES} pages for target '${target}' \u2014 it is probably not advancing its cursor.`
113
+ async function deliver(event, at, eventId) {
114
+ const url = endpointFor(event.op);
115
+ const init = {
116
+ method: METHOD[event.op],
117
+ headers: { ...headers, "Idempotency-Key": eventId },
118
+ body: bodyFor(event, at, eventId)
119
+ };
120
+ for (let attempt = 0; attempt <= retries; attempt++) {
121
+ try {
122
+ const res = await fetchFn(url, init);
123
+ if (res.ok) {
124
+ stats.delivered++;
125
+ return;
126
+ }
127
+ if (res.status >= 400 && res.status < 500) {
128
+ stats.failed++;
129
+ console.warn(
130
+ `[TalaDB] webhook ${event.op} ${event.collection}/${event.id} \u2192 ${res.status} ${res.statusText} (not retried)`
108
131
  );
132
+ return;
109
133
  }
134
+ } catch {
110
135
  }
111
- } else {
112
- await importOne(await adapter.pull(0));
136
+ if (attempt < retries) await sleep(BACKOFF_BASE_MS * 2 ** attempt);
113
137
  }
138
+ stats.failed++;
139
+ console.warn(
140
+ `[TalaDB] webhook ${event.op} ${event.collection}/${event.id} failed after ${retries + 1} attempts`
141
+ );
114
142
  }
115
- let pushed = 0;
116
- if (doPush && local !== "[]") {
117
- pushed = JSON.parse(local).length;
118
- await adapter.push(local);
119
- }
120
- await writeCursor(cursorCol, target, {
121
- pushMs: cursor.pushMs,
122
- pullMs: cursor.pullMs,
123
- ...pullCursor !== void 0 ? { pullCursor } : {}
124
- });
125
- return { pushed, pulled, skipped, quarantined, cursor: 0 };
143
+ return {
144
+ reports(collection) {
145
+ if (collection.startsWith("_")) return false;
146
+ return only === null || only.has(collection);
147
+ },
148
+ emit(event) {
149
+ if (!this.reports(event.collection)) return;
150
+ if (stats.pending >= maxQueue) {
151
+ stats.dropped++;
152
+ console.warn(
153
+ `[TalaDB] webhook queue full (${maxQueue}) \u2014 dropped ${event.op} ${event.collection}/${event.id}. The endpoint is not keeping up.`
154
+ );
155
+ return;
156
+ }
157
+ stats.pending++;
158
+ const at = event.committedAt ?? Date.now();
159
+ const eventId = nextEventId();
160
+ const key = `${event.collection}\0${event.id}`;
161
+ const prior = chains.get(key) ?? Promise.resolve();
162
+ const next = prior.then(() => deliver(event, at, eventId)).finally(() => {
163
+ stats.pending--;
164
+ if (chains.get(key) === next) chains.delete(key);
165
+ });
166
+ chains.set(key, next);
167
+ },
168
+ async flush(timeoutMs = 5e3) {
169
+ const deadline = Date.now() + timeoutMs;
170
+ while (stats.pending > 0) {
171
+ const remaining = deadline - Date.now();
172
+ if (remaining <= 0) break;
173
+ let timer;
174
+ const expired = new Promise((resolve) => {
175
+ timer = setTimeout(resolve, remaining);
176
+ });
177
+ try {
178
+ await Promise.race([Promise.allSettled([...chains.values()]), expired]);
179
+ } finally {
180
+ clearTimeout(timer);
181
+ }
182
+ }
183
+ return stats.pending === 0;
184
+ },
185
+ stats() {
186
+ return { ...stats };
187
+ }
188
+ };
126
189
  }
127
-
128
- // src/http-adapter.ts
129
- var HttpSyncAdapter = class {
130
- constructor(options) {
131
- this.endpoint = options.endpoint.replace(/\/$/, "");
132
- this.headers = options.headers ?? {};
133
- const f = options.fetch ?? globalThis.fetch?.bind(globalThis);
134
- if (!f) {
135
- throw new Error(
136
- "HttpSyncAdapter: no fetch available. Pass options.fetch on runtimes without a global fetch."
137
- );
190
+ function byIds(ids) {
191
+ return ids.length === 1 ? { _id: ids[0] } : { _id: { $in: ids } };
192
+ }
193
+ function idFromFilter(filter) {
194
+ if (filter === null || typeof filter !== "object") return null;
195
+ const keys = Object.keys(filter);
196
+ if (keys.length !== 1 || keys[0] !== "_id") return null;
197
+ const id = filter._id;
198
+ return typeof id === "string" ? id : null;
199
+ }
200
+ function wrapCollectionWithWebhook(col, collection, webhook) {
201
+ if (!webhook.reports(collection)) return col;
202
+ async function idsFor(filter, limitOne) {
203
+ const fast = idFromFilter(filter);
204
+ if (fast !== null) return [fast];
205
+ if (limitOne) {
206
+ const doc = await col.findOne(filter);
207
+ return doc && typeof doc._id === "string" ? [doc._id] : [];
138
208
  }
139
- this.fetchFn = f;
140
- this.pushPath = options.paths?.push ?? "/push";
141
- this.pullPath = options.paths?.pull ?? "/pull";
209
+ const docs = col.aggregate ? await col.aggregate([{ $match: filter }, { $project: { _id: 1 } }]) : await col.find(filter);
210
+ return docs.map((d) => d._id).filter((id) => typeof id === "string");
142
211
  }
143
- async push(changeset) {
144
- const res = await this.fetchFn(`${this.endpoint}${this.pushPath}`, {
145
- method: "POST",
146
- headers: { "content-type": "application/json", ...this.headers },
147
- body: changeset
148
- });
149
- if (!res.ok) {
150
- throw new Error(`HttpSyncAdapter push failed: ${res.status} ${res.statusText}`);
212
+ async function docsFor(filter, limitOne) {
213
+ if (limitOne) {
214
+ const doc = await col.findOne(filter);
215
+ return doc === null ? [] : [doc];
151
216
  }
152
- }
153
- async pull(sinceMs) {
154
- const url = `${this.endpoint}${this.pullPath}?since=${encodeURIComponent(String(sinceMs))}`;
155
- const res = await this.fetchFn(url, { method: "GET", headers: this.headers });
156
- if (!res.ok) {
157
- throw new Error(`HttpSyncAdapter pull failed: ${res.status} ${res.statusText}`);
217
+ return col.find(filter);
218
+ }
219
+ async function emitPostImages(ids, op, committedAt) {
220
+ if (ids.length === 0) return;
221
+ const docs = await col.find(byIds(ids));
222
+ const byId = /* @__PURE__ */ new Map();
223
+ for (const doc of docs) {
224
+ if (typeof doc._id === "string") byId.set(doc._id, doc);
225
+ }
226
+ for (const id of ids) {
227
+ const doc = byId.get(id);
228
+ if (doc === void 0) {
229
+ webhook.emit({ op: "delete", collection, id, document: null, committedAt });
230
+ } else {
231
+ webhook.emit({ op, collection, id, document: doc, committedAt });
232
+ }
158
233
  }
159
- const body = (await res.text()).trim();
160
- return body.length === 0 ? "[]" : body;
161
234
  }
162
- };
235
+ return {
236
+ ...col,
237
+ async insert(doc) {
238
+ const id = await col.insert(doc);
239
+ const committedAt = Date.now();
240
+ await emitPostImages([id], "insert", committedAt);
241
+ return id;
242
+ },
243
+ async insertMany(docs) {
244
+ const ids = await col.insertMany(docs);
245
+ const committedAt = Date.now();
246
+ await emitPostImages(ids, "insert", committedAt);
247
+ return ids;
248
+ },
249
+ async updateOne(filter, update) {
250
+ const ids = await idsFor(filter, true);
251
+ const changed = await col.updateOne(filter, update);
252
+ const committedAt = Date.now();
253
+ if (changed) await emitPostImages(ids, "update", committedAt);
254
+ return changed;
255
+ },
256
+ async updateMany(filter, update) {
257
+ const ids = await idsFor(filter, false);
258
+ const n = await col.updateMany(filter, update);
259
+ const committedAt = Date.now();
260
+ if (n > 0) await emitPostImages(ids, "update", committedAt);
261
+ return n;
262
+ },
263
+ async deleteOne(filter) {
264
+ const docs = await docsFor(filter, true);
265
+ const deleted = await col.deleteOne(filter);
266
+ const committedAt = Date.now();
267
+ if (deleted) {
268
+ for (const document of docs) {
269
+ webhook.emit({
270
+ op: "delete",
271
+ collection,
272
+ id: document._id,
273
+ document,
274
+ committedAt
275
+ });
276
+ }
277
+ }
278
+ return deleted;
279
+ },
280
+ async deleteMany(filter) {
281
+ const docs = await docsFor(filter, false);
282
+ const n = await col.deleteMany(filter);
283
+ const committedAt = Date.now();
284
+ if (n > 0) {
285
+ for (const document of docs) {
286
+ webhook.emit({
287
+ op: "delete",
288
+ collection,
289
+ id: document._id,
290
+ document,
291
+ committedAt
292
+ });
293
+ }
294
+ }
295
+ return n;
296
+ }
297
+ };
298
+ }
163
299
 
164
300
  // src/derive-id.ts
165
301
  var FNV1A128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dn;
@@ -174,6 +310,10 @@ function encodeUlid(value) {
174
310
  }
175
311
  return out;
176
312
  }
313
+ var DOC_ID_PATTERN = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/;
314
+ function isDocId(value) {
315
+ return typeof value === "string" && DOC_ID_PATTERN.test(value);
316
+ }
177
317
  function deriveDocId(collection, key) {
178
318
  const bytes = [...UTF8.encode(collection), 0, ...UTF8.encode(key)];
179
319
  let hash = FNV1A128_OFFSET_BASIS;
@@ -184,455 +324,6 @@ function deriveDocId(collection, key) {
184
324
  return encodeUlid(hash);
185
325
  }
186
326
 
187
- // src/replication/coverage.ts
188
- var COVERAGE_COLLECTION = "__taladb_replica";
189
- function coverageKey(key) {
190
- return [
191
- key.origin,
192
- key.collection,
193
- key.scope,
194
- `p${key.projectionVersion}`,
195
- `s${key.schemaVersion}`
196
- ].map(encodeURIComponent).join("|");
197
- }
198
- var CoverageStore = class {
199
- constructor(db) {
200
- this.col = db.collection(COVERAGE_COLLECTION);
201
- }
202
- async read(key) {
203
- const doc = await this.col.findOne({ key: coverageKey(key) });
204
- if (!doc?.state) return { status: "empty" };
205
- try {
206
- return JSON.parse(doc.state);
207
- } catch {
208
- return { status: "empty" };
209
- }
210
- }
211
- async write(key, state) {
212
- const k = coverageKey(key);
213
- const state_json = JSON.stringify(state);
214
- await this.col.replaceManyWithIds(
215
- [{ _id: deriveDocId(COVERAGE_COLLECTION, k), key: k, state: state_json }],
216
- "local"
217
- );
218
- }
219
- /** Drop a scope's coverage, forcing a fresh bootstrap on next use. */
220
- async clear(key) {
221
- const k = coverageKey(key);
222
- await this.col.deleteManyWithIds([deriveDocId(COVERAGE_COLLECTION, k)], "local");
223
- }
224
- };
225
- function isAuthoritative(state) {
226
- return state.status === "complete";
227
- }
228
- function rowsApplied(state) {
229
- switch (state.status) {
230
- case "hydrating":
231
- case "complete":
232
- case "best-effort":
233
- return state.rowsApplied;
234
- default:
235
- return 0;
236
- }
237
- }
238
- function progress(state) {
239
- if (state.status === "complete") return 1;
240
- if (state.status !== "hydrating" || !state.total) return void 0;
241
- return Math.min(1, state.rowsApplied / state.total);
242
- }
243
-
244
- // src/replication/coordinator.ts
245
- var DEFAULT_PAGE_SIZE = 500;
246
- var defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0));
247
- var MAX_BOOTSTRAP_PAGES = 1e5;
248
- var inflightByDatabase = /* @__PURE__ */ new WeakMap();
249
- var REPLICA_SCOPE_FIELD = "_replica_scope";
250
- var REPLICA_REVISION_FIELD = "_remote_rev";
251
- var ReplicationCoordinator = class {
252
- constructor(db, source, options = {}) {
253
- this.db = db;
254
- this.source = source;
255
- this.coverage = new CoverageStore(db);
256
- this.key = {
257
- origin: source.origin,
258
- collection: source.collection,
259
- scope: source.scope,
260
- projectionVersion: source.projectionVersion,
261
- schemaVersion: source.schemaVersion
262
- };
263
- this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
264
- this.yieldFn = options.yieldFn ?? defaultYield;
265
- this.onProgress = options.onProgress;
266
- this.collectionOptions = options.collectionOptions;
267
- let shared = inflightByDatabase.get(db);
268
- if (!shared) {
269
- shared = /* @__PURE__ */ new Map();
270
- inflightByDatabase.set(db, shared);
271
- }
272
- this.inflight = shared;
273
- }
274
- get replicaScope() {
275
- return coverageKey(this.key);
276
- }
277
- get identityNamespace() {
278
- return `${this.source.origin}\0${this.source.scope}\0${this.source.collection}`;
279
- }
280
- getCoverage() {
281
- return this.coverage.read(this.key);
282
- }
283
- /** Whether a purely local read is authorized right now. */
284
- async isReady() {
285
- return isAuthoritative(await this.getCoverage());
286
- }
287
- /** Dedup by intent: identical concurrent work joins rather than duplicating. */
288
- dedup(key, run) {
289
- const existing = this.inflight.get(key);
290
- if (existing) return existing;
291
- const pass = run().finally(() => this.inflight.delete(key));
292
- this.inflight.set(key, pass);
293
- return pass;
294
- }
295
- /**
296
- * Write a batch of remote rows into the local collection.
297
- *
298
- * One commit for the whole batch, ids derived from the origin's primary key, and
299
- * `origin: 'remote'` so the rows can never replicate back out at the origin they
300
- * came from. This is the *only* write path in the coordinator — bootstrap, delta
301
- * and bridge all funnel through it, which is precisely why they converge instead
302
- * of conflicting.
303
- */
304
- async applyRows(rows) {
305
- if (rows.length === 0) return [];
306
- const col = this.db.collection(this.source.collection, this.collectionOptions);
307
- const docs = rows.map(
308
- (row) => {
309
- const revision = this.source.revisionOf(row);
310
- return {
311
- ...this.source.mapRow(row),
312
- _id: deriveDocId(this.identityNamespace, String(this.source.keyOf(row))),
313
- [REPLICA_SCOPE_FIELD]: this.replicaScope,
314
- [REPLICA_REVISION_FIELD]: revision
315
- };
316
- }
317
- );
318
- await col.replaceManyWithIds(docs, "remote");
319
- return docs.map((doc) => doc._id);
320
- }
321
- /**
322
- * Hydrate the scope: walk the origin page by page until the whole collection is
323
- * local, then mark it complete.
324
- *
325
- * Resumable and idempotent. If the walk is interrupted — a reload, a crash, a
326
- * dead network — the next call picks up from the last committed page, and
327
- * re-applying a page it already wrote is a no-op because the ids are derived.
328
- */
329
- hydrate() {
330
- return this.dedup(`${this.replicaScope}:hydrate`, () => this.runHydrate());
331
- }
332
- async runHydrate() {
333
- let state = await this.coverage.read(this.key);
334
- if (state.status === "complete") return state;
335
- let page = null;
336
- let snapshot = null;
337
- let rowsApplied2 = 0;
338
- let total;
339
- let deltaCursor;
340
- if (state.status === "hydrating") {
341
- page = state.nextPage;
342
- snapshot = state.snapshot;
343
- rowsApplied2 = state.rowsApplied;
344
- total = state.total;
345
- deltaCursor = state.deltaCursor;
346
- } else if (state.status === "error" && state.snapshot) {
347
- page = state.resumeFrom;
348
- snapshot = state.snapshot;
349
- rowsApplied2 = state.rowsApplied ?? 0;
350
- total = state.total;
351
- deltaCursor = state.deltaCursor;
352
- }
353
- let snapshotSupported = true;
354
- let pages = 0;
355
- try {
356
- for (; ; ) {
357
- const result = await this.source.bootstrap({ page, snapshot, limit: this.pageSize });
358
- if (snapshot !== null && result.snapshot !== void 0 && result.snapshot !== snapshot) {
359
- throw new Error(
360
- `replication: origin '${this.source.origin}' changed snapshot token mid-walk`
361
- );
362
- }
363
- if (snapshot === null && result.snapshot) snapshot = result.snapshot;
364
- if (result.snapshot === void 0 && page === null) snapshotSupported = false;
365
- if (result.deltaCursor && !deltaCursor) deltaCursor = result.deltaCursor;
366
- if (result.total !== void 0) total = result.total;
367
- rowsApplied2 += (await this.applyRows(result.rows)).length;
368
- page = result.nextPage;
369
- if (page !== null) {
370
- const next = {
371
- status: "hydrating",
372
- snapshot: snapshot ?? "",
373
- nextPage: page,
374
- rowsApplied: rowsApplied2,
375
- ...deltaCursor !== void 0 ? { deltaCursor } : {},
376
- ...total !== void 0 ? { total } : {}
377
- };
378
- await this.coverage.write(this.key, next);
379
- this.onProgress?.(next);
380
- if (++pages >= MAX_BOOTSTRAP_PAGES) {
381
- throw new Error(
382
- `replication: origin '${this.source.origin}' offered more than ${MAX_BOOTSTRAP_PAGES} bootstrap pages for '${this.source.collection}' \u2014 it is probably not advancing nextPage.`
383
- );
384
- }
385
- await this.yieldFn();
386
- continue;
387
- }
388
- if (snapshotSupported && this.source.delta && deltaCursor === void 0) {
389
- throw new Error(
390
- `replication: origin '${this.source.origin}' supports delta refresh but did not issue deltaCursor on the first bootstrap page`
391
- );
392
- }
393
- state = snapshotSupported ? {
394
- status: "complete",
395
- cursor: deltaCursor ?? "",
396
- completedAt: Date.now(),
397
- rowsApplied: rowsApplied2,
398
- ...total !== void 0 ? { total } : {}
399
- } : {
400
- // Every row the origin offered was applied — but without a snapshot
401
- // we cannot prove we saw a consistent view of it, so we must not
402
- // claim completeness. Reads keep going to the network.
403
- status: "best-effort",
404
- cursor: deltaCursor ?? "",
405
- reason: "the origin did not return a snapshot token, so a row that moved between pages during the walk may have been missed",
406
- rowsApplied: rowsApplied2,
407
- ...total !== void 0 ? { total } : {}
408
- };
409
- await this.coverage.write(this.key, state);
410
- this.onProgress?.(state);
411
- return state;
412
- }
413
- } catch (error) {
414
- const failed = {
415
- status: "error",
416
- resumeFrom: page ?? 0,
417
- ...snapshot ? { snapshot } : {},
418
- ...deltaCursor !== void 0 ? { deltaCursor } : {},
419
- rowsApplied: rowsApplied2,
420
- ...total !== void 0 ? { total } : {},
421
- error: error instanceof Error ? error.message : String(error)
422
- };
423
- await this.coverage.write(this.key, failed);
424
- this.onProgress?.(failed);
425
- throw error;
426
- }
427
- }
428
- /**
429
- * Apply incremental changes since the stored cursor.
430
- *
431
- * Deletions are applied by mapping the origin's primary keys through the same
432
- * `deriveDocId`, and are written with `origin: 'remote'` so they leave no
433
- * tombstone — the origin already knows it deleted these, and a tombstone would
434
- * push its own deletion back at it.
435
- */
436
- refresh() {
437
- return this.dedup(`${this.replicaScope}:refresh`, () => this.runRefresh());
438
- }
439
- async runRefresh() {
440
- const state = await this.coverage.read(this.key);
441
- if (state.status !== "complete") return state;
442
- if (!this.source.delta) return state;
443
- const col = this.db.collection(this.source.collection);
444
- let cursor = state.cursor;
445
- let rowsApplied2 = state.rowsApplied;
446
- for (; ; ) {
447
- const page = await this.source.delta(cursor);
448
- rowsApplied2 += (await this.applyRows(page.changed)).length;
449
- if (page.deleted.length > 0) {
450
- const ids = page.deleted.map(
451
- (k) => deriveDocId(this.identityNamespace, String(k))
452
- );
453
- await col.deleteManyWithIds(ids, "remote");
454
- }
455
- cursor = page.cursor;
456
- const next = { ...state, cursor, rowsApplied: rowsApplied2 };
457
- await this.coverage.write(this.key, next);
458
- if (!page.hasMore) {
459
- this.onProgress?.(next);
460
- return next;
461
- }
462
- await this.yieldFn();
463
- }
464
- }
465
- /**
466
- * Cold-start bridge: fetch exactly the rows one query needs, right now.
467
- *
468
- * Needed because a SPA or React Native app has no server render to paint behind
469
- * while the replica fills. The rows land in the same collection under the same
470
- * derived ids as the walk's, so this is not a cache — it is the replica, arriving
471
- * early.
472
- *
473
- * **Does not advance coverage.** These rows did not come from the bootstrap
474
- * snapshot and prove nothing about completeness; treating them as progress would
475
- * let a page-1 fetch masquerade as a hydrated catalog.
476
- */
477
- bridge(query) {
478
- if (!this.source.fetchQuery) return Promise.resolve({ count: 0, ids: [] });
479
- const key = `bridge:${coverageKey(this.key)}:${JSON.stringify(query)}`;
480
- return this.dedup(key, async () => {
481
- const rows = await this.source.fetchQuery(query);
482
- const ids = await this.applyRows(rows);
483
- return { count: ids.length, ids };
484
- });
485
- }
486
- /** Drop coverage and force a fresh bootstrap. Local rows are left alone. */
487
- async reset() {
488
- await this.coverage.clear(this.key);
489
- }
490
- };
491
-
492
- // src/replication/rest.ts
493
- function parseRows(body, endpoint) {
494
- if (Array.isArray(body)) return body;
495
- if (body && typeof body === "object") {
496
- const env = body;
497
- for (const field of ["data", "items", "rows"]) {
498
- const value = env[field];
499
- if (Array.isArray(value)) return value;
500
- }
501
- throw new Error(
502
- `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.`
503
- );
504
- }
505
- throw new Error(
506
- `taladb: expected an array or object from ${endpoint}, got ${typeof body}.`
507
- );
508
- }
509
- function pick(body, names) {
510
- if (!body || typeof body !== "object") return void 0;
511
- const rec = body;
512
- for (const n of names) {
513
- if (rec[n] !== void 0) return rec[n];
514
- const meta = rec.meta;
515
- if (meta && meta[n] !== void 0) return meta[n];
516
- }
517
- return void 0;
518
- }
519
- function createRestSource(options) {
520
- const {
521
- endpoint,
522
- collection,
523
- origin = endpoint,
524
- scope = "global",
525
- projectionVersion = 1,
526
- schemaVersion = 1,
527
- key = "id",
528
- revision = "rev",
529
- mapRow,
530
- getAuth,
531
- paths,
532
- toParams,
533
- parse,
534
- pagination = "page"
535
- } = options;
536
- const doFetch = options.fetch ?? globalThis.fetch;
537
- async function get(path, params) {
538
- const url = new URL(path, globalThis.location?.origin ?? "http://localhost");
539
- for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
540
- const headers = getAuth ? await getAuth() : void 0;
541
- const response = await doFetch(url.href, { headers });
542
- if (!response.ok) {
543
- throw new Error(
544
- `taladb: ${path} responded ${response.status} ${response.statusText}`
545
- );
546
- }
547
- return response.json();
548
- }
549
- const rowsFrom = (body) => parse ? parse(body) : parseRows(body, endpoint);
550
- return {
551
- origin,
552
- collection,
553
- scope,
554
- projectionVersion,
555
- schemaVersion,
556
- keyOf: (row) => {
557
- const value = row[key];
558
- if (value === void 0 || value === null) {
559
- throw new Error(
560
- `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.`
561
- );
562
- }
563
- return String(value);
564
- },
565
- revisionOf: (row) => {
566
- const value = typeof revision === "function" ? revision(row) : row[revision];
567
- if (value === void 0 || value === null) {
568
- throw new Error(
569
- `taladb: row from ${endpoint} has no authoritative revision. Pass { revision } to name the monotonic revision field.`
570
- );
571
- }
572
- const n = Number(value);
573
- if (!Number.isSafeInteger(n)) {
574
- throw new Error(`taladb: authoritative revision must be a safe integer, got ${String(value)}`);
575
- }
576
- return n;
577
- },
578
- mapRow: (row) => {
579
- if (mapRow) return mapRow(row);
580
- const { _id, ...rest } = row;
581
- void _id;
582
- return rest;
583
- },
584
- bootstrap: async (request) => {
585
- const params = { limit: String(request.limit) };
586
- if (request.page !== null) params.page = String(request.page);
587
- if (request.snapshot !== null) params.snapshot = request.snapshot;
588
- const body = await get(endpoint + (paths?.bootstrap ?? ""), params);
589
- const rows = rowsFrom(body);
590
- const nextPage = pick(body, ["nextPage", "next_page", "next"]);
591
- const snapshot = pick(body, ["snapshot"]);
592
- const deltaCursor = pick(body, ["deltaCursor", "delta_cursor", "cursor"]);
593
- const total = pick(body, ["total", "totalCount", "count"]);
594
- return {
595
- rows,
596
- // An origin that reports no explicit `nextPage` is treated as exhausted
597
- // once it returns a short page — the conventional REST behavior.
598
- nextPage: nextPage !== void 0 ? nextPage : rows.length < request.limit ? null : pagination === "offset" ? Number(request.page ?? 0) + request.limit : Number(request.page ?? 1) + 1,
599
- ...snapshot !== void 0 ? { snapshot } : {},
600
- ...deltaCursor !== void 0 ? { deltaCursor } : {},
601
- ...total !== void 0 ? { total } : {}
602
- };
603
- },
604
- ...options.delta === true || paths?.delta ? { delta: async (cursor) => {
605
- const body = await get(endpoint + (paths?.delta ?? ""), { since: cursor });
606
- const changed = rowsFrom(body);
607
- const deleted = pick(body, ["deleted", "removed"]) ?? [];
608
- const next = pick(body, ["cursor", "now", "nextCursor"]);
609
- return {
610
- changed,
611
- deleted: deleted.map(String),
612
- cursor: next ?? cursor,
613
- hasMore: Boolean(pick(body, ["hasMore", "has_more"]))
614
- };
615
- } } : {},
616
- fetchQuery: async (query) => {
617
- if (!toParams && Object.values(query.filter ?? {}).some((v) => typeof v === "object" && v !== null)) {
618
- throw new Error(
619
- "taladb: bridge filters with operators require RestSourceOptions.toParams; the default translator only supports scalar equality fields."
620
- );
621
- }
622
- const sortEntry = Object.entries(query.sort ?? {})[0];
623
- const params = toParams ? toParams(query) : {
624
- ...query.page !== void 0 ? { page: String(query.page) } : {},
625
- ...query.limit !== void 0 ? { limit: String(query.limit) } : {},
626
- ...Object.fromEntries(
627
- Object.entries(query.filter ?? {}).map(([k, v]) => [k, String(v)])
628
- ),
629
- ...sortEntry ? { sort: sortEntry[0], order: sortEntry[1] === -1 ? "desc" : "asc" } : {}
630
- };
631
- return rowsFrom(await get(endpoint, params));
632
- }
633
- };
634
- }
635
-
636
327
  // src/index.ts
637
328
  var TalaDbValidationError = class extends Error {
638
329
  constructor(cause, context) {
@@ -687,10 +378,7 @@ function applySchema(col, options) {
687
378
  const engineOwned = /* @__PURE__ */ new Set([
688
379
  "_id",
689
380
  "_v",
690
- "_changed_at",
691
- "_remote",
692
- "_remote_rev",
693
- "_replica_scope"
381
+ "_changed_at"
694
382
  ]);
695
383
  const downcastViews = /* @__PURE__ */ new WeakSet();
696
384
  function preserveFields(original, next, preserveUnknown, preserveVersion = true) {
@@ -753,10 +441,6 @@ function applySchema(col, options) {
753
441
  if (!stampVersion || doc._v !== void 0) return doc;
754
442
  return { ...doc, _v: targetVersion };
755
443
  }
756
- function stampDoc(doc) {
757
- if (!stampVersion || doc._v !== void 0) return doc;
758
- return { ...doc, _v: targetVersion };
759
- }
760
444
  function diffUpdate(original, migrated) {
761
445
  const $set = {};
762
446
  const $unset = {};
@@ -847,35 +531,6 @@ function applySchema(col, options) {
847
531
  if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
848
532
  return col.insertMany(docs.map(stamp));
849
533
  } : col.insertMany.bind(col),
850
- // Rows arriving from a remote origin are validated like any other write. This
851
- // is the "parse, don't assert" boundary: the compile-time generic and the
852
- // runtime schema check have to be the same seam, or a malformed server
853
- // response walks straight into a typed collection.
854
- replaceManyWithIds: wrapWrites ? async (docs, origin) => {
855
- if (origin !== "remote") {
856
- docs.forEach((doc, i) => assertWritableDocument(doc, `replaceManyWithIds[${i}]`));
857
- if (targetVersion > 0) {
858
- throw new Error(
859
- "local replaceManyWithIds is disabled on versioned collections; use updateOne/updateMany so schema-version guards are atomic"
860
- );
861
- }
862
- }
863
- if (schema) docs.forEach((doc, i) => {
864
- const { _replica_scope, _remote_rev, ...schemaDoc } = doc;
865
- void _replica_scope;
866
- void _remote_rev;
867
- parseWrite(schemaDoc, `replaceManyWithIds[${i}]`);
868
- });
869
- return col.replaceManyWithIds(docs.map((d) => stampDoc(d)), origin);
870
- } : col.replaceManyWithIds.bind(col),
871
- deleteManyWithIds: stampVersion ? async (ids, origin) => {
872
- if (origin !== "remote") {
873
- throw new Error(
874
- "local deleteManyWithIds is disabled on versioned collections; use deleteOne/deleteMany so schema-version guards are atomic"
875
- );
876
- }
877
- return col.deleteManyWithIds(ids, origin);
878
- } : col.deleteManyWithIds.bind(col),
879
534
  updateOne: wrapWrites ? async (filter, update) => {
880
535
  assertSafeUpdate(update);
881
536
  return col.updateOne(writableFilter(filter), update);
@@ -939,6 +594,10 @@ function applySchema(col, options) {
939
594
  ) : col.subscribeAggregate.bind(col)
940
595
  };
941
596
  }
597
+ function decorateCollection(raw, name, opts, webhook) {
598
+ const reported = webhook ? wrapCollectionWithWebhook(raw, name, webhook) : raw;
599
+ return opts ? applySchema(reported, opts) : reported;
600
+ }
942
601
  function detectPlatform() {
943
602
  if (typeof navigator !== "undefined" && navigator.product === "ReactNative") {
944
603
  return "react-native";
@@ -1024,7 +683,7 @@ function makePoller(findFn, callback, onError) {
1024
683
  active = false;
1025
684
  };
1026
685
  }
1027
- async function createBrowserDB(dbName, config, passphrase, migrations) {
686
+ async function createBrowserDB(dbName, webhook, config, passphrase, migrations) {
1028
687
  const workerUrl = new URL("@taladb/web/worker/taladb.worker.js", import.meta.url);
1029
688
  const worker = new Worker(workerUrl, { type: "module", name: "taladb" });
1030
689
  const proxy = new WorkerProxy(worker);
@@ -1049,9 +708,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1049
708
  }
1050
709
  };
1051
710
  }
1052
- const syncSchemas = {};
1053
711
  function wrapCollection(name, opts) {
1054
- if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
1055
712
  const s = JSON.stringify;
1056
713
  const wrapped = {
1057
714
  insert: (doc) => proxy.send("insert", { collection: name, docJson: s(doc) }),
@@ -1062,19 +719,6 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1062
719
  });
1063
720
  return JSON.parse(json);
1064
721
  },
1065
- replaceManyWithIds: async (docs, origin = "local") => {
1066
- const json = await proxy.send("replaceManyWithIds", {
1067
- collection: name,
1068
- docsJson: s(docs),
1069
- origin
1070
- });
1071
- return JSON.parse(json);
1072
- },
1073
- deleteManyWithIds: (ids, origin = "local") => proxy.send("deleteManyWithIds", {
1074
- collection: name,
1075
- idsJson: s(ids),
1076
- origin
1077
- }),
1078
722
  find: async (filter) => {
1079
723
  const json = await proxy.send("find", {
1080
724
  collection: name,
@@ -1189,7 +833,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1189
833
  onError
1190
834
  )
1191
835
  };
1192
- return opts ? applySchema(wrapped, opts) : wrapped;
836
+ return decorateCollection(wrapped, name, opts, webhook);
1193
837
  }
1194
838
  function nudgedPoller(collection, fetchJson, callback, onError) {
1195
839
  let active = true;
@@ -1258,8 +902,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1258
902
  flush: async () => {
1259
903
  await proxy.send("flush");
1260
904
  },
1261
- syncStatus: async () => JSON.parse(await proxy.send("syncStatus")),
1262
- flushSync: (timeoutMs = 5e3) => proxy.send("flushSync", { timeoutMs }),
905
+ isPrimary: () => proxy.send("isPrimary"),
1263
906
  close: async () => {
1264
907
  channel?.close();
1265
908
  try {
@@ -1269,14 +912,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1269
912
  proxy.abort(new Error("taladb worker closed"));
1270
913
  }
1271
914
  },
1272
- // All engine work (export scan, LWW merge) runs inside the worker, off the
1273
- // main thread — a sync pass never blocks rendering, whatever its size.
1274
- exportChanges: (collections, sinceMs) => proxy.send("exportChangeset", { collectionsJson: JSON.stringify(collections), sinceMs }),
1275
- importChanges: (changeset) => proxy.send("importChangeset", { changesetJson: changeset }),
1276
- importChangesValidated: async (changeset, schemasJson) => JSON.parse(await proxy.send("importChangesetValidated", { changesetJson: changeset, schemasJson })),
1277
- listCollectionNames: async () => JSON.parse(await proxy.send("listCollections")),
1278
- quarantined: async (collection) => JSON.parse(await proxy.send("quarantined", { collection })),
1279
- sync: (adapter, options) => runSync(handle, adapter, options, syncSchemas)
915
+ listCollectionNames: async () => JSON.parse(await proxy.send("listCollections"))
1280
916
  };
1281
917
  if (migrations?.length) {
1282
918
  await runMigrations(
@@ -1290,21 +926,21 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1290
926
  }
1291
927
  return handle;
1292
928
  }
1293
- async function createNodeDB(dbName, config, passphrase, migrations) {
1294
- const native = await import("./node-A4LKRSW5.mjs");
929
+ async function createNodeDB(dbName, webhook, config, passphrase, migrations) {
930
+ const native = await import(
931
+ /* webpackIgnore: true */
932
+ /* turbopackIgnore: true */
933
+ "./node-A4LKRSW5.mjs"
934
+ );
1295
935
  const TalaDBNode = native.TalaDbNode ?? native.TalaDBNode;
1296
936
  if (!TalaDBNode) throw new Error("@taladb/node loaded but exports no TalaDbNode class \u2014 rebuild the native module");
1297
937
  const configJson = config !== void 0 ? JSON.stringify(config) : null;
1298
938
  const db = TalaDBNode.open(dbName, configJson, passphrase ?? null);
1299
- const syncSchemas = {};
1300
939
  function wrapCollection(name, opts) {
1301
- if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
1302
940
  const col = db.collection(name);
1303
941
  const wrapped = {
1304
942
  insert: async (doc) => col.insertAsync ? col.insertAsync(doc) : col.insert(doc),
1305
943
  insertMany: async (docs) => col.insertManyAsync ? col.insertManyAsync(docs) : col.insertMany(docs),
1306
- replaceManyWithIds: async (docs, origin = "local") => col.replaceManyWithIdsAsync ? col.replaceManyWithIdsAsync(docs, origin) : col.replaceManyWithIds(docs, origin),
1307
- deleteManyWithIds: async (ids, origin = "local") => col.deleteManyWithIdsAsync ? col.deleteManyWithIdsAsync(ids, origin) : col.deleteManyWithIds(ids, origin),
1308
944
  find: async (filter) => col.findAsync ? col.findAsync(filter ?? null) : col.find(filter ?? null),
1309
945
  findOne: async (filter) => col.findOne(filter) ?? null,
1310
946
  updateOne: async (filter, update) => col.updateOneAsync ? col.updateOneAsync(filter, update) : col.updateOne(filter, update),
@@ -1339,7 +975,7 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
1339
975
  subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
1340
976
  subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
1341
977
  };
1342
- return opts ? applySchema(wrapped, opts) : wrapped;
978
+ return decorateCollection(wrapped, name, opts, webhook);
1343
979
  }
1344
980
  const handle = {
1345
981
  collection: (name, opts) => wrapCollection(name, opts),
@@ -1349,14 +985,9 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
1349
985
  flush: db.flush ? async () => {
1350
986
  db.flush();
1351
987
  } : void 0,
1352
- exportChanges: async (collections, sinceMs) => db.exportChanges(sinceMs, collections),
1353
- importChanges: async (changeset) => db.importChanges(changeset),
1354
- // Feature-detected: only present when the loaded .node binary supports it,
1355
- // so older prebuilt binaries fall back to plain importChanges.
1356
- importChangesValidated: db.importChangesValidated ? async (changeset, schemasJson) => db.importChangesValidated(changeset, schemasJson) : void 0,
1357
- listCollectionNames: async () => db.listCollectionNames(),
1358
- quarantined: async (collection) => db.quarantined ? db.quarantined(collection) : [],
1359
- sync: (adapter, options) => runSync(handle, adapter, options, syncSchemas)
988
+ // One process owns the file — there is no other tab to defer to.
989
+ isPrimary: async () => true,
990
+ listCollectionNames: async () => db.listCollectionNames()
1360
991
  };
1361
992
  if (migrations?.length) {
1362
993
  if (typeof db.userVersion !== "function" || typeof db.setUserVersion !== "function") {
@@ -1371,7 +1002,7 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
1371
1002
  }
1372
1003
  return handle;
1373
1004
  }
1374
- async function createNativeDB(_dbName, migrations) {
1005
+ async function createNativeDB(_dbName, webhook, migrations) {
1375
1006
  const maybeNative = globalThis.__TalaDB__;
1376
1007
  if (!maybeNative) {
1377
1008
  throw new Error(
@@ -1379,14 +1010,10 @@ async function createNativeDB(_dbName, migrations) {
1379
1010
  );
1380
1011
  }
1381
1012
  const native = maybeNative;
1382
- const syncSchemas = {};
1383
1013
  function wrapCollection(name, opts) {
1384
- if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
1385
1014
  const wrapped = {
1386
1015
  insert: async (doc) => native.insert(name, doc),
1387
1016
  insertMany: async (docs) => native.insertMany(name, docs),
1388
- replaceManyWithIds: async (docs, origin = "local") => native.replaceManyWithIds(name, docs, origin),
1389
- deleteManyWithIds: async (ids, origin = "local") => native.deleteManyWithIds(name, ids, origin),
1390
1017
  find: async (filter) => native.find(name, filter ?? {}),
1391
1018
  findOne: async (filter) => native.findOne(name, filter ?? {}),
1392
1019
  updateOne: async (filter, update) => native.updateOne(name, filter, update),
@@ -1433,25 +1060,8 @@ async function createNativeDB(_dbName, migrations) {
1433
1060
  subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
1434
1061
  subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
1435
1062
  };
1436
- return opts ? applySchema(wrapped, opts) : wrapped;
1063
+ return decorateCollection(wrapped, name, opts, webhook);
1437
1064
  }
1438
- const syncSurface = typeof native.exportChanges === "function" && typeof native.importChanges === "function" && typeof native.listCollectionNames === "function" ? (() => {
1439
- const handle2 = {
1440
- collection: (name, opts) => wrapCollection(name, opts),
1441
- exportChanges: async (collections, sinceMs) => native.exportChanges(collections, sinceMs),
1442
- importChanges: async (changeset) => native.importChanges(changeset),
1443
- // Feature-detected: present on 0.9.2+ JSI HostObjects; when absent,
1444
- // runSync falls back to unvalidated importChanges.
1445
- importChangesValidated: native.importChangesValidated ? async (changeset, schemasJson) => native.importChangesValidated(changeset, schemasJson) : void 0,
1446
- listCollectionNames: async () => native.listCollectionNames(),
1447
- sync: (adapter, options) => runSync(handle2, adapter, options, syncSchemas)
1448
- };
1449
- return {
1450
- exportChanges: handle2.exportChanges,
1451
- importChanges: handle2.importChanges,
1452
- sync: handle2.sync
1453
- };
1454
- })() : unsupportedSync("react-native");
1455
1065
  const handle = {
1456
1066
  collection: (name, opts) => wrapCollection(name, opts),
1457
1067
  compact: async () => native.compact(),
@@ -1459,8 +1069,8 @@ async function createNativeDB(_dbName, migrations) {
1459
1069
  flush: native.flush ? async () => {
1460
1070
  native.flush();
1461
1071
  } : void 0,
1462
- quarantined: native.quarantined ? async (collection) => native.quarantined(collection) : void 0,
1463
- ...syncSurface
1072
+ // One process owns the file — there is no other tab to defer to.
1073
+ isPrimary: async () => true
1464
1074
  };
1465
1075
  if (migrations?.length) {
1466
1076
  if (typeof native.userVersion !== "function" || typeof native.setUserVersion !== "function") {
@@ -1512,35 +1122,45 @@ async function openDB(dbName = "taladb.db", options) {
1512
1122
  durability: { ...resolvedConfig?.durability, ...options.durability }
1513
1123
  };
1514
1124
  }
1125
+ const webhook = createWebhookDispatcher(options?.webhook ?? resolvedConfig?.webhook);
1515
1126
  const platform = detectPlatform();
1516
1127
  const migrations = options?.migrations;
1128
+ let db;
1517
1129
  switch (platform) {
1518
1130
  case "browser":
1519
- return createBrowserDB(dbName, resolvedConfig, options?.passphrase, migrations);
1131
+ db = await createBrowserDB(dbName, webhook, resolvedConfig, options?.passphrase, migrations);
1132
+ break;
1520
1133
  case "react-native":
1521
1134
  if (options?.passphrase !== void 0) {
1522
1135
  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");
1523
1136
  }
1524
- return createNativeDB(dbName, migrations);
1137
+ db = await createNativeDB(dbName, webhook, migrations);
1138
+ break;
1525
1139
  case "node":
1526
- return createNodeDB(dbName, resolvedConfig, options?.passphrase, migrations);
1140
+ db = await createNodeDB(dbName, webhook, resolvedConfig, options?.passphrase, migrations);
1141
+ break;
1527
1142
  }
1143
+ return webhook ? attachWebhook(db, webhook) : db;
1144
+ }
1145
+ function attachWebhook(db, webhook) {
1146
+ return {
1147
+ ...db,
1148
+ webhookStats: () => webhook.stats(),
1149
+ flushWebhook: (timeoutMs) => webhook.flush(timeoutMs),
1150
+ async close() {
1151
+ await webhook.flush();
1152
+ await db.close();
1153
+ }
1154
+ };
1528
1155
  }
1529
1156
  export {
1530
- COVERAGE_COLLECTION,
1531
- CoverageStore,
1532
- HttpSyncAdapter,
1533
- REPLICA_REVISION_FIELD,
1534
- REPLICA_SCOPE_FIELD,
1535
- ReplicationCoordinator,
1536
1157
  TalaDbValidationError,
1537
1158
  applySchema,
1538
- coverageKey,
1539
- createRestSource,
1159
+ createWebhookDispatcher,
1160
+ decorateCollection,
1540
1161
  deriveDocId,
1541
- isAuthoritative,
1162
+ isDocId,
1542
1163
  openDB,
1543
- progress,
1544
- rowsApplied,
1545
- runMigrations
1164
+ runMigrations,
1165
+ validateWebhookConfig
1546
1166
  };