@powerhousedao/reactor-attachments 6.2.3-dev.50 → 6.2.3-dev.52

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.
@@ -0,0 +1,1975 @@
1
+ import { a as parseRef, c as AttachmentPending, i as createRef, r as isAttachmentMetadata, s as AttachmentNotFound, t as SwitchboardAttachmentTransport } from "./switchboard-attachment-transport-BH3ZAmxE.js";
2
+ import { t as AttachmentSchemaCompiler } from "./attachment-schema-compiler-rIaJpiHO.js";
3
+ import { t as sha256Hex } from "./hash-CKHeI4AH.js";
4
+ import { GQL_CHANNEL_TYPE, ReactorEventTypes } from "@powerhousedao/reactor";
5
+ import { isPurgeMarker } from "@powerhousedao/shared/document-model";
6
+ //#region src/access/attachment-read-gate.ts
7
+ /** The scope an attachment's bytes belong to: the document's own domain state. */
8
+ const ATTACHMENT_READ_SCOPE = "global";
9
+ /** The branch an attachment reference is resolved against. */
10
+ const ATTACHMENT_READ_BRANCH = "main";
11
+ const HASH_PATTERN = /^[a-f0-9]{64}$/;
12
+ /** Whether `value` is a canonical v1 hash: 64 lowercase SHA-256 hex chars. */
13
+ function isAttachmentHash(value) {
14
+ return typeof value === "string" && HASH_PATTERN.test(value);
15
+ }
16
+ /** Rethrows a failure: a read side that is down must not read as a denial. */
17
+ async function scopeGateAllowsAttachmentRead(scopeGate, documentId, subject) {
18
+ return (await scopeGate.scopePredicateById(documentId, subject, ATTACHMENT_READ_BRANCH))(ATTACHMENT_READ_SCOPE);
19
+ }
20
+ /** Served to the subject, references `ref`, and a referencing scope is readable. */
21
+ async function readGateAllowsAttachmentRead(readGate, references, documentId, ref, subject) {
22
+ let served;
23
+ try {
24
+ served = await readGate.isServed(documentId, { subject });
25
+ } catch {
26
+ return false;
27
+ }
28
+ if (!served) return false;
29
+ const scopes = await references.referencingScopes(documentId, ref);
30
+ if (scopes.length === 0) return false;
31
+ let held;
32
+ try {
33
+ held = (await readGate.get(documentId, {
34
+ subject,
35
+ scopes
36
+ })).state;
37
+ } catch {
38
+ return false;
39
+ }
40
+ return scopes.some((scope) => scope in held);
41
+ }
42
+ //#endregion
43
+ //#region src/local/protocol.ts
44
+ /**
45
+ * Wire tag on every message, so one `LocalChannelPort` can carry this
46
+ * request/response protocol without being confused with anything else on the
47
+ * port, and so a version skew is a readable refusal rather than a crash.
48
+ */
49
+ const LOCAL_ATTACHMENT_PROTOCOL = "ph-attachment/v1";
50
+ /** Default bytes per `chunk` message. */
51
+ const DEFAULT_LOCAL_CHUNK_BYTES = 262144;
52
+ /** Default wait for the next message of a request before it is abandoned. */
53
+ const DEFAULT_LOCAL_REQUEST_TIMEOUT_MS = 3e4;
54
+ const REQUEST_KINDS = /* @__PURE__ */ new Set(["fetch", "cancel"]);
55
+ const RESPONSE_KINDS = /* @__PURE__ */ new Set([
56
+ "begin",
57
+ "chunk",
58
+ "end",
59
+ "pending",
60
+ "not-found",
61
+ "error"
62
+ ]);
63
+ function tagged(value) {
64
+ if (typeof value !== "object" || value === null) return false;
65
+ const record = value;
66
+ return record.protocol === "ph-attachment/v1" && typeof record.kind === "string" && typeof record.id === "string";
67
+ }
68
+ /**
69
+ * Whether `value` is a request for this protocol.
70
+ *
71
+ * Both halves live on one port -- each peer runs a transport AND a server over
72
+ * the same `LocalChannelPort` -- so each half has to recognise only its own
73
+ * side of the conversation and ignore the rest rather than treat it as
74
+ * malformed.
75
+ */
76
+ function isLocalAttachmentRequest(value) {
77
+ return tagged(value) && REQUEST_KINDS.has(value.kind);
78
+ }
79
+ /** Whether `value` is a response for this protocol. */
80
+ function isLocalAttachmentResponse(value) {
81
+ return tagged(value) && RESPONSE_KINDS.has(value.kind);
82
+ }
83
+ //#endregion
84
+ //#region src/local/local-attachment-server.ts
85
+ /** Default {@link LocalAttachmentServerOptions.maxConcurrentServes}. */
86
+ const DEFAULT_LOCAL_MAX_CONCURRENT_SERVES = 4;
87
+ /** A macrotask, so a cancel posted by the peer is handled between slices. */
88
+ function nextTurn() {
89
+ return new Promise((resolve) => setTimeout(resolve, 0));
90
+ }
91
+ /**
92
+ * Authorizes a peer read the way the Switchboard's attachment route does: the
93
+ * link's subject may read the document's `global` scope, the reactor serves
94
+ * the document to it, and a scope it may read references the hash.
95
+ */
96
+ function readGateAttachmentAuthorizer(options) {
97
+ return async (link, hash, documentId) => {
98
+ const subject = options.subjectOf(link);
99
+ if (!subject) return false;
100
+ if (!await scopeGateAllowsAttachmentRead(options.scopeGate, documentId, subject)) return false;
101
+ return readGateAllowsAttachmentRead(options.readGate, options.references, documentId, createRef(hash), subject);
102
+ };
103
+ }
104
+ /**
105
+ * Serves attachment bytes to a brokered local peer from this reactor's own
106
+ * store.
107
+ *
108
+ * The answering half of {@link LocalAttachmentTransport}; both run on the same
109
+ * `LocalChannelPort` and ignore each other's messages.
110
+ *
111
+ * It never chains. The store is read WITHOUT a document id, which means a
112
+ * `LocalAttachmentStore` serves only bytes it actually holds and cannot reach
113
+ * for its own transport on the requester's behalf. Without that, two peers
114
+ * linked to each other would answer a miss by asking each other, and a hash
115
+ * neither held would bounce between them.
116
+ */
117
+ var LocalAttachmentServer = class {
118
+ port;
119
+ link;
120
+ store;
121
+ authorize;
122
+ chunkSizeBytes;
123
+ maxConcurrentServes;
124
+ onDiagnostic;
125
+ cancelled = /* @__PURE__ */ new Set();
126
+ inFlight = /* @__PURE__ */ new Set();
127
+ detachPort;
128
+ served = 0;
129
+ bytesServed = 0;
130
+ refused = 0;
131
+ closed = false;
132
+ constructor(options) {
133
+ this.port = options.port;
134
+ this.link = { ...options.link };
135
+ this.store = options.store;
136
+ this.authorize = options.authorize ?? (() => Promise.resolve(false));
137
+ this.chunkSizeBytes = options.chunkSizeBytes ?? 262144;
138
+ this.maxConcurrentServes = options.maxConcurrentServes ?? 4;
139
+ this.onDiagnostic = options.onDiagnostic ?? (() => void 0);
140
+ this.detachPort = this.port.onMessage((data) => this.onMessage(data));
141
+ }
142
+ /** What this peer has handed out. */
143
+ stats() {
144
+ return {
145
+ served: this.served,
146
+ bytesServed: this.bytesServed,
147
+ refused: this.refused
148
+ };
149
+ }
150
+ /** Detaches from the port. In-flight serves stop at their next slice. */
151
+ close() {
152
+ if (this.closed) return;
153
+ this.closed = true;
154
+ this.detachPort();
155
+ this.cancelled.clear();
156
+ this.inFlight.clear();
157
+ }
158
+ onMessage(data) {
159
+ if (!isLocalAttachmentRequest(data)) return;
160
+ if (data.kind === "cancel") {
161
+ if (this.inFlight.has(data.id)) this.cancelled.add(data.id);
162
+ return;
163
+ }
164
+ if (this.inFlight.has(data.id)) {
165
+ this.onDiagnostic(`ignoring a duplicate attachment request id ${data.id}`);
166
+ return;
167
+ }
168
+ if (!isAttachmentHash(data.hash) || typeof data.documentId !== "string" || data.documentId === "") {
169
+ this.refuse(data);
170
+ return;
171
+ }
172
+ if (this.inFlight.size >= this.maxConcurrentServes) {
173
+ this.post({
174
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
175
+ kind: "pending",
176
+ id: data.id,
177
+ hash: data.hash,
178
+ expiresAtUtc: new Date(Date.now() + BUSY_PENDING_TTL_MS).toISOString(),
179
+ retryAfterMs: BUSY_RETRY_MS,
180
+ busy: true
181
+ });
182
+ return;
183
+ }
184
+ this.serve(data);
185
+ }
186
+ stopped(id) {
187
+ return this.closed || this.cancelled.has(id);
188
+ }
189
+ async serve(request) {
190
+ this.inFlight.add(request.id);
191
+ try {
192
+ await this.serveInner(request);
193
+ } finally {
194
+ this.inFlight.delete(request.id);
195
+ this.cancelled.delete(request.id);
196
+ }
197
+ }
198
+ async serveInner(request) {
199
+ try {
200
+ if (!await this.authorize({ ...this.link }, request.hash, request.documentId)) {
201
+ this.refuse(request);
202
+ return;
203
+ }
204
+ } catch (error) {
205
+ this.onDiagnostic(`authorizing attachment ${request.hash} for ${request.documentId} failed`, error);
206
+ this.post({
207
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
208
+ kind: "error",
209
+ id: request.id,
210
+ message: "authorization check failed"
211
+ });
212
+ return;
213
+ }
214
+ if (this.stopped(request.id)) return;
215
+ let held;
216
+ try {
217
+ held = await this.store.has(request.hash);
218
+ } catch (error) {
219
+ this.failRequest(request, "reading the local attachment store failed", error);
220
+ return;
221
+ }
222
+ if (this.stopped(request.id)) return;
223
+ if (!held) {
224
+ this.refuse(request);
225
+ return;
226
+ }
227
+ await this.stream(request);
228
+ }
229
+ async stream(request) {
230
+ let response;
231
+ try {
232
+ response = await this.store.get(request.hash);
233
+ } catch (error) {
234
+ if (error instanceof AttachmentPending) {
235
+ this.post({
236
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
237
+ kind: "pending",
238
+ id: request.id,
239
+ hash: request.hash,
240
+ expiresAtUtc: error.expiresAtUtc,
241
+ retryAfterMs: DEFAULT_PENDING_RETRY_MS
242
+ });
243
+ return;
244
+ }
245
+ this.onDiagnostic(`serving attachment ${request.hash} failed`, error);
246
+ this.refuse(request);
247
+ return;
248
+ }
249
+ this.post({
250
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
251
+ kind: "begin",
252
+ id: request.id,
253
+ hash: request.hash,
254
+ metadata: {
255
+ mimeType: response.header.mimeType,
256
+ fileName: response.header.fileName,
257
+ sizeBytes: response.header.sizeBytes,
258
+ extension: response.header.extension,
259
+ createdAtUtc: response.header.createdAtUtc,
260
+ lastAccessedAtUtc: response.header.lastAccessedAtUtc
261
+ }
262
+ });
263
+ const reader = response.body.getReader();
264
+ let seq = 0;
265
+ try {
266
+ for (;;) {
267
+ if (this.stopped(request.id)) {
268
+ await reader.cancel();
269
+ return;
270
+ }
271
+ const { done, value } = await reader.read();
272
+ if (done) break;
273
+ for (let offset = 0; offset < value.byteLength; offset += this.chunkSizeBytes) {
274
+ if (offset > 0) await nextTurn();
275
+ if (this.stopped(request.id)) {
276
+ await reader.cancel();
277
+ return;
278
+ }
279
+ const slice = value.subarray(offset, Math.min(offset + this.chunkSizeBytes, value.byteLength));
280
+ const bytes = new Uint8Array(slice.byteLength);
281
+ bytes.set(slice);
282
+ this.post({
283
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
284
+ kind: "chunk",
285
+ id: request.id,
286
+ seq: seq++,
287
+ bytes
288
+ });
289
+ this.bytesServed += bytes.byteLength;
290
+ }
291
+ }
292
+ } catch (error) {
293
+ this.onDiagnostic(`streaming attachment ${request.hash} failed`, error);
294
+ this.post({
295
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
296
+ kind: "error",
297
+ id: request.id,
298
+ message: "reading the attachment body failed"
299
+ });
300
+ return;
301
+ }
302
+ this.served += 1;
303
+ this.post({
304
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
305
+ kind: "end",
306
+ id: request.id
307
+ });
308
+ }
309
+ /** Refused and absent are one answer, so a peer learns nothing from it. */
310
+ refuse(request) {
311
+ this.refused += 1;
312
+ this.post({
313
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
314
+ kind: "not-found",
315
+ id: request.id
316
+ });
317
+ }
318
+ failRequest(request, message, error) {
319
+ this.onDiagnostic(`${message} (${request.hash})`, error);
320
+ this.post({
321
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
322
+ kind: "error",
323
+ id: request.id,
324
+ message
325
+ });
326
+ }
327
+ post(message) {
328
+ try {
329
+ this.port.postMessage(message);
330
+ } catch (error) {
331
+ this.onDiagnostic("posting an attachment response failed", error);
332
+ }
333
+ }
334
+ };
335
+ /**
336
+ * Retry hint sent with a `pending` answer. A local store has no reservation
337
+ * table, so this only arises when the served store is a server-side one; the
338
+ * value mirrors `SwitchboardAttachmentTransport`'s own default.
339
+ */
340
+ const DEFAULT_PENDING_RETRY_MS = 5e3;
341
+ /** Retry hint sent with the `pending` that answers a request over the serve cap. */
342
+ const BUSY_RETRY_MS = 1e3;
343
+ /** Expiry of that `pending`, for callers that surface it as `AttachmentPending`. */
344
+ const BUSY_PENDING_TTL_MS = 6e4;
345
+ //#endregion
346
+ //#region src/local/local-attachment-transport.ts
347
+ /**
348
+ * `IAttachmentTransport` over a brokered `LocalChannelPort`: one linked reactor
349
+ * pulling attachment bytes straight from its peer, with no Switchboard and no
350
+ * HTTP.
351
+ *
352
+ * The counterpart of {@link LocalAttachmentServer}, and a pair of them runs on
353
+ * each end of one port: a reactor both asks for bytes and serves them. The two
354
+ * halves share the port and ignore each other's messages (see
355
+ * `isLocalAttachmentRequest` / `isLocalAttachmentResponse`), and request ids
356
+ * are namespaced per instance so two peers that both start counting at 1 never
357
+ * match each other's replies.
358
+ *
359
+ * Pull-only by design. The agreed byte-movement model is lazy
360
+ * fetch-on-reference, so {@link announce} is a no-op and {@link push} refuses
361
+ * by name rather than silently succeeding: a push that reported success while
362
+ * moving nothing would make an eager-replication caller believe bytes had
363
+ * landed on a peer.
364
+ */
365
+ var LocalAttachmentTransport = class {
366
+ port;
367
+ requestTimeoutMs;
368
+ setTimer;
369
+ clearTimer;
370
+ instanceId;
371
+ maxBytes;
372
+ pending = /* @__PURE__ */ new Map();
373
+ detachPort;
374
+ nextRequest = 0;
375
+ closed = false;
376
+ constructor(options) {
377
+ this.port = options.port;
378
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
379
+ this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
380
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
381
+ this.instanceId = options.instanceId ?? Math.random().toString(36).slice(2, 10);
382
+ this.maxBytes = options.maxBytes;
383
+ this.detachPort = this.port.onMessage((data) => this.onMessage(data));
384
+ }
385
+ fetch(hash, documentId, signal) {
386
+ if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("Local attachment transport is closed; the link is severed"));
387
+ if (signal?.aborted) return Promise.reject(/* @__PURE__ */ new Error("Attachment fetch aborted"));
388
+ const id = `${this.instanceId}:${++this.nextRequest}`;
389
+ return new Promise((resolve, reject) => {
390
+ const entry = {
391
+ hash,
392
+ settle: resolve,
393
+ fail: reject,
394
+ controller: void 0,
395
+ nextSeq: 0,
396
+ declaredBytes: 0,
397
+ receivedBytes: 0,
398
+ timer: void 0,
399
+ detachAbort: void 0
400
+ };
401
+ this.pending.set(id, entry);
402
+ this.arm(id, entry);
403
+ if (signal) {
404
+ const onAbort = () => {
405
+ this.post({
406
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
407
+ kind: "cancel",
408
+ id
409
+ });
410
+ this.abandon(id, /* @__PURE__ */ new Error("Attachment fetch aborted"));
411
+ };
412
+ signal.addEventListener("abort", onAbort, { once: true });
413
+ entry.detachAbort = () => signal.removeEventListener("abort", onAbort);
414
+ }
415
+ this.post({
416
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
417
+ kind: "fetch",
418
+ id,
419
+ hash,
420
+ documentId
421
+ });
422
+ });
423
+ }
424
+ /**
425
+ * No-op: the agreed model is the peer PULLING on reference, so there is
426
+ * nothing useful to tell it -- it will ask when one of its own operations
427
+ * names the hash. An eager-announce protocol would need a peer that acts on
428
+ * the notice, which nothing does.
429
+ */
430
+ announce() {
431
+ return Promise.resolve();
432
+ }
433
+ /** Refuses: this transport moves bytes only in answer to a peer's request. */
434
+ push() {
435
+ return Promise.reject(/* @__PURE__ */ new Error("LocalAttachmentTransport is pull-only: a linked peer fetches bytes on reference, so there is no push path to a local peer"));
436
+ }
437
+ /** Detaches from the port and fails every in-flight request. */
438
+ close() {
439
+ if (this.closed) return;
440
+ this.closed = true;
441
+ this.detachPort();
442
+ for (const id of [...this.pending.keys()]) this.abandon(id, /* @__PURE__ */ new Error("Local attachment transport closed while a fetch was in flight"));
443
+ }
444
+ onMessage(data) {
445
+ if (!isLocalAttachmentResponse(data)) return;
446
+ const entry = this.pending.get(data.id);
447
+ if (!entry) return;
448
+ this.arm(data.id, entry);
449
+ this.handle(data.id, entry, data);
450
+ }
451
+ handle(id, entry, message) {
452
+ if (entry.controller && (message.kind === "begin" || message.kind === "pending" || message.kind === "not-found")) {
453
+ this.violate(id, `Local attachment peer sent ${message.kind} after the body began`);
454
+ return;
455
+ }
456
+ switch (message.kind) {
457
+ case "begin": {
458
+ if (message.hash !== entry.hash || !isAttachmentMetadata(message.metadata)) {
459
+ this.violate(id, "Local attachment peer sent a malformed begin");
460
+ return;
461
+ }
462
+ const sizeBytes = message.metadata.sizeBytes;
463
+ if (this.maxBytes !== void 0 && sizeBytes > this.maxBytes) {
464
+ this.violate(id, `Local attachment of ${sizeBytes} bytes exceeds the ${this.maxBytes} byte limit`);
465
+ return;
466
+ }
467
+ entry.declaredBytes = sizeBytes;
468
+ const body = new ReadableStream({
469
+ start: (controller) => {
470
+ entry.controller = controller;
471
+ },
472
+ cancel: () => {
473
+ this.post({
474
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
475
+ kind: "cancel",
476
+ id
477
+ });
478
+ this.release(id, entry);
479
+ }
480
+ });
481
+ entry.settle({
482
+ kind: "data",
483
+ response: {
484
+ hash: message.hash,
485
+ metadata: message.metadata,
486
+ body
487
+ }
488
+ });
489
+ return;
490
+ }
491
+ case "chunk":
492
+ if (!entry.controller) {
493
+ this.abandon(id, /* @__PURE__ */ new Error("Local attachment peer sent body bytes before announcing the attachment"));
494
+ return;
495
+ }
496
+ if (!(message.bytes instanceof Uint8Array)) {
497
+ this.violate(id, "Local attachment peer sent a malformed chunk");
498
+ return;
499
+ }
500
+ if (message.seq !== entry.nextSeq) {
501
+ this.abandon(id, /* @__PURE__ */ new Error(`Local attachment chunk out of order: expected ${entry.nextSeq}, got ${message.seq}`));
502
+ return;
503
+ }
504
+ entry.receivedBytes += message.bytes.byteLength;
505
+ if (entry.receivedBytes > entry.declaredBytes) {
506
+ this.violate(id, `Local attachment peer sent more than the ${entry.declaredBytes} bytes it declared`);
507
+ return;
508
+ }
509
+ entry.nextSeq += 1;
510
+ entry.controller.enqueue(new Uint8Array(message.bytes));
511
+ return;
512
+ case "end":
513
+ if (!entry.controller) {
514
+ this.abandon(id, /* @__PURE__ */ new Error("Local attachment peer ended the transfer before announcing the attachment"));
515
+ return;
516
+ }
517
+ if (entry.receivedBytes !== entry.declaredBytes) {
518
+ this.abandon(id, /* @__PURE__ */ new Error(`Local attachment peer ended after ${entry.receivedBytes} of ${entry.declaredBytes} declared bytes`));
519
+ return;
520
+ }
521
+ entry.controller.close();
522
+ this.release(id, entry);
523
+ return;
524
+ case "pending":
525
+ if (message.hash !== entry.hash || typeof message.expiresAtUtc !== "string") {
526
+ this.violate(id, "Local attachment peer sent a malformed pending");
527
+ return;
528
+ }
529
+ entry.settle({
530
+ kind: "pending",
531
+ hash: message.hash,
532
+ expiresAtUtc: message.expiresAtUtc,
533
+ retryAfterMs: typeof message.retryAfterMs === "number" ? message.retryAfterMs : NaN,
534
+ ...message.busy === true ? { busy: true } : {}
535
+ });
536
+ this.release(id, entry);
537
+ return;
538
+ case "not-found":
539
+ entry.settle({ kind: "not-found" });
540
+ this.release(id, entry);
541
+ return;
542
+ case "error":
543
+ this.abandon(id, /* @__PURE__ */ new Error(`Local attachment peer failed: ${message.message}`));
544
+ return;
545
+ }
546
+ }
547
+ /** A reply that breaks the protocol: tell the peer to stop, fail the request. */
548
+ violate(id, message) {
549
+ this.post({
550
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
551
+ kind: "cancel",
552
+ id
553
+ });
554
+ this.abandon(id, new Error(message));
555
+ }
556
+ /** Resets this request's silence timer. */
557
+ arm(id, entry) {
558
+ if (entry.timer !== void 0) this.clearTimer(entry.timer);
559
+ entry.timer = this.setTimer(() => {
560
+ this.post({
561
+ protocol: LOCAL_ATTACHMENT_PROTOCOL,
562
+ kind: "cancel",
563
+ id
564
+ });
565
+ this.abandon(id, /* @__PURE__ */ new Error(`Local attachment peer went silent for ${this.requestTimeoutMs}ms while serving ${entry.hash}`));
566
+ }, this.requestTimeoutMs);
567
+ }
568
+ /** Forgets a request that completed normally. */
569
+ release(id, entry) {
570
+ if (entry.timer !== void 0) {
571
+ this.clearTimer(entry.timer);
572
+ entry.timer = void 0;
573
+ }
574
+ entry.detachAbort?.();
575
+ this.pending.delete(id);
576
+ }
577
+ /**
578
+ * Forgets a request that failed, pushing the failure to whichever side is
579
+ * still listening: the fetch promise before `begin`, the body stream after.
580
+ */
581
+ abandon(id, error) {
582
+ const entry = this.pending.get(id);
583
+ if (!entry) return;
584
+ this.release(id, entry);
585
+ if (entry.controller) {
586
+ entry.controller.error(error);
587
+ return;
588
+ }
589
+ entry.fail(error);
590
+ }
591
+ post(message) {
592
+ try {
593
+ this.port.postMessage(message);
594
+ } catch {}
595
+ }
596
+ };
597
+ //#endregion
598
+ //#region src/peers/attachment-peer-links.ts
599
+ /** Holds one raw listener for the link's life and fans it out to both halves. */
600
+ var LinkPort = class {
601
+ raw;
602
+ onDiagnostic;
603
+ subscribers = /* @__PURE__ */ new Set();
604
+ detachRaw;
605
+ constructor(raw, onDiagnostic) {
606
+ this.raw = raw;
607
+ this.onDiagnostic = onDiagnostic;
608
+ this.detachRaw = raw.onMessage((data) => this.dispatch(data));
609
+ }
610
+ /** A view for one half; its close() is a no-op, the link owns the port. */
611
+ view() {
612
+ return {
613
+ postMessage: (data) => this.raw.postMessage(data),
614
+ onMessage: (callback) => {
615
+ this.subscribers.add(callback);
616
+ return () => {
617
+ this.subscribers.delete(callback);
618
+ };
619
+ },
620
+ close: () => void 0
621
+ };
622
+ }
623
+ detach() {
624
+ this.subscribers.clear();
625
+ this.detachRaw();
626
+ }
627
+ release() {
628
+ this.detach();
629
+ try {
630
+ this.raw.close();
631
+ } catch (error) {
632
+ this.onDiagnostic("closing an attachment link port failed", error);
633
+ }
634
+ }
635
+ dispatch(data) {
636
+ for (const subscriber of [...this.subscribers]) try {
637
+ subscriber(data);
638
+ } catch (error) {
639
+ this.onDiagnostic("an attachment link handler threw", error);
640
+ }
641
+ }
642
+ };
643
+ /** Per-peer server and transport pairs, each on its own port, not the sync port. */
644
+ var AttachmentPeerLinks = class {
645
+ options;
646
+ links = /* @__PURE__ */ new Map();
647
+ onDiagnostic;
648
+ constructor(options) {
649
+ this.options = options;
650
+ this.onDiagnostic = options.onDiagnostic ?? (() => void 0);
651
+ }
652
+ has(peerId, channelName) {
653
+ return this.links.has(linkKey(peerId, channelName));
654
+ }
655
+ /** Takes ownership of `port` on success; on failure the port is untouched. */
656
+ addPeer(peerId, channelName, port, linkOptions = {}) {
657
+ const key = linkKey(peerId, channelName);
658
+ if (this.links.has(key)) throw new Error(`This reactor already holds an attachment link to peer '${peerId}' on channel '${channelName}'; remove it before linking again`);
659
+ const linkPort = new LinkPort(port, this.onDiagnostic);
660
+ const authorize = linkOptions.authorize ?? this.options.authorize;
661
+ const server = new LocalAttachmentServer({
662
+ port: linkPort.view(),
663
+ link: {
664
+ peerId,
665
+ channelName
666
+ },
667
+ store: this.options.store,
668
+ ...authorize ? { authorize } : {},
669
+ ...this.options.chunkSizeBytes !== void 0 ? { chunkSizeBytes: this.options.chunkSizeBytes } : {},
670
+ ...this.options.maxConcurrentServes !== void 0 ? { maxConcurrentServes: this.options.maxConcurrentServes } : {},
671
+ onDiagnostic: this.onDiagnostic
672
+ });
673
+ const transport = new LocalAttachmentTransport({
674
+ port: linkPort.view(),
675
+ ...this.options.requestTimeoutMs !== void 0 ? { requestTimeoutMs: this.options.requestTimeoutMs } : {},
676
+ ...this.options.maxBytes !== void 0 ? { maxBytes: this.options.maxBytes } : {}
677
+ });
678
+ try {
679
+ this.options.transport.addPeer(peerId, channelName, transport);
680
+ } catch (error) {
681
+ server.close();
682
+ transport.close();
683
+ linkPort.detach();
684
+ throw error;
685
+ }
686
+ this.links.set(key, {
687
+ peerId,
688
+ channelName,
689
+ port: linkPort,
690
+ server,
691
+ transport
692
+ });
693
+ try {
694
+ this.options.onPeerAdded?.(peerId, channelName);
695
+ } catch (error) {
696
+ this.onDiagnostic("an attachment onPeerAdded hook threw", error);
697
+ }
698
+ }
699
+ /** Closes the link's server, transport and port. False when absent. */
700
+ removePeer(peerId, channelName) {
701
+ const key = linkKey(peerId, channelName);
702
+ const link = this.links.get(key);
703
+ if (!link) return false;
704
+ this.links.delete(key);
705
+ this.options.transport.removePeer(peerId, channelName);
706
+ link.server.close();
707
+ link.transport.close();
708
+ link.port.release();
709
+ return true;
710
+ }
711
+ servedStats() {
712
+ const total = {
713
+ served: 0,
714
+ bytesServed: 0,
715
+ refused: 0
716
+ };
717
+ for (const link of this.links.values()) {
718
+ const stats = link.server.stats();
719
+ total.served += stats.served;
720
+ total.bytesServed += stats.bytesServed;
721
+ total.refused += stats.refused;
722
+ }
723
+ return total;
724
+ }
725
+ close() {
726
+ for (const link of [...this.links.values()]) this.removePeer(link.peerId, link.channelName);
727
+ }
728
+ };
729
+ function linkKey(peerId, channelName) {
730
+ return `${peerId}\u0000${channelName}`;
731
+ }
732
+ //#endregion
733
+ //#region src/storage/local/bytes.ts
734
+ /**
735
+ * Drains `stream` into one `Uint8Array`.
736
+ *
737
+ * The local stores hold whole blobs (see {@link ILocalAttachmentBackend}), so
738
+ * the stream an `IAttachmentStore.put` is handed has to be collected before it
739
+ * can be written. Realm-neutral: no `node:buffer`, no `Blob`.
740
+ */
741
+ async function collectStream(stream) {
742
+ const reader = stream.getReader();
743
+ const chunks = [];
744
+ let total = 0;
745
+ for (;;) {
746
+ const { done, value } = await reader.read();
747
+ if (done) break;
748
+ chunks.push(value);
749
+ total += value.byteLength;
750
+ }
751
+ const out = new Uint8Array(total);
752
+ let offset = 0;
753
+ for (const chunk of chunks) {
754
+ out.set(chunk, offset);
755
+ offset += chunk.byteLength;
756
+ }
757
+ return out;
758
+ }
759
+ /** A one-chunk `ReadableStream` over `bytes`. */
760
+ function streamFromBytes(bytes) {
761
+ return new ReadableStream({ start(controller) {
762
+ controller.enqueue(bytes);
763
+ controller.close();
764
+ } });
765
+ }
766
+ //#endregion
767
+ //#region src/peers/peered-attachment-transport.ts
768
+ /**
769
+ * What the sources that did not answer data said, ranked by the combine rule:
770
+ * a reservation, then a busy source, then an error, then a not-found. A busy
771
+ * source may hold the bytes; the replicator bounds a busy run.
772
+ */
773
+ var Answers = class {
774
+ pending;
775
+ busy;
776
+ firstError;
777
+ notFound = false;
778
+ error(error) {
779
+ this.firstError ??= error instanceof Error ? error : new Error(String(error));
780
+ }
781
+ result() {
782
+ if (this.pending) return this.pending;
783
+ if (this.busy) {
784
+ const { othersNotFound: _, ...busy } = this.busy;
785
+ return this.notFound && this.firstError === void 0 ? {
786
+ ...busy,
787
+ othersNotFound: true
788
+ } : busy;
789
+ }
790
+ if (this.firstError !== void 0) throw this.firstError;
791
+ return { kind: "not-found" };
792
+ }
793
+ };
794
+ /** Collects and checks a body, so a source's wrong bytes are its own error. */
795
+ async function verified(hash, result) {
796
+ const bytes = await collectStream(result.response.body);
797
+ const actual = await sha256Hex(bytes);
798
+ if (actual !== hash) throw new Error(`Attachment bytes for ${hash} hashed to ${actual}; the source served content that is not what was asked for`);
799
+ return {
800
+ kind: "data",
801
+ response: {
802
+ ...result.response,
803
+ body: streamFromBytes(bytes)
804
+ }
805
+ };
806
+ }
807
+ /**
808
+ * Peers in parallel, then Switchboards in turn; verified data > pending >
809
+ * busy > error > unanimous not-found.
810
+ */
811
+ var PeeredAttachmentTransport = class {
812
+ syncManager;
813
+ switchboardUrl;
814
+ jwtHandler;
815
+ fetchFn;
816
+ peers = /* @__PURE__ */ new Map();
817
+ switchboards = /* @__PURE__ */ new Map();
818
+ constructor(options = {}) {
819
+ this.syncManager = options.syncManager;
820
+ this.switchboardUrl = options.switchboardUrl;
821
+ this.jwtHandler = options.jwtHandler;
822
+ this.fetchFn = options.fetchFn;
823
+ }
824
+ /** Keyed by (peerId, channelName), so one peer may hold several links. */
825
+ addPeer(peerId, channelName, transport) {
826
+ const key = peerKey(peerId, channelName);
827
+ if (this.peers.has(key)) throw new Error(`An attachment peer is already registered for '${peerId}' on channel '${channelName}'; remove it before linking again`);
828
+ this.peers.set(key, {
829
+ peerId,
830
+ transport
831
+ });
832
+ }
833
+ /** Forgets one peer link; the caller owns closing its transport. */
834
+ removePeer(peerId, channelName) {
835
+ const key = peerKey(peerId, channelName);
836
+ const entry = this.peers.get(key);
837
+ this.peers.delete(key);
838
+ return entry?.transport;
839
+ }
840
+ /** Distinct linked peer ids. */
841
+ peerNames() {
842
+ return [...new Set([...this.peers.values()].map((entry) => entry.peerId))];
843
+ }
844
+ /** The explicit Switchboard, then every gql remote's origin. */
845
+ switchboardSources() {
846
+ const urls = /* @__PURE__ */ new Set();
847
+ if (this.switchboardUrl) urls.add(this.switchboardUrl);
848
+ for (const remote of this.syncManager?.list() ?? []) {
849
+ if (remote.meta.channelConfig.type !== GQL_CHANNEL_TYPE) continue;
850
+ const url = remote.meta.channelConfig.parameters.url;
851
+ if (typeof url === "string" && url !== "") urls.add(attachmentOriginOf(url));
852
+ }
853
+ return [...urls];
854
+ }
855
+ async fetch(hash, documentId, signal) {
856
+ signal?.throwIfAborted();
857
+ const peers = [...this.peers.values()].map((entry) => entry.transport);
858
+ const switchboards = this.switchboardSources().map((url) => this.switchboardFor(url));
859
+ if (peers.length === 0 && switchboards.length === 0) return { kind: "not-found" };
860
+ const answers = new Answers();
861
+ if (peers.length > 0) {
862
+ const data = await this.askPeers(peers, hash, documentId, answers, signal);
863
+ if (data) return data;
864
+ }
865
+ for (const switchboard of switchboards) {
866
+ signal?.throwIfAborted();
867
+ const data = await this.ask(switchboard, hash, documentId, answers, signal);
868
+ if (data) return data;
869
+ }
870
+ signal?.throwIfAborted();
871
+ return answers.result();
872
+ }
873
+ async ask(source, hash, documentId, answers, signal) {
874
+ let result;
875
+ try {
876
+ result = await source.fetch(hash, documentId, signal);
877
+ } catch (error) {
878
+ answers.error(error);
879
+ return;
880
+ }
881
+ if (result.kind === "pending") {
882
+ if (result.busy) answers.busy ??= result;
883
+ else answers.pending ??= result;
884
+ return;
885
+ }
886
+ if (result.kind === "not-found") {
887
+ answers.notFound = true;
888
+ return;
889
+ }
890
+ try {
891
+ return await verified(hash, result);
892
+ } catch (error) {
893
+ answers.error(error);
894
+ return;
895
+ }
896
+ }
897
+ /** The first verified data wins and cancels the other peers. */
898
+ askPeers(peers, hash, documentId, answers, signal) {
899
+ const controllers = peers.map(() => new AbortController());
900
+ const abortAll = () => {
901
+ for (const controller of controllers) controller.abort();
902
+ };
903
+ if (signal?.aborted) abortAll();
904
+ signal?.addEventListener("abort", abortAll, { once: true });
905
+ return new Promise((resolve) => {
906
+ let remaining = peers.length;
907
+ let won = false;
908
+ peers.forEach((peer, index) => {
909
+ this.ask(peer, hash, documentId, answers, controllers[index].signal).then((data) => {
910
+ remaining -= 1;
911
+ if (data && !won) {
912
+ won = true;
913
+ controllers.forEach((controller, other) => {
914
+ if (other !== index) controller.abort();
915
+ });
916
+ resolve(data);
917
+ return;
918
+ }
919
+ if (remaining === 0 && !won) resolve(void 0);
920
+ });
921
+ });
922
+ }).finally(() => signal?.removeEventListener("abort", abortAll));
923
+ }
924
+ /** Best effort across every source. */
925
+ async announce(hash) {
926
+ await Promise.allSettled(this.sources().map((source) => source.announce(hash)));
927
+ }
928
+ push() {
929
+ return Promise.reject(/* @__PURE__ */ new Error("PeeredAttachmentTransport is pull-only: bytes move when a reactor references a hash it lacks, not by being pushed"));
930
+ }
931
+ sources() {
932
+ return [...[...this.peers.values()].map((entry) => entry.transport), ...this.switchboardSources().map((url) => this.switchboardFor(url))];
933
+ }
934
+ switchboardFor(url) {
935
+ const existing = this.switchboards.get(url);
936
+ if (existing) return existing;
937
+ const transport = new SwitchboardAttachmentTransport({
938
+ remoteUrl: url,
939
+ ...this.jwtHandler ? { jwtHandler: this.jwtHandler } : {},
940
+ ...this.fetchFn ? { fetchFn: this.fetchFn } : {}
941
+ });
942
+ this.switchboards.set(url, transport);
943
+ return transport;
944
+ }
945
+ };
946
+ /** NUL-joined so no peer id or channel name can forge another pair's key. */
947
+ function peerKey(peerId, channelName) {
948
+ return `${peerId}\u0000${channelName}`;
949
+ }
950
+ /** Attachments are served from the origin, not under the GraphQL path. */
951
+ function attachmentOriginOf(graphqlUrl) {
952
+ try {
953
+ return new URL(graphqlUrl).origin;
954
+ } catch {
955
+ const marker = graphqlUrl.lastIndexOf("/graphql");
956
+ return (marker >= 0 ? graphqlUrl.slice(0, marker) : graphqlUrl).replace(/\/+$/, "");
957
+ }
958
+ }
959
+ //#endregion
960
+ //#region src/replication/retry-state.ts
961
+ /** setTimeout's largest delay; a longer one fires at once. */
962
+ const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
963
+ function newRetryEntry(documentId) {
964
+ return {
965
+ notFoundAnswers: 0,
966
+ errorRun: 0,
967
+ pendingRun: 0,
968
+ busyRun: 0,
969
+ livePending: void 0,
970
+ unasked: [documentId],
971
+ asked: []
972
+ };
973
+ }
974
+ /** The document the next attempt asks through. */
975
+ function documentToAsk(entry) {
976
+ return entry.unasked[0] ?? entry.asked[0];
977
+ }
978
+ /** Every counter zeroed and every document unasked again. */
979
+ function resetRetry(documentIds) {
980
+ return {
981
+ notFoundAnswers: 0,
982
+ errorRun: 0,
983
+ pendingRun: 0,
984
+ busyRun: 0,
985
+ livePending: void 0,
986
+ unasked: [...documentIds],
987
+ asked: []
988
+ };
989
+ }
990
+ /** `revive`: a terminal entry earns one more attempt through the new document. */
991
+ function withDocument(entry, documentId, state) {
992
+ return {
993
+ entry: {
994
+ ...entry,
995
+ unasked: [...entry.unasked, documentId]
996
+ },
997
+ revive: state === "not-found" || state === "failed"
998
+ };
999
+ }
1000
+ /** The whole retry policy; `now` is epoch ms. */
1001
+ function nextAfter(entry, outcome, now, policy) {
1002
+ switch (outcome.kind) {
1003
+ case "data": return {
1004
+ entry: {
1005
+ ...entry,
1006
+ errorRun: 0,
1007
+ pendingRun: 0,
1008
+ busyRun: 0,
1009
+ livePending: void 0
1010
+ },
1011
+ state: "held",
1012
+ delayMs: void 0
1013
+ };
1014
+ case "aborted": return {
1015
+ entry,
1016
+ state: "queued",
1017
+ delayMs: void 0
1018
+ };
1019
+ case "pending": {
1020
+ const untilMs = typeof outcome.expiresAtUtc === "string" ? Date.parse(outcome.expiresAtUtc) : NaN;
1021
+ if (!(untilMs > now)) return notFound(entry, outcome.documentId, now, policy);
1022
+ const pendingRun = entry.pendingRun + 1;
1023
+ return {
1024
+ entry: {
1025
+ ...entry,
1026
+ errorRun: 0,
1027
+ pendingRun,
1028
+ busyRun: 0,
1029
+ livePending: {
1030
+ documentId: outcome.documentId,
1031
+ untilMs
1032
+ },
1033
+ unasked: without(entry.unasked, outcome.documentId),
1034
+ asked: [outcome.documentId, ...without(entry.asked, outcome.documentId)]
1035
+ },
1036
+ state: "waiting",
1037
+ delayMs: pendingDelay(outcome.retryAfterMs, pendingRun, policy)
1038
+ };
1039
+ }
1040
+ case "busy": {
1041
+ const busyRun = entry.busyRun + 1;
1042
+ if (pendingGrowth(outcome.retryAfterMs, busyRun, policy) >= policy.maxPendingRetryMs) return outcome.othersNotFound ? notFound(entry, outcome.documentId, now, policy) : failure(entry, outcome.documentId, policy);
1043
+ return {
1044
+ entry: {
1045
+ ...entry,
1046
+ busyRun
1047
+ },
1048
+ state: "waiting",
1049
+ delayMs: pendingDelay(outcome.retryAfterMs, busyRun, policy)
1050
+ };
1051
+ }
1052
+ case "not-found": return notFound(entry, outcome.documentId, now, policy);
1053
+ case "error": return failure(entry, outcome.documentId, policy);
1054
+ }
1055
+ }
1056
+ function failure(entry, documentId, policy) {
1057
+ const errorRun = entry.errorRun + 1;
1058
+ if (errorRun < policy.errorAttempts) return {
1059
+ entry: {
1060
+ ...entry,
1061
+ errorRun,
1062
+ pendingRun: 0,
1063
+ busyRun: 0
1064
+ },
1065
+ state: "waiting",
1066
+ delayMs: backoff(policy.errorRetryMs, errorRun, policy)
1067
+ };
1068
+ const next = {
1069
+ ...entry,
1070
+ errorRun,
1071
+ pendingRun: 0,
1072
+ busyRun: 0,
1073
+ unasked: without(entry.unasked, documentId),
1074
+ asked: entry.asked.includes(documentId) ? entry.asked : [...entry.asked, documentId]
1075
+ };
1076
+ if (next.unasked.length > 0) return {
1077
+ entry: next,
1078
+ state: "waiting",
1079
+ delayMs: policy.errorRetryMs
1080
+ };
1081
+ return {
1082
+ entry: next,
1083
+ state: "failed",
1084
+ delayMs: void 0
1085
+ };
1086
+ }
1087
+ function notFound(entry, documentId, now, policy) {
1088
+ const notFoundAnswers = entry.notFoundAnswers + 1;
1089
+ const livePending = entry.livePending && entry.livePending.documentId !== documentId && entry.livePending.untilMs > now ? entry.livePending : void 0;
1090
+ const next = {
1091
+ ...entry,
1092
+ notFoundAnswers,
1093
+ errorRun: 0,
1094
+ pendingRun: 0,
1095
+ busyRun: 0,
1096
+ livePending,
1097
+ unasked: without(entry.unasked, documentId),
1098
+ asked: [...without(entry.asked, documentId), documentId]
1099
+ };
1100
+ if (next.unasked.length > 0 || livePending !== void 0) return {
1101
+ entry: next,
1102
+ state: "waiting",
1103
+ delayMs: policy.notFoundRetryMs
1104
+ };
1105
+ if (notFoundAnswers < policy.notFoundAttempts) return {
1106
+ entry: next,
1107
+ state: "waiting",
1108
+ delayMs: backoff(policy.notFoundRetryMs, notFoundAnswers, policy)
1109
+ };
1110
+ return {
1111
+ entry: next,
1112
+ state: "not-found",
1113
+ delayMs: void 0
1114
+ };
1115
+ }
1116
+ /** A run's uncapped wait: the asked delay, floored, doubled per answer. */
1117
+ function pendingGrowth(retryAfterMs, run, policy) {
1118
+ const asked = Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : policy.pendingRetryMs;
1119
+ return Math.max(asked, policy.minPendingRetryMs) * 2 ** (run - 1);
1120
+ }
1121
+ function pendingDelay(retryAfterMs, pendingRun, policy) {
1122
+ const grown = Math.min(pendingGrowth(retryAfterMs, pendingRun, policy), policy.maxPendingRetryMs);
1123
+ return Math.min(Math.max(grown, policy.minPendingRetryMs), MAX_TIMER_DELAY_MS);
1124
+ }
1125
+ function backoff(baseMs, run, policy) {
1126
+ return Math.min(baseMs * 2 ** (run - 1), policy.maxRetryMs, MAX_TIMER_DELAY_MS);
1127
+ }
1128
+ function without(list, item) {
1129
+ return list.filter((entry) => entry !== item);
1130
+ }
1131
+ //#endregion
1132
+ //#region src/replication/types.ts
1133
+ const DEFAULT_ATTACHMENT_RETRY_POLICY = {
1134
+ pendingRetryMs: 5e3,
1135
+ minPendingRetryMs: 250,
1136
+ maxPendingRetryMs: 3e5,
1137
+ notFoundAttempts: 3,
1138
+ notFoundRetryMs: 2e3,
1139
+ errorAttempts: 5,
1140
+ errorRetryMs: 1e3,
1141
+ maxRetryMs: 3e5
1142
+ };
1143
+ /** Default bounded concurrency for in-flight attachment fetches. */
1144
+ const DEFAULT_ATTACHMENT_REPLICATION_CONCURRENCY = 3;
1145
+ /** Default number of held hashes a replicator remembers after dropping their entries. */
1146
+ const DEFAULT_ATTACHMENT_HELD_HASH_LIMIT = 1e4;
1147
+ /** Default page size for the boot re-scan over the reference backlog. */
1148
+ const DEFAULT_ATTACHMENT_BACKLOG_PAGE_SIZE = 200;
1149
+ //#endregion
1150
+ //#region src/replication/attachment-replicator.ts
1151
+ const SYSTEM_REPLICATION_TIMERS = {
1152
+ now: () => Date.now(),
1153
+ setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
1154
+ clearTimer: (handle) => clearTimeout(handle)
1155
+ };
1156
+ /**
1157
+ * Lazy fetch-on-reference: pulls attachment bytes a reactor's own operations
1158
+ * reference but whose hash its local store lacks.
1159
+ *
1160
+ * The seam is `ReactorEventTypes.JOB_READ_READY` on the reactor's event bus,
1161
+ * and that choice matters in two ways. It is AFTER the pre-ready read models,
1162
+ * so by the time the replicator asks a peer for a hash, this reactor's own
1163
+ * attachment reference index has recorded the reference -- the same reactor is
1164
+ * therefore able to serve the hash onward, and a reactor cannot be in the
1165
+ * position of chasing bytes it would refuse to authorize itself. And it is off
1166
+ * the write path: a slow or unreachable peer cannot delay a commit, because
1167
+ * the subscriber only notes hashes and returns.
1168
+ *
1169
+ * Resume is a re-scan of the reference index ({@link IAttachmentReferenceScanner}),
1170
+ * not a cursor: the index plus `store.has()` re-derives the exact outstanding
1171
+ * work set on every boot, idempotently, and a persisted cursor could only add a
1172
+ * second truth that is wrong in the one direction that loses data.
1173
+ *
1174
+ * Loop-safety is structural: one entry per hash, created once, and every
1175
+ * retry decision is {@link nextAfter}'s. A terminal entry (`not-found`,
1176
+ * `failed`) is never re-queued by a further reference from a document it
1177
+ * already knows; a document it has not seen earns it one more attempt, through
1178
+ * that document. Otherwise only {@link AttachmentReplicator.retry} moves a
1179
+ * terminal entry back. A held hash drops its entry and is remembered in a
1180
+ * bounded set, so the map holds only unfinished work.
1181
+ */
1182
+ var AttachmentReplicator = class {
1183
+ store;
1184
+ transport;
1185
+ refs;
1186
+ eventBus;
1187
+ backlog;
1188
+ concurrency;
1189
+ policy;
1190
+ backlogPageSize;
1191
+ heldHashLimit;
1192
+ verifyHash;
1193
+ timers;
1194
+ onDiagnostic;
1195
+ entries = /* @__PURE__ */ new Map();
1196
+ heldHashes = /* @__PURE__ */ new Set();
1197
+ queue = [];
1198
+ idleWaiters = [];
1199
+ aborts = /* @__PURE__ */ new Set();
1200
+ unsubscribe;
1201
+ running = false;
1202
+ inFlight = 0;
1203
+ timerHandle;
1204
+ timerAtMs;
1205
+ backlogScan;
1206
+ backlogDone = false;
1207
+ lastError;
1208
+ constructor(options) {
1209
+ this.store = options.store;
1210
+ this.transport = options.transport;
1211
+ this.refs = options.refs;
1212
+ this.eventBus = options.eventBus;
1213
+ this.backlog = options.backlog;
1214
+ this.concurrency = options.concurrency ?? 3;
1215
+ this.policy = {
1216
+ ...DEFAULT_ATTACHMENT_RETRY_POLICY,
1217
+ ...options.retry
1218
+ };
1219
+ this.backlogPageSize = options.backlogPageSize ?? 200;
1220
+ this.heldHashLimit = options.heldHashLimit ?? 1e4;
1221
+ this.verifyHash = options.verifyHash ?? true;
1222
+ this.timers = options.timers ?? SYSTEM_REPLICATION_TIMERS;
1223
+ this.onDiagnostic = options.onDiagnostic ?? (() => void 0);
1224
+ }
1225
+ /**
1226
+ * Subscribes to committed operations and starts the boot re-scan.
1227
+ *
1228
+ * The subscription is taken BEFORE the scan so no operation committed during
1229
+ * the scan can slip between the two. A hash both paths find is one entry.
1230
+ *
1231
+ * A restart resumes its own unfinished work: a `stop()` empties the queue but
1232
+ * keeps the entries, so entries left `queued` or `waiting` are re-armed here.
1233
+ * Without that, a hash that was outstanding at `stop()` would sit in the map
1234
+ * forever unless an unrelated live operation happened to pump the queue --
1235
+ * the backlog re-scan does not rescue it, because `observeReference` treats
1236
+ * an already-known hash as nothing to do.
1237
+ */
1238
+ start() {
1239
+ if (this.running) return;
1240
+ this.running = true;
1241
+ this.unsubscribe = this.eventBus.subscribe(ReactorEventTypes.JOB_READ_READY, (_type, event) => {
1242
+ this.observeOperations(event.operations);
1243
+ });
1244
+ this.resumeOutstanding();
1245
+ this.backlogScan = this.scanBacklog();
1246
+ }
1247
+ /**
1248
+ * Stops scheduling, drops the subscription and aborts in-flight fetches.
1249
+ *
1250
+ * Entries are kept: the in-memory retry counters are not durable state and a
1251
+ * restart legitimately starts the budgets over (a hash that was `not-found`
1252
+ * because a peer's index lagged deserves another look after a reboot), but a
1253
+ * `stop()` that is not a restart should not forget what is held either --
1254
+ * `store.has()` is re-checked before every fetch regardless.
1255
+ */
1256
+ async stop() {
1257
+ this.running = false;
1258
+ this.unsubscribe?.();
1259
+ this.unsubscribe = void 0;
1260
+ this.cancelTimer();
1261
+ for (const controller of this.aborts) controller.abort();
1262
+ this.aborts.clear();
1263
+ this.queue.length = 0;
1264
+ const scan = this.backlogScan;
1265
+ this.backlogScan = void 0;
1266
+ if (scan) await scan.catch(() => void 0);
1267
+ }
1268
+ /** Notes every ref carried by `items`. Synchronous: it only enqueues. */
1269
+ observeOperations(items) {
1270
+ for (const item of items) for (const ref of this.refs.refsOf(item)) this.observeReference(item.context.documentId, ref);
1271
+ }
1272
+ /** Notes one (document, ref) pair, queueing the hash if it is new. */
1273
+ observeReference(documentId, ref) {
1274
+ let hash;
1275
+ try {
1276
+ hash = parseRef(ref).hash;
1277
+ } catch (error) {
1278
+ this.onDiagnostic(`ignoring malformed attachment ref ${ref}`, error);
1279
+ return;
1280
+ }
1281
+ if (this.heldHashes.has(hash)) return;
1282
+ const existing = this.entries.get(hash);
1283
+ if (existing) {
1284
+ if (existing.documentIds.includes(documentId)) return;
1285
+ existing.documentIds.push(documentId);
1286
+ const { entry, revive } = withDocument(existing.retry, documentId, existing.state);
1287
+ existing.retry = entry;
1288
+ if (revive) {
1289
+ existing.state = "queued";
1290
+ if (!this.queue.includes(hash)) this.queue.push(hash);
1291
+ this.pump();
1292
+ }
1293
+ return;
1294
+ }
1295
+ this.entries.set(hash, {
1296
+ hash,
1297
+ state: "queued",
1298
+ documentIds: [documentId],
1299
+ attempts: 0,
1300
+ retry: newRetryEntry(documentId),
1301
+ nextAttemptAtMs: void 0,
1302
+ lastError: void 0
1303
+ });
1304
+ this.queue.push(hash);
1305
+ this.pump();
1306
+ }
1307
+ async status() {
1308
+ const counts = {
1309
+ queued: 0,
1310
+ fetching: 0,
1311
+ held: 0,
1312
+ waiting: 0,
1313
+ "not-found": 0,
1314
+ failed: 0
1315
+ };
1316
+ for (const entry of this.entries.values()) counts[entry.state] += 1;
1317
+ const bytesHeld = await this.store.storageUsed();
1318
+ return {
1319
+ running: this.running,
1320
+ refsSeen: this.entries.size + this.heldHashes.size,
1321
+ held: this.heldHashes.size,
1322
+ bytesHeld,
1323
+ queued: counts.queued,
1324
+ fetching: counts.fetching,
1325
+ waiting: counts.waiting,
1326
+ notFound: counts["not-found"],
1327
+ failed: counts.failed,
1328
+ backlogScanned: this.backlogDone,
1329
+ lastError: this.lastError
1330
+ };
1331
+ }
1332
+ /** Every hash not yet held and what is happening to it. */
1333
+ report() {
1334
+ return [...this.entries.values()].map((entry) => ({
1335
+ hash: entry.hash,
1336
+ state: entry.state,
1337
+ documentIds: [...entry.documentIds],
1338
+ attempts: entry.attempts,
1339
+ notFoundAnswers: entry.retry.notFoundAnswers,
1340
+ nextAttemptAtMs: entry.nextAttemptAtMs,
1341
+ lastError: entry.lastError
1342
+ }));
1343
+ }
1344
+ /**
1345
+ * Re-queues terminal entries so a `not-found` or `failed` hash is chased
1346
+ * again, resetting their budgets. `hash` narrows it to one; absent, every
1347
+ * terminal entry is retried.
1348
+ *
1349
+ * The deliberate manual lever over the reference-index race: when an
1350
+ * operator can see that a peer has caught up, nothing should make them wait
1351
+ * for a reboot to find out.
1352
+ */
1353
+ retry(hash) {
1354
+ const targets = hash ? [this.entries.get(hash)].filter((entry) => entry !== void 0) : [...this.entries.values()];
1355
+ for (const entry of targets) {
1356
+ if (entry.state === "held" || entry.state === "fetching") continue;
1357
+ entry.state = "queued";
1358
+ entry.retry = resetRetry(entry.documentIds);
1359
+ entry.nextAttemptAtMs = void 0;
1360
+ if (!this.queue.includes(entry.hash)) this.queue.push(entry.hash);
1361
+ }
1362
+ this.pump();
1363
+ }
1364
+ /**
1365
+ * Resolves when nothing is queued or in flight.
1366
+ *
1367
+ * Entries `waiting` on a retry deadline do NOT hold this open -- their next
1368
+ * attempt is the timer's business, and a caller that wants to reach it
1369
+ * advances the injected clock. A test seam, and what a load harness waits on.
1370
+ */
1371
+ idle() {
1372
+ if (this.queue.length === 0 && this.inFlight === 0) return Promise.resolve();
1373
+ return new Promise((resolve) => {
1374
+ this.idleWaiters.push(resolve);
1375
+ });
1376
+ }
1377
+ /** Awaits the boot re-scan; resolves immediately when there is no backlog. */
1378
+ async backlogScanned() {
1379
+ await this.backlogScan;
1380
+ }
1381
+ /** Re-queues every non-terminal entry a previous `stop()` stranded. */
1382
+ resumeOutstanding() {
1383
+ for (const entry of this.entries.values()) {
1384
+ if (entry.state !== "queued" && entry.state !== "waiting") continue;
1385
+ entry.state = "queued";
1386
+ entry.nextAttemptAtMs = void 0;
1387
+ if (!this.queue.includes(entry.hash)) this.queue.push(entry.hash);
1388
+ }
1389
+ this.pump();
1390
+ }
1391
+ async scanBacklog() {
1392
+ const backlog = this.backlog;
1393
+ if (!backlog) return;
1394
+ let cursor;
1395
+ for (;;) {
1396
+ if (!this.running) return;
1397
+ let page;
1398
+ try {
1399
+ page = await backlog.listReferences(cursor, this.backlogPageSize);
1400
+ } catch (error) {
1401
+ this.onDiagnostic("scanning the attachment reference backlog failed", error);
1402
+ return;
1403
+ }
1404
+ for (const reference of page.references) this.observeReference(reference.documentId, reference.ref);
1405
+ if (page.nextCursor === void 0) break;
1406
+ cursor = page.nextCursor;
1407
+ }
1408
+ this.backlogDone = true;
1409
+ }
1410
+ /** Starts as many queued fetches as the concurrency bound allows. */
1411
+ pump() {
1412
+ while (this.running && this.inFlight < this.concurrency && this.queue.length > 0) {
1413
+ const hash = this.queue.shift();
1414
+ if (hash === void 0) break;
1415
+ const entry = this.entries.get(hash);
1416
+ if (!entry || entry.state !== "queued") continue;
1417
+ this.fetchOne(entry);
1418
+ }
1419
+ this.settleIdle();
1420
+ }
1421
+ async fetchOne(entry) {
1422
+ entry.state = "fetching";
1423
+ entry.attempts += 1;
1424
+ this.inFlight += 1;
1425
+ const controller = new AbortController();
1426
+ this.aborts.add(controller);
1427
+ const documentId = documentToAsk(entry.retry) ?? entry.documentIds[0];
1428
+ let outcome;
1429
+ try {
1430
+ outcome = await this.attempt(entry, documentId, controller.signal);
1431
+ } catch (error) {
1432
+ if (controller.signal.aborted) outcome = { kind: "aborted" };
1433
+ else {
1434
+ this.recordError(entry, error);
1435
+ outcome = {
1436
+ kind: "error",
1437
+ documentId
1438
+ };
1439
+ }
1440
+ }
1441
+ try {
1442
+ this.apply(entry, outcome);
1443
+ } finally {
1444
+ this.aborts.delete(controller);
1445
+ this.inFlight -= 1;
1446
+ this.pump();
1447
+ }
1448
+ }
1449
+ async attempt(entry, documentId, signal) {
1450
+ if (await this.store.has(entry.hash)) return { kind: "data" };
1451
+ signal.throwIfAborted();
1452
+ const result = await this.transport.fetch(entry.hash, documentId, signal);
1453
+ if (result.kind === "pending" && result.busy) return {
1454
+ kind: "busy",
1455
+ documentId,
1456
+ retryAfterMs: result.retryAfterMs,
1457
+ othersNotFound: result.othersNotFound === true
1458
+ };
1459
+ if (result.kind === "pending") return {
1460
+ kind: "pending",
1461
+ documentId,
1462
+ expiresAtUtc: result.expiresAtUtc,
1463
+ retryAfterMs: result.retryAfterMs
1464
+ };
1465
+ if (result.kind === "not-found") return {
1466
+ kind: "not-found",
1467
+ documentId
1468
+ };
1469
+ const bytes = await collectStream(result.response.body);
1470
+ if (this.verifyHash) {
1471
+ const actual = await sha256Hex(bytes);
1472
+ if (actual !== entry.hash) throw new Error(`Attachment bytes for ${entry.hash} hashed to ${actual}; the peer served content that is not what was asked for`);
1473
+ }
1474
+ await this.store.put(entry.hash, result.response.metadata, streamFromBytes(bytes));
1475
+ return { kind: "data" };
1476
+ }
1477
+ apply(entry, outcome) {
1478
+ const next = nextAfter(entry.retry, outcome, this.timers.now(), this.policy);
1479
+ if (outcome.kind === "busy" && next.entry.errorRun > entry.retry.errorRun) this.recordError(entry, /* @__PURE__ */ new Error(`the attachment source stayed busy for ${entry.hash} through a full backoff`));
1480
+ entry.retry = next.entry;
1481
+ switch (next.state) {
1482
+ case "held":
1483
+ this.markHeld(entry);
1484
+ return;
1485
+ case "waiting":
1486
+ this.schedule(entry, next.delayMs ?? 0);
1487
+ return;
1488
+ case "queued":
1489
+ entry.state = "queued";
1490
+ entry.nextAttemptAtMs = void 0;
1491
+ if (this.running && !this.queue.includes(entry.hash)) this.queue.push(entry.hash);
1492
+ return;
1493
+ case "not-found":
1494
+ case "failed":
1495
+ entry.state = next.state;
1496
+ entry.nextAttemptAtMs = void 0;
1497
+ return;
1498
+ }
1499
+ }
1500
+ markHeld(entry) {
1501
+ entry.state = "held";
1502
+ entry.nextAttemptAtMs = void 0;
1503
+ this.entries.delete(entry.hash);
1504
+ this.heldHashes.delete(entry.hash);
1505
+ this.heldHashes.add(entry.hash);
1506
+ for (const oldest of this.heldHashes) {
1507
+ if (this.heldHashes.size <= this.heldHashLimit) break;
1508
+ this.heldHashes.delete(oldest);
1509
+ }
1510
+ }
1511
+ recordError(entry, error) {
1512
+ const message = error instanceof Error ? error.message : String(error);
1513
+ entry.lastError = message;
1514
+ this.lastError = message;
1515
+ this.onDiagnostic(`fetching attachment ${entry.hash} failed`, error);
1516
+ }
1517
+ schedule(entry, delayMs) {
1518
+ entry.state = "waiting";
1519
+ entry.nextAttemptAtMs = this.timers.now() + Math.min(Math.max(delayMs, 0), MAX_TIMER_DELAY_MS);
1520
+ this.armTimer();
1521
+ }
1522
+ /** Keeps exactly one timer, set to the earliest outstanding deadline. */
1523
+ armTimer() {
1524
+ if (!this.running) return;
1525
+ let earliest;
1526
+ for (const entry of this.entries.values()) {
1527
+ if (entry.state !== "waiting" || entry.nextAttemptAtMs === void 0) continue;
1528
+ if (earliest === void 0 || entry.nextAttemptAtMs < earliest) earliest = entry.nextAttemptAtMs;
1529
+ }
1530
+ if (earliest === void 0) {
1531
+ this.cancelTimer();
1532
+ return;
1533
+ }
1534
+ if (this.timerAtMs !== void 0 && this.timerAtMs <= earliest) return;
1535
+ this.cancelTimer();
1536
+ this.timerAtMs = earliest;
1537
+ this.timerHandle = this.timers.setTimer(() => this.onTimer(), Math.max(earliest - this.timers.now(), 0));
1538
+ }
1539
+ onTimer() {
1540
+ this.timerHandle = void 0;
1541
+ this.timerAtMs = void 0;
1542
+ if (!this.running) return;
1543
+ const nowMs = this.timers.now();
1544
+ for (const entry of this.entries.values()) if (entry.state === "waiting" && entry.nextAttemptAtMs !== void 0 && entry.nextAttemptAtMs <= nowMs) {
1545
+ entry.state = "queued";
1546
+ entry.nextAttemptAtMs = void 0;
1547
+ if (!this.queue.includes(entry.hash)) this.queue.push(entry.hash);
1548
+ }
1549
+ this.pump();
1550
+ this.armTimer();
1551
+ }
1552
+ cancelTimer() {
1553
+ if (this.timerHandle !== void 0) this.timers.clearTimer(this.timerHandle);
1554
+ this.timerHandle = void 0;
1555
+ this.timerAtMs = void 0;
1556
+ }
1557
+ settleIdle() {
1558
+ if (this.queue.length > 0 || this.inFlight > 0) return;
1559
+ for (const resolve of this.idleWaiters.splice(0)) resolve();
1560
+ }
1561
+ };
1562
+ /** A scanner over a plain list, for a host without a reference index. */
1563
+ function staticAttachmentReferenceScanner(references) {
1564
+ return { listReferences: (cursor, limit) => {
1565
+ const start = cursor === void 0 ? 0 : Number(cursor);
1566
+ const page = references.slice(start, start + limit);
1567
+ const next = start + page.length;
1568
+ return Promise.resolve({
1569
+ references: page,
1570
+ nextCursor: next < references.length ? String(next) : void 0
1571
+ });
1572
+ } };
1573
+ }
1574
+ //#endregion
1575
+ //#region src/replication/operation-attachment-refs.ts
1576
+ /**
1577
+ * The production {@link IOperationAttachmentRefs}: the compiled, schema-driven
1578
+ * extractor, reused rather than reimplemented.
1579
+ *
1580
+ * This is the same `(registry, compiler)` pair `AttachmentReferenceReadModel`
1581
+ * runs, so a ref the index records and a ref the replicator chases are found by
1582
+ * one piece of code -- including the compiler's per-module/per-action cache,
1583
+ * which is held by the compiler instance a host shares between the two. A host
1584
+ * with no read model to share one with takes the default compiler.
1585
+ *
1586
+ * Extraction failures do NOT propagate. A malformed ref or an unregistered
1587
+ * document type must not take down the event-bus subscriber the replicator
1588
+ * runs on: that would turn an attachment problem into a reactor-wide write
1589
+ * failure, since `IEventBus.emit` aggregates subscriber errors back to the
1590
+ * emitter. The read model is the component whose job is to refuse a bad ref
1591
+ * loudly; this one is a best-effort byte chaser, so it reports and moves on.
1592
+ */
1593
+ var SchemaCompiledOperationRefs = class {
1594
+ registry;
1595
+ compiler;
1596
+ onDiagnostic;
1597
+ constructor(registry, compiler = new AttachmentSchemaCompiler(), onDiagnostic = () => void 0) {
1598
+ this.registry = registry;
1599
+ this.compiler = compiler;
1600
+ this.onDiagnostic = onDiagnostic;
1601
+ }
1602
+ refsOf(item) {
1603
+ const { operation, context } = item;
1604
+ if (isPurgeMarker(operation) || operation.error !== void 0) return [];
1605
+ try {
1606
+ const module = this.registry.getModule(context.documentType);
1607
+ return this.compiler.forModuleAction(module, operation.action.type).extract(operation.action);
1608
+ } catch (error) {
1609
+ this.onDiagnostic(`extracting attachment refs from ${context.documentType}/${operation.action.type} failed`, error);
1610
+ return [];
1611
+ }
1612
+ }
1613
+ };
1614
+ //#endregion
1615
+ //#region src/storage/local/idb-attachment-backend.ts
1616
+ /** Metadata rows, keyed by content hash. */
1617
+ const IDB_RECORD_STORE = "records";
1618
+ /** Blob rows (`{ hash, bytes }`), keyed by the same content hash. */
1619
+ const IDB_BLOB_STORE = "blobs";
1620
+ /** Index over {@link LocalAttachmentRecord.status}; what `storageUsed()` scans. */
1621
+ const IDB_STATUS_INDEX = "by_status";
1622
+ /** Default database name. One database holds one reactor's attachment store. */
1623
+ const DEFAULT_IDB_DATABASE = "ph-attachments";
1624
+ const IDB_VERSION = 1;
1625
+ function requestResult(request) {
1626
+ return new Promise((resolve, reject) => {
1627
+ request.onsuccess = () => resolve(request.result);
1628
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("IndexedDB request failed"));
1629
+ });
1630
+ }
1631
+ function transactionDone(transaction) {
1632
+ return new Promise((resolve, reject) => {
1633
+ transaction.oncomplete = () => resolve();
1634
+ transaction.onabort = () => reject(transaction.error ?? /* @__PURE__ */ new Error("IndexedDB transaction aborted"));
1635
+ transaction.onerror = () => reject(transaction.error ?? /* @__PURE__ */ new Error("IndexedDB transaction failed"));
1636
+ });
1637
+ }
1638
+ function toArrayBuffer(bytes) {
1639
+ const copy = new Uint8Array(bytes.byteLength);
1640
+ copy.set(bytes);
1641
+ return copy.buffer;
1642
+ }
1643
+ /**
1644
+ * An IndexedDB-backed {@link ILocalAttachmentBackend}: the browser-resident
1645
+ * attachment store a reactor in a tab or a SharedWorker replicates into.
1646
+ *
1647
+ * Two object stores, written in one transaction so a record and its blob land
1648
+ * or fail together (the atomicity {@link ILocalAttachmentBackend.write}
1649
+ * requires). Bytes are kept as `ArrayBuffer` rather than `Blob`: both are
1650
+ * structured-clone-safe values IndexedDB stores natively, and an ArrayBuffer
1651
+ * needs no async read to get back to the `Uint8Array` the store serves.
1652
+ *
1653
+ * OPFS is the obvious later alternative for large blobs -- it streams and does
1654
+ * not hold a whole attachment in the realm's heap -- and nothing above this
1655
+ * interface would change: it is one more backend.
1656
+ *
1657
+ * The database handle is opened lazily on first use and reused, so constructing
1658
+ * this in a realm that has no `indexedDB` (a Node import of the barrel) costs
1659
+ * nothing and fails only if something actually reads or writes.
1660
+ */
1661
+ var IdbAttachmentBackend = class {
1662
+ databaseName;
1663
+ factory;
1664
+ opening;
1665
+ database;
1666
+ closed = false;
1667
+ constructor(options = {}) {
1668
+ this.databaseName = options.databaseName ?? "ph-attachments";
1669
+ this.factory = options.indexedDB ?? globalThis.indexedDB;
1670
+ }
1671
+ async readRecord(hash) {
1672
+ return await requestResult((await this.open()).transaction("records", "readonly").objectStore("records").get(hash)) ?? void 0;
1673
+ }
1674
+ async write(record, bytes) {
1675
+ const transaction = (await this.open()).transaction([IDB_RECORD_STORE, IDB_BLOB_STORE], "readwrite");
1676
+ const row = {
1677
+ hash: record.hash,
1678
+ bytes: toArrayBuffer(bytes)
1679
+ };
1680
+ transaction.objectStore(IDB_RECORD_STORE).put(record);
1681
+ transaction.objectStore(IDB_BLOB_STORE).put(row);
1682
+ await transactionDone(transaction);
1683
+ }
1684
+ async readBytes(hash) {
1685
+ const row = await requestResult((await this.open()).transaction(IDB_BLOB_STORE, "readonly").objectStore(IDB_BLOB_STORE).get(hash));
1686
+ return row ? new Uint8Array(row.bytes) : void 0;
1687
+ }
1688
+ async evict(record) {
1689
+ const transaction = (await this.open()).transaction([IDB_RECORD_STORE, IDB_BLOB_STORE], "readwrite");
1690
+ transaction.objectStore(IDB_RECORD_STORE).put(record);
1691
+ transaction.objectStore(IDB_BLOB_STORE).delete(record.hash);
1692
+ await transactionDone(transaction);
1693
+ }
1694
+ async touch(hash, lastAccessedAtUtc) {
1695
+ const transaction = (await this.open()).transaction(IDB_RECORD_STORE, "readwrite");
1696
+ const store = transaction.objectStore(IDB_RECORD_STORE);
1697
+ const existing = await requestResult(store.get(hash));
1698
+ if (existing) store.put({
1699
+ ...existing,
1700
+ lastAccessedAtUtc
1701
+ });
1702
+ await transactionDone(transaction);
1703
+ }
1704
+ async availableBytes() {
1705
+ return (await requestResult((await this.open()).transaction(IDB_RECORD_STORE, "readonly").objectStore(IDB_RECORD_STORE).index(IDB_STATUS_INDEX).getAll("available"))).reduce((total, row) => total + row.sizeBytes, 0);
1706
+ }
1707
+ close() {
1708
+ this.closed = true;
1709
+ this.database?.close();
1710
+ this.database = void 0;
1711
+ this.opening = void 0;
1712
+ return Promise.resolve();
1713
+ }
1714
+ open() {
1715
+ if (this.closed) return Promise.reject(/* @__PURE__ */ new Error(`IndexedDB attachment backend for database "${this.databaseName}" is closed`));
1716
+ if (this.database) return Promise.resolve(this.database);
1717
+ if (!this.opening) {
1718
+ const opening = this.openDatabase();
1719
+ this.opening = opening;
1720
+ opening.catch(() => {
1721
+ if (this.opening === opening) this.opening = void 0;
1722
+ });
1723
+ }
1724
+ return this.opening;
1725
+ }
1726
+ openDatabase() {
1727
+ const factory = this.factory;
1728
+ if (!factory) return Promise.reject(/* @__PURE__ */ new Error("No IndexedDB in this realm; the IndexedDB attachment backend needs a browser realm or an injected IDBFactory"));
1729
+ const request = factory.open(this.databaseName, IDB_VERSION);
1730
+ request.onupgradeneeded = () => {
1731
+ const db = request.result;
1732
+ if (!db.objectStoreNames.contains("records")) db.createObjectStore(IDB_RECORD_STORE, { keyPath: "hash" }).createIndex(IDB_STATUS_INDEX, "status", { unique: false });
1733
+ if (!db.objectStoreNames.contains("blobs")) db.createObjectStore(IDB_BLOB_STORE, { keyPath: "hash" });
1734
+ };
1735
+ return requestResult(request).then((db) => {
1736
+ if (this.closed) {
1737
+ db.close();
1738
+ throw new Error(`IndexedDB attachment backend for database "${this.databaseName}" is closed`);
1739
+ }
1740
+ this.database = db;
1741
+ const forget = () => {
1742
+ if (this.database === db) {
1743
+ this.database = void 0;
1744
+ this.opening = void 0;
1745
+ }
1746
+ };
1747
+ db.onversionchange = () => {
1748
+ db.close();
1749
+ forget();
1750
+ };
1751
+ db.onclose = forget;
1752
+ return db;
1753
+ });
1754
+ }
1755
+ };
1756
+ //#endregion
1757
+ //#region src/storage/local/local-attachment-store.ts
1758
+ function headerOf(record) {
1759
+ return {
1760
+ hash: record.hash,
1761
+ mimeType: record.mimeType,
1762
+ fileName: record.fileName,
1763
+ sizeBytes: record.sizeBytes,
1764
+ extension: record.extension,
1765
+ status: record.status,
1766
+ source: record.source,
1767
+ createdAtUtc: record.createdAtUtc,
1768
+ lastAccessedAtUtc: record.lastAccessedAtUtc,
1769
+ expiresAtUtc: null
1770
+ };
1771
+ }
1772
+ /**
1773
+ * A realm-resident, content-addressed {@link IAttachmentStore} over a
1774
+ * {@link ILocalAttachmentBackend} -- the browser-capable half of attachment
1775
+ * byte movement.
1776
+ *
1777
+ * The same class serves both backends, so an IndexedDB store in a tab and the
1778
+ * memory twin a Node test runs cannot diverge in behaviour: every semantic the
1779
+ * `IAttachmentStore` contract states is implemented once, here.
1780
+ *
1781
+ * Differences from `KyselyAttachmentStore`, all of them deliberate:
1782
+ *
1783
+ * - No reservation table, so `stat()`/`get()` never synthesize `pending` from
1784
+ * local state. `pending` only ever arrives as a TRANSPORT answer, which
1785
+ * `get()` raises as {@link AttachmentPending} exactly as the Kysely store
1786
+ * does for a remote pending.
1787
+ * - `get()` reads the whole blob before it hands back a stream, so there is no
1788
+ * such thing as an in-flight read for `evict()` to destroy. The contract's
1789
+ * "skip hashes with active readers" clause is satisfied by construction
1790
+ * rather than by a refcount; a caller already holding a stream holds a
1791
+ * snapshot.
1792
+ * - `sizeBytes` is the measured length of the bytes received, not the
1793
+ * producer's `AttachmentMetadata.sizeBytes` claim, so `storageUsed()` cannot
1794
+ * be talked into a wrong number by a remote.
1795
+ */
1796
+ var LocalAttachmentStore = class {
1797
+ backend;
1798
+ transport;
1799
+ now;
1800
+ constructor(backend, transport, now = () => /* @__PURE__ */ new Date()) {
1801
+ this.backend = backend;
1802
+ this.transport = transport;
1803
+ this.now = now;
1804
+ }
1805
+ async stat(hash) {
1806
+ const record = await this.backend.readRecord(hash);
1807
+ if (!record) throw new AttachmentNotFound(hash);
1808
+ return headerOf(record);
1809
+ }
1810
+ async has(hash) {
1811
+ return (await this.backend.readRecord(hash))?.status === "available";
1812
+ }
1813
+ async get(hash, signal, documentId) {
1814
+ const record = await this.backend.readRecord(hash);
1815
+ if (!record || record.status === "evicted") return this.fetchRemote(hash, signal, documentId);
1816
+ const bytes = await this.backend.readBytes(hash);
1817
+ if (!bytes) return this.fetchRemote(hash, signal, documentId);
1818
+ const accessedAt = this.now().toISOString();
1819
+ await this.backend.touch(hash, accessedAt);
1820
+ return {
1821
+ header: {
1822
+ ...headerOf(record),
1823
+ lastAccessedAtUtc: accessedAt
1824
+ },
1825
+ body: streamFromBytes(bytes)
1826
+ };
1827
+ }
1828
+ put(hash, metadata, data) {
1829
+ return this.store(hash, metadata, data, "sync", false);
1830
+ }
1831
+ async evict(hash) {
1832
+ const record = await this.backend.readRecord(hash);
1833
+ if (!record || record.status === "evicted") return;
1834
+ await this.backend.evict({
1835
+ ...record,
1836
+ status: "evicted"
1837
+ });
1838
+ }
1839
+ storageUsed() {
1840
+ return this.backend.availableBytes();
1841
+ }
1842
+ /** Releases the backend handle. */
1843
+ close() {
1844
+ return this.backend.close();
1845
+ }
1846
+ /**
1847
+ * Stores bytes this realm produced itself, as `source: "local"`.
1848
+ *
1849
+ * `put()` is the transport's entry point and marks everything `sync`, which
1850
+ * is the right provenance for a replicated blob and the wrong one for a blob
1851
+ * that originated here. The server-side store gets `source: "local"` rows
1852
+ * from its upload handles; a local store has no upload handle, so this is
1853
+ * how a producer (an editor that just hashed a file, a test that seeds a
1854
+ * peer) says the bytes are its own.
1855
+ */
1856
+ putLocal(hash, metadata, data) {
1857
+ return this.store(hash, metadata, data, "local", false);
1858
+ }
1859
+ /**
1860
+ * `overwrite` skips the available-hash dedup check.
1861
+ *
1862
+ * Only {@link fetchRemote} sets it, and only because it is reached when the
1863
+ * local copy is unusable -- including the torn case where the record says
1864
+ * `available` over a missing blob. Without it, that record would deduplicate
1865
+ * away its own repair and the hash could never be restored.
1866
+ */
1867
+ async store(hash, metadata, data, source, overwrite) {
1868
+ const existing = await this.backend.readRecord(hash);
1869
+ if (!overwrite && existing?.status === "available") {
1870
+ await data.cancel();
1871
+ return;
1872
+ }
1873
+ const bytes = await collectStream(data);
1874
+ await this.backend.write({
1875
+ hash,
1876
+ mimeType: metadata.mimeType,
1877
+ fileName: metadata.fileName,
1878
+ sizeBytes: bytes.byteLength,
1879
+ extension: metadata.extension ?? null,
1880
+ status: "available",
1881
+ source: existing?.source ?? source,
1882
+ createdAtUtc: existing?.createdAtUtc ?? metadata.createdAtUtc,
1883
+ lastAccessedAtUtc: this.now().toISOString()
1884
+ }, bytes);
1885
+ }
1886
+ /**
1887
+ * Restores `hash` from the transport on behalf of `documentId`, persists it
1888
+ * through the same write path `put` uses (so provenance follows one rule)
1889
+ * and serves the result from the store rather than from the transport's
1890
+ * stream, which has already been consumed.
1891
+ *
1892
+ * The fetched bytes are hashed and refused when they are not what was asked
1893
+ * for, mirroring {@link AttachmentReplicator}: a transport peer is another
1894
+ * reactor rather than a trusted server, and a content-addressed store that
1895
+ * accepts bytes it never verified is no longer content-addressed. A mismatch
1896
+ * is surfaced as an error and nothing is written, so a lying peer cannot
1897
+ * poison the store.
1898
+ */
1899
+ async fetchRemote(hash, signal, documentId) {
1900
+ if (documentId === void 0) throw new AttachmentNotFound(hash);
1901
+ const remote = await this.transport.fetch(hash, documentId, signal);
1902
+ if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
1903
+ if (remote.kind === "not-found") throw new AttachmentNotFound(hash);
1904
+ const fetchedBytes = await collectStream(remote.response.body);
1905
+ const actual = await sha256Hex(fetchedBytes);
1906
+ if (actual !== hash) throw new Error(`Attachment bytes for ${hash} hashed to ${actual}; the transport served content that is not what was asked for`);
1907
+ await this.store(hash, remote.response.metadata, streamFromBytes(fetchedBytes), "sync", true);
1908
+ const record = await this.backend.readRecord(hash);
1909
+ const bytes = await this.backend.readBytes(hash);
1910
+ if (!record || record.status !== "available" || !bytes) throw new AttachmentNotFound(hash);
1911
+ return {
1912
+ header: headerOf(record),
1913
+ body: streamFromBytes(bytes)
1914
+ };
1915
+ }
1916
+ };
1917
+ //#endregion
1918
+ //#region src/storage/local/memory-attachment-backend.ts
1919
+ /**
1920
+ * The in-realm memory twin of {@link IdbAttachmentBackend}.
1921
+ *
1922
+ * It exists so the whole {@link LocalAttachmentStore} contract -- and the
1923
+ * replicator above it -- is testable in Node without an IndexedDB shim, and so
1924
+ * a reactor that wants a deliberately ephemeral attachment store (a test, a
1925
+ * throwaway reactor) has one. The backend contract suite runs against
1926
+ * this and against the IndexedDB backend, so the two answer identically.
1927
+ *
1928
+ * Records are copied in and out: a caller mutating a returned record must not
1929
+ * be able to change what the store holds, which is the behaviour a real
1930
+ * persistence layer has for free.
1931
+ */
1932
+ var MemoryAttachmentBackend = class {
1933
+ records = /* @__PURE__ */ new Map();
1934
+ blobs = /* @__PURE__ */ new Map();
1935
+ readRecord(hash) {
1936
+ const record = this.records.get(hash);
1937
+ return Promise.resolve(record ? { ...record } : void 0);
1938
+ }
1939
+ write(record, bytes) {
1940
+ this.records.set(record.hash, { ...record });
1941
+ this.blobs.set(record.hash, bytes.slice());
1942
+ return Promise.resolve();
1943
+ }
1944
+ readBytes(hash) {
1945
+ const bytes = this.blobs.get(hash);
1946
+ return Promise.resolve(bytes ? bytes.slice() : void 0);
1947
+ }
1948
+ evict(record) {
1949
+ this.records.set(record.hash, { ...record });
1950
+ this.blobs.delete(record.hash);
1951
+ return Promise.resolve();
1952
+ }
1953
+ touch(hash, lastAccessedAtUtc) {
1954
+ const record = this.records.get(hash);
1955
+ if (record) this.records.set(hash, {
1956
+ ...record,
1957
+ lastAccessedAtUtc
1958
+ });
1959
+ return Promise.resolve();
1960
+ }
1961
+ availableBytes() {
1962
+ let total = 0;
1963
+ for (const record of this.records.values()) if (record.status === "available") total += record.sizeBytes;
1964
+ return Promise.resolve(total);
1965
+ }
1966
+ close() {
1967
+ this.records.clear();
1968
+ this.blobs.clear();
1969
+ return Promise.resolve();
1970
+ }
1971
+ };
1972
+ //#endregion
1973
+ export { ATTACHMENT_READ_BRANCH, ATTACHMENT_READ_SCOPE, AttachmentPeerLinks, AttachmentReplicator, DEFAULT_ATTACHMENT_BACKLOG_PAGE_SIZE, DEFAULT_ATTACHMENT_HELD_HASH_LIMIT, DEFAULT_ATTACHMENT_REPLICATION_CONCURRENCY, DEFAULT_ATTACHMENT_RETRY_POLICY, DEFAULT_IDB_DATABASE, DEFAULT_LOCAL_CHUNK_BYTES, DEFAULT_LOCAL_MAX_CONCURRENT_SERVES, DEFAULT_LOCAL_REQUEST_TIMEOUT_MS, IDB_BLOB_STORE, IDB_RECORD_STORE, IDB_STATUS_INDEX, IdbAttachmentBackend, LOCAL_ATTACHMENT_PROTOCOL, LocalAttachmentServer, LocalAttachmentStore, LocalAttachmentTransport, MemoryAttachmentBackend, PeeredAttachmentTransport, SYSTEM_REPLICATION_TIMERS, SchemaCompiledOperationRefs, attachmentOriginOf, collectStream, isAttachmentHash, isLocalAttachmentRequest, isLocalAttachmentResponse, readGateAllowsAttachmentRead, readGateAttachmentAuthorizer, scopeGateAllowsAttachmentRead, sha256Hex, staticAttachmentReferenceScanner, streamFromBytes };
1974
+
1975
+ //# sourceMappingURL=replication.js.map