taladb 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/dist/index.browser.mjs +320 -693
- package/dist/index.d.mts +229 -759
- package/dist/index.d.ts +229 -759
- package/dist/index.js +330 -739
- package/dist/index.mjs +326 -727
- package/dist/index.react-native.mjs +320 -693
- package/package.json +13 -12
package/dist/index.browser.mjs
CHANGED
|
@@ -21,145 +21,281 @@ async function loadConfig(_configPath) {
|
|
|
21
21
|
return {};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// src/
|
|
25
|
-
var
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
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
|
-
|
|
86
|
+
"TalaDB webhook: no global fetch available. Pass `fetch` in the webhook config."
|
|
67
87
|
);
|
|
68
88
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
|
@@ -184,455 +320,6 @@ function deriveDocId(collection, key) {
|
|
|
184
320
|
return encodeUlid(hash);
|
|
185
321
|
}
|
|
186
322
|
|
|
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
323
|
// src/index.ts
|
|
637
324
|
var TalaDbValidationError = class extends Error {
|
|
638
325
|
constructor(cause, context) {
|
|
@@ -687,10 +374,7 @@ function applySchema(col, options) {
|
|
|
687
374
|
const engineOwned = /* @__PURE__ */ new Set([
|
|
688
375
|
"_id",
|
|
689
376
|
"_v",
|
|
690
|
-
"_changed_at"
|
|
691
|
-
"_remote",
|
|
692
|
-
"_remote_rev",
|
|
693
|
-
"_replica_scope"
|
|
377
|
+
"_changed_at"
|
|
694
378
|
]);
|
|
695
379
|
const downcastViews = /* @__PURE__ */ new WeakSet();
|
|
696
380
|
function preserveFields(original, next, preserveUnknown, preserveVersion = true) {
|
|
@@ -753,10 +437,6 @@ function applySchema(col, options) {
|
|
|
753
437
|
if (!stampVersion || doc._v !== void 0) return doc;
|
|
754
438
|
return { ...doc, _v: targetVersion };
|
|
755
439
|
}
|
|
756
|
-
function stampDoc(doc) {
|
|
757
|
-
if (!stampVersion || doc._v !== void 0) return doc;
|
|
758
|
-
return { ...doc, _v: targetVersion };
|
|
759
|
-
}
|
|
760
440
|
function diffUpdate(original, migrated) {
|
|
761
441
|
const $set = {};
|
|
762
442
|
const $unset = {};
|
|
@@ -847,35 +527,6 @@ function applySchema(col, options) {
|
|
|
847
527
|
if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
|
|
848
528
|
return col.insertMany(docs.map(stamp));
|
|
849
529
|
} : 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
530
|
updateOne: wrapWrites ? async (filter, update) => {
|
|
880
531
|
assertSafeUpdate(update);
|
|
881
532
|
return col.updateOne(writableFilter(filter), update);
|
|
@@ -939,6 +590,10 @@ function applySchema(col, options) {
|
|
|
939
590
|
) : col.subscribeAggregate.bind(col)
|
|
940
591
|
};
|
|
941
592
|
}
|
|
593
|
+
function decorateCollection(raw, name, opts, webhook) {
|
|
594
|
+
const reported = webhook ? wrapCollectionWithWebhook(raw, name, webhook) : raw;
|
|
595
|
+
return opts ? applySchema(reported, opts) : reported;
|
|
596
|
+
}
|
|
942
597
|
function detectPlatform() {
|
|
943
598
|
if (typeof navigator !== "undefined" && navigator.product === "ReactNative") {
|
|
944
599
|
return "react-native";
|
|
@@ -1024,7 +679,7 @@ function makePoller(findFn, callback, onError) {
|
|
|
1024
679
|
active = false;
|
|
1025
680
|
};
|
|
1026
681
|
}
|
|
1027
|
-
async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
682
|
+
async function createBrowserDB(dbName, webhook, config, passphrase, migrations) {
|
|
1028
683
|
const workerUrl = new URL("@taladb/web/worker/taladb.worker.js", import.meta.url);
|
|
1029
684
|
const worker = new Worker(workerUrl, { type: "module", name: "taladb" });
|
|
1030
685
|
const proxy = new WorkerProxy(worker);
|
|
@@ -1049,9 +704,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1049
704
|
}
|
|
1050
705
|
};
|
|
1051
706
|
}
|
|
1052
|
-
const syncSchemas = {};
|
|
1053
707
|
function wrapCollection(name, opts) {
|
|
1054
|
-
if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
|
|
1055
708
|
const s = JSON.stringify;
|
|
1056
709
|
const wrapped = {
|
|
1057
710
|
insert: (doc) => proxy.send("insert", { collection: name, docJson: s(doc) }),
|
|
@@ -1062,19 +715,6 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1062
715
|
});
|
|
1063
716
|
return JSON.parse(json);
|
|
1064
717
|
},
|
|
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
718
|
find: async (filter) => {
|
|
1079
719
|
const json = await proxy.send("find", {
|
|
1080
720
|
collection: name,
|
|
@@ -1171,6 +811,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1171
811
|
return JSON.parse(json);
|
|
1172
812
|
},
|
|
1173
813
|
subscribe: (filter, callback, onError) => nudgedPoller(
|
|
814
|
+
name,
|
|
1174
815
|
() => proxy.send("find", {
|
|
1175
816
|
collection: name,
|
|
1176
817
|
filterJson: filter ? s(filter) : "null"
|
|
@@ -1179,6 +820,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1179
820
|
onError
|
|
1180
821
|
),
|
|
1181
822
|
subscribeAggregate: (pipeline, callback, onError) => nudgedPoller(
|
|
823
|
+
name,
|
|
1182
824
|
() => proxy.send("aggregate", {
|
|
1183
825
|
collection: name,
|
|
1184
826
|
pipelineJson: s(pipeline)
|
|
@@ -1187,11 +829,13 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1187
829
|
onError
|
|
1188
830
|
)
|
|
1189
831
|
};
|
|
1190
|
-
return
|
|
832
|
+
return decorateCollection(wrapped, name, opts, webhook);
|
|
1191
833
|
}
|
|
1192
|
-
function nudgedPoller(fetchJson, callback, onError) {
|
|
834
|
+
function nudgedPoller(collection, fetchJson, callback, onError) {
|
|
1193
835
|
let active = true;
|
|
1194
836
|
let lastJson = "";
|
|
837
|
+
let lastGeneration = -1;
|
|
838
|
+
let generationSupported = true;
|
|
1195
839
|
let timer = null;
|
|
1196
840
|
let running = false;
|
|
1197
841
|
let rerun = false;
|
|
@@ -1207,11 +851,23 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1207
851
|
timer = null;
|
|
1208
852
|
}
|
|
1209
853
|
try {
|
|
1210
|
-
|
|
1211
|
-
if (
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
854
|
+
let unchanged = false;
|
|
855
|
+
if (generationSupported) {
|
|
856
|
+
try {
|
|
857
|
+
const gen = await proxy.send("writeGeneration", { collection });
|
|
858
|
+
if (gen === lastGeneration) unchanged = true;
|
|
859
|
+
else lastGeneration = gen;
|
|
860
|
+
} catch {
|
|
861
|
+
generationSupported = false;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (!unchanged) {
|
|
865
|
+
const json = await fetchJson();
|
|
866
|
+
if (!active) return;
|
|
867
|
+
if (json !== lastJson) {
|
|
868
|
+
lastJson = json;
|
|
869
|
+
callback(JSON.parse(json));
|
|
870
|
+
}
|
|
1215
871
|
}
|
|
1216
872
|
} catch (error) {
|
|
1217
873
|
if (active) onError?.(error);
|
|
@@ -1242,8 +898,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1242
898
|
flush: async () => {
|
|
1243
899
|
await proxy.send("flush");
|
|
1244
900
|
},
|
|
1245
|
-
|
|
1246
|
-
flushSync: (timeoutMs = 5e3) => proxy.send("flushSync", { timeoutMs }),
|
|
901
|
+
isPrimary: () => proxy.send("isPrimary"),
|
|
1247
902
|
close: async () => {
|
|
1248
903
|
channel?.close();
|
|
1249
904
|
try {
|
|
@@ -1253,14 +908,7 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1253
908
|
proxy.abort(new Error("taladb worker closed"));
|
|
1254
909
|
}
|
|
1255
910
|
},
|
|
1256
|
-
|
|
1257
|
-
// main thread — a sync pass never blocks rendering, whatever its size.
|
|
1258
|
-
exportChanges: (collections, sinceMs) => proxy.send("exportChangeset", { collectionsJson: JSON.stringify(collections), sinceMs }),
|
|
1259
|
-
importChanges: (changeset) => proxy.send("importChangeset", { changesetJson: changeset }),
|
|
1260
|
-
importChangesValidated: async (changeset, schemasJson) => JSON.parse(await proxy.send("importChangesetValidated", { changesetJson: changeset, schemasJson })),
|
|
1261
|
-
listCollectionNames: async () => JSON.parse(await proxy.send("listCollections")),
|
|
1262
|
-
quarantined: async (collection) => JSON.parse(await proxy.send("quarantined", { collection })),
|
|
1263
|
-
sync: (adapter, options) => runSync(handle, adapter, options, syncSchemas)
|
|
911
|
+
listCollectionNames: async () => JSON.parse(await proxy.send("listCollections"))
|
|
1264
912
|
};
|
|
1265
913
|
if (migrations?.length) {
|
|
1266
914
|
await runMigrations(
|
|
@@ -1274,21 +922,17 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
1274
922
|
}
|
|
1275
923
|
return handle;
|
|
1276
924
|
}
|
|
1277
|
-
async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
925
|
+
async function createNodeDB(dbName, webhook, config, passphrase, migrations) {
|
|
1278
926
|
const native = await import("./node-A4LKRSW5.mjs");
|
|
1279
927
|
const TalaDBNode = native.TalaDbNode ?? native.TalaDBNode;
|
|
1280
928
|
if (!TalaDBNode) throw new Error("@taladb/node loaded but exports no TalaDbNode class \u2014 rebuild the native module");
|
|
1281
929
|
const configJson = config !== void 0 ? JSON.stringify(config) : null;
|
|
1282
930
|
const db = TalaDBNode.open(dbName, configJson, passphrase ?? null);
|
|
1283
|
-
const syncSchemas = {};
|
|
1284
931
|
function wrapCollection(name, opts) {
|
|
1285
|
-
if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
|
|
1286
932
|
const col = db.collection(name);
|
|
1287
933
|
const wrapped = {
|
|
1288
934
|
insert: async (doc) => col.insertAsync ? col.insertAsync(doc) : col.insert(doc),
|
|
1289
935
|
insertMany: async (docs) => col.insertManyAsync ? col.insertManyAsync(docs) : col.insertMany(docs),
|
|
1290
|
-
replaceManyWithIds: async (docs, origin = "local") => col.replaceManyWithIdsAsync ? col.replaceManyWithIdsAsync(docs, origin) : col.replaceManyWithIds(docs, origin),
|
|
1291
|
-
deleteManyWithIds: async (ids, origin = "local") => col.deleteManyWithIdsAsync ? col.deleteManyWithIdsAsync(ids, origin) : col.deleteManyWithIds(ids, origin),
|
|
1292
936
|
find: async (filter) => col.findAsync ? col.findAsync(filter ?? null) : col.find(filter ?? null),
|
|
1293
937
|
findOne: async (filter) => col.findOne(filter) ?? null,
|
|
1294
938
|
updateOne: async (filter, update) => col.updateOneAsync ? col.updateOneAsync(filter, update) : col.updateOne(filter, update),
|
|
@@ -1323,7 +967,7 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
1323
967
|
subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
|
|
1324
968
|
subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
|
|
1325
969
|
};
|
|
1326
|
-
return
|
|
970
|
+
return decorateCollection(wrapped, name, opts, webhook);
|
|
1327
971
|
}
|
|
1328
972
|
const handle = {
|
|
1329
973
|
collection: (name, opts) => wrapCollection(name, opts),
|
|
@@ -1333,14 +977,9 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
1333
977
|
flush: db.flush ? async () => {
|
|
1334
978
|
db.flush();
|
|
1335
979
|
} : void 0,
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
// so older prebuilt binaries fall back to plain importChanges.
|
|
1340
|
-
importChangesValidated: db.importChangesValidated ? async (changeset, schemasJson) => db.importChangesValidated(changeset, schemasJson) : void 0,
|
|
1341
|
-
listCollectionNames: async () => db.listCollectionNames(),
|
|
1342
|
-
quarantined: async (collection) => db.quarantined ? db.quarantined(collection) : [],
|
|
1343
|
-
sync: (adapter, options) => runSync(handle, adapter, options, syncSchemas)
|
|
980
|
+
// One process owns the file — there is no other tab to defer to.
|
|
981
|
+
isPrimary: async () => true,
|
|
982
|
+
listCollectionNames: async () => db.listCollectionNames()
|
|
1344
983
|
};
|
|
1345
984
|
if (migrations?.length) {
|
|
1346
985
|
if (typeof db.userVersion !== "function" || typeof db.setUserVersion !== "function") {
|
|
@@ -1355,7 +994,7 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
1355
994
|
}
|
|
1356
995
|
return handle;
|
|
1357
996
|
}
|
|
1358
|
-
async function createNativeDB(_dbName, migrations) {
|
|
997
|
+
async function createNativeDB(_dbName, webhook, migrations) {
|
|
1359
998
|
const maybeNative = globalThis.__TalaDB__;
|
|
1360
999
|
if (!maybeNative) {
|
|
1361
1000
|
throw new Error(
|
|
@@ -1363,14 +1002,10 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
1363
1002
|
);
|
|
1364
1003
|
}
|
|
1365
1004
|
const native = maybeNative;
|
|
1366
|
-
const syncSchemas = {};
|
|
1367
1005
|
function wrapCollection(name, opts) {
|
|
1368
|
-
if (opts?.syncSchema) syncSchemas[name] = opts.syncSchema;
|
|
1369
1006
|
const wrapped = {
|
|
1370
1007
|
insert: async (doc) => native.insert(name, doc),
|
|
1371
1008
|
insertMany: async (docs) => native.insertMany(name, docs),
|
|
1372
|
-
replaceManyWithIds: async (docs, origin = "local") => native.replaceManyWithIds(name, docs, origin),
|
|
1373
|
-
deleteManyWithIds: async (ids, origin = "local") => native.deleteManyWithIds(name, ids, origin),
|
|
1374
1009
|
find: async (filter) => native.find(name, filter ?? {}),
|
|
1375
1010
|
findOne: async (filter) => native.findOne(name, filter ?? {}),
|
|
1376
1011
|
updateOne: async (filter, update) => native.updateOne(name, filter, update),
|
|
@@ -1417,25 +1052,8 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
1417
1052
|
subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
|
|
1418
1053
|
subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
|
|
1419
1054
|
};
|
|
1420
|
-
return
|
|
1055
|
+
return decorateCollection(wrapped, name, opts, webhook);
|
|
1421
1056
|
}
|
|
1422
|
-
const syncSurface = typeof native.exportChanges === "function" && typeof native.importChanges === "function" && typeof native.listCollectionNames === "function" ? (() => {
|
|
1423
|
-
const handle2 = {
|
|
1424
|
-
collection: (name, opts) => wrapCollection(name, opts),
|
|
1425
|
-
exportChanges: async (collections, sinceMs) => native.exportChanges(collections, sinceMs),
|
|
1426
|
-
importChanges: async (changeset) => native.importChanges(changeset),
|
|
1427
|
-
// Feature-detected: present on 0.9.2+ JSI HostObjects; when absent,
|
|
1428
|
-
// runSync falls back to unvalidated importChanges.
|
|
1429
|
-
importChangesValidated: native.importChangesValidated ? async (changeset, schemasJson) => native.importChangesValidated(changeset, schemasJson) : void 0,
|
|
1430
|
-
listCollectionNames: async () => native.listCollectionNames(),
|
|
1431
|
-
sync: (adapter, options) => runSync(handle2, adapter, options, syncSchemas)
|
|
1432
|
-
};
|
|
1433
|
-
return {
|
|
1434
|
-
exportChanges: handle2.exportChanges,
|
|
1435
|
-
importChanges: handle2.importChanges,
|
|
1436
|
-
sync: handle2.sync
|
|
1437
|
-
};
|
|
1438
|
-
})() : unsupportedSync("react-native");
|
|
1439
1057
|
const handle = {
|
|
1440
1058
|
collection: (name, opts) => wrapCollection(name, opts),
|
|
1441
1059
|
compact: async () => native.compact(),
|
|
@@ -1443,8 +1061,8 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
1443
1061
|
flush: native.flush ? async () => {
|
|
1444
1062
|
native.flush();
|
|
1445
1063
|
} : void 0,
|
|
1446
|
-
|
|
1447
|
-
|
|
1064
|
+
// One process owns the file — there is no other tab to defer to.
|
|
1065
|
+
isPrimary: async () => true
|
|
1448
1066
|
};
|
|
1449
1067
|
if (migrations?.length) {
|
|
1450
1068
|
if (typeof native.userVersion !== "function" || typeof native.setUserVersion !== "function") {
|
|
@@ -1496,35 +1114,44 @@ async function openDB(dbName = "taladb.db", options) {
|
|
|
1496
1114
|
durability: { ...resolvedConfig?.durability, ...options.durability }
|
|
1497
1115
|
};
|
|
1498
1116
|
}
|
|
1117
|
+
const webhook = createWebhookDispatcher(options?.webhook ?? resolvedConfig?.webhook);
|
|
1499
1118
|
const platform = detectPlatform();
|
|
1500
1119
|
const migrations = options?.migrations;
|
|
1120
|
+
let db;
|
|
1501
1121
|
switch (platform) {
|
|
1502
1122
|
case "browser":
|
|
1503
|
-
|
|
1123
|
+
db = await createBrowserDB(dbName, webhook, resolvedConfig, options?.passphrase, migrations);
|
|
1124
|
+
break;
|
|
1504
1125
|
case "react-native":
|
|
1505
1126
|
if (options?.passphrase !== void 0) {
|
|
1506
1127
|
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");
|
|
1507
1128
|
}
|
|
1508
|
-
|
|
1129
|
+
db = await createNativeDB(dbName, webhook, migrations);
|
|
1130
|
+
break;
|
|
1509
1131
|
case "node":
|
|
1510
|
-
|
|
1132
|
+
db = await createNodeDB(dbName, webhook, resolvedConfig, options?.passphrase, migrations);
|
|
1133
|
+
break;
|
|
1511
1134
|
}
|
|
1135
|
+
return webhook ? attachWebhook(db, webhook) : db;
|
|
1136
|
+
}
|
|
1137
|
+
function attachWebhook(db, webhook) {
|
|
1138
|
+
return {
|
|
1139
|
+
...db,
|
|
1140
|
+
webhookStats: () => webhook.stats(),
|
|
1141
|
+
flushWebhook: (timeoutMs) => webhook.flush(timeoutMs),
|
|
1142
|
+
async close() {
|
|
1143
|
+
await webhook.flush();
|
|
1144
|
+
await db.close();
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1512
1147
|
}
|
|
1513
1148
|
export {
|
|
1514
|
-
COVERAGE_COLLECTION,
|
|
1515
|
-
CoverageStore,
|
|
1516
|
-
HttpSyncAdapter,
|
|
1517
|
-
REPLICA_REVISION_FIELD,
|
|
1518
|
-
REPLICA_SCOPE_FIELD,
|
|
1519
|
-
ReplicationCoordinator,
|
|
1520
1149
|
TalaDbValidationError,
|
|
1521
1150
|
applySchema,
|
|
1522
|
-
|
|
1523
|
-
|
|
1151
|
+
createWebhookDispatcher,
|
|
1152
|
+
decorateCollection,
|
|
1524
1153
|
deriveDocId,
|
|
1525
|
-
isAuthoritative,
|
|
1526
1154
|
openDB,
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
runMigrations
|
|
1155
|
+
runMigrations,
|
|
1156
|
+
validateWebhookConfig
|
|
1530
1157
|
};
|