@fungi.computer/carrier 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +457 -2
- package/dist/cloudflare.d.ts +32 -0
- package/dist/cloudflare.js +80 -0
- package/dist/email.d.ts +2 -0
- package/dist/email.js +2 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +28 -0
- package/dist/inspection.d.ts +108 -0
- package/dist/inspection.js +188 -0
- package/dist/internal/cloudflare-send.d.ts +9 -0
- package/dist/internal/cloudflare-send.js +79 -0
- package/dist/internal/contracts.d.ts +94 -0
- package/dist/internal/contracts.js +7 -0
- package/dist/internal/owner.d.ts +24 -0
- package/dist/internal/owner.js +419 -0
- package/dist/internal/policy.d.ts +25 -0
- package/dist/internal/policy.js +18 -0
- package/dist/internal/post-contract.d.ts +27 -0
- package/dist/internal/post-contract.js +22 -0
- package/dist/internal/post-execution.d.ts +45 -0
- package/dist/internal/post-execution.js +73 -0
- package/dist/internal/post-sql.d.ts +51 -0
- package/dist/internal/post-sql.js +255 -0
- package/dist/internal/receipt-schema.d.ts +3 -0
- package/dist/internal/receipt-schema.js +12 -0
- package/dist/post-d1.d.ts +36 -0
- package/dist/post-d1.js +55 -0
- package/dist/post-outbox.d.ts +40 -0
- package/dist/post-outbox.js +99 -0
- package/dist/post-postgres.d.ts +25 -0
- package/dist/post-postgres.js +79 -0
- package/dist/post.d.ts +189 -0
- package/dist/post.js +436 -0
- package/dist/testing.d.ts +16 -0
- package/dist/testing.js +20 -0
- package/docs/api/README.md +20 -0
- package/docs/api/cloudflare.md +100 -0
- package/docs/api/email.md +87 -0
- package/docs/api/index.md +343 -0
- package/docs/api/inspection.md +281 -0
- package/docs/api/post-d1.md +184 -0
- package/docs/api/post-postgres.md +108 -0
- package/docs/api/post.md +620 -0
- package/docs/api/testing.md +46 -0
- package/package.json +94 -3
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { defineIdFamily } from "@fungi.computer/cairn";
|
|
2
|
+
import { makeRuntime, } from "@fungi.computer/watchdog/effect";
|
|
3
|
+
import { WatchdogError } from "@fungi.computer/watchdog";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Predicate from "effect/Predicate";
|
|
6
|
+
import * as Schema from "effect/Schema";
|
|
7
|
+
import * as Struct from "effect/Struct";
|
|
8
|
+
import { CarrierError, } from "./contracts.js";
|
|
9
|
+
import { carrierPolicy } from "./policy.js";
|
|
10
|
+
import { CarrierStatusSchema } from "./receipt-schema.js";
|
|
11
|
+
const ids = defineIdFamily({
|
|
12
|
+
family: "carrier",
|
|
13
|
+
version: 1,
|
|
14
|
+
kinds: {
|
|
15
|
+
message: { prefix: "carrier_message" },
|
|
16
|
+
work: { prefix: "carrier_work" },
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const Address = Schema.Union([
|
|
20
|
+
Schema.NonEmptyString,
|
|
21
|
+
Schema.Struct({ email: Schema.NonEmptyString, name: Schema.String }),
|
|
22
|
+
]);
|
|
23
|
+
const Submission = Schema.Struct({
|
|
24
|
+
id: Schema.NonEmptyString,
|
|
25
|
+
sendBefore: Schema.optionalKey(Schema.Number.check(Schema.isFinite())),
|
|
26
|
+
from: Address,
|
|
27
|
+
to: Schema.optionalKey(Schema.Array(Address)),
|
|
28
|
+
cc: Schema.optionalKey(Schema.Array(Address)),
|
|
29
|
+
bcc: Schema.optionalKey(Schema.Array(Address)),
|
|
30
|
+
subject: Schema.String,
|
|
31
|
+
html: Schema.optionalKey(Schema.String),
|
|
32
|
+
text: Schema.optionalKey(Schema.String),
|
|
33
|
+
headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
34
|
+
});
|
|
35
|
+
const decodeSubmission = Schema.decodeUnknownSync(Submission, {
|
|
36
|
+
onExcessProperty: "error",
|
|
37
|
+
});
|
|
38
|
+
const decodeStoredSubmission = Schema.decodeUnknownSync(Schema.fromJsonString(Submission));
|
|
39
|
+
const RejectionClass = Schema.Literals(["permanent", "temporary"]);
|
|
40
|
+
const Row = Schema.Struct({
|
|
41
|
+
id: Schema.String,
|
|
42
|
+
content: Schema.NullOr(Schema.String),
|
|
43
|
+
fingerprint: Schema.String,
|
|
44
|
+
status: CarrierStatusSchema,
|
|
45
|
+
job_id: Schema.String,
|
|
46
|
+
provider_id: Schema.NullOr(Schema.String),
|
|
47
|
+
attempts: Schema.Number,
|
|
48
|
+
retry_at: Schema.NullOr(Schema.Number),
|
|
49
|
+
error_code: Schema.NullOr(Schema.String),
|
|
50
|
+
error_class: Schema.NullOr(RejectionClass),
|
|
51
|
+
});
|
|
52
|
+
const rowColumns = "id,content,fingerprint,status,job_id,provider_id,attempts,retry_at,error_code,error_class";
|
|
53
|
+
const decodeRow = Schema.decodeUnknownSync(Row);
|
|
54
|
+
const decodeRepairRow = Schema.decodeUnknownSync(Row.mapFields(Struct.pick(["id", "job_id", "status", "retry_at"])));
|
|
55
|
+
const decodeCount = Schema.decodeUnknownSync(Schema.Struct({ count: Schema.Number }));
|
|
56
|
+
const MessageRow = Schema.Struct({
|
|
57
|
+
id: Schema.String,
|
|
58
|
+
recipient: Schema.String,
|
|
59
|
+
roles: Schema.fromJsonString(Schema.Array(Schema.Literals(["to", "cc", "bcc"]))),
|
|
60
|
+
});
|
|
61
|
+
const decodeMessage = Schema.decodeUnknownSync(MessageRow);
|
|
62
|
+
const Evidence = Schema.Union([
|
|
63
|
+
Schema.Struct({
|
|
64
|
+
kind: Schema.Literal("provider_accepted"),
|
|
65
|
+
providerMessageId: Schema.NonEmptyString,
|
|
66
|
+
}),
|
|
67
|
+
Schema.Struct({
|
|
68
|
+
kind: Schema.Literal("provider_rejected"),
|
|
69
|
+
code: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
|
|
70
|
+
class: RejectionClass,
|
|
71
|
+
}),
|
|
72
|
+
Schema.Struct({
|
|
73
|
+
kind: Schema.Literal("outcome_unknown"),
|
|
74
|
+
cause: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(64))),
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
const decodeEvidence = Schema.decodeUnknownSync(Evidence);
|
|
78
|
+
/** Recipient identity for the per-recipient cap; the envelope keeps its spelling. */
|
|
79
|
+
const recipientKey = (recipient) => recipient.trim().toLowerCase();
|
|
80
|
+
/** One structured line per failed or refused delivery; never content or addresses. */
|
|
81
|
+
const logFailure = (fields) => {
|
|
82
|
+
console.error("Carrier delivery failed", fields);
|
|
83
|
+
};
|
|
84
|
+
const unavailable = () => new CarrierError("unavailable");
|
|
85
|
+
const sync = (operation) => Effect.try({ try: operation, catch: unavailable });
|
|
86
|
+
const promise = (operation) => Effect.tryPromise({ try: operation, catch: unavailable });
|
|
87
|
+
/** One private lifecycle; the public Promise entry only runs these effects. */
|
|
88
|
+
export function openOwner(options) {
|
|
89
|
+
const sql = options.storage.sql;
|
|
90
|
+
let watchdog;
|
|
91
|
+
let ticking = false;
|
|
92
|
+
let repairCursor = "";
|
|
93
|
+
const readRow = (id) => {
|
|
94
|
+
const row = sql
|
|
95
|
+
.exec(`SELECT ${rowColumns} FROM carrier_emails WHERE id=?`, id)
|
|
96
|
+
.toArray()[0];
|
|
97
|
+
return row === undefined ? undefined : decodeRow(row);
|
|
98
|
+
};
|
|
99
|
+
const receipt = (id) => {
|
|
100
|
+
const row = readRow(id);
|
|
101
|
+
if (row === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
const messages = sql
|
|
104
|
+
.exec("SELECT id,recipient,roles FROM carrier_messages WHERE email_id=? ORDER BY position", id)
|
|
105
|
+
.toArray()
|
|
106
|
+
.map((raw) => ({
|
|
107
|
+
...decodeMessage(raw),
|
|
108
|
+
status: "unconfirmed",
|
|
109
|
+
}));
|
|
110
|
+
const result = { id, status: row.status, messages };
|
|
111
|
+
if (row.provider_id !== null)
|
|
112
|
+
return { ...result, providerMessageId: row.provider_id };
|
|
113
|
+
if (row.error_code !== null && row.error_class !== null)
|
|
114
|
+
return {
|
|
115
|
+
...result,
|
|
116
|
+
providerRejection: { code: row.error_code, class: row.error_class },
|
|
117
|
+
};
|
|
118
|
+
return result;
|
|
119
|
+
};
|
|
120
|
+
const enqueue = (id, jobId) => {
|
|
121
|
+
watchdog.transactional.enqueueAcceptedJob({
|
|
122
|
+
jobId,
|
|
123
|
+
queue: "carrier",
|
|
124
|
+
lane: "email",
|
|
125
|
+
priority: 0,
|
|
126
|
+
payload: { emailId: id },
|
|
127
|
+
state: "queued",
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
const finish = (id, status, providerId = null) => {
|
|
131
|
+
sql.exec("UPDATE carrier_emails SET status=?,provider_id=?,content=NULL,retry_at=NULL WHERE id=?", status, providerId, id);
|
|
132
|
+
};
|
|
133
|
+
const retryDelay = (attempt) => Math.min(carrierPolicy.retry.baseDelayMs * 2 ** (attempt - 1), carrierPolicy.retry.maxDelayMs);
|
|
134
|
+
const execute = (job) => Effect.gen(function* () {
|
|
135
|
+
const row = yield* sync(() => {
|
|
136
|
+
const raw = sql
|
|
137
|
+
.exec(`SELECT ${rowColumns} FROM carrier_emails WHERE job_id=?`, job.jobId)
|
|
138
|
+
.toArray()[0];
|
|
139
|
+
if (raw === undefined)
|
|
140
|
+
throw unavailable();
|
|
141
|
+
return decodeRow(raw);
|
|
142
|
+
});
|
|
143
|
+
if (row.status !== "queued")
|
|
144
|
+
return { kind: "completed" };
|
|
145
|
+
const content = row.content;
|
|
146
|
+
if (content === null)
|
|
147
|
+
return yield* Effect.fail(unavailable());
|
|
148
|
+
const submission = yield* sync(() => decodeStoredSubmission(content));
|
|
149
|
+
if (submission.sendBefore !== undefined &&
|
|
150
|
+
submission.sendBefore <= options.now()) {
|
|
151
|
+
yield* sync(() => {
|
|
152
|
+
options.storage.transactionSync(() => {
|
|
153
|
+
finish(row.id, "expired");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
return { kind: "completed" };
|
|
157
|
+
}
|
|
158
|
+
const canSend = options.canSend;
|
|
159
|
+
const current = canSend === undefined ? true : yield* promise(() => canSend(row.id));
|
|
160
|
+
const eligible = yield* sync(() => options.storage.transactionSync(() => {
|
|
161
|
+
// A shared owner may erase a queued Email while canSend awaits its
|
|
162
|
+
// source. The earlier snapshot cannot authorize a later dispatch.
|
|
163
|
+
if (readRow(row.id)?.status !== "queued")
|
|
164
|
+
return false;
|
|
165
|
+
if (submission.sendBefore !== undefined &&
|
|
166
|
+
submission.sendBefore <= options.now()) {
|
|
167
|
+
finish(row.id, "expired");
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (!current) {
|
|
171
|
+
finish(row.id, "superseded");
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
sql.exec("UPDATE carrier_emails SET status='dispatching' WHERE id=? AND status='queued'", row.id);
|
|
175
|
+
return true;
|
|
176
|
+
}));
|
|
177
|
+
if (!eligible)
|
|
178
|
+
return { kind: "completed" };
|
|
179
|
+
// Dispatch intent is committed before the one provider invocation.
|
|
180
|
+
// Any interruption after the marker is ambiguous and cannot authorize a resend.
|
|
181
|
+
const email = Struct.omit(submission, ["id", "sendBefore"]);
|
|
182
|
+
const evidence = yield* promise(async () => {
|
|
183
|
+
try {
|
|
184
|
+
return decodeEvidence(await options.transport(email));
|
|
185
|
+
}
|
|
186
|
+
catch (cause) {
|
|
187
|
+
// Exception messages may contain email content; retain only the
|
|
188
|
+
// transport failure class as safe evidence for the outcome log.
|
|
189
|
+
return {
|
|
190
|
+
kind: "outcome_unknown",
|
|
191
|
+
cause: cause instanceof Error
|
|
192
|
+
? "transport_exception"
|
|
193
|
+
: "non_error_thrown",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
if (evidence.kind === "provider_accepted") {
|
|
198
|
+
yield* sync(() => {
|
|
199
|
+
options.storage.transactionSync(() => {
|
|
200
|
+
finish(row.id, "provider_accepted", evidence.providerMessageId);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
return { kind: "completed" };
|
|
204
|
+
}
|
|
205
|
+
if (evidence.kind === "outcome_unknown") {
|
|
206
|
+
yield* sync(() => {
|
|
207
|
+
options.storage.transactionSync(() => {
|
|
208
|
+
finish(row.id, "outcome_unknown");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
logFailure({
|
|
212
|
+
id: row.id,
|
|
213
|
+
class: "unknown",
|
|
214
|
+
cause: evidence.cause ?? "no_provider_answer",
|
|
215
|
+
status: "outcome_unknown",
|
|
216
|
+
});
|
|
217
|
+
return { kind: "outcome_unknown" };
|
|
218
|
+
}
|
|
219
|
+
// A definite provider refusal: nothing was accepted, so a temporary one
|
|
220
|
+
// may be dispatched again after backoff under this same owner.
|
|
221
|
+
const attempt = row.attempts + 1;
|
|
222
|
+
const retry = evidence.class === "temporary" &&
|
|
223
|
+
attempt < carrierPolicy.retry.maxAttempts;
|
|
224
|
+
yield* sync(() => {
|
|
225
|
+
options.storage.transactionSync(() => {
|
|
226
|
+
sql.exec("UPDATE carrier_emails SET attempts=?,error_code=?,error_class=? WHERE id=?", attempt, evidence.code, evidence.class, row.id);
|
|
227
|
+
if (retry)
|
|
228
|
+
sql.exec("UPDATE carrier_emails SET status='queued',retry_at=? WHERE id=?", options.now() + retryDelay(attempt), row.id);
|
|
229
|
+
else
|
|
230
|
+
finish(row.id, "rejected");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
logFailure({
|
|
234
|
+
id: row.id,
|
|
235
|
+
class: evidence.class,
|
|
236
|
+
cause: evidence.code,
|
|
237
|
+
status: retry ? "queued" : "rejected",
|
|
238
|
+
attempt,
|
|
239
|
+
});
|
|
240
|
+
// Settling this job lets repair enqueue fresh work once retry_at passes.
|
|
241
|
+
return { kind: retry ? "failed" : "completed" };
|
|
242
|
+
}).pipe(Effect.mapError(() => new WatchdogError("execution_failed")));
|
|
243
|
+
const initialize = Effect.gen(function* () {
|
|
244
|
+
yield* sync(() => {
|
|
245
|
+
options.storage.transactionSync(() => {
|
|
246
|
+
sql.exec("CREATE TABLE IF NOT EXISTS carrier_emails (id TEXT PRIMARY KEY,content TEXT,fingerprint TEXT NOT NULL,status TEXT NOT NULL,job_id TEXT NOT NULL UNIQUE,provider_id TEXT,accepted_at INTEGER NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,retry_at INTEGER,error_code TEXT,error_class TEXT) STRICT");
|
|
247
|
+
sql.exec("CREATE TABLE IF NOT EXISTS carrier_messages (id TEXT PRIMARY KEY,email_id TEXT NOT NULL REFERENCES carrier_emails(id),position INTEGER NOT NULL,recipient TEXT NOT NULL,recipient_key TEXT NOT NULL,roles TEXT NOT NULL,UNIQUE(email_id,recipient),UNIQUE(email_id,position)) STRICT");
|
|
248
|
+
sql.exec("CREATE INDEX IF NOT EXISTS carrier_messages_recipient_key ON carrier_messages(recipient_key)");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
watchdog = yield* makeRuntime({
|
|
252
|
+
// One Carrier owns this store for its runtime lifetime. Its tick guard
|
|
253
|
+
// excludes overlapping reconciliation; running claims observed by a new
|
|
254
|
+
// runtime therefore belong to the terminated previous lifetime.
|
|
255
|
+
owner: options.storage,
|
|
256
|
+
isAlive: () => Effect.succeed(false),
|
|
257
|
+
executor: { execute },
|
|
258
|
+
wake: {
|
|
259
|
+
recompute: () => Effect.tryPromise({
|
|
260
|
+
try: options.host.requestWake,
|
|
261
|
+
catch: () => new WatchdogError("wake_unavailable"),
|
|
262
|
+
}),
|
|
263
|
+
},
|
|
264
|
+
}).pipe(Effect.mapError(unavailable));
|
|
265
|
+
});
|
|
266
|
+
const send = (input) => Effect.gen(function* () {
|
|
267
|
+
const submission = yield* Effect.try({
|
|
268
|
+
try: () => {
|
|
269
|
+
const parsed = decodeSubmission(input);
|
|
270
|
+
if (parsed.html === undefined && parsed.text === undefined)
|
|
271
|
+
throw new CarrierError("invalid_input");
|
|
272
|
+
if ((parsed.to?.length ?? 0) +
|
|
273
|
+
(parsed.cc?.length ?? 0) +
|
|
274
|
+
(parsed.bcc?.length ?? 0) ===
|
|
275
|
+
0)
|
|
276
|
+
throw new CarrierError("invalid_input");
|
|
277
|
+
return parsed;
|
|
278
|
+
},
|
|
279
|
+
catch: () => new CarrierError("invalid_input"),
|
|
280
|
+
});
|
|
281
|
+
const address = (value) => Predicate.isString(value)
|
|
282
|
+
? value
|
|
283
|
+
: { email: value.email, name: value.name };
|
|
284
|
+
// Stable serialization preserves address spelling and recipient ordering.
|
|
285
|
+
const content = JSON.stringify({
|
|
286
|
+
id: submission.id,
|
|
287
|
+
sendBefore: submission.sendBefore,
|
|
288
|
+
from: address(submission.from),
|
|
289
|
+
to: submission.to?.map(address),
|
|
290
|
+
cc: submission.cc?.map(address),
|
|
291
|
+
bcc: submission.bcc?.map(address),
|
|
292
|
+
subject: submission.subject,
|
|
293
|
+
html: submission.html,
|
|
294
|
+
text: submission.text,
|
|
295
|
+
headers: submission.headers,
|
|
296
|
+
});
|
|
297
|
+
const digest = yield* promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(content)));
|
|
298
|
+
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
299
|
+
const { accepted, refused } = yield* Effect.tryPromise({
|
|
300
|
+
try: () => options.host.commit(() => options.storage.transactionSync(() => {
|
|
301
|
+
const prior = readRow(submission.id);
|
|
302
|
+
let refused = false;
|
|
303
|
+
if (prior !== undefined) {
|
|
304
|
+
if (prior.fingerprint !== fingerprint)
|
|
305
|
+
throw new CarrierError("conflict");
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
const recipients = new Map();
|
|
309
|
+
for (const role of ["to", "cc", "bcc"]) {
|
|
310
|
+
for (const address of submission[role] ?? []) {
|
|
311
|
+
const recipient = Predicate.isString(address)
|
|
312
|
+
? address
|
|
313
|
+
: address.email;
|
|
314
|
+
const roles = recipients.get(recipient) ?? [];
|
|
315
|
+
if (!roles.includes(role))
|
|
316
|
+
roles.push(role);
|
|
317
|
+
recipients.set(recipient, roles);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const acceptedAt = options.now();
|
|
321
|
+
const cap = carrierPolicy.recipientCap;
|
|
322
|
+
// Emails that never reached the provider do not use the cap.
|
|
323
|
+
const capped = [...recipients.keys()].some((recipient) => decodeCount(sql
|
|
324
|
+
.exec("SELECT COUNT(DISTINCT e.id) AS count FROM carrier_messages m JOIN carrier_emails e ON e.id=m.email_id WHERE m.recipient_key=? AND e.accepted_at>? AND e.status IN ('queued','dispatching','provider_accepted','outcome_unknown')", recipientKey(recipient), acceptedAt - cap.windowMs)
|
|
325
|
+
.toArray()[0]).count >= cap.messages);
|
|
326
|
+
// A refusal is durable: replaying this ID cannot bypass the cap.
|
|
327
|
+
// Its unused job identity only satisfies the row's key.
|
|
328
|
+
const jobId = ids.work.generate();
|
|
329
|
+
sql.exec("INSERT INTO carrier_emails (id,content,fingerprint,status,job_id,accepted_at) VALUES (?,?,?,?,?,?)", submission.id, capped ? null : content, fingerprint, capped ? "refused" : "queued", jobId, acceptedAt);
|
|
330
|
+
let position = 0;
|
|
331
|
+
for (const [recipient, roles] of recipients) {
|
|
332
|
+
sql.exec("INSERT INTO carrier_messages (id,email_id,position,recipient,recipient_key,roles) VALUES (?,?,?,?,?,?)", ids.message.generate(), submission.id, position++, recipient, recipientKey(recipient), JSON.stringify(roles));
|
|
333
|
+
}
|
|
334
|
+
if (capped)
|
|
335
|
+
refused = true;
|
|
336
|
+
else
|
|
337
|
+
enqueue(submission.id, jobId);
|
|
338
|
+
}
|
|
339
|
+
const accepted = receipt(submission.id);
|
|
340
|
+
if (accepted === undefined)
|
|
341
|
+
throw unavailable();
|
|
342
|
+
// Only the first refusal logs; a replay returns the same receipt.
|
|
343
|
+
return { accepted, refused };
|
|
344
|
+
})),
|
|
345
|
+
catch: (error) => error instanceof CarrierError ? error : unavailable(),
|
|
346
|
+
});
|
|
347
|
+
if (refused)
|
|
348
|
+
logFailure({
|
|
349
|
+
id: submission.id,
|
|
350
|
+
class: "recipient_cap",
|
|
351
|
+
cause: "recipient_cap_reached",
|
|
352
|
+
status: "refused",
|
|
353
|
+
});
|
|
354
|
+
return accepted;
|
|
355
|
+
});
|
|
356
|
+
const repair = Effect.gen(function* () {
|
|
357
|
+
const rows = yield* sync(() => sql
|
|
358
|
+
.exec("SELECT id,job_id,status,retry_at FROM carrier_emails WHERE status IN ('queued','dispatching') AND id>? ORDER BY id LIMIT 64", repairCursor)
|
|
359
|
+
.toArray()
|
|
360
|
+
.map((row) => decodeRepairRow(row)));
|
|
361
|
+
for (const row of rows) {
|
|
362
|
+
const work = yield* watchdog
|
|
363
|
+
.read(row.job_id)
|
|
364
|
+
.pipe(Effect.mapError(unavailable));
|
|
365
|
+
if (work.status !== "found")
|
|
366
|
+
return yield* Effect.fail(unavailable());
|
|
367
|
+
if (work.job.state !== "settled")
|
|
368
|
+
continue;
|
|
369
|
+
// A temporary provider rejection waits for its backoff on the same wake.
|
|
370
|
+
if (row.retry_at !== null && row.retry_at > options.now())
|
|
371
|
+
continue;
|
|
372
|
+
const interrupted = yield* promise(() => options.host.commit(() => options.storage.transactionSync(() => {
|
|
373
|
+
const current = readRow(row.id);
|
|
374
|
+
if (current === undefined)
|
|
375
|
+
return false;
|
|
376
|
+
if (current.job_id !== row.job_id || current.status !== row.status)
|
|
377
|
+
return false;
|
|
378
|
+
if (row.status === "dispatching") {
|
|
379
|
+
finish(row.id, "outcome_unknown");
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
const jobId = ids.work.generate();
|
|
383
|
+
sql.exec("UPDATE carrier_emails SET job_id=? WHERE id=? AND job_id=? AND status='queued'", jobId, row.id, row.job_id);
|
|
384
|
+
enqueue(row.id, jobId);
|
|
385
|
+
return false;
|
|
386
|
+
})));
|
|
387
|
+
if (interrupted)
|
|
388
|
+
logFailure({
|
|
389
|
+
id: row.id,
|
|
390
|
+
class: "unknown",
|
|
391
|
+
cause: "interrupted_dispatch",
|
|
392
|
+
status: "outcome_unknown",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const last = rows.at(-1);
|
|
396
|
+
repairCursor = rows.length === 64 && last !== undefined ? last.id : "";
|
|
397
|
+
});
|
|
398
|
+
const tick = Effect.suspend(() => {
|
|
399
|
+
if (ticking)
|
|
400
|
+
return Effect.void;
|
|
401
|
+
ticking = true;
|
|
402
|
+
return watchdog.tick().pipe(Effect.mapError(unavailable), Effect.andThen(repair), Effect.asVoid, Effect.ensuring(Effect.sync(() => {
|
|
403
|
+
ticking = false;
|
|
404
|
+
})));
|
|
405
|
+
});
|
|
406
|
+
return {
|
|
407
|
+
initialize,
|
|
408
|
+
send,
|
|
409
|
+
tick,
|
|
410
|
+
get: (id) => sync(() => receipt(id)),
|
|
411
|
+
hasPendingWork: Effect.gen(function* () {
|
|
412
|
+
const domainPending = yield* sync(() => sql
|
|
413
|
+
.exec("SELECT 1 FROM carrier_emails WHERE status IN ('queued','dispatching') LIMIT 1")
|
|
414
|
+
.toArray().length !== 0);
|
|
415
|
+
return (domainPending ||
|
|
416
|
+
(yield* watchdog.hasPendingWork.pipe(Effect.mapError(unavailable))));
|
|
417
|
+
}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Carrier's tunable delivery numbers. Every value here is policy, not provider law. */
|
|
2
|
+
/**
|
|
3
|
+
* Launch defaults; decision pending. See
|
|
4
|
+
* wiki/3-resources/decisions/adr-fungi-launch-live-email.md.
|
|
5
|
+
*
|
|
6
|
+
* `recipientCap`: at most `messages` accepted Emails per normalized recipient
|
|
7
|
+
* address within a rolling `windowMs`, counted per Carrier store (one Durable
|
|
8
|
+
* Object). Emails that never reached the provider (refused, rejected, expired,
|
|
9
|
+
* superseded) do not count.
|
|
10
|
+
*
|
|
11
|
+
* `retry`: a temporary provider rejection is dispatched again after
|
|
12
|
+
* `baseDelayMs * 2^(attempt-1)`, capped at `maxDelayMs`, until `maxAttempts`
|
|
13
|
+
* provider invocations have been rejected; then the Email is `rejected`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const carrierPolicy: {
|
|
16
|
+
readonly recipientCap: {
|
|
17
|
+
readonly messages: 5;
|
|
18
|
+
readonly windowMs: number;
|
|
19
|
+
};
|
|
20
|
+
readonly retry: {
|
|
21
|
+
readonly maxAttempts: 6;
|
|
22
|
+
readonly baseDelayMs: 30000;
|
|
23
|
+
readonly maxDelayMs: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Carrier's tunable delivery numbers. Every value here is policy, not provider law. */
|
|
2
|
+
/**
|
|
3
|
+
* Launch defaults; decision pending. See
|
|
4
|
+
* wiki/3-resources/decisions/adr-fungi-launch-live-email.md.
|
|
5
|
+
*
|
|
6
|
+
* `recipientCap`: at most `messages` accepted Emails per normalized recipient
|
|
7
|
+
* address within a rolling `windowMs`, counted per Carrier store (one Durable
|
|
8
|
+
* Object). Emails that never reached the provider (refused, rejected, expired,
|
|
9
|
+
* superseded) do not count.
|
|
10
|
+
*
|
|
11
|
+
* `retry`: a temporary provider rejection is dispatched again after
|
|
12
|
+
* `baseDelayMs * 2^(attempt-1)`, capped at `maxDelayMs`, until `maxAttempts`
|
|
13
|
+
* provider invocations have been rejected; then the Email is `rejected`.
|
|
14
|
+
*/
|
|
15
|
+
export const carrierPolicy = {
|
|
16
|
+
recipientCap: { messages: 5, windowMs: 60 * 60 * 1000 },
|
|
17
|
+
retry: { maxAttempts: 6, baseDelayMs: 30_000, maxDelayMs: 15 * 60 * 1000 },
|
|
18
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
/** Receiver's idempotent admission decision. */
|
|
3
|
+
export type PostDeliveryResult = "accepted" | "duplicate" | "refused" | Readonly<{
|
|
4
|
+
status: "accepted" | "duplicate";
|
|
5
|
+
cursor: number;
|
|
6
|
+
}> | Readonly<{
|
|
7
|
+
status: "accepted" | "duplicate" | "refused";
|
|
8
|
+
receipt: Schema.Json;
|
|
9
|
+
}>;
|
|
10
|
+
/** Accepted receipt, revision cursor, or permanent refusal returned by a receiver. */
|
|
11
|
+
export declare const PostDeliveryResultSchema: Schema.Union<readonly [Schema.Literals<readonly ["accepted", "duplicate", "refused"]>, Schema.Struct<{
|
|
12
|
+
readonly status: Schema.Literals<readonly ["accepted", "duplicate"]>;
|
|
13
|
+
readonly cursor: Schema.Int;
|
|
14
|
+
}>, Schema.Struct<{
|
|
15
|
+
readonly status: Schema.Literals<readonly ["accepted", "duplicate", "refused"]>;
|
|
16
|
+
readonly receipt: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
17
|
+
}>]>;
|
|
18
|
+
declare const PostError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
19
|
+
readonly _tag: "PostError";
|
|
20
|
+
} & Readonly<A>;
|
|
21
|
+
/** Post rejected an unknown route or conflicting key. */
|
|
22
|
+
export declare class PostError extends PostError_base<{
|
|
23
|
+
readonly message: string;
|
|
24
|
+
}> {
|
|
25
|
+
constructor(message: string);
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import * as Data from "effect/Data";
|
|
3
|
+
/** Decode the receiver's RPC response before settling delivery. */
|
|
4
|
+
const CursorResultSchema = Schema.Struct({
|
|
5
|
+
status: Schema.Literals(["accepted", "duplicate"]),
|
|
6
|
+
cursor: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
7
|
+
});
|
|
8
|
+
/** Accepted receipt, revision cursor, or permanent refusal returned by a receiver. */
|
|
9
|
+
export const PostDeliveryResultSchema = Schema.Union([
|
|
10
|
+
Schema.Literals(["accepted", "duplicate", "refused"]),
|
|
11
|
+
CursorResultSchema,
|
|
12
|
+
Schema.Struct({
|
|
13
|
+
status: Schema.Literals(["accepted", "duplicate", "refused"]),
|
|
14
|
+
receipt: Schema.Json,
|
|
15
|
+
}),
|
|
16
|
+
]);
|
|
17
|
+
/** Post rejected an unknown route or conflicting key. */
|
|
18
|
+
export class PostError extends Data.TaggedError("PostError") {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super({ message });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import type { PostEnvelope, PostRegistryRoute, PostRow } from "../post.js";
|
|
3
|
+
import { type PostDeliveryResult } from "./post-contract.js";
|
|
4
|
+
export type DeliveryEvidence = Readonly<{
|
|
5
|
+
result: PostDeliveryResult | "unavailable";
|
|
6
|
+
envelope: PostEnvelope | undefined;
|
|
7
|
+
failure?: unknown;
|
|
8
|
+
}>;
|
|
9
|
+
/** Shared preparation, bounded receiver I/O and response decoding for every Post backend. */
|
|
10
|
+
export declare function deliverPost(input: Readonly<{
|
|
11
|
+
attempt: Extract<PostRow, {
|
|
12
|
+
state: "pending";
|
|
13
|
+
}>;
|
|
14
|
+
signal?: AbortSignal | undefined;
|
|
15
|
+
route: PostRegistryRoute;
|
|
16
|
+
savePrepared: (envelope: PostEnvelope) => void | Promise<void>;
|
|
17
|
+
}>): Promise<DeliveryEvidence>;
|
|
18
|
+
/** One settlement decision, shared by native storage adapters. */
|
|
19
|
+
export declare function postDisposition(evidence: DeliveryEvidence, route: PostRegistryRoute, attempts: number, maxAttempts: number): {
|
|
20
|
+
readonly state: "dead";
|
|
21
|
+
readonly reason: "exhausted";
|
|
22
|
+
readonly cursor?: never;
|
|
23
|
+
} | {
|
|
24
|
+
readonly state: "pending";
|
|
25
|
+
readonly reason?: never;
|
|
26
|
+
readonly cursor?: never;
|
|
27
|
+
} | {
|
|
28
|
+
readonly state: "dead";
|
|
29
|
+
readonly reason: "refused";
|
|
30
|
+
readonly cursor?: never;
|
|
31
|
+
} | {
|
|
32
|
+
readonly state: "keep";
|
|
33
|
+
readonly reason?: never;
|
|
34
|
+
readonly cursor?: never;
|
|
35
|
+
} | {
|
|
36
|
+
readonly state: "delivered";
|
|
37
|
+
readonly reason?: never;
|
|
38
|
+
readonly cursor?: never;
|
|
39
|
+
} | {
|
|
40
|
+
readonly state: "delivered";
|
|
41
|
+
readonly cursor: number;
|
|
42
|
+
readonly reason?: never;
|
|
43
|
+
};
|
|
44
|
+
/** Durable provider/domain receipt data, separate from the staged event. */
|
|
45
|
+
export declare function deliveryReceipt(evidence: DeliveryEvidence): Schema.Json | undefined;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { PostDeliveryResultSchema, PostError, } from "./post-contract.js";
|
|
4
|
+
/** Shared preparation, bounded receiver I/O and response decoding for every Post backend. */
|
|
5
|
+
export async function deliverPost(input) {
|
|
6
|
+
const { attempt, route } = input;
|
|
7
|
+
let envelope = attempt.delivery;
|
|
8
|
+
let rawResult;
|
|
9
|
+
try {
|
|
10
|
+
rawResult = await Effect.runPromise(Effect.tryPromise({
|
|
11
|
+
try: async (signal) => {
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
if (envelope === undefined) {
|
|
14
|
+
envelope =
|
|
15
|
+
route.prepareEnvelope === undefined
|
|
16
|
+
? route.parseDelivery(attempt.envelope)
|
|
17
|
+
: await route.prepareEnvelope(attempt.envelope, signal);
|
|
18
|
+
signal.throwIfAborted();
|
|
19
|
+
if (route.prepareEnvelope !== undefined)
|
|
20
|
+
await input.savePrepared(envelope);
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
envelope = route.parseDelivery(envelope);
|
|
24
|
+
signal.throwIfAborted();
|
|
25
|
+
return route.namespace
|
|
26
|
+
.getByName(route.nameOf(envelope))
|
|
27
|
+
.deliver(envelope);
|
|
28
|
+
},
|
|
29
|
+
catch: (cause) => cause,
|
|
30
|
+
}).pipe(Effect.timeout("10 seconds")), { signal: input.signal });
|
|
31
|
+
}
|
|
32
|
+
catch (failure) {
|
|
33
|
+
return { result: "unavailable", envelope, failure };
|
|
34
|
+
}
|
|
35
|
+
const decoded = Schema.decodeUnknownResult(PostDeliveryResultSchema)(rawResult);
|
|
36
|
+
if (decoded._tag === "Failure")
|
|
37
|
+
return { result: "unavailable", envelope, failure: decoded.failure };
|
|
38
|
+
return { result: decoded.success, envelope };
|
|
39
|
+
}
|
|
40
|
+
/** One settlement decision, shared by native storage adapters. */
|
|
41
|
+
export function postDisposition(evidence, route, attempts, maxAttempts) {
|
|
42
|
+
const { result, envelope } = evidence;
|
|
43
|
+
if (result === "unavailable")
|
|
44
|
+
return attempts >= maxAttempts
|
|
45
|
+
? { state: "dead", reason: "exhausted" }
|
|
46
|
+
: { state: "pending" };
|
|
47
|
+
if (envelope === undefined)
|
|
48
|
+
throw new PostError("missing delivered fact");
|
|
49
|
+
if (result === "refused")
|
|
50
|
+
return { state: "dead", reason: "refused" };
|
|
51
|
+
if (result === "accepted" || result === "duplicate") {
|
|
52
|
+
return route.recordCursor !== undefined
|
|
53
|
+
? { state: "keep" }
|
|
54
|
+
: { state: "delivered" };
|
|
55
|
+
}
|
|
56
|
+
if (result.status === "refused")
|
|
57
|
+
return { state: "dead", reason: "refused" };
|
|
58
|
+
if ("cursor" in result)
|
|
59
|
+
return { state: "delivered", cursor: result.cursor };
|
|
60
|
+
return route.recordCursor !== undefined
|
|
61
|
+
? { state: "keep" }
|
|
62
|
+
: { state: "delivered" };
|
|
63
|
+
}
|
|
64
|
+
/** Durable provider/domain receipt data, separate from the staged event. */
|
|
65
|
+
export function deliveryReceipt(evidence) {
|
|
66
|
+
const result = evidence.result;
|
|
67
|
+
if (result === "accepted" ||
|
|
68
|
+
result === "duplicate" ||
|
|
69
|
+
result === "refused" ||
|
|
70
|
+
result === "unavailable")
|
|
71
|
+
return undefined;
|
|
72
|
+
return "receipt" in result ? result.receipt : undefined;
|
|
73
|
+
}
|