@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -2
  3. package/dist/cloudflare.d.ts +32 -0
  4. package/dist/cloudflare.js +80 -0
  5. package/dist/email.d.ts +2 -0
  6. package/dist/email.js +2 -0
  7. package/dist/index.d.ts +21 -0
  8. package/dist/index.js +28 -0
  9. package/dist/inspection.d.ts +108 -0
  10. package/dist/inspection.js +188 -0
  11. package/dist/internal/cloudflare-send.d.ts +9 -0
  12. package/dist/internal/cloudflare-send.js +79 -0
  13. package/dist/internal/contracts.d.ts +94 -0
  14. package/dist/internal/contracts.js +7 -0
  15. package/dist/internal/owner.d.ts +24 -0
  16. package/dist/internal/owner.js +419 -0
  17. package/dist/internal/policy.d.ts +25 -0
  18. package/dist/internal/policy.js +18 -0
  19. package/dist/internal/post-contract.d.ts +27 -0
  20. package/dist/internal/post-contract.js +22 -0
  21. package/dist/internal/post-execution.d.ts +45 -0
  22. package/dist/internal/post-execution.js +73 -0
  23. package/dist/internal/post-sql.d.ts +51 -0
  24. package/dist/internal/post-sql.js +255 -0
  25. package/dist/internal/receipt-schema.d.ts +3 -0
  26. package/dist/internal/receipt-schema.js +12 -0
  27. package/dist/post-d1.d.ts +36 -0
  28. package/dist/post-d1.js +55 -0
  29. package/dist/post-outbox.d.ts +40 -0
  30. package/dist/post-outbox.js +99 -0
  31. package/dist/post-postgres.d.ts +25 -0
  32. package/dist/post-postgres.js +79 -0
  33. package/dist/post.d.ts +189 -0
  34. package/dist/post.js +436 -0
  35. package/dist/testing.d.ts +16 -0
  36. package/dist/testing.js +20 -0
  37. package/docs/api/README.md +20 -0
  38. package/docs/api/cloudflare.md +100 -0
  39. package/docs/api/email.md +87 -0
  40. package/docs/api/index.md +343 -0
  41. package/docs/api/inspection.md +281 -0
  42. package/docs/api/post-d1.md +184 -0
  43. package/docs/api/post-postgres.md +108 -0
  44. package/docs/api/post.md +620 -0
  45. package/docs/api/testing.md +46 -0
  46. 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
+ }