@fungi.computer/carrier 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,6 +215,12 @@ ambiguous first dispatch has zero. Post `attempts` counts receiver invocations.
214
215
 
215
216
  ## Post
216
217
 
218
+ Post uses Almanac's shared pure backoff calculation with its retained policy: 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. Almanac supplies arithmetic only,
222
+ not a second scheduler or retry owner.
223
+
217
224
  `@fungi.computer/carrier/post` delivers typed, point-to-point envelopes between
218
225
  named Durable Objects. `definePostRoute` pairs each address schema and event
219
226
  schema with its Durable Object namespace, name function and fixed `addressKind`
@@ -264,7 +271,11 @@ its reason and its attempt count. `deadLetters` returns `letters` and
264
271
 
265
272
  The same retry and receipt machinery is exported as `makePostOutbox` for a
266
273
  string-backed outbox. [BirdDog](../bird-dog/README.md) uses it for agent reply
267
- delivery.
274
+ delivery. `postOutboxPending` reports whether such an outbox still owes any
275
+ message a delivery attempt; receipts and exhausted dead letters owe nothing. Its
276
+ `nextWakeAt()` reads the earliest owed delivery across the retained rows,
277
+ including future retries. Delivered receipts, exhausted dead letters and
278
+ malformed rows do not request a wake.
268
279
 
269
280
  ## Post transactions on D1 and PostgreSQL
270
281
 
@@ -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,
@@ -133,10 +134,13 @@ export function openSqlPost(database, options) {
133
134
  maxDelayMs: Schema.Int.check(Schema.isGreaterThan(0)),
134
135
  }))(options.retry ?? {
135
136
  maxAttempts: maxDeliveryAttempts,
136
- baseDelayMs: retryDelay(1),
137
- maxDelayMs: retryDelay(maxDeliveryAttempts),
137
+ baseDelayMs: backoffDelay(1, postRetryPolicy),
138
+ maxDelayMs: backoffDelay(maxDeliveryAttempts, postRetryPolicy),
139
+ });
140
+ const delay = (attempt) => backoffDelay(attempt, {
141
+ baseMs: policy.baseDelayMs,
142
+ maxMs: policy.maxDelayMs,
138
143
  });
139
- const delay = (attempt) => Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempt - 1), policy.maxDelayMs);
140
144
  const retrySql = Array.from({ length: policy.maxAttempts }, (_, index) => `WHEN ${String(index)} THEN ${String(delay(index + 1))}`).join(" ");
141
145
  for (const route of Object.values(options.routes)) {
142
146
  // Synchronous owner hooks cannot participate in an asynchronous transaction.
@@ -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
  }
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<{
package/dist/post.js CHANGED
@@ -3,8 +3,9 @@ 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));
@@ -252,7 +253,7 @@ export function createPost(options) {
252
253
  return undefined;
253
254
  }
254
255
  const attempts = row.attempts + 1;
255
- const nextAttemptAt = options.now() + retryDelay(attempts);
256
+ const nextAttemptAt = options.now() + backoffDelay(attempts, postRetryPolicy);
256
257
  const started = { ...row, attempts, nextAttemptAt };
257
258
  // Preserve recovery evidence before invoking the receiver. A failed receipt
258
259
  // write leaves this row available for retry after Watchdog settles the job.
@@ -298,7 +299,7 @@ export function createPost(options) {
298
299
  finish(postKey, current, disposition, deliveryReceipt(evidence));
299
300
  }
300
301
  else {
301
- const due = options.now() + retryDelay(current.attempts);
302
+ const due = options.now() + backoffDelay(current.attempts, postRetryPolicy);
302
303
  if (due !== current.nextAttemptAt) {
303
304
  putRow(postKey, { ...current, nextAttemptAt: due });
304
305
  retryAt = due;
package/docs/api/post.md CHANGED
@@ -118,6 +118,7 @@ Durable, string-backed rows. A delivered receipt must outlive source replay.
118
118
  type PostOutbox<M, E> = Readonly<{
119
119
  enqueue: (message, at?) => Effect.Effect<number | undefined, E>;
120
120
  drain: () => Effect.Effect<void, E>;
121
+ nextWakeAt: () => Effect.Effect<number | undefined, E>;
121
122
  deadLetters: (limit, afterKey?) => Effect.Effect<M[], E>;
122
123
  }>;
123
124
  ```
@@ -368,33 +369,42 @@ Persisted Post row parser.
368
369
 
369
370
  ## Functions
370
371
 
371
- ### retryDelay()
372
+ ### makePostOutbox()
372
373
 
373
374
  ```ts
374
- function retryDelay(attempts): number;
375
+ function makePostOutbox<M, E>(options): PostOutbox<M, E>;
375
376
  ```
376
377
 
377
- BirdDog's retained bounded retry schedule.
378
+ The same bounded delivery and receipt machinery serves Post and BirdDog.
379
+
380
+ #### Type Parameters
381
+
382
+ | Type Parameter |
383
+ | -------------- |
384
+ | `M` |
385
+ | `E` |
378
386
 
379
387
  #### Parameters
380
388
 
381
- | Parameter | Type |
382
- | ---------- | -------- |
383
- | `attempts` | `number` |
389
+ | Parameter | Type |
390
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
391
+ | `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
392
 
385
393
  #### Returns
386
394
 
387
- `number`
395
+ [`PostOutbox`](#postoutbox)\<`M`, `E`\>
388
396
 
389
397
  ---
390
398
 
391
- ### makePostOutbox()
399
+ ### postOutboxPending()
392
400
 
393
401
  ```ts
394
- function makePostOutbox<M, E>(options): PostOutbox<M, E>;
402
+ function postOutboxPending<M, E>(options): Effect<boolean, E>;
395
403
  ```
396
404
 
397
- The same bounded delivery and receipt machinery serves Post and BirdDog.
405
+ Whether any message in an outbox still awaits a delivery attempt. Delivered
406
+ receipts and exhausted dead letters are retained but owe nothing more, so a host
407
+ may let their owner rest.
398
408
 
399
409
  #### Type Parameters
400
410
 
@@ -405,13 +415,13 @@ The same bounded delivery and receipt machinery serves Post and BirdDog.
405
415
 
406
416
  #### Parameters
407
417
 
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`; \}\> |
418
+ | Parameter | Type |
419
+ | --------- | --------------------------------------------------------------------------------------------------------------------------------- |
420
+ | `options` | `Readonly`\<\{ `message`: `Schema.Codec`\<`M`\>; `outbox`: `Pick`\<[`PostOutboxStore`](#postoutboxstore)\<`E`\>, `"list"`\>; \}\> |
411
421
 
412
422
  #### Returns
413
423
 
414
- [`PostOutbox`](#postoutbox)\<`M`, `E`\>
424
+ `Effect`\<`boolean`, `E`\>
415
425
 
416
426
  ---
417
427
 
@@ -537,37 +547,6 @@ Preserve each route's inferred address and event types in a registry.
537
547
 
538
548
  ---
539
549
 
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`
568
-
569
- ---
570
-
571
550
  ### createPost()
572
551
 
573
552
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/carrier",
3
- "version": "0.0.1",
3
+ "version": "0.1.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"