@tinycloud/replica 0.1.0-beta.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/dist/index.js ADDED
@@ -0,0 +1,897 @@
1
+ // src/engine.ts
2
+ import { blake3 } from "@noble/hashes/blake3";
3
+ import { bytesToHex } from "@noble/hashes/utils";
4
+
5
+ // src/errors.ts
6
+ var ReplicaErrorCode = {
7
+ /** Another process holds the sync lease. */
8
+ BUSY: "REPLICA_BUSY",
9
+ /** No replica with this name exists. */
10
+ NOT_FOUND: "REPLICA_NOT_FOUND",
11
+ /** The replica exists but its stored configuration differs from the request. */
12
+ CONFIG_MISMATCH: "REPLICA_CONFIG_MISMATCH",
13
+ /** The runtime has no supported durable store (e.g. Node < 22.13). */
14
+ RUNTIME_UNSUPPORTED: "RUNTIME_UNSUPPORTED",
15
+ /** The local store failed (I/O, corruption, schema). */
16
+ STORAGE_ERROR: "STORAGE_ERROR",
17
+ /** The local disk is full. */
18
+ STORAGE_FULL: "STORAGE_FULL",
19
+ /** The key is outside the replica's prefix. */
20
+ NOT_COVERED: "NOT_COVERED",
21
+ /** Syncing the secrets space or the vault namespace needs an explicit opt-in. */
22
+ SECRETS_OPT_IN_REQUIRED: "SECRETS_OPT_IN_REQUIRED",
23
+ /** No grant is installed, or none covers the requested scope. */
24
+ GRANT_MISSING: "GRANT_MISSING",
25
+ /** The grant bytes are not a verifiable UCAN, or its signature is wrong. */
26
+ GRANT_INVALID: "GRANT_INVALID",
27
+ /** SIWE/CACAO and other non-UCAN grants cannot be used for a replica. */
28
+ GRANT_FORMAT_UNSUPPORTED: "GRANT_FORMAT_UNSUPPORTED",
29
+ /** The grant was issued to a different device. */
30
+ GRANT_AUDIENCE_MISMATCH: "GRANT_AUDIENCE_MISMATCH",
31
+ /** The grant lacks `tinycloud.kv/sync` or `tinycloud.kv/get` on the prefix. */
32
+ GRANT_NOT_COVERING: "GRANT_NOT_COVERING",
33
+ GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID",
34
+ GRANT_EXPIRED: "GRANT_EXPIRED",
35
+ GRANT_REVOKED: "GRANT_REVOKED",
36
+ /** The node refused the invocation for a reason other than revocation. */
37
+ GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED",
38
+ /** The node refused the retention grant. */
39
+ RETENTION_GRANT_REFUSED: "RETENTION_GRANT_REFUSED",
40
+ /** The node could not be reached. */
41
+ NETWORK_ERROR: "NETWORK_ERROR",
42
+ /** The node answered with an unexpected status. */
43
+ NODE_ERROR: "NODE_ERROR",
44
+ /** The node's answer violates the wire contract. */
45
+ PROTOCOL_ERROR: "PROTOCOL_ERROR",
46
+ /** The node asked for a fresh bootstrap (410). Handled inside sync. */
47
+ RESET_REQUIRED: "RESET_REQUIRED",
48
+ /** The feed now comes from a different node than the one pinned. */
49
+ SOURCE_CHANGED: "SOURCE_CHANGED",
50
+ /** The feed delivered a key outside the replica's prefix. */
51
+ SCOPE_VIOLATION: "SCOPE_VIOLATION",
52
+ /** Fetched bytes do not hash to the ETag the node attested for them. */
53
+ CONTENT_MISMATCH: "CONTENT_MISMATCH",
54
+ /** A stored blob no longer hashes to its name. */
55
+ INTEGRITY_ERROR: "INTEGRITY_ERROR"
56
+ };
57
+ var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
58
+ var ReplicaError = class extends Error {
59
+ [REPLICA_ERROR] = true;
60
+ code;
61
+ detail;
62
+ constructor(code, message, detail, options) {
63
+ super(message, options);
64
+ this.name = "ReplicaError";
65
+ this.code = code;
66
+ if (detail !== void 0) this.detail = detail;
67
+ }
68
+ };
69
+ function isReplicaError(error, code) {
70
+ return typeof error === "object" && error !== null && error[REPLICA_ERROR] === true && (code === void 0 || error.code === code);
71
+ }
72
+
73
+ // src/scope.ts
74
+ function kvPrefixCovers(prefix, key) {
75
+ if (prefix === "") return true;
76
+ if (prefix.endsWith("/")) return key.startsWith(prefix);
77
+ return key === prefix || key.startsWith(`${prefix}/`);
78
+ }
79
+ function requiresSecretsOptIn(space, prefix) {
80
+ if (space === "secrets" || space.endsWith(":secrets")) return true;
81
+ const firstSegment = prefix.split("/", 1)[0];
82
+ return firstSegment === "vault";
83
+ }
84
+ function hashFromEtag(etag) {
85
+ const match = /^"?blake3-([0-9a-f]{64})"?$/.exec(etag);
86
+ return match?.[1];
87
+ }
88
+ var RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
89
+ function isInstant(value) {
90
+ return RFC3339.test(value) && Number.isFinite(Date.parse(value));
91
+ }
92
+ function minIso(a, bSeconds) {
93
+ if (bSeconds === null) return a;
94
+ const b = new Date(bSeconds * 1e3).toISOString();
95
+ if (a === null) return b;
96
+ return Date.parse(a) <= Date.parse(b) ? a : b;
97
+ }
98
+ function maxIso(a, bSeconds) {
99
+ if (bSeconds === null) return a;
100
+ const b = new Date(bSeconds * 1e3).toISOString();
101
+ if (a === null) return b;
102
+ return Date.parse(a) >= Date.parse(b) ? a : b;
103
+ }
104
+ function effectiveAuthority(input) {
105
+ const notBefore = maxIso(input.window?.notBefore ?? null, input.grant?.notBefore ?? null);
106
+ const expiresAt = minIso(input.window?.expiresAt ?? null, input.grant?.expiresAt ?? null);
107
+ const retainUntil = input.policy === "retainAfterExpiry" ? input.window?.retainUntil ?? null : null;
108
+ let state = "valid";
109
+ if (input.revoked !== null) state = "revoked";
110
+ else if (notBefore !== null && input.now < Date.parse(notBefore)) state = "not-yet-valid";
111
+ else if (expiresAt !== null && input.now >= Date.parse(expiresAt)) state = "expired";
112
+ return { state, notBefore, expiresAt, retainUntil, retentionRevoked: (input.retentionRevoked ?? null) !== null };
113
+ }
114
+ function assertReadable(authority, now) {
115
+ switch (authority.state) {
116
+ case "valid":
117
+ return "valid";
118
+ case "revoked":
119
+ throw new ReplicaError(ReplicaErrorCode.GRANT_REVOKED, "The replica's grant was revoked; local reads are blocked.");
120
+ case "not-yet-valid":
121
+ throw new ReplicaError(
122
+ ReplicaErrorCode.GRANT_NOT_YET_VALID,
123
+ `The replica's grant is not valid before ${authority.notBefore}.`
124
+ );
125
+ case "expired":
126
+ if (authority.retentionRevoked) {
127
+ throw new ReplicaError(
128
+ ReplicaErrorCode.GRANT_REVOKED,
129
+ `The replica's grant expired at ${authority.expiresAt} and its retention grant was revoked; local reads are blocked.`
130
+ );
131
+ }
132
+ if (authority.retainUntil !== null && now < Date.parse(authority.retainUntil)) return "expired";
133
+ throw new ReplicaError(
134
+ ReplicaErrorCode.GRANT_EXPIRED,
135
+ authority.retainUntil === null ? `The replica's grant expired at ${authority.expiresAt}; local reads are blocked.` : `The replica's retention ended at ${authority.retainUntil}; local reads are blocked.`
136
+ );
137
+ }
138
+ }
139
+
140
+ // src/engine.ts
141
+ var DEFAULT_PAGE_LIMIT = 500;
142
+ var DEFAULT_LEASE_TTL_MS = 12e4;
143
+ var FETCH_CHUNK = 100;
144
+ var REPAIR_BATCH = 100;
145
+ var RETENTION_REVOKED = /* @__PURE__ */ new Set(["retention-grant-revoked", "retention-grant-ancestor-revoked"]);
146
+ var PENDING_REFUSALS = /* @__PURE__ */ new Set([
147
+ ReplicaErrorCode.GRANT_UNAUTHORIZED,
148
+ ReplicaErrorCode.GRANT_EXPIRED,
149
+ ReplicaErrorCode.GRANT_NOT_YET_VALID
150
+ ]);
151
+ function contentHash(bytes) {
152
+ return bytesToHex(blake3(bytes));
153
+ }
154
+ function sameEtag(a, b) {
155
+ return a.replace(/^"|"$/g, "") === b.replace(/^"|"$/g, "");
156
+ }
157
+ function lastErrorOf(error, at) {
158
+ return {
159
+ at: new Date(at).toISOString(),
160
+ code: isReplicaError(error) ? error.code : "ERROR",
161
+ message: error instanceof Error ? error.message : String(error)
162
+ };
163
+ }
164
+ var Replica = class {
165
+ #store;
166
+ #transportFor;
167
+ #now;
168
+ #leaseTtlMs;
169
+ constructor(options) {
170
+ this.#store = options.store;
171
+ const transport = options.transport;
172
+ this.#transportFor = options.transportFor ?? (transport === void 0 ? void 0 : () => transport);
173
+ this.#now = options.now ?? Date.now;
174
+ this.#leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
175
+ }
176
+ async #state() {
177
+ const state = await this.#store.open();
178
+ if (state === null) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica has not been created.");
179
+ return state;
180
+ }
181
+ /** Throws unless local reads are allowed now; returns the meta every read reports. */
182
+ #readMeta(state) {
183
+ const now = this.#now();
184
+ const authority = assertReadable(
185
+ effectiveAuthority({
186
+ window: state.authority,
187
+ grant: state.grant,
188
+ revoked: state.revoked,
189
+ retentionRevoked: state.retentionRevoked,
190
+ policy: state.config.localReadPolicy,
191
+ now
192
+ }),
193
+ now
194
+ );
195
+ return {
196
+ consistency: "observed",
197
+ source: { host: state.config.host, nodeDid: state.nodeDid, space: state.config.space, prefix: state.config.prefix },
198
+ asOf: state.lastSyncAt,
199
+ coverage: state.coverage,
200
+ authority
201
+ };
202
+ }
203
+ /**
204
+ * Authority again, from the store's current state and the clock, once the
205
+ * read has its data: a revocation (and its purge) or an expiry that landed
206
+ * while the read ran wins, and the caller gets the error, not the data.
207
+ */
208
+ async #confirmedMeta() {
209
+ return this.#readMeta(await this.#state());
210
+ }
211
+ async get(key, options = {}) {
212
+ const state = await this.#state();
213
+ this.#readMeta(state);
214
+ if (!kvPrefixCovers(state.config.prefix, key)) return { status: "not_covered", key, meta: await this.#confirmedMeta() };
215
+ for (let attempt = 0; ; attempt += 1) {
216
+ const entry = await this.#store.get(key);
217
+ if (entry === void 0) {
218
+ const status = state.coverage === "complete" ? "absent" : "coverage_incomplete";
219
+ return { status, key, meta: await this.#confirmedMeta() };
220
+ }
221
+ if (entry.deleted) return { status: "deleted", key, meta: await this.#confirmedMeta() };
222
+ if (!entry.content) {
223
+ return { status: "content_missing", key, etag: entry.etag, metadata: entry.metadata, meta: await this.#confirmedMeta() };
224
+ }
225
+ const value = await this.#store.readContent(entry.hash);
226
+ const intact = value !== void 0 && (options.verify === false || contentHash(value) === entry.hash);
227
+ if (intact) {
228
+ return { status: "present", key, value, etag: entry.etag, metadata: entry.metadata, meta: await this.#confirmedMeta() };
229
+ }
230
+ if (attempt === 0) continue;
231
+ await this.#confirmedMeta();
232
+ throw new ReplicaError(
233
+ ReplicaErrorCode.INTEGRITY_ERROR,
234
+ value === void 0 ? `The stored content for ${JSON.stringify(key)} is missing.` : `The stored content for ${JSON.stringify(key)} does not match its hash.`,
235
+ { key }
236
+ );
237
+ }
238
+ }
239
+ /** Live keys under `prefix` (a plain string prefix within the replica's scope). */
240
+ async list(options = {}) {
241
+ const state = await this.#state();
242
+ this.#readMeta(state);
243
+ const scope = state.config.prefix;
244
+ const prefix = options.prefix ?? "";
245
+ if (prefix !== "" && !scope.startsWith(prefix) && !kvPrefixCovers(scope, prefix)) {
246
+ throw new ReplicaError(
247
+ ReplicaErrorCode.NOT_COVERED,
248
+ `${JSON.stringify(prefix)} is outside the replica's prefix ${JSON.stringify(scope)}.`
249
+ );
250
+ }
251
+ const rows = await this.#store.list(options);
252
+ const entries = [];
253
+ for (const row of rows) {
254
+ if (row.deleted) continue;
255
+ entries.push({ key: row.key, etag: row.etag, metadata: row.metadata, content: row.content });
256
+ }
257
+ return { entries, meta: await this.#confirmedMeta() };
258
+ }
259
+ async status() {
260
+ await this.#state();
261
+ return this.#store.status(this.#now());
262
+ }
263
+ /** Clear entries, blobs, cursor and the source pin; keep the configuration and grant. */
264
+ async reset(reason) {
265
+ await this.#state();
266
+ const lease = await this.#store.acquireSyncLease(this.#leaseTtlMs);
267
+ if (lease === null) throw new ReplicaError(ReplicaErrorCode.BUSY, "Another process is syncing this replica.");
268
+ try {
269
+ await this.#store.reset(lease, reason);
270
+ } finally {
271
+ await this.#store.releaseLease(lease);
272
+ }
273
+ }
274
+ /**
275
+ * Sync under the pending grant if there is one, else the active grant. If
276
+ * the node refuses a pending grant before anything committed under it, the
277
+ * active grant serves: a revoked pending grant is discarded, any other
278
+ * refusal is recorded on it. A revocation of the grant serving the replica,
279
+ * learned anywhere in the sync, purges the replica before this returns.
280
+ */
281
+ async sync(options = {}) {
282
+ const transportFor = this.#transportFor;
283
+ if (transportFor === void 0) throw new Error("Replica.sync needs a transport");
284
+ const initial = await this.#state();
285
+ if (initial.revoked !== null) {
286
+ throw new ReplicaError(ReplicaErrorCode.GRANT_REVOKED, `The replica's grant was revoked: ${initial.revoked}`);
287
+ }
288
+ const candidates = [initial.pendingGrant, initial.grant].filter((grant) => grant !== null);
289
+ if (candidates.length === 0) throw new ReplicaError(ReplicaErrorCode.GRANT_MISSING, "No grant is installed for this replica.");
290
+ const lease = await this.#store.acquireSyncLease(this.#leaseTtlMs);
291
+ if (lease === null) throw new ReplicaError(ReplicaErrorCode.BUSY, "Another process is syncing this replica.");
292
+ try {
293
+ for (const [index, grant] of candidates.entries()) {
294
+ const pending = grant.cid === initial.pendingGrant?.cid;
295
+ const attempt = {
296
+ lease,
297
+ grant,
298
+ transport: transportFor(grant),
299
+ promote: pending ? grant : null,
300
+ signal: options.signal
301
+ };
302
+ try {
303
+ return await this.#syncUnder(attempt, options.limit ?? DEFAULT_PAGE_LIMIT);
304
+ } catch (error) {
305
+ const canFallBack = pending && attempt.promote !== null && index < candidates.length - 1;
306
+ if (canFallBack && isReplicaError(error, ReplicaErrorCode.GRANT_REVOKED)) {
307
+ await this.#store.discardPendingGrant(grant.cid, error.message);
308
+ continue;
309
+ }
310
+ if (canFallBack && isReplicaError(error) && PENDING_REFUSALS.has(error.code)) {
311
+ await this.#store.recordPendingGrantError(grant.cid, lastErrorOf(error, this.#now()));
312
+ continue;
313
+ }
314
+ throw error;
315
+ }
316
+ }
317
+ throw new Error("unreachable: the last candidate either returns or throws");
318
+ } catch (error) {
319
+ await this.#learn(error);
320
+ throw error;
321
+ } finally {
322
+ await this.#store.releaseLease(lease).catch(() => void 0);
323
+ }
324
+ }
325
+ /** Persist what a failed sync taught us before the error reaches the caller. */
326
+ async #learn(error) {
327
+ if (isReplicaError(error, ReplicaErrorCode.GRANT_REVOKED)) {
328
+ if ((await this.#store.open())?.revoked != null) return;
329
+ await this.#store.markRevoked(error.message);
330
+ return;
331
+ }
332
+ if (isReplicaError(error, ReplicaErrorCode.RETENTION_GRANT_REFUSED)) {
333
+ const cid = error.detail?.retentionGrantCid;
334
+ const reason = error.detail?.reason;
335
+ if (typeof cid === "string" && typeof reason === "string" && RETENTION_REVOKED.has(reason)) {
336
+ await this.#store.markRetentionRevoked(cid, error.message);
337
+ return;
338
+ }
339
+ }
340
+ await this.#store.recordError(lastErrorOf(error, this.#now())).catch(() => void 0);
341
+ }
342
+ /** The effective window for `grant` under `window`; throws unless it holds now (no retention for syncing). */
343
+ #syncWindow(window, grant) {
344
+ const now = this.#now();
345
+ const authority = effectiveAuthority({ window, grant, revoked: null, policy: "whileGrantValid", now });
346
+ if (authority.state !== "valid") assertReadable({ ...authority, retainUntil: null }, now);
347
+ return { notBefore: authority.notBefore, expiresAt: authority.expiresAt };
348
+ }
349
+ async #syncUnder(attempt, limit) {
350
+ const state = await this.#state();
351
+ const { config } = state;
352
+ this.#syncWindow(attempt.promote === null ? state.authority : null, attempt.grant);
353
+ const report = {
354
+ pages: 0,
355
+ changes: 0,
356
+ deleted: 0,
357
+ fetched: 0,
358
+ contentMissing: 0,
359
+ repaired: 0,
360
+ resets: 0,
361
+ coverage: state.coverage,
362
+ cursorAdvanced: false,
363
+ promotedGrant: false,
364
+ blobsCollected: 0
365
+ };
366
+ let cursor = state.cursor;
367
+ let pinned = state.nodeDid;
368
+ let coverage = state.coverage;
369
+ for (; ; ) {
370
+ let page;
371
+ const retentionGrant = config.retentionGrantCid;
372
+ try {
373
+ page = await attempt.transport.syncPage({
374
+ prefix: config.prefix,
375
+ ...cursor === null ? {} : { cursor },
376
+ limit,
377
+ ...retentionGrant === null ? {} : { retentionGrant },
378
+ ...attempt.signal === void 0 ? {} : { signal: attempt.signal }
379
+ });
380
+ } catch (error) {
381
+ if (isReplicaError(error, ReplicaErrorCode.RESET_REQUIRED) && report.resets === 0) {
382
+ const reason = typeof error.detail?.reason === "string" ? error.detail.reason : "reset-required";
383
+ await this.#store.reset(attempt.lease, `node: ${reason}`, { keepSource: true });
384
+ report.resets += 1;
385
+ cursor = null;
386
+ coverage = "empty";
387
+ continue;
388
+ }
389
+ if (isReplicaError(error, ReplicaErrorCode.RETENTION_GRANT_REFUSED) && retentionGrant !== null) {
390
+ throw new ReplicaError(error.code, error.message, { ...error.detail, retentionGrantCid: retentionGrant }, { cause: error });
391
+ }
392
+ throw error;
393
+ }
394
+ this.#checkPage(page, config.space, config.prefix, pinned);
395
+ const window = this.#syncWindow(page.authority, attempt.grant);
396
+ const verified = await this.#verify(page, attempt);
397
+ coverage = page.more ? coverage === "complete" ? "complete" : "bootstrapping" : "complete";
398
+ const at = new Date(this.#now()).toISOString();
399
+ await this.#store.applyPage(attempt.lease, {
400
+ changes: verified.changes,
401
+ blobs: verified.blobs,
402
+ cursor: page.cursor,
403
+ source: page.source,
404
+ authority: page.authority,
405
+ coverage,
406
+ at,
407
+ window,
408
+ complete: !page.more,
409
+ promoteGrant: attempt.promote
410
+ });
411
+ if (attempt.promote !== null) {
412
+ report.promotedGrant = true;
413
+ attempt.promote = null;
414
+ }
415
+ report.pages += 1;
416
+ report.changes += page.changes.length;
417
+ report.deleted += page.changes.filter((change) => change.deleted).length;
418
+ report.fetched += verified.fetched;
419
+ if (page.cursor !== cursor) report.cursorAdvanced = true;
420
+ cursor = page.cursor;
421
+ pinned = page.source.nodeDid;
422
+ await this.#store.renewLease(attempt.lease, this.#leaseTtlMs);
423
+ if (!page.more) break;
424
+ }
425
+ report.repaired = await this.#repair(attempt, cursor, pinned, coverage);
426
+ report.blobsCollected = await this.#store.collectGarbage(attempt.lease);
427
+ report.coverage = coverage;
428
+ report.contentMissing = (await this.#store.pendingRepairs(Number.MAX_SAFE_INTEGER)).length;
429
+ return report;
430
+ }
431
+ #checkPage(page, space, prefix, pinnedNodeDid) {
432
+ if (page.source.space !== space || page.source.prefix !== prefix) {
433
+ throw new ReplicaError(
434
+ ReplicaErrorCode.PROTOCOL_ERROR,
435
+ `The node answered for ${page.source.space}/${page.source.prefix}, not ${space}/${prefix}.`
436
+ );
437
+ }
438
+ for (const [name, value] of Object.entries(page.authority)) {
439
+ if (value !== null && (typeof value !== "string" || !isInstant(value))) {
440
+ throw new ReplicaError(
441
+ ReplicaErrorCode.PROTOCOL_ERROR,
442
+ `The node attested authority.${name} = ${JSON.stringify(value)}, which is not a timestamp; nothing was committed.`
443
+ );
444
+ }
445
+ }
446
+ if (pinnedNodeDid !== null && page.source.nodeDid !== pinnedNodeDid) {
447
+ throw new ReplicaError(
448
+ ReplicaErrorCode.SOURCE_CHANGED,
449
+ `The feed now comes from ${page.source.nodeDid}, not the pinned source ${pinnedNodeDid}. Reset the replica to follow a new source.`,
450
+ { pinned: pinnedNodeDid, received: page.source.nodeDid }
451
+ );
452
+ }
453
+ for (const change of page.changes) {
454
+ if (!kvPrefixCovers(prefix, change.key)) {
455
+ throw new ReplicaError(
456
+ ReplicaErrorCode.SCOPE_VIOLATION,
457
+ `The feed delivered ${JSON.stringify(change.key)}, outside the prefix ${JSON.stringify(prefix)}.`,
458
+ { key: change.key }
459
+ );
460
+ }
461
+ }
462
+ }
463
+ async #verify(page, attempt) {
464
+ const blobs = /* @__PURE__ */ new Map();
465
+ const changes = [];
466
+ const candidates = [];
467
+ for (const change of page.changes) {
468
+ if (change.deleted) {
469
+ changes.push({ key: change.key, deleted: true });
470
+ continue;
471
+ }
472
+ const hash = hashFromEtag(change.etag);
473
+ if (hash === void 0) {
474
+ throw new ReplicaError(ReplicaErrorCode.PROTOCOL_ERROR, `The feed sent an ETag that is not blake3: ${change.etag}`, {
475
+ key: change.key
476
+ });
477
+ }
478
+ const previous = await this.#store.get(change.key);
479
+ const unchanged = previous !== void 0 && !previous.deleted && previous.etag === change.etag && previous.content;
480
+ changes.push({ key: change.key, deleted: false, etag: change.etag, hash, metadata: change.metadata, content: unchanged });
481
+ if (!unchanged) candidates.push({ index: changes.length - 1, key: change.key, etag: change.etag, hash });
482
+ }
483
+ const held = await this.#store.hasContent([...new Set(candidates.map((candidate) => candidate.hash))]);
484
+ const needed = candidates.filter((candidate) => {
485
+ if (!held.has(candidate.hash)) return true;
486
+ changes[candidate.index].content = true;
487
+ return false;
488
+ });
489
+ let fetched2 = 0;
490
+ for (let start = 0; start < needed.length; start += FETCH_CHUNK) {
491
+ const chunk = needed.slice(start, start + FETCH_CHUNK);
492
+ const contents = await attempt.transport.fetchContent(
493
+ chunk.map((candidate) => candidate.key),
494
+ attempt.signal === void 0 ? void 0 : { signal: attempt.signal }
495
+ );
496
+ for (const candidate of chunk) {
497
+ const ok = acceptContent(candidate, contents.get(candidate.key));
498
+ if (ok === void 0) continue;
499
+ blobs.set(candidate.hash, ok);
500
+ changes[candidate.index].content = true;
501
+ fetched2 += 1;
502
+ }
503
+ await this.#store.renewLease(attempt.lease, this.#leaseTtlMs);
504
+ }
505
+ return { changes, blobs, fetched: fetched2 };
506
+ }
507
+ async #repair(attempt, cursor, nodeDid, coverage) {
508
+ const pending = await this.#store.pendingRepairs(Number.MAX_SAFE_INTEGER);
509
+ let repaired = 0;
510
+ for (let start = 0; start < pending.length; start += REPAIR_BATCH) {
511
+ const chunk = pending.slice(start, start + REPAIR_BATCH);
512
+ const contents = await attempt.transport.fetchContent(
513
+ chunk.map((entry) => entry.key),
514
+ attempt.signal === void 0 ? void 0 : { signal: attempt.signal }
515
+ );
516
+ const blobs = /* @__PURE__ */ new Map();
517
+ const changes = [];
518
+ for (const entry of chunk) {
519
+ const bytes = acceptContent(entry, contents.get(entry.key));
520
+ if (bytes === void 0) continue;
521
+ blobs.set(entry.hash, bytes);
522
+ changes.push({ ...entry, content: true });
523
+ }
524
+ await this.#store.renewLease(attempt.lease, this.#leaseTtlMs);
525
+ if (changes.length === 0) continue;
526
+ const state = await this.#state();
527
+ await this.#store.applyPage(attempt.lease, {
528
+ changes,
529
+ blobs,
530
+ cursor,
531
+ source: { nodeDid: nodeDid ?? "", space: state.config.space, prefix: state.config.prefix },
532
+ authority: null,
533
+ coverage,
534
+ at: new Date(this.#now()).toISOString(),
535
+ window: this.#syncWindow(state.authority, attempt.grant),
536
+ complete: false,
537
+ promoteGrant: null
538
+ });
539
+ repaired += changes.length;
540
+ }
541
+ return repaired;
542
+ }
543
+ };
544
+ function acceptContent(expected, fetched2) {
545
+ if (fetched2 === void 0 || "missing" in fetched2) return void 0;
546
+ if (contentHash(fetched2.bytes) === expected.hash) return fetched2.bytes;
547
+ if (sameEtag(fetched2.etag, expected.etag)) {
548
+ throw new ReplicaError(
549
+ ReplicaErrorCode.CONTENT_MISMATCH,
550
+ `The content of ${JSON.stringify(expected.key)} does not hash to its ETag ${expected.etag}.`,
551
+ { key: expected.key }
552
+ );
553
+ }
554
+ return void 0;
555
+ }
556
+
557
+ // src/grant.ts
558
+ import { ed25519 } from "@noble/curves/ed25519";
559
+ import { blake3 as blake32 } from "@noble/hashes/blake3";
560
+ var BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
561
+ var BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
562
+ function base58Decode(text) {
563
+ let value = 0n;
564
+ for (const char of text) {
565
+ const digit = BASE58.indexOf(char);
566
+ if (digit < 0) throw new Error("invalid base58");
567
+ value = value * 58n + BigInt(digit);
568
+ }
569
+ const bytes = [];
570
+ while (value > 0n) {
571
+ bytes.unshift(Number(value & 0xffn));
572
+ value >>= 8n;
573
+ }
574
+ for (const char of text) {
575
+ if (char !== "1") break;
576
+ bytes.unshift(0);
577
+ }
578
+ return Uint8Array.from(bytes);
579
+ }
580
+ function base32Encode(bytes) {
581
+ let out = "";
582
+ let buffer = 0;
583
+ let bits = 0;
584
+ for (const byte of bytes) {
585
+ buffer = buffer << 8 | byte;
586
+ bits += 8;
587
+ while (bits >= 5) {
588
+ out += BASE32[buffer >>> bits - 5 & 31];
589
+ bits -= 5;
590
+ }
591
+ }
592
+ if (bits > 0) out += BASE32[buffer << 5 - bits & 31];
593
+ return out;
594
+ }
595
+ function base64UrlDecode(text) {
596
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error("invalid base64url");
597
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4);
598
+ const binary = globalThis.atob(padded);
599
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
600
+ }
601
+ function ucanCid(jwt) {
602
+ const digest = blake32(new TextEncoder().encode(jwt));
603
+ const bytes = new Uint8Array(4 + digest.length);
604
+ bytes.set([1, 85, 30, 32]);
605
+ bytes.set(digest, 4);
606
+ return `b${base32Encode(bytes)}`;
607
+ }
608
+ function ed25519KeyOfDid(did) {
609
+ const bare = did.split("#", 1)[0];
610
+ if (!bare.startsWith("did:key:z")) return void 0;
611
+ let decoded;
612
+ try {
613
+ decoded = base58Decode(bare.slice("did:key:z".length));
614
+ } catch {
615
+ return void 0;
616
+ }
617
+ if (decoded.length !== 34 || decoded[0] !== 237 || decoded[1] !== 1) return void 0;
618
+ return decoded.subarray(2);
619
+ }
620
+ function invalid(message) {
621
+ return new ReplicaError(ReplicaErrorCode.GRANT_INVALID, message);
622
+ }
623
+ function parseUcanGrant(input) {
624
+ const raw = typeof input === "string" ? input : new TextDecoder().decode(input);
625
+ const jwt = raw.trim().replace(/^Bearer\s+/i, "");
626
+ const parts = jwt.split(".");
627
+ if (parts.length !== 3) {
628
+ throw new ReplicaError(
629
+ ReplicaErrorCode.GRANT_FORMAT_UNSUPPORTED,
630
+ "The grant is not a compact UCAN. SIWE/CACAO grants cannot back a replica; ask the owner for a device grant (tc auth grant)."
631
+ );
632
+ }
633
+ const [headerPart, payloadPart, signaturePart] = parts;
634
+ let header;
635
+ let payload;
636
+ let signature;
637
+ try {
638
+ header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerPart)));
639
+ payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadPart)));
640
+ signature = base64UrlDecode(signaturePart);
641
+ } catch {
642
+ throw invalid("The grant is not well-formed base64url JSON.");
643
+ }
644
+ if (header?.alg !== "EdDSA") {
645
+ throw new ReplicaError(
646
+ ReplicaErrorCode.GRANT_FORMAT_UNSUPPORTED,
647
+ `The grant is signed with ${String(header?.alg)}; a replica needs an Ed25519 (EdDSA) device grant.`
648
+ );
649
+ }
650
+ const issuer = payload?.iss;
651
+ const audience = payload?.aud;
652
+ if (typeof issuer !== "string" || typeof audience !== "string" || audience.length === 0) {
653
+ throw invalid("The grant has no signed issuer or audience.");
654
+ }
655
+ const key = ed25519KeyOfDid(issuer);
656
+ if (key === void 0) {
657
+ throw new ReplicaError(
658
+ ReplicaErrorCode.GRANT_FORMAT_UNSUPPORTED,
659
+ `The grant issuer ${issuer} is not an Ed25519 did:key.`
660
+ );
661
+ }
662
+ let verified = false;
663
+ try {
664
+ verified = ed25519.verify(signature, new TextEncoder().encode(`${headerPart}.${payloadPart}`), key);
665
+ } catch {
666
+ verified = false;
667
+ }
668
+ if (!verified) throw invalid("The grant's signature does not verify against its issuer.");
669
+ const att = payload.att;
670
+ if (att === null || typeof att !== "object" || Array.isArray(att)) throw invalid("The grant has no signed attenuation.");
671
+ for (const abilities of Object.values(att)) {
672
+ if (abilities === null || typeof abilities !== "object" || Array.isArray(abilities)) {
673
+ throw invalid("The grant has a malformed signed attenuation.");
674
+ }
675
+ }
676
+ const prf = payload.prf ?? [];
677
+ if (!Array.isArray(prf) || prf.some((proof) => typeof proof !== "string")) throw invalid("The grant has malformed signed proofs.");
678
+ const window = (name) => {
679
+ const value = payload[name];
680
+ if (value === void 0 || value === null) return null;
681
+ if (typeof value !== "number" || !Number.isFinite(value)) throw invalid(`The grant's signed ${name} is not a number.`);
682
+ return value;
683
+ };
684
+ return {
685
+ cid: ucanCid(jwt),
686
+ bytes: new TextEncoder().encode(jwt),
687
+ jwt,
688
+ issuer,
689
+ audience,
690
+ notBefore: window("nbf"),
691
+ expiresAt: window("exp"),
692
+ att,
693
+ prf
694
+ };
695
+ }
696
+ function kvPathIn(resource, space) {
697
+ for (const base of [`${space}/kv/`, `tinycloud://${space}/kv/`]) {
698
+ if (resource.startsWith(base)) return resource.slice(base.length);
699
+ }
700
+ return void 0;
701
+ }
702
+ function grantCovers(grant, space, prefix, ability) {
703
+ for (const [resource, abilities] of Object.entries(grant.att)) {
704
+ const path = kvPathIn(resource, space);
705
+ if (path === void 0 || !kvPrefixCovers(path, prefix)) continue;
706
+ if (ability in abilities) return true;
707
+ if (ability !== "tinycloud.kv/sync" && ability !== "tinycloud.kv/retain" && "tinycloud.kv/*" in abilities) return true;
708
+ }
709
+ return false;
710
+ }
711
+ function syncGrantSpaces(grant) {
712
+ const spaces = /* @__PURE__ */ new Set();
713
+ for (const [resource, abilities] of Object.entries(grant.att)) {
714
+ if (!("tinycloud.kv/sync" in abilities)) continue;
715
+ const match = /^(?:tinycloud:\/\/)?(tinycloud:[^/]+)\/kv\//.exec(resource);
716
+ if (match) spaces.add(match[1]);
717
+ }
718
+ return [...spaces];
719
+ }
720
+ function assertGrantInstallable(grant, input) {
721
+ if (grant.audience.split("#", 1)[0] !== input.deviceDid.split("#", 1)[0]) {
722
+ throw new ReplicaError(
723
+ ReplicaErrorCode.GRANT_AUDIENCE_MISMATCH,
724
+ `The grant was issued to ${grant.audience}, not this device (${input.deviceDid}).`
725
+ );
726
+ }
727
+ for (const ability of ["tinycloud.kv/sync", "tinycloud.kv/get"]) {
728
+ if (!grantCovers(grant, input.space, input.prefix, ability)) {
729
+ throw new ReplicaError(
730
+ ReplicaErrorCode.GRANT_NOT_COVERING,
731
+ `The grant does not carry ${ability} on ${input.space}/kv/${input.prefix}.`,
732
+ { ability }
733
+ );
734
+ }
735
+ }
736
+ const nowSeconds = input.now / 1e3;
737
+ if (grant.notBefore !== null && nowSeconds < grant.notBefore) {
738
+ throw new ReplicaError(
739
+ ReplicaErrorCode.GRANT_NOT_YET_VALID,
740
+ `The grant is not valid before ${new Date(grant.notBefore * 1e3).toISOString()}.`
741
+ );
742
+ }
743
+ if (grant.expiresAt !== null && nowSeconds >= grant.expiresAt) {
744
+ throw new ReplicaError(
745
+ ReplicaErrorCode.GRANT_EXPIRED,
746
+ `The grant expired at ${new Date(grant.expiresAt * 1e3).toISOString()}.`
747
+ );
748
+ }
749
+ }
750
+
751
+ // src/transport.ts
752
+ var REVOKED_CODES = /* @__PURE__ */ new Set(["AUTH_DELEGATION_REVOKED", "AUTH_DELEGATION_ANCESTOR_REVOKED"]);
753
+ var REVOKED_BODY = /^(?:Invalid invocation: )?(?:delegation-revoked: [A-Za-z0-9]+|delegation-ancestor-revoked: ancestor=[A-Za-z0-9]+ (?:invoked|parent)=[A-Za-z0-9]+)$/;
754
+ function isRevocation(error, status, request) {
755
+ if (REVOKED_CODES.has(error.code)) return true;
756
+ if (status !== 401 || request === void 0) return false;
757
+ const lead = `${request}: 401 - `;
758
+ return error.message.startsWith(lead) && REVOKED_BODY.test(error.message.slice(lead.length));
759
+ }
760
+ function replicaErrorFromService(error, context, request) {
761
+ const meta = error.meta ?? {};
762
+ const status = typeof meta.status === "number" ? meta.status : void 0;
763
+ const detail = { serviceCode: error.code, ...status === void 0 ? {} : { status } };
764
+ const reason = typeof meta.reason === "string" ? meta.reason : void 0;
765
+ if (error.code === "KV_SYNC_RESET_REQUIRED" || status === 410) {
766
+ return new ReplicaError(
767
+ ReplicaErrorCode.RESET_REQUIRED,
768
+ `${context}: the node requires a fresh bootstrap (${reason ?? "reason not given"}).`,
769
+ { ...detail, reason: reason ?? "reset-required" }
770
+ );
771
+ }
772
+ if (isRevocation(error, status, request)) {
773
+ return new ReplicaError(ReplicaErrorCode.GRANT_REVOKED, `${context}: ${error.message}`, detail);
774
+ }
775
+ if (error.code === "KV_RETENTION_GRANT_REFUSED") {
776
+ return new ReplicaError(ReplicaErrorCode.RETENTION_GRANT_REFUSED, `${context}: ${error.message}`, {
777
+ ...detail,
778
+ ...reason === void 0 ? {} : { reason }
779
+ });
780
+ }
781
+ if (status === 401 || status === 403 || /^AUTH_/.test(error.code)) {
782
+ if (/expired/i.test(error.message)) {
783
+ return new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, `${context}: ${error.message}`, detail);
784
+ }
785
+ return new ReplicaError(ReplicaErrorCode.GRANT_UNAUTHORIZED, `${context}: ${error.message}`, detail);
786
+ }
787
+ if (status !== void 0) {
788
+ return new ReplicaError(ReplicaErrorCode.NODE_ERROR, `${context}: ${error.message}`, detail);
789
+ }
790
+ if (error.code === "NETWORK_ERROR" || /TIMEOUT|ABORT/.test(error.code)) {
791
+ return new ReplicaError(ReplicaErrorCode.NETWORK_ERROR, `${context}: ${error.message}`, detail);
792
+ }
793
+ return new ReplicaError(ReplicaErrorCode.NODE_ERROR, `${context}: ${error.message}`, detail);
794
+ }
795
+ var encoder = new TextEncoder();
796
+ function compareKeyBytes(a, b) {
797
+ const x = encoder.encode(a);
798
+ const y = encoder.encode(b);
799
+ for (let index = 0; index < Math.min(x.length, y.length); index += 1) {
800
+ if (x[index] !== y[index]) return x[index] - y[index];
801
+ }
802
+ return x.length - y.length;
803
+ }
804
+ function isNotFound(error) {
805
+ return error.code === "KV_NOT_FOUND" || error.code === "NOT_FOUND";
806
+ }
807
+ function instant(value) {
808
+ if (value === null) return null;
809
+ if (typeof value === "string" && isInstant(value)) return value;
810
+ throw new ReplicaError(ReplicaErrorCode.PROTOCOL_ERROR, `The kv/sync authority holds a bound that is not a timestamp: ${JSON.stringify(value)}.`);
811
+ }
812
+ function checkPage(data) {
813
+ const ok = data !== null && typeof data === "object" && Array.isArray(data.changes) && typeof data.more === "boolean" && typeof data.cursor === "string" && typeof data.source?.nodeDid === "string" && typeof data.source?.space === "string" && typeof data.source?.prefix === "string" && data.authority !== null && typeof data.authority === "object" && data.changes.every(
814
+ (change) => typeof change?.key === "string" && (change.deleted === true || change.deleted === false && typeof change.etag === "string" && change.metadata !== null && typeof change.metadata === "object" && Object.values(change.metadata).every((value) => typeof value === "string"))
815
+ );
816
+ if (!ok) throw new ReplicaError(ReplicaErrorCode.PROTOCOL_ERROR, "The kv/sync response does not match the wire contract.");
817
+ return {
818
+ changes: data.changes.map(
819
+ (change) => change.deleted ? { key: change.key, deleted: true } : { key: change.key, deleted: false, etag: change.etag, metadata: { ...change.metadata } }
820
+ ),
821
+ more: data.more,
822
+ cursor: data.cursor,
823
+ source: { nodeDid: data.source.nodeDid, space: data.source.space, prefix: data.source.prefix },
824
+ authority: {
825
+ notBefore: instant(data.authority.notBefore),
826
+ expiresAt: instant(data.authority.expiresAt),
827
+ retainUntil: instant(data.authority.retainUntil)
828
+ }
829
+ };
830
+ }
831
+ function fetched(key, result, request) {
832
+ if (!result.ok) {
833
+ if (isNotFound(result.error)) return { missing: true };
834
+ throw replicaErrorFromService(result.error, `Fetching ${JSON.stringify(key)}`, request);
835
+ }
836
+ if (!(result.data.data instanceof Uint8Array)) {
837
+ throw new ReplicaError(ReplicaErrorCode.PROTOCOL_ERROR, `The value of ${JSON.stringify(key)} did not arrive as bytes.`);
838
+ }
839
+ return { bytes: result.data.data, etag: result.data.headers.etag ?? "" };
840
+ }
841
+ function kvSyncTransport(kv) {
842
+ return {
843
+ async syncPage({ prefix, cursor, limit, retentionGrant, signal }) {
844
+ const result = await kv.changes({
845
+ prefix,
846
+ limit,
847
+ ...cursor === void 0 ? {} : { cursor },
848
+ ...retentionGrant === void 0 ? {} : { retentionGrant },
849
+ ...signal === void 0 ? {} : { signal }
850
+ });
851
+ if (!result.ok) throw replicaErrorFromService(result.error, "Syncing");
852
+ return checkPage(result.data);
853
+ },
854
+ async fetchContent(keys, options) {
855
+ const contents = /* @__PURE__ */ new Map();
856
+ if (keys.length === 0) return contents;
857
+ const signal = options?.signal;
858
+ const ordered = [...keys].sort(compareKeyBytes);
859
+ const batch = await kv.batchGet(ordered, { binary: true, ...signal === void 0 ? {} : { signal } });
860
+ if (batch.ok) {
861
+ for (const item of batch.data.results) {
862
+ const result = item.result;
863
+ if (!result.ok && result.error.code === "KV_RESPONSE_TOO_LARGE") continue;
864
+ contents.set(item.key, fetched(item.key, result, `Failed to get key ${JSON.stringify(item.key)}`));
865
+ }
866
+ } else if (batch.error.code !== "KV_RESPONSE_TOO_LARGE") {
867
+ throw replicaErrorFromService(batch.error, `Fetching ${keys.length} value(s)`, `Failed to batch read ${ordered.length} key(s)`);
868
+ }
869
+ for (const key of keys) {
870
+ if (contents.has(key)) continue;
871
+ const request = `Failed to get key ${JSON.stringify(key)}`;
872
+ contents.set(key, fetched(key, await kv.get(key, { binary: true, ...signal === void 0 ? {} : { signal } }), request));
873
+ }
874
+ return contents;
875
+ }
876
+ };
877
+ }
878
+ export {
879
+ Replica,
880
+ ReplicaError,
881
+ ReplicaErrorCode,
882
+ assertGrantInstallable,
883
+ assertReadable,
884
+ contentHash,
885
+ effectiveAuthority,
886
+ grantCovers,
887
+ hashFromEtag,
888
+ isReplicaError,
889
+ kvPrefixCovers,
890
+ kvSyncTransport,
891
+ parseUcanGrant,
892
+ replicaErrorFromService,
893
+ requiresSecretsOptIn,
894
+ syncGrantSpaces,
895
+ ucanCid
896
+ };
897
+ //# sourceMappingURL=index.js.map