dsh-live-teams 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +176 -0
- package/NOTICE +11 -0
- package/README.md +85 -0
- package/cordis.patch.yml +25 -0
- package/lib/binding.d.ts +18 -0
- package/lib/binding.js +42 -0
- package/lib/changed-paths.d.ts +26 -0
- package/lib/changed-paths.js +69 -0
- package/lib/client.js +6753 -0
- package/lib/command-queue.d.ts +60 -0
- package/lib/command-queue.js +185 -0
- package/lib/compatibility.js +109 -0
- package/lib/context-provider.d.ts +110 -0
- package/lib/context-provider.js +249 -0
- package/lib/dispatch.d.ts +174 -0
- package/lib/dispatch.js +624 -0
- package/lib/errors.d.ts +36 -0
- package/lib/errors.js +103 -0
- package/lib/git-artifacts.d.ts +50 -0
- package/lib/git-artifacts.js +242 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +14 -0
- package/lib/mailbox.d.ts +274 -0
- package/lib/mailbox.js +721 -0
- package/lib/member-tools.d.ts +57 -0
- package/lib/member-tools.js +1265 -0
- package/lib/migrations.d.ts +17 -0
- package/lib/migrations.js +47 -0
- package/lib/plugin.d.ts +106 -0
- package/lib/plugin.js +1003 -0
- package/lib/roles.d.ts +35 -0
- package/lib/roles.js +284 -0
- package/lib/routes.d.ts +586 -0
- package/lib/routes.js +2816 -0
- package/lib/scope.d.ts +62 -0
- package/lib/scope.js +133 -0
- package/lib/session-bridge.d.ts +76 -0
- package/lib/session-bridge.js +147 -0
- package/lib/session-title.js +35 -0
- package/lib/storage.d.ts +9 -0
- package/lib/storage.js +65 -0
- package/lib/task-store.d.ts +729 -0
- package/lib/task-store.js +2205 -0
- package/lib/team-store.d.ts +216 -0
- package/lib/team-store.js +765 -0
- package/lib/tree-snapshot.d.ts +28 -0
- package/lib/tree-snapshot.js +80 -0
- package/lib/types/client/TeamView.d.ts +26 -0
- package/lib/types/client/TeamView.dom.test.d.ts +1 -0
- package/lib/types/client/api.d.ts +522 -0
- package/lib/types/client/api.test.d.ts +1 -0
- package/lib/types/client/attention.d.ts +65 -0
- package/lib/types/client/attention.test.d.ts +1 -0
- package/lib/types/client/index.d.ts +31 -0
- package/lib/types/client/locales.d.ts +577 -0
- package/lib/types/client/member-name.d.ts +14 -0
- package/lib/types/client/member-name.test.d.ts +1 -0
- package/lib/types/client/roster.d.ts +26 -0
- package/lib/types/client/roster.test.d.ts +1 -0
- package/lib/types/client/styles.d.ts +3 -0
- package/package.json +104 -0
- package/roles/builder.md +40 -0
- package/roles/delegate.md +36 -0
- package/roles/lead.md +46 -0
- package/roles/oracle.md +36 -0
- package/roles/researcher.md +37 -0
- package/roles/reviewer.md +45 -0
- package/roles/scout.md +36 -0
- package/roles/verifier.md +36 -0
package/lib/mailbox.js
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import { ERROR_CODES, LiveTeamsError } from "./errors.js";
|
|
2
|
+
import { atomicWriteJson, clone, fileExists, readJson, withProcessLock } from "./storage.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
//#region src/mailbox.ts
|
|
7
|
+
/**
|
|
8
|
+
* The one delivery failure the dispatcher may retry on its own: the recipient had
|
|
9
|
+
* no usable binding yet. Every other failure is terminal and stays visible —
|
|
10
|
+
* the reconciler never resurrects a delivery for a reason it cannot name.
|
|
11
|
+
*/
|
|
12
|
+
const NO_SESSION_FAILURE = "recipient has no current session binding";
|
|
13
|
+
function recipientLabel(recipient) {
|
|
14
|
+
return recipient.displayName?.trim() || recipient.memberId;
|
|
15
|
+
}
|
|
16
|
+
/** Resolve the one addressing vocabulary shared by member and human sends. */
|
|
17
|
+
function resolveMailboxRecipient(value, recipients) {
|
|
18
|
+
const requested = value.trim();
|
|
19
|
+
const exact = recipients.find((candidate) => candidate.memberId === requested);
|
|
20
|
+
if (exact !== void 0) return {
|
|
21
|
+
recipient: exact.memberId,
|
|
22
|
+
name: recipientLabel(exact)
|
|
23
|
+
};
|
|
24
|
+
if (requested === "team") return {
|
|
25
|
+
recipient: "team",
|
|
26
|
+
name: "team"
|
|
27
|
+
};
|
|
28
|
+
if (requested === "human") return {
|
|
29
|
+
recipient: "human",
|
|
30
|
+
name: "human"
|
|
31
|
+
};
|
|
32
|
+
const matching = recipients.filter((candidate) => candidate.displayName?.trim().toLocaleLowerCase() === requested.toLocaleLowerCase());
|
|
33
|
+
if (matching.length === 1) {
|
|
34
|
+
const candidate = matching[0];
|
|
35
|
+
if (candidate === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "recipient resolution state disappeared");
|
|
36
|
+
return {
|
|
37
|
+
recipient: candidate.memberId,
|
|
38
|
+
name: recipientLabel(candidate)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (matching.length > 1) {
|
|
42
|
+
const candidates = matching.map((candidate) => `${candidate.memberId} (${recipientLabel(candidate)})`);
|
|
43
|
+
throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `ambiguous recipient "${requested}"; candidates: ${candidates.join(", ")}`, { details: { candidates } });
|
|
44
|
+
}
|
|
45
|
+
const existing = recipients.map((candidate) => `${candidate.memberId} (${recipientLabel(candidate)})`);
|
|
46
|
+
throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `unknown recipient "${requested}"; existing recipients: ${existing.length > 0 ? existing.join(", ") : "none"}`, { details: { recipients: existing } });
|
|
47
|
+
}
|
|
48
|
+
const SCHEMA_VERSION = 1;
|
|
49
|
+
const DEFAULT_LEASE_MS = 3e4;
|
|
50
|
+
const MESSAGE_KEYS = /* @__PURE__ */ new Set([
|
|
51
|
+
"id",
|
|
52
|
+
"teamId",
|
|
53
|
+
"from",
|
|
54
|
+
"to",
|
|
55
|
+
"kind",
|
|
56
|
+
"content",
|
|
57
|
+
"threadId",
|
|
58
|
+
"createdAt"
|
|
59
|
+
]);
|
|
60
|
+
const DELIVERY_KEYS = /* @__PURE__ */ new Set([
|
|
61
|
+
"id",
|
|
62
|
+
"messageId",
|
|
63
|
+
"recipient",
|
|
64
|
+
"wakePolicy",
|
|
65
|
+
"ackPolicy",
|
|
66
|
+
"state",
|
|
67
|
+
"claimedAt",
|
|
68
|
+
"deliveredAt",
|
|
69
|
+
"acknowledgedAt",
|
|
70
|
+
"failure",
|
|
71
|
+
"revision"
|
|
72
|
+
]);
|
|
73
|
+
const MESSAGE_WRAPPER_KEYS = /* @__PURE__ */ new Set([
|
|
74
|
+
"schemaVersion",
|
|
75
|
+
"teamId",
|
|
76
|
+
"messages"
|
|
77
|
+
]);
|
|
78
|
+
const DELIVERY_WRAPPER_KEYS = /* @__PURE__ */ new Set([
|
|
79
|
+
"schemaVersion",
|
|
80
|
+
"teamId",
|
|
81
|
+
"deliveries"
|
|
82
|
+
]);
|
|
83
|
+
function record(value) {
|
|
84
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
85
|
+
}
|
|
86
|
+
function text(value) {
|
|
87
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
88
|
+
}
|
|
89
|
+
function nonNegativeInteger(value) {
|
|
90
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
91
|
+
}
|
|
92
|
+
function positiveInteger(value) {
|
|
93
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
94
|
+
}
|
|
95
|
+
function keysAllowed(value, keys) {
|
|
96
|
+
return Object.keys(value).every((key) => keys.has(key));
|
|
97
|
+
}
|
|
98
|
+
function isMessageKind(value) {
|
|
99
|
+
return value === "message" || value === "direction" || value === "comment";
|
|
100
|
+
}
|
|
101
|
+
function isWakePolicy(value) {
|
|
102
|
+
return value === "never" || value === "queue" || value === "steer";
|
|
103
|
+
}
|
|
104
|
+
function isAckPolicy(value) {
|
|
105
|
+
return value === "delivery" || value === "consumption" || value === "formal";
|
|
106
|
+
}
|
|
107
|
+
function isDeliveryState(value) {
|
|
108
|
+
return value === "stored" || value === "leased" || value === "delivered" || value === "acknowledged" || value === "failed";
|
|
109
|
+
}
|
|
110
|
+
function cloneMessage(value) {
|
|
111
|
+
return clone(value);
|
|
112
|
+
}
|
|
113
|
+
function cloneDelivery(value) {
|
|
114
|
+
return clone(value);
|
|
115
|
+
}
|
|
116
|
+
function messagePolicy(kind, recipient) {
|
|
117
|
+
if (recipient === "human") return {
|
|
118
|
+
wakePolicy: "never",
|
|
119
|
+
ackPolicy: "consumption"
|
|
120
|
+
};
|
|
121
|
+
if (kind === "comment") return {
|
|
122
|
+
wakePolicy: "never",
|
|
123
|
+
ackPolicy: "consumption"
|
|
124
|
+
};
|
|
125
|
+
if (kind === "direction") return {
|
|
126
|
+
wakePolicy: "steer",
|
|
127
|
+
ackPolicy: "delivery"
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
wakePolicy: "queue",
|
|
131
|
+
ackPolicy: "delivery"
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Derive the externally reported outcome from a final delivery state and policy. */
|
|
135
|
+
function messageDeliveryOutcome(delivery) {
|
|
136
|
+
if (delivery.state === "failed") return "failed";
|
|
137
|
+
if (delivery.state === "delivered" || delivery.state === "acknowledged") return "delivered";
|
|
138
|
+
if (delivery.state === "stored" && delivery.wakePolicy === "never") return "inbox";
|
|
139
|
+
return "pending";
|
|
140
|
+
}
|
|
141
|
+
function checkedMessage(value, teamId) {
|
|
142
|
+
const candidate = record(value);
|
|
143
|
+
if (candidate === void 0 || !keysAllowed(candidate, MESSAGE_KEYS) || text(candidate.id) === void 0 || candidate.teamId !== teamId || candidate.from !== "human" && candidate.from !== "scheduler" && text(candidate.from) === void 0 || candidate.to !== "human" && candidate.to !== "team" && text(candidate.to) === void 0 || !isMessageKind(candidate.kind) || typeof candidate.content !== "string" || !nonNegativeInteger(candidate.createdAt) || candidate.threadId !== void 0 && text(candidate.threadId) === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid mailbox message state", { auditDetail: `invalid message in team ${teamId}` });
|
|
144
|
+
return clone(candidate);
|
|
145
|
+
}
|
|
146
|
+
function checkedDelivery(value, teamId) {
|
|
147
|
+
const candidate = record(value);
|
|
148
|
+
if (candidate === void 0 || !keysAllowed(candidate, DELIVERY_KEYS) || text(candidate.id) === void 0 || text(candidate.messageId) === void 0 || typeof candidate.recipient !== "string" || candidate.recipient.length === 0 || !isWakePolicy(candidate.wakePolicy) || !isAckPolicy(candidate.ackPolicy) || !isDeliveryState(candidate.state) || !positiveInteger(candidate.revision) || candidate.claimedAt !== void 0 && !nonNegativeInteger(candidate.claimedAt) || candidate.deliveredAt !== void 0 && !nonNegativeInteger(candidate.deliveredAt) || candidate.acknowledgedAt !== void 0 && !nonNegativeInteger(candidate.acknowledgedAt) || candidate.failure !== void 0 && text(candidate.failure) === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid mailbox delivery state", { auditDetail: `invalid delivery in team ${teamId}` });
|
|
149
|
+
if (candidate.state === "failed" && candidate.failure === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "failed delivery has no reason");
|
|
150
|
+
if (candidate.state === "acknowledged" && candidate.acknowledgedAt === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "acknowledged delivery has no timestamp");
|
|
151
|
+
if (candidate.state === "delivered" && candidate.deliveredAt === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "delivered delivery has no timestamp");
|
|
152
|
+
if (candidate.state === "stored" && (candidate.claimedAt !== void 0 || candidate.deliveredAt !== void 0 || candidate.acknowledgedAt !== void 0 || candidate.failure !== void 0)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "stored delivery has transition fields");
|
|
153
|
+
if (candidate.state === "leased" && candidate.claimedAt === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "leased delivery has no claim timestamp");
|
|
154
|
+
return clone(candidate);
|
|
155
|
+
}
|
|
156
|
+
function checkedArray(value, key, teamId) {
|
|
157
|
+
const wrapper = record(value);
|
|
158
|
+
if (wrapper === void 0 || !keysAllowed(wrapper, key === "messages" ? MESSAGE_WRAPPER_KEYS : DELIVERY_WRAPPER_KEYS) || wrapper.schemaVersion !== SCHEMA_VERSION || wrapper.teamId !== teamId || !Array.isArray(wrapper[key])) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid mailbox ${key} state`, { auditDetail: `invalid ${key}.json in team ${teamId}` });
|
|
159
|
+
return key === "messages" ? wrapper.messages.map((entry) => checkedMessage(entry, teamId)) : wrapper.deliveries.map((entry) => checkedDelivery(entry, teamId));
|
|
160
|
+
}
|
|
161
|
+
function requestDraft(value) {
|
|
162
|
+
const candidate = record(value);
|
|
163
|
+
if (candidate === void 0 || Object.keys(candidate).some((key) => !(/* @__PURE__ */ new Set([
|
|
164
|
+
"to",
|
|
165
|
+
"kind",
|
|
166
|
+
"content",
|
|
167
|
+
"threadId"
|
|
168
|
+
])).has(key)) || typeof candidate.to !== "string" || candidate.to.length === 0 || !isMessageKind(candidate.kind) || typeof candidate.content !== "string" || candidate.threadId !== void 0 && text(candidate.threadId) === void 0 || candidate.from !== void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "invalid mailbox message request");
|
|
169
|
+
return {
|
|
170
|
+
to: candidate.to,
|
|
171
|
+
kind: candidate.kind,
|
|
172
|
+
content: candidate.content,
|
|
173
|
+
...candidate.threadId === void 0 ? {} : { threadId: candidate.threadId }
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function senderActor(value) {
|
|
177
|
+
const candidate = record(value);
|
|
178
|
+
if (candidate?.type === "human") return { type: "human" };
|
|
179
|
+
if (candidate?.type === "scheduler") return { type: "scheduler" };
|
|
180
|
+
if (candidate?.type === "session" && text(candidate.sessionId) !== void 0) return {
|
|
181
|
+
type: "session",
|
|
182
|
+
sessionId: candidate.sessionId
|
|
183
|
+
};
|
|
184
|
+
throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox sender identity is required");
|
|
185
|
+
}
|
|
186
|
+
var MailboxService = class {
|
|
187
|
+
workspacePath;
|
|
188
|
+
teamId;
|
|
189
|
+
queue;
|
|
190
|
+
listRecipients;
|
|
191
|
+
authorize;
|
|
192
|
+
resolveSessionSender;
|
|
193
|
+
appendAudit;
|
|
194
|
+
now;
|
|
195
|
+
leaseMs;
|
|
196
|
+
autoDeliver;
|
|
197
|
+
directory;
|
|
198
|
+
messagesFile;
|
|
199
|
+
deliveriesFile;
|
|
200
|
+
lockFile;
|
|
201
|
+
constructor(options) {
|
|
202
|
+
if (text(options?.workspacePath) === void 0 || text(options?.teamId) === void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox requires workspacePath and teamId");
|
|
203
|
+
if (!options.queue || typeof options.queue.admit !== "function" || typeof options.queue.dispatch !== "function") throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox requires the existing command queue");
|
|
204
|
+
if (typeof options.listRecipients !== "function") throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox requires a recipient resolver");
|
|
205
|
+
this.workspacePath = path.resolve(options.workspacePath);
|
|
206
|
+
this.teamId = options.teamId;
|
|
207
|
+
this.queue = options.queue;
|
|
208
|
+
this.listRecipients = options.listRecipients;
|
|
209
|
+
this.authorize = options.authorize;
|
|
210
|
+
this.resolveSessionSender = options.resolveSessionSender;
|
|
211
|
+
this.appendAudit = options.appendAudit;
|
|
212
|
+
this.now = options.now ?? Date.now;
|
|
213
|
+
this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
|
|
214
|
+
if (!positiveInteger(this.leaseMs)) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox leaseMs must be positive");
|
|
215
|
+
this.autoDeliver = options.autoDeliver ?? true;
|
|
216
|
+
this.directory = path.join(this.workspacePath, ".dsh-live-teams", "teams", this.teamId);
|
|
217
|
+
this.messagesFile = path.join(this.directory, "messages.json");
|
|
218
|
+
this.deliveriesFile = path.join(this.directory, "deliveries.json");
|
|
219
|
+
this.lockFile = path.join(this.directory, ".mailbox.lock");
|
|
220
|
+
}
|
|
221
|
+
async listMessages() {
|
|
222
|
+
return (await this.snapshot()).messages.map(cloneMessage);
|
|
223
|
+
}
|
|
224
|
+
async listDeliveries() {
|
|
225
|
+
return (await this.snapshot()).deliveries.map(cloneDelivery);
|
|
226
|
+
}
|
|
227
|
+
async getMessage(messageId) {
|
|
228
|
+
return (await this.listMessages()).find((message) => message.id === messageId);
|
|
229
|
+
}
|
|
230
|
+
async getDelivery(deliveryId) {
|
|
231
|
+
return (await this.listDeliveries()).find((delivery) => delivery.id === deliveryId);
|
|
232
|
+
}
|
|
233
|
+
async deliveriesFor(messageId) {
|
|
234
|
+
return (await this.listDeliveries()).filter((delivery) => delivery.messageId === messageId);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Internal send path.
|
|
238
|
+
*
|
|
239
|
+
* Deliberately private: an actor argument that any caller may supply would let
|
|
240
|
+
* a member send as `human` or as another member, which ADR 0004 forbids. The
|
|
241
|
+
* public entries derive the sender instead — from the calling Session binding
|
|
242
|
+
* for member traffic, and from the host itself for human and scheduler paths.
|
|
243
|
+
*/
|
|
244
|
+
async send(draftValue, actorValue) {
|
|
245
|
+
const draft = requestDraft(draftValue);
|
|
246
|
+
const actor = senderActor(actorValue);
|
|
247
|
+
const from = await this.resolveFrom(actor);
|
|
248
|
+
const result = await withProcessLock(this.lockFile, async () => {
|
|
249
|
+
const snapshot = await this.readSnapshot();
|
|
250
|
+
const availableRecipients = await this.listRecipients();
|
|
251
|
+
const resolved = resolveMailboxRecipient(draft.to, availableRecipients);
|
|
252
|
+
const recipients = resolved.recipient === "team" ? availableRecipients.filter((recipient) => recipient.active !== false) : [];
|
|
253
|
+
if (this.authorize !== void 0) await this.authorize({
|
|
254
|
+
from,
|
|
255
|
+
to: resolved.recipient
|
|
256
|
+
});
|
|
257
|
+
const message = {
|
|
258
|
+
id: randomUUID(),
|
|
259
|
+
teamId: this.teamId,
|
|
260
|
+
from,
|
|
261
|
+
to: resolved.recipient,
|
|
262
|
+
kind: draft.kind,
|
|
263
|
+
content: draft.content,
|
|
264
|
+
...draft.threadId === void 0 ? {} : { threadId: draft.threadId },
|
|
265
|
+
createdAt: this.now()
|
|
266
|
+
};
|
|
267
|
+
const recipientIds = resolved.recipient === "team" ? recipients.map((recipient) => recipient.memberId) : [resolved.recipient];
|
|
268
|
+
if (recipientIds.some((recipient) => typeof recipient !== "string" || recipient.length === 0)) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "mailbox recipient is invalid");
|
|
269
|
+
const deliveries = recipientIds.map((recipient) => {
|
|
270
|
+
const policy = messagePolicy(draft.kind, recipient);
|
|
271
|
+
return {
|
|
272
|
+
id: randomUUID(),
|
|
273
|
+
messageId: message.id,
|
|
274
|
+
recipient,
|
|
275
|
+
...policy,
|
|
276
|
+
state: "stored",
|
|
277
|
+
revision: 1
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
await this.persist({
|
|
281
|
+
messages: [...snapshot.messages, message],
|
|
282
|
+
deliveries: [...snapshot.deliveries, ...deliveries]
|
|
283
|
+
});
|
|
284
|
+
await this.audit({
|
|
285
|
+
kind: "message/sent",
|
|
286
|
+
teamId: this.teamId,
|
|
287
|
+
messageId: message.id,
|
|
288
|
+
from: message.from,
|
|
289
|
+
actor: message.from,
|
|
290
|
+
to: message.to,
|
|
291
|
+
messageKind: message.kind,
|
|
292
|
+
at: this.now()
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
message: cloneMessage(message),
|
|
296
|
+
deliveries: deliveries.map(cloneDelivery)
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
if (!this.autoDeliver) return result;
|
|
300
|
+
const delivered = [];
|
|
301
|
+
for (const delivery of result.deliveries) delivered.push(await this.deliver(delivery.id));
|
|
302
|
+
return {
|
|
303
|
+
message: result.message,
|
|
304
|
+
deliveries: delivered
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
async sendFromSession(sessionId, draft) {
|
|
308
|
+
return this.send(draft, {
|
|
309
|
+
type: "session",
|
|
310
|
+
sessionId
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async sendFromHuman(draft) {
|
|
314
|
+
return this.send(draft, { type: "human" });
|
|
315
|
+
}
|
|
316
|
+
async sendFromScheduler(draft) {
|
|
317
|
+
return this.send(draft, { type: "scheduler" });
|
|
318
|
+
}
|
|
319
|
+
async claimDelivery(deliveryId) {
|
|
320
|
+
let leased;
|
|
321
|
+
await withProcessLock(this.lockFile, async () => {
|
|
322
|
+
const snapshot = await this.readSnapshot();
|
|
323
|
+
const index = snapshot.deliveries.findIndex((delivery) => delivery.id === deliveryId);
|
|
324
|
+
if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, `unknown mailbox delivery "${deliveryId}"`);
|
|
325
|
+
const current = snapshot.deliveries[index];
|
|
326
|
+
if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
|
|
327
|
+
if (current.state === "delivered" || current.state === "acknowledged") {
|
|
328
|
+
leased = cloneDelivery(current);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (current.state === "leased" && current.claimedAt !== void 0 && current.claimedAt + this.leaseMs > this.now()) return;
|
|
332
|
+
if (current.state === "failed") return;
|
|
333
|
+
const next = {
|
|
334
|
+
...current,
|
|
335
|
+
state: "leased",
|
|
336
|
+
claimedAt: this.now(),
|
|
337
|
+
revision: current.revision + 1
|
|
338
|
+
};
|
|
339
|
+
delete next.failure;
|
|
340
|
+
delete next.deliveredAt;
|
|
341
|
+
delete next.acknowledgedAt;
|
|
342
|
+
snapshot.deliveries[index] = next;
|
|
343
|
+
await this.persist(snapshot);
|
|
344
|
+
await this.audit({
|
|
345
|
+
kind: "delivery/leased",
|
|
346
|
+
teamId: this.teamId,
|
|
347
|
+
deliveryId: next.id,
|
|
348
|
+
messageId: next.messageId,
|
|
349
|
+
recipient: next.recipient,
|
|
350
|
+
at: this.now(),
|
|
351
|
+
revision: next.revision
|
|
352
|
+
});
|
|
353
|
+
leased = cloneDelivery(next);
|
|
354
|
+
});
|
|
355
|
+
return leased;
|
|
356
|
+
}
|
|
357
|
+
async deliver(deliveryId) {
|
|
358
|
+
const existing = await this.getDelivery(deliveryId);
|
|
359
|
+
if (existing === void 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, `unknown mailbox delivery "${deliveryId}"`);
|
|
360
|
+
if (existing.state === "delivered" || existing.state === "acknowledged") return existing;
|
|
361
|
+
if (existing.recipient === "human" || existing.wakePolicy === "never") return existing;
|
|
362
|
+
const claimed = await this.claimDelivery(deliveryId);
|
|
363
|
+
if (claimed === void 0) {
|
|
364
|
+
const current = await this.getDelivery(deliveryId);
|
|
365
|
+
if (current === void 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, `unknown mailbox delivery "${deliveryId}"`);
|
|
366
|
+
if (current.state === "leased") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox delivery lease is held");
|
|
367
|
+
if (current.state === "failed") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, current.failure ?? "mailbox delivery failed");
|
|
368
|
+
return current;
|
|
369
|
+
}
|
|
370
|
+
if (claimed.state === "delivered" || claimed.state === "acknowledged") return claimed;
|
|
371
|
+
const message = await this.getMessage(claimed.messageId);
|
|
372
|
+
if (message === void 0) return this.fail(claimed.id, "mailbox message is missing");
|
|
373
|
+
if (claimed.recipient === "human" || claimed.wakePolicy === "never") return claimed;
|
|
374
|
+
const recipient = (await this.listRecipients()).find((entry) => entry.memberId === claimed.recipient && entry.active !== false);
|
|
375
|
+
if (recipient === void 0 || recipient.sessionId === void 0 || recipient.bindingGeneration === void 0) return this.fail(claimed.id, NO_SESSION_FAILURE);
|
|
376
|
+
try {
|
|
377
|
+
const senderName = message.from === "human" || message.from === "scheduler" ? void 0 : (await this.listRecipients()).find((entry) => entry.memberId === message.from)?.displayName;
|
|
378
|
+
const first = await this.attemptDelivery(message, claimed, recipient, claimed.wakePolicy, senderName);
|
|
379
|
+
if (first.kind === "delivered") return this.markDelivered(claimed.id);
|
|
380
|
+
if (claimed.wakePolicy !== "steer") return this.fail(claimed.id, first.reason);
|
|
381
|
+
await this.audit({
|
|
382
|
+
kind: "delivery/steer-fallback",
|
|
383
|
+
teamId: this.teamId,
|
|
384
|
+
deliveryId: claimed.id,
|
|
385
|
+
messageId: message.id,
|
|
386
|
+
reason: first.reason,
|
|
387
|
+
at: this.now()
|
|
388
|
+
});
|
|
389
|
+
const second = await this.attemptDelivery(message, claimed, recipient, "queue", senderName);
|
|
390
|
+
if (second.kind === "delivered") return this.markDelivered(claimed.id);
|
|
391
|
+
return this.fail(claimed.id, second.reason);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return this.fail(claimed.id, error instanceof LiveTeamsError ? error.auditDetail ?? error.message : error instanceof Error ? error.message : String(error));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* One transport attempt in one mode.
|
|
398
|
+
*
|
|
399
|
+
* The idempotency key carries the mode, because the key is bound to the first
|
|
400
|
+
* command admitted under it: reusing one key for a queue retry after a steer
|
|
401
|
+
* attempt would return the steer command and change nothing. The mode is part
|
|
402
|
+
* of what makes this attempt distinct, and `(messageId, recipient)` still
|
|
403
|
+
* identifies the delivery itself.
|
|
404
|
+
*/
|
|
405
|
+
async attemptDelivery(message, claimed, recipient, mode, senderName) {
|
|
406
|
+
try {
|
|
407
|
+
const admission = await this.admitOnce(message, claimed, recipient, mode, senderName);
|
|
408
|
+
const command = await this.queue.dispatch(admission.record.commandId);
|
|
409
|
+
if (command.state === "admitted" || command.state === "applied") return { kind: "delivered" };
|
|
410
|
+
return {
|
|
411
|
+
kind: "refused",
|
|
412
|
+
reason: command.failureDetail ?? command.failureCode ?? "command queue did not admit delivery"
|
|
413
|
+
};
|
|
414
|
+
} catch (error) {
|
|
415
|
+
return {
|
|
416
|
+
kind: "refused",
|
|
417
|
+
reason: error instanceof LiveTeamsError ? error.auditDetail ?? error.message : error instanceof Error ? error.message : String(error)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async admitOnce(message, claimed, recipient, mode, senderName) {
|
|
422
|
+
const attributed = message.from === "human" ? `Human instruction (${message.kind}): ${message.content}` : message.from === "scheduler" ? `Scheduler notification (${message.kind}): ${message.content}` : `Peer message from ${senderName ?? message.from} (${message.kind}): ${message.content}`;
|
|
423
|
+
return this.queue.admit({
|
|
424
|
+
idempotencyKey: `${message.id}:${claimed.recipient}:${mode}`,
|
|
425
|
+
sessionId: recipient.sessionId,
|
|
426
|
+
bindingGeneration: recipient.bindingGeneration,
|
|
427
|
+
mode,
|
|
428
|
+
reason: `mailbox ${message.kind}`,
|
|
429
|
+
causationId: message.id,
|
|
430
|
+
content: [{
|
|
431
|
+
type: "text",
|
|
432
|
+
text: attributed
|
|
433
|
+
}]
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
async retryExpiredLeases() {
|
|
437
|
+
const candidates = (await this.snapshot()).deliveries.filter((delivery) => delivery.state === "leased" && delivery.claimedAt !== void 0 && delivery.claimedAt + this.leaseMs <= this.now());
|
|
438
|
+
const results = [];
|
|
439
|
+
for (const candidate of candidates) try {
|
|
440
|
+
results.push(await this.deliver(candidate.id));
|
|
441
|
+
} catch {}
|
|
442
|
+
return results;
|
|
443
|
+
}
|
|
444
|
+
async retryDelivery(deliveryId) {
|
|
445
|
+
await withProcessLock(this.lockFile, async () => {
|
|
446
|
+
const snapshot = await this.readSnapshot();
|
|
447
|
+
const index = snapshot.deliveries.findIndex((delivery) => delivery.id === deliveryId);
|
|
448
|
+
if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, `unknown mailbox delivery "${deliveryId}"`);
|
|
449
|
+
const current = snapshot.deliveries[index];
|
|
450
|
+
if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
|
|
451
|
+
if (current.state === "delivered" || current.state === "acknowledged") return;
|
|
452
|
+
if (current.state === "leased" && current.claimedAt !== void 0 && current.claimedAt + this.leaseMs > this.now()) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox delivery lease is held");
|
|
453
|
+
if (current.ackPolicy !== "delivery" && current.ackPolicy !== "formal" && current.ackPolicy !== "consumption") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox acknowledgement policy cannot be retried");
|
|
454
|
+
const next = {
|
|
455
|
+
...current,
|
|
456
|
+
state: "stored",
|
|
457
|
+
revision: current.revision + 1
|
|
458
|
+
};
|
|
459
|
+
delete next.claimedAt;
|
|
460
|
+
delete next.deliveredAt;
|
|
461
|
+
delete next.acknowledgedAt;
|
|
462
|
+
delete next.failure;
|
|
463
|
+
snapshot.deliveries[index] = next;
|
|
464
|
+
await this.persist(snapshot);
|
|
465
|
+
});
|
|
466
|
+
return this.deliver(deliveryId);
|
|
467
|
+
}
|
|
468
|
+
async acknowledge(messageId, recipient, _formal = false) {
|
|
469
|
+
if (text(messageId) === void 0 || text(recipient) === void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "acknowledgement requires messageId and recipient");
|
|
470
|
+
let result;
|
|
471
|
+
await withProcessLock(this.lockFile, async () => {
|
|
472
|
+
const snapshot = await this.readSnapshot();
|
|
473
|
+
const index = snapshot.deliveries.findIndex((delivery) => delivery.messageId === messageId && delivery.recipient === recipient);
|
|
474
|
+
if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "unknown mailbox delivery");
|
|
475
|
+
const current = snapshot.deliveries[index];
|
|
476
|
+
if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
|
|
477
|
+
if (current.state === "acknowledged" || current.ackPolicy === "delivery" && current.state === "delivered") {
|
|
478
|
+
result = {
|
|
479
|
+
delivery: cloneDelivery(current),
|
|
480
|
+
duplicate: true
|
|
481
|
+
};
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (current.state !== "stored" && current.state !== "delivered") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox delivery is not acknowledgeable");
|
|
485
|
+
if (current.ackPolicy === "delivery") {
|
|
486
|
+
result = {
|
|
487
|
+
delivery: cloneDelivery(current),
|
|
488
|
+
duplicate: true
|
|
489
|
+
};
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const next = {
|
|
493
|
+
...current,
|
|
494
|
+
state: "acknowledged",
|
|
495
|
+
acknowledgedAt: this.now(),
|
|
496
|
+
revision: current.revision + 1
|
|
497
|
+
};
|
|
498
|
+
snapshot.deliveries[index] = next;
|
|
499
|
+
await this.persist(snapshot);
|
|
500
|
+
await this.audit({
|
|
501
|
+
kind: "delivery/acknowledged",
|
|
502
|
+
teamId: this.teamId,
|
|
503
|
+
deliveryId: next.id,
|
|
504
|
+
messageId: next.messageId,
|
|
505
|
+
recipient: next.recipient,
|
|
506
|
+
actor: next.recipient,
|
|
507
|
+
at: this.now(),
|
|
508
|
+
revision: next.revision
|
|
509
|
+
});
|
|
510
|
+
result = {
|
|
511
|
+
delivery: cloneDelivery(next),
|
|
512
|
+
duplicate: false
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
if (result === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "acknowledgement produced no result");
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
async resolveFrom(actor) {
|
|
519
|
+
if (actor.type === "human" || actor.type === "scheduler") return actor.type;
|
|
520
|
+
if (this.resolveSessionSender === void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "session sender resolver is unavailable");
|
|
521
|
+
const memberId = await this.resolveSessionSender(actor.sessionId);
|
|
522
|
+
if (memberId === void 0 || memberId.length === 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "calling session is not bound to an active team member");
|
|
523
|
+
return memberId;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* A message persisted without its delivery rows is a message nobody will ever see: the snapshot is
|
|
527
|
+
* two files, and a process that dies between them leaves exactly that. The reconciler calls this,
|
|
528
|
+
* and the orphan becomes a visible failed delivery instead of silence (external review, mailbox
|
|
529
|
+
* atomicity).
|
|
530
|
+
*/
|
|
531
|
+
async healOrphanMessages() {
|
|
532
|
+
let healed = [];
|
|
533
|
+
await withProcessLock(this.lockFile, async () => {
|
|
534
|
+
const snapshot = await this.readSnapshot();
|
|
535
|
+
const withDeliveries = new Set(snapshot.deliveries.map((delivery) => delivery.messageId));
|
|
536
|
+
const orphans = snapshot.messages.filter((message) => !withDeliveries.has(message.id));
|
|
537
|
+
if (orphans.length === 0) return;
|
|
538
|
+
const rows = orphans.map((message) => ({
|
|
539
|
+
id: `delivery-${randomUUID()}`,
|
|
540
|
+
messageId: message.id,
|
|
541
|
+
recipient: message.to,
|
|
542
|
+
wakePolicy: "never",
|
|
543
|
+
ackPolicy: "delivery",
|
|
544
|
+
state: "failed",
|
|
545
|
+
failure: "the message was stored without a delivery row: the process stopped between the two writes",
|
|
546
|
+
revision: snapshot.deliveries.length + 1
|
|
547
|
+
}));
|
|
548
|
+
const next = {
|
|
549
|
+
...snapshot,
|
|
550
|
+
deliveries: [...snapshot.deliveries, ...rows]
|
|
551
|
+
};
|
|
552
|
+
await atomicWriteJson(this.deliveriesFile, next);
|
|
553
|
+
for (const row of rows) await this.audit({
|
|
554
|
+
kind: "delivery/failed",
|
|
555
|
+
teamId: this.teamId,
|
|
556
|
+
deliveryId: row.id,
|
|
557
|
+
messageId: row.messageId,
|
|
558
|
+
recipient: row.recipient,
|
|
559
|
+
reason: row.failure ?? "orphan message",
|
|
560
|
+
at: this.now(),
|
|
561
|
+
revision: row.revision
|
|
562
|
+
});
|
|
563
|
+
healed = rows.map((row) => ({
|
|
564
|
+
messageId: row.messageId,
|
|
565
|
+
recipient: row.recipient
|
|
566
|
+
}));
|
|
567
|
+
});
|
|
568
|
+
return healed;
|
|
569
|
+
}
|
|
570
|
+
async markDelivered(deliveryId) {
|
|
571
|
+
let result;
|
|
572
|
+
await withProcessLock(this.lockFile, async () => {
|
|
573
|
+
const snapshot = await this.readSnapshot();
|
|
574
|
+
const index = snapshot.deliveries.findIndex((delivery) => delivery.id === deliveryId);
|
|
575
|
+
if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "unknown mailbox delivery");
|
|
576
|
+
const current = snapshot.deliveries[index];
|
|
577
|
+
if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
|
|
578
|
+
if (current.state === "delivered" || current.state === "acknowledged") {
|
|
579
|
+
result = cloneDelivery(current);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (current.state !== "leased") throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "mailbox delivery lease is not held");
|
|
583
|
+
const next = {
|
|
584
|
+
...current,
|
|
585
|
+
state: "delivered",
|
|
586
|
+
deliveredAt: this.now(),
|
|
587
|
+
revision: current.revision + 1
|
|
588
|
+
};
|
|
589
|
+
delete next.failure;
|
|
590
|
+
snapshot.deliveries[index] = next;
|
|
591
|
+
await this.persist(snapshot);
|
|
592
|
+
await this.audit({
|
|
593
|
+
kind: "delivery/delivered",
|
|
594
|
+
teamId: this.teamId,
|
|
595
|
+
deliveryId: next.id,
|
|
596
|
+
messageId: next.messageId,
|
|
597
|
+
recipient: next.recipient,
|
|
598
|
+
at: this.now(),
|
|
599
|
+
revision: next.revision
|
|
600
|
+
});
|
|
601
|
+
result = cloneDelivery(next);
|
|
602
|
+
});
|
|
603
|
+
if (result === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "delivery transition produced no result");
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
async fail(deliveryId, reason) {
|
|
607
|
+
const failure = text(reason) ?? "delivery failed";
|
|
608
|
+
let result;
|
|
609
|
+
await withProcessLock(this.lockFile, async () => {
|
|
610
|
+
const snapshot = await this.readSnapshot();
|
|
611
|
+
const index = snapshot.deliveries.findIndex((delivery) => delivery.id === deliveryId);
|
|
612
|
+
if (index < 0) throw new LiveTeamsError(ERROR_CODES.DELIVERY_REJECTED, "unknown mailbox delivery");
|
|
613
|
+
const current = snapshot.deliveries[index];
|
|
614
|
+
if (current === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery disappeared");
|
|
615
|
+
if (current.state === "delivered" || current.state === "acknowledged") {
|
|
616
|
+
result = cloneDelivery(current);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const next = {
|
|
620
|
+
...current,
|
|
621
|
+
state: "failed",
|
|
622
|
+
failure,
|
|
623
|
+
revision: current.revision + 1
|
|
624
|
+
};
|
|
625
|
+
snapshot.deliveries[index] = next;
|
|
626
|
+
await this.persist(snapshot);
|
|
627
|
+
await this.audit({
|
|
628
|
+
kind: "delivery/failed",
|
|
629
|
+
teamId: this.teamId,
|
|
630
|
+
deliveryId: next.id,
|
|
631
|
+
messageId: next.messageId,
|
|
632
|
+
recipient: next.recipient,
|
|
633
|
+
reason: failure,
|
|
634
|
+
at: this.now(),
|
|
635
|
+
revision: next.revision
|
|
636
|
+
});
|
|
637
|
+
result = cloneDelivery(next);
|
|
638
|
+
});
|
|
639
|
+
if (result === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "failure transition produced no result");
|
|
640
|
+
return result;
|
|
641
|
+
}
|
|
642
|
+
async snapshot() {
|
|
643
|
+
return withProcessLock(this.lockFile, () => this.readSnapshot());
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Comments this member has not acknowledged, read synchronously.
|
|
647
|
+
*
|
|
648
|
+
* The membership context is assembled synchronously, and a comment is stored with
|
|
649
|
+
* `wakePolicy: never` — nothing wakes the Session for it. Without a sync reader the recipient only
|
|
650
|
+
* ever learns about a comment if it happens to ask (external review, finding 13).
|
|
651
|
+
*/
|
|
652
|
+
unreadCommentsForSync(memberId) {
|
|
653
|
+
try {
|
|
654
|
+
if (!existsSync(this.messagesFile) || !existsSync(this.deliveriesFile)) return [];
|
|
655
|
+
const messages = JSON.parse(readFileSync(this.messagesFile, "utf8"));
|
|
656
|
+
const deliveries = JSON.parse(readFileSync(this.deliveriesFile, "utf8"));
|
|
657
|
+
const unread = new Set((deliveries.deliveries ?? []).filter((delivery) => delivery.recipient === memberId && delivery.state !== "acknowledged" && delivery.state !== "failed").map((delivery) => delivery.messageId));
|
|
658
|
+
return (messages.messages ?? []).filter((message) => message.kind === "comment" && message.to === memberId && unread.has(message.id)).sort((left, right) => left.createdAt - right.createdAt).slice(-3).map((message) => ({
|
|
659
|
+
from: message.from,
|
|
660
|
+
content: message.content,
|
|
661
|
+
at: message.createdAt
|
|
662
|
+
}));
|
|
663
|
+
} catch {
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async readSnapshot() {
|
|
668
|
+
if (await fileExists(this.messagesFile) !== await fileExists(this.deliveriesFile)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox state is torn: only one of the two files exists");
|
|
669
|
+
const messages = await this.readMessages();
|
|
670
|
+
const deliveries = await this.readDeliveries();
|
|
671
|
+
const ids = new Set(messages.map((message) => message.id));
|
|
672
|
+
if (ids.size !== messages.length) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox contains duplicate message ids");
|
|
673
|
+
if (new Set(deliveries.map((delivery) => delivery.id)).size !== deliveries.length) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox contains duplicate delivery ids");
|
|
674
|
+
if (new Set(deliveries.map((delivery) => `${delivery.messageId}:${delivery.recipient}`)).size !== deliveries.length) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox repeats a message for one recipient");
|
|
675
|
+
if (deliveries.some((delivery) => !ids.has(delivery.messageId))) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox delivery references a missing message");
|
|
676
|
+
return {
|
|
677
|
+
messages,
|
|
678
|
+
deliveries
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
async readMessages() {
|
|
682
|
+
if (!await fileExists(this.messagesFile)) return [];
|
|
683
|
+
let parsed;
|
|
684
|
+
try {
|
|
685
|
+
parsed = await readJson(this.messagesFile);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox messages state is not readable JSON", { cause: error });
|
|
688
|
+
}
|
|
689
|
+
return checkedArray(parsed, "messages", this.teamId);
|
|
690
|
+
}
|
|
691
|
+
async readDeliveries() {
|
|
692
|
+
if (!await fileExists(this.deliveriesFile)) return [];
|
|
693
|
+
let parsed;
|
|
694
|
+
try {
|
|
695
|
+
parsed = await readJson(this.deliveriesFile);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "mailbox deliveries state is not readable JSON", { cause: error });
|
|
698
|
+
}
|
|
699
|
+
return checkedArray(parsed, "deliveries", this.teamId);
|
|
700
|
+
}
|
|
701
|
+
async persist(snapshot) {
|
|
702
|
+
await atomicWriteJson(this.messagesFile, {
|
|
703
|
+
schemaVersion: SCHEMA_VERSION,
|
|
704
|
+
teamId: this.teamId,
|
|
705
|
+
messages: snapshot.messages
|
|
706
|
+
});
|
|
707
|
+
await atomicWriteJson(this.deliveriesFile, {
|
|
708
|
+
schemaVersion: SCHEMA_VERSION,
|
|
709
|
+
teamId: this.teamId,
|
|
710
|
+
deliveries: snapshot.deliveries
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
async audit(event) {
|
|
714
|
+
if (this.appendAudit !== void 0) await this.appendAudit(event);
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
function createMailbox(options) {
|
|
718
|
+
return new MailboxService(options);
|
|
719
|
+
}
|
|
720
|
+
//#endregion
|
|
721
|
+
export { MailboxService, NO_SESSION_FAILURE, createMailbox, messageDeliveryOutcome, messagePolicy, resolveMailboxRecipient };
|