@fungi.computer/carrier 0.0.1 → 0.2.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/README.md CHANGED
@@ -97,7 +97,8 @@ In a Durable Object, `ctx.storage` is already the SQLite owner.
97
97
  `createMemoryOutbox()` stores encoded string rows in process memory for tests
98
98
  and quick starts. It provides `put`, `read`, key-ordered `list(limit, afterKey)`
99
99
  and `delete`. Each instance is independent; it is not durable storage. BirdDog’s
100
- [quick start](../bird-dog/README.md#quick-start) uses this helper.
100
+ [quick start](https://docs.fungi.computer/bird-dog/quickstart/) uses this
101
+ helper.
101
102
 
102
103
  ```ts
103
104
  import { createMemoryOutbox } from "@fungi.computer/carrier/testing";
@@ -214,14 +215,26 @@ ambiguous first dispatch has zero. Post `attempts` counts receiver invocations.
214
215
 
215
216
  ## Post
216
217
 
218
+ Post's ordinary retry policy uses Almanac's shared pure backoff calculation: 2
219
+ seconds initially, doubling to a 5-minute cap, with at most 20 receiver
220
+ attempts. The synchronous and SQL adapters share that default policy; explicit
221
+ SQL retry policies retain their own delays. A SQLite route with `deadlineMs`
222
+ uses its finite lifetime instead of the attempt ceiling. Almanac supplies
223
+ arithmetic only, not a second scheduler or retry owner.
224
+
217
225
  `@fungi.computer/carrier/post` delivers typed, point-to-point envelopes between
218
226
  named Durable Objects. `definePostRoute` pairs each address schema and event
219
227
  schema with its Durable Object namespace, name function and fixed `addressKind`
220
- label. Every event route to the same namespace shares that label, so different
221
- event kinds for one recipient keep producer order. The receiver implements
222
- `deliver(envelope)` and calls `admitPost` inside its own SQLite transaction, so
223
- the event mutation and admission receipt commit together. Keys must identify the
224
- producing event across all senders to that receiver.
228
+ label. A SQLite-backed route may opt into a retry lifetime of at most seven days
229
+ with `deadlineMs`; Post stores its absolute deadline at admission and settles an
230
+ undelivered row as an `expired` dead letter when the deadline is reached.
231
+ Deadline routes retry until expiry rather than using the ordinary attempt
232
+ ceiling. Omitted deadlines preserve the existing retry budget. Every event route
233
+ to the same namespace shares that label, so different event kinds for one
234
+ recipient keep producer order. The receiver implements `deliver(envelope)` and
235
+ calls `admitPost` inside its own SQLite transaction, so the event mutation and
236
+ admission receipt commit together. Keys must identify the producing event across
237
+ all senders to that receiver.
225
238
 
226
239
  Construct `createPost` with the producer's structural SQLite `owner`, typed
227
240
  `routes`, Watchdog's `transactional` projection and `readJob` operation, a
@@ -258,20 +271,29 @@ acknowledge a domain ledger in the same producer transaction as the Post
258
271
  receipt. An acknowledgment failure leaves both pending for retry. Prepared facts
259
272
  should carry sealed grants, never raw credentials.
260
273
 
261
- A definite refusal or an exhausted retry budget keeps a dead-letter envelope,
262
- its reason and its attempt count. `deadLetters` returns `letters` and
263
- `nextCursor`, including a continuation through pages that contain only receipts.
274
+ A definite refusal, an exhausted retry budget or a route deadline keeps a
275
+ dead-letter envelope, its reason and its attempt count. `deadLetters` returns
276
+ `letters` and `nextCursor`, including a continuation through pages that contain
277
+ only receipts.
264
278
 
265
279
  The same retry and receipt machinery is exported as `makePostOutbox` for a
266
280
  string-backed outbox. [BirdDog](../bird-dog/README.md) uses it for agent reply
267
- delivery.
281
+ delivery. `postOutboxPending` reports whether such an outbox still owes any
282
+ message a delivery attempt; receipts and exhausted dead letters owe nothing. Its
283
+ `nextWakeAt()` reads the earliest owed delivery across the retained rows,
284
+ including future retries. Delivered receipts, exhausted dead letters and
285
+ malformed rows do not request a wake.
268
286
 
269
287
  ## Post transactions on D1 and PostgreSQL
270
288
 
271
289
  `@fungi.computer/carrier/post-d1` and `/post-postgres` bind the ordinary Post
272
- address and event registry to Watchdog's database adapters. Apply `postD1Schema`
273
- or `postPostgresSchema` alongside the matching Watchdog schema through your
274
- migration tool.
290
+ address and event registry to Watchdog's database adapters. These relational
291
+ adapters preserve their existing schema and retry contract and do not support
292
+ route deadlines. Constructing either adapter with a route that sets `deadlineMs`
293
+ throws `PostError` with `reason: "relational-deadline-unsupported"` before
294
+ database access. Use SQLite-backed Post for deadline routes. Apply
295
+ `postD1Schema` or `postPostgresSchema` alongside the matching Watchdog schema
296
+ through your migration tool.
275
297
 
276
298
  On D1, `post.send(envelope)` returns the envelope and job statements. Include
277
299
  both in the same `database.batch` as the producer fact. `sendFromSelect` accepts
@@ -30,7 +30,7 @@ export declare const PostReceiptSummarySchema: Schema.Union<readonly [Schema.Str
30
30
  readonly state: Schema.Literal<"dead">;
31
31
  readonly kind: Schema.String;
32
32
  readonly attempts: Schema.Int;
33
- readonly reason: Schema.Literals<readonly ["refused", "exhausted"]>;
33
+ readonly reason: Schema.Literals<readonly ["refused", "exhausted", "expired"]>;
34
34
  }>]>;
35
35
  /** Metadata for one retained Post row. */
36
36
  export type PostReceiptSummary = Schema.Schema.Type<typeof PostReceiptSummarySchema>;
@@ -53,7 +53,7 @@ export declare const PostInspectionPageSchema: Schema.Union<readonly [Schema.Str
53
53
  readonly state: Schema.Literal<"dead">;
54
54
  readonly kind: Schema.String;
55
55
  readonly attempts: Schema.Int;
56
- readonly reason: Schema.Literals<readonly ["refused", "exhausted"]>;
56
+ readonly reason: Schema.Literals<readonly ["refused", "exhausted", "expired"]>;
57
57
  }>]>>;
58
58
  readonly nextCursor: Schema.NullOr<Schema.String>;
59
59
  }>]>;
@@ -29,7 +29,7 @@ export const PostReceiptSummarySchema = Schema.Union([
29
29
  state: Schema.Literal("dead"),
30
30
  kind: Key,
31
31
  attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
32
- reason: Schema.Literals(["refused", "exhausted"]),
32
+ reason: Schema.Literals(["refused", "exhausted", "expired"]),
33
33
  }),
34
34
  ]);
35
35
  /** Bounded evidence with an explicit distinction between empty and uninitialized storage. */
@@ -18,10 +18,11 @@ export declare const PostDeliveryResultSchema: Schema.Union<readonly [Schema.Lit
18
18
  declare const PostError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
19
19
  readonly _tag: "PostError";
20
20
  } & Readonly<A>;
21
- /** Post rejected an unknown route or conflicting key. */
21
+ /** Post rejected an unknown route, conflicting key, or unsupported policy. */
22
22
  export declare class PostError extends PostError_base<{
23
23
  readonly message: string;
24
+ readonly reason?: "relational-deadline-unsupported";
24
25
  }> {
25
- constructor(message: string);
26
+ constructor(message: string, reason?: "relational-deadline-unsupported");
26
27
  }
27
28
  export {};
@@ -14,9 +14,9 @@ export const PostDeliveryResultSchema = Schema.Union([
14
14
  receipt: Schema.Json,
15
15
  }),
16
16
  ]);
17
- /** Post rejected an unknown route or conflicting key. */
17
+ /** Post rejected an unknown route, conflicting key, or unsupported policy. */
18
18
  export class PostError extends Data.TaggedError("PostError") {
19
- constructor(message) {
20
- super({ message });
19
+ constructor(message, reason) {
20
+ super(reason === undefined ? { message } : { message, reason });
21
21
  }
22
22
  }
@@ -6,6 +6,18 @@ export type DeliveryEvidence = Readonly<{
6
6
  envelope: PostEnvelope | undefined;
7
7
  failure?: unknown;
8
8
  }>;
9
+ /** The settlement a delivery attempt resolves to; internal to the Post backends. */
10
+ export type PostDisposition = Readonly<{
11
+ state: "dead";
12
+ reason: "refused" | "exhausted" | "expired";
13
+ }> | Readonly<{
14
+ state: "pending";
15
+ }> | Readonly<{
16
+ state: "delivered";
17
+ cursor?: number;
18
+ }> | Readonly<{
19
+ state: "keep";
20
+ }>;
9
21
  /** Shared preparation, bounded receiver I/O and response decoding for every Post backend. */
10
22
  export declare function deliverPost(input: Readonly<{
11
23
  attempt: Extract<PostRow, {
@@ -16,30 +28,9 @@ export declare function deliverPost(input: Readonly<{
16
28
  savePrepared: (envelope: PostEnvelope) => void | Promise<void>;
17
29
  }>): Promise<DeliveryEvidence>;
18
30
  /** One settlement decision, shared by native storage adapters. */
19
- export declare function postDisposition(evidence: DeliveryEvidence, route: PostRegistryRoute, attempts: number, maxAttempts: number): {
20
- readonly state: "dead";
21
- readonly reason: "exhausted";
22
- readonly cursor?: never;
23
- } | {
24
- readonly state: "pending";
25
- readonly reason?: never;
26
- readonly cursor?: never;
27
- } | {
28
- readonly state: "dead";
29
- readonly reason: "refused";
30
- readonly cursor?: never;
31
- } | {
32
- readonly state: "keep";
33
- readonly reason?: never;
34
- readonly cursor?: never;
35
- } | {
36
- readonly state: "delivered";
37
- readonly reason?: never;
38
- readonly cursor?: never;
39
- } | {
40
- readonly state: "delivered";
41
- readonly cursor: number;
42
- readonly reason?: never;
43
- };
31
+ export declare function postDisposition(evidence: DeliveryEvidence, route: PostRegistryRoute, attempts: number, maxAttempts: number, options?: Readonly<{
32
+ deadlineExpired?: boolean;
33
+ retryUntilDeadline?: boolean;
34
+ }>): PostDisposition;
44
35
  /** Durable provider/domain receipt data, separate from the staged event. */
45
36
  export declare function deliveryReceipt(evidence: DeliveryEvidence): Schema.Json | undefined;
@@ -38,10 +38,12 @@ export async function deliverPost(input) {
38
38
  return { result: decoded.success, envelope };
39
39
  }
40
40
  /** One settlement decision, shared by native storage adapters. */
41
- export function postDisposition(evidence, route, attempts, maxAttempts) {
41
+ export function postDisposition(evidence, route, attempts, maxAttempts, options = {}) {
42
42
  const { result, envelope } = evidence;
43
+ if (result === "unavailable" && options.deadlineExpired === true)
44
+ return { state: "dead", reason: "expired" };
43
45
  if (result === "unavailable")
44
- return attempts >= maxAttempts
46
+ return !options.retryUntilDeadline && attempts >= maxAttempts
45
47
  ? { state: "dead", reason: "exhausted" }
46
48
  : { state: "pending" };
47
49
  if (envelope === undefined)
@@ -12,7 +12,7 @@ export type PostSql = Readonly<{
12
12
  }>;
13
13
  /** SQL native Carrier runtime configuration; route decisions remain in Post. */
14
14
  export type SqlPostOptions<R extends Record<string, PostRegistryRoute>> = Readonly<{
15
- /** The same address/event registry used by SQLite Post. */
15
+ /** The same registry used by SQLite Post; routes with deadlines are rejected. */
16
16
  routes: R;
17
17
  /** Optional bounded, redacted family diagnostic; never persist raw provider errors. */
18
18
  failureDetail?: (cause: unknown) => string;
@@ -1,8 +1,9 @@
1
1
  import * as Schema from "effect/Schema";
2
+ import { backoffDelay } from "@fungi.computer/almanac";
2
3
  import { defineIdFamily } from "@fungi.computer/cairn";
3
4
  import { PostEnvelopeSchema, PostError, } from "../post.js";
4
5
  import { deliverPost, postDisposition, deliveryReceipt, } from "./post-execution.js";
5
- import { maxDeliveryAttempts, retryDelay } from "../post-outbox.js";
6
+ import { maxDeliveryAttempts, postRetryPolicy } from "../post-outbox.js";
6
7
  const ids = defineIdFamily({
7
8
  family: "post",
8
9
  version: 1,
@@ -127,16 +128,21 @@ function publicRow(stored) {
127
128
  };
128
129
  }
129
130
  export function openSqlPost(database, options) {
131
+ if (Object.values(options.routes).some((route) => route.deadlineMs !== undefined))
132
+ throw new PostError("relational Post adapters do not support route deadlines", "relational-deadline-unsupported");
130
133
  const policy = Schema.decodeUnknownSync(Schema.Struct({
131
134
  maxAttempts: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: maxDeliveryAttempts })),
132
135
  baseDelayMs: Schema.Int.check(Schema.isGreaterThan(0)),
133
136
  maxDelayMs: Schema.Int.check(Schema.isGreaterThan(0)),
134
137
  }))(options.retry ?? {
135
138
  maxAttempts: maxDeliveryAttempts,
136
- baseDelayMs: retryDelay(1),
137
- maxDelayMs: retryDelay(maxDeliveryAttempts),
139
+ baseDelayMs: backoffDelay(1, postRetryPolicy),
140
+ maxDelayMs: backoffDelay(maxDeliveryAttempts, postRetryPolicy),
141
+ });
142
+ const delay = (attempt) => backoffDelay(attempt, {
143
+ baseMs: policy.baseDelayMs,
144
+ maxMs: policy.maxDelayMs,
138
145
  });
139
- const delay = (attempt) => Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempt - 1), policy.maxDelayMs);
140
146
  const retrySql = Array.from({ length: policy.maxAttempts }, (_, index) => `WHEN ${String(index)} THEN ${String(delay(index + 1))}`).join(" ");
141
147
  for (const route of Object.values(options.routes)) {
142
148
  // Synchronous owner hooks cannot participate in an asynchronous transaction.
package/dist/post-d1.d.ts CHANGED
@@ -19,7 +19,7 @@ export type D1Post = SqlPostRuntime & Readonly<{
19
19
  }>;
20
20
  /** Carrier-owned D1 schema, applied by the deployment migration owner. */
21
21
  export declare const postD1Schema: readonly string[];
22
- /** Bind the Post registry to native D1 batch admission and the shared delivery engine. */
22
+ /** Bind the Post registry to D1; route deadlines are rejected with PostError. */
23
23
  export declare function createD1Post<R extends Record<string, PostRegistryRoute>>(options: SqlPostOptions<R> & Readonly<{
24
24
  database: D1Database;
25
25
  watchdog: D1Watchdog;
package/dist/post-d1.js CHANGED
@@ -2,7 +2,7 @@ import { purgeD1Queues, prepareD1Admission, } from "@fungi.computer/watchdog/d1"
2
2
  import { admitPostSelect, jobSelect, openSqlPost, postSchema, preparePost, } from "./internal/post-sql.js";
3
3
  /** Carrier-owned D1 schema, applied by the deployment migration owner. */
4
4
  export const postD1Schema = postSchema(false);
5
- /** Bind the Post registry to native D1 batch admission and the shared delivery engine. */
5
+ /** Bind the Post registry to D1; route deadlines are rejected with PostError. */
6
6
  export function createD1Post(options) {
7
7
  // Only package-owned templates use $N; caller SELECTs remain native SQL.
8
8
  const prepare = (statement) => options.database
@@ -1,9 +1,10 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Schema from "effect/Schema";
3
+ import { type RetryPolicy } from "@fungi.computer/almanac";
3
4
  /** Maximum receiver attempts retained from BirdDog. */
4
5
  export declare const maxDeliveryAttempts = 20;
5
- /** BirdDog's retained bounded retry schedule. */
6
- export declare const retryDelay: (attempts: number) => number;
6
+ /** BirdDog's retained receiver retry policy, shared by every Post adapter. */
7
+ export declare const postRetryPolicy: RetryPolicy;
7
8
  /** Durable, string-backed rows. A delivered receipt must outlive source replay. */
8
9
  export type PostOutboxStore<E> = Readonly<{
9
10
  /** Commit one pending row or completion receipt. */
@@ -24,6 +25,8 @@ export type PostOutbox<M, E> = Readonly<{
24
25
  enqueue: (message: M, at?: number) => Effect.Effect<number | undefined, E>;
25
26
  /** Attempt one bounded page and arrange the next wake. */
26
27
  drain: () => Effect.Effect<void, E>;
28
+ /** Earliest owed delivery, including a future retry; absent when none remain. */
29
+ nextWakeAt: () => Effect.Effect<number | undefined, E>;
27
30
  /** Inspect exhausted messages in an ordered page. */
28
31
  deadLetters: (limit: number, afterKey?: string) => Effect.Effect<M[], E>;
29
32
  }>;
@@ -38,3 +41,12 @@ export declare function makePostOutbox<M, E>(options: Readonly<{
38
41
  invalidRow: () => E;
39
42
  encodingFailed: () => E;
40
43
  }>): PostOutbox<M, E>;
44
+ /**
45
+ * Whether any message in an outbox still awaits a delivery attempt. Delivered
46
+ * receipts and exhausted dead letters are retained but owe nothing more, so a
47
+ * host may let their owner rest.
48
+ */
49
+ export declare function postOutboxPending<M, E>(options: Readonly<{
50
+ message: Schema.Codec<M>;
51
+ outbox: Pick<PostOutboxStore<E>, "list">;
52
+ }>): Effect.Effect<boolean, E>;
@@ -1,28 +1,34 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Schema from "effect/Schema";
3
+ import { backoffDelay } from "@fungi.computer/almanac";
3
4
  const PageSize = 64;
4
5
  /** Maximum receiver attempts retained from BirdDog. */
5
6
  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);
7
+ /** BirdDog's retained receiver retry policy, shared by every Post adapter. */
8
+ export const postRetryPolicy = {
9
+ baseMs: 2_000,
10
+ maxMs: 300_000,
11
+ };
8
12
  const earlier = (a, b) => b === undefined ? a : a === undefined ? b : Math.min(a, b);
9
13
  const logDeliveryFailure = (cause) => {
10
14
  console.error("Carrier Post outbox delivery failed", {
11
15
  failureKind: cause instanceof Error ? "Error" : "NonErrorThrown",
12
16
  });
13
17
  };
18
+ /** One durable row: a message still owed delivery, or its delivered receipt. */
19
+ function postRowText(message) {
20
+ return Schema.fromJsonString(Schema.Union([
21
+ Schema.Struct({
22
+ mail: message,
23
+ attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
24
+ nextAttemptAt: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))),
25
+ }),
26
+ Schema.Struct({ state: Schema.Literal("delivered") }),
27
+ ]));
28
+ }
14
29
  /** The same bounded delivery and receipt machinery serves Post and BirdDog. */
15
30
  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);
31
+ const Text = postRowText(options.message);
26
32
  const decode = (value) => Schema.decodeUnknownEffect(Text)(value).pipe(Effect.mapError(options.invalidRow));
27
33
  const encode = (row) => Effect.try({
28
34
  try: () => Schema.encodeSync(Text)(row),
@@ -60,7 +66,7 @@ export function makePostOutbox(options) {
60
66
  return undefined;
61
67
  }
62
68
  const attempts = row.attempts + 1;
63
- const nextAttemptAt = now + retryDelay(attempts);
69
+ const nextAttemptAt = now + backoffDelay(attempts, postRetryPolicy);
64
70
  yield* options.outbox.put(stored.key, yield* encode({ mail: row.mail, attempts, nextAttemptAt }));
65
71
  return attempts < maxDeliveryAttempts ? nextAttemptAt : undefined;
66
72
  });
@@ -95,5 +101,52 @@ export function makePostOutbox(options) {
95
101
  }
96
102
  return dead;
97
103
  });
98
- return { enqueue, drain, deadLetters };
104
+ const nextWakeAt = () => Effect.gen(function* () {
105
+ let afterKey;
106
+ let due;
107
+ for (;;) {
108
+ const rows = yield* options.outbox.list(PageSize, afterKey);
109
+ for (const stored of rows) {
110
+ // Malformed rows are discarded by drain, not deliverable work.
111
+ const decoded = yield* Effect.result(decode(stored.value));
112
+ if (decoded._tag === "Failure")
113
+ continue;
114
+ const row = decoded.success;
115
+ if (!("state" in row) && row.attempts < maxDeliveryAttempts)
116
+ due = earlier(due, row.nextAttemptAt ?? options.now());
117
+ }
118
+ const lastKey = rows.at(-1)?.key;
119
+ if (rows.length < PageSize || lastKey === undefined)
120
+ return due;
121
+ afterKey = lastKey;
122
+ }
123
+ });
124
+ return { enqueue, drain, deadLetters, nextWakeAt };
125
+ }
126
+ /**
127
+ * Whether any message in an outbox still awaits a delivery attempt. Delivered
128
+ * receipts and exhausted dead letters are retained but owe nothing more, so a
129
+ * host may let their owner rest.
130
+ */
131
+ export function postOutboxPending(options) {
132
+ const Text = postRowText(options.message);
133
+ return Effect.gen(function* () {
134
+ let afterKey;
135
+ for (;;) {
136
+ const rows = yield* options.outbox.list(PageSize, afterKey);
137
+ for (const stored of rows) {
138
+ // A malformed row is not deliverable; the next drain discards it.
139
+ const decoded = yield* Effect.result(Schema.decodeUnknownEffect(Text)(stored.value));
140
+ if (decoded._tag === "Failure")
141
+ continue;
142
+ const row = decoded.success;
143
+ if (!("state" in row) && row.attempts < maxDeliveryAttempts)
144
+ return true;
145
+ }
146
+ const lastKey = rows.at(-1)?.key;
147
+ if (rows.length < PageSize || lastKey === undefined)
148
+ return false;
149
+ afterKey = lastKey;
150
+ }
151
+ });
99
152
  }
@@ -10,7 +10,7 @@ export type PostgresPost = SqlPostRuntime & Readonly<{
10
10
  }>;
11
11
  /** Carrier-owned PostgreSQL schema, applied by the deployment migration owner. */
12
12
  export declare const postPostgresSchema: readonly string[];
13
- /** Bind the Post registry to a PostgreSQL owner and the shared delivery engine. */
13
+ /** Bind the Post registry to PostgreSQL; route deadlines are rejected with PostError. */
14
14
  export declare function createPostgresPost<R extends Record<string, PostRegistryRoute>>(options: SqlPostOptions<R> & Readonly<{
15
15
  database: Pick<Pool, "query" | "connect">;
16
16
  watchdog: PostgresWatchdog;
@@ -10,7 +10,7 @@ const Job = Schema.Struct({
10
10
  priority: Schema.Number,
11
11
  payload_json: Schema.fromJsonString(Schema.Unknown),
12
12
  });
13
- /** Bind the Post registry to a PostgreSQL owner and the shared delivery engine. */
13
+ /** Bind the Post registry to PostgreSQL; route deadlines are rejected with PostError. */
14
14
  export function createPostgresPost(options) {
15
15
  const enqueue = async (client, source) => {
16
16
  const native = source;
package/dist/post.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type PostDeliveryResult } from "./internal/post-contract.js";
3
3
  export { PostError, PostDeliveryResultSchema, } from "./internal/post-contract.js";
4
4
  export type { PostDeliveryResult } from "./internal/post-contract.js";
5
5
  import type { WatchdogRunningJob, WatchdogSqliteOwner, WatchdogRuntime, WatchdogTransactionalProjection } from "@fungi.computer/watchdog";
6
- export { makePostOutbox, retryDelay } from "./post-outbox.js";
6
+ export { makePostOutbox, postOutboxPending } from "./post-outbox.js";
7
7
  export type { PostOutbox, PostOutboxStore } from "./post-outbox.js";
8
8
  /** Wire envelope parsed at the durable row and receiver boundaries. */
9
9
  export declare const PostEnvelopeSchema: Schema.Struct<{
@@ -23,6 +23,7 @@ export declare const PostRowSchema: Schema.fromJsonString<Schema.Union<readonly
23
23
  }>;
24
24
  readonly attempts: Schema.Int;
25
25
  readonly nextAttemptAt: Schema.Number;
26
+ readonly deadlineAt: Schema.optional<Schema.Number>;
26
27
  readonly jobId: Schema.optional<Schema.String>;
27
28
  readonly delivery: Schema.optional<Schema.Struct<{
28
29
  readonly key: Schema.String;
@@ -44,7 +45,8 @@ export declare const PostRowSchema: Schema.fromJsonString<Schema.Union<readonly
44
45
  readonly event: Schema.Unknown;
45
46
  }>;
46
47
  readonly attempts: Schema.Int;
47
- readonly reason: Schema.Literals<readonly ["refused", "exhausted"]>;
48
+ readonly reason: Schema.Literals<readonly ["refused", "exhausted", "expired"]>;
49
+ readonly deadlineAt: Schema.optional<Schema.Number>;
48
50
  readonly receipt: Schema.optional<Schema.Codec<Schema.Json, Schema.Json, never, never>>;
49
51
  readonly delivery: Schema.optional<Schema.Struct<{
50
52
  readonly key: Schema.String;
@@ -62,6 +64,8 @@ export type PostRow = Schema.Schema.Type<typeof PostRowSchema>;
62
64
  export type PostRoute<A, E, D = E, AI = A, EI = E, DI = EI> = Readonly<{
63
65
  /** Fixed registry label shared by routes to one recipient namespace. */
64
66
  addressKind: string;
67
+ /** Optional SQLite Post lifetime of 1 ms to 7 days from admission; relational adapters reject it. */
68
+ deadlineMs?: number | undefined;
65
69
  /** Address parser for this kind. */
66
70
  address: Schema.Codec<A, AI>;
67
71
  /** Staged event parser for this kind. */
@@ -104,6 +108,8 @@ export type PostRoute<A, E, D = E, AI = A, EI = E, DI = EI> = Readonly<{
104
108
  export type PostRegistryRoute = Readonly<{
105
109
  /** Recipient namespace identity used for per-address ordering. */
106
110
  addressKind: string;
111
+ /** Optional SQLite Post lifetime of 1 ms to 7 days from admission; relational adapters reject it. */
112
+ deadlineMs?: number | undefined;
107
113
  /** Parser for this address kind. */
108
114
  address: Schema.Codec<unknown, unknown>;
109
115
  /** Parser for this event kind. */
@@ -147,11 +153,13 @@ export type PostRuntime<R extends Record<string, PostRegistryRoute>> = Readonly<
147
153
  }>>;
148
154
  /** Recover settled delivery jobs and enqueue due retries after a wake. */
149
155
  enqueueDue: (limit?: number, afterKey?: string) => Promise<string | undefined>;
150
- /** Read an ordered page of refused or exhausted keys for operators. */
156
+ /** Release settled pending retries from backoff without resetting attempts. */
157
+ releaseNow: (limit?: number, afterKey?: string) => Promise<string | undefined>;
158
+ /** Read an ordered page of refused, exhausted or expired keys for operators. */
151
159
  deadLetters: (limit?: number, afterKey?: string) => Readonly<{
152
160
  letters: readonly Readonly<{
153
161
  key: string;
154
- reason: "refused" | "exhausted";
162
+ reason: "refused" | "exhausted" | "expired";
155
163
  envelope: PostEnvelope;
156
164
  attempts: number;
157
165
  }>[];
package/dist/post.js CHANGED
@@ -3,11 +3,13 @@ import { PostError, } from "./internal/post-contract.js";
3
3
  export { PostError, PostDeliveryResultSchema, } from "./internal/post-contract.js";
4
4
  import { deliverPost, postDisposition, deliveryReceipt, } from "./internal/post-execution.js";
5
5
  import { defineIdFamily } from "@fungi.computer/cairn";
6
- import { maxDeliveryAttempts, retryDelay } from "./post-outbox.js";
7
- export { makePostOutbox, retryDelay } from "./post-outbox.js";
6
+ import { backoffDelay } from "@fungi.computer/almanac";
7
+ import { maxDeliveryAttempts, postRetryPolicy } from "./post-outbox.js";
8
+ export { makePostOutbox, postOutboxPending } from "./post-outbox.js";
8
9
  const Key = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
9
10
  const JobPayload = Schema.Struct({ postKey: Key });
10
11
  const PageLimit = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(64));
12
+ const maxRouteDeadlineMs = 7 * 24 * 60 * 60 * 1_000;
11
13
  const ids = defineIdFamily({
12
14
  family: "post",
13
15
  version: 1,
@@ -27,6 +29,7 @@ export const PostRowSchema = Schema.fromJsonString(Schema.Union([
27
29
  envelope: PostEnvelopeSchema,
28
30
  attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
29
31
  nextAttemptAt: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)),
32
+ deadlineAt: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))),
30
33
  jobId: Schema.optional(Key),
31
34
  delivery: Schema.optional(PostEnvelopeSchema),
32
35
  nextKey: Schema.optional(Key),
@@ -40,7 +43,8 @@ export const PostRowSchema = Schema.fromJsonString(Schema.Union([
40
43
  state: Schema.Literal("dead"),
41
44
  envelope: PostEnvelopeSchema,
42
45
  attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
43
- reason: Schema.Literals(["refused", "exhausted"]),
46
+ reason: Schema.Literals(["refused", "exhausted", "expired"]),
47
+ deadlineAt: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))),
44
48
  receipt: Schema.optional(Schema.Json),
45
49
  delivery: Schema.optional(PostEnvelopeSchema),
46
50
  nextKey: Schema.optional(Key),
@@ -78,6 +82,8 @@ export function definePostRoute(route) {
78
82
  ? receiptOperations(route.address, route.event, route)
79
83
  : receiptOperations(route.address, route.prepare.event, route);
80
84
  const prepare = route.prepare;
85
+ if (route.deadlineMs !== undefined)
86
+ Schema.decodeUnknownSync(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: maxRouteDeadlineMs })))(route.deadlineMs);
81
87
  return {
82
88
  ...route,
83
89
  ...delivery,
@@ -178,12 +184,16 @@ export function createPost(options) {
178
184
  const queue = queueOf(envelope);
179
185
  const previousKey = store.read(tailKey(queue));
180
186
  const previous = previousKey === undefined ? undefined : readRow(previousKey);
181
- const row = {
187
+ const admittedAt = options.now();
188
+ const baseRow = {
182
189
  state: "pending",
183
190
  envelope,
184
191
  attempts: 0,
185
- nextAttemptAt: options.now(),
192
+ nextAttemptAt: admittedAt,
186
193
  };
194
+ const row = route.deadlineMs === undefined
195
+ ? baseRow
196
+ : { ...baseRow, deadlineAt: admittedAt + route.deadlineMs };
187
197
  putRow(envelope.key, row);
188
198
  if (previousKey !== undefined && previous?.state === "pending") {
189
199
  putRow(previousKey, { ...previous, nextKey: envelope.key });
@@ -227,8 +237,13 @@ export function createPost(options) {
227
237
  envelope: row.envelope,
228
238
  attempts: row.attempts,
229
239
  };
240
+ const settled = row.deadlineAt === undefined
241
+ ? dead
242
+ : { ...dead, deadlineAt: row.deadlineAt };
230
243
  receipt =
231
- row.delivery === undefined ? dead : { ...dead, delivery: row.delivery };
244
+ row.delivery === undefined
245
+ ? settled
246
+ : { ...settled, delivery: row.delivery };
232
247
  }
233
248
  else
234
249
  receipt = terminal;
@@ -247,12 +262,16 @@ export function createPost(options) {
247
262
  throw new PostError("delivery row is missing");
248
263
  if (row.state !== "pending" || row.jobId !== job.jobId)
249
264
  return undefined;
250
- if (row.attempts >= maxDeliveryAttempts) {
265
+ if (row.deadlineAt !== undefined && options.now() >= row.deadlineAt) {
266
+ finish(postKey, row, { state: "dead", reason: "expired" });
267
+ return undefined;
268
+ }
269
+ if (row.deadlineAt === undefined && row.attempts >= maxDeliveryAttempts) {
251
270
  finish(postKey, row, { state: "dead", reason: "exhausted" });
252
271
  return undefined;
253
272
  }
254
273
  const attempts = row.attempts + 1;
255
- const nextAttemptAt = options.now() + retryDelay(attempts);
274
+ const nextAttemptAt = Math.min(options.now() + backoffDelay(attempts, postRetryPolicy), row.deadlineAt ?? Number.POSITIVE_INFINITY);
256
275
  const started = { ...row, attempts, nextAttemptAt };
257
276
  // Preserve recovery evidence before invoking the receiver. A failed receipt
258
277
  // write leaves this row available for retry after Watchdog settles the job.
@@ -281,7 +300,11 @@ export function createPost(options) {
281
300
  const current = readRow(postKey);
282
301
  if (current?.state !== "pending" || current.jobId !== job.jobId)
283
302
  return;
284
- const disposition = postDisposition(evidence, route, current.attempts, maxDeliveryAttempts);
303
+ const disposition = postDisposition(evidence, route, current.attempts, maxDeliveryAttempts, {
304
+ deadlineExpired: current.deadlineAt !== undefined &&
305
+ options.now() >= current.deadlineAt,
306
+ retryUntilDeadline: current.deadlineAt !== undefined,
307
+ });
285
308
  if (disposition.state === "keep")
286
309
  return;
287
310
  if (disposition.state === "delivered") {
@@ -298,10 +321,16 @@ export function createPost(options) {
298
321
  finish(postKey, current, disposition, deliveryReceipt(evidence));
299
322
  }
300
323
  else {
301
- const due = options.now() + retryDelay(current.attempts);
302
- if (due !== current.nextAttemptAt) {
303
- putRow(postKey, { ...current, nextAttemptAt: due });
304
- retryAt = due;
324
+ const now = options.now();
325
+ if (current.deadlineAt !== undefined && now >= current.deadlineAt) {
326
+ finish(postKey, current, { state: "dead", reason: "expired" });
327
+ }
328
+ else {
329
+ const due = Math.min(now + backoffDelay(current.attempts, postRetryPolicy), current.deadlineAt ?? Number.POSITIVE_INFINITY);
330
+ if (due !== current.nextAttemptAt) {
331
+ putRow(postKey, { ...current, nextAttemptAt: due });
332
+ retryAt = due;
333
+ }
305
334
  }
306
335
  }
307
336
  });
@@ -329,7 +358,14 @@ export function createPost(options) {
329
358
  const current = readRow(row.envelope.key);
330
359
  if (current?.state !== "pending" || current.jobId !== row.jobId)
331
360
  return;
332
- if (current.attempts >= maxDeliveryAttempts)
361
+ if (current.deadlineAt !== undefined &&
362
+ options.now() >= current.deadlineAt)
363
+ finish(current.envelope.key, current, {
364
+ state: "dead",
365
+ reason: "expired",
366
+ });
367
+ else if (current.deadlineAt === undefined &&
368
+ current.attempts >= maxDeliveryAttempts)
333
369
  finish(current.envelope.key, current, {
334
370
  state: "dead",
335
371
  reason: "exhausted",
@@ -369,13 +405,16 @@ export function createPost(options) {
369
405
  enqueueJob(pending);
370
406
  }
371
407
  else {
372
- const pending = {
408
+ const retry = {
373
409
  state: "pending",
374
410
  envelope: current.envelope,
375
411
  attempts: 0,
376
412
  nextAttemptAt: options.now(),
377
413
  delivery: current.delivery,
378
414
  };
415
+ const pending = current.deadlineAt === undefined
416
+ ? retry
417
+ : { ...retry, deadlineAt: current.deadlineAt };
379
418
  putRow(key, pending);
380
419
  const queue = queueOf(pending.envelope);
381
420
  const previousKey = store.read(tailKey(queue));
@@ -391,6 +430,41 @@ export function createPost(options) {
391
430
  });
392
431
  await options.scheduleWake(options.now());
393
432
  };
433
+ const releasePendingRow = async (row, now) => {
434
+ if (row.state !== "pending" ||
435
+ row.jobId === undefined ||
436
+ row.nextAttemptAt <= now)
437
+ return false;
438
+ const work = await options.readJob(row.jobId);
439
+ if (work.status === "missing")
440
+ throw new PostError("delivery job is missing");
441
+ if (work.job.state !== "settled")
442
+ return false;
443
+ return store.commit(() => {
444
+ const current = readRow(row.envelope.key);
445
+ if (current?.state !== "pending" ||
446
+ current.jobId !== row.jobId ||
447
+ current.nextAttemptAt <= now)
448
+ return false;
449
+ const pending = { ...current, nextAttemptAt: now };
450
+ putRow(row.envelope.key, pending);
451
+ enqueueJob(pending);
452
+ return true;
453
+ });
454
+ };
455
+ const releaseNow = async (limit = 64, afterKey) => {
456
+ const pageSize = Schema.decodeUnknownSync(PageLimit)(limit);
457
+ const rows = store.list(pageSize, afterKey);
458
+ const now = options.now();
459
+ let released = false;
460
+ for (const stored of rows) {
461
+ const row = Schema.decodeUnknownSync(PostRowSchema)(stored.value);
462
+ released = (await releasePendingRow(row, now)) || released;
463
+ }
464
+ if (released)
465
+ await options.scheduleWake(now);
466
+ return rows.length === pageSize ? rows.at(-1)?.key : undefined;
467
+ };
394
468
  const deadLetters = (limit = 64, afterKey) => {
395
469
  const pageSize = Schema.decodeUnknownSync(PageLimit)(limit);
396
470
  const rows = store.list(pageSize, afterKey);
@@ -418,6 +492,7 @@ export function createPost(options) {
418
492
  retry,
419
493
  execute,
420
494
  enqueueDue,
495
+ releaseNow,
421
496
  deadLetters,
422
497
  read: readRow,
423
498
  nextWake,
@@ -157,7 +157,7 @@ const PostReceiptSummarySchema: Union<
157
157
  state: Literal<"dead">;
158
158
  kind: String;
159
159
  attempts: Int;
160
- reason: Literals<readonly ["refused", "exhausted"]>;
160
+ reason: Literals<readonly ["refused", "exhausted", "expired"]>;
161
161
  }>,
162
162
  ]
163
163
  >;
@@ -188,7 +188,7 @@ const PostInspectionPageSchema: Union<readonly [Struct<{
188
188
  state: Literal<"dead">;
189
189
  kind: String;
190
190
  attempts: Int;
191
- reason: Literals<readonly [..., ...]>;
191
+ reason: Literals<readonly [..., ..., ...]>;
192
192
  }>]>>;
193
193
  nextCursor: NullOr<String>;
194
194
  }>]>;
@@ -275,7 +275,7 @@ jobs or arming a wake. The trusted host must admit an operator before calling.
275
275
 
276
276
  #### Returns
277
277
 
278
- | Name | Type | Description |
279
- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
280
- | `email()` | (`input`) => \| \{ `status`: `"uninitialized"`; \} \| \{ `status`: `"available"`; `records`: readonly `object`[]; `nextCursor`: `string` \| `null`; \} | Read aggregate dispatch evidence without email cargo or recipient addresses. |
281
- | `post()` | (`input`) => \| \{ `status`: `"uninitialized"`; \} \| \{ `status`: `"available"`; `records`: readonly ( \| \{ `key`: `string`; `state`: `"delivered"`; \} \| \{ `key`: `string`; `state`: `"pending"`; `kind`: `string`; `attempts`: `number`; `nextAttemptAt`: `number`; \} \| \{ `key`: `string`; `state`: `"dead"`; `kind`: `string`; `attempts`: `number`; `reason`: `"refused"` \| `"exhausted"`; \})[]; `nextCursor`: `string` \| `null`; \} | Read Post metadata; filtered pages retain their continuation through other states. |
278
+ | Name | Type | Description |
279
+ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
280
+ | `email()` | (`input`) => \| \{ `status`: `"uninitialized"`; \} \| \{ `status`: `"available"`; `records`: readonly `object`[]; `nextCursor`: `string` \| `null`; \} | Read aggregate dispatch evidence without email cargo or recipient addresses. |
281
+ | `post()` | (`input`) => \| \{ `status`: `"uninitialized"`; \} \| \{ `status`: `"available"`; `records`: readonly ( \| \{ `key`: `string`; `state`: `"delivered"`; \} \| \{ `key`: `string`; `state`: `"pending"`; `kind`: `string`; `attempts`: `number`; `nextAttemptAt`: `number`; \} \| \{ `key`: `string`; `state`: `"dead"`; `kind`: `string`; `attempts`: `number`; `reason`: `"expired"` \| `"refused"` \| `"exhausted"`; \})[]; `nextCursor`: `string` \| `null`; \} | Read Post metadata; filtered pages retain their continuation through other states. |
@@ -114,14 +114,13 @@ Carrier-owned D1 schema, applied by the deployment migration owner.
114
114
  function createD1Post<R>(options): D1Post;
115
115
  ```
116
116
 
117
- Bind the Post registry to native D1 batch admission and the shared delivery
118
- engine.
117
+ Bind the Post registry to D1; route deadlines are rejected with PostError.
119
118
 
120
119
  #### Type Parameters
121
120
 
122
- | Type Parameter |
123
- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
- | `R` _extends_ `Record`\<`string`, `Readonly`\<\{ `addressKind`: `string`; `address`: `Codec`\<`unknown`, `unknown`\>; `event`: `Codec`\<`unknown`, `unknown`\>; `encodeEnvelope`: (`envelope`) => `object`; `parseDelivery`: (`envelope`) => `object`; `prepareEnvelope?`: (`envelope`, `signal`) => `Promise`\<\{ `key`: `string`; `kind`: `string`; `address`: `unknown`; `event`: `unknown`; \}\>; `recordDelivered?`: (`envelope`) => `void`; `nameOf`: (`envelope`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](post.md#postdeliveryresult)\>; \}\>; \}\>; `recordCursor?`: (`envelope`, `cursor`) => `void`; `recordRefused?`: (`envelope`) => `void`; \}\>\> |
121
+ | Type Parameter |
122
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `R` _extends_ `Record`\<`string`, `Readonly`\<\{ `addressKind`: `string`; `deadlineMs?`: `number`; `address`: `Codec`\<`unknown`, `unknown`\>; `event`: `Codec`\<`unknown`, `unknown`\>; `encodeEnvelope`: (`envelope`) => `object`; `parseDelivery`: (`envelope`) => `object`; `prepareEnvelope?`: (`envelope`, `signal`) => `Promise`\<\{ `key`: `string`; `kind`: `string`; `address`: `unknown`; `event`: `unknown`; \}\>; `recordDelivered?`: (`envelope`) => `void`; `nameOf`: (`envelope`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](post.md#postdeliveryresult)\>; \}\>; \}\>; `recordCursor?`: (`envelope`, `cursor`) => `void`; `recordRefused?`: (`envelope`) => `void`; \}\>\> |
125
124
 
126
125
  #### Parameters
127
126
 
@@ -52,13 +52,14 @@ Carrier-owned PostgreSQL schema, applied by the deployment migration owner.
52
52
  function createPostgresPost<R>(options): PostgresPost;
53
53
  ```
54
54
 
55
- Bind the Post registry to a PostgreSQL owner and the shared delivery engine.
55
+ Bind the Post registry to PostgreSQL; route deadlines are rejected with
56
+ PostError.
56
57
 
57
58
  #### Type Parameters
58
59
 
59
- | Type Parameter |
60
- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
61
- | `R` _extends_ `Record`\<`string`, `Readonly`\<\{ `addressKind`: `string`; `address`: `Codec`\<`unknown`, `unknown`\>; `event`: `Codec`\<`unknown`, `unknown`\>; `encodeEnvelope`: (`envelope`) => `object`; `parseDelivery`: (`envelope`) => `object`; `prepareEnvelope?`: (`envelope`, `signal`) => `Promise`\<\{ `key`: `string`; `kind`: `string`; `address`: `unknown`; `event`: `unknown`; \}\>; `recordDelivered?`: (`envelope`) => `void`; `nameOf`: (`envelope`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](post.md#postdeliveryresult)\>; \}\>; \}\>; `recordCursor?`: (`envelope`, `cursor`) => `void`; `recordRefused?`: (`envelope`) => `void`; \}\>\> |
60
+ | Type Parameter |
61
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
62
+ | `R` _extends_ `Record`\<`string`, `Readonly`\<\{ `addressKind`: `string`; `deadlineMs?`: `number`; `address`: `Codec`\<`unknown`, `unknown`\>; `event`: `Codec`\<`unknown`, `unknown`\>; `encodeEnvelope`: (`envelope`) => `object`; `parseDelivery`: (`envelope`) => `object`; `prepareEnvelope?`: (`envelope`, `signal`) => `Promise`\<\{ `key`: `string`; `kind`: `string`; `address`: `unknown`; `event`: `unknown`; \}\>; `recordDelivered?`: (`envelope`) => `void`; `nameOf`: (`envelope`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](post.md#postdeliveryresult)\>; \}\>; \}\>; `recordCursor?`: (`envelope`, `cursor`) => `void`; `recordRefused?`: (`envelope`) => `void`; \}\>\> |
62
63
 
63
64
  #### Parameters
64
65
 
package/docs/api/post.md CHANGED
@@ -12,26 +12,27 @@
12
12
 
13
13
  ### PostError
14
14
 
15
- Post rejected an unknown route or conflicting key.
15
+ Post rejected an unknown route, conflicting key, or unsupported policy.
16
16
 
17
17
  #### Extends
18
18
 
19
19
  - `YieldableError`\<`this`\> & `object` & `Readonly`\<\{ `message`: `string`;
20
- \}\>
20
+ `reason?`: `"relational-deadline-unsupported"`; \}\>
21
21
 
22
22
  #### Constructors
23
23
 
24
24
  ##### Constructor
25
25
 
26
26
  ```ts
27
- new PostError(message): PostError;
27
+ new PostError(message, reason?): PostError;
28
28
  ```
29
29
 
30
30
  ###### Parameters
31
31
 
32
- | Parameter | Type |
33
- | --------- | -------- |
34
- | `message` | `string` |
32
+ | Parameter | Type |
33
+ | --------- | ----------------------------------- |
34
+ | `message` | `string` |
35
+ | `reason?` | `"relational-deadline-unsupported"` |
35
36
 
36
37
  ###### Returns
37
38
 
@@ -42,11 +43,24 @@ new PostError(message): PostError;
42
43
  ```ts
43
44
  Data.TaggedError("PostError")<{
44
45
  readonly message: string;
46
+ readonly reason?: "relational-deadline-unsupported";
45
47
  }>.constructor
46
48
  ```
47
49
 
48
50
  #### Properties
49
51
 
52
+ ##### reason?
53
+
54
+ ```ts
55
+ readonly optional reason?: "relational-deadline-unsupported";
56
+ ```
57
+
58
+ ###### Inherited from
59
+
60
+ ```ts
61
+ Data.TaggedError("PostError").reason;
62
+ ```
63
+
50
64
  ##### message
51
65
 
52
66
  ```ts
@@ -118,6 +132,7 @@ Durable, string-backed rows. A delivered receipt must outlive source replay.
118
132
  type PostOutbox<M, E> = Readonly<{
119
133
  enqueue: (message, at?) => Effect.Effect<number | undefined, E>;
120
134
  drain: () => Effect.Effect<void, E>;
135
+ nextWakeAt: () => Effect.Effect<number | undefined, E>;
121
136
  deadLetters: (limit, afterKey?) => Effect.Effect<M[], E>;
122
137
  }>;
123
138
  ```
@@ -158,6 +173,7 @@ Pending row, terminal receipt, or dead letter.
158
173
  ```ts
159
174
  type PostRoute<A, E, D, AI, EI, DI> = Readonly<{
160
175
  addressKind: string;
176
+ deadlineMs?: number;
161
177
  address: Schema.Codec<A, AI>;
162
178
  event: Schema.Codec<E, EI>;
163
179
  name: (address) => string;
@@ -204,6 +220,7 @@ Schema-correlated ordinary events or prepared delivery facts.
204
220
  ```ts
205
221
  type PostRegistryRoute = Readonly<{
206
222
  addressKind: string;
223
+ deadlineMs?: number;
207
224
  address: Schema.Codec<unknown, unknown>;
208
225
  event: Schema.Codec<unknown, unknown>;
209
226
  encodeEnvelope: (envelope) => PostEnvelope;
@@ -232,13 +249,14 @@ type PostRuntime<R> = Readonly<{
232
249
  }>
233
250
  >;
234
251
  enqueueDue: (limit?, afterKey?) => Promise<string | undefined>;
252
+ releaseNow: (limit?, afterKey?) => Promise<string | undefined>;
235
253
  deadLetters: (
236
254
  limit?,
237
255
  afterKey?,
238
256
  ) => Readonly<{
239
257
  letters: readonly Readonly<{
240
258
  key: string;
241
- reason: "refused" | "exhausted";
259
+ reason: "refused" | "exhausted" | "expired";
242
260
  envelope: PostEnvelope;
243
261
  attempts: number;
244
262
  }>[];
@@ -322,6 +340,7 @@ const PostRowSchema: fromJsonString<
322
340
  }>;
323
341
  attempts: Int;
324
342
  nextAttemptAt: Number;
343
+ deadlineAt: optional<Number>;
325
344
  jobId: optional<String>;
326
345
  delivery: optional<
327
346
  Struct<{
@@ -347,7 +366,8 @@ const PostRowSchema: fromJsonString<
347
366
  event: Unknown;
348
367
  }>;
349
368
  attempts: Int;
350
- reason: Literals<readonly ["refused", "exhausted"]>;
369
+ reason: Literals<readonly ["refused", "exhausted", "expired"]>;
370
+ deadlineAt: optional<Number>;
351
371
  receipt: optional<Codec<Json, Json, never, never>>;
352
372
  delivery: optional<
353
373
  Struct<{
@@ -368,33 +388,42 @@ Persisted Post row parser.
368
388
 
369
389
  ## Functions
370
390
 
371
- ### retryDelay()
391
+ ### makePostOutbox()
372
392
 
373
393
  ```ts
374
- function retryDelay(attempts): number;
394
+ function makePostOutbox<M, E>(options): PostOutbox<M, E>;
375
395
  ```
376
396
 
377
- BirdDog's retained bounded retry schedule.
397
+ The same bounded delivery and receipt machinery serves Post and BirdDog.
398
+
399
+ #### Type Parameters
400
+
401
+ | Type Parameter |
402
+ | -------------- |
403
+ | `M` |
404
+ | `E` |
378
405
 
379
406
  #### Parameters
380
407
 
381
- | Parameter | Type |
382
- | ---------- | -------- |
383
- | `attempts` | `number` |
408
+ | Parameter | Type |
409
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
410
+ | `options` | `Readonly`\<\{ `message`: `Schema.Codec`\<`M`\>; `key`: (`message`) => `string`; `outbox`: [`PostOutboxStore`](#postoutboxstore)\<`E`\>; `deliver`: (`message`) => `Effect.Effect`\<`boolean`, `E`\>; `scheduleWake`: (`at`) => `Effect.Effect`\<`void`, `E`\>; `now`: () => `number`; `invalidRow`: () => `E`; `encodingFailed`: () => `E`; \}\> |
384
411
 
385
412
  #### Returns
386
413
 
387
- `number`
414
+ [`PostOutbox`](#postoutbox)\<`M`, `E`\>
388
415
 
389
416
  ---
390
417
 
391
- ### makePostOutbox()
418
+ ### postOutboxPending()
392
419
 
393
420
  ```ts
394
- function makePostOutbox<M, E>(options): PostOutbox<M, E>;
421
+ function postOutboxPending<M, E>(options): Effect<boolean, E>;
395
422
  ```
396
423
 
397
- The same bounded delivery and receipt machinery serves Post and BirdDog.
424
+ Whether any message in an outbox still awaits a delivery attempt. Delivered
425
+ receipts and exhausted dead letters are retained but owe nothing more, so a host
426
+ may let their owner rest.
398
427
 
399
428
  #### Type Parameters
400
429
 
@@ -405,13 +434,13 @@ The same bounded delivery and receipt machinery serves Post and BirdDog.
405
434
 
406
435
  #### Parameters
407
436
 
408
- | Parameter | Type |
409
- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
410
- | `options` | `Readonly`\<\{ `message`: `Schema.Codec`\<`M`\>; `key`: (`message`) => `string`; `outbox`: [`PostOutboxStore`](#postoutboxstore)\<`E`\>; `deliver`: (`message`) => `Effect.Effect`\<`boolean`, `E`\>; `scheduleWake`: (`at`) => `Effect.Effect`\<`void`, `E`\>; `now`: () => `number`; `invalidRow`: () => `E`; `encodingFailed`: () => `E`; \}\> |
437
+ | Parameter | Type |
438
+ | --------- | --------------------------------------------------------------------------------------------------------------------------------- |
439
+ | `options` | `Readonly`\<\{ `message`: `Schema.Codec`\<`M`\>; `outbox`: `Pick`\<[`PostOutboxStore`](#postoutboxstore)\<`E`\>, `"list"`\>; \}\> |
411
440
 
412
441
  #### Returns
413
442
 
414
- [`PostOutbox`](#postoutbox)\<`M`, `E`\>
443
+ `Effect`\<`boolean`, `E`\>
415
444
 
416
445
  ---
417
446
 
@@ -422,6 +451,7 @@ The same bounded delivery and receipt machinery serves Post and BirdDog.
422
451
  ```ts
423
452
  function definePostRoute<A, E, AI, EI>(route): PostRoute<A, E, E, AI, EI, EI> & Omit<Readonly<{
424
453
  addressKind: string;
454
+ deadlineMs?: number;
425
455
  address: Codec<unknown, unknown>;
426
456
  event: Codec<unknown, unknown>;
427
457
  encodeEnvelope: (envelope) => object;
@@ -457,29 +487,30 @@ Preserve each route's inferred address and event types in a registry.
457
487
 
458
488
  ##### Parameters
459
489
 
460
- | Parameter | Type |
461
- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
462
- | `route` | `Readonly`\<\{ `addressKind`: `string`; `address`: `Codec`\<`A`, `AI`\>; `event`: `Codec`\<`E`, `EI`\>; `name`: (`address`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](#postdeliveryresult)\>; \}\>; \}\>; \}\> & `Readonly`\<\{ `prepare?`: `undefined`; `onDelivered?`: (`address`, `event`) => `void`; `onCursor?`: (`address`, `event`, `cursor`) => `void`; `onRefused?`: (`address`, `event`) => `void`; \}\> |
490
+ | Parameter | Type |
491
+ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
492
+ | `route` | `Readonly`\<\{ `addressKind`: `string`; `deadlineMs?`: `number`; `address`: `Codec`\<`A`, `AI`\>; `event`: `Codec`\<`E`, `EI`\>; `name`: (`address`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](#postdeliveryresult)\>; \}\>; \}\>; \}\> & `Readonly`\<\{ `prepare?`: `undefined`; `onDelivered?`: (`address`, `event`) => `void`; `onCursor?`: (`address`, `event`, `cursor`) => `void`; `onRefused?`: (`address`, `event`) => `void`; \}\> |
463
493
 
464
494
  ##### Returns
465
495
 
466
496
  [`PostRoute`](#postroute)\<`A`, `E`, `E`, `AI`, `EI`, `EI`\> &
467
- `Omit`\<`Readonly`\<\{ `addressKind`: `string`; `address`: `Codec`\<`unknown`,
468
- `unknown`\>; `event`: `Codec`\<`unknown`, `unknown`\>; `encodeEnvelope`:
469
- (`envelope`) => `object`; `parseDelivery`: (`envelope`) => `object`;
470
- `prepareEnvelope?`: (`envelope`, `signal`) => `Promise`\<\{ `key`: `string`;
471
- `kind`: `string`; `address`: `unknown`; `event`: `unknown`; \}\>;
472
- `recordDelivered?`: (`envelope`) => `void`; `nameOf`: (`envelope`) => `string`;
473
- `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`:
474
- (`envelope`) => `Promise`\<...\>; \}\>; \}\>; `recordCursor?`: (`envelope`,
475
- `cursor`) => `void`; `recordRefused?`: (`envelope`) => `void`; \}\>, `"address"`
476
- \| `"event"`\>
497
+ `Omit`\<`Readonly`\<\{ `addressKind`: `string`; `deadlineMs?`: `number`;
498
+ `address`: `Codec`\<`unknown`, `unknown`\>; `event`: `Codec`\<`unknown`,
499
+ `unknown`\>; `encodeEnvelope`: (`envelope`) => `object`; `parseDelivery`:
500
+ (`envelope`) => `object`; `prepareEnvelope?`: (`envelope`, `signal`) =>
501
+ `Promise`\<\{ `key`: `string`; `kind`: `string`; `address`: `unknown`; `event`:
502
+ `unknown`; \}\>; `recordDelivered?`: (`envelope`) => `void`; `nameOf`:
503
+ (`envelope`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) =>
504
+ `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<...\>; \}\>; \}\>;
505
+ `recordCursor?`: (`envelope`, `cursor`) => `void`; `recordRefused?`:
506
+ (`envelope`) => `void`; \}\>, `"address"` \| `"event"`\>
477
507
 
478
508
  #### Call Signature
479
509
 
480
510
  ```ts
481
511
  function definePostRoute<A, E, D, AI, EI, DI>(route): PostRoute<A, E, D, AI, EI, DI> & Omit<Readonly<{
482
512
  addressKind: string;
513
+ deadlineMs?: number;
483
514
  address: Codec<unknown, unknown>;
484
515
  event: Codec<unknown, unknown>;
485
516
  encodeEnvelope: (envelope) => object;
@@ -517,54 +548,23 @@ Preserve each route's inferred address and event types in a registry.
517
548
 
518
549
  ##### Parameters
519
550
 
520
- | Parameter | Type |
521
- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
522
- | `route` | `Readonly`\<\{ `addressKind`: `string`; `address`: `Codec`\<`A`, `AI`\>; `event`: `Codec`\<`E`, `EI`\>; `name`: (`address`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](#postdeliveryresult)\>; \}\>; \}\>; \}\> & `Readonly`\<\{ `prepare`: `Readonly`\<\{ `event`: `Schema.Codec`\<`D`, `DI`\>; `run`: (`address`, `intent`, `signal`) => `Promise`\<`D`\>; \}\>; `onDelivered?`: (`address`, `event`) => `void`; `onCursor?`: (`address`, `event`, `cursor`) => `void`; `onRefused?`: (`address`, `event`) => `void`; \}\> |
551
+ | Parameter | Type |
552
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
553
+ | `route` | `Readonly`\<\{ `addressKind`: `string`; `deadlineMs?`: `number`; `address`: `Codec`\<`A`, `AI`\>; `event`: `Codec`\<`E`, `EI`\>; `name`: (`address`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](#postdeliveryresult)\>; \}\>; \}\>; \}\> & `Readonly`\<\{ `prepare`: `Readonly`\<\{ `event`: `Schema.Codec`\<`D`, `DI`\>; `run`: (`address`, `intent`, `signal`) => `Promise`\<`D`\>; \}\>; `onDelivered?`: (`address`, `event`) => `void`; `onCursor?`: (`address`, `event`, `cursor`) => `void`; `onRefused?`: (`address`, `event`) => `void`; \}\> |
523
554
 
524
555
  ##### Returns
525
556
 
526
557
  [`PostRoute`](#postroute)\<`A`, `E`, `D`, `AI`, `EI`, `DI`\> &
527
- `Omit`\<`Readonly`\<\{ `addressKind`: `string`; `address`: `Codec`\<`unknown`,
528
- `unknown`\>; `event`: `Codec`\<`unknown`, `unknown`\>; `encodeEnvelope`:
529
- (`envelope`) => `object`; `parseDelivery`: (`envelope`) => `object`;
530
- `prepareEnvelope?`: (`envelope`, `signal`) => `Promise`\<\{ `key`: `string`;
531
- `kind`: `string`; `address`: `unknown`; `event`: `unknown`; \}\>;
532
- `recordDelivered?`: (`envelope`) => `void`; `nameOf`: (`envelope`) => `string`;
533
- `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`:
534
- (`envelope`) => `Promise`\<...\>; \}\>; \}\>; `recordCursor?`: (`envelope`,
535
- `cursor`) => `void`; `recordRefused?`: (`envelope`) => `void`; \}\>, `"address"`
536
- \| `"event"`\>
537
-
538
- ---
539
-
540
- ### migratePostEvents()
541
-
542
- ```ts
543
- function migratePostEvents<A, AI, B, BI>(options): void;
544
- ```
545
-
546
- Migrate persisted ordinary events once, before opening their producer. Carrier
547
- preserves delivery evidence, ordering and Watchdog job identities. A failed
548
- decoder or transform rolls back both rows and the migration receipt.
549
-
550
- #### Type Parameters
551
-
552
- | Type Parameter |
553
- | -------------- |
554
- | `A` |
555
- | `AI` |
556
- | `B` |
557
- | `BI` |
558
-
559
- #### Parameters
560
-
561
- | Parameter | Type |
562
- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
563
- | `options` | `Readonly`\<\{ `owner`: `WatchdogSqliteOwner`; `id`: `string`; `kind`: `string`; `toKind?`: `string`; `from`: `Schema.Codec`\<`A`, `AI`\>; `to`: `Schema.Codec`\<`B`, `BI`\>; `migrate`: (`event`) => `B`; \}\> |
564
-
565
- #### Returns
566
-
567
- `void`
558
+ `Omit`\<`Readonly`\<\{ `addressKind`: `string`; `deadlineMs?`: `number`;
559
+ `address`: `Codec`\<`unknown`, `unknown`\>; `event`: `Codec`\<`unknown`,
560
+ `unknown`\>; `encodeEnvelope`: (`envelope`) => `object`; `parseDelivery`:
561
+ (`envelope`) => `object`; `prepareEnvelope?`: (`envelope`, `signal`) =>
562
+ `Promise`\<\{ `key`: `string`; `kind`: `string`; `address`: `unknown`; `event`:
563
+ `unknown`; \}\>; `recordDelivered?`: (`envelope`) => `void`; `nameOf`:
564
+ (`envelope`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) =>
565
+ `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<...\>; \}\>; \}\>;
566
+ `recordCursor?`: (`envelope`, `cursor`) => `void`; `recordRefused?`:
567
+ (`envelope`) => `void`; \}\>, `"address"` \| `"event"`\>
568
568
 
569
569
  ---
570
570
 
@@ -578,9 +578,9 @@ Decode at both persisted and DO boundaries with the route's Effect Schema.
578
578
 
579
579
  #### Type Parameters
580
580
 
581
- | Type Parameter |
582
- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
583
- | `R` _extends_ `Record`\<`string`, `Readonly`\<\{ `addressKind`: `string`; `address`: `Codec`\<`unknown`, `unknown`\>; `event`: `Codec`\<`unknown`, `unknown`\>; `encodeEnvelope`: (`envelope`) => `object`; `parseDelivery`: (`envelope`) => `object`; `prepareEnvelope?`: (`envelope`, `signal`) => `Promise`\<\{ `key`: `string`; `kind`: `string`; `address`: `unknown`; `event`: `unknown`; \}\>; `recordDelivered?`: (`envelope`) => `void`; `nameOf`: (`envelope`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](#postdeliveryresult)\>; \}\>; \}\>; `recordCursor?`: (`envelope`, `cursor`) => `void`; `recordRefused?`: (`envelope`) => `void`; \}\>\> |
581
+ | Type Parameter |
582
+ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
583
+ | `R` _extends_ `Record`\<`string`, `Readonly`\<\{ `addressKind`: `string`; `deadlineMs?`: `number`; `address`: `Codec`\<`unknown`, `unknown`\>; `event`: `Codec`\<`unknown`, `unknown`\>; `encodeEnvelope`: (`envelope`) => `object`; `parseDelivery`: (`envelope`) => `object`; `prepareEnvelope?`: (`envelope`, `signal`) => `Promise`\<\{ `key`: `string`; `kind`: `string`; `address`: `unknown`; `event`: `unknown`; \}\>; `recordDelivered?`: (`envelope`) => `void`; `nameOf`: (`envelope`) => `string`; `namespace`: `Readonly`\<\{ `getByName`: (`name`) => `Readonly`\<\{ `deliver`: (`envelope`) => `Promise`\<[`PostDeliveryResult`](#postdeliveryresult)\>; \}\>; \}\>; `recordCursor?`: (`envelope`, `cursor`) => `void`; `recordRefused?`: (`envelope`) => `void`; \}\>\> |
584
584
 
585
585
  #### Parameters
586
586
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/carrier",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Durable email submission and recipient delivery tracking",
6
6
  "type": "module",
@@ -48,8 +48,9 @@
48
48
  ],
49
49
  "dependencies": {
50
50
  "@cloudflare/workers-types": "5.20260828.1",
51
- "@fungi.computer/cairn": "0.0.1",
52
- "@fungi.computer/watchdog": "0.0.1",
51
+ "@fungi.computer/almanac": "0.2.0",
52
+ "@fungi.computer/cairn": "0.1.0",
53
+ "@fungi.computer/watchdog": "0.1.0",
53
54
  "effect": "4.0.2",
54
55
  "pg": "8.23.0",
55
56
  "@types/pg": "8.23.1"