@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
package/dist/post.d.ts ADDED
@@ -0,0 +1,189 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { type PostDeliveryResult } from "./internal/post-contract.js";
3
+ export { PostError, PostDeliveryResultSchema, } from "./internal/post-contract.js";
4
+ export type { PostDeliveryResult } from "./internal/post-contract.js";
5
+ import type { WatchdogRunningJob, WatchdogSqliteOwner, WatchdogRuntime, WatchdogTransactionalProjection } from "@fungi.computer/watchdog";
6
+ export { makePostOutbox, retryDelay } from "./post-outbox.js";
7
+ export type { PostOutbox, PostOutboxStore } from "./post-outbox.js";
8
+ /** Wire envelope parsed at the durable row and receiver boundaries. */
9
+ export declare const PostEnvelopeSchema: Schema.Struct<{
10
+ readonly key: Schema.String;
11
+ readonly kind: Schema.String;
12
+ readonly address: Schema.Unknown;
13
+ readonly event: Schema.Unknown;
14
+ }>;
15
+ /** Persisted Post row parser. */
16
+ export declare const PostRowSchema: Schema.fromJsonString<Schema.Union<readonly [Schema.Struct<{
17
+ readonly state: Schema.Literal<"pending">;
18
+ readonly envelope: Schema.Struct<{
19
+ readonly key: Schema.String;
20
+ readonly kind: Schema.String;
21
+ readonly address: Schema.Unknown;
22
+ readonly event: Schema.Unknown;
23
+ }>;
24
+ readonly attempts: Schema.Int;
25
+ readonly nextAttemptAt: Schema.Number;
26
+ readonly jobId: Schema.optional<Schema.String>;
27
+ readonly delivery: Schema.optional<Schema.Struct<{
28
+ readonly key: Schema.String;
29
+ readonly kind: Schema.String;
30
+ readonly address: Schema.Unknown;
31
+ readonly event: Schema.Unknown;
32
+ }>>;
33
+ readonly nextKey: Schema.optional<Schema.String>;
34
+ }>, Schema.Struct<{
35
+ readonly state: Schema.Literal<"delivered">;
36
+ readonly receipt: Schema.optional<Schema.Codec<Schema.Json, Schema.Json, never, never>>;
37
+ readonly nextKey: Schema.optional<Schema.String>;
38
+ }>, Schema.Struct<{
39
+ readonly state: Schema.Literal<"dead">;
40
+ readonly envelope: Schema.Struct<{
41
+ readonly key: Schema.String;
42
+ readonly kind: Schema.String;
43
+ readonly address: Schema.Unknown;
44
+ readonly event: Schema.Unknown;
45
+ }>;
46
+ readonly attempts: Schema.Int;
47
+ readonly reason: Schema.Literals<readonly ["refused", "exhausted"]>;
48
+ readonly receipt: Schema.optional<Schema.Codec<Schema.Json, Schema.Json, never, never>>;
49
+ readonly delivery: Schema.optional<Schema.Struct<{
50
+ readonly key: Schema.String;
51
+ readonly kind: Schema.String;
52
+ readonly address: Schema.Unknown;
53
+ readonly event: Schema.Unknown;
54
+ }>>;
55
+ readonly nextKey: Schema.optional<Schema.String>;
56
+ }>]>>;
57
+ /** One addressed event identified by the producer's stable key. */
58
+ export type PostEnvelope = Schema.Schema.Type<typeof PostEnvelopeSchema>;
59
+ /** Pending row, terminal receipt, or dead letter. */
60
+ export type PostRow = Schema.Schema.Type<typeof PostRowSchema>;
61
+ /** Schema-correlated ordinary events or prepared delivery facts. */
62
+ export type PostRoute<A, E, D = E, AI = A, EI = E, DI = EI> = Readonly<{
63
+ /** Fixed registry label shared by routes to one recipient namespace. */
64
+ addressKind: string;
65
+ /** Address parser for this kind. */
66
+ address: Schema.Codec<A, AI>;
67
+ /** Staged event parser for this kind. */
68
+ event: Schema.Codec<E, EI>;
69
+ /** Stable Durable Object name for one address. */
70
+ name: (address: A) => string;
71
+ /** Durable Object namespace for this kind. */
72
+ namespace: Readonly<{
73
+ /** Obtain the addressed Durable Object. */
74
+ getByName: (name: string) => Readonly<{
75
+ /** Idempotently apply one addressed envelope. */
76
+ deliver: (envelope: PostEnvelope) => Promise<PostDeliveryResult>;
77
+ }>;
78
+ }>;
79
+ }> & (Readonly<{
80
+ /** Prepare one fact before receiver I/O; its callbacks consume that fact. */
81
+ prepare: Readonly<{
82
+ /** Parser for the prepared delivery fact. */
83
+ event: Schema.Codec<D, DI>;
84
+ /** Resolve the staged intent into a delivery fact. */
85
+ run: (address: A, intent: E, signal: AbortSignal) => Promise<D>;
86
+ }>;
87
+ /** Acknowledge delivery in the producer's receipt transaction. */
88
+ onDelivered?: (address: A, event: D) => void;
89
+ /** Commit the receiver cursor in the producer's receipt transaction. */
90
+ onCursor?: (address: A, event: D, cursor: number) => void;
91
+ /** Revoke a subscription after permanent refusal. */
92
+ onRefused?: (address: A, event: D) => void;
93
+ }> | Readonly<{
94
+ /** An ordinary event is also its delivery fact. */
95
+ prepare?: undefined;
96
+ /** Acknowledge delivery in the producer's receipt transaction. */
97
+ onDelivered?: (address: A, event: E) => void;
98
+ /** Commit the receiver cursor in the producer's receipt transaction. */
99
+ onCursor?: (address: A, event: E, cursor: number) => void;
100
+ /** Revoke a subscription after permanent refusal. */
101
+ onRefused?: (address: A, event: E) => void;
102
+ }>);
103
+ /** Runtime operations that preserve the route's schema evidence. */
104
+ export type PostRegistryRoute = Readonly<{
105
+ /** Recipient namespace identity used for per-address ordering. */
106
+ addressKind: string;
107
+ /** Parser for this address kind. */
108
+ address: Schema.Codec<unknown, unknown>;
109
+ /** Parser for this event kind. */
110
+ event: Schema.Codec<unknown, unknown>;
111
+ /** Encode a producer's domain values with this route's schemas. */
112
+ encodeEnvelope: (envelope: PostEnvelope) => PostEnvelope;
113
+ /** Decode a prepared wire fact, or the ordinary event when no preparation is needed. */
114
+ parseDelivery: (envelope: PostEnvelope) => PostEnvelope;
115
+ /** Prepare the wire fact before receiver I/O. */
116
+ prepareEnvelope?: ((envelope: PostEnvelope, signal: AbortSignal) => Promise<PostEnvelope>) | undefined;
117
+ /** Acknowledge producer domain state in the receipt transaction. */
118
+ recordDelivered?: ((envelope: PostEnvelope) => void) | undefined;
119
+ /** Decode the address and return its stable Durable Object name. */
120
+ nameOf: (envelope: Pick<PostEnvelope, "address">) => string;
121
+ /** Durable Object namespace. */
122
+ namespace: PostRoute<never, never>["namespace"];
123
+ /** Apply a typed receiver cursor in the producer transaction. */
124
+ recordCursor?: ((envelope: PostEnvelope, cursor: number) => void) | undefined;
125
+ /** Apply a typed permanent refusal in the producer transaction. */
126
+ recordRefused?: ((envelope: PostEnvelope) => void) | undefined;
127
+ }>;
128
+ /** Preserve each route's inferred address and event types in a registry. */
129
+ export declare function definePostRoute<A, E, AI = A, EI = E>(route: Extract<PostRoute<A, E, E, AI, EI, EI>, {
130
+ prepare?: undefined;
131
+ }>): PostRoute<A, E, E, AI, EI, EI> & Omit<PostRegistryRoute, "address" | "event">;
132
+ export declare function definePostRoute<A, E, D, AI = A, EI = E, DI = EI>(route: Extract<PostRoute<A, E, D, AI, EI, DI>, {
133
+ prepare: unknown;
134
+ }>): PostRoute<A, E, D, AI, EI, DI> & Omit<PostRegistryRoute, "address" | "event">;
135
+ /** One producer's Post operations. */
136
+ export type PostRuntime<R extends Record<string, PostRegistryRoute>> = Readonly<{
137
+ /** Write a row and Watchdog job in the producer's own transaction. */
138
+ send: <K extends keyof R & string>(input: Readonly<{
139
+ key: string;
140
+ kind: K;
141
+ address: Schema.Schema.Type<R[K]["address"]>;
142
+ event: Schema.Schema.Type<R[K]["event"]>;
143
+ }>) => void;
144
+ /** Run as the Watchdog executor for a claimed Post job. */
145
+ execute: (job: WatchdogRunningJob) => Promise<Readonly<{
146
+ kind: "completed";
147
+ }>>;
148
+ /** Recover settled delivery jobs and enqueue due retries after a wake. */
149
+ enqueueDue: (limit?: number, afterKey?: string) => Promise<string | undefined>;
150
+ /** Read an ordered page of refused or exhausted keys for operators. */
151
+ deadLetters: (limit?: number, afterKey?: string) => Readonly<{
152
+ letters: readonly Readonly<{
153
+ key: string;
154
+ reason: "refused" | "exhausted";
155
+ envelope: PostEnvelope;
156
+ attempts: number;
157
+ }>[];
158
+ nextCursor: string | undefined;
159
+ }>;
160
+ /** The latest staged event of one kind for an address, including its terminal receipt. */
161
+ latest: <K extends keyof R & string>(kind: K, address: Schema.Schema.Type<R[K]["address"]>) => Readonly<{
162
+ key: string;
163
+ row: PostRow;
164
+ }> | undefined;
165
+ /** Requeue a failed event or expedite its settled retry, preserving its prepared fact. */
166
+ retry: (key: string) => Promise<void>;
167
+ /** Inspect one producer-local row or receipt. */
168
+ read: (key: string) => PostRow | undefined;
169
+ /** Earliest pending queue head, including a settled job waiting to retry. */
170
+ nextWake: () => number | null;
171
+ }>;
172
+ /** Decode at both persisted and DO boundaries with the route's Effect Schema. */
173
+ export declare function createPost<R extends Record<string, PostRegistryRoute>>(options: Readonly<{
174
+ routes: R;
175
+ owner: WatchdogSqliteOwner;
176
+ transactional: WatchdogTransactionalProjection;
177
+ readJob: WatchdogRuntime["read"];
178
+ now: () => number;
179
+ scheduleWake: (at: number) => Promise<void>;
180
+ }>): PostRuntime<R>;
181
+ /** Consumer admission runs inside the receiver's own SQLite transaction. */
182
+ export declare function admitPost<E, I>(options: Readonly<{
183
+ key: string;
184
+ event: unknown;
185
+ schema: Schema.Codec<E, I>;
186
+ hasReceipt: (key: string) => boolean;
187
+ apply: (event: E) => PostDeliveryResult;
188
+ putReceipt: (key: string) => void;
189
+ }>): PostDeliveryResult;
package/dist/post.js ADDED
@@ -0,0 +1,436 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { PostError, } from "./internal/post-contract.js";
3
+ export { PostError, PostDeliveryResultSchema, } from "./internal/post-contract.js";
4
+ import { deliverPost, postDisposition, deliveryReceipt, } from "./internal/post-execution.js";
5
+ import { defineIdFamily } from "@fungi.computer/cairn";
6
+ import { maxDeliveryAttempts, retryDelay } from "./post-outbox.js";
7
+ export { makePostOutbox, retryDelay } from "./post-outbox.js";
8
+ const Key = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
9
+ const JobPayload = Schema.Struct({ postKey: Key });
10
+ const PageLimit = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(64));
11
+ const ids = defineIdFamily({
12
+ family: "post",
13
+ version: 1,
14
+ kinds: { work: { prefix: "post_work" } },
15
+ });
16
+ /** Wire envelope parsed at the durable row and receiver boundaries. */
17
+ export const PostEnvelopeSchema = Schema.Struct({
18
+ key: Key,
19
+ kind: Key,
20
+ address: Schema.Unknown,
21
+ event: Schema.Unknown,
22
+ });
23
+ /** Persisted Post row parser. */
24
+ export const PostRowSchema = Schema.fromJsonString(Schema.Union([
25
+ Schema.Struct({
26
+ state: Schema.Literal("pending"),
27
+ envelope: PostEnvelopeSchema,
28
+ attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
29
+ nextAttemptAt: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)),
30
+ jobId: Schema.optional(Key),
31
+ delivery: Schema.optional(PostEnvelopeSchema),
32
+ nextKey: Schema.optional(Key),
33
+ }),
34
+ Schema.Struct({
35
+ state: Schema.Literal("delivered"),
36
+ receipt: Schema.optional(Schema.Json),
37
+ nextKey: Schema.optional(Key),
38
+ }),
39
+ Schema.Struct({
40
+ state: Schema.Literal("dead"),
41
+ envelope: PostEnvelopeSchema,
42
+ attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
43
+ reason: Schema.Literals(["refused", "exhausted"]),
44
+ receipt: Schema.optional(Schema.Json),
45
+ delivery: Schema.optional(PostEnvelopeSchema),
46
+ nextKey: Schema.optional(Key),
47
+ }),
48
+ ]));
49
+ function receiptOperations(address, event, hooks) {
50
+ const { onCursor, onRefused, onDelivered } = hooks;
51
+ const decodeAddress = Schema.decodeUnknownSync(address);
52
+ const decodeEvent = Schema.decodeUnknownSync(event);
53
+ return {
54
+ recordCursor: onCursor === undefined
55
+ ? undefined
56
+ : (envelope, cursor) => {
57
+ onCursor(decodeAddress(envelope.address), decodeEvent(envelope.event), cursor);
58
+ },
59
+ recordRefused: onRefused === undefined
60
+ ? undefined
61
+ : (envelope) => {
62
+ onRefused(decodeAddress(envelope.address), decodeEvent(envelope.event));
63
+ },
64
+ recordDelivered: onDelivered === undefined
65
+ ? undefined
66
+ : (envelope) => {
67
+ onDelivered(decodeAddress(envelope.address), decodeEvent(envelope.event));
68
+ },
69
+ parseDelivery: (envelope) => ({
70
+ ...envelope,
71
+ address: Schema.encodeSync(address)(decodeAddress(envelope.address)),
72
+ event: Schema.encodeSync(event)(decodeEvent(envelope.event)),
73
+ }),
74
+ };
75
+ }
76
+ export function definePostRoute(route) {
77
+ const delivery = route.prepare === undefined
78
+ ? receiptOperations(route.address, route.event, route)
79
+ : receiptOperations(route.address, route.prepare.event, route);
80
+ const prepare = route.prepare;
81
+ return {
82
+ ...route,
83
+ ...delivery,
84
+ addressKind: Schema.decodeUnknownSync(Key)(route.addressKind),
85
+ prepareEnvelope: prepare === undefined
86
+ ? undefined
87
+ : async (envelope, signal) => ({
88
+ ...envelope,
89
+ event: Schema.encodeUnknownSync(prepare.event)(await prepare.run(Schema.decodeUnknownSync(route.address)(envelope.address), Schema.decodeUnknownSync(route.event)(envelope.event), signal)),
90
+ }),
91
+ encodeEnvelope: (envelope) => ({
92
+ ...envelope,
93
+ address: Schema.encodeUnknownSync(route.address)(envelope.address),
94
+ event: Schema.encodeUnknownSync(route.event)(envelope.event),
95
+ }),
96
+ nameOf: (envelope) => route.name(Schema.decodeUnknownSync(route.address)(envelope.address)),
97
+ };
98
+ }
99
+ const StoredRow = Schema.Struct({ key: Schema.String, value: Schema.String });
100
+ const DueRow = Schema.Struct({
101
+ due: Schema.NullOr(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))),
102
+ });
103
+ const openPostStore = (owner) => {
104
+ const sql = owner.sql;
105
+ owner.transactionSync(() => {
106
+ sql.exec("CREATE TABLE IF NOT EXISTS carrier_post_rows (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
107
+ });
108
+ return {
109
+ commit: (mutation) => owner.transactionSync(mutation),
110
+ read: (key) => {
111
+ const row = sql
112
+ .exec("SELECT key,value FROM carrier_post_rows WHERE key=?", key)
113
+ .toArray()[0];
114
+ return row === undefined
115
+ ? undefined
116
+ : Schema.decodeUnknownSync(StoredRow)(row).value;
117
+ },
118
+ put: (key, value) => {
119
+ sql.exec("INSERT INTO carrier_post_rows VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", key, value);
120
+ },
121
+ list: (limit, afterKey = "") => Schema.decodeUnknownSync(Schema.Array(StoredRow))(sql
122
+ .exec("SELECT key,value FROM carrier_post_rows WHERE key LIKE 'post:%' AND key>? ORDER BY key LIMIT ?", afterKey, limit)
123
+ .toArray()),
124
+ nextWake: () => Schema.decodeUnknownSync(DueRow)(sql
125
+ .exec("SELECT MIN(json_extract(value,'$.nextAttemptAt')) AS due FROM carrier_post_rows WHERE key LIKE 'post:%' AND json_extract(value,'$.state')='pending' AND json_type(value,'$.jobId')='text'")
126
+ .toArray()[0]).due,
127
+ };
128
+ };
129
+ /** Decode at both persisted and DO boundaries with the route's Effect Schema. */
130
+ export function createPost(options) {
131
+ const store = openPostStore(options.owner);
132
+ const rowKey = (key) => `post:${key}`;
133
+ const tailKey = (queue) => `post-tail:${queue}`;
134
+ const headKey = (kind, queue) => `post-head:${JSON.stringify([kind, queue])}`;
135
+ const readRow = (key) => {
136
+ const raw = store.read(rowKey(key));
137
+ return raw === undefined
138
+ ? undefined
139
+ : Schema.decodeUnknownSync(PostRowSchema)(raw);
140
+ };
141
+ const nextWake = () => store.nextWake();
142
+ const putRow = (key, row) => {
143
+ store.put(rowKey(key), Schema.encodeSync(PostRowSchema)(row));
144
+ };
145
+ const routeFor = (envelope) => {
146
+ const route = options.routes[envelope.kind];
147
+ if (route === undefined)
148
+ throw new PostError("unknown address kind");
149
+ return route;
150
+ };
151
+ const queueOf = (envelope) => {
152
+ const route = routeFor(envelope);
153
+ return JSON.stringify([route.addressKind, route.nameOf(envelope)]);
154
+ };
155
+ const enqueueJob = (row) => {
156
+ const jobId = ids.work.generate();
157
+ putRow(row.envelope.key, { ...row, jobId });
158
+ options.transactional.enqueueAcceptedJob({
159
+ jobId,
160
+ queue: queueOf(row.envelope),
161
+ lane: "post",
162
+ priority: 0,
163
+ payload: { postKey: row.envelope.key },
164
+ state: "queued",
165
+ });
166
+ };
167
+ const send = (input) => {
168
+ const incoming = Schema.decodeUnknownSync(PostEnvelopeSchema)(input);
169
+ const route = routeFor(incoming);
170
+ const envelope = route.encodeEnvelope(incoming);
171
+ const existing = readRow(envelope.key);
172
+ if (existing !== undefined) {
173
+ if (existing.state !== "delivered" &&
174
+ JSON.stringify(existing.envelope) !== JSON.stringify(envelope))
175
+ throw new PostError("conflicting post key");
176
+ return;
177
+ }
178
+ const queue = queueOf(envelope);
179
+ const previousKey = store.read(tailKey(queue));
180
+ const previous = previousKey === undefined ? undefined : readRow(previousKey);
181
+ const row = {
182
+ state: "pending",
183
+ envelope,
184
+ attempts: 0,
185
+ nextAttemptAt: options.now(),
186
+ };
187
+ putRow(envelope.key, row);
188
+ if (previousKey !== undefined && previous?.state === "pending") {
189
+ putRow(previousKey, { ...previous, nextKey: envelope.key });
190
+ }
191
+ else {
192
+ enqueueJob(row);
193
+ }
194
+ store.put(tailKey(queue), envelope.key);
195
+ store.put(headKey(envelope.kind, queue), envelope.key);
196
+ };
197
+ const latest = (kind, address) => {
198
+ const route = options.routes[kind];
199
+ if (route === undefined)
200
+ throw new PostError("unknown address kind");
201
+ const queue = JSON.stringify([
202
+ route.addressKind,
203
+ route.nameOf({
204
+ address: Schema.encodeUnknownSync(route.address)(address),
205
+ }),
206
+ ]);
207
+ const key = store.read(headKey(kind, queue));
208
+ if (key === undefined)
209
+ return undefined;
210
+ const row = readRow(key);
211
+ if (row === undefined)
212
+ throw new PostError("latest delivery row is missing");
213
+ return { key, row };
214
+ };
215
+ const releaseNext = (nextKey) => {
216
+ if (nextKey === undefined)
217
+ return;
218
+ const next = readRow(nextKey);
219
+ if (next?.state === "pending" && next.jobId === undefined)
220
+ enqueueJob(next);
221
+ };
222
+ const finish = (key, row, terminal, metadata) => {
223
+ let receipt;
224
+ if (terminal.state === "dead") {
225
+ const dead = {
226
+ ...terminal,
227
+ envelope: row.envelope,
228
+ attempts: row.attempts,
229
+ };
230
+ receipt =
231
+ row.delivery === undefined ? dead : { ...dead, delivery: row.delivery };
232
+ }
233
+ else
234
+ receipt = terminal;
235
+ if (metadata !== undefined)
236
+ receipt = { ...receipt, receipt: metadata };
237
+ putRow(key, row.nextKey === undefined
238
+ ? receipt
239
+ : { ...receipt, nextKey: row.nextKey });
240
+ releaseNext(row.nextKey);
241
+ };
242
+ const execute = async (job) => {
243
+ const { postKey } = Schema.decodeUnknownSync(JobPayload)(job.payload);
244
+ const attempt = store.commit(() => {
245
+ const row = readRow(postKey);
246
+ if (row === undefined)
247
+ throw new PostError("delivery row is missing");
248
+ if (row.state !== "pending" || row.jobId !== job.jobId)
249
+ return undefined;
250
+ if (row.attempts >= maxDeliveryAttempts) {
251
+ finish(postKey, row, { state: "dead", reason: "exhausted" });
252
+ return undefined;
253
+ }
254
+ const attempts = row.attempts + 1;
255
+ const nextAttemptAt = options.now() + retryDelay(attempts);
256
+ const started = { ...row, attempts, nextAttemptAt };
257
+ // Preserve recovery evidence before invoking the receiver. A failed receipt
258
+ // write leaves this row available for retry after Watchdog settles the job.
259
+ putRow(postKey, started);
260
+ return started;
261
+ });
262
+ if (attempt === undefined)
263
+ return { kind: "completed" };
264
+ await options.scheduleWake(attempt.nextAttemptAt);
265
+ const route = routeFor(attempt.envelope);
266
+ const evidence = await deliverPost({
267
+ attempt,
268
+ route,
269
+ savePrepared: (envelope) => {
270
+ store.commit(() => {
271
+ const current = readRow(postKey);
272
+ if (current?.state !== "pending" || current.jobId !== job.jobId)
273
+ throw new PostError("preparation superseded");
274
+ putRow(postKey, { ...current, delivery: envelope });
275
+ });
276
+ },
277
+ });
278
+ let retryAt;
279
+ store.commit(() => {
280
+ // Another send may have linked a successor during the remote call.
281
+ const current = readRow(postKey);
282
+ if (current?.state !== "pending" || current.jobId !== job.jobId)
283
+ return;
284
+ const disposition = postDisposition(evidence, route, current.attempts, maxDeliveryAttempts);
285
+ if (disposition.state === "keep")
286
+ return;
287
+ if (disposition.state === "delivered") {
288
+ if (evidence.envelope === undefined)
289
+ throw new PostError("missing delivered fact");
290
+ if (disposition.cursor !== undefined)
291
+ route.recordCursor?.(evidence.envelope, disposition.cursor);
292
+ route.recordDelivered?.(evidence.envelope);
293
+ finish(postKey, current, { state: "delivered" }, deliveryReceipt(evidence));
294
+ }
295
+ else if (disposition.state === "dead") {
296
+ if (disposition.reason === "refused" && evidence.envelope !== undefined)
297
+ route.recordRefused?.(evidence.envelope);
298
+ finish(postKey, current, disposition, deliveryReceipt(evidence));
299
+ }
300
+ else {
301
+ const due = options.now() + retryDelay(current.attempts);
302
+ if (due !== current.nextAttemptAt) {
303
+ putRow(postKey, { ...current, nextAttemptAt: due });
304
+ retryAt = due;
305
+ }
306
+ }
307
+ });
308
+ if (retryAt !== undefined)
309
+ await options.scheduleWake(retryAt);
310
+ return { kind: "completed" };
311
+ };
312
+ const enqueueDue = async (limit = 64, afterKey) => {
313
+ const pageSize = Schema.decodeUnknownSync(PageLimit)(limit);
314
+ const rows = store.list(pageSize, afterKey);
315
+ for (const stored of rows) {
316
+ const row = Schema.decodeUnknownSync(PostRowSchema)(stored.value);
317
+ if (row.state !== "pending" || row.jobId === undefined)
318
+ continue;
319
+ const work = await options.readJob(row.jobId);
320
+ if (work.status === "missing")
321
+ throw new PostError("delivery job is missing");
322
+ if (work.job.state !== "settled")
323
+ continue;
324
+ if (row.nextAttemptAt > options.now()) {
325
+ await options.scheduleWake(row.nextAttemptAt);
326
+ continue;
327
+ }
328
+ store.commit(() => {
329
+ const current = readRow(row.envelope.key);
330
+ if (current?.state !== "pending" || current.jobId !== row.jobId)
331
+ return;
332
+ if (current.attempts >= maxDeliveryAttempts)
333
+ finish(current.envelope.key, current, {
334
+ state: "dead",
335
+ reason: "exhausted",
336
+ });
337
+ else
338
+ enqueueJob(current);
339
+ });
340
+ }
341
+ return rows.length === pageSize ? rows.at(-1)?.key : undefined;
342
+ };
343
+ const retry = async (input) => {
344
+ const key = Schema.decodeUnknownSync(Key)(input);
345
+ const row = readRow(key);
346
+ if (row === undefined || row.state === "delivered")
347
+ return;
348
+ const work = row.state === "pending" && row.jobId !== undefined
349
+ ? await options.readJob(row.jobId)
350
+ : undefined;
351
+ if (work?.status === "missing")
352
+ throw new PostError("delivery job is missing");
353
+ if (work?.status === "found" && work.job.state !== "settled")
354
+ return;
355
+ store.commit(() => {
356
+ const current = readRow(key);
357
+ if (current === undefined || current.state === "delivered")
358
+ return;
359
+ if (current.state === "pending") {
360
+ if (row.state !== "pending" || current.jobId !== row.jobId)
361
+ return;
362
+ const pending = {
363
+ ...current,
364
+ attempts: 0,
365
+ nextAttemptAt: options.now(),
366
+ };
367
+ putRow(key, pending);
368
+ if (current.jobId !== undefined)
369
+ enqueueJob(pending);
370
+ }
371
+ else {
372
+ const pending = {
373
+ state: "pending",
374
+ envelope: current.envelope,
375
+ attempts: 0,
376
+ nextAttemptAt: options.now(),
377
+ delivery: current.delivery,
378
+ };
379
+ putRow(key, pending);
380
+ const queue = queueOf(pending.envelope);
381
+ const previousKey = store.read(tailKey(queue));
382
+ const previous = previousKey === undefined || previousKey === key
383
+ ? undefined
384
+ : readRow(previousKey);
385
+ if (previous?.state === "pending" && previousKey !== undefined)
386
+ putRow(previousKey, { ...previous, nextKey: key });
387
+ else
388
+ enqueueJob(pending);
389
+ store.put(tailKey(queue), key);
390
+ }
391
+ });
392
+ await options.scheduleWake(options.now());
393
+ };
394
+ const deadLetters = (limit = 64, afterKey) => {
395
+ const pageSize = Schema.decodeUnknownSync(PageLimit)(limit);
396
+ const rows = store.list(pageSize, afterKey);
397
+ const letters = rows.flatMap(({ key, value }) => {
398
+ const row = Schema.decodeUnknownSync(PostRowSchema)(value);
399
+ return row.state === "dead"
400
+ ? [
401
+ {
402
+ key,
403
+ reason: row.reason,
404
+ envelope: row.envelope,
405
+ attempts: row.attempts,
406
+ },
407
+ ]
408
+ : [];
409
+ });
410
+ return {
411
+ letters,
412
+ nextCursor: rows.length === pageSize ? rows.at(-1)?.key : undefined,
413
+ };
414
+ };
415
+ return Object.freeze({
416
+ send,
417
+ latest,
418
+ retry,
419
+ execute,
420
+ enqueueDue,
421
+ deadLetters,
422
+ read: readRow,
423
+ nextWake,
424
+ });
425
+ }
426
+ /** Consumer admission runs inside the receiver's own SQLite transaction. */
427
+ export function admitPost(options) {
428
+ const key = Schema.decodeUnknownSync(Key)(options.key);
429
+ const event = Schema.decodeUnknownSync(options.schema)(options.event);
430
+ if (options.hasReceipt(key))
431
+ return "duplicate";
432
+ const result = options.apply(event);
433
+ if (result === "accepted")
434
+ options.putReceipt(key);
435
+ return result;
436
+ }
@@ -0,0 +1,16 @@
1
+ /** String-row outbox for deterministic tests and quick starts; no persistence. */
2
+ export type MemoryOutbox = Readonly<{
3
+ /** Store or replace one encoded row. */
4
+ put: (key: string, value: string) => Promise<void>;
5
+ /** Read a retained row, if present. */
6
+ read: (key: string) => Promise<string | undefined>;
7
+ /** Read a key-ordered page strictly after the supplied cursor. */
8
+ list: (limit: number, afterKey?: string) => Promise<readonly Readonly<{
9
+ key: string;
10
+ value: string;
11
+ }>[]>;
12
+ /** Remove one row. */
13
+ delete: (key: string) => Promise<void>;
14
+ }>;
15
+ /** Create an independent in-process outbox for trusted tests and examples. */
16
+ export declare function createMemoryOutbox(): MemoryOutbox;
@@ -0,0 +1,20 @@
1
+ /** Create an independent in-process outbox for trusted tests and examples. */
2
+ export function createMemoryOutbox() {
3
+ const rows = new Map();
4
+ return {
5
+ put: (key, value) => {
6
+ rows.set(key, value);
7
+ return Promise.resolve();
8
+ },
9
+ read: (key) => Promise.resolve(rows.get(key)),
10
+ list: (limit, afterKey) => Promise.resolve([...rows]
11
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
12
+ .filter(([key]) => afterKey === undefined || key > afterKey)
13
+ .slice(0, limit)
14
+ .map(([key, value]) => ({ key, value }))),
15
+ delete: (key) => {
16
+ rows.delete(key);
17
+ return Promise.resolve();
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,20 @@
1
+ <!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
2
+
3
+ **@fungi.computer/carrier**
4
+
5
+ ---
6
+
7
+ # @fungi.computer/carrier
8
+
9
+ ## Modules
10
+
11
+ | Module | Description |
12
+ | --------------------------------- | ----------- |
13
+ | [cloudflare](cloudflare.md) | - |
14
+ | [email](email.md) | - |
15
+ | [index](index.md) | - |
16
+ | [inspection](inspection.md) | - |
17
+ | [post-d1](post-d1.md) | - |
18
+ | [post-postgres](post-postgres.md) | - |
19
+ | [post](post.md) | - |
20
+ | [testing](testing.md) | - |