sealbridge 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +492 -30
- package/README.md +522 -109
- package/dist/cli/dist/accept.d.ts +13 -0
- package/dist/cli/dist/accept.js +115 -0
- package/dist/cli/dist/accept.js.map +1 -0
- package/dist/cli/dist/approval-summary.d.ts +1 -1
- package/dist/cli/dist/board.d.ts +10 -9
- package/dist/cli/dist/board.js +62 -31
- package/dist/cli/dist/board.js.map +1 -1
- package/dist/cli/dist/command-identity.d.ts +6 -0
- package/dist/cli/dist/command-identity.js +13 -0
- package/dist/cli/dist/command-identity.js.map +1 -0
- package/dist/cli/dist/config.d.ts +3 -0
- package/dist/cli/dist/config.js +29 -0
- package/dist/cli/dist/config.js.map +1 -0
- package/dist/cli/dist/connections.d.ts +15 -0
- package/dist/cli/dist/connections.js +191 -0
- package/dist/cli/dist/connections.js.map +1 -0
- package/dist/cli/dist/contacts.d.ts +4 -3
- package/dist/cli/dist/contacts.js +39 -28
- package/dist/cli/dist/contacts.js.map +1 -1
- package/dist/cli/dist/doctor.d.ts +9 -0
- package/dist/cli/dist/doctor.js +46 -5
- package/dist/cli/dist/doctor.js.map +1 -1
- package/dist/cli/dist/error-messages.js +22 -0
- package/dist/cli/dist/error-messages.js.map +1 -1
- package/dist/cli/dist/index.js +44 -6
- package/dist/cli/dist/index.js.map +1 -1
- package/dist/cli/dist/invite-output.d.ts +14 -0
- package/dist/cli/dist/invite-output.js +66 -0
- package/dist/cli/dist/invite-output.js.map +1 -0
- package/dist/cli/dist/invite-share.d.ts +6 -3
- package/dist/cli/dist/invite-share.js +40 -15
- package/dist/cli/dist/invite-share.js.map +1 -1
- package/dist/cli/dist/invites-migrate.d.ts +3 -0
- package/dist/cli/dist/invites-migrate.js +33 -0
- package/dist/cli/dist/invites-migrate.js.map +1 -0
- package/dist/cli/dist/mcp.d.ts +7 -5
- package/dist/cli/dist/mcp.js +23 -18
- package/dist/cli/dist/mcp.js.map +1 -1
- package/dist/cli/dist/network.d.ts +11 -1
- package/dist/cli/dist/network.js +150 -87
- package/dist/cli/dist/network.js.map +1 -1
- package/dist/cli/dist/pending.d.ts +8 -0
- package/dist/cli/dist/pending.js +50 -0
- package/dist/cli/dist/pending.js.map +1 -0
- package/dist/cli/dist/public-settings.d.ts +5 -4
- package/dist/cli/dist/public-settings.js +13 -15
- package/dist/cli/dist/public-settings.js.map +1 -1
- package/dist/cli/dist/receipt.d.ts +1 -1
- package/dist/cli/dist/relay-auth.d.ts +3 -0
- package/dist/cli/dist/relay-auth.js +23 -0
- package/dist/cli/dist/relay-auth.js.map +1 -0
- package/dist/cli/dist/requests.d.ts +10 -0
- package/dist/cli/dist/requests.js +91 -0
- package/dist/cli/dist/requests.js.map +1 -0
- package/dist/cli/dist/sdk.d.ts +10 -0
- package/dist/cli/dist/sdk.js +7 -0
- package/dist/cli/dist/sdk.js.map +1 -0
- package/dist/cli/dist/setup-files.d.ts +24 -0
- package/dist/cli/dist/setup-files.js +190 -0
- package/dist/cli/dist/setup-files.js.map +1 -0
- package/dist/cli/dist/setup-hosts.d.ts +29 -0
- package/dist/cli/dist/setup-hosts.js +75 -0
- package/dist/cli/dist/setup-hosts.js.map +1 -0
- package/dist/cli/dist/setup.d.ts +61 -0
- package/dist/cli/dist/setup.js +243 -0
- package/dist/cli/dist/setup.js.map +1 -0
- package/dist/cli/dist/task-input.d.ts +1 -1
- package/dist/cli/dist/wake-hook.d.ts +1 -1
- package/dist/cli/dist/worker-json.d.ts +4 -0
- package/dist/cli/dist/worker-json.js +87 -0
- package/dist/cli/dist/worker-json.js.map +1 -0
- package/dist/examples/two-owner-demo/dist/run-demo.d.ts +13 -13
- package/dist/package.json +1 -1
- package/dist/packages/adapters/dist/command.d.ts +2 -2
- package/dist/packages/adapters/dist/credential-proxy.d.ts +2 -2
- package/dist/packages/adapters/dist/index.d.ts +1 -1
- package/dist/packages/adapters/dist/mcp.d.ts +2 -2
- package/dist/packages/adapters/dist/openai.d.ts +2 -2
- package/dist/packages/adapters/dist/sandbox.d.ts +2 -2
- package/dist/packages/core/dist/connection.d.ts +58 -0
- package/dist/packages/core/dist/connection.js +132 -0
- package/dist/packages/core/dist/connection.js.map +1 -0
- package/dist/packages/core/dist/delivery-status.d.ts +22 -0
- package/dist/packages/core/dist/delivery-status.js +53 -0
- package/dist/packages/core/dist/delivery-status.js.map +1 -0
- package/dist/packages/core/dist/discovery.d.ts +7 -6
- package/dist/packages/core/dist/discovery.js +19 -20
- package/dist/packages/core/dist/discovery.js.map +1 -1
- package/dist/packages/core/dist/edge.d.ts +1 -0
- package/dist/packages/core/dist/edge.js +1 -0
- package/dist/packages/core/dist/edge.js.map +1 -1
- package/dist/packages/core/dist/index.d.ts +3 -0
- package/dist/packages/core/dist/index.js +3 -0
- package/dist/packages/core/dist/index.js.map +1 -1
- package/dist/packages/core/dist/protocol.d.ts +24 -0
- package/dist/packages/core/dist/protocol.js +54 -0
- package/dist/packages/core/dist/protocol.js.map +1 -0
- package/dist/packages/core/dist/receipts.d.ts +1 -0
- package/dist/packages/core/dist/receipts.js +1 -1
- package/dist/packages/core/dist/receipts.js.map +1 -1
- package/dist/packages/core/dist/schema.d.ts +1 -0
- package/dist/packages/core/dist/schema.js +13 -2
- package/dist/packages/core/dist/schema.js.map +1 -1
- package/dist/packages/ledger/dist/index.d.ts +1 -1
- package/dist/packages/relay/dist/admission.d.ts +14 -0
- package/dist/packages/relay/dist/admission.js +70 -0
- package/dist/packages/relay/dist/admission.js.map +1 -0
- package/dist/packages/relay/dist/fetch.d.ts +8 -2
- package/dist/packages/relay/dist/fetch.js +41 -12
- package/dist/packages/relay/dist/fetch.js.map +1 -1
- package/dist/packages/relay/dist/index.d.ts +23 -12
- package/dist/packages/relay/dist/index.js +130 -78
- package/dist/packages/relay/dist/index.js.map +1 -1
- package/dist/packages/relay/dist/journal.d.ts +1 -1
- package/dist/packages/relay/dist/notifications.d.ts +1 -1
- package/dist/packages/relay/dist/retry.d.ts +23 -0
- package/dist/packages/relay/dist/retry.js +43 -1
- package/dist/packages/relay/dist/retry.js.map +1 -1
- package/dist/packages/relay/dist/ssrf.d.ts +3 -2
- package/dist/packages/relay/dist/ssrf.js.map +1 -1
- package/dist/packages/relay/dist/worker.js +1 -1
- package/dist/packages/relay/dist/worker.js.map +1 -1
- package/dist/packages/runtime/dist/a2a.d.ts +1 -1
- package/dist/packages/runtime/dist/a2a.js +1 -1
- package/dist/packages/runtime/dist/adapter.d.ts +2 -2
- package/dist/packages/runtime/dist/application-config.d.ts +20 -0
- package/dist/packages/runtime/dist/application-config.js +218 -0
- package/dist/packages/runtime/dist/application-config.js.map +1 -0
- package/dist/packages/runtime/dist/approval-providers.d.ts +24 -3
- package/dist/packages/runtime/dist/approval-providers.js +79 -3
- package/dist/packages/runtime/dist/approval-providers.js.map +1 -1
- package/dist/packages/runtime/dist/connection-admission-review.d.ts +39 -0
- package/dist/packages/runtime/dist/connection-admission-review.js +86 -0
- package/dist/packages/runtime/dist/connection-admission-review.js.map +1 -0
- package/dist/packages/runtime/dist/connection-persistence.d.ts +35 -0
- package/dist/packages/runtime/dist/connection-persistence.js +181 -0
- package/dist/packages/runtime/dist/connection-persistence.js.map +1 -0
- package/dist/packages/runtime/dist/connection-records.d.ts +167 -0
- package/dist/packages/runtime/dist/connection-records.js +132 -0
- package/dist/packages/runtime/dist/connection-records.js.map +1 -0
- package/dist/packages/runtime/dist/connection-router.d.ts +45 -0
- package/dist/packages/runtime/dist/connection-router.js +130 -0
- package/dist/packages/runtime/dist/connection-router.js.map +1 -0
- package/dist/packages/runtime/dist/connection-service.d.ts +159 -0
- package/dist/packages/runtime/dist/connection-service.js +239 -0
- package/dist/packages/runtime/dist/connection-service.js.map +1 -0
- package/dist/packages/runtime/dist/connection-transport.d.ts +14 -0
- package/dist/packages/runtime/dist/connection-transport.js +89 -0
- package/dist/packages/runtime/dist/connection-transport.js.map +1 -0
- package/dist/packages/runtime/dist/delivery-records.d.ts +180 -0
- package/dist/packages/runtime/dist/delivery-records.js +205 -0
- package/dist/packages/runtime/dist/delivery-records.js.map +1 -0
- package/dist/packages/runtime/dist/delivery-status-router.d.ts +27 -0
- package/dist/packages/runtime/dist/delivery-status-router.js +150 -0
- package/dist/packages/runtime/dist/delivery-status-router.js.map +1 -0
- package/dist/packages/runtime/dist/delivery-status.d.ts +43 -0
- package/dist/packages/runtime/dist/delivery-status.js +193 -0
- package/dist/packages/runtime/dist/delivery-status.js.map +1 -0
- package/dist/packages/runtime/dist/delivery-store.d.ts +125 -0
- package/dist/packages/runtime/dist/delivery-store.js +1047 -0
- package/dist/packages/runtime/dist/delivery-store.js.map +1 -0
- package/dist/packages/runtime/dist/gateway.d.ts +4 -3
- package/dist/packages/runtime/dist/gateway.js.map +1 -1
- package/dist/packages/runtime/dist/held-request-review.d.ts +49 -0
- package/dist/packages/runtime/dist/held-request-review.js +122 -0
- package/dist/packages/runtime/dist/held-request-review.js.map +1 -0
- package/dist/packages/runtime/dist/held-request-store.d.ts +112 -0
- package/dist/packages/runtime/dist/held-request-store.js +290 -0
- package/dist/packages/runtime/dist/held-request-store.js.map +1 -0
- package/dist/packages/runtime/dist/idempotency-store.d.ts +53 -0
- package/dist/packages/runtime/dist/idempotency-store.js +331 -0
- package/dist/packages/runtime/dist/idempotency-store.js.map +1 -0
- package/dist/packages/runtime/dist/inbox.d.ts +1 -1
- package/dist/packages/runtime/dist/index.d.ts +27 -1
- package/dist/packages/runtime/dist/index.js +26 -0
- package/dist/packages/runtime/dist/index.js.map +1 -1
- package/dist/packages/runtime/dist/invite-store.d.ts +76 -0
- package/dist/packages/runtime/dist/invite-store.js +337 -0
- package/dist/packages/runtime/dist/invite-store.js.map +1 -0
- package/dist/packages/runtime/dist/key-store.d.ts +77 -0
- package/dist/packages/runtime/dist/key-store.js +226 -0
- package/dist/packages/runtime/dist/key-store.js.map +1 -0
- package/dist/packages/runtime/dist/local-identity.d.ts +33 -0
- package/dist/packages/runtime/dist/local-identity.js +279 -0
- package/dist/packages/runtime/dist/local-identity.js.map +1 -0
- package/dist/packages/runtime/dist/mcp-rpc.d.ts +19 -0
- package/dist/packages/runtime/dist/mcp-rpc.js +175 -0
- package/dist/packages/runtime/dist/mcp-rpc.js.map +1 -0
- package/dist/packages/runtime/dist/mcp-server.d.ts +46 -19
- package/dist/packages/runtime/dist/mcp-server.js +427 -184
- package/dist/packages/runtime/dist/mcp-server.js.map +1 -1
- package/dist/packages/runtime/dist/mcp-tool-catalog.d.ts +1 -1
- package/dist/packages/runtime/dist/mcp-tool-catalog.js +172 -13
- package/dist/packages/runtime/dist/mcp-tool-catalog.js.map +1 -1
- package/dist/packages/runtime/dist/mcp.d.ts +2 -2
- package/dist/packages/runtime/dist/message-status.d.ts +32 -0
- package/dist/packages/runtime/dist/message-status.js +29 -0
- package/dist/packages/runtime/dist/message-status.js.map +1 -0
- package/dist/packages/runtime/dist/outbox.d.ts +91 -0
- package/dist/packages/runtime/dist/outbox.js +462 -0
- package/dist/packages/runtime/dist/outbox.js.map +1 -0
- package/dist/packages/runtime/dist/policy.d.ts +1 -1
- package/dist/packages/runtime/dist/protocol-peer-store.d.ts +40 -0
- package/dist/packages/runtime/dist/protocol-peer-store.js +176 -0
- package/dist/packages/runtime/dist/protocol-peer-store.js.map +1 -0
- package/dist/packages/runtime/dist/protocol-release.d.ts +26 -0
- package/dist/packages/runtime/dist/protocol-release.js +76 -0
- package/dist/packages/runtime/dist/protocol-release.js.map +1 -0
- package/dist/packages/runtime/dist/quota.d.ts +1 -1
- package/dist/packages/runtime/dist/relay-selection.d.ts +20 -0
- package/dist/packages/runtime/dist/relay-selection.js +34 -0
- package/dist/packages/runtime/dist/relay-selection.js.map +1 -0
- package/dist/packages/runtime/dist/requester-pending-store.d.ts +59 -0
- package/dist/packages/runtime/dist/requester-pending-store.js +266 -0
- package/dist/packages/runtime/dist/requester-pending-store.js.map +1 -0
- package/dist/packages/runtime/dist/secret-store.d.ts +19 -0
- package/dist/packages/runtime/dist/secret-store.js +51 -0
- package/dist/packages/runtime/dist/secret-store.js.map +1 -0
- package/dist/packages/runtime/dist/session-audit.d.ts +29 -7
- package/dist/packages/runtime/dist/session-audit.js +149 -29
- package/dist/packages/runtime/dist/session-audit.js.map +1 -1
- package/dist/packages/runtime/dist/session.d.ts +101 -9
- package/dist/packages/runtime/dist/session.js +696 -96
- package/dist/packages/runtime/dist/session.js.map +1 -1
- package/dist/packages/runtime/dist/task-journal.d.ts +18 -3
- package/dist/packages/runtime/dist/task-journal.js +271 -18
- package/dist/packages/runtime/dist/task-journal.js.map +1 -1
- package/dist/packages/runtime/dist/verifier.d.ts +3 -2
- package/dist/packages/runtime/dist/verifier.js +1 -0
- package/dist/packages/runtime/dist/verifier.js.map +1 -1
- package/dist/packages/runtime/dist/worker-trust-store.d.ts +111 -0
- package/dist/packages/runtime/dist/worker-trust-store.js +592 -0
- package/dist/packages/runtime/dist/worker-trust-store.js.map +1 -0
- package/dist/packages/sandbox/dist/container.d.ts +1 -1
- package/dist/packages/sandbox/dist/index.d.ts +1 -1
- package/dist/spec/README.ja.md +21 -1
- package/dist/spec/README.md +20 -1
- package/dist/spec/agent-card.schema.json +11 -1
- package/dist/spec/delivery-status.schema.json +106 -0
- package/dist/spec/examples/protocol-versions/sample-agent-card.json +35 -0
- package/dist/spec/examples/sample-delivery-status.json +21 -0
- package/dist/spec/examples/sample-task-manifest.0.1.0.json +173 -0
- package/dist/spec/examples/sample-task-manifest.0.2.0.json +173 -0
- package/dist/spec/task-manifest.0.1.0.schema.json +422 -0
- package/dist/spec/task-manifest.0.2.0.schema.json +422 -0
- package/dist/spec/task-manifest.schema.json +9 -418
- package/docs/sdk.ja.md +133 -0
- package/docs/sdk.md +138 -0
- package/package.json +15 -3
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { digest, signPayload, verifySigned } from '../../core/dist/index.js';
|
|
6
|
+
import { checkPath, checkStat, inspectDirectory, privateDirectory, readPrivateFile, resolveDataRoot } from './local-identity.js';
|
|
7
|
+
import { DEFAULT_DELIVERY_LIMITS, DELIVERY_RETENTION_MS, UUID, deliveryError, effectiveMessage, requireDelivery, receiptFileId, validateDeliveryRecord, validateSignedShape, } from './delivery-records.js';
|
|
8
|
+
export function deliveryRoot(did, options = {}) {
|
|
9
|
+
return join(resolveDataRoot(options), 'delivery', createHash('sha256').update(did, 'utf8').digest('hex'));
|
|
10
|
+
}
|
|
11
|
+
const kinds = ['observations', 'messages', 'outbox', 'batches', 'receipts', 'operations', 'idempotency'];
|
|
12
|
+
const idempotencyKinds = ['operations', 'idempotency'];
|
|
13
|
+
const isIdempotencyKind = (kind) => kind === 'operations' || kind === 'idempotency';
|
|
14
|
+
function summarizeIdempotency(record) {
|
|
15
|
+
return { operationId: record.operationId, createdAt: record.createdAt, retainUntil: record.retainUntil,
|
|
16
|
+
bytes: 'response' in record && record.response ? Buffer.byteLength(JSON.stringify(record)) : undefined };
|
|
17
|
+
}
|
|
18
|
+
function statusMessage(r) {
|
|
19
|
+
return { messageId: r.messageId, relayUrl: r.relayUrl, protocolVersion: r.protocolVersion, retainUntil: r.retainUntil };
|
|
20
|
+
}
|
|
21
|
+
function summarize(r) {
|
|
22
|
+
return { envelopeId: r.envelopeId, messageId: r.messageId, batchId: r.batchId, kind: r.kind,
|
|
23
|
+
state: r.state, ordinal: r.ordinal, nextAttemptAt: r.nextAttemptAt, lease: r.lease ? { ...r.lease } : undefined,
|
|
24
|
+
bytes: Buffer.byteLength(JSON.stringify(r.envelope)) };
|
|
25
|
+
}
|
|
26
|
+
const errno = (e) => e.code;
|
|
27
|
+
// Atomic record replacement changes these attributes, including across store instances.
|
|
28
|
+
function fingerprint(stat) {
|
|
29
|
+
return [stat.dev, stat.ino, stat.mode, stat.uid, stat.size, stat.mtimeNs, stat.ctimeNs].join(':');
|
|
30
|
+
}
|
|
31
|
+
const VERIFIED_CACHE_ENTRIES = 256;
|
|
32
|
+
const VERIFIED_CACHE_BYTES = 4 * 1024 * 1024;
|
|
33
|
+
function recordIdentity(kind, id, record) {
|
|
34
|
+
const recordId = kind === 'idempotency' ? record.keyHash
|
|
35
|
+
: kind === 'operations' ? record.operationId
|
|
36
|
+
: kind === 'messages' || kind === 'observations' ? record.messageId
|
|
37
|
+
: 'envelopeId' in record ? record.envelopeId : record.batchId;
|
|
38
|
+
requireDelivery(kind === 'receipts' ? receiptFileId(recordId) === receiptFileId(id) : recordId === id);
|
|
39
|
+
}
|
|
40
|
+
/** Signer-scoped records. Construction and reads have no publication or cleanup side effects. */
|
|
41
|
+
class DeliveryStoreImplementation {
|
|
42
|
+
owner;
|
|
43
|
+
options;
|
|
44
|
+
root;
|
|
45
|
+
limits;
|
|
46
|
+
io;
|
|
47
|
+
platform;
|
|
48
|
+
now;
|
|
49
|
+
generationFd;
|
|
50
|
+
stagedWrites;
|
|
51
|
+
transactionDirectories;
|
|
52
|
+
transactionReads;
|
|
53
|
+
verified = new Map();
|
|
54
|
+
verifiedBytes = 0;
|
|
55
|
+
preparedMode = false;
|
|
56
|
+
idempotencyLeaseMs;
|
|
57
|
+
outboxIndex;
|
|
58
|
+
idempotencyIndexes = new Map();
|
|
59
|
+
messageIndex;
|
|
60
|
+
inspected = new Map();
|
|
61
|
+
listings = new Map();
|
|
62
|
+
forget(path) {
|
|
63
|
+
const cached = this.verified.get(path);
|
|
64
|
+
if (cached) {
|
|
65
|
+
this.verifiedBytes -= Buffer.byteLength(cached.data);
|
|
66
|
+
this.verified.delete(path);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Reuse verified records only while file identity, permissions and timestamps match. */
|
|
70
|
+
remember(path, data, record, stamp = fingerprint(this.io.lstatSync(path, { bigint: true }))) {
|
|
71
|
+
this.forget(path);
|
|
72
|
+
const bytes = Buffer.byteLength(data);
|
|
73
|
+
if (bytes > VERIFIED_CACHE_BYTES)
|
|
74
|
+
return;
|
|
75
|
+
while (this.verified.size >= VERIFIED_CACHE_ENTRIES || this.verifiedBytes + bytes > VERIFIED_CACHE_BYTES) {
|
|
76
|
+
const oldest = this.verified.keys().next().value;
|
|
77
|
+
this.verifiedBytes -= Buffer.byteLength(this.verified.get(oldest).data);
|
|
78
|
+
this.verified.delete(oldest);
|
|
79
|
+
}
|
|
80
|
+
this.verified.set(path, { stamp, data, record: structuredClone(record) });
|
|
81
|
+
this.verifiedBytes += bytes;
|
|
82
|
+
}
|
|
83
|
+
constructor(owner, options = {}) {
|
|
84
|
+
this.owner = owner;
|
|
85
|
+
this.options = options;
|
|
86
|
+
try {
|
|
87
|
+
requireDelivery(owner?.privateKey && verifySigned(signPayload({ type: 'DeliveryStore' }, owner), owner.did));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw deliveryError('OUTBOX_STORE_INVALID');
|
|
91
|
+
}
|
|
92
|
+
this.root = deliveryRoot(owner.did, options);
|
|
93
|
+
this.io = options.fs ?? fs;
|
|
94
|
+
this.platform = options.platform ?? process.platform;
|
|
95
|
+
this.now = options.now ?? Date.now;
|
|
96
|
+
// IdempotencyStore forwards its lease option in this same options object.
|
|
97
|
+
this.idempotencyLeaseMs = options.leaseMs ?? 120_000;
|
|
98
|
+
this.limits = { ...DEFAULT_DELIVERY_LIMITS, ...options.limits };
|
|
99
|
+
requireDelivery(Object.values(this.limits).every(n => Number.isSafeInteger(n) && n > 0));
|
|
100
|
+
}
|
|
101
|
+
path(kind, id) {
|
|
102
|
+
if (kind === 'receipts')
|
|
103
|
+
id = receiptFileId(id);
|
|
104
|
+
requireDelivery(kind === 'idempotency' ? /^[a-f0-9]{64}$/.test(id) : UUID.test(id));
|
|
105
|
+
return join(this.root, kind, id + '.json');
|
|
106
|
+
}
|
|
107
|
+
directories(kind) {
|
|
108
|
+
return [dirname(dirname(this.root)), dirname(this.root), this.root, ...(kind ? [join(this.root, kind)] : [])];
|
|
109
|
+
}
|
|
110
|
+
inspectPath(dir, create = false) {
|
|
111
|
+
if (this.transactionDirectories?.has(dir))
|
|
112
|
+
return;
|
|
113
|
+
let stat;
|
|
114
|
+
try {
|
|
115
|
+
stat = this.io.lstatSync(dir);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!create || errno(error) !== 'ENOENT')
|
|
119
|
+
throw error;
|
|
120
|
+
privateDirectory(dir, this.io, this.platform);
|
|
121
|
+
stat = this.io.lstatSync(dir);
|
|
122
|
+
}
|
|
123
|
+
checkStat(stat, true);
|
|
124
|
+
const stamp = [stat.dev, stat.ino, stat.mode, stat.uid].join(':');
|
|
125
|
+
if (this.inspected.get(dir) !== stamp) {
|
|
126
|
+
inspectDirectory(dir, this.io, this.platform);
|
|
127
|
+
this.inspected.set(dir, stamp);
|
|
128
|
+
}
|
|
129
|
+
this.transactionDirectories?.add(dir);
|
|
130
|
+
}
|
|
131
|
+
inspect(kind) {
|
|
132
|
+
if (!this.transactionDirectories)
|
|
133
|
+
checkPath(dirname(dirname(dirname(this.root))), this.io, this.platform);
|
|
134
|
+
for (const dir of this.directories(kind)) {
|
|
135
|
+
try {
|
|
136
|
+
this.inspectPath(dir);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
if (errno(e) !== 'ENOENT')
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
directoryStamp(kind) {
|
|
145
|
+
try {
|
|
146
|
+
return fingerprint(this.io.lstatSync(join(this.root, kind), { bigint: true }));
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (errno(error) === 'ENOENT')
|
|
150
|
+
return 'absent';
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
generationStamp() {
|
|
155
|
+
try {
|
|
156
|
+
const stat = this.io.lstatSync(join(this.root, '.idempotency-gen'), { bigint: true });
|
|
157
|
+
requireDelivery(stat.isFile() && (this.platform === 'win32' || stat.uid === BigInt(process.getuid()) && (stat.mode & 63n) === 0n));
|
|
158
|
+
return [stat.ino, stat.size].join(':');
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (errno(error) === 'ENOENT')
|
|
162
|
+
return 'absent';
|
|
163
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
listingStamp(kind) {
|
|
167
|
+
if (!isIdempotencyKind(kind))
|
|
168
|
+
return this.directoryStamp(kind);
|
|
169
|
+
// Older writers do not increment the generation. Keep directory values too;
|
|
170
|
+
// concurrent writes by two older versions can still share those values.
|
|
171
|
+
return [this.generationStamp(), ...idempotencyKinds.map(k => this.directoryStamp(k))].join('|');
|
|
172
|
+
}
|
|
173
|
+
adoptIdempotencyStamp(before, after, publishedKind) {
|
|
174
|
+
for (const kind of idempotencyKinds) {
|
|
175
|
+
const index = this.idempotencyIndexes.get(kind), listing = this.listings.get(kind);
|
|
176
|
+
if (index?.stamp === before)
|
|
177
|
+
index.stamp = after;
|
|
178
|
+
if (kind !== publishedKind && listing?.stamp === before)
|
|
179
|
+
listing.stamp = after;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Lazily held for this signer lock, including multiple record publications. */
|
|
183
|
+
openGeneration() {
|
|
184
|
+
if (!this.transactionDirectories)
|
|
185
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
186
|
+
if (this.generationFd !== undefined)
|
|
187
|
+
return this.generationFd;
|
|
188
|
+
const fd = this.io.openSync(join(this.root, '.idempotency-gen'), fs.constants.O_WRONLY | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
189
|
+
try {
|
|
190
|
+
checkStat(this.io.fstatSync(fd));
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
this.io.closeSync(fd);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
this.generationFd = fd;
|
|
197
|
+
return fd;
|
|
198
|
+
}
|
|
199
|
+
publishIdempotencyChange(kind, change) {
|
|
200
|
+
if (!isIdempotencyKind(kind)) {
|
|
201
|
+
change();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const before = this.listingStamp(kind), fd = this.openGeneration();
|
|
205
|
+
const size = this.io.fstatSync(fd).size;
|
|
206
|
+
if (!Number.isSafeInteger(size + 1))
|
|
207
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
208
|
+
// Never shrink or replace this file. An interruption before publication only
|
|
209
|
+
// invalidates indexes; the next reader rebuilds both from the retained records.
|
|
210
|
+
this.io.ftruncateSync(fd, size + 1);
|
|
211
|
+
change();
|
|
212
|
+
this.adoptIdempotencyStamp(before, this.listingStamp(kind), kind);
|
|
213
|
+
}
|
|
214
|
+
ids(kind, onError) {
|
|
215
|
+
try {
|
|
216
|
+
this.inspect(kind);
|
|
217
|
+
const stamp = this.listingStamp(kind), cached = this.listings.get(kind);
|
|
218
|
+
if (cached?.stamp === stamp)
|
|
219
|
+
return [...cached.ids];
|
|
220
|
+
if (stamp === 'absent' || isIdempotencyKind(kind) && this.directoryStamp(kind) === 'absent') {
|
|
221
|
+
this.listings.set(kind, { stamp, ids: [] });
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
let invalid = false;
|
|
225
|
+
const ids = this.io.readdirSync(join(this.root, kind)).filter(n => n.endsWith('.json')).flatMap(n => {
|
|
226
|
+
const id = n.slice(0, -5);
|
|
227
|
+
if (kind === 'idempotency' ? /^[a-f0-9]{64}$/.test(id) : UUID.test(id))
|
|
228
|
+
return [id];
|
|
229
|
+
if (!onError)
|
|
230
|
+
throw deliveryError('OUTBOX_STORE_INVALID');
|
|
231
|
+
invalid = true;
|
|
232
|
+
onError(deliveryError('OUTBOX_STORE_INVALID'));
|
|
233
|
+
return [];
|
|
234
|
+
}).sort();
|
|
235
|
+
if (!invalid)
|
|
236
|
+
this.listings.set(kind, { stamp, ids });
|
|
237
|
+
return ids;
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
if (errno(e) === 'ENOENT')
|
|
241
|
+
return [];
|
|
242
|
+
throw deliveryError('OUTBOX_STORE_INVALID');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
read(kind, id) {
|
|
246
|
+
const path = this.path(kind, id);
|
|
247
|
+
try {
|
|
248
|
+
if (this.transactionReads?.has(path))
|
|
249
|
+
return structuredClone(this.transactionReads.get(path));
|
|
250
|
+
this.inspect(kind);
|
|
251
|
+
let stamp;
|
|
252
|
+
try {
|
|
253
|
+
const stat = this.io.lstatSync(path, { bigint: true });
|
|
254
|
+
requireDelivery(stat.isFile() && (this.platform === 'win32' || stat.uid === BigInt(process.getuid()) && (stat.mode & 63n) === 0n));
|
|
255
|
+
stamp = fingerprint(stat);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (errno(error) !== 'ENOENT')
|
|
259
|
+
throw error;
|
|
260
|
+
this.forget(path);
|
|
261
|
+
this.transactionReads?.set(path, undefined);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const cached = this.verified.get(path);
|
|
265
|
+
if (cached?.stamp === stamp) {
|
|
266
|
+
this.transactionReads?.set(path, cached.record);
|
|
267
|
+
return structuredClone(cached.record);
|
|
268
|
+
}
|
|
269
|
+
// Once a change was observed, old validation must not become usable again
|
|
270
|
+
// if a later write returns to the same size and filesystem timestamp tick.
|
|
271
|
+
this.forget(path);
|
|
272
|
+
const data = readPrivateFile(path, this.io, kind === 'operations' ? this.limits.operationBytes : this.limits.recordBytes, this.platform);
|
|
273
|
+
requireDelivery(data !== undefined);
|
|
274
|
+
const signed = JSON.parse(data);
|
|
275
|
+
validateSignedShape(signed);
|
|
276
|
+
requireDelivery(verifySigned(signed, this.owner.did));
|
|
277
|
+
validateDeliveryRecord(kind, signed.payload, this.owner.did);
|
|
278
|
+
const r = signed.payload;
|
|
279
|
+
recordIdentity(kind, id, r);
|
|
280
|
+
// Do not associate bytes read through an old descriptor with a newer replacement.
|
|
281
|
+
if (fingerprint(this.io.lstatSync(path, { bigint: true })) === stamp)
|
|
282
|
+
this.remember(path, data, r, stamp);
|
|
283
|
+
this.transactionReads?.set(path, structuredClone(r));
|
|
284
|
+
return r;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
this.forget(path);
|
|
288
|
+
throw deliveryError('OUTBOX_STORE_INVALID');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
snapshot(kind, onError) {
|
|
292
|
+
return this.ids(kind, onError).flatMap(id => {
|
|
293
|
+
try {
|
|
294
|
+
const r = this.read(kind, id);
|
|
295
|
+
return r ? [r] : [];
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (!onError)
|
|
299
|
+
throw error;
|
|
300
|
+
onError(deliveryError('OUTBOX_STORE_INVALID'));
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async *scan(kind, signal) {
|
|
306
|
+
for (const id of this.ids(kind)) {
|
|
307
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
308
|
+
signal.throwIfAborted();
|
|
309
|
+
const record = this.read(kind, id);
|
|
310
|
+
if (record)
|
|
311
|
+
yield record;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
getMessage(id) {
|
|
315
|
+
const r = this.read('messages', id);
|
|
316
|
+
if (!r || this.now() >= r.retainUntil)
|
|
317
|
+
return;
|
|
318
|
+
if (r?.status === 'queued') {
|
|
319
|
+
const proposal = this.read('outbox', id);
|
|
320
|
+
if (!proposal || !this.committed(proposal.batchId).length)
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
return r && effectiveMessage(r, this.now());
|
|
324
|
+
}
|
|
325
|
+
findTask(taskId) {
|
|
326
|
+
return this.snapshot('messages').filter(r => r.taskId === taskId).flatMap(r => this.getMessage(r.messageId) ?? []);
|
|
327
|
+
}
|
|
328
|
+
/** All cross-process changes use this short lock; no callback performs I/O to a relay. */
|
|
329
|
+
transaction(change) {
|
|
330
|
+
const lock = join(this.root, '.lock');
|
|
331
|
+
let acquired = false;
|
|
332
|
+
try {
|
|
333
|
+
checkPath(dirname(dirname(dirname(this.root))), this.io, this.platform);
|
|
334
|
+
for (const dir of this.directories())
|
|
335
|
+
this.inspectPath(dir, true);
|
|
336
|
+
try {
|
|
337
|
+
// The exclusive empty lock coordinates live writers; it has no recovery payload to flush.
|
|
338
|
+
const fd = this.io.openSync(lock, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
|
|
339
|
+
(fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
340
|
+
acquired = true;
|
|
341
|
+
try {
|
|
342
|
+
checkStat(this.io.fstatSync(fd));
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
this.io.closeSync(fd);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
if (errno(e) === 'EEXIST')
|
|
350
|
+
throw deliveryError('OUTBOX_STORE_BUSY');
|
|
351
|
+
throw e;
|
|
352
|
+
}
|
|
353
|
+
this.transactionReads = new Map();
|
|
354
|
+
this.transactionDirectories = new Set(this.directories());
|
|
355
|
+
return change();
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
if (e instanceof Error && /^(OUTBOX_|DELIVERY_RECORD_CONFLICT|IDEMPOTENCY_|INVALID_DELIVERY_STATUS|DELIVERY_STATUS_CONFLICT)/.test(e.message))
|
|
359
|
+
throw e;
|
|
360
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
if (acquired) {
|
|
364
|
+
try {
|
|
365
|
+
if (this.generationFd !== undefined)
|
|
366
|
+
this.io.closeSync(this.generationFd);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
this.generationFd = undefined;
|
|
370
|
+
this.transactionReads = undefined;
|
|
371
|
+
this.transactionDirectories = undefined;
|
|
372
|
+
this.io.unlinkSync(lock);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Caller holds the signer lock. */
|
|
378
|
+
write(kind, id, record) {
|
|
379
|
+
validateDeliveryRecord(kind, record, this.owner.did);
|
|
380
|
+
recordIdentity(kind, id, record);
|
|
381
|
+
if (this.stagedWrites) {
|
|
382
|
+
this.stagedWrites.push({ kind, id, record: structuredClone(record) });
|
|
383
|
+
this.transactionReads.set(this.path(kind, id), structuredClone(record));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const listingId = kind === 'receipts' ? receiptFileId(id) : id;
|
|
387
|
+
const path = this.path(kind, id), temporary = join(dirname(path), '.' + randomUUID() + '.tmp');
|
|
388
|
+
const bytes = Buffer.from(JSON.stringify(signPayload(record, this.owner)) + '\n');
|
|
389
|
+
if (bytes.length > (kind === 'operations' ? this.limits.operationBytes : this.limits.recordBytes))
|
|
390
|
+
throw deliveryError(kind === 'operations' ? 'IDEMPOTENCY_STORE_FULL' : 'OUTBOX_FULL');
|
|
391
|
+
const retained = this.idempotencyIndexes.get(kind);
|
|
392
|
+
const knownRetained = retained && retained.stamp === this.listingStamp(kind) ? retained : undefined;
|
|
393
|
+
if (kind === 'messages' && !this.messageIndex && this.listingStamp('messages') === 'absent')
|
|
394
|
+
this.messageIndex = { stamp: 'absent', entries: new Map() };
|
|
395
|
+
const priorMessages = kind === 'messages' && this.messageIndex?.stamp === 'absent' ? this.messageIndex : undefined;
|
|
396
|
+
const priorIndex = kind === 'outbox' && this.outboxIndex?.stamp === 'absent' ? this.outboxIndex : undefined;
|
|
397
|
+
this.inspectPath(dirname(path), true);
|
|
398
|
+
if (priorIndex)
|
|
399
|
+
priorIndex.stamp = this.listingStamp('outbox');
|
|
400
|
+
if (priorMessages)
|
|
401
|
+
priorMessages.stamp = this.listingStamp('messages');
|
|
402
|
+
const listing = this.listings.get(kind);
|
|
403
|
+
const beforeStamp = this.listingStamp(kind);
|
|
404
|
+
const knownListing = listing?.stamp === beforeStamp ? listing : undefined;
|
|
405
|
+
const knownIndex = kind === 'outbox' && this.outboxIndex?.stamp === beforeStamp ? this.outboxIndex : undefined;
|
|
406
|
+
const knownMessages = kind === 'messages' && this.messageIndex?.stamp === beforeStamp ? this.messageIndex : undefined;
|
|
407
|
+
let created = false;
|
|
408
|
+
try {
|
|
409
|
+
// Parent directories were checked under the signer lock. Exclusive creation owns this temp.
|
|
410
|
+
const fd = this.io.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
|
|
411
|
+
(fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
412
|
+
created = true;
|
|
413
|
+
try {
|
|
414
|
+
checkStat(this.io.fstatSync(fd));
|
|
415
|
+
let offset = 0;
|
|
416
|
+
while (offset < bytes.length) {
|
|
417
|
+
const written = this.io.writeSync(fd, bytes, offset, bytes.length - offset);
|
|
418
|
+
if (!written)
|
|
419
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
420
|
+
offset += written;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
this.io.closeSync(fd);
|
|
425
|
+
}
|
|
426
|
+
const previous = this.read(kind, id);
|
|
427
|
+
this.checkObservation(kind, previous, record);
|
|
428
|
+
if (kind === 'outbox' && previous && 'envelopeDigest' in previous &&
|
|
429
|
+
previous.envelopeDigest !== record.envelopeDigest)
|
|
430
|
+
throw deliveryError('DELIVERY_RECORD_CONFLICT');
|
|
431
|
+
this.publishIdempotencyChange(kind, () => {
|
|
432
|
+
this.io.renameSync(temporary, path);
|
|
433
|
+
created = false;
|
|
434
|
+
this.remember(path, bytes.toString('utf8'), record);
|
|
435
|
+
this.transactionReads?.set(path, structuredClone(record));
|
|
436
|
+
if (knownIndex) {
|
|
437
|
+
knownIndex.entries.set(id, summarize(record));
|
|
438
|
+
knownIndex.stamp = this.listingStamp('outbox');
|
|
439
|
+
}
|
|
440
|
+
if (knownRetained) {
|
|
441
|
+
knownRetained.entries.set(id, summarizeIdempotency(record));
|
|
442
|
+
knownRetained.stamp = this.listingStamp(kind);
|
|
443
|
+
}
|
|
444
|
+
if (knownMessages) {
|
|
445
|
+
knownMessages.entries.set(id, statusMessage(record));
|
|
446
|
+
knownMessages.stamp = this.listingStamp('messages');
|
|
447
|
+
}
|
|
448
|
+
if (knownListing)
|
|
449
|
+
this.listings.set(kind, { stamp: this.listingStamp(kind), ids: [...new Set([...knownListing.ids, listingId])].sort() });
|
|
450
|
+
else
|
|
451
|
+
this.listings.delete(kind);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
if (created)
|
|
456
|
+
this.io.unlinkSync(temporary);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
checkObservation(kind, previous, record) {
|
|
460
|
+
if (kind === 'observations' && previous) {
|
|
461
|
+
const before = previous, after = record;
|
|
462
|
+
requireDelivery(['senderDid', 'taskId', 'originalEnvelopeDigest', 'createdAt', 'deliveryDeadline', 'retainUntil', 'protocolVersion', 'disclosure']
|
|
463
|
+
.every(k => before[k] === after[k]) &&
|
|
464
|
+
before.facts.every((f, i) => digest(f) === digest(after.facts[i])) &&
|
|
465
|
+
before.published.every((p, i) => digest(p) === digest(after.published[i])));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/** Creating/removing an unpublished temporary does not change the indexed JSON records. */
|
|
469
|
+
temporaryChange(kind, change) {
|
|
470
|
+
const before = this.listingStamp(kind), listing = this.listings.get(kind);
|
|
471
|
+
if (isIdempotencyKind(kind)) {
|
|
472
|
+
try {
|
|
473
|
+
return change();
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
// Staging is outside the lock: a concurrent publication must invalidate
|
|
477
|
+
// both indexes rather than be adopted as our temporary directory change.
|
|
478
|
+
const after = this.listingStamp(kind);
|
|
479
|
+
if (after.split('|')[0] === before.split('|')[0])
|
|
480
|
+
this.adoptIdempotencyStamp(before, after);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const index = kind === 'outbox' && this.outboxIndex?.stamp === before ? this.outboxIndex : undefined;
|
|
484
|
+
const messages = kind === 'messages' && this.messageIndex?.stamp === before ? this.messageIndex : undefined;
|
|
485
|
+
try {
|
|
486
|
+
return change();
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
const stamp = this.listingStamp(kind);
|
|
490
|
+
if (index)
|
|
491
|
+
index.stamp = stamp;
|
|
492
|
+
if (messages)
|
|
493
|
+
messages.stamp = stamp;
|
|
494
|
+
if (listing?.stamp === before)
|
|
495
|
+
listing.stamp = stamp;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
stageWrites(writes, prepared, descriptors) {
|
|
499
|
+
// Unique unpublished files need no writer lock. Publication alone changes visible records.
|
|
500
|
+
checkPath(dirname(dirname(dirname(this.root))), this.io, this.platform);
|
|
501
|
+
for (const dir of this.directories())
|
|
502
|
+
this.inspectPath(dir, true);
|
|
503
|
+
this.openWrites(writes, prepared, descriptors);
|
|
504
|
+
}
|
|
505
|
+
openWrites(writes, prepared, descriptors) {
|
|
506
|
+
for (const { kind, id, record: input } of writes) {
|
|
507
|
+
const record = structuredClone(input);
|
|
508
|
+
validateDeliveryRecord(kind, record, this.owner.did);
|
|
509
|
+
recordIdentity(kind, id, record);
|
|
510
|
+
const path = this.path(kind, id), temporary = join(dirname(path), '.' + randomUUID() + '.tmp');
|
|
511
|
+
const bytes = Buffer.from(JSON.stringify(signPayload(record, this.owner)) + '\n');
|
|
512
|
+
if (bytes.length > (kind === 'operations' ? this.limits.operationBytes : this.limits.recordBytes))
|
|
513
|
+
throw deliveryError(kind === 'operations' ? 'IDEMPOTENCY_STORE_FULL' : 'OUTBOX_FULL');
|
|
514
|
+
// Make absent directory inventories available before local temporary creation changes them.
|
|
515
|
+
if (kind === 'messages' && !this.messageIndex && this.listingStamp('messages') === 'absent')
|
|
516
|
+
this.messageIndex = { stamp: 'absent', entries: new Map() };
|
|
517
|
+
const absentMessages = kind === 'messages' && this.messageIndex?.stamp === 'absent' ? this.messageIndex : undefined;
|
|
518
|
+
const absentIndex = kind === 'outbox' && this.outboxIndex?.stamp === 'absent' ? this.outboxIndex : undefined;
|
|
519
|
+
this.inspectPath(dirname(path), true);
|
|
520
|
+
if (absentIndex)
|
|
521
|
+
absentIndex.stamp = this.listingStamp(kind);
|
|
522
|
+
if (absentMessages)
|
|
523
|
+
absentMessages.stamp = this.listingStamp(kind);
|
|
524
|
+
const fd = this.temporaryChange(kind, () => this.io.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), 0o600));
|
|
525
|
+
descriptors.push(fd);
|
|
526
|
+
prepared.push({ kind, id, record, temporary, bytes });
|
|
527
|
+
checkStat(this.io.fstatSync(fd));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** Synchronous callers also close written handles before acquiring the publication lock. */
|
|
531
|
+
prepareWritesSync(writes) {
|
|
532
|
+
const prepared = [], descriptors = [];
|
|
533
|
+
try {
|
|
534
|
+
this.stageWrites(writes, prepared, descriptors);
|
|
535
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
536
|
+
const { bytes } = prepared[i], fd = descriptors[i];
|
|
537
|
+
let offset = 0;
|
|
538
|
+
while (offset < bytes.length) {
|
|
539
|
+
const written = this.io.writeSync(fd, bytes, offset, bytes.length - offset);
|
|
540
|
+
if (!written)
|
|
541
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
542
|
+
offset += written;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return prepared;
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
for (const fd of descriptors.splice(0))
|
|
549
|
+
this.io.closeSync(fd);
|
|
550
|
+
this.discardWrites(prepared);
|
|
551
|
+
if (error instanceof Error && /^(OUTBOX_|DELIVERY_RECORD_CONFLICT|IDEMPOTENCY_)/.test(error.message))
|
|
552
|
+
throw error;
|
|
553
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
for (const fd of descriptors)
|
|
557
|
+
this.io.closeSync(fd);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** Complete unique temporary writes outside the signer lock; close handles before publication. */
|
|
561
|
+
async prepareWrites(writes) {
|
|
562
|
+
const prepared = [], descriptors = [];
|
|
563
|
+
try {
|
|
564
|
+
this.stageWrites(writes, prepared, descriptors);
|
|
565
|
+
const results = await Promise.allSettled(prepared.map(async ({ bytes }, i) => {
|
|
566
|
+
const fd = descriptors[i];
|
|
567
|
+
let offset = 0;
|
|
568
|
+
while (offset < bytes.length) {
|
|
569
|
+
const written = await new Promise((resolve, reject) => this.io.write(fd, bytes, offset, bytes.length - offset, null, (error, count) => error ? reject(error) : resolve(count)));
|
|
570
|
+
if (!written)
|
|
571
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
572
|
+
offset += written;
|
|
573
|
+
}
|
|
574
|
+
}));
|
|
575
|
+
const failed = results.find(r => r.status === 'rejected');
|
|
576
|
+
if (failed?.status === 'rejected')
|
|
577
|
+
throw failed.reason;
|
|
578
|
+
return prepared;
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
for (const fd of descriptors.splice(0))
|
|
582
|
+
this.io.closeSync(fd);
|
|
583
|
+
this.discardWrites(prepared);
|
|
584
|
+
if (error instanceof Error && /^(OUTBOX_|DELIVERY_RECORD_CONFLICT|IDEMPOTENCY_)/.test(error.message))
|
|
585
|
+
throw error;
|
|
586
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
for (const fd of descriptors)
|
|
590
|
+
this.io.closeSync(fd);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/** C4 synchronous lifecycle writes: plan under lock, write outside, then recheck before publication.
|
|
594
|
+
* Callbacks only read and stage record replacements; they may run again after a concurrent writer.
|
|
595
|
+
*/
|
|
596
|
+
preparedTransaction(change) {
|
|
597
|
+
const stamps = () => kinds.map(kind => this.listingStamp(kind));
|
|
598
|
+
const matches = (expected) => expected.every((stamp, i) => stamp === this.listingStamp(kinds[i]));
|
|
599
|
+
for (;;) {
|
|
600
|
+
const writes = [], prepared = [], descriptors = [];
|
|
601
|
+
try {
|
|
602
|
+
const plan = this.transaction(() => {
|
|
603
|
+
this.stagedWrites = writes;
|
|
604
|
+
let result, failure;
|
|
605
|
+
try {
|
|
606
|
+
result = change();
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
failure = { error };
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
this.stagedWrites = undefined;
|
|
613
|
+
}
|
|
614
|
+
this.openWrites(writes, prepared, descriptors);
|
|
615
|
+
return { result, failure, stamps: stamps() };
|
|
616
|
+
});
|
|
617
|
+
if (!writes.length) {
|
|
618
|
+
if (plan.failure)
|
|
619
|
+
throw plan.failure.error;
|
|
620
|
+
return plan.result;
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
624
|
+
const { bytes } = prepared[i], fd = descriptors[i];
|
|
625
|
+
let offset = 0;
|
|
626
|
+
while (offset < bytes.length) {
|
|
627
|
+
const written = this.io.writeSync(fd, bytes, offset, bytes.length - offset);
|
|
628
|
+
if (!written)
|
|
629
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
630
|
+
offset += written;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
for (const fd of descriptors.splice(0))
|
|
636
|
+
this.io.closeSync(fd);
|
|
637
|
+
}
|
|
638
|
+
const published = this.transaction(() => {
|
|
639
|
+
if (!matches(plan.stamps))
|
|
640
|
+
return false;
|
|
641
|
+
for (const write of prepared)
|
|
642
|
+
this.publishWrite(write);
|
|
643
|
+
return true;
|
|
644
|
+
});
|
|
645
|
+
if (published) {
|
|
646
|
+
// Preserve completed record writes even when a later step of the callback failed.
|
|
647
|
+
if (plan.failure)
|
|
648
|
+
throw plan.failure.error;
|
|
649
|
+
return plan.result;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
if (error instanceof Error && /^(OUTBOX_|DELIVERY_RECORD_CONFLICT|INVALID_DELIVERY_STATUS|DELIVERY_STATUS_CONFLICT)/.test(error.message))
|
|
654
|
+
throw error;
|
|
655
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
for (const fd of descriptors)
|
|
659
|
+
this.io.closeSync(fd);
|
|
660
|
+
this.discardWrites(prepared);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** Caller holds the signer lock. Prepared writes have completed and their handles are closed. */
|
|
665
|
+
publishWrite(write) {
|
|
666
|
+
const { kind, id, record, temporary, bytes } = write, path = this.path(kind, id);
|
|
667
|
+
this.inspectPath(dirname(path));
|
|
668
|
+
const previous = this.read(kind, id);
|
|
669
|
+
this.checkObservation(kind, previous, record);
|
|
670
|
+
if (kind === 'outbox' && previous && 'envelopeDigest' in previous &&
|
|
671
|
+
previous.envelopeDigest !== record.envelopeDigest)
|
|
672
|
+
throw deliveryError('DELIVERY_RECORD_CONFLICT');
|
|
673
|
+
const before = this.listingStamp(kind), listing = this.listings.get(kind);
|
|
674
|
+
const index = kind === 'outbox' && this.outboxIndex?.stamp === before ? this.outboxIndex : undefined;
|
|
675
|
+
const retained = this.idempotencyIndexes.get(kind);
|
|
676
|
+
const knownRetained = retained?.stamp === before ? retained : undefined;
|
|
677
|
+
const messages = kind === 'messages' && this.messageIndex?.stamp === before ? this.messageIndex : undefined;
|
|
678
|
+
this.publishIdempotencyChange(kind, () => {
|
|
679
|
+
this.io.renameSync(temporary, path);
|
|
680
|
+
write.published = true;
|
|
681
|
+
this.remember(path, bytes.toString('utf8'), record);
|
|
682
|
+
this.transactionReads?.set(path, structuredClone(record));
|
|
683
|
+
if (index) {
|
|
684
|
+
index.entries.set(id, summarize(record));
|
|
685
|
+
index.stamp = this.listingStamp(kind);
|
|
686
|
+
}
|
|
687
|
+
if (knownRetained) {
|
|
688
|
+
knownRetained.entries.set(id, summarizeIdempotency(record));
|
|
689
|
+
knownRetained.stamp = this.listingStamp(kind);
|
|
690
|
+
}
|
|
691
|
+
if (messages) {
|
|
692
|
+
messages.entries.set(id, statusMessage(record));
|
|
693
|
+
messages.stamp = this.listingStamp(kind);
|
|
694
|
+
}
|
|
695
|
+
if (listing?.stamp === before)
|
|
696
|
+
this.listings.set(kind, { stamp: this.listingStamp(kind),
|
|
697
|
+
ids: [...new Set([...listing.ids, kind === 'receipts' ? receiptFileId(id) : id])].sort() });
|
|
698
|
+
else
|
|
699
|
+
this.listings.delete(kind);
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
discardWrites(writes) {
|
|
703
|
+
const remaining = writes.filter(write => !write.published);
|
|
704
|
+
if (!remaining.length)
|
|
705
|
+
return;
|
|
706
|
+
this.transaction(() => {
|
|
707
|
+
for (const { kind, temporary } of remaining)
|
|
708
|
+
this.temporaryChange(kind, () => {
|
|
709
|
+
try {
|
|
710
|
+
this.io.unlinkSync(temporary);
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
if (errno(error) !== 'ENOENT')
|
|
714
|
+
throw deliveryError('OUTBOX_STORE_UNWRITABLE');
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
remove(kind, id) {
|
|
720
|
+
const listingId = kind === 'receipts' ? receiptFileId(id) : id;
|
|
721
|
+
const path = this.path(kind, id);
|
|
722
|
+
const listing = this.listings.get(kind);
|
|
723
|
+
const beforeStamp = this.listingStamp(kind);
|
|
724
|
+
const knownListing = listing?.stamp === beforeStamp ? listing : undefined;
|
|
725
|
+
const knownIndex = kind === 'outbox' && this.outboxIndex?.stamp === beforeStamp ? this.outboxIndex : undefined;
|
|
726
|
+
const retained = this.idempotencyIndexes.get(kind);
|
|
727
|
+
const knownRetained = retained?.stamp === beforeStamp ? retained : undefined;
|
|
728
|
+
const knownMessages = kind === 'messages' && this.messageIndex?.stamp === beforeStamp ? this.messageIndex : undefined;
|
|
729
|
+
if (this.read(kind, id)) {
|
|
730
|
+
this.publishIdempotencyChange(kind, () => {
|
|
731
|
+
this.io.unlinkSync(path);
|
|
732
|
+
this.transactionReads?.set(path, undefined);
|
|
733
|
+
if (knownIndex) {
|
|
734
|
+
knownIndex.entries.delete(id);
|
|
735
|
+
knownIndex.stamp = this.listingStamp('outbox');
|
|
736
|
+
}
|
|
737
|
+
if (knownRetained) {
|
|
738
|
+
knownRetained.entries.delete(id);
|
|
739
|
+
knownRetained.stamp = this.listingStamp(kind);
|
|
740
|
+
}
|
|
741
|
+
if (knownMessages) {
|
|
742
|
+
knownMessages.entries.delete(id);
|
|
743
|
+
knownMessages.stamp = this.listingStamp('messages');
|
|
744
|
+
}
|
|
745
|
+
this.forget(path);
|
|
746
|
+
if (knownListing)
|
|
747
|
+
this.listings.set(kind, { stamp: this.listingStamp(kind), ids: knownListing.ids.filter(entry => entry !== listingId) });
|
|
748
|
+
else
|
|
749
|
+
this.listings.delete(kind);
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/** Gateway entry points load/reconcile indexes in bounded slices before synchronous publication. */
|
|
754
|
+
async prepare(signal) {
|
|
755
|
+
await this.prepareStatusMessages(signal);
|
|
756
|
+
for (;;) {
|
|
757
|
+
signal?.throwIfAborted();
|
|
758
|
+
const stamp = this.listingStamp('outbox');
|
|
759
|
+
if (this.outboxIndex?.stamp === stamp) {
|
|
760
|
+
this.preparedMode = true;
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const entries = new Map();
|
|
764
|
+
for (const id of this.ids('outbox')) {
|
|
765
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
766
|
+
signal?.throwIfAborted();
|
|
767
|
+
const record = this.read('outbox', id);
|
|
768
|
+
if (record)
|
|
769
|
+
entries.set(id, summarize(record));
|
|
770
|
+
}
|
|
771
|
+
if (this.listingStamp('outbox') === stamp) {
|
|
772
|
+
this.outboxIndex = { stamp, entries };
|
|
773
|
+
this.preparedMode = true;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/** Retain only routing metadata; recovery yields and local writes keep it current. */
|
|
779
|
+
async prepareStatusMessages(signal) {
|
|
780
|
+
for (;;) {
|
|
781
|
+
signal?.throwIfAborted();
|
|
782
|
+
const stamp = this.listingStamp('messages');
|
|
783
|
+
if (this.messageIndex?.stamp === stamp)
|
|
784
|
+
return;
|
|
785
|
+
const entries = new Map();
|
|
786
|
+
for await (const r of this.scan('messages', signal ?? new AbortController().signal))
|
|
787
|
+
entries.set(r.messageId, statusMessage(r));
|
|
788
|
+
if (this.listingStamp('messages') === stamp) {
|
|
789
|
+
this.messageIndex = { stamp, entries };
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
statusMessages(onError) {
|
|
795
|
+
const stamp = this.listingStamp('messages');
|
|
796
|
+
if (this.messageIndex?.stamp !== stamp) {
|
|
797
|
+
const entries = new Map(this.snapshot('messages', onError).map(r => [r.messageId, statusMessage(r)]));
|
|
798
|
+
this.messageIndex = { stamp, entries };
|
|
799
|
+
}
|
|
800
|
+
return [...this.messageIndex.entries.values()].map(r => ({ ...r }));
|
|
801
|
+
}
|
|
802
|
+
/** Resolve status candidates from metadata without reopening unrelated task ciphertext. */
|
|
803
|
+
statusOutbox(messageId, published = []) {
|
|
804
|
+
const known = new Set(published);
|
|
805
|
+
return this.summaries().filter(r => r.kind === 'delivery-status' && (!messageId || r.messageId === messageId) && !known.has(r.envelopeId))
|
|
806
|
+
.flatMap(r => this.read('outbox', r.envelopeId) ?? []);
|
|
807
|
+
}
|
|
808
|
+
/** Metadata only: local atomic writes update this index without scanning retained ciphertext. */
|
|
809
|
+
summaries() {
|
|
810
|
+
const stamp = this.listingStamp('outbox');
|
|
811
|
+
if (this.outboxIndex?.stamp !== stamp) {
|
|
812
|
+
// A concurrent publisher requires another bounded preparation, never a scan inside a live send.
|
|
813
|
+
if (this.preparedMode)
|
|
814
|
+
throw deliveryError('OUTBOX_STORE_BUSY');
|
|
815
|
+
const entries = new Map(this.snapshot('outbox').map(r => [r.envelopeId, summarize(r)]));
|
|
816
|
+
this.outboxIndex = { stamp, entries };
|
|
817
|
+
}
|
|
818
|
+
return [...this.outboxIndex.entries.values()];
|
|
819
|
+
}
|
|
820
|
+
pending(messageId) {
|
|
821
|
+
return this.summaries().filter(r => r.messageId === messageId && r.state === 'pending' &&
|
|
822
|
+
this.read('batches', r.batchId)?.envelopeIds.includes(r.envelopeId)).map(r => structuredClone(r));
|
|
823
|
+
}
|
|
824
|
+
/** Reconcile C2 capacity metadata outside the signer lock, yielding between retained records. */
|
|
825
|
+
async prepareIdempotency() {
|
|
826
|
+
for (;;) {
|
|
827
|
+
const current = this.listingStamp('operations');
|
|
828
|
+
if (idempotencyKinds.every(kind => this.idempotencyIndexes.get(kind)?.stamp === current))
|
|
829
|
+
return;
|
|
830
|
+
// Sample both ends under the signer lock: an unlocked scan must not adopt
|
|
831
|
+
// an increment whose corresponding rename/unlink has not finished yet.
|
|
832
|
+
const stamp = this.transaction(() => this.listingStamp('operations'));
|
|
833
|
+
const indexes = new Map();
|
|
834
|
+
for (const kind of idempotencyKinds) {
|
|
835
|
+
const entries = new Map();
|
|
836
|
+
for await (const record of this.scan(kind, new AbortController().signal)) {
|
|
837
|
+
const id = kind === 'operations' ? record.operationId : record.keyHash;
|
|
838
|
+
entries.set(id, summarizeIdempotency(record));
|
|
839
|
+
}
|
|
840
|
+
indexes.set(kind, { stamp, entries });
|
|
841
|
+
}
|
|
842
|
+
const adopted = this.transaction(() => {
|
|
843
|
+
if (this.listingStamp('operations') !== stamp)
|
|
844
|
+
return false;
|
|
845
|
+
for (const [kind, index] of indexes)
|
|
846
|
+
this.idempotencyIndexes.set(kind, index);
|
|
847
|
+
return true;
|
|
848
|
+
});
|
|
849
|
+
if (adopted)
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/** Caller holds the signer lock. Reread both inventories and validate every listed record. */
|
|
854
|
+
refreshIdempotencyIndexes() {
|
|
855
|
+
for (const kind of ['operations', 'idempotency']) {
|
|
856
|
+
// A matching directory stamp alone does not confirm the inventory for orphan cleanup.
|
|
857
|
+
this.listings.delete(kind);
|
|
858
|
+
const entries = new Map(this.snapshot(kind).map(record => [kind === 'operations' ? record.operationId
|
|
859
|
+
: record.keyHash, summarizeIdempotency(record)]));
|
|
860
|
+
this.idempotencyIndexes.set(kind, { stamp: this.listingStamp(kind), entries });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** Caller holds the signer lock. Unchanged retained results require no file scans or reopens. */
|
|
864
|
+
reserveIdempotencyCapacity(now) {
|
|
865
|
+
// Creating a missing generation changes the shared stamp and requires a full
|
|
866
|
+
// preparation before any capacity or orphan decision can use these indexes.
|
|
867
|
+
if (this.generationStamp() === 'absent') {
|
|
868
|
+
this.idempotencyIndexes.clear();
|
|
869
|
+
for (const kind of idempotencyKinds)
|
|
870
|
+
this.listings.delete(kind);
|
|
871
|
+
this.openGeneration();
|
|
872
|
+
}
|
|
873
|
+
let operations = this.idempotencyIndexes.get('operations'), bindings = this.idempotencyIndexes.get('idempotency');
|
|
874
|
+
if (!operations || !bindings || operations.stamp !== this.listingStamp('operations') || bindings.stamp !== this.listingStamp('idempotency'))
|
|
875
|
+
throw deliveryError('IDEMPOTENCY_STORE_BUSY');
|
|
876
|
+
const leaseMs = this.idempotencyLeaseMs;
|
|
877
|
+
const retainedBindings = new Set([...bindings.entries.values()].filter(b => b.retainUntil > now).map(b => b.operationId));
|
|
878
|
+
let refreshed = false;
|
|
879
|
+
if (this.transactionReads && [...operations.entries.values()].some(r => r.retainUntil > now &&
|
|
880
|
+
!retainedBindings.has(r.operationId) && r.createdAt + leaseMs <= now)) {
|
|
881
|
+
// Only an aged orphan candidate needs this extra I/O; warm reservations reuse their indexes.
|
|
882
|
+
this.refreshIdempotencyIndexes();
|
|
883
|
+
operations = this.idempotencyIndexes.get('operations');
|
|
884
|
+
bindings = this.idempotencyIndexes.get('idempotency');
|
|
885
|
+
refreshed = true;
|
|
886
|
+
}
|
|
887
|
+
for (const [id, binding] of bindings.entries)
|
|
888
|
+
if (binding.retainUntil <= now)
|
|
889
|
+
this.remove('idempotency', id);
|
|
890
|
+
const bound = new Set([...bindings.entries.values()].map(b => b.operationId));
|
|
891
|
+
for (const [id, record] of operations.entries)
|
|
892
|
+
if (record.retainUntil <= now ||
|
|
893
|
+
refreshed && !bound.has(id) && record.createdAt + leaseMs <= now)
|
|
894
|
+
this.remove('operations', id);
|
|
895
|
+
const bytes = [...operations.entries.values()].reduce((n, r) => n + (r.bytes ?? this.limits.operationBytes), 0);
|
|
896
|
+
if (operations.entries.size >= this.limits.operations || bytes + this.limits.operationBytes > this.limits.cachedResultBytes)
|
|
897
|
+
throw deliveryError('IDEMPOTENCY_STORE_FULL');
|
|
898
|
+
}
|
|
899
|
+
assertCapacity(records, message) {
|
|
900
|
+
const all = this.summaries(), active = all.filter(r => r.state === 'pending');
|
|
901
|
+
if (active.length + records.length > this.limits.activeEnvelopes ||
|
|
902
|
+
all.reduce((n, r) => n + r.bytes, 0) + records.reduce((n, r) => n + Buffer.byteLength(JSON.stringify(r.envelope)), 0) > this.limits.envelopeBytes ||
|
|
903
|
+
records.some(r => Buffer.byteLength(JSON.stringify(r.envelope)) > this.limits.wireBytes) ||
|
|
904
|
+
message && !this.read('messages', message.messageId) && this.ids('messages').length >= this.limits.messages)
|
|
905
|
+
throw deliveryError('OUTBOX_FULL');
|
|
906
|
+
}
|
|
907
|
+
/** Read-only preflight; publication rechecks capacity under the signer lock. */
|
|
908
|
+
checkAvailable(envelopes = 2) {
|
|
909
|
+
if (this.summaries().filter(r => r.state === 'pending').length + envelopes > this.limits.activeEnvelopes ||
|
|
910
|
+
this.ids('messages').length >= this.limits.messages)
|
|
911
|
+
throw deliveryError('OUTBOX_FULL');
|
|
912
|
+
}
|
|
913
|
+
committed(batchId) {
|
|
914
|
+
const b = this.read('batches', batchId);
|
|
915
|
+
if (!b)
|
|
916
|
+
return [];
|
|
917
|
+
const records = b.envelopeIds.map(id => this.read('outbox', id));
|
|
918
|
+
requireDelivery(records.every((r) => !!r && r.batchId === b.batchId && r.messageId === b.messageId));
|
|
919
|
+
const members = records;
|
|
920
|
+
requireDelivery(members.every(r => r.localOwnerDid === b.localOwnerDid && r.peerOwnerDid === members[0].peerOwnerDid &&
|
|
921
|
+
r.relayUrl === members[0].relayUrl && r.deliveryDeadline === members[0].deliveryDeadline && r.kind === members[0].kind) &&
|
|
922
|
+
(members.length === 1 || members[0].kind === 'task' && members[1].ordinal === members[0].ordinal + 1 &&
|
|
923
|
+
members[1].envelope.payload.eventType === 'AUDIT_ENTRY' && members[0].envelope.payload.taskId === members[1].envelope.payload.taskId));
|
|
924
|
+
const proposal = members.find(r => r.envelope.payload.eventType === 'PROPOSE');
|
|
925
|
+
if (proposal) {
|
|
926
|
+
const message = this.read('messages', b.messageId);
|
|
927
|
+
requireDelivery(message && message.messageId === proposal.envelopeId && message.proposalEnvelopeDigest === proposal.envelopeDigest &&
|
|
928
|
+
message.peerOwnerDid === proposal.peerOwnerDid && message.relayUrl === proposal.relayUrl &&
|
|
929
|
+
message.taskId === proposal.envelope.payload.taskId && message.deliveryDeadline === proposal.deliveryDeadline);
|
|
930
|
+
}
|
|
931
|
+
return members;
|
|
932
|
+
}
|
|
933
|
+
finishBatch(batchId) {
|
|
934
|
+
const records = this.committed(batchId);
|
|
935
|
+
if (!records.length || records.some(r => r.state === 'pending'))
|
|
936
|
+
return;
|
|
937
|
+
// The marker is removed first; an interrupted unlink cannot make members dispatchable.
|
|
938
|
+
this.remove('batches', batchId);
|
|
939
|
+
for (const r of records)
|
|
940
|
+
this.remove('outbox', r.envelopeId);
|
|
941
|
+
}
|
|
942
|
+
receipt(envelope, deadline, state, outcomeCode) {
|
|
943
|
+
return this.transaction(() => this.receiptLocked(envelope, deadline, state, outcomeCode));
|
|
944
|
+
}
|
|
945
|
+
async receiptPrepared(envelope, deadline, state, signal) {
|
|
946
|
+
const now = this.now(), id = envelope.payload.envelopeId;
|
|
947
|
+
for (;;) {
|
|
948
|
+
signal?.throwIfAborted();
|
|
949
|
+
const record = this.nextReceipt(envelope, deadline, state, undefined, now);
|
|
950
|
+
if (JSON.stringify(this.read('receipts', id)) === JSON.stringify(record))
|
|
951
|
+
return;
|
|
952
|
+
const prepared = await this.prepareWrites([{ kind: 'receipts', id, record }]);
|
|
953
|
+
try {
|
|
954
|
+
signal?.throwIfAborted();
|
|
955
|
+
const published = this.transaction(() => {
|
|
956
|
+
const current = this.nextReceipt(envelope, deadline, state, undefined, now);
|
|
957
|
+
if (JSON.stringify(current) !== JSON.stringify(record))
|
|
958
|
+
return false;
|
|
959
|
+
if (JSON.stringify(this.read('receipts', id)) !== JSON.stringify(record))
|
|
960
|
+
this.publishWrite(prepared[0]);
|
|
961
|
+
return true;
|
|
962
|
+
});
|
|
963
|
+
if (published)
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
finally {
|
|
967
|
+
this.discardWrites(prepared);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/** Caller holds the signer lock, including when publishing a task checkpoint. */
|
|
972
|
+
receiptLocked(envelope, deadline, state, outcomeCode) {
|
|
973
|
+
const id = envelope.payload.envelopeId, r = this.nextReceipt(envelope, deadline, state, outcomeCode);
|
|
974
|
+
const prior = this.read('receipts', id);
|
|
975
|
+
if (!prior || JSON.stringify(prior) !== JSON.stringify(r))
|
|
976
|
+
this.write('receipts', id, r);
|
|
977
|
+
return r;
|
|
978
|
+
}
|
|
979
|
+
nextReceipt(envelope, deadline, state, outcomeCode, now = this.now()) {
|
|
980
|
+
const id = envelope.payload.envelopeId, prior = this.read('receipts', id);
|
|
981
|
+
if (prior && (prior.envelopeId !== id || prior.senderDid !== envelope.payload.senderDid || prior.taskId !== envelope.payload.taskId || prior.envelopeDigest !== digest(envelope)))
|
|
982
|
+
throw deliveryError('DELIVERY_RECORD_CONFLICT');
|
|
983
|
+
if (!prior && this.ids('receipts').length >= this.limits.receipts)
|
|
984
|
+
throw deliveryError('OUTBOX_FULL');
|
|
985
|
+
const rank = { persisted: 0, processed: 1, terminal: 2 };
|
|
986
|
+
const r = { version: 1, envelopeId: id, localOwnerDid: this.owner.did,
|
|
987
|
+
senderDid: envelope.payload.senderDid, taskId: envelope.payload.taskId, envelopeDigest: digest(envelope),
|
|
988
|
+
receivedAt: prior?.receivedAt ?? now, retainUntil: prior?.retainUntil ?? Math.max(now, deadline) + DELIVERY_RETENTION_MS,
|
|
989
|
+
state: prior && rank[prior.state] > rank[state] ? prior.state : state, ...(outcomeCode ? { outcomeCode } : {}) };
|
|
990
|
+
return r;
|
|
991
|
+
}
|
|
992
|
+
*maintenanceSteps(onError) {
|
|
993
|
+
for (const id of this.ids('batches', onError))
|
|
994
|
+
yield () => this.transaction(() => {
|
|
995
|
+
const records = this.committed(id);
|
|
996
|
+
// Close members before pruning their message metadata, including after a long shutdown.
|
|
997
|
+
const now = this.now();
|
|
998
|
+
for (const r of records)
|
|
999
|
+
if (r.state === 'pending' && r.deliveryDeadline <= now) {
|
|
1000
|
+
const next = { ...r, state: 'closed', lastError: 'OUTBOX_DEADLINE_EXPIRED' };
|
|
1001
|
+
delete next.lease;
|
|
1002
|
+
this.write('outbox', r.envelopeId, next);
|
|
1003
|
+
const message = r.kind === 'task' && this.read('messages', r.messageId);
|
|
1004
|
+
if (message && ['queued', 'relayed'].includes(message.status))
|
|
1005
|
+
this.write('messages', message.messageId, { ...message, status: 'closed', closedBy: 'deadline', lastError: 'OUTBOX_DEADLINE_EXPIRED', updatedAt: now });
|
|
1006
|
+
}
|
|
1007
|
+
this.finishBatch(id);
|
|
1008
|
+
});
|
|
1009
|
+
for (const kind of kinds)
|
|
1010
|
+
for (const id of this.ids(kind, onError))
|
|
1011
|
+
yield () => this.transaction(() => {
|
|
1012
|
+
const r = this.read(kind, id);
|
|
1013
|
+
if (!r)
|
|
1014
|
+
return;
|
|
1015
|
+
// Recheck under this lock: another process may publish work between maintenance slices.
|
|
1016
|
+
const orphan = kind === 'outbox' && !this.read('batches', r.batchId)?.envelopeIds.includes(id);
|
|
1017
|
+
if (orphan || 'retainUntil' in r && r.retainUntil <= this.now())
|
|
1018
|
+
this.remove(kind, id);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
maintain(onError = () => { }) {
|
|
1022
|
+
for (const step of this.maintenanceSteps(onError)) {
|
|
1023
|
+
try {
|
|
1024
|
+
step();
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
onError(deliveryError('OUTBOX_STORE_INVALID'));
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/** One batch (at most two frames) or one retained record per event-loop turn. */
|
|
1032
|
+
async maintainInBackground(onError, signal) {
|
|
1033
|
+
for (const step of this.maintenanceSteps(onError)) {
|
|
1034
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
1035
|
+
if (signal.aborted)
|
|
1036
|
+
return;
|
|
1037
|
+
try {
|
|
1038
|
+
step();
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
onError(deliveryError('OUTBOX_STORE_INVALID'));
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
export const DeliveryStore = DeliveryStoreImplementation;
|
|
1047
|
+
//# sourceMappingURL=delivery-store.js.map
|