@fungi.computer/carrier 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +468 -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 +259 -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 +52 -0
- package/dist/post-outbox.js +152 -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 +437 -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 +599 -0
- package/docs/api/testing.md +46 -0
- package/package.json +95 -3
package/dist/post.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
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 { backoffDelay } from "@fungi.computer/almanac";
|
|
7
|
+
import { maxDeliveryAttempts, postRetryPolicy } from "./post-outbox.js";
|
|
8
|
+
export { makePostOutbox, postOutboxPending } from "./post-outbox.js";
|
|
9
|
+
const Key = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
|
|
10
|
+
const JobPayload = Schema.Struct({ postKey: Key });
|
|
11
|
+
const PageLimit = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(64));
|
|
12
|
+
const ids = defineIdFamily({
|
|
13
|
+
family: "post",
|
|
14
|
+
version: 1,
|
|
15
|
+
kinds: { work: { prefix: "post_work" } },
|
|
16
|
+
});
|
|
17
|
+
/** Wire envelope parsed at the durable row and receiver boundaries. */
|
|
18
|
+
export const PostEnvelopeSchema = Schema.Struct({
|
|
19
|
+
key: Key,
|
|
20
|
+
kind: Key,
|
|
21
|
+
address: Schema.Unknown,
|
|
22
|
+
event: Schema.Unknown,
|
|
23
|
+
});
|
|
24
|
+
/** Persisted Post row parser. */
|
|
25
|
+
export const PostRowSchema = Schema.fromJsonString(Schema.Union([
|
|
26
|
+
Schema.Struct({
|
|
27
|
+
state: Schema.Literal("pending"),
|
|
28
|
+
envelope: PostEnvelopeSchema,
|
|
29
|
+
attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
30
|
+
nextAttemptAt: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
31
|
+
jobId: Schema.optional(Key),
|
|
32
|
+
delivery: Schema.optional(PostEnvelopeSchema),
|
|
33
|
+
nextKey: Schema.optional(Key),
|
|
34
|
+
}),
|
|
35
|
+
Schema.Struct({
|
|
36
|
+
state: Schema.Literal("delivered"),
|
|
37
|
+
receipt: Schema.optional(Schema.Json),
|
|
38
|
+
nextKey: Schema.optional(Key),
|
|
39
|
+
}),
|
|
40
|
+
Schema.Struct({
|
|
41
|
+
state: Schema.Literal("dead"),
|
|
42
|
+
envelope: PostEnvelopeSchema,
|
|
43
|
+
attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
44
|
+
reason: Schema.Literals(["refused", "exhausted"]),
|
|
45
|
+
receipt: Schema.optional(Schema.Json),
|
|
46
|
+
delivery: Schema.optional(PostEnvelopeSchema),
|
|
47
|
+
nextKey: Schema.optional(Key),
|
|
48
|
+
}),
|
|
49
|
+
]));
|
|
50
|
+
function receiptOperations(address, event, hooks) {
|
|
51
|
+
const { onCursor, onRefused, onDelivered } = hooks;
|
|
52
|
+
const decodeAddress = Schema.decodeUnknownSync(address);
|
|
53
|
+
const decodeEvent = Schema.decodeUnknownSync(event);
|
|
54
|
+
return {
|
|
55
|
+
recordCursor: onCursor === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: (envelope, cursor) => {
|
|
58
|
+
onCursor(decodeAddress(envelope.address), decodeEvent(envelope.event), cursor);
|
|
59
|
+
},
|
|
60
|
+
recordRefused: onRefused === undefined
|
|
61
|
+
? undefined
|
|
62
|
+
: (envelope) => {
|
|
63
|
+
onRefused(decodeAddress(envelope.address), decodeEvent(envelope.event));
|
|
64
|
+
},
|
|
65
|
+
recordDelivered: onDelivered === undefined
|
|
66
|
+
? undefined
|
|
67
|
+
: (envelope) => {
|
|
68
|
+
onDelivered(decodeAddress(envelope.address), decodeEvent(envelope.event));
|
|
69
|
+
},
|
|
70
|
+
parseDelivery: (envelope) => ({
|
|
71
|
+
...envelope,
|
|
72
|
+
address: Schema.encodeSync(address)(decodeAddress(envelope.address)),
|
|
73
|
+
event: Schema.encodeSync(event)(decodeEvent(envelope.event)),
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function definePostRoute(route) {
|
|
78
|
+
const delivery = route.prepare === undefined
|
|
79
|
+
? receiptOperations(route.address, route.event, route)
|
|
80
|
+
: receiptOperations(route.address, route.prepare.event, route);
|
|
81
|
+
const prepare = route.prepare;
|
|
82
|
+
return {
|
|
83
|
+
...route,
|
|
84
|
+
...delivery,
|
|
85
|
+
addressKind: Schema.decodeUnknownSync(Key)(route.addressKind),
|
|
86
|
+
prepareEnvelope: prepare === undefined
|
|
87
|
+
? undefined
|
|
88
|
+
: async (envelope, signal) => ({
|
|
89
|
+
...envelope,
|
|
90
|
+
event: Schema.encodeUnknownSync(prepare.event)(await prepare.run(Schema.decodeUnknownSync(route.address)(envelope.address), Schema.decodeUnknownSync(route.event)(envelope.event), signal)),
|
|
91
|
+
}),
|
|
92
|
+
encodeEnvelope: (envelope) => ({
|
|
93
|
+
...envelope,
|
|
94
|
+
address: Schema.encodeUnknownSync(route.address)(envelope.address),
|
|
95
|
+
event: Schema.encodeUnknownSync(route.event)(envelope.event),
|
|
96
|
+
}),
|
|
97
|
+
nameOf: (envelope) => route.name(Schema.decodeUnknownSync(route.address)(envelope.address)),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const StoredRow = Schema.Struct({ key: Schema.String, value: Schema.String });
|
|
101
|
+
const DueRow = Schema.Struct({
|
|
102
|
+
due: Schema.NullOr(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))),
|
|
103
|
+
});
|
|
104
|
+
const openPostStore = (owner) => {
|
|
105
|
+
const sql = owner.sql;
|
|
106
|
+
owner.transactionSync(() => {
|
|
107
|
+
sql.exec("CREATE TABLE IF NOT EXISTS carrier_post_rows (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
commit: (mutation) => owner.transactionSync(mutation),
|
|
111
|
+
read: (key) => {
|
|
112
|
+
const row = sql
|
|
113
|
+
.exec("SELECT key,value FROM carrier_post_rows WHERE key=?", key)
|
|
114
|
+
.toArray()[0];
|
|
115
|
+
return row === undefined
|
|
116
|
+
? undefined
|
|
117
|
+
: Schema.decodeUnknownSync(StoredRow)(row).value;
|
|
118
|
+
},
|
|
119
|
+
put: (key, value) => {
|
|
120
|
+
sql.exec("INSERT INTO carrier_post_rows VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", key, value);
|
|
121
|
+
},
|
|
122
|
+
list: (limit, afterKey = "") => Schema.decodeUnknownSync(Schema.Array(StoredRow))(sql
|
|
123
|
+
.exec("SELECT key,value FROM carrier_post_rows WHERE key LIKE 'post:%' AND key>? ORDER BY key LIMIT ?", afterKey, limit)
|
|
124
|
+
.toArray()),
|
|
125
|
+
nextWake: () => Schema.decodeUnknownSync(DueRow)(sql
|
|
126
|
+
.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'")
|
|
127
|
+
.toArray()[0]).due,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
/** Decode at both persisted and DO boundaries with the route's Effect Schema. */
|
|
131
|
+
export function createPost(options) {
|
|
132
|
+
const store = openPostStore(options.owner);
|
|
133
|
+
const rowKey = (key) => `post:${key}`;
|
|
134
|
+
const tailKey = (queue) => `post-tail:${queue}`;
|
|
135
|
+
const headKey = (kind, queue) => `post-head:${JSON.stringify([kind, queue])}`;
|
|
136
|
+
const readRow = (key) => {
|
|
137
|
+
const raw = store.read(rowKey(key));
|
|
138
|
+
return raw === undefined
|
|
139
|
+
? undefined
|
|
140
|
+
: Schema.decodeUnknownSync(PostRowSchema)(raw);
|
|
141
|
+
};
|
|
142
|
+
const nextWake = () => store.nextWake();
|
|
143
|
+
const putRow = (key, row) => {
|
|
144
|
+
store.put(rowKey(key), Schema.encodeSync(PostRowSchema)(row));
|
|
145
|
+
};
|
|
146
|
+
const routeFor = (envelope) => {
|
|
147
|
+
const route = options.routes[envelope.kind];
|
|
148
|
+
if (route === undefined)
|
|
149
|
+
throw new PostError("unknown address kind");
|
|
150
|
+
return route;
|
|
151
|
+
};
|
|
152
|
+
const queueOf = (envelope) => {
|
|
153
|
+
const route = routeFor(envelope);
|
|
154
|
+
return JSON.stringify([route.addressKind, route.nameOf(envelope)]);
|
|
155
|
+
};
|
|
156
|
+
const enqueueJob = (row) => {
|
|
157
|
+
const jobId = ids.work.generate();
|
|
158
|
+
putRow(row.envelope.key, { ...row, jobId });
|
|
159
|
+
options.transactional.enqueueAcceptedJob({
|
|
160
|
+
jobId,
|
|
161
|
+
queue: queueOf(row.envelope),
|
|
162
|
+
lane: "post",
|
|
163
|
+
priority: 0,
|
|
164
|
+
payload: { postKey: row.envelope.key },
|
|
165
|
+
state: "queued",
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const send = (input) => {
|
|
169
|
+
const incoming = Schema.decodeUnknownSync(PostEnvelopeSchema)(input);
|
|
170
|
+
const route = routeFor(incoming);
|
|
171
|
+
const envelope = route.encodeEnvelope(incoming);
|
|
172
|
+
const existing = readRow(envelope.key);
|
|
173
|
+
if (existing !== undefined) {
|
|
174
|
+
if (existing.state !== "delivered" &&
|
|
175
|
+
JSON.stringify(existing.envelope) !== JSON.stringify(envelope))
|
|
176
|
+
throw new PostError("conflicting post key");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const queue = queueOf(envelope);
|
|
180
|
+
const previousKey = store.read(tailKey(queue));
|
|
181
|
+
const previous = previousKey === undefined ? undefined : readRow(previousKey);
|
|
182
|
+
const row = {
|
|
183
|
+
state: "pending",
|
|
184
|
+
envelope,
|
|
185
|
+
attempts: 0,
|
|
186
|
+
nextAttemptAt: options.now(),
|
|
187
|
+
};
|
|
188
|
+
putRow(envelope.key, row);
|
|
189
|
+
if (previousKey !== undefined && previous?.state === "pending") {
|
|
190
|
+
putRow(previousKey, { ...previous, nextKey: envelope.key });
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
enqueueJob(row);
|
|
194
|
+
}
|
|
195
|
+
store.put(tailKey(queue), envelope.key);
|
|
196
|
+
store.put(headKey(envelope.kind, queue), envelope.key);
|
|
197
|
+
};
|
|
198
|
+
const latest = (kind, address) => {
|
|
199
|
+
const route = options.routes[kind];
|
|
200
|
+
if (route === undefined)
|
|
201
|
+
throw new PostError("unknown address kind");
|
|
202
|
+
const queue = JSON.stringify([
|
|
203
|
+
route.addressKind,
|
|
204
|
+
route.nameOf({
|
|
205
|
+
address: Schema.encodeUnknownSync(route.address)(address),
|
|
206
|
+
}),
|
|
207
|
+
]);
|
|
208
|
+
const key = store.read(headKey(kind, queue));
|
|
209
|
+
if (key === undefined)
|
|
210
|
+
return undefined;
|
|
211
|
+
const row = readRow(key);
|
|
212
|
+
if (row === undefined)
|
|
213
|
+
throw new PostError("latest delivery row is missing");
|
|
214
|
+
return { key, row };
|
|
215
|
+
};
|
|
216
|
+
const releaseNext = (nextKey) => {
|
|
217
|
+
if (nextKey === undefined)
|
|
218
|
+
return;
|
|
219
|
+
const next = readRow(nextKey);
|
|
220
|
+
if (next?.state === "pending" && next.jobId === undefined)
|
|
221
|
+
enqueueJob(next);
|
|
222
|
+
};
|
|
223
|
+
const finish = (key, row, terminal, metadata) => {
|
|
224
|
+
let receipt;
|
|
225
|
+
if (terminal.state === "dead") {
|
|
226
|
+
const dead = {
|
|
227
|
+
...terminal,
|
|
228
|
+
envelope: row.envelope,
|
|
229
|
+
attempts: row.attempts,
|
|
230
|
+
};
|
|
231
|
+
receipt =
|
|
232
|
+
row.delivery === undefined ? dead : { ...dead, delivery: row.delivery };
|
|
233
|
+
}
|
|
234
|
+
else
|
|
235
|
+
receipt = terminal;
|
|
236
|
+
if (metadata !== undefined)
|
|
237
|
+
receipt = { ...receipt, receipt: metadata };
|
|
238
|
+
putRow(key, row.nextKey === undefined
|
|
239
|
+
? receipt
|
|
240
|
+
: { ...receipt, nextKey: row.nextKey });
|
|
241
|
+
releaseNext(row.nextKey);
|
|
242
|
+
};
|
|
243
|
+
const execute = async (job) => {
|
|
244
|
+
const { postKey } = Schema.decodeUnknownSync(JobPayload)(job.payload);
|
|
245
|
+
const attempt = store.commit(() => {
|
|
246
|
+
const row = readRow(postKey);
|
|
247
|
+
if (row === undefined)
|
|
248
|
+
throw new PostError("delivery row is missing");
|
|
249
|
+
if (row.state !== "pending" || row.jobId !== job.jobId)
|
|
250
|
+
return undefined;
|
|
251
|
+
if (row.attempts >= maxDeliveryAttempts) {
|
|
252
|
+
finish(postKey, row, { state: "dead", reason: "exhausted" });
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const attempts = row.attempts + 1;
|
|
256
|
+
const nextAttemptAt = options.now() + backoffDelay(attempts, postRetryPolicy);
|
|
257
|
+
const started = { ...row, attempts, nextAttemptAt };
|
|
258
|
+
// Preserve recovery evidence before invoking the receiver. A failed receipt
|
|
259
|
+
// write leaves this row available for retry after Watchdog settles the job.
|
|
260
|
+
putRow(postKey, started);
|
|
261
|
+
return started;
|
|
262
|
+
});
|
|
263
|
+
if (attempt === undefined)
|
|
264
|
+
return { kind: "completed" };
|
|
265
|
+
await options.scheduleWake(attempt.nextAttemptAt);
|
|
266
|
+
const route = routeFor(attempt.envelope);
|
|
267
|
+
const evidence = await deliverPost({
|
|
268
|
+
attempt,
|
|
269
|
+
route,
|
|
270
|
+
savePrepared: (envelope) => {
|
|
271
|
+
store.commit(() => {
|
|
272
|
+
const current = readRow(postKey);
|
|
273
|
+
if (current?.state !== "pending" || current.jobId !== job.jobId)
|
|
274
|
+
throw new PostError("preparation superseded");
|
|
275
|
+
putRow(postKey, { ...current, delivery: envelope });
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
let retryAt;
|
|
280
|
+
store.commit(() => {
|
|
281
|
+
// Another send may have linked a successor during the remote call.
|
|
282
|
+
const current = readRow(postKey);
|
|
283
|
+
if (current?.state !== "pending" || current.jobId !== job.jobId)
|
|
284
|
+
return;
|
|
285
|
+
const disposition = postDisposition(evidence, route, current.attempts, maxDeliveryAttempts);
|
|
286
|
+
if (disposition.state === "keep")
|
|
287
|
+
return;
|
|
288
|
+
if (disposition.state === "delivered") {
|
|
289
|
+
if (evidence.envelope === undefined)
|
|
290
|
+
throw new PostError("missing delivered fact");
|
|
291
|
+
if (disposition.cursor !== undefined)
|
|
292
|
+
route.recordCursor?.(evidence.envelope, disposition.cursor);
|
|
293
|
+
route.recordDelivered?.(evidence.envelope);
|
|
294
|
+
finish(postKey, current, { state: "delivered" }, deliveryReceipt(evidence));
|
|
295
|
+
}
|
|
296
|
+
else if (disposition.state === "dead") {
|
|
297
|
+
if (disposition.reason === "refused" && evidence.envelope !== undefined)
|
|
298
|
+
route.recordRefused?.(evidence.envelope);
|
|
299
|
+
finish(postKey, current, disposition, deliveryReceipt(evidence));
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const due = options.now() + backoffDelay(current.attempts, postRetryPolicy);
|
|
303
|
+
if (due !== current.nextAttemptAt) {
|
|
304
|
+
putRow(postKey, { ...current, nextAttemptAt: due });
|
|
305
|
+
retryAt = due;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
if (retryAt !== undefined)
|
|
310
|
+
await options.scheduleWake(retryAt);
|
|
311
|
+
return { kind: "completed" };
|
|
312
|
+
};
|
|
313
|
+
const enqueueDue = async (limit = 64, afterKey) => {
|
|
314
|
+
const pageSize = Schema.decodeUnknownSync(PageLimit)(limit);
|
|
315
|
+
const rows = store.list(pageSize, afterKey);
|
|
316
|
+
for (const stored of rows) {
|
|
317
|
+
const row = Schema.decodeUnknownSync(PostRowSchema)(stored.value);
|
|
318
|
+
if (row.state !== "pending" || row.jobId === undefined)
|
|
319
|
+
continue;
|
|
320
|
+
const work = await options.readJob(row.jobId);
|
|
321
|
+
if (work.status === "missing")
|
|
322
|
+
throw new PostError("delivery job is missing");
|
|
323
|
+
if (work.job.state !== "settled")
|
|
324
|
+
continue;
|
|
325
|
+
if (row.nextAttemptAt > options.now()) {
|
|
326
|
+
await options.scheduleWake(row.nextAttemptAt);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
store.commit(() => {
|
|
330
|
+
const current = readRow(row.envelope.key);
|
|
331
|
+
if (current?.state !== "pending" || current.jobId !== row.jobId)
|
|
332
|
+
return;
|
|
333
|
+
if (current.attempts >= maxDeliveryAttempts)
|
|
334
|
+
finish(current.envelope.key, current, {
|
|
335
|
+
state: "dead",
|
|
336
|
+
reason: "exhausted",
|
|
337
|
+
});
|
|
338
|
+
else
|
|
339
|
+
enqueueJob(current);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return rows.length === pageSize ? rows.at(-1)?.key : undefined;
|
|
343
|
+
};
|
|
344
|
+
const retry = async (input) => {
|
|
345
|
+
const key = Schema.decodeUnknownSync(Key)(input);
|
|
346
|
+
const row = readRow(key);
|
|
347
|
+
if (row === undefined || row.state === "delivered")
|
|
348
|
+
return;
|
|
349
|
+
const work = row.state === "pending" && row.jobId !== undefined
|
|
350
|
+
? await options.readJob(row.jobId)
|
|
351
|
+
: undefined;
|
|
352
|
+
if (work?.status === "missing")
|
|
353
|
+
throw new PostError("delivery job is missing");
|
|
354
|
+
if (work?.status === "found" && work.job.state !== "settled")
|
|
355
|
+
return;
|
|
356
|
+
store.commit(() => {
|
|
357
|
+
const current = readRow(key);
|
|
358
|
+
if (current === undefined || current.state === "delivered")
|
|
359
|
+
return;
|
|
360
|
+
if (current.state === "pending") {
|
|
361
|
+
if (row.state !== "pending" || current.jobId !== row.jobId)
|
|
362
|
+
return;
|
|
363
|
+
const pending = {
|
|
364
|
+
...current,
|
|
365
|
+
attempts: 0,
|
|
366
|
+
nextAttemptAt: options.now(),
|
|
367
|
+
};
|
|
368
|
+
putRow(key, pending);
|
|
369
|
+
if (current.jobId !== undefined)
|
|
370
|
+
enqueueJob(pending);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
const pending = {
|
|
374
|
+
state: "pending",
|
|
375
|
+
envelope: current.envelope,
|
|
376
|
+
attempts: 0,
|
|
377
|
+
nextAttemptAt: options.now(),
|
|
378
|
+
delivery: current.delivery,
|
|
379
|
+
};
|
|
380
|
+
putRow(key, pending);
|
|
381
|
+
const queue = queueOf(pending.envelope);
|
|
382
|
+
const previousKey = store.read(tailKey(queue));
|
|
383
|
+
const previous = previousKey === undefined || previousKey === key
|
|
384
|
+
? undefined
|
|
385
|
+
: readRow(previousKey);
|
|
386
|
+
if (previous?.state === "pending" && previousKey !== undefined)
|
|
387
|
+
putRow(previousKey, { ...previous, nextKey: key });
|
|
388
|
+
else
|
|
389
|
+
enqueueJob(pending);
|
|
390
|
+
store.put(tailKey(queue), key);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
await options.scheduleWake(options.now());
|
|
394
|
+
};
|
|
395
|
+
const deadLetters = (limit = 64, afterKey) => {
|
|
396
|
+
const pageSize = Schema.decodeUnknownSync(PageLimit)(limit);
|
|
397
|
+
const rows = store.list(pageSize, afterKey);
|
|
398
|
+
const letters = rows.flatMap(({ key, value }) => {
|
|
399
|
+
const row = Schema.decodeUnknownSync(PostRowSchema)(value);
|
|
400
|
+
return row.state === "dead"
|
|
401
|
+
? [
|
|
402
|
+
{
|
|
403
|
+
key,
|
|
404
|
+
reason: row.reason,
|
|
405
|
+
envelope: row.envelope,
|
|
406
|
+
attempts: row.attempts,
|
|
407
|
+
},
|
|
408
|
+
]
|
|
409
|
+
: [];
|
|
410
|
+
});
|
|
411
|
+
return {
|
|
412
|
+
letters,
|
|
413
|
+
nextCursor: rows.length === pageSize ? rows.at(-1)?.key : undefined,
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
return Object.freeze({
|
|
417
|
+
send,
|
|
418
|
+
latest,
|
|
419
|
+
retry,
|
|
420
|
+
execute,
|
|
421
|
+
enqueueDue,
|
|
422
|
+
deadLetters,
|
|
423
|
+
read: readRow,
|
|
424
|
+
nextWake,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/** Consumer admission runs inside the receiver's own SQLite transaction. */
|
|
428
|
+
export function admitPost(options) {
|
|
429
|
+
const key = Schema.decodeUnknownSync(Key)(options.key);
|
|
430
|
+
const event = Schema.decodeUnknownSync(options.schema)(options.event);
|
|
431
|
+
if (options.hasReceipt(key))
|
|
432
|
+
return "duplicate";
|
|
433
|
+
const result = options.apply(event);
|
|
434
|
+
if (result === "accepted")
|
|
435
|
+
options.putReceipt(key);
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
@@ -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;
|
package/dist/testing.js
ADDED
|
@@ -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) | - |
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
|
|
2
|
+
|
|
3
|
+
[**@fungi.computer/carrier**](README.md)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
[@fungi.computer/carrier](README.md) / cloudflare
|
|
8
|
+
|
|
9
|
+
# cloudflare
|
|
10
|
+
|
|
11
|
+
## Type Aliases
|
|
12
|
+
|
|
13
|
+
### CloudflareCarrierStorage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
type CloudflareCarrierStorage = WatchdogSqliteOwner &
|
|
17
|
+
Pick<
|
|
18
|
+
DurableObjectStorage,
|
|
19
|
+
"transaction" | "getAlarm" | "setAlarm" | "deleteAlarm"
|
|
20
|
+
>;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Native SQLite and alarms; the Cloudflare adapter owns their atomic composition.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### CloudflareCarrierOptions
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
type CloudflareCarrierOptions = Readonly<{
|
|
31
|
+
storage: CloudflareCarrierStorage;
|
|
32
|
+
canSend?: (emailId) => Promise<boolean>;
|
|
33
|
+
}> &
|
|
34
|
+
| Readonly<{
|
|
35
|
+
email: SendEmail;
|
|
36
|
+
transport?: never;
|
|
37
|
+
}>
|
|
38
|
+
| Readonly<{
|
|
39
|
+
transport: Options["transport"];
|
|
40
|
+
email?: never;
|
|
41
|
+
}>;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Native storage/wake composition with exactly one provider capability.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### CloudflareCarrier
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
type CloudflareCarrier = Pick<Carrier, "send" | "get"> &
|
|
52
|
+
Readonly<{
|
|
53
|
+
alarm: () => Promise<void>;
|
|
54
|
+
}>;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Ordinary submission operations and the native Durable Object alarm door.
|
|
58
|
+
|
|
59
|
+
## Functions
|
|
60
|
+
|
|
61
|
+
### createCloudflareCarrier()
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
function createCloudflareCarrier(options): Promise<CloudflareCarrier>;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Construct one owner per Durable Object. Forward its native alarm to `alarm`.
|
|
68
|
+
Acceptance commits the Email, recipient Messages, Watchdog job and wake
|
|
69
|
+
together.
|
|
70
|
+
|
|
71
|
+
#### Parameters
|
|
72
|
+
|
|
73
|
+
| Parameter | Type |
|
|
74
|
+
| --------- | ------------------------------------------------------- |
|
|
75
|
+
| `options` | [`CloudflareCarrierOptions`](#cloudflarecarrieroptions) |
|
|
76
|
+
|
|
77
|
+
#### Returns
|
|
78
|
+
|
|
79
|
+
`Promise`\<[`CloudflareCarrier`](#cloudflarecarrier)\>
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### cloudflareEmailTransport()
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
function cloudflareEmailTransport(binding): (email) => Promise<SendEvidence>;
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The native binding as a Carrier transport. Exported so a host that holds the
|
|
90
|
+
binding elsewhere (the Hub's Cloudflare edge) classifies sends identically.
|
|
91
|
+
|
|
92
|
+
#### Parameters
|
|
93
|
+
|
|
94
|
+
| Parameter | Type |
|
|
95
|
+
| --------- | ----------- |
|
|
96
|
+
| `binding` | `SendEmail` |
|
|
97
|
+
|
|
98
|
+
#### Returns
|
|
99
|
+
|
|
100
|
+
(`email`) => `Promise`\<[`SendEvidence`](index.md#sendevidence)\>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
|
|
2
|
+
|
|
3
|
+
[**@fungi.computer/carrier**](README.md)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
[@fungi.computer/carrier](README.md) / email
|
|
8
|
+
|
|
9
|
+
# email
|
|
10
|
+
|
|
11
|
+
## References
|
|
12
|
+
|
|
13
|
+
### CarrierError
|
|
14
|
+
|
|
15
|
+
Re-exports [CarrierError](index.md#carriererror)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
### CarrierStatusSchema
|
|
20
|
+
|
|
21
|
+
Re-exports [CarrierStatusSchema](index.md#carrierstatusschema)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### Address
|
|
26
|
+
|
|
27
|
+
Re-exports [Address](index.md#address)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
### Email
|
|
32
|
+
|
|
33
|
+
Re-exports [Email](index.md#email)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
### Host
|
|
38
|
+
|
|
39
|
+
Re-exports [Host](index.md#host)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
### Message
|
|
44
|
+
|
|
45
|
+
Re-exports [Message](index.md#message)
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
### Options
|
|
50
|
+
|
|
51
|
+
Re-exports [Options](index.md#options)
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### ProviderRejection
|
|
56
|
+
|
|
57
|
+
Re-exports [ProviderRejection](index.md#providerrejection)
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### Receipt
|
|
62
|
+
|
|
63
|
+
Re-exports [Receipt](index.md#receipt)
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
### SendEvidence
|
|
68
|
+
|
|
69
|
+
Re-exports [SendEvidence](index.md#sendevidence)
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
### Submission
|
|
74
|
+
|
|
75
|
+
Re-exports [Submission](index.md#submission)
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
### Carrier
|
|
80
|
+
|
|
81
|
+
Re-exports [Carrier](index.md#carrier)
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
### createCarrier
|
|
86
|
+
|
|
87
|
+
Re-exports [createCarrier](index.md#createcarrier)
|