@streamsy/serve 0.0.0-stage → 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.
@@ -0,0 +1,182 @@
1
+ import { backoffAfter } from "./contract.js";
2
+ import { ActionSinkDeliveryFailure } from "./errors.js";
3
+ import { OutboxStore } from "./outbox.js";
4
+ import { n as Wakeup } from "../wakeup-CWKg8pts.js";
5
+ import { Cause, Clock, Effect, Exit, Semaphore } from "effect";
6
+ import { StorageFault } from "@streamsy/core";
7
+ //#region src/action/runtime.ts
8
+ /**
9
+ * The delivery runtime: one bounded, serialized pass over a sink's outbox.
10
+ *
11
+ * `drain` is the whole runtime. It claims the entries in one lane whose next
12
+ * attempt instant has arrived, and delivers them **one at a time in enqueue
13
+ * order** — never concurrently, because an external effect that must not be
14
+ * duplicated must also not be interleaved with its own retry.
15
+ *
16
+ * With `ordered`, a future retry blocks later entries in the drained scope.
17
+ * By default, serialized is not the same as blocking. A failing entry is rescheduled into
18
+ * the future and the pass moves on to the next one, so a poisoned delivery
19
+ * cannot hold a lane's healthy traffic hostage. That is the difference between
20
+ * an ordered queue and a stuck one.
21
+ *
22
+ * A pass has exactly one operational failure mode: the outbox itself. Every
23
+ * handler outcome — a typed refusal, a thrown defect, a payload that no longer
24
+ * decodes — is recorded against the entry and the pass keeps going, so a broken
25
+ * handler can never fail the caller that asked for a drain.
26
+ */
27
+ /**
28
+ * Bind a delivery function to a sink's declared handler.
29
+ *
30
+ * A handler's own dependencies stay ordinary Effect requirements, so a host
31
+ * supplies them with a layer and the declaration never mentions the transport:
32
+ * the same sink delivers through a real notifier in the host and through a
33
+ * recording fake in a test, with no branch in between.
34
+ */
35
+ function actionSinkHandler(sink, deliver) {
36
+ return { deliver: (delivery) => deliver(delivery, {
37
+ retryable: (detail) => ActionSinkDeliveryFailure.retryable(sink.handler.name, delivery.idempotencyKey, detail),
38
+ permanent: (detail) => ActionSinkDeliveryFailure.permanent(sink.handler.name, delivery.idempotencyKey, detail)
39
+ }) };
40
+ }
41
+ const DEFAULT_DRAIN_LIMIT = 100;
42
+ /** Lower declared payloads to outbox drafts. Pure, so a caller can write them transactionally. */
43
+ function draftsFor(sink, payloads, enqueuedAtMs) {
44
+ return payloads.map((payload) => ({
45
+ sink: sink.name,
46
+ partitionId: sink.partitionBy(payload),
47
+ idempotencyKey: sink.idempotencyKey(payload),
48
+ payload: sink.payload.encode(payload),
49
+ enqueuedAtMs
50
+ }));
51
+ }
52
+ const drainPermits = /* @__PURE__ */ new WeakMap();
53
+ const drainPermit = (store, sink) => {
54
+ let permits = drainPermits.get(store);
55
+ if (permits === void 0) {
56
+ permits = /* @__PURE__ */ new Map();
57
+ drainPermits.set(store, permits);
58
+ }
59
+ let permit = permits.get(sink);
60
+ if (permit === void 0) {
61
+ permit = Semaphore.makeUnsafe(1);
62
+ permits.set(sink, permit);
63
+ }
64
+ return permit;
65
+ };
66
+ /**
67
+ * Deliver due entries serially, applying the declared retry policy and optional strict order.
68
+ *
69
+ * Each sink's permit is not reentrant. A handler must not drain its own sink.
70
+ */
71
+ const drain = (sink, handler, options = {}) => Effect.gen(function* () {
72
+ const store = yield* OutboxStore;
73
+ return yield* drainPermit(store, sink.name).withPermit(Effect.gen(function* () {
74
+ const startedAtMs = yield* Clock.currentTimeMillis;
75
+ const claimed = yield* store.claimDue(sink.name, options.partitionId, startedAtMs, options.limit ?? 100, options.ordered);
76
+ let attempted = 0;
77
+ let delivered = 0;
78
+ let retried = 0;
79
+ let deadLettered = 0;
80
+ for (const entry of claimed) {
81
+ if (entry.nextAttemptAtMs > (yield* Clock.currentTimeMillis)) break;
82
+ attempted += 1;
83
+ const attempt = entry.attempts + 1;
84
+ const decoded = decodePayload(sink, entry);
85
+ if (decoded.kind === "poison") {
86
+ yield* settle(entry, attempt, "payload-poison", decoded.detail);
87
+ deadLettered += 1;
88
+ continue;
89
+ }
90
+ const exit = yield* Effect.exit(handler.deliver({
91
+ entryId: entry.id,
92
+ sink: entry.sink,
93
+ partitionId: entry.partitionId,
94
+ idempotencyKey: entry.idempotencyKey,
95
+ attempt,
96
+ payload: decoded.payload
97
+ }));
98
+ if (Exit.isSuccess(exit)) {
99
+ const at = yield* Clock.currentTimeMillis;
100
+ yield* store.markDelivered(entry.id, attempt, at);
101
+ delivered += 1;
102
+ continue;
103
+ }
104
+ if (Cause.hasInterrupts(exit.cause)) return yield* Effect.interrupt;
105
+ const failure = failureOf(sink, entry, exit.cause);
106
+ const reason = deadLetterReason(sink, attempt, failure);
107
+ if (reason !== void 0) {
108
+ yield* settle(entry, attempt, reason, failure.detail);
109
+ deadLettered += 1;
110
+ continue;
111
+ }
112
+ const at = yield* Clock.currentTimeMillis;
113
+ yield* store.reschedule(entry.id, attempt, at + backoffAfter(sink.delivery, attempt), failure.detail);
114
+ retried += 1;
115
+ if (options.ordered) break;
116
+ }
117
+ return {
118
+ sink: sink.name,
119
+ claimed: attempted,
120
+ delivered,
121
+ retried,
122
+ deadLettered
123
+ };
124
+ function settle(entry, attempt, reason, detail) {
125
+ return Clock.currentTimeMillis.pipe(Effect.flatMap((at) => store.deadLetter(entry.id, attempt, reason, detail, at)));
126
+ }
127
+ }));
128
+ });
129
+ /**
130
+ * A stored payload the declared codec now rejects is dead on arrival.
131
+ *
132
+ * Retrying it would re-run the same decode against the same bytes forever, so
133
+ * it dead-letters immediately — fail-stop for that entry, and only that entry.
134
+ */
135
+ function decodePayload(sink, entry) {
136
+ try {
137
+ return {
138
+ kind: "payload",
139
+ payload: sink.payload.decode(JSON.parse(entry.payload))
140
+ };
141
+ } catch (cause) {
142
+ return {
143
+ kind: "poison",
144
+ detail: describe(cause)
145
+ };
146
+ }
147
+ }
148
+ /** Every non-interrupt cause becomes one retryable refusal; a thrown handler is still a handler. */
149
+ function failureOf(sink, entry, cause) {
150
+ const declared = cause.reasons.find(Cause.isFailReason);
151
+ if (declared !== void 0) return declared.error;
152
+ return ActionSinkDeliveryFailure.retryable(sink.handler.name, entry.idempotencyKey, Cause.pretty(cause).slice(0, 500));
153
+ }
154
+ function deadLetterReason(sink, attempt, failure) {
155
+ if (!failure.retryable) return "permanent";
156
+ if (attempt >= sink.delivery.maxAttempts) return "attempts-exhausted";
157
+ }
158
+ function describe(cause) {
159
+ return cause instanceof Error ? cause.message : String(cause);
160
+ }
161
+ /**
162
+ * The wakeup for one action outbox. The host entry does not load action code.
163
+ *
164
+ * Its name is `streamsy/outbox/<sink>` or `streamsy/outbox/<sink>/<partition>`,
165
+ * with each part URI-encoded so a sink named `a/b` cannot collide with a lane.
166
+ * A pass that fills its limit reports more work, so a backlog is not backed off.
167
+ */
168
+ const OutboxWakeup = { make: (sink, handler, options = {}) => Wakeup.make({
169
+ name: outboxWakeupName(sink.name, options.partitionId),
170
+ next: OutboxStore.use((store) => store.nextDue(sink.name, options.partitionId, options.ordered)).pipe(Effect.mapError((fault) => new StorageFault({
171
+ operation: fault.operation,
172
+ message: fault.detail,
173
+ retryable: true
174
+ }))),
175
+ run: drain(sink, handler, options).pipe(Effect.map((report) => ({ more: report.claimed >= (options.limit ?? 100) })))
176
+ }) };
177
+ function outboxWakeupName(sink, partitionId) {
178
+ const lane = partitionId === void 0 ? "" : `/${encodeURIComponent(partitionId)}`;
179
+ return `streamsy/outbox/${encodeURIComponent(sink)}${lane}`;
180
+ }
181
+ //#endregion
182
+ export { DEFAULT_DRAIN_LIMIT, OutboxWakeup, actionSinkHandler, draftsFor, drain };
@@ -0,0 +1,8 @@
1
+ import { OutboxUnavailable } from "./errors.js";
2
+ import { OutboxBacking } from "./outbox.js";
3
+ import { Effect } from "effect";
4
+ import { SqlClient } from "effect/sql/SqlClient";
5
+ //#region src/action/sql.d.ts
6
+ export declare function migrateOutbox(sql: SqlClient): Effect.Effect<void, OutboxUnavailable>;
7
+ export declare function createSqlOutboxBacking(sql: SqlClient): OutboxBacking;
8
+ //#endregion
@@ -0,0 +1,229 @@
1
+ import { OutboxUnavailable } from "./errors.js";
2
+ import { Effect, Option, Predicate, Schema } from "effect";
3
+ //#region src/action/sql.ts
4
+ const TABLE = "streamsy_effect_outbox";
5
+ /** MySQL key columns. Their sum fits the 3072-byte InnoDB index limit at 4 bytes a character. */
6
+ const MYSQL_SINK_LENGTH = 255;
7
+ const MYSQL_PARTITION_LENGTH = 255;
8
+ const MYSQL_KEY_LENGTH = 512;
9
+ const MYSQL_KEY_TYPE = "CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL";
10
+ const CREATE_TABLE = {
11
+ sqlite: `CREATE TABLE IF NOT EXISTS ${TABLE} (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ sink TEXT NOT NULL,
14
+ partition_id TEXT NOT NULL,
15
+ idempotency_key TEXT NOT NULL,
16
+ payload TEXT NOT NULL,
17
+ state TEXT NOT NULL,
18
+ attempts INTEGER NOT NULL DEFAULT 0,
19
+ next_attempt_at_ms INTEGER NOT NULL,
20
+ last_error TEXT,
21
+ dead_letter_reason TEXT,
22
+ enqueued_at_ms INTEGER NOT NULL,
23
+ settled_at_ms INTEGER
24
+ )`,
25
+ pg: `CREATE TABLE IF NOT EXISTS ${TABLE} (
26
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
27
+ sink TEXT NOT NULL,
28
+ partition_id TEXT NOT NULL,
29
+ idempotency_key TEXT NOT NULL,
30
+ payload TEXT NOT NULL,
31
+ state TEXT NOT NULL,
32
+ attempts INTEGER NOT NULL DEFAULT 0,
33
+ next_attempt_at_ms BIGINT NOT NULL,
34
+ last_error TEXT,
35
+ dead_letter_reason TEXT,
36
+ enqueued_at_ms BIGINT NOT NULL,
37
+ settled_at_ms BIGINT
38
+ )`,
39
+ mysql: `CREATE TABLE IF NOT EXISTS ${TABLE} (
40
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
41
+ sink VARCHAR(${MYSQL_SINK_LENGTH}) ${MYSQL_KEY_TYPE},
42
+ partition_id VARCHAR(${MYSQL_PARTITION_LENGTH}) ${MYSQL_KEY_TYPE},
43
+ idempotency_key VARCHAR(${MYSQL_KEY_LENGTH}) ${MYSQL_KEY_TYPE},
44
+ payload LONGTEXT NOT NULL,
45
+ state VARCHAR(16) NOT NULL,
46
+ attempts INT NOT NULL DEFAULT 0,
47
+ next_attempt_at_ms BIGINT NOT NULL,
48
+ last_error LONGTEXT,
49
+ dead_letter_reason VARCHAR(32),
50
+ enqueued_at_ms BIGINT NOT NULL,
51
+ settled_at_ms BIGINT
52
+ )`
53
+ };
54
+ const INDEXES = [
55
+ {
56
+ name: `${TABLE}_identity`,
57
+ unique: true,
58
+ columns: "(sink, idempotency_key)"
59
+ },
60
+ {
61
+ name: `${TABLE}_due`,
62
+ unique: false,
63
+ columns: "(sink, state, next_attempt_at_ms, id)"
64
+ },
65
+ {
66
+ name: `${TABLE}_order`,
67
+ unique: false,
68
+ columns: "(sink, state, id)"
69
+ }
70
+ ];
71
+ const createIndex = (index, ifNotExists) => `CREATE ${index.unique ? "UNIQUE " : ""}INDEX ${ifNotExists ? "IF NOT EXISTS " : ""}${index.name} ON ${TABLE} ${index.columns}`;
72
+ const isOutboxUnavailable = Schema.is(OutboxUnavailable);
73
+ const attempt = (operation, effect) => effect.pipe(Effect.mapError((cause) => isOutboxUnavailable(cause) ? cause : new OutboxUnavailable({
74
+ operation,
75
+ detail: cause instanceof Error ? cause.message : String(cause)
76
+ })));
77
+ const unsupported = (operation) => Effect.fail(new OutboxUnavailable({
78
+ operation,
79
+ detail: "Unsupported SQL dialect; the SQL outbox supports sqlite, pg and mysql"
80
+ }));
81
+ /** The names of the outbox indexes that exist in the current MySQL database. */
82
+ const mysqlIndexes = (sql) => sql`SELECT DISTINCT index_name AS name FROM information_schema.statistics
83
+ WHERE table_schema = DATABASE() AND table_name = ${TABLE}`.pipe(Effect.map((rows) => new Set(rows.map(({ name }) => name))));
84
+ /** MySQL has no `CREATE INDEX IF NOT EXISTS`. A concurrent migration may win the create. */
85
+ const migrateMysql = (sql) => Effect.gen(function* () {
86
+ if (Option.isSome(yield* Effect.serviceOption(sql.transactionService))) return yield* new OutboxUnavailable({
87
+ operation: "migrateOutbox",
88
+ detail: "On MySQL, run migrateOutbox outside a transaction, at placement start"
89
+ });
90
+ yield* sql.unsafe(CREATE_TABLE.mysql);
91
+ for (const index of INDEXES) {
92
+ if ((yield* mysqlIndexes(sql)).has(index.name)) continue;
93
+ yield* sql.unsafe(createIndex(index, false)).pipe(Effect.catch((error) => Effect.flatMap(mysqlIndexes(sql), (names) => names.has(index.name) ? Effect.void : Effect.fail(error))));
94
+ }
95
+ });
96
+ function migrateOutbox(sql) {
97
+ const statements = (dialect) => Effect.forEach([CREATE_TABLE[dialect], ...INDEXES.map((index) => createIndex(index, true))], (statement) => sql.unsafe(statement), { discard: true });
98
+ return attempt("migrateOutbox", sql.onDialectOrElse({
99
+ sqlite: () => statements("sqlite"),
100
+ pg: () => statements("pg"),
101
+ mysql: () => migrateMysql(sql),
102
+ orElse: () => unsupported("migrateOutbox")
103
+ }));
104
+ }
105
+ /**
106
+ * MySQL readiness only reads the catalog. The backing may run inside a caller's
107
+ * transaction, and DDL there would commit it early.
108
+ */
109
+ const mysqlReady = (sql) => Effect.flatMap(mysqlIndexes(sql), (names) => INDEXES.every((index) => names.has(index.name)) ? Effect.void : Effect.fail(new OutboxUnavailable({
110
+ operation: "ready",
111
+ detail: `${TABLE} is not migrated; on MySQL, run migrateOutbox at placement start`
112
+ })));
113
+ /** MySQL `INSERT IGNORE` would truncate an over-long key, so refuse it first. */
114
+ const mysqlKeyFault = (draft) => {
115
+ const limits = [
116
+ [
117
+ "sink",
118
+ draft.sink,
119
+ MYSQL_SINK_LENGTH
120
+ ],
121
+ [
122
+ "partition id",
123
+ draft.partitionId,
124
+ MYSQL_PARTITION_LENGTH
125
+ ],
126
+ [
127
+ "idempotency key",
128
+ draft.idempotencyKey,
129
+ MYSQL_KEY_LENGTH
130
+ ]
131
+ ];
132
+ for (const [label, value, limit] of limits) {
133
+ const length = [...value].length;
134
+ if (length > limit) return new OutboxUnavailable({
135
+ operation: "enqueue",
136
+ detail: `The ${label} has ${length} characters; the MySQL outbox limit is ${limit}`
137
+ });
138
+ }
139
+ };
140
+ function createSqlOutboxBacking(sql) {
141
+ const ready = Effect.suspend(() => sql.onDialectOrElse({
142
+ mysql: () => mysqlReady(sql),
143
+ orElse: () => migrateOutbox(sql)
144
+ }));
145
+ const run = (operation, statement) => attempt(operation, Effect.andThen(ready, statement));
146
+ const execute = (operation, statement) => run(operation, statement).pipe(Effect.asVoid);
147
+ const rows = (operation, statement) => run(operation, statement).pipe(Effect.map((found) => found.map(decodeRow)));
148
+ /** Whether the draft was new. A draft with a known sink and key changes nothing. */
149
+ const insert = (draft) => {
150
+ const row = sql.insert({
151
+ sink: draft.sink,
152
+ partition_id: draft.partitionId,
153
+ idempotency_key: draft.idempotencyKey,
154
+ payload: draft.payload,
155
+ state: "pending",
156
+ attempts: 0,
157
+ next_attempt_at_ms: draft.enqueuedAtMs,
158
+ enqueued_at_ms: draft.enqueuedAtMs
159
+ });
160
+ return sql.onDialectOrElse({
161
+ mysql: () => {
162
+ const fault = mysqlKeyFault(draft);
163
+ if (fault !== void 0) return Effect.fail(fault);
164
+ return sql`INSERT IGNORE INTO streamsy_effect_outbox ${row}`.raw.pipe(Effect.map((result) => Predicate.hasProperty(result, "affectedRows") && Number(result.affectedRows) > 0));
165
+ },
166
+ orElse: () => sql`INSERT INTO streamsy_effect_outbox ${row}
167
+ ON CONFLICT (sink, idempotency_key) DO NOTHING RETURNING 1 AS inserted`.pipe(Effect.map((inserted) => inserted.length > 0))
168
+ });
169
+ };
170
+ return {
171
+ enqueue: (drafts) => run("enqueue", Effect.gen(function* () {
172
+ let enqueued = 0;
173
+ for (const draft of drafts) if (yield* insert(draft)) enqueued += 1;
174
+ return {
175
+ enqueued,
176
+ absorbed: drafts.length - enqueued
177
+ };
178
+ })),
179
+ nextDue: (sink, partitionId, ordered = false) => run("nextDue", sql`SELECT next_attempt_at_ms AS at
180
+ FROM streamsy_effect_outbox WHERE ${sql.and([
181
+ sql`sink = ${sink}`,
182
+ sql`state = 'pending'`,
183
+ ...partitionId === void 0 ? [] : [sql`partition_id = ${partitionId}`]
184
+ ])} ORDER BY ${ordered ? sql`id` : sql`next_attempt_at_ms, id`} LIMIT 1`).pipe(Effect.map((found) => Option.fromUndefinedOr(found[0]).pipe(Option.map((row) => Number(row.at))))),
185
+ claimDue: (sink, partitionId, nowMs, limit, ordered = false) => rows("claimDue", sql`SELECT * FROM streamsy_effect_outbox WHERE ${sql.and([
186
+ sql`sink = ${sink}`,
187
+ ...partitionId === void 0 ? [] : [sql`partition_id = ${partitionId}`],
188
+ sql`state = 'pending'`,
189
+ ...ordered ? [] : [sql`next_attempt_at_ms <= ${nowMs}`]
190
+ ])} ORDER BY id LIMIT ${limit}`),
191
+ markDelivered: (id, attempts, atMs) => execute("markDelivered", sql`UPDATE streamsy_effect_outbox SET state = 'delivered', attempts = ${attempts},
192
+ last_error = NULL, settled_at_ms = ${atMs} WHERE id = ${id}`),
193
+ reschedule: (id, attempts, nextAttemptAtMs, detail) => execute("reschedule", sql`UPDATE streamsy_effect_outbox SET state = 'pending', attempts = ${attempts},
194
+ next_attempt_at_ms = ${nextAttemptAtMs}, last_error = ${detail} WHERE id = ${id}`),
195
+ deadLetter: (id, attempts, reason, detail, atMs) => execute("deadLetter", sql`UPDATE streamsy_effect_outbox SET state = 'dead', attempts = ${attempts},
196
+ dead_letter_reason = ${reason}, last_error = ${detail}, settled_at_ms = ${atMs} WHERE id = ${id}`),
197
+ list: (sink, partitionId) => rows("list", sql`SELECT * FROM streamsy_effect_outbox WHERE ${sql.and([sql`sink = ${sink}`, ...partitionId === void 0 ? [] : [sql`partition_id = ${partitionId}`]])} ORDER BY id`)
198
+ };
199
+ }
200
+ /** Postgres returns BIGINT as a `bigint`; SQLite and MySQL return a `number`. */
201
+ const toNumber = (value) => Number(value);
202
+ function decodeRow(row) {
203
+ return {
204
+ id: toNumber(row.id),
205
+ sink: row.sink,
206
+ partitionId: row.partition_id,
207
+ idempotencyKey: row.idempotency_key,
208
+ payload: row.payload,
209
+ state: decodeState(row.state),
210
+ attempts: row.attempts,
211
+ nextAttemptAtMs: toNumber(row.next_attempt_at_ms),
212
+ lastError: row.last_error ?? void 0,
213
+ deadLetterReason: decodeReason(row.dead_letter_reason),
214
+ enqueuedAtMs: toNumber(row.enqueued_at_ms),
215
+ settledAtMs: row.settled_at_ms === null ? void 0 : toNumber(row.settled_at_ms)
216
+ };
217
+ }
218
+ /** A stored state this build does not know is a schema fault, never a silent "pending". */
219
+ function decodeState(value) {
220
+ if (value === "pending" || value === "delivered" || value === "dead") return value;
221
+ throw new TypeError(`unknown outbox state ${value}`);
222
+ }
223
+ function decodeReason(value) {
224
+ if (value === null) return void 0;
225
+ if (value === "attempts-exhausted" || value === "permanent" || value === "payload-poison") return value;
226
+ throw new TypeError(`unknown outbox dead-letter reason ${value}`);
227
+ }
228
+ //#endregion
229
+ export { createSqlOutboxBacking, migrateOutbox };
@@ -0,0 +1,25 @@
1
+ import { a as alarmSlotLayer, i as AlarmSlot, n as WakeupProgress, r as Wakeups, t as Wakeup } from "./wakeup-BtnMEjLv.js";
2
+ import { i as ObjectServices, n as rule, r as ObjectApp, t as Placement } from "./placement-BOM3E08P.js";
3
+ import { Effect, Layer, Scope } from "effect";
4
+ import { HttpServerRequest, HttpServerResponse } from "effect/http";
5
+ import { Storage, StorageFault } from "@streamsy/core";
6
+ import { DurableObject } from "alchemy/Cloudflare";
7
+ import { HttpServerError } from "effect/http/HttpServerError";
8
+ //#region src/alchemy.d.ts
9
+ /** An Alchemy HttpEffect; authenticate before evaluating this router. */
10
+ export declare const router: (options: {
11
+ readonly objects: Pick<DurableObject, "getByName">;
12
+ readonly prefix?: `/${string}`;
13
+ readonly placement?: Placement;
14
+ }) => Effect.Effect<HttpServerResponse.HttpServerResponse, HttpServerError, HttpServerRequest.HttpServerRequest>;
15
+ export type ObjectHandlers = {
16
+ readonly fetch: Effect.Effect<HttpServerResponse.HttpServerResponse, never, HttpServerRequest.HttpServerRequest>;
17
+ readonly alarm: () => Effect.Effect<void>;
18
+ };
19
+ export declare function objectHandlers<R = never>(options: {
20
+ readonly app: ObjectApp<R>;
21
+ readonly layer: Layer.Layer<Storage | AlarmSlot | R, StorageFault>;
22
+ readonly wakeups?: ReadonlyArray<Wakeup<ObjectServices<R>>>;
23
+ }): Effect.Effect<ObjectHandlers, never, Scope.Scope>;
24
+ //#endregion
25
+ export { Placement, Wakeup, type WakeupProgress, Wakeups, alarmSlotLayer, rule };
@@ -0,0 +1,39 @@
1
+ import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-CWKg8pts.js";
2
+ import { a as acquireObject, i as rule, o as providedApp, r as Placement, s as unavailable, t as resolvePlacement } from "./router-DviIc1O7.js";
3
+ import { Effect, Option } from "effect";
4
+ import { HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/http";
5
+ import { securityHeaders, streamPath } from "@streamsy/core/http";
6
+ //#region src/alchemy.ts
7
+ /** An Alchemy HttpEffect; authenticate before evaluating this router. */
8
+ const router = (options) => {
9
+ const path = streamPath(options.prefix);
10
+ const placement = options.placement ?? Placement.byStream();
11
+ return Effect.gen(function* () {
12
+ const request = yield* HttpServerRequest.HttpServerRequest;
13
+ const pathname = new URL(request.url, "https://streamsy.internal").pathname;
14
+ const streamPath = path.strip(pathname);
15
+ if (!streamPath || streamPath === pathname) return HttpServerResponse.text(`Stream path required: ${path.requiredPathPattern()}`, {
16
+ status: 400,
17
+ headers: securityHeaders
18
+ });
19
+ const child = resolvePlacement(placement, streamPath);
20
+ if (!child.ok) return HttpServerResponse.fromWeb(child.response);
21
+ return yield* options.objects.getByName(child.name).fetch(request);
22
+ });
23
+ };
24
+ function objectHandlers(options) {
25
+ return Effect.gen(function* () {
26
+ const get = yield* acquireObject(options.layer, (context, alarm) => Effect.gen(function* () {
27
+ return {
28
+ fetch: (yield* HttpRouter.toHttpEffect(providedApp(options.app, context))).pipe(Effect.scoped, Effect.catch(HttpServerRespondable.toResponse)),
29
+ alarm: alarm.pipe(Effect.provide(context))
30
+ };
31
+ }), options.wakeups);
32
+ return {
33
+ fetch: Effect.flatMap(get.pipe(Effect.asSome, Effect.catch(() => Effect.succeedNone), Effect.catchDefect(() => Effect.succeedNone)), (compiled) => Option.isSome(compiled) ? compiled.value.fetch : Effect.succeed(unavailable())),
34
+ alarm: () => Effect.flatMap(get, (compiled) => compiled.alarm).pipe(Effect.orDie)
35
+ };
36
+ });
37
+ }
38
+ //#endregion
39
+ export { Placement, Wakeup, Wakeups, alarmSlotLayer, objectHandlers, router, rule };
package/dist/bun.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { Duration } from "effect";
2
+ //#region src/bun.d.ts
3
+ export declare const DEFAULT_PORT = 3000;
4
+ export declare const DEFAULT_HOSTNAME = "127.0.0.1";
5
+ export interface ListenerOptions {
6
+ readonly port?: number;
7
+ readonly hostname?: string;
8
+ readonly idleTimeout?: number;
9
+ readonly gracefulShutdownTimeout?: Duration.Input;
10
+ }
11
+ /** The Bun listener that keeps a parked long poll alive. */
12
+ export declare const listener: (options?: ListenerOptions) => import("effect/Layer").Layer<import("effect/http/Etag").Generator | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/HttpServer").HttpServer | import("@effect/platform-bun/BunServices").BunServices, import("effect/http/HttpServerError").ServeError, never>;
13
+ //#endregion
package/dist/bun.js ADDED
@@ -0,0 +1,14 @@
1
+ import { BunHttpServer } from "@effect/platform-bun";
2
+ //#region src/bun.ts
3
+ const DEFAULT_PORT = 3e3;
4
+ const DEFAULT_HOSTNAME = "127.0.0.1";
5
+ const listenerOptions = (options) => ({
6
+ port: options.port ?? 3e3,
7
+ hostname: options.hostname ?? "127.0.0.1",
8
+ idleTimeout: options.idleTimeout ?? 0,
9
+ ...options.gracefulShutdownTimeout === void 0 ? { disablePreemptiveShutdown: true } : { gracefulShutdownTimeout: options.gracefulShutdownTimeout }
10
+ });
11
+ /** The Bun listener that keeps a parked long poll alive. */
12
+ const listener = (options = {}) => BunHttpServer.layer(listenerOptions(options));
13
+ //#endregion
14
+ export { DEFAULT_HOSTNAME, DEFAULT_PORT, listener };
@@ -0,0 +1,35 @@
1
+ import { a as alarmSlotLayer, n as WakeupProgress, r as Wakeups, t as Wakeup } from "./wakeup-BtnMEjLv.js";
2
+ import { i as ObjectServices, n as rule, r as ObjectApp, t as Placement } from "./placement-BOM3E08P.js";
3
+ import { Layer } from "effect";
4
+ import { Storage, StorageFault } from "@streamsy/core";
5
+ import { DurableObject } from "cloudflare:workers";
6
+ import { AlarmInvocationInfo, DurableObjectNamespace, DurableObjectState, ExportedHandler } from "@cloudflare/workers-types";
7
+ //#region src/cloudflare/object.d.ts
8
+ interface ObjectConfiguration<Env, R = never> {
9
+ readonly app: ObjectApp<R>;
10
+ readonly wakeups?: ReadonlyArray<Wakeup<ObjectServices<R>>>;
11
+ readonly layer: (state: DurableObjectState, env: Env) => Layer.Layer<Storage | R, StorageFault>;
12
+ }
13
+ interface ObjectInstance {
14
+ fetch(request: Request): Promise<Response>;
15
+ alarm(info?: AlarmInvocationInfo): Promise<void>;
16
+ }
17
+ declare class ObjectHost<Env, R> extends DurableObject<Env> {
18
+ #private;
19
+ constructor(state: DurableObjectState, env: Env, configuration: ObjectConfiguration<Env, R>);
20
+ fetch(request: Request): Promise<Response>;
21
+ alarm(_info?: AlarmInvocationInfo): Promise<void>;
22
+ }
23
+ export declare const StreamsyObject: {
24
+ make: <Env = unknown, R = never>(configuration: ObjectConfiguration<Env, R>) => new (state: DurableObjectState, env: Env) => ObjectHost<Env, R>;
25
+ };
26
+ //#endregion
27
+ //#region src/cloudflare/router.d.ts
28
+ interface RouterOptions<Env> {
29
+ readonly namespace: (env: Env) => DurableObjectNamespace;
30
+ readonly placement?: Placement;
31
+ readonly prefix?: `/${string}`;
32
+ }
33
+ export declare const router: <Env>(options: RouterOptions<Env>) => ExportedHandler<Env>;
34
+ //#endregion
35
+ export { type ObjectConfiguration, type ObjectInstance, Placement, type RouterOptions, Wakeup, type WakeupProgress, Wakeups, alarmSlotLayer, rule };
@@ -0,0 +1,40 @@
1
+ import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-CWKg8pts.js";
2
+ import { a as acquireObject, i as rule, n as router, o as providedApp, r as Placement, s as unavailable } from "./router-DviIc1O7.js";
3
+ import { Context, Effect, Layer, ManagedRuntime, Option } from "effect";
4
+ import { HttpRouter, HttpServerResponse } from "effect/http";
5
+ import { DurableObject } from "cloudflare:workers";
6
+ //#region src/cloudflare/object.ts
7
+ var ObjectOwner = class extends Context.Service()("streamsy/ObjectOwner") {};
8
+ var ObjectHost = class extends DurableObject {
9
+ #runtime;
10
+ constructor(state, env, configuration) {
11
+ super(state, env);
12
+ this.#runtime = ManagedRuntime.make(Layer.effect(ObjectOwner, Effect.map(acquireObject(Layer.suspend(() => Layer.merge(configuration.layer(state, env), alarmSlotLayer(state.storage))), (context, alarm) => Effect.gen(function* () {
13
+ const web = HttpRouter.toWebHandler(providedApp(configuration.app, context), { disableLogger: true });
14
+ yield* Effect.addFinalizer(() => Effect.promise(web.dispose));
15
+ return {
16
+ handler: web.handler,
17
+ alarm: alarm.pipe(Effect.provide(context))
18
+ };
19
+ }), configuration.wakeups), (get) => ({ get }))));
20
+ }
21
+ fetch(request) {
22
+ return this.#runtime.runPromise(Effect.gen(function* () {
23
+ const compiled = yield* (yield* ObjectOwner).get.pipe(Effect.asSome, Effect.catch(() => Effect.succeedNone), Effect.catchDefect(() => Effect.succeedNone));
24
+ if (Option.isNone(compiled)) return HttpServerResponse.toWeb(unavailable());
25
+ return yield* Effect.promise(() => compiled.value.handler(request));
26
+ }));
27
+ }
28
+ alarm(_info) {
29
+ return this.#runtime.runPromise(Effect.gen(function* () {
30
+ yield* (yield* (yield* ObjectOwner).get).alarm;
31
+ }));
32
+ }
33
+ };
34
+ const StreamsyObject = { make: (configuration) => class extends ObjectHost {
35
+ constructor(state, env) {
36
+ super(state, env, configuration);
37
+ }
38
+ } };
39
+ //#endregion
40
+ export { Placement, StreamsyObject, Wakeup, Wakeups, alarmSlotLayer, router, rule };
@@ -0,0 +1,67 @@
1
+ //#region src/contract.d.ts
2
+ /**
3
+ * Canonical encoding and the change-detection digests served routes need.
4
+ *
5
+ * A contract fingerprint and a value ETag answer the same question — "is
6
+ * this still the thing you were holding?" — so both are computed from one
7
+ * canonical encoding. Object keys are sorted and array order is preserved, so
8
+ * two values that differ only in property order encode identically and cannot
9
+ * invalidate a consumer's cache for no reason.
10
+ *
11
+ * These are change-detection identities, not security digests.
12
+ */
13
+ type CanonicalValue = string | number | boolean | null | readonly CanonicalValue[] | {
14
+ readonly [key: string]: CanonicalValue;
15
+ };
16
+ /** Encode a value with object keys sorted, recursively. Array order is preserved. */
17
+ declare function canonicalJson(value: CanonicalValue): string;
18
+ /** Eight lowercase hex characters of FNV-1a over the canonical encoding's UTF-16 code units. */
19
+ declare function contractFingerprint(value: CanonicalValue): string;
20
+ /**
21
+ * Sixteen lowercase hex characters, as a strong entity tag body.
22
+ *
23
+ * Two FNV-1a passes with different offset bases are concatenated, because a
24
+ * cache validator that collides serves a stale value: eight hex characters
25
+ * are enough to notice that a contract changed, but not enough to key a
26
+ * consumer's cache on.
27
+ */
28
+ declare function valueEtag(canonical: string): string;
29
+ declare const STATE_VERSION_HEADER = "x-streamsy-state-version";
30
+ declare const STATE_CONTRACT_HEADER = "x-streamsy-state-contract";
31
+ declare const STREAM_VERSION_HEADER = "x-streamsy-stream-version";
32
+ declare const STREAM_CONTRACT_HEADER = "x-streamsy-stream-contract";
33
+ declare const VALUE_CONTRACT_HEADER = "x-streamsy-value-contract";
34
+ declare const DURABLE_STATE_PROTOCOL = "https://durable-streams.dev/state-protocol/v1";
35
+ type ReplayRecovery = "replay-from-start";
36
+ type ResumeRejectedReason = "invalid-offset" | "history-unavailable" | "contract-changed";
37
+ type PublicError = {
38
+ readonly _tag: "InvalidParams";
39
+ readonly route: string;
40
+ readonly parameter: string;
41
+ readonly detail: string;
42
+ } | {
43
+ readonly _tag: "ProtocolVersionUnsupported";
44
+ readonly route: string;
45
+ readonly supported: 1;
46
+ readonly received: string;
47
+ readonly recovery: ReplayRecovery;
48
+ } | {
49
+ readonly _tag: "ResumeRejected";
50
+ readonly route: string;
51
+ readonly reason: ResumeRejectedReason;
52
+ readonly recovery: ReplayRecovery;
53
+ } | {
54
+ readonly _tag: "ContractChanged";
55
+ readonly route: string;
56
+ readonly recovery: "refetch";
57
+ } | {
58
+ readonly _tag: "TransportUnavailable" | "WireEncodeFailed";
59
+ readonly route: string;
60
+ readonly detail: string;
61
+ };
62
+ declare const PUBLIC_ERROR_TAGS: readonly ["InvalidParams", "ProtocolVersionUnsupported", "ResumeRejected", "ContractChanged", "TransportUnavailable", "WireEncodeFailed"];
63
+ type PublicErrorTag = PublicError["_tag"];
64
+ /** The browser's JSON error boundary; unknown fields are discarded. */
65
+ declare function decodePublicError(value: unknown): PublicError;
66
+ //#endregion
67
+ export { PublicErrorTag as a, STATE_CONTRACT_HEADER as c, STREAM_VERSION_HEADER as d, VALUE_CONTRACT_HEADER as f, valueEtag as g, decodePublicError as h, PublicError as i, STATE_VERSION_HEADER as l, contractFingerprint as m, DURABLE_STATE_PROTOCOL as n, ReplayRecovery as o, canonicalJson as p, PUBLIC_ERROR_TAGS as r, ResumeRejectedReason as s, CanonicalValue as t, STREAM_CONTRACT_HEADER as u };
@@ -0,0 +1,2 @@
1
+ import { a as PublicErrorTag, c as STATE_CONTRACT_HEADER, d as STREAM_VERSION_HEADER, f as VALUE_CONTRACT_HEADER, g as valueEtag, h as decodePublicError, i as PublicError, l as STATE_VERSION_HEADER, m as contractFingerprint, n as DURABLE_STATE_PROTOCOL, o as ReplayRecovery, p as canonicalJson, r as PUBLIC_ERROR_TAGS, s as ResumeRejectedReason, t as CanonicalValue, u as STREAM_CONTRACT_HEADER } from "./contract-BUWJ-Sy2.js";
2
+ export { CanonicalValue, DURABLE_STATE_PROTOCOL, PUBLIC_ERROR_TAGS, PublicError, PublicErrorTag, ReplayRecovery, ResumeRejectedReason, STATE_CONTRACT_HEADER, STATE_VERSION_HEADER, STREAM_CONTRACT_HEADER, STREAM_VERSION_HEADER, VALUE_CONTRACT_HEADER, canonicalJson, contractFingerprint, decodePublicError, valueEtag };