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.
package/dist/index.mjs CHANGED
@@ -1,35 +1,283 @@
1
- // src/config.ts
2
- var ENDPOINT_FIELDS = [
1
+ // src/webhook.ts
2
+ var METHOD = {
3
+ insert: "POST",
4
+ update: "PUT",
5
+ delete: "DELETE"
6
+ };
7
+ var ENDPOINT_KEYS = [
3
8
  "endpoint",
4
9
  "insert_endpoint",
5
10
  "update_endpoint",
6
11
  "delete_endpoint"
7
12
  ];
8
- var LOCALHOST_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
9
- function isLocalhostUrl(url) {
13
+ var LOCALHOST = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
14
+ function isLocalhost(url) {
10
15
  try {
11
- return LOCALHOST_HOSTS.has(new URL(url).hostname);
16
+ return LOCALHOST.has(new URL(url).hostname);
12
17
  } catch {
13
18
  return false;
14
19
  }
15
20
  }
16
- function validateConfig(config) {
17
- const sync = config.sync;
18
- if (!sync) return;
19
- for (const key of ENDPOINT_FIELDS) {
20
- const url = sync[key];
21
- if (url !== void 0 && !url.startsWith("http://") && !url.startsWith("https://")) {
21
+ function validateWebhookConfig(config) {
22
+ for (const key of ENDPOINT_KEYS) {
23
+ const url = config[key];
24
+ if (url === void 0) continue;
25
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
22
26
  throw new Error(
23
- `TalaDB config: invalid endpoint URL "${url}" \u2014 must start with http:// or https://`
27
+ `TalaDB webhook: invalid ${key} "${url}" \u2014 must start with http:// or https://`
24
28
  );
25
29
  }
26
- if (url?.startsWith("http://") && !isLocalhostUrl(url)) {
30
+ if (url.startsWith("http://") && !isLocalhost(url)) {
27
31
  console.warn(
28
- `[TalaDB] sync endpoint "${url}" uses plaintext HTTP \u2014 use HTTPS in production to prevent changeset interception`
32
+ `[TalaDB] webhook ${key} "${url}" uses plaintext HTTP \u2014 document bodies will cross the network unencrypted. Use HTTPS in production.`
33
+ );
34
+ }
35
+ }
36
+ if (config.enabled && !config.endpoint) {
37
+ const perOp = ENDPOINT_KEYS.slice(1).every((k) => config[k] !== void 0);
38
+ if (!perOp) {
39
+ throw new Error(
40
+ "TalaDB webhook: `enabled: true` requires `endpoint` (or all three per-op endpoints)"
29
41
  );
30
42
  }
31
43
  }
32
44
  }
45
+ var DEFAULT_MAX_QUEUE = 512;
46
+ var DEFAULT_RETRIES = 3;
47
+ var BACKOFF_BASE_MS = 200;
48
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
49
+ function stripFields(doc, exclude) {
50
+ if (exclude.size === 0) return doc;
51
+ const out = {};
52
+ for (const [k, v] of Object.entries(doc)) {
53
+ if (!exclude.has(k)) out[k] = v;
54
+ }
55
+ return out;
56
+ }
57
+ function createWebhookDispatcher(config) {
58
+ if (!config?.enabled) return null;
59
+ validateWebhookConfig(config);
60
+ const fetchFn = config.fetch ?? globalThis.fetch?.bind(globalThis);
61
+ if (!fetchFn) {
62
+ throw new Error(
63
+ "TalaDB webhook: no global fetch available. Pass `fetch` in the webhook config."
64
+ );
65
+ }
66
+ const headers = { "content-type": "application/json", ...config.headers ?? {} };
67
+ const exclude = new Set(config.exclude_fields ?? []);
68
+ const only = config.collections ? new Set(config.collections) : null;
69
+ const maxQueue = config.max_queue ?? DEFAULT_MAX_QUEUE;
70
+ const retries = config.retries ?? DEFAULT_RETRIES;
71
+ const stats = { pending: 0, delivered: 0, failed: 0, dropped: 0 };
72
+ const chains = /* @__PURE__ */ new Map();
73
+ let eventSequence = 0;
74
+ function nextEventId() {
75
+ eventSequence++;
76
+ return `${Date.now().toString(36)}-${eventSequence.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
77
+ }
78
+ function endpointFor(op) {
79
+ return config[`${op}_endpoint`] ?? config.endpoint;
80
+ }
81
+ function bodyFor(event, at, eventId) {
82
+ return JSON.stringify({
83
+ event_id: eventId,
84
+ collection: event.collection,
85
+ id: event.id,
86
+ timestamp: at,
87
+ document: event.document ? stripFields(event.document, exclude) : null
88
+ });
89
+ }
90
+ async function deliver(event, at, eventId) {
91
+ const url = endpointFor(event.op);
92
+ const init = {
93
+ method: METHOD[event.op],
94
+ headers: { ...headers, "Idempotency-Key": eventId },
95
+ body: bodyFor(event, at, eventId)
96
+ };
97
+ for (let attempt = 0; attempt <= retries; attempt++) {
98
+ try {
99
+ const res = await fetchFn(url, init);
100
+ if (res.ok) {
101
+ stats.delivered++;
102
+ return;
103
+ }
104
+ if (res.status >= 400 && res.status < 500) {
105
+ stats.failed++;
106
+ console.warn(
107
+ `[TalaDB] webhook ${event.op} ${event.collection}/${event.id} \u2192 ${res.status} ${res.statusText} (not retried)`
108
+ );
109
+ return;
110
+ }
111
+ } catch {
112
+ }
113
+ if (attempt < retries) await sleep(BACKOFF_BASE_MS * 2 ** attempt);
114
+ }
115
+ stats.failed++;
116
+ console.warn(
117
+ `[TalaDB] webhook ${event.op} ${event.collection}/${event.id} failed after ${retries + 1} attempts`
118
+ );
119
+ }
120
+ return {
121
+ reports(collection) {
122
+ if (collection.startsWith("_")) return false;
123
+ return only === null || only.has(collection);
124
+ },
125
+ emit(event) {
126
+ if (!this.reports(event.collection)) return;
127
+ if (stats.pending >= maxQueue) {
128
+ stats.dropped++;
129
+ console.warn(
130
+ `[TalaDB] webhook queue full (${maxQueue}) \u2014 dropped ${event.op} ${event.collection}/${event.id}. The endpoint is not keeping up.`
131
+ );
132
+ return;
133
+ }
134
+ stats.pending++;
135
+ const at = event.committedAt ?? Date.now();
136
+ const eventId = nextEventId();
137
+ const key = `${event.collection}\0${event.id}`;
138
+ const prior = chains.get(key) ?? Promise.resolve();
139
+ const next = prior.then(() => deliver(event, at, eventId)).finally(() => {
140
+ stats.pending--;
141
+ if (chains.get(key) === next) chains.delete(key);
142
+ });
143
+ chains.set(key, next);
144
+ },
145
+ async flush(timeoutMs = 5e3) {
146
+ const deadline = Date.now() + timeoutMs;
147
+ while (stats.pending > 0) {
148
+ const remaining = deadline - Date.now();
149
+ if (remaining <= 0) break;
150
+ let timer;
151
+ const expired = new Promise((resolve) => {
152
+ timer = setTimeout(resolve, remaining);
153
+ });
154
+ try {
155
+ await Promise.race([Promise.allSettled([...chains.values()]), expired]);
156
+ } finally {
157
+ clearTimeout(timer);
158
+ }
159
+ }
160
+ return stats.pending === 0;
161
+ },
162
+ stats() {
163
+ return { ...stats };
164
+ }
165
+ };
166
+ }
167
+ function byIds(ids) {
168
+ return ids.length === 1 ? { _id: ids[0] } : { _id: { $in: ids } };
169
+ }
170
+ function idFromFilter(filter) {
171
+ if (filter === null || typeof filter !== "object") return null;
172
+ const keys = Object.keys(filter);
173
+ if (keys.length !== 1 || keys[0] !== "_id") return null;
174
+ const id = filter._id;
175
+ return typeof id === "string" ? id : null;
176
+ }
177
+ function wrapCollectionWithWebhook(col, collection, webhook) {
178
+ if (!webhook.reports(collection)) return col;
179
+ async function idsFor(filter, limitOne) {
180
+ const fast = idFromFilter(filter);
181
+ if (fast !== null) return [fast];
182
+ if (limitOne) {
183
+ const doc = await col.findOne(filter);
184
+ return doc && typeof doc._id === "string" ? [doc._id] : [];
185
+ }
186
+ const docs = col.aggregate ? await col.aggregate([{ $match: filter }, { $project: { _id: 1 } }]) : await col.find(filter);
187
+ return docs.map((d) => d._id).filter((id) => typeof id === "string");
188
+ }
189
+ async function docsFor(filter, limitOne) {
190
+ if (limitOne) {
191
+ const doc = await col.findOne(filter);
192
+ return doc === null ? [] : [doc];
193
+ }
194
+ return col.find(filter);
195
+ }
196
+ async function emitPostImages(ids, op, committedAt) {
197
+ if (ids.length === 0) return;
198
+ const docs = await col.find(byIds(ids));
199
+ const byId = /* @__PURE__ */ new Map();
200
+ for (const doc of docs) {
201
+ if (typeof doc._id === "string") byId.set(doc._id, doc);
202
+ }
203
+ for (const id of ids) {
204
+ const doc = byId.get(id);
205
+ if (doc === void 0) {
206
+ webhook.emit({ op: "delete", collection, id, document: null, committedAt });
207
+ } else {
208
+ webhook.emit({ op, collection, id, document: doc, committedAt });
209
+ }
210
+ }
211
+ }
212
+ return {
213
+ ...col,
214
+ async insert(doc) {
215
+ const id = await col.insert(doc);
216
+ const committedAt = Date.now();
217
+ await emitPostImages([id], "insert", committedAt);
218
+ return id;
219
+ },
220
+ async insertMany(docs) {
221
+ const ids = await col.insertMany(docs);
222
+ const committedAt = Date.now();
223
+ await emitPostImages(ids, "insert", committedAt);
224
+ return ids;
225
+ },
226
+ async updateOne(filter, update) {
227
+ const ids = await idsFor(filter, true);
228
+ const changed = await col.updateOne(filter, update);
229
+ const committedAt = Date.now();
230
+ if (changed) await emitPostImages(ids, "update", committedAt);
231
+ return changed;
232
+ },
233
+ async updateMany(filter, update) {
234
+ const ids = await idsFor(filter, false);
235
+ const n = await col.updateMany(filter, update);
236
+ const committedAt = Date.now();
237
+ if (n > 0) await emitPostImages(ids, "update", committedAt);
238
+ return n;
239
+ },
240
+ async deleteOne(filter) {
241
+ const docs = await docsFor(filter, true);
242
+ const deleted = await col.deleteOne(filter);
243
+ const committedAt = Date.now();
244
+ if (deleted) {
245
+ for (const document of docs) {
246
+ webhook.emit({
247
+ op: "delete",
248
+ collection,
249
+ id: document._id,
250
+ document,
251
+ committedAt
252
+ });
253
+ }
254
+ }
255
+ return deleted;
256
+ },
257
+ async deleteMany(filter) {
258
+ const docs = await docsFor(filter, false);
259
+ const n = await col.deleteMany(filter);
260
+ const committedAt = Date.now();
261
+ if (n > 0) {
262
+ for (const document of docs) {
263
+ webhook.emit({
264
+ op: "delete",
265
+ collection,
266
+ id: document._id,
267
+ document,
268
+ committedAt
269
+ });
270
+ }
271
+ }
272
+ return n;
273
+ }
274
+ };
275
+ }
276
+
277
+ // src/config.ts
278
+ function validateConfig(config) {
279
+ if (config.webhook) validateWebhookConfig(config.webhook);
280
+ }
33
281
  async function loadConfig(configPath) {
34
282
  if (typeof process === "undefined" || typeof process.cwd !== "function") {
35
283
  return {};
@@ -78,146 +326,6 @@ async function loadConfig(configPath) {
78
326
  return {};
79
327
  }
80
328
 
81
- // src/sync.ts
82
- var CURSOR_COLLECTION = "__taladb_sync";
83
- async function resolveCollections(handle, options) {
84
- const base = options.collections ?? await handle.listCollectionNames();
85
- const excluded = new Set(options.exclude ?? []);
86
- return base.filter((c) => !excluded.has(c) && !c.startsWith("_"));
87
- }
88
- function unsupportedSync(runtime) {
89
- const err = () => new Error(
90
- `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.`
91
- );
92
- return {
93
- sync: () => Promise.reject(err()),
94
- exportChanges: () => Promise.reject(err()),
95
- importChanges: () => Promise.reject(err())
96
- };
97
- }
98
- async function readCursor(cursorCol, target) {
99
- const doc = await cursorCol.findOne({ target });
100
- return {
101
- pushMs: doc?.pushMs ?? 0,
102
- pullMs: doc?.pullMs ?? 0,
103
- pullCursor: doc?.pullCursor
104
- };
105
- }
106
- async function writeCursor(cursorCol, target, cursor) {
107
- const updated = await cursorCol.updateOne({ target }, { $set: { ...cursor } });
108
- if (!updated) {
109
- await cursorCol.insert({ target, ...cursor });
110
- }
111
- }
112
- function isCursorAdapter(adapter) {
113
- return typeof adapter.pullWithCursor === "function";
114
- }
115
- var MAX_PULL_PAGES = 1e4;
116
- async function runSync(handle, adapter, options, syncSchemas = {}) {
117
- const direction = options.direction ?? "both";
118
- const target = options.target ?? "default";
119
- const doPush = direction === "push" || direction === "both";
120
- const doPull = direction === "pull" || direction === "both";
121
- if (doPull && !adapter.pull && !isCursorAdapter(adapter)) {
122
- throw new Error(
123
- `sync direction '${direction}' requires adapter.pull() or adapter.pullWithCursor()`
124
- );
125
- }
126
- if (doPush && !adapter.push) {
127
- throw new Error(`sync direction '${direction}' requires adapter.push()`);
128
- }
129
- const collections = await resolveCollections(handle, options);
130
- const cursorCol = handle.collection(CURSOR_COLLECTION);
131
- const cursor = await readCursor(cursorCol, target);
132
- const local = doPush ? await handle.exportChanges(collections, 0) : "[]";
133
- const scopedSchemas = {};
134
- for (const c of collections) {
135
- if (syncSchemas[c]) scopedSchemas[c] = syncSchemas[c];
136
- }
137
- const useValidated = handle.importChangesValidated && Object.keys(scopedSchemas).length > 0;
138
- let pulled = 0;
139
- let skipped = 0;
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
- }
153
- if (doPull) {
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
- }
167
- }
168
- } else {
169
- await importOne(await adapter.pull(0));
170
- }
171
- }
172
- let pushed = 0;
173
- if (doPush && local !== "[]") {
174
- pushed = JSON.parse(local).length;
175
- await adapter.push(local);
176
- }
177
- await writeCursor(cursorCol, target, {
178
- pushMs: cursor.pushMs,
179
- pullMs: cursor.pullMs,
180
- ...pullCursor !== void 0 ? { pullCursor } : {}
181
- });
182
- return { pushed, pulled, skipped, quarantined, cursor: 0 };
183
- }
184
-
185
- // src/http-adapter.ts
186
- var HttpSyncAdapter = class {
187
- constructor(options) {
188
- this.endpoint = options.endpoint.replace(/\/$/, "");
189
- this.headers = options.headers ?? {};
190
- const f = options.fetch ?? globalThis.fetch?.bind(globalThis);
191
- if (!f) {
192
- throw new Error(
193
- "HttpSyncAdapter: no fetch available. Pass options.fetch on runtimes without a global fetch."
194
- );
195
- }
196
- this.fetchFn = f;
197
- this.pushPath = options.paths?.push ?? "/push";
198
- this.pullPath = options.paths?.pull ?? "/pull";
199
- }
200
- async push(changeset) {
201
- const res = await this.fetchFn(`${this.endpoint}${this.pushPath}`, {
202
- method: "POST",
203
- headers: { "content-type": "application/json", ...this.headers },
204
- body: changeset
205
- });
206
- if (!res.ok) {
207
- throw new Error(`HttpSyncAdapter push failed: ${res.status} ${res.statusText}`);
208
- }
209
- }
210
- async pull(sinceMs) {
211
- const url = `${this.endpoint}${this.pullPath}?since=${encodeURIComponent(String(sinceMs))}`;
212
- const res = await this.fetchFn(url, { method: "GET", headers: this.headers });
213
- if (!res.ok) {
214
- throw new Error(`HttpSyncAdapter pull failed: ${res.status} ${res.statusText}`);
215
- }
216
- const body = (await res.text()).trim();
217
- return body.length === 0 ? "[]" : body;
218
- }
219
- };
220
-
221
329
  // src/derive-id.ts
222
330
  var FNV1A128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dn;
223
331
  var FNV1A128_PRIME = 0x0000000001000000000000000000013bn;
@@ -231,6 +339,10 @@ function encodeUlid(value) {
231
339
  }
232
340
  return out;
233
341
  }
342
+ var DOC_ID_PATTERN = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/;
343
+ function isDocId(value) {
344
+ return typeof value === "string" && DOC_ID_PATTERN.test(value);
345
+ }
234
346
  function deriveDocId(collection, key) {
235
347
  const bytes = [...UTF8.encode(collection), 0, ...UTF8.encode(key)];
236
348
  let hash = FNV1A128_OFFSET_BASIS;
@@ -241,455 +353,6 @@ function deriveDocId(collection, key) {
241
353
  return encodeUlid(hash);
242
354
  }
243
355
 
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
-
693
356
  // src/index.ts
694
357
  var TalaDbValidationError = class extends Error {
695
358
  constructor(cause, context) {
@@ -744,10 +407,7 @@ function applySchema(col, options) {
744
407
  const engineOwned = /* @__PURE__ */ new Set([
745
408
  "_id",
746
409
  "_v",
747
- "_changed_at",
748
- "_remote",
749
- "_remote_rev",
750
- "_replica_scope"
410
+ "_changed_at"
751
411
  ]);
752
412
  const downcastViews = /* @__PURE__ */ new WeakSet();
753
413
  function preserveFields(original, next, preserveUnknown, preserveVersion = true) {
@@ -810,10 +470,6 @@ function applySchema(col, options) {
810
470
  if (!stampVersion || doc._v !== void 0) return doc;
811
471
  return { ...doc, _v: targetVersion };
812
472
  }
813
- function stampDoc(doc) {
814
- if (!stampVersion || doc._v !== void 0) return doc;
815
- return { ...doc, _v: targetVersion };
816
- }
817
473
  function diffUpdate(original, migrated) {
818
474
  const $set = {};
819
475
  const $unset = {};
@@ -904,35 +560,6 @@ function applySchema(col, options) {
904
560
  if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
905
561
  return col.insertMany(docs.map(stamp));
906
562
  } : col.insertMany.bind(col),
907
- // Rows arriving from a remote origin are validated like any other write. This
908
- // is the "parse, don't assert" boundary: the compile-time generic and the
909
- // runtime schema check have to be the same seam, or a malformed server
910
- // response walks straight into a typed collection.
911
- replaceManyWithIds: wrapWrites ? async (docs, origin) => {
912
- if (origin !== "remote") {
913
- docs.forEach((doc, i) => assertWritableDocument(doc, `replaceManyWithIds[${i}]`));
914
- if (targetVersion > 0) {
915
- throw new Error(
916
- "local replaceManyWithIds is disabled on versioned collections; use updateOne/updateMany so schema-version guards are atomic"
917
- );
918
- }
919
- }
920
- if (schema) docs.forEach((doc, i) => {
921
- const { _replica_scope, _remote_rev, ...schemaDoc } = doc;
922
- void _replica_scope;
923
- void _remote_rev;
924
- parseWrite(schemaDoc, `replaceManyWithIds[${i}]`);
925
- });
926
- return col.replaceManyWithIds(docs.map((d) => stampDoc(d)), origin);
927
- } : col.replaceManyWithIds.bind(col),
928
- deleteManyWithIds: stampVersion ? async (ids, origin) => {
929
- if (origin !== "remote") {
930
- throw new Error(
931
- "local deleteManyWithIds is disabled on versioned collections; use deleteOne/deleteMany so schema-version guards are atomic"
932
- );
933
- }
934
- return col.deleteManyWithIds(ids, origin);
935
- } : col.deleteManyWithIds.bind(col),
936
563
  updateOne: wrapWrites ? async (filter, update) => {
937
564
  assertSafeUpdate(update);
938
565
  return col.updateOne(writableFilter(filter), update);
@@ -996,6 +623,10 @@ function applySchema(col, options) {
996
623
  ) : col.subscribeAggregate.bind(col)
997
624
  };
998
625
  }
626
+ function decorateCollection(raw, name, opts, webhook) {
627
+ const reported = webhook ? wrapCollectionWithWebhook(raw, name, webhook) : raw;
628
+ return opts ? applySchema(reported, opts) : reported;
629
+ }
999
630
  function detectPlatform() {
1000
631
  if (typeof navigator !== "undefined" && navigator.product === "ReactNative") {
1001
632
  return "react-native";
@@ -1081,7 +712,7 @@ function makePoller(findFn, callback, onError) {
1081
712
  active = false;
1082
713
  };
1083
714
  }
1084
- async function createBrowserDB(dbName, config, passphrase, migrations) {
715
+ async function createBrowserDB(dbName, webhook, config, passphrase, migrations) {
1085
716
  const workerUrl = new URL("@taladb/web/worker/taladb.worker.js", import.meta.url);
1086
717
  const worker = new Worker(workerUrl, { type: "module", name: "taladb" });
1087
718
  const proxy = new WorkerProxy(worker);
@@ -1106,9 +737,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1106
737
  }
1107
738
  };
1108
739
  }
1109
- const syncSchemas = {};
1110
740
  function wrapCollection(name, opts) {
1111
- if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
1112
741
  const s = JSON.stringify;
1113
742
  const wrapped = {
1114
743
  insert: (doc) => proxy.send("insert", { collection: name, docJson: s(doc) }),
@@ -1119,19 +748,6 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1119
748
  });
1120
749
  return JSON.parse(json);
1121
750
  },
1122
- replaceManyWithIds: async (docs, origin = "local") => {
1123
- const json = await proxy.send("replaceManyWithIds", {
1124
- collection: name,
1125
- docsJson: s(docs),
1126
- origin
1127
- });
1128
- return JSON.parse(json);
1129
- },
1130
- deleteManyWithIds: (ids, origin = "local") => proxy.send("deleteManyWithIds", {
1131
- collection: name,
1132
- idsJson: s(ids),
1133
- origin
1134
- }),
1135
751
  find: async (filter) => {
1136
752
  const json = await proxy.send("find", {
1137
753
  collection: name,
@@ -1246,7 +862,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1246
862
  onError
1247
863
  )
1248
864
  };
1249
- return opts ? applySchema(wrapped, opts) : wrapped;
865
+ return decorateCollection(wrapped, name, opts, webhook);
1250
866
  }
1251
867
  function nudgedPoller(collection, fetchJson, callback, onError) {
1252
868
  let active = true;
@@ -1315,8 +931,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1315
931
  flush: async () => {
1316
932
  await proxy.send("flush");
1317
933
  },
1318
- syncStatus: async () => JSON.parse(await proxy.send("syncStatus")),
1319
- flushSync: (timeoutMs = 5e3) => proxy.send("flushSync", { timeoutMs }),
934
+ isPrimary: () => proxy.send("isPrimary"),
1320
935
  close: async () => {
1321
936
  channel?.close();
1322
937
  try {
@@ -1326,14 +941,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1326
941
  proxy.abort(new Error("taladb worker closed"));
1327
942
  }
1328
943
  },
1329
- // All engine work (export scan, LWW merge) runs inside the worker, off the
1330
- // main thread — a sync pass never blocks rendering, whatever its size.
1331
- exportChanges: (collections, sinceMs) => proxy.send("exportChangeset", { collectionsJson: JSON.stringify(collections), sinceMs }),
1332
- importChanges: (changeset) => proxy.send("importChangeset", { changesetJson: changeset }),
1333
- importChangesValidated: async (changeset, schemasJson) => JSON.parse(await proxy.send("importChangesetValidated", { changesetJson: changeset, schemasJson })),
1334
- listCollectionNames: async () => JSON.parse(await proxy.send("listCollections")),
1335
- quarantined: async (collection) => JSON.parse(await proxy.send("quarantined", { collection })),
1336
- sync: (adapter, options) => runSync(handle, adapter, options, syncSchemas)
944
+ listCollectionNames: async () => JSON.parse(await proxy.send("listCollections"))
1337
945
  };
1338
946
  if (migrations?.length) {
1339
947
  await runMigrations(
@@ -1347,21 +955,21 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
1347
955
  }
1348
956
  return handle;
1349
957
  }
1350
- async function createNodeDB(dbName, config, passphrase, migrations) {
1351
- const native = await import("@taladb/node");
958
+ async function createNodeDB(dbName, webhook, config, passphrase, migrations) {
959
+ const native = await import(
960
+ /* webpackIgnore: true */
961
+ /* turbopackIgnore: true */
962
+ "@taladb/node"
963
+ );
1352
964
  const TalaDBNode = native.TalaDbNode ?? native.TalaDBNode;
1353
965
  if (!TalaDBNode) throw new Error("@taladb/node loaded but exports no TalaDbNode class \u2014 rebuild the native module");
1354
966
  const configJson = config !== void 0 ? JSON.stringify(config) : null;
1355
967
  const db = TalaDBNode.open(dbName, configJson, passphrase ?? null);
1356
- const syncSchemas = {};
1357
968
  function wrapCollection(name, opts) {
1358
- if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
1359
969
  const col = db.collection(name);
1360
970
  const wrapped = {
1361
971
  insert: async (doc) => col.insertAsync ? col.insertAsync(doc) : col.insert(doc),
1362
972
  insertMany: async (docs) => col.insertManyAsync ? col.insertManyAsync(docs) : col.insertMany(docs),
1363
- replaceManyWithIds: async (docs, origin = "local") => col.replaceManyWithIdsAsync ? col.replaceManyWithIdsAsync(docs, origin) : col.replaceManyWithIds(docs, origin),
1364
- deleteManyWithIds: async (ids, origin = "local") => col.deleteManyWithIdsAsync ? col.deleteManyWithIdsAsync(ids, origin) : col.deleteManyWithIds(ids, origin),
1365
973
  find: async (filter) => col.findAsync ? col.findAsync(filter ?? null) : col.find(filter ?? null),
1366
974
  findOne: async (filter) => col.findOne(filter) ?? null,
1367
975
  updateOne: async (filter, update) => col.updateOneAsync ? col.updateOneAsync(filter, update) : col.updateOne(filter, update),
@@ -1396,7 +1004,7 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
1396
1004
  subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
1397
1005
  subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
1398
1006
  };
1399
- return opts ? applySchema(wrapped, opts) : wrapped;
1007
+ return decorateCollection(wrapped, name, opts, webhook);
1400
1008
  }
1401
1009
  const handle = {
1402
1010
  collection: (name, opts) => wrapCollection(name, opts),
@@ -1406,14 +1014,9 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
1406
1014
  flush: db.flush ? async () => {
1407
1015
  db.flush();
1408
1016
  } : void 0,
1409
- exportChanges: async (collections, sinceMs) => db.exportChanges(sinceMs, collections),
1410
- importChanges: async (changeset) => db.importChanges(changeset),
1411
- // Feature-detected: only present when the loaded .node binary supports it,
1412
- // so older prebuilt binaries fall back to plain importChanges.
1413
- importChangesValidated: db.importChangesValidated ? async (changeset, schemasJson) => db.importChangesValidated(changeset, schemasJson) : void 0,
1414
- listCollectionNames: async () => db.listCollectionNames(),
1415
- quarantined: async (collection) => db.quarantined ? db.quarantined(collection) : [],
1416
- sync: (adapter, options) => runSync(handle, adapter, options, syncSchemas)
1017
+ // One process owns the file — there is no other tab to defer to.
1018
+ isPrimary: async () => true,
1019
+ listCollectionNames: async () => db.listCollectionNames()
1417
1020
  };
1418
1021
  if (migrations?.length) {
1419
1022
  if (typeof db.userVersion !== "function" || typeof db.setUserVersion !== "function") {
@@ -1428,7 +1031,7 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
1428
1031
  }
1429
1032
  return handle;
1430
1033
  }
1431
- async function createNativeDB(_dbName, migrations) {
1034
+ async function createNativeDB(_dbName, webhook, migrations) {
1432
1035
  const maybeNative = globalThis.__TalaDB__;
1433
1036
  if (!maybeNative) {
1434
1037
  throw new Error(
@@ -1436,14 +1039,10 @@ async function createNativeDB(_dbName, migrations) {
1436
1039
  );
1437
1040
  }
1438
1041
  const native = maybeNative;
1439
- const syncSchemas = {};
1440
1042
  function wrapCollection(name, opts) {
1441
- if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
1442
1043
  const wrapped = {
1443
1044
  insert: async (doc) => native.insert(name, doc),
1444
1045
  insertMany: async (docs) => native.insertMany(name, docs),
1445
- replaceManyWithIds: async (docs, origin = "local") => native.replaceManyWithIds(name, docs, origin),
1446
- deleteManyWithIds: async (ids, origin = "local") => native.deleteManyWithIds(name, ids, origin),
1447
1046
  find: async (filter) => native.find(name, filter ?? {}),
1448
1047
  findOne: async (filter) => native.findOne(name, filter ?? {}),
1449
1048
  updateOne: async (filter, update) => native.updateOne(name, filter, update),
@@ -1490,25 +1089,8 @@ async function createNativeDB(_dbName, migrations) {
1490
1089
  subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
1491
1090
  subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
1492
1091
  };
1493
- return opts ? applySchema(wrapped, opts) : wrapped;
1092
+ return decorateCollection(wrapped, name, opts, webhook);
1494
1093
  }
1495
- const syncSurface = typeof native.exportChanges === "function" && typeof native.importChanges === "function" && typeof native.listCollectionNames === "function" ? (() => {
1496
- const handle2 = {
1497
- collection: (name, opts) => wrapCollection(name, opts),
1498
- exportChanges: async (collections, sinceMs) => native.exportChanges(collections, sinceMs),
1499
- importChanges: async (changeset) => native.importChanges(changeset),
1500
- // Feature-detected: present on 0.9.2+ JSI HostObjects; when absent,
1501
- // runSync falls back to unvalidated importChanges.
1502
- importChangesValidated: native.importChangesValidated ? async (changeset, schemasJson) => native.importChangesValidated(changeset, schemasJson) : void 0,
1503
- listCollectionNames: async () => native.listCollectionNames(),
1504
- sync: (adapter, options) => runSync(handle2, adapter, options, syncSchemas)
1505
- };
1506
- return {
1507
- exportChanges: handle2.exportChanges,
1508
- importChanges: handle2.importChanges,
1509
- sync: handle2.sync
1510
- };
1511
- })() : unsupportedSync("react-native");
1512
1094
  const handle = {
1513
1095
  collection: (name, opts) => wrapCollection(name, opts),
1514
1096
  compact: async () => native.compact(),
@@ -1516,8 +1098,8 @@ async function createNativeDB(_dbName, migrations) {
1516
1098
  flush: native.flush ? async () => {
1517
1099
  native.flush();
1518
1100
  } : void 0,
1519
- quarantined: native.quarantined ? async (collection) => native.quarantined(collection) : void 0,
1520
- ...syncSurface
1101
+ // One process owns the file — there is no other tab to defer to.
1102
+ isPrimary: async () => true
1521
1103
  };
1522
1104
  if (migrations?.length) {
1523
1105
  if (typeof native.userVersion !== "function" || typeof native.setUserVersion !== "function") {
@@ -1569,35 +1151,45 @@ async function openDB(dbName = "taladb.db", options) {
1569
1151
  durability: { ...resolvedConfig?.durability, ...options.durability }
1570
1152
  };
1571
1153
  }
1154
+ const webhook = createWebhookDispatcher(options?.webhook ?? resolvedConfig?.webhook);
1572
1155
  const platform = detectPlatform();
1573
1156
  const migrations = options?.migrations;
1157
+ let db;
1574
1158
  switch (platform) {
1575
1159
  case "browser":
1576
- return createBrowserDB(dbName, resolvedConfig, options?.passphrase, migrations);
1160
+ db = await createBrowserDB(dbName, webhook, resolvedConfig, options?.passphrase, migrations);
1161
+ break;
1577
1162
  case "react-native":
1578
1163
  if (options?.passphrase !== void 0) {
1579
1164
  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");
1580
1165
  }
1581
- return createNativeDB(dbName, migrations);
1166
+ db = await createNativeDB(dbName, webhook, migrations);
1167
+ break;
1582
1168
  case "node":
1583
- return createNodeDB(dbName, resolvedConfig, options?.passphrase, migrations);
1169
+ db = await createNodeDB(dbName, webhook, resolvedConfig, options?.passphrase, migrations);
1170
+ break;
1584
1171
  }
1172
+ return webhook ? attachWebhook(db, webhook) : db;
1173
+ }
1174
+ function attachWebhook(db, webhook) {
1175
+ return {
1176
+ ...db,
1177
+ webhookStats: () => webhook.stats(),
1178
+ flushWebhook: (timeoutMs) => webhook.flush(timeoutMs),
1179
+ async close() {
1180
+ await webhook.flush();
1181
+ await db.close();
1182
+ }
1183
+ };
1585
1184
  }
1586
1185
  export {
1587
- COVERAGE_COLLECTION,
1588
- CoverageStore,
1589
- HttpSyncAdapter,
1590
- REPLICA_REVISION_FIELD,
1591
- REPLICA_SCOPE_FIELD,
1592
- ReplicationCoordinator,
1593
1186
  TalaDbValidationError,
1594
1187
  applySchema,
1595
- coverageKey,
1596
- createRestSource,
1188
+ createWebhookDispatcher,
1189
+ decorateCollection,
1597
1190
  deriveDocId,
1598
- isAuthoritative,
1191
+ isDocId,
1599
1192
  openDB,
1600
- progress,
1601
- rowsApplied,
1602
- runMigrations
1193
+ runMigrations,
1194
+ validateWebhookConfig
1603
1195
  };