@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,51 @@
1
+ import type { WatchdogRunningJob } from "@fungi.computer/watchdog";
2
+ import { type PostEnvelope, type PostRegistryRoute, type PostRow } from "../post.js";
3
+ export type Value = string | number | null;
4
+ export type Statement = Readonly<{
5
+ text: string;
6
+ values: readonly Value[];
7
+ }>;
8
+ export type Row = Record<string, Value>;
9
+ export type PostSql = Readonly<{
10
+ query: (statement: Statement) => Promise<readonly Row[]>;
11
+ commit: (statements: readonly Statement[], key: string, jobId: string) => Promise<void>;
12
+ }>;
13
+ /** SQL native Carrier runtime configuration; route decisions remain in Post. */
14
+ export type SqlPostOptions<R extends Record<string, PostRegistryRoute>> = Readonly<{
15
+ /** The same address/event registry used by SQLite Post. */
16
+ routes: R;
17
+ /** Optional bounded, redacted family diagnostic; never persist raw provider errors. */
18
+ failureDetail?: (cause: unknown) => string;
19
+ /** Family policy may tighten the twenty-attempt Post ceiling. */
20
+ retry?: Readonly<{
21
+ maxAttempts: number;
22
+ baseDelayMs: number;
23
+ maxDelayMs: number;
24
+ }>;
25
+ /** Clock shared with the database's Watchdog. */
26
+ now: () => number;
27
+ /** Arm a durable wake after committed progress. */
28
+ scheduleWake: (at: number) => Promise<void>;
29
+ }>;
30
+ /** Async inspection and Watchdog executor shared by D1 and PostgreSQL. */
31
+ export type SqlPostRuntime = Readonly<{
32
+ /** Execute an already claimed Post job through the registered receiver. */
33
+ execute: (job: WatchdogRunningJob, signal?: AbortSignal) => Promise<Readonly<{
34
+ kind: "completed";
35
+ }>>;
36
+ /** Recover a bounded page of settled attempts into new Watchdog jobs. */
37
+ enqueueDue: (limit?: number, afterKey?: string) => Promise<string | undefined>;
38
+ /** Read one producer receipt or pending envelope. */
39
+ read: (key: string) => Promise<PostRow | undefined>;
40
+ }>;
41
+ export declare function postSchema(postgres: boolean): readonly string[];
42
+ export declare function admitPostSelect(source: Statement): Statement;
43
+ export declare function jobSelect(key: string, postgres: boolean, expectedJobId?: string): Statement;
44
+ export declare function preparePost<R extends Record<string, PostRegistryRoute>>(options: SqlPostOptions<R>, input: PostEnvelope): {
45
+ key: string;
46
+ statement: Readonly<{
47
+ text: string;
48
+ values: readonly Value[];
49
+ }>;
50
+ };
51
+ export declare function openSqlPost<R extends Record<string, PostRegistryRoute>>(database: PostSql, options: SqlPostOptions<R>): SqlPostRuntime;
@@ -0,0 +1,255 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { defineIdFamily } from "@fungi.computer/cairn";
3
+ import { PostEnvelopeSchema, PostError, } from "../post.js";
4
+ import { deliverPost, postDisposition, deliveryReceipt, } from "./post-execution.js";
5
+ import { maxDeliveryAttempts, retryDelay } from "../post-outbox.js";
6
+ const ids = defineIdFamily({
7
+ family: "post",
8
+ version: 1,
9
+ kinds: { work: { prefix: "post_work" } },
10
+ });
11
+ const EnvelopeJson = Schema.fromJsonString(PostEnvelopeSchema);
12
+ const ReceiptJson = Schema.fromJsonString(Schema.Json);
13
+ const Stored = Schema.Struct({
14
+ post_key: Schema.String,
15
+ envelope_json: Schema.String,
16
+ queue_name: Schema.String,
17
+ state: Schema.Literals(["pending", "delivered", "dead"]),
18
+ attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
19
+ next_attempt_at: Schema.Number,
20
+ job_id: Schema.String,
21
+ delivery_json: Schema.NullOr(Schema.String),
22
+ receipt_json: Schema.NullOr(ReceiptJson),
23
+ reason: Schema.NullOr(Schema.Literals(["refused", "exhausted"])),
24
+ });
25
+ const columns = "post_key,envelope_json,queue_name,state,attempts,next_attempt_at,job_id,delivery_json,reason,receipt_json";
26
+ const JobReference = Schema.Struct({
27
+ source_job: Schema.NullOr(Schema.String),
28
+ });
29
+ const pageSize = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(64));
30
+ export function postSchema(postgres) {
31
+ return [
32
+ `CREATE TABLE IF NOT EXISTS carrier_post_rows (
33
+ sequence ${postgres ? "SERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT"},
34
+ post_key TEXT NOT NULL UNIQUE CHECK(length(post_key) BETWEEN 1 AND 256),
35
+ envelope_json TEXT NOT NULL,queue_name TEXT NOT NULL,
36
+ state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','delivered','dead')),
37
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0),
38
+ next_attempt_at ${postgres ? "DOUBLE PRECISION" : "INTEGER"} NOT NULL,
39
+ job_id TEXT NOT NULL,delivery_json TEXT,
40
+ reason TEXT CHECK(reason IN ('refused','exhausted')),
41
+ receipt_json TEXT,
42
+ settled_at ${postgres ? "DOUBLE PRECISION" : "INTEGER"},
43
+ last_error TEXT CHECK(last_error IS NULL OR length(last_error)<=2048),
44
+ attempt_until ${postgres ? "DOUBLE PRECISION" : "INTEGER"}
45
+ )`,
46
+ "CREATE INDEX IF NOT EXISTS idx_post_due ON carrier_post_rows(state,next_attempt_at,post_key)",
47
+ "CREATE INDEX IF NOT EXISTS idx_post_address ON carrier_post_rows(queue_name,state,sequence)",
48
+ ];
49
+ }
50
+ export function admitPostSelect(source) {
51
+ return {
52
+ text: `INSERT INTO carrier_post_rows(post_key,envelope_json,queue_name,next_attempt_at,job_id)
53
+ SELECT post_key,envelope_json,queue_name,next_attempt_at,job_id FROM (${source.text}) AS admission WHERE true
54
+ ON CONFLICT(post_key) DO UPDATE SET post_key=NULL
55
+ WHERE carrier_post_rows.envelope_json<>excluded.envelope_json OR carrier_post_rows.queue_name<>excluded.queue_name`,
56
+ values: source.values,
57
+ };
58
+ }
59
+ export function jobSelect(key, postgres, expectedJobId) {
60
+ const bind = postgres ? "$" : "?";
61
+ const payload = postgres
62
+ ? "json_build_object('postKey',post_key)::text"
63
+ : "json_object('postKey',post_key)";
64
+ return {
65
+ text: `SELECT job_id,queue_name,'post' AS lane,0 AS priority,${payload} AS payload_json,
66
+ CAST(NULL AS INTEGER) AS recovery_max FROM carrier_post_rows
67
+ WHERE post_key=${bind}1 AND state='pending' ${expectedJobId === undefined ? "" : `AND job_id=${bind}2`}`,
68
+ values: expectedJobId === undefined ? [key] : [key, expectedJobId],
69
+ };
70
+ }
71
+ export function preparePost(options, input) {
72
+ const incoming = Schema.decodeUnknownSync(PostEnvelopeSchema)(input);
73
+ const route = options.routes[incoming.kind];
74
+ if (route === undefined)
75
+ throw new PostError("unknown address kind");
76
+ const envelope = route.encodeEnvelope(incoming);
77
+ const queue = JSON.stringify([route.addressKind, route.nameOf(envelope)]);
78
+ const jobId = ids.work.generate();
79
+ return {
80
+ key: envelope.key,
81
+ statement: admitPostSelect({
82
+ text: `SELECT CAST($1 AS TEXT) AS post_key,CAST($2 AS TEXT) AS envelope_json,
83
+ CAST($3 AS TEXT) AS queue_name,CAST($4 AS DOUBLE PRECISION) AS next_attempt_at,CAST($5 AS TEXT) AS job_id`,
84
+ values: [
85
+ envelope.key,
86
+ Schema.encodeSync(EnvelopeJson)(envelope),
87
+ queue,
88
+ options.now(),
89
+ jobId,
90
+ ],
91
+ }),
92
+ };
93
+ }
94
+ function parseStored(raw) {
95
+ const stored = Schema.decodeUnknownSync(Stored)(raw);
96
+ const envelope = Schema.decodeUnknownSync(EnvelopeJson)(stored.envelope_json);
97
+ const delivery = stored.delivery_json === null
98
+ ? undefined
99
+ : Schema.decodeUnknownSync(EnvelopeJson)(stored.delivery_json);
100
+ return { ...stored, envelope, delivery };
101
+ }
102
+ function publicRow(stored) {
103
+ const receipt = stored.receipt_json;
104
+ if (stored.state === "delivered")
105
+ return receipt === null
106
+ ? { state: "delivered" }
107
+ : { state: "delivered", receipt };
108
+ if (stored.state === "dead") {
109
+ if (stored.reason === null)
110
+ throw new PostError("dead letter has no reason");
111
+ const dead = {
112
+ state: "dead",
113
+ envelope: stored.envelope,
114
+ attempts: stored.attempts,
115
+ reason: stored.reason,
116
+ delivery: stored.delivery,
117
+ };
118
+ return receipt === null ? dead : { ...dead, receipt };
119
+ }
120
+ return {
121
+ state: "pending",
122
+ envelope: stored.envelope,
123
+ attempts: stored.attempts,
124
+ nextAttemptAt: stored.next_attempt_at,
125
+ jobId: stored.job_id,
126
+ delivery: stored.delivery,
127
+ };
128
+ }
129
+ export function openSqlPost(database, options) {
130
+ const policy = Schema.decodeUnknownSync(Schema.Struct({
131
+ maxAttempts: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: maxDeliveryAttempts })),
132
+ baseDelayMs: Schema.Int.check(Schema.isGreaterThan(0)),
133
+ maxDelayMs: Schema.Int.check(Schema.isGreaterThan(0)),
134
+ }))(options.retry ?? {
135
+ maxAttempts: maxDeliveryAttempts,
136
+ baseDelayMs: retryDelay(1),
137
+ maxDelayMs: retryDelay(maxDeliveryAttempts),
138
+ });
139
+ const delay = (attempt) => Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempt - 1), policy.maxDelayMs);
140
+ const retrySql = Array.from({ length: policy.maxAttempts }, (_, index) => `WHEN ${String(index)} THEN ${String(delay(index + 1))}`).join(" ");
141
+ for (const route of Object.values(options.routes)) {
142
+ // Synchronous owner hooks cannot participate in an asynchronous transaction.
143
+ if (route.recordDelivered !== undefined ||
144
+ route.recordRefused !== undefined ||
145
+ route.recordCursor !== undefined)
146
+ throw new PostError("SQL Post requires transaction-bound receipt projections");
147
+ }
148
+ const query = (text, values = []) => database.query({ text, values });
149
+ return {
150
+ read: async (key) => {
151
+ const raw = (await query(`SELECT ${columns} FROM carrier_post_rows WHERE post_key=$1`, [key]))[0];
152
+ return raw === undefined ? undefined : publicRow(parseStored(raw));
153
+ },
154
+ execute: async (job, signal) => {
155
+ const { postKey } = Schema.decodeUnknownSync(Schema.Struct({ postKey: Schema.String }))(job.payload);
156
+ const raw = (await query(`UPDATE carrier_post_rows SET attempts=attempts+1,
157
+ next_attempt_at=CAST($1 AS DOUBLE PRECISION)+CASE attempts ${retrySql} ELSE ${String(delay(policy.maxAttempts))} END,attempt_until=$2 WHERE post_key=$3 AND job_id=$4 AND state='pending'
158
+ AND attempts<$5 AND NOT EXISTS (SELECT 1 FROM carrier_post_rows AS earlier
159
+ WHERE earlier.queue_name=carrier_post_rows.queue_name AND earlier.state='pending'
160
+ AND earlier.sequence<carrier_post_rows.sequence) RETURNING ${columns}`, [
161
+ options.now(),
162
+ options.now() + 10_000,
163
+ postKey,
164
+ job.jobId,
165
+ policy.maxAttempts,
166
+ ]))[0];
167
+ if (raw === undefined)
168
+ return { kind: "completed" };
169
+ const stored = parseStored(raw);
170
+ const attempt = publicRow(stored);
171
+ if (attempt.state !== "pending")
172
+ throw new PostError("attempt is not pending");
173
+ const route = options.routes[attempt.envelope.kind];
174
+ if (route === undefined)
175
+ throw new PostError("unknown address kind");
176
+ await options.scheduleWake(attempt.nextAttemptAt);
177
+ const evidence = await deliverPost({
178
+ signal,
179
+ attempt,
180
+ route,
181
+ savePrepared: async (envelope) => {
182
+ const saved = await query(`UPDATE carrier_post_rows SET delivery_json=$1
183
+ WHERE post_key=$2 AND job_id=$3 AND attempts=$4 AND state='pending' AND attempt_until>$5 RETURNING post_key`, [
184
+ Schema.encodeSync(EnvelopeJson)(envelope),
185
+ postKey,
186
+ job.jobId,
187
+ attempt.attempts,
188
+ options.now(),
189
+ ]);
190
+ if (saved.length === 0)
191
+ throw new PostError("preparation superseded");
192
+ },
193
+ });
194
+ const disposition = postDisposition(evidence, route, attempt.attempts, policy.maxAttempts);
195
+ if (disposition.state === "keep")
196
+ return { kind: "completed" };
197
+ const next = options.now() + delay(attempt.attempts);
198
+ const receipt = deliveryReceipt(evidence);
199
+ await query(`UPDATE carrier_post_rows SET state=$1,reason=$2,next_attempt_at=$3,attempt_until=NULL,last_error=$8,receipt_json=$9,settled_at=CASE WHEN $1='pending' THEN NULL ELSE CAST($10 AS DOUBLE PRECISION) END
200
+ WHERE post_key=$4 AND job_id=$5 AND attempts=$6 AND state='pending' AND attempt_until>$7`, [
201
+ disposition.state,
202
+ disposition.state === "dead" ? disposition.reason : null,
203
+ next,
204
+ postKey,
205
+ job.jobId,
206
+ attempt.attempts,
207
+ options.now(),
208
+ evidence.result === "unavailable" &&
209
+ options.failureDetail !== undefined
210
+ ? options.failureDetail(evidence.failure).slice(0, 2048)
211
+ : null,
212
+ receipt === undefined
213
+ ? null
214
+ : Schema.encodeSync(ReceiptJson)(receipt),
215
+ options.now(),
216
+ ]);
217
+ await options.scheduleWake(disposition.state === "pending" ? next : options.now());
218
+ return { kind: "completed" };
219
+ },
220
+ enqueueDue: async (limit = 64, afterKey = "") => {
221
+ Schema.decodeUnknownSync(pageSize)(limit);
222
+ const rows = await query(`SELECT ${columns
223
+ .split(",")
224
+ .map((column) => `p.${column}`)
225
+ .join(",")},w.job_id AS source_job
226
+ FROM carrier_post_rows p LEFT JOIN watchdog_jobs w ON p.job_id=w.job_id
227
+ WHERE p.state='pending' AND (w.job_id IS NULL OR (w.state='settled' AND p.next_attempt_at<=$1)) AND p.post_key>$2
228
+ ORDER BY p.post_key LIMIT $3`, [options.now(), afterKey, limit]);
229
+ let cursor;
230
+ for (const raw of rows) {
231
+ if (Schema.decodeUnknownSync(JobReference)(raw).source_job === null)
232
+ throw new PostError("delivery job is missing");
233
+ const row = parseStored(raw);
234
+ cursor = row.post_key;
235
+ const nextJobId = ids.work.generate();
236
+ await database.commit([
237
+ {
238
+ text: `UPDATE carrier_post_rows SET job_id=$1,state=CASE WHEN attempts>=$2 THEN 'dead' ELSE 'pending' END,
239
+ reason=CASE WHEN attempts>=$2 THEN 'exhausted' ELSE NULL END,
240
+ settled_at=CASE WHEN attempts>=$2 THEN CAST($5 AS DOUBLE PRECISION) ELSE NULL END
241
+ WHERE post_key=$3 AND job_id=$4 AND state='pending' AND next_attempt_at<=$5`,
242
+ values: [
243
+ nextJobId,
244
+ policy.maxAttempts,
245
+ row.post_key,
246
+ row.job_id,
247
+ options.now(),
248
+ ],
249
+ },
250
+ ], row.post_key, nextJobId);
251
+ }
252
+ return rows.length === limit ? cursor : undefined;
253
+ },
254
+ };
255
+ }
@@ -0,0 +1,3 @@
1
+ import * as Schema from "effect/Schema";
2
+ /** Carrier's aggregate dispatch states at both storage and read boundaries. */
3
+ export declare const CarrierStatusSchema: Schema.Literals<readonly ["queued", "dispatching", "provider_accepted", "outcome_unknown", "rejected", "refused", "expired", "superseded"]>;
@@ -0,0 +1,12 @@
1
+ import * as Schema from "effect/Schema";
2
+ /** Carrier's aggregate dispatch states at both storage and read boundaries. */
3
+ export const CarrierStatusSchema = Schema.Literals([
4
+ "queued",
5
+ "dispatching",
6
+ "provider_accepted",
7
+ "outcome_unknown",
8
+ "rejected",
9
+ "refused",
10
+ "expired",
11
+ "superseded",
12
+ ]);
@@ -0,0 +1,36 @@
1
+ import type { D1Database, D1PreparedStatement } from "@cloudflare/workers-types";
2
+ import { type D1Watchdog } from "@fungi.computer/watchdog/d1";
3
+ import type { PostEnvelope, PostRegistryRoute } from "./post.js";
4
+ import { type SqlPostOptions, type SqlPostRuntime } from "./internal/post-sql.js";
5
+ export type { SqlPostOptions, SqlPostRuntime } from "./internal/post-sql.js";
6
+ /** Trusted SELECT yielding post_key, envelope_json, queue_name, next_attempt_at and job_id. */
7
+ export type D1PostSource = Readonly<{
8
+ /** Trusted native SELECT text with SQLite ?NNN bindings. */
9
+ text: string;
10
+ /** Scalar values bound by the native driver. */
11
+ values: readonly (string | number | null)[];
12
+ }>;
13
+ /** Post admission statements; callers commit them with the originating D1 fact. */
14
+ export type D1Post = SqlPostRuntime & Readonly<{
15
+ /** Prepare envelope and Watchdog admission without performing I/O. */
16
+ send: (envelope: PostEnvelope) => readonly D1PreparedStatement[];
17
+ /** Admit conditionally generated source facts entirely inside the producer batch. */
18
+ sendFromSelect: (source: D1PostSource) => readonly D1PreparedStatement[];
19
+ }>;
20
+ /** Carrier-owned D1 schema, applied by the deployment migration owner. */
21
+ export declare const postD1Schema: readonly string[];
22
+ /** Bind the Post registry to native D1 batch admission and the shared delivery engine. */
23
+ export declare function createD1Post<R extends Record<string, PostRegistryRoute>>(options: SqlPostOptions<R> & Readonly<{
24
+ database: D1Database;
25
+ watchdog: D1Watchdog;
26
+ }>): D1Post;
27
+ /** Prepare conditional envelopes and their jobs inside the producer's D1 batch. */
28
+ export declare function prepareD1PostAdmission(database: D1Database, source: D1PostSource): readonly D1PreparedStatement[];
29
+ /** Native erasure statement; its values remain bound by the caller's D1 driver. */
30
+ export type D1PostErasureStatement = Readonly<{
31
+ text: string;
32
+ values: readonly (string | number | null)[];
33
+ }>;
34
+ /** Remove selected recipient queues, including every envelope and attempt, atomically.
35
+ * The trusted SELECT yields queue_name from producer/envelope facts, not job state. */
36
+ export declare function purgeD1Post(source: D1PostErasureStatement): readonly D1PostErasureStatement[];
@@ -0,0 +1,55 @@
1
+ import { purgeD1Queues, prepareD1Admission, } from "@fungi.computer/watchdog/d1";
2
+ import { admitPostSelect, jobSelect, openSqlPost, postSchema, preparePost, } from "./internal/post-sql.js";
3
+ /** Carrier-owned D1 schema, applied by the deployment migration owner. */
4
+ export const postD1Schema = postSchema(false);
5
+ /** Bind the Post registry to native D1 batch admission and the shared delivery engine. */
6
+ export function createD1Post(options) {
7
+ // Only package-owned templates use $N; caller SELECTs remain native SQL.
8
+ const prepare = (statement) => options.database
9
+ .prepare(statement.text.replace(/\$(\d+)/gu, "?$1"))
10
+ .bind(...statement.values);
11
+ const runtime = openSqlPost({
12
+ query: async (statement) => (await prepare(statement).all()).results,
13
+ commit: async (statements, key, jobId) => {
14
+ await options.database.batch([
15
+ ...statements.map(prepare),
16
+ options.watchdog.enqueueFromSelect(jobSelect(key, false, jobId)),
17
+ ]);
18
+ },
19
+ }, options);
20
+ return {
21
+ ...runtime,
22
+ send: (envelope) => {
23
+ const prepared = preparePost(options, envelope);
24
+ return [
25
+ prepare(prepared.statement),
26
+ options.watchdog.enqueueFromSelect(jobSelect(prepared.key, false)),
27
+ ];
28
+ },
29
+ sendFromSelect: (source) => prepareD1PostAdmission(options.database, source),
30
+ };
31
+ }
32
+ /** Prepare conditional envelopes and their jobs inside the producer's D1 batch. */
33
+ export function prepareD1PostAdmission(database, source) {
34
+ return [
35
+ database.prepare(admitPostSelect(source).text).bind(...source.values),
36
+ prepareD1Admission(database).enqueueFromSelect({
37
+ text: `SELECT p.job_id,p.queue_name,'post' AS lane,0 AS priority,
38
+ json_object('postKey',p.post_key) AS payload_json,CAST(NULL AS INTEGER) AS recovery_max
39
+ FROM carrier_post_rows p JOIN (${source.text}) AS source ON p.post_key=source.post_key
40
+ WHERE p.state='pending'`,
41
+ values: source.values,
42
+ }),
43
+ ];
44
+ }
45
+ /** Remove selected recipient queues, including every envelope and attempt, atomically.
46
+ * The trusted SELECT yields queue_name from producer/envelope facts, not job state. */
47
+ export function purgeD1Post(source) {
48
+ return [
49
+ ...purgeD1Queues(source),
50
+ {
51
+ text: `DELETE FROM carrier_post_rows WHERE queue_name IN (SELECT queue_name FROM (${source.text}) AS erasure)`,
52
+ values: source.values,
53
+ },
54
+ ];
55
+ }
@@ -0,0 +1,40 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ /** Maximum receiver attempts retained from BirdDog. */
4
+ export declare const maxDeliveryAttempts = 20;
5
+ /** BirdDog's retained bounded retry schedule. */
6
+ export declare const retryDelay: (attempts: number) => number;
7
+ /** Durable, string-backed rows. A delivered receipt must outlive source replay. */
8
+ export type PostOutboxStore<E> = Readonly<{
9
+ /** Commit one pending row or completion receipt. */
10
+ put: (key: string, value: string) => Effect.Effect<void, E>;
11
+ /** Inspect one durable row by key. */
12
+ read: (key: string) => Effect.Effect<string | undefined, E>;
13
+ /** Read an ordered bounded page. */
14
+ list: (limit: number, afterKey?: string) => Effect.Effect<readonly Readonly<{
15
+ key: string;
16
+ value: string;
17
+ }>[], E>;
18
+ /** Discard one malformed row. */
19
+ delete: (key: string) => Effect.Effect<void, E>;
20
+ }>;
21
+ /** Bounded generic delivery owned by Post. */
22
+ export type PostOutbox<M, E> = Readonly<{
23
+ /** Queue a message and return its wake hint so a fan-out can schedule once. */
24
+ enqueue: (message: M, at?: number) => Effect.Effect<number | undefined, E>;
25
+ /** Attempt one bounded page and arrange the next wake. */
26
+ drain: () => Effect.Effect<void, E>;
27
+ /** Inspect exhausted messages in an ordered page. */
28
+ deadLetters: (limit: number, afterKey?: string) => Effect.Effect<M[], E>;
29
+ }>;
30
+ /** The same bounded delivery and receipt machinery serves Post and BirdDog. */
31
+ export declare function makePostOutbox<M, E>(options: Readonly<{
32
+ message: Schema.Codec<M>;
33
+ key: (message: M) => string;
34
+ outbox: PostOutboxStore<E>;
35
+ deliver: (message: M) => Effect.Effect<boolean, E>;
36
+ scheduleWake: (at: number) => Effect.Effect<void, E>;
37
+ now: () => number;
38
+ invalidRow: () => E;
39
+ encodingFailed: () => E;
40
+ }>): PostOutbox<M, E>;
@@ -0,0 +1,99 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ const PageSize = 64;
4
+ /** Maximum receiver attempts retained from BirdDog. */
5
+ export const maxDeliveryAttempts = 20;
6
+ /** BirdDog's retained bounded retry schedule. */
7
+ export const retryDelay = (attempts) => Math.min(2_000 * 2 ** Math.max(0, attempts - 1), 300_000);
8
+ const earlier = (a, b) => b === undefined ? a : a === undefined ? b : Math.min(a, b);
9
+ const logDeliveryFailure = (cause) => {
10
+ console.error("Carrier Post outbox delivery failed", {
11
+ failureKind: cause instanceof Error ? "Error" : "NonErrorThrown",
12
+ });
13
+ };
14
+ /** The same bounded delivery and receipt machinery serves Post and BirdDog. */
15
+ export function makePostOutbox(options) {
16
+ const Pending = Schema.Struct({
17
+ mail: options.message,
18
+ attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
19
+ nextAttemptAt: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))),
20
+ });
21
+ const Row = Schema.Union([
22
+ Pending,
23
+ Schema.Struct({ state: Schema.Literal("delivered") }),
24
+ ]);
25
+ const Text = Schema.fromJsonString(Row);
26
+ const decode = (value) => Schema.decodeUnknownEffect(Text)(value).pipe(Effect.mapError(options.invalidRow));
27
+ const encode = (row) => Effect.try({
28
+ try: () => Schema.encodeSync(Text)(row),
29
+ catch: options.encodingFailed,
30
+ });
31
+ const enqueue = (message, at = options.now()) => Effect.gen(function* () {
32
+ const key = options.key(message);
33
+ const existing = yield* options.outbox.read(key);
34
+ if (existing !== undefined) {
35
+ const row = yield* decode(existing);
36
+ if ("state" in row || row.attempts >= maxDeliveryAttempts)
37
+ return;
38
+ return Math.max(at, row.nextAttemptAt ?? at);
39
+ }
40
+ const value = yield* encode({ mail: message, attempts: 0 });
41
+ yield* options.outbox.put(key, value);
42
+ return at;
43
+ });
44
+ const process = (stored, now) => Effect.gen(function* () {
45
+ const decoded = yield* Effect.result(decode(stored.value));
46
+ if (decoded._tag === "Failure") {
47
+ yield* options.outbox.delete(stored.key);
48
+ return undefined;
49
+ }
50
+ const row = decoded.success;
51
+ if ("state" in row || row.attempts >= maxDeliveryAttempts)
52
+ return undefined;
53
+ if (row.nextAttemptAt !== undefined && row.nextAttemptAt > now)
54
+ return row.nextAttemptAt;
55
+ const delivered = yield* options.deliver(row.mail).pipe(Effect.catch((cause) => Effect.sync(() => {
56
+ logDeliveryFailure(cause);
57
+ }).pipe(Effect.as(false))));
58
+ if (delivered) {
59
+ yield* options.outbox.put(stored.key, yield* encode({ state: "delivered" }));
60
+ return undefined;
61
+ }
62
+ const attempts = row.attempts + 1;
63
+ const nextAttemptAt = now + retryDelay(attempts);
64
+ yield* options.outbox.put(stored.key, yield* encode({ mail: row.mail, attempts, nextAttemptAt }));
65
+ return attempts < maxDeliveryAttempts ? nextAttemptAt : undefined;
66
+ });
67
+ let cursor;
68
+ let earliestFuture;
69
+ const drain = () => Effect.gen(function* () {
70
+ const now = options.now();
71
+ const rows = yield* options.outbox.list(PageSize, cursor);
72
+ for (const row of rows)
73
+ earliestFuture = earlier(earliestFuture, yield* process(row, now));
74
+ if (rows.length === PageSize) {
75
+ const lastKey = rows[rows.length - 1]?.key;
76
+ if (lastKey !== undefined &&
77
+ (yield* options.outbox.list(1, lastKey)).length > 0) {
78
+ cursor = lastKey;
79
+ yield* options.scheduleWake(now);
80
+ return;
81
+ }
82
+ }
83
+ cursor = undefined;
84
+ if (earliestFuture !== undefined)
85
+ yield* options.scheduleWake(Math.max(now, earliestFuture));
86
+ earliestFuture = undefined;
87
+ });
88
+ const deadLetters = (limit, afterKey) => Effect.gen(function* () {
89
+ const rows = yield* options.outbox.list(limit, afterKey);
90
+ const dead = [];
91
+ for (const stored of rows) {
92
+ const row = yield* decode(stored.value);
93
+ if (!("state" in row) && row.attempts >= maxDeliveryAttempts)
94
+ dead.push(row.mail);
95
+ }
96
+ return dead;
97
+ });
98
+ return { enqueue, drain, deadLetters };
99
+ }
@@ -0,0 +1,25 @@
1
+ import type { Pool, PoolClient } from "pg";
2
+ import { type PostgresWatchdog } from "@fungi.computer/watchdog/postgres";
3
+ import type { PostEnvelope, PostRegistryRoute } from "./post.js";
4
+ import { type SqlPostOptions, type SqlPostRuntime } from "./internal/post-sql.js";
5
+ export type { SqlPostOptions, SqlPostRuntime } from "./internal/post-sql.js";
6
+ /** Post on PostgreSQL; the producer retains ownership of BEGIN and COMMIT. */
7
+ export type PostgresPost = SqlPostRuntime & Readonly<{
8
+ /** Persist the envelope and job using the already checked-out transaction client. */
9
+ send: (client: Pick<PoolClient, "query">, envelope: PostEnvelope) => Promise<void>;
10
+ }>;
11
+ /** Carrier-owned PostgreSQL schema, applied by the deployment migration owner. */
12
+ export declare const postPostgresSchema: readonly string[];
13
+ /** Bind the Post registry to a PostgreSQL owner and the shared delivery engine. */
14
+ export declare function createPostgresPost<R extends Record<string, PostRegistryRoute>>(options: SqlPostOptions<R> & Readonly<{
15
+ database: Pick<Pool, "query" | "connect">;
16
+ watchdog: PostgresWatchdog;
17
+ }>): PostgresPost;
18
+ /** Parameterized native erasure; the producer controls BEGIN and COMMIT. */
19
+ export type PostgresPostErasureStatement = Readonly<{
20
+ text: string;
21
+ values: readonly (string | number | null)[];
22
+ }>;
23
+ /** Purge selected recipient queues, including all envelopes and attempts.
24
+ * The trusted SELECT yields queue_name from producer/envelope facts, not job state. */
25
+ export declare function purgePostgresPost(source: PostgresPostErasureStatement): readonly PostgresPostErasureStatement[];
@@ -0,0 +1,79 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { purgePostgresQueues, } from "@fungi.computer/watchdog/postgres";
3
+ import { jobSelect, openSqlPost, postSchema, preparePost, } from "./internal/post-sql.js";
4
+ /** Carrier-owned PostgreSQL schema, applied by the deployment migration owner. */
5
+ export const postPostgresSchema = postSchema(true);
6
+ const Job = Schema.Struct({
7
+ job_id: Schema.String,
8
+ queue_name: Schema.String,
9
+ lane: Schema.String,
10
+ priority: Schema.Number,
11
+ payload_json: Schema.fromJsonString(Schema.Unknown),
12
+ });
13
+ /** Bind the Post registry to a PostgreSQL owner and the shared delivery engine. */
14
+ export function createPostgresPost(options) {
15
+ const enqueue = async (client, source) => {
16
+ const native = source;
17
+ const rows = (await client.query(native.text, [...native.values]))
18
+ .rows;
19
+ for (const raw of rows) {
20
+ const job = Schema.decodeUnknownSync(Job)(raw);
21
+ await options.watchdog.enqueue(client, {
22
+ jobId: job.job_id,
23
+ queue: job.queue_name,
24
+ lane: job.lane,
25
+ priority: job.priority,
26
+ payload: job.payload_json,
27
+ state: "queued",
28
+ });
29
+ }
30
+ };
31
+ const runtime = openSqlPost({
32
+ query: async (statement) => (await options.database.query(statement.text, [
33
+ ...statement.values,
34
+ ])).rows,
35
+ commit: async (statements, key, jobId) => {
36
+ const client = await options.database.connect();
37
+ try {
38
+ await client.query("BEGIN");
39
+ for (const statement of statements)
40
+ await client.query(statement.text, [...statement.values]);
41
+ await enqueue(client, jobSelect(key, true, jobId));
42
+ await client.query("COMMIT");
43
+ }
44
+ catch (error) {
45
+ await client.query("ROLLBACK");
46
+ throw error;
47
+ }
48
+ finally {
49
+ client.release();
50
+ }
51
+ },
52
+ }, options);
53
+ return {
54
+ ...runtime,
55
+ send: async (client, envelope) => {
56
+ const prepared = preparePost(options, envelope);
57
+ await client.query(prepared.statement.text, [
58
+ ...prepared.statement.values,
59
+ ]);
60
+ await enqueue(client, jobSelect(prepared.key, true));
61
+ },
62
+ };
63
+ }
64
+ /** Purge selected recipient queues, including all envelopes and attempts.
65
+ * The trusted SELECT yields queue_name from producer/envelope facts, not job state. */
66
+ export function purgePostgresPost(source) {
67
+ return [
68
+ // Serialize erasure with send/retry before taking the jobs' deletion snapshot.
69
+ {
70
+ text: `UPDATE carrier_post_rows SET post_key=post_key WHERE queue_name IN (SELECT queue_name FROM (${source.text}) AS erasure)`,
71
+ values: source.values,
72
+ },
73
+ ...purgePostgresQueues(source),
74
+ {
75
+ text: `DELETE FROM carrier_post_rows WHERE queue_name IN (SELECT queue_name FROM (${source.text}) AS erasure)`,
76
+ values: source.values,
77
+ },
78
+ ];
79
+ }