@streamsy/serve 0.0.0-tegami-trusted-publish-setup → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Gareth Andrew
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,108 @@
1
- # Placeholder package
2
-
3
- This empty package was published by [Tegami](https://tegami.fuma-nama.dev) to configure npm trusted publishing.
4
-
5
- The real package contents will be published via CI with OIDC.
6
-
1
+ # @streamsy/serve
2
+
3
+ Effect HttpRouter routes for streams, Durable State and JSON values, plus Bun,
4
+ Node, Deno and Cloudflare host glue and durable action delivery. Requires Effect
5
+ `4.0.1`.
6
+
7
+ ```ts
8
+ import { Layer, Schema } from "effect";
9
+ import { BunRuntime } from "@effect/platform-bun";
10
+ import { Http, Streamsy, MemoryStorage } from "@streamsy/core";
11
+ import { Serve } from "@streamsy/serve";
12
+ import { listener } from "@streamsy/serve/bun";
13
+
14
+ class Events extends Streamsy.json<Events>()("Readme/Events", "events/:seat", {
15
+ params: { seat: Schema.String },
16
+ schema: Schema.String,
17
+ }) {}
18
+ const streams = Streamsy.group(Events);
19
+ const App = Layer.mergeAll(
20
+ Http.routes(streams, { prefix: "/streams", access: "full" }),
21
+ Serve.mount(Events, "/feed/:seat"),
22
+ );
23
+ const Server = Serve.layer(App).pipe(
24
+ Layer.provide(MemoryStorage.layer(streams)),
25
+ Layer.provide(listener({ port: 3000 })),
26
+ );
27
+ BunRuntime.runMain(Layer.launch(Server), { teardown: Serve.teardown });
28
+ ```
29
+
30
+ `Serve.stream(address)` and `Serve.state(address)` return response Effects.
31
+ State serves changes from `-1`, with no snapshot. `Serve.value(schema, effect)`
32
+ serves canonical JSON with a deterministic ETag. Map `Projection.read` to serve
33
+ custom values; an unsaved projection returns its declared initial value.
34
+ An absent stream answers 404. Reads create no stream, checkpoint or state row.
35
+
36
+ `Serve.params(Stream)` decodes path parameters. `Serve.mount(Stream, path)`
37
+ mounts a declared stream or state. `Serve.route(path, handler)` owns all methods:
38
+ GET and HEAD read; others return 405 with `allow: GET, HEAD`, unless CORS
39
+ middleware answers a preflight. Handlers also compose inside app routes and
40
+ `HttpApiBuilder.handleRaw`. Their respondable errors remain catchable.
41
+ Auth can provide a service from which the handler builds an address.
42
+ Protect the protocol routes too when their underlying data is private.
43
+
44
+ Bun's listener defaults to `idleTimeout: 0`. Route handlers are interruptible
45
+ on the pinned Effect/Bun router; client abort releases a parked long poll.
46
+ The Node listener keeps the `node:http` defaults, which do not cut a parked
47
+ long poll or an SSE body, and takes `server` options for `createServer`. The
48
+ Deno listener needs no timeout setting. On Deno, run `nodeSqliteCompat` before
49
+ an `@effect/sql-sqlite-node` client opens: Deno's `DatabaseSync` lacks the
50
+ `enableLoadExtension` method that the driver calls.
51
+ The caller owns the Layer scope. Tests or custom hosts can use Effect's
52
+ `HttpRouter.toWebHandler(App)` and must call its `dispose` at shutdown.
53
+
54
+ ### Long-running hosts and shutdown
55
+
56
+ `Serve.layer(App, options)` is `HttpRouter.serve(App, options)` with the drain
57
+ for live readers built in. It takes the options of `HttpRouter.serve`, plus
58
+ `drainTimeout` (default 2 seconds). Without a drain, closing the serve layer
59
+ interrupts every handler: a parked long poll gets an empty `503` and an open
60
+ SSE body is reset with no final event. With `Serve.layer`, the shutdown order
61
+ is:
62
+
63
+ 1. The drain signals every live read. A parked long poll answers `204` with
64
+ `Stream-Next-Offset`, `Stream-Up-To-Date` and a fresh `Stream-Cursor`, the
65
+ same as a long-poll timeout. An open SSE body ends after its last whole
66
+ batch, which ends with a control event.
67
+ 2. The drain waits until no live read is running, or until `drainTimeout`
68
+ passes. A live request that arrives in this window gets the same reply at
69
+ once.
70
+ 3. The serve layer closes and interrupts any handler that is still running.
71
+ 4. Storage closes, then the listener stops.
72
+
73
+ `Serve.teardown` is the exit policy for `runMain`. An interrupt-only exit, such
74
+ as a stop by `SIGINT` or `SIGTERM`, exits 0 in place of Effect's 130. Any other
75
+ exit keeps `Runtime.defaultTeardown`, so a startup failure stays non-zero.
76
+ Both live in the root entry and depend on no runtime. A host subpath gives only
77
+ the listener.
78
+
79
+ The Durable Object host does not get a stop signal and uses neither.
80
+
81
+ | Entry | Exports |
82
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
83
+ | `.` | `Serve.stream`, `Serve.state`, `Serve.value`, `Serve.params`, `Serve.route`, `Serve.mount`, `Serve.layer`, `Serve.teardown`; errors and types; `PublicErrorSchema` |
84
+ | `/contract` | Effect-free headers, public errors/decoder, canonical JSON, fingerprints |
85
+ | `/bun` | `listener`, its options and defaults |
86
+ | `/node` | `listener`, its options and defaults |
87
+ | `/deno` | `listener`, its options and defaults; `nodeSqliteCompat` |
88
+ | `/cloudflare` | `StreamsyObject`, placement router/rules, alarm service |
89
+ | `/alchemy` | `objectHandlers`, placement router/rules, alarm service |
90
+ | `/action` | Unchanged action declarations |
91
+ | `/action/errors`, `/action/outbox`, `/action/runtime`, `/action/sql` | Action delivery and backing APIs; `createSqlOutboxBacking` runs on SQLite, Postgres and MySQL |
92
+
93
+ Twelve physical entries form six groups. Action's existing Sink names remain.
94
+ State/stream recovery is `replay-from-start`; state consumers clear their local
95
+ collection before replay. Old sink headers and snapshot reset exports are gone.
96
+ Fingerprints cover route/address metadata and an optional `contract` revision;
97
+ change that revision when codec behavior changes without metadata changing.
98
+ Fingerprints are change detectors, not authorization or cryptographic identities.
99
+
100
+ The value contract header is `x-streamsy-value-contract` (`VALUE_CONTRACT_HEADER`
101
+ in `/contract`), and `Serve.value` options are `ValueOptions`. A projection's
102
+ served state comes from `Projection.read(declaration.at(params))`.
103
+
104
+ Guides: [Serve routes](https://streamsy.dev/docs/http/serve),
105
+ [Protocol routes](https://streamsy.dev/docs/http/routes),
106
+ [Cloudflare](https://streamsy.dev/docs/hosting/cloudflare),
107
+ [Host contract](https://streamsy.dev/docs/hosting/host-contract),
108
+ [Alchemy](https://streamsy.dev/docs/hosting/cloudflare#alchemy).
@@ -0,0 +1,100 @@
1
+ //#region src/action/contract.d.ts
2
+ /**
3
+ * The action-sink declaration.
4
+ *
5
+ * A `stateSink` publishes a relation as data a consumer reads. An `actionSink`
6
+ * does the opposite: it turns changes to a relation into *external effects* —
7
+ * a notification, a webhook, a mail — which the world observes once and cannot
8
+ * un-observe. That asymmetry is the whole design. A published row can be
9
+ * republished; a delivered effect cannot be undelivered, so the contract is
10
+ * built around a durable outbox, a deterministic idempotency key, a bounded
11
+ * serialized retry policy, and a dead-letter terminus.
12
+ *
13
+ * The declaration is inert data. It names the relation it observes, the handler
14
+ * that performs the effect, the payload's wire boundary, and the delivery
15
+ * policy. It contains no I/O and no runtime state.
16
+ */
17
+ /** The part of a keyed relation an action sink needs: the key its rows are declared by. */
18
+ export interface ActionSinkRelation<Key extends string = string> {
19
+ readonly key: Key;
20
+ }
21
+ /**
22
+ * One change to the observed relation.
23
+ *
24
+ * This is structurally the engine's `Change`, restated here so the declaration
25
+ * surface does not drag the IR package into every consumer of a sink contract.
26
+ */
27
+ export type ActionSinkChange<Row, Key = string> = {
28
+ readonly kind: "enter";
29
+ readonly key: Key;
30
+ readonly after: Row;
31
+ } | {
32
+ readonly kind: "update";
33
+ readonly key: Key;
34
+ readonly before: Row;
35
+ readonly after: Row;
36
+ } | {
37
+ readonly kind: "exit";
38
+ readonly key: Key;
39
+ readonly before: Row;
40
+ };
41
+ /** The handler an action sink is bound to, by name and version. */
42
+ export interface ActionSinkHandlerRef {
43
+ readonly name: string;
44
+ readonly version: number;
45
+ }
46
+ /**
47
+ * The bounded retry policy.
48
+ *
49
+ * `maxAttempts` counts *total* attempts, so `maxAttempts: 1` means deliver once
50
+ * and dead-letter on failure. Backoff is exponential and clamped, and it is
51
+ * computed rather than scheduled: the outbox stores the next attempt instant,
52
+ * so a restart resumes the same policy instead of restarting it.
53
+ */
54
+ export interface ActionSinkDeliveryPolicy {
55
+ readonly maxAttempts: number;
56
+ readonly initialBackoffMs: number;
57
+ readonly backoffFactor: number;
58
+ readonly maxBackoffMs: number;
59
+ }
60
+ /** The durable payload's wire boundary: JSON text in the outbox, a typed value in the handler. */
61
+ export interface ActionSinkPayloadCodec<Payload> {
62
+ readonly encode: (payload: Payload) => string;
63
+ readonly decode: (value: unknown) => Payload;
64
+ }
65
+ export interface ActionSinkSpec<Payload, From extends ActionSinkRelation = ActionSinkRelation> {
66
+ readonly name: string;
67
+ readonly from: From;
68
+ readonly handler: ActionSinkHandlerRef;
69
+ readonly payload: ActionSinkPayloadCodec<Payload>;
70
+ /**
71
+ * The delivery's durable identity.
72
+ *
73
+ * It must be a pure function of durable facts, because it is the only thing
74
+ * standing between a retried enqueue and a second real-world effect. Two
75
+ * enqueues that describe the same fact must produce the same key.
76
+ */
77
+ readonly idempotencyKey: (payload: Payload) => string;
78
+ /** Which serialized lane the delivery belongs to. Usually the relation's partition. */
79
+ readonly partitionBy: (payload: Payload) => string;
80
+ readonly delivery: ActionSinkDeliveryPolicy;
81
+ }
82
+ export interface CheckedActionSink<Payload, From extends ActionSinkRelation = ActionSinkRelation> extends ActionSinkSpec<Payload, From> {
83
+ readonly kind: "checked-action-sink";
84
+ /** The observed relation's declared key, carried so consumers read one key, not two. */
85
+ readonly key: From["key"];
86
+ /**
87
+ * The contract's identity.
88
+ *
89
+ * It covers the sink name, the relation key, the handler reference and the
90
+ * delivery policy — every part a consumer or an operator can observe. It
91
+ * deliberately does not cover the declared functions: a closure has no honest
92
+ * hash, and pretending otherwise would make the fingerprint a decoration.
93
+ */
94
+ readonly fingerprint: string;
95
+ }
96
+ export declare function defineActionSink<Payload, From extends ActionSinkRelation>(spec: ActionSinkSpec<Payload, From>): CheckedActionSink<Payload, From>;
97
+ /** The next backoff, in milliseconds, after `attempts` failed attempts. */
98
+ export declare function backoffAfter(policy: ActionSinkDeliveryPolicy, attempts: number): number;
99
+ export type PayloadOf<Sink> = Sink extends CheckedActionSink<infer Payload, infer _From> ? Payload : never;
100
+ //#endregion
@@ -0,0 +1,28 @@
1
+ import { contractFingerprint } from "../contract.js";
2
+ //#region src/action/contract.ts
3
+ function defineActionSink(spec) {
4
+ if (!Number.isInteger(spec.delivery.maxAttempts) || spec.delivery.maxAttempts < 1) throw new TypeError(`action sink ${spec.name} must declare at least one delivery attempt`);
5
+ if (spec.delivery.backoffFactor < 1) throw new TypeError(`action sink ${spec.name} must not shrink its backoff`);
6
+ if (spec.delivery.initialBackoffMs < 0 || spec.delivery.maxBackoffMs < spec.delivery.initialBackoffMs) throw new TypeError(`action sink ${spec.name} declares an impossible backoff window`);
7
+ const key = spec.from.key;
8
+ const fingerprint = contractFingerprint({
9
+ name: spec.name,
10
+ key,
11
+ handler: { ...spec.handler },
12
+ delivery: Object.fromEntries(Object.entries(spec.delivery).map(([name, value]) => [name, Number.isFinite(value) ? value : String(value)]))
13
+ });
14
+ return Object.freeze({
15
+ ...spec,
16
+ kind: "checked-action-sink",
17
+ key,
18
+ fingerprint
19
+ });
20
+ }
21
+ /** The next backoff, in milliseconds, after `attempts` failed attempts. */
22
+ function backoffAfter(policy, attempts) {
23
+ const exponent = Math.max(0, attempts - 1);
24
+ const raw = policy.initialBackoffMs * policy.backoffFactor ** exponent;
25
+ return Math.min(policy.maxBackoffMs, Math.round(raw));
26
+ }
27
+ //#endregion
28
+ export { backoffAfter, defineActionSink };
@@ -0,0 +1,35 @@
1
+ import { Schema } from "effect";
2
+ import * as Cause$1 from "effect/Cause";
3
+ //#region src/action/errors.d.ts
4
+ export declare const ACTION_SINK_ERROR_TAGS: readonly ["ActionSinkDeliveryFailure", "OutboxUnavailable"];
5
+ export type ActionSinkErrorTag = (typeof ACTION_SINK_ERROR_TAGS)[number];
6
+ /** The durable outbox could not be read or written. Never a delivery outcome. */
7
+ type OutboxUnavailableSchema = Schema.TaggedStruct<"OutboxUnavailable", {
8
+ readonly operation: Schema.String;
9
+ readonly detail: Schema.String;
10
+ }>;
11
+ declare const OutboxUnavailableBase: Schema.Class<OutboxUnavailable, OutboxUnavailableSchema, Cause$1.YieldableError>;
12
+ export declare class OutboxUnavailable extends OutboxUnavailableBase {}
13
+ /**
14
+ * A handler refused one delivery.
15
+ *
16
+ * `retryable` is the handler's own judgement. A handler that knows the failure
17
+ * cannot change says so, and the runtime dead-letters immediately instead of
18
+ * spending the whole retry budget on an outcome that is already settled.
19
+ */
20
+ type ActionSinkDeliveryFailureSchema = Schema.TaggedStruct<"ActionSinkDeliveryFailure", {
21
+ readonly handler: Schema.String;
22
+ readonly idempotencyKey: Schema.String;
23
+ readonly detail: Schema.String;
24
+ readonly retryable: Schema.Boolean;
25
+ }>;
26
+ declare const ActionSinkDeliveryFailureBase: Schema.Class<ActionSinkDeliveryFailure, ActionSinkDeliveryFailureSchema, Cause$1.YieldableError>;
27
+ export declare class ActionSinkDeliveryFailure extends ActionSinkDeliveryFailureBase {
28
+ static retryable(handler: string, idempotencyKey: string, detail: string): ActionSinkDeliveryFailure;
29
+ static permanent(handler: string, idempotencyKey: string, detail: string): ActionSinkDeliveryFailure;
30
+ }
31
+ /** Why one outbox entry stopped being retried. */
32
+ export declare const DEAD_LETTER_REASONS: readonly ["attempts-exhausted", "permanent", "payload-poison"];
33
+ export type DeadLetterReason = (typeof DEAD_LETTER_REASONS)[number];
34
+ //#endregion
35
+ export {};
@@ -0,0 +1,51 @@
1
+ import { Schema } from "effect";
2
+ //#region src/action/errors.ts
3
+ /**
4
+ * Typed action-sink failures.
5
+ *
6
+ * An action sink has two failure domains and they are deliberately separate.
7
+ * `OutboxUnavailable` is a storage failure: the durable outbox itself could not
8
+ * be read or written, and no delivery decision can be trusted until it
9
+ * recovers. `ActionSinkDeliveryFailure` is a *handler* outcome: the delivery was
10
+ * attempted and the external effect refused it. The first is an operational
11
+ * error the host reports; the second is ordinary traffic for the retry policy
12
+ * and never leaves the delivery loop.
13
+ */
14
+ const ACTION_SINK_ERROR_TAGS = ["ActionSinkDeliveryFailure", "OutboxUnavailable"];
15
+ const OutboxUnavailableBase = Schema.TaggedError()("OutboxUnavailable", {
16
+ operation: Schema.String,
17
+ detail: Schema.String
18
+ });
19
+ var OutboxUnavailable = class extends OutboxUnavailableBase {};
20
+ const ActionSinkDeliveryFailureBase = Schema.TaggedError()("ActionSinkDeliveryFailure", {
21
+ handler: Schema.String,
22
+ idempotencyKey: Schema.String,
23
+ detail: Schema.String,
24
+ retryable: Schema.Boolean
25
+ });
26
+ var ActionSinkDeliveryFailure = class ActionSinkDeliveryFailure extends ActionSinkDeliveryFailureBase {
27
+ static retryable(handler, idempotencyKey, detail) {
28
+ return new ActionSinkDeliveryFailure({
29
+ handler,
30
+ idempotencyKey,
31
+ detail,
32
+ retryable: true
33
+ });
34
+ }
35
+ static permanent(handler, idempotencyKey, detail) {
36
+ return new ActionSinkDeliveryFailure({
37
+ handler,
38
+ idempotencyKey,
39
+ detail,
40
+ retryable: false
41
+ });
42
+ }
43
+ };
44
+ /** Why one outbox entry stopped being retried. */
45
+ const DEAD_LETTER_REASONS = [
46
+ "attempts-exhausted",
47
+ "permanent",
48
+ "payload-poison"
49
+ ];
50
+ //#endregion
51
+ export { ACTION_SINK_ERROR_TAGS, ActionSinkDeliveryFailure, DEAD_LETTER_REASONS, OutboxUnavailable };
@@ -0,0 +1,67 @@
1
+ import { DeadLetterReason, OutboxUnavailable } from "./errors.js";
2
+ import { Context, Effect, Layer, Option } from "effect";
3
+ //#region src/action/outbox.d.ts
4
+ export type OutboxEntryState = "pending" | "delivered" | "dead";
5
+ export interface OutboxEntry {
6
+ /** Monotonic enqueue order. Delivery walks a lane in this order. */
7
+ readonly id: number;
8
+ readonly sink: string;
9
+ readonly partitionId: string;
10
+ readonly idempotencyKey: string;
11
+ /** The declared payload, as the JSON text that was durably written. */
12
+ readonly payload: string;
13
+ readonly state: OutboxEntryState;
14
+ /** Attempts already spent. Zero until the first delivery is tried. */
15
+ readonly attempts: number;
16
+ /** The earliest instant this entry may be attempted again. */
17
+ readonly nextAttemptAtMs: number;
18
+ readonly lastError: string | undefined;
19
+ readonly deadLetterReason: DeadLetterReason | undefined;
20
+ readonly enqueuedAtMs: number;
21
+ readonly settledAtMs: number | undefined;
22
+ }
23
+ /** One decision to deliver, before it has an identity in the outbox. */
24
+ export interface OutboxDraft {
25
+ readonly sink: string;
26
+ readonly partitionId: string;
27
+ readonly idempotencyKey: string;
28
+ readonly payload: string;
29
+ readonly enqueuedAtMs: number;
30
+ }
31
+ export interface OutboxEnqueueReport {
32
+ readonly enqueued: number;
33
+ /** Drafts whose sink and idempotency key were already durable. Never a second effect. */
34
+ readonly absorbed: number;
35
+ }
36
+ /** The durable storage boundary for one outbox implementation. */
37
+ export interface OutboxBacking {
38
+ readonly enqueue: (drafts: readonly OutboxDraft[]) => Effect.Effect<OutboxEnqueueReport, OutboxUnavailable>;
39
+ /** Pending deadline in one lane or the whole sink. Settled entries are excluded. */
40
+ readonly nextDue: (sink: string, partitionId: string | undefined,
41
+ /** Read the oldest pending entry's deadline for strict enqueue order. */
42
+ ordered?: boolean) => Effect.Effect<Option.Option<number>, OutboxUnavailable>;
43
+ /** Pending entries in one lane whose next attempt instant has arrived, in enqueue order. */
44
+ readonly claimDue: (sink: string, partitionId: string | undefined, nowMs: number, limit: number,
45
+ /** Include the future head so an ordered pass cannot skip a retry. */
46
+ ordered?: boolean) => Effect.Effect<readonly OutboxEntry[], OutboxUnavailable>;
47
+ readonly markDelivered: (id: number, attempts: number, atMs: number) => Effect.Effect<void, OutboxUnavailable>;
48
+ readonly reschedule: (id: number, attempts: number, nextAttemptAtMs: number, detail: string) => Effect.Effect<void, OutboxUnavailable>;
49
+ readonly deadLetter: (id: number, attempts: number, reason: DeadLetterReason, detail: string, atMs: number) => Effect.Effect<void, OutboxUnavailable>;
50
+ readonly list: (sink: string, partitionId: string | undefined) => Effect.Effect<readonly OutboxEntry[], OutboxUnavailable>;
51
+ }
52
+ export interface OutboxStoreService extends OutboxBacking {}
53
+ declare const OutboxStore_base: Context.ServiceClass<OutboxStore, "streamsy/action-sink/OutboxStore", OutboxStoreService>;
54
+ export declare class OutboxStore extends OutboxStore_base {}
55
+ /** Lift one durable backing into the service every runtime asks for. */
56
+ export declare function outboxStore(backing: OutboxBacking): OutboxStoreService;
57
+ export declare const outboxStoreLayer: (backing: OutboxBacking) => Layer.Layer<OutboxStore>;
58
+ /**
59
+ * The in-memory backing.
60
+ *
61
+ * It is not a stub. It enforces the same sink-and-key uniqueness and the same
62
+ * lane ordering as SQLite, so a declaration that behaves one way on the memory
63
+ * host behaves the same way on the durable one.
64
+ */
65
+ export declare function makeMemoryOutboxBacking(): OutboxBacking;
66
+ //#endregion
67
+ export {};
@@ -0,0 +1,109 @@
1
+ import "./errors.js";
2
+ import { Context, Effect, Layer, Option } from "effect";
3
+ //#region src/action/outbox.ts
4
+ /**
5
+ * The durable outbox.
6
+ *
7
+ * An action sink cannot enqueue by holding work in memory: the process that
8
+ * decided to deliver is the same process that can die before delivering. So the
9
+ * decision to deliver is written down first, in the same durable step as the
10
+ * fact that caused it, and delivery is a separate, resumable pass over what was
11
+ * written.
12
+ */
13
+ var OutboxStore = class extends Context.Service()("streamsy/action-sink/OutboxStore") {};
14
+ /** Lift one durable backing into the service every runtime asks for. */
15
+ function outboxStore(backing) {
16
+ return OutboxStore.of(backing);
17
+ }
18
+ const outboxStoreLayer = (backing) => Layer.sync(OutboxStore, () => outboxStore(backing));
19
+ /**
20
+ * The in-memory backing.
21
+ *
22
+ * It is not a stub. It enforces the same sink-and-key uniqueness and the same
23
+ * lane ordering as SQLite, so a declaration that behaves one way on the memory
24
+ * host behaves the same way on the durable one.
25
+ */
26
+ function makeMemoryOutboxBacking() {
27
+ const entries = /* @__PURE__ */ new Map();
28
+ const identities = /* @__PURE__ */ new Map();
29
+ let nextId = 1;
30
+ const replace = (id, patch) => {
31
+ const existing = entries.get(id);
32
+ if (existing === void 0) throw new Error(`outbox entry ${id} does not exist`);
33
+ entries.set(id, {
34
+ ...existing,
35
+ ...patch
36
+ });
37
+ };
38
+ const ordered = () => [...entries.values()].toSorted((left, right) => left.id - right.id);
39
+ return {
40
+ enqueue: (drafts) => Effect.sync(() => {
41
+ let enqueued = 0;
42
+ let absorbed = 0;
43
+ for (const draft of drafts) {
44
+ const sinkIdentities = identities.get(draft.sink);
45
+ if (sinkIdentities?.has(draft.idempotencyKey) === true) {
46
+ absorbed += 1;
47
+ continue;
48
+ }
49
+ const id = nextId;
50
+ nextId += 1;
51
+ if (sinkIdentities === void 0) identities.set(draft.sink, /* @__PURE__ */ new Set([draft.idempotencyKey]));
52
+ else sinkIdentities.add(draft.idempotencyKey);
53
+ entries.set(id, {
54
+ id,
55
+ sink: draft.sink,
56
+ partitionId: draft.partitionId,
57
+ idempotencyKey: draft.idempotencyKey,
58
+ payload: draft.payload,
59
+ state: "pending",
60
+ attempts: 0,
61
+ nextAttemptAtMs: draft.enqueuedAtMs,
62
+ lastError: void 0,
63
+ deadLetterReason: void 0,
64
+ enqueuedAtMs: draft.enqueuedAtMs,
65
+ settledAtMs: void 0
66
+ });
67
+ enqueued += 1;
68
+ }
69
+ return {
70
+ enqueued,
71
+ absorbed
72
+ };
73
+ }),
74
+ nextDue: (sink, partitionId, inOrder = false) => Effect.sync(() => {
75
+ const pending = ordered().filter((entry) => entry.sink === sink && entry.state === "pending" && (partitionId === void 0 || entry.partitionId === partitionId));
76
+ const first = pending[0];
77
+ return first === void 0 ? Option.none() : Option.some(inOrder ? first.nextAttemptAtMs : pending.reduce((at, entry) => Math.min(at, entry.nextAttemptAtMs), Infinity));
78
+ }),
79
+ claimDue: (sink, partitionId, nowMs, limit, inOrder = false) => Effect.sync(() => ordered().filter((entry) => entry.sink === sink && entry.state === "pending" && (inOrder || entry.nextAttemptAtMs <= nowMs) && (partitionId === void 0 || entry.partitionId === partitionId)).slice(0, limit)),
80
+ markDelivered: (id, attempts, atMs) => Effect.sync(() => {
81
+ replace(id, {
82
+ state: "delivered",
83
+ attempts,
84
+ settledAtMs: atMs,
85
+ lastError: void 0
86
+ });
87
+ }),
88
+ reschedule: (id, attempts, nextAttemptAtMs, detail) => Effect.sync(() => {
89
+ replace(id, {
90
+ state: "pending",
91
+ attempts,
92
+ nextAttemptAtMs,
93
+ lastError: detail
94
+ });
95
+ }),
96
+ deadLetter: (id, attempts, reason, detail, atMs) => Effect.sync(() => {
97
+ replace(id, {
98
+ state: "dead",
99
+ attempts,
100
+ lastError: detail,
101
+ deadLetterReason: reason,
102
+ settledAtMs: atMs
103
+ });
104
+ }),
105
+ list: (sink, partitionId) => Effect.sync(() => ordered().filter((entry) => entry.sink === sink && (partitionId === void 0 || entry.partitionId === partitionId)))
106
+ };
107
+ }
108
+ //#endregion
109
+ export { OutboxStore, makeMemoryOutboxBacking, outboxStore, outboxStoreLayer };
@@ -0,0 +1,80 @@
1
+ import { ActionSinkRelation, CheckedActionSink } from "./contract.js";
2
+ import { ActionSinkDeliveryFailure, OutboxUnavailable } from "./errors.js";
3
+ import { OutboxDraft, OutboxStore } from "./outbox.js";
4
+ import { t as Wakeup } from "../wakeup-BtnMEjLv.js";
5
+ import { Effect } from "effect";
6
+ //#region src/action/runtime.d.ts
7
+ /** One attempt at one delivery, as the handler sees it. */
8
+ export interface ActionSinkDelivery<Payload> {
9
+ readonly entryId: number;
10
+ readonly sink: string;
11
+ readonly partitionId: string;
12
+ /**
13
+ * The delivery's durable identity.
14
+ *
15
+ * A handler that has already accepted this key must accept it again without
16
+ * repeating the effect. Delivery is at-least-once by construction — the
17
+ * process can die between a successful handler call and the outbox write that
18
+ * records it — so absorbing a repeat is the handler's half of the contract.
19
+ */
20
+ readonly idempotencyKey: string;
21
+ /** One-based: `1` on the first attempt. */
22
+ readonly attempt: number;
23
+ readonly payload: Payload;
24
+ }
25
+ export interface ActionSinkHandler<Payload, Requirements = never> {
26
+ readonly deliver: (delivery: ActionSinkDelivery<Payload>) => Effect.Effect<void, ActionSinkDeliveryFailure, Requirements>;
27
+ }
28
+ /** How a handler refuses the delivery it was given, without restating its own identity. */
29
+ export interface RefuseDelivery {
30
+ /** The effect may still succeed later: the runtime spends another attempt. */
31
+ readonly retryable: (detail: string) => ActionSinkDeliveryFailure;
32
+ /** The effect can never succeed for this payload: the runtime dead-letters it now. */
33
+ readonly permanent: (detail: string) => ActionSinkDeliveryFailure;
34
+ }
35
+ /**
36
+ * Bind a delivery function to a sink's declared handler.
37
+ *
38
+ * A handler's own dependencies stay ordinary Effect requirements, so a host
39
+ * supplies them with a layer and the declaration never mentions the transport:
40
+ * the same sink delivers through a real notifier in the host and through a
41
+ * recording fake in a test, with no branch in between.
42
+ */
43
+ export declare function actionSinkHandler<Payload, From extends ActionSinkRelation, Requirements = never>(sink: CheckedActionSink<Payload, From>, deliver: (delivery: ActionSinkDelivery<Payload>, refuse: RefuseDelivery) => Effect.Effect<void, ActionSinkDeliveryFailure, Requirements>): ActionSinkHandler<Payload, Requirements>;
44
+ export interface DrainOptions {
45
+ /** Restrict the pass to one lane. Omitted, it drains every lane of the sink. */
46
+ readonly partitionId?: string;
47
+ /** Upper bound on entries claimed by one pass. */
48
+ readonly limit?: number;
49
+ /** A future retry blocks every later pending entry in the drained scope. */
50
+ readonly ordered?: boolean;
51
+ }
52
+ export interface DrainReport {
53
+ readonly sink: string;
54
+ /** Entries this pass attempted. An ordered pass does not count the future head it read. */
55
+ readonly claimed: number;
56
+ readonly delivered: number;
57
+ /** Entries that failed and are scheduled for another attempt. */
58
+ readonly retried: number;
59
+ readonly deadLettered: number;
60
+ }
61
+ export declare const DEFAULT_DRAIN_LIMIT = 100;
62
+ /** Lower declared payloads to outbox drafts. Pure, so a caller can write them transactionally. */
63
+ export declare function draftsFor<Payload, From extends ActionSinkRelation>(sink: CheckedActionSink<Payload, From>, payloads: readonly Payload[], enqueuedAtMs: number): readonly OutboxDraft[];
64
+ /**
65
+ * Deliver due entries serially, applying the declared retry policy and optional strict order.
66
+ *
67
+ * Each sink's permit is not reentrant. A handler must not drain its own sink.
68
+ */
69
+ export declare const drain: <Payload, From extends ActionSinkRelation, Requirements = never>(sink: CheckedActionSink<Payload, From>, handler: ActionSinkHandler<Payload, Requirements>, options?: DrainOptions) => Effect.Effect<DrainReport, OutboxUnavailable, OutboxStore | Requirements>;
70
+ /**
71
+ * The wakeup for one action outbox. The host entry does not load action code.
72
+ *
73
+ * Its name is `streamsy/outbox/<sink>` or `streamsy/outbox/<sink>/<partition>`,
74
+ * with each part URI-encoded so a sink named `a/b` cannot collide with a lane.
75
+ * A pass that fills its limit reports more work, so a backlog is not backed off.
76
+ */
77
+ export declare const OutboxWakeup: {
78
+ make: <Payload, From extends ActionSinkRelation, R = never>(sink: CheckedActionSink<Payload, From>, handler: ActionSinkHandler<Payload, R>, options?: DrainOptions) => Wakeup<OutboxStore | R>;
79
+ };
80
+ //#endregion