@streamsy/serve 0.4.0 → 0.6.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Effect HttpRouter routes for streams, Durable State and JSON values, plus Bun,
4
4
  Node, Deno and Cloudflare host glue and durable action delivery. Requires Effect
5
- `4.0.1`.
5
+ `4.0.5`.
6
6
 
7
7
  ```ts
8
8
  import { Layer, Schema } from "effect";
@@ -85,8 +85,8 @@ The Durable Object host does not get a stop signal and uses neither.
85
85
  | `/bun` | `listener`, its options and defaults |
86
86
  | `/node` | `listener`, its options and defaults |
87
87
  | `/deno` | `listener`, its options and defaults; `nodeSqliteCompat` |
88
- | `/cloudflare` | `StreamsyObject`, placement router/rules, alarm service |
89
- | `/alchemy` | `objectHandlers`, placement router/rules, alarm service |
88
+ | `/cloudflare` | `StreamsyObject`, placement router/rules, alarm service, `ObjectLink`, `ObjectStreams` |
89
+ | `/alchemy` | `objectHandlers`, placement router/rules, alarm service, `ObjectLink`, `objectFetch` |
90
90
  | `/action` | Unchanged action declarations |
91
91
  | `/action/errors`, `/action/outbox`, `/action/runtime`, `/action/sql` | Action delivery and backing APIs; `createSqlOutboxBacking` runs on SQLite, Postgres and MySQL |
92
92
 
@@ -106,3 +106,36 @@ Guides: [Serve routes](https://streamsy.dev/docs/http/serve),
106
106
  [Cloudflare](https://streamsy.dev/docs/hosting/cloudflare),
107
107
  [Host contract](https://streamsy.dev/docs/hosting/host-contract),
108
108
  [Alchemy](https://streamsy.dev/docs/hosting/cloudflare#alchemy).
109
+
110
+ ## Object links and projection wakeups
111
+
112
+ `ObjectLink.make({ group, prefix, placement, access })` from `/cloudflare` or
113
+ `/alchemy` declares an internal protocol mount and its client together. Add
114
+ `link.routes` to the owner app and the client to the caller's `links`.
115
+ The prefix must start with `/__`. Both public placement routers return 404
116
+ for paths whose first segment starts with `__`, without calling an object.
117
+ The owner still needs the explicit internal route.
118
+
119
+ Both hosts use one `ObjectLink` and one `LinkClient`. A native Cloudflare
120
+ object takes `links: (env) => [link.client(env.OWNERS)]`. Alchemy
121
+ `objectHandlers` takes `links: [link.client(objectFetch(owners))]`. A linked
122
+ declaration must not also be provided by local storage; the host rejects that
123
+ clash during acquisition.
124
+
125
+ `ObjectStreams.layer(group, { objects, placement, prefix })` builds the same
126
+ remote stream services for an existing protocol mount. `objects` is a native
127
+ namespace or an `ObjectFetch`: `(name, request) => Effect<Response,
128
+ HttpClientError>`. The client uses `HttpClient.make`, retaining caller context,
129
+ tracing and interruption. Alchemy's `objectFetch` adapts its stub Effects.
130
+
131
+ Access is mount policy for every stream. `Streamsy.wake` is a State preset
132
+ with the normal writer service. Use `read` for public projection outputs,
133
+ `full` when an internal producer creates its output, or `append` when the
134
+ owner creates it first. `Serve.mount` accepts GET and HEAD.
135
+
136
+ Object hosts acquire `ProjectionWakeup.attach(projection)` from
137
+ `@streamsy/projection` in their scope through `wakeups: [...]`. Local
138
+ `Storage.committed` events populate its in-memory pending set. Alarms drain
139
+ bounded passes. Retry options take an Effect `Schedule`; finite schedules stop
140
+ on exhaustion. A crash can leave work pending until the next source commit.
141
+ See the [projection host reference](https://streamsy.dev/docs/hosting/cloudflare#projections).
@@ -1,4 +1,3 @@
1
- //#region src/action/contract.d.ts
2
1
  /**
3
2
  * The action-sink declaration.
4
3
  *
@@ -96,5 +95,4 @@ export interface CheckedActionSink<Payload, From extends ActionSinkRelation = Ac
96
95
  export declare function defineActionSink<Payload, From extends ActionSinkRelation>(spec: ActionSinkSpec<Payload, From>): CheckedActionSink<Payload, From>;
97
96
  /** The next backoff, in milliseconds, after `attempts` failed attempts. */
98
97
  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
98
+ export type PayloadOf<Sink> = Sink extends CheckedActionSink<infer Payload, infer _From> ? Payload : never;
@@ -1,5 +1,6 @@
1
1
  import { contractFingerprint } from "../contract.js";
2
- //#region src/action/contract.ts
2
+ import { Array, Record } from "effect";
3
+ // src/action/contract.ts
3
4
  function defineActionSink(spec) {
4
5
  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
6
  if (spec.delivery.backoffFactor < 1) throw new TypeError(`action sink ${spec.name} must not shrink its backoff`);
@@ -9,7 +10,7 @@ function defineActionSink(spec) {
9
10
  name: spec.name,
10
11
  key,
11
12
  handler: { ...spec.handler },
12
- delivery: Object.fromEntries(Object.entries(spec.delivery).map(([name, value]) => [name, Number.isFinite(value) ? value : String(value)]))
13
+ delivery: Record.fromEntries(Array.map(Record.toEntries(spec.delivery), ([name, value]) => [name, Number.isFinite(value) ? value : String(value)]))
13
14
  });
14
15
  return Object.freeze({
15
16
  ...spec,
@@ -24,5 +25,4 @@ function backoffAfter(policy, attempts) {
24
25
  const raw = policy.initialBackoffMs * policy.backoffFactor ** exponent;
25
26
  return Math.min(policy.maxBackoffMs, Math.round(raw));
26
27
  }
27
- //#endregion
28
28
  export { backoffAfter, defineActionSink };
@@ -1,6 +1,4 @@
1
- import { Schema } from "effect";
2
- import * as Cause$1 from "effect/Cause";
3
- //#region src/action/errors.d.ts
1
+ import { Cause, Schema } from "effect";
4
2
  export declare const ACTION_SINK_ERROR_TAGS: readonly ["ActionSinkDeliveryFailure", "OutboxUnavailable"];
5
3
  export type ActionSinkErrorTag = (typeof ACTION_SINK_ERROR_TAGS)[number];
6
4
  /** The durable outbox could not be read or written. Never a delivery outcome. */
@@ -8,7 +6,7 @@ type OutboxUnavailableSchema = Schema.TaggedStruct<"OutboxUnavailable", {
8
6
  readonly operation: Schema.String;
9
7
  readonly detail: Schema.String;
10
8
  }>;
11
- declare const OutboxUnavailableBase: Schema.Class<OutboxUnavailable, OutboxUnavailableSchema, Cause$1.YieldableError>;
9
+ declare const OutboxUnavailableBase: Schema.Class<OutboxUnavailable, OutboxUnavailableSchema, Cause.YieldableError>;
12
10
  export declare class OutboxUnavailable extends OutboxUnavailableBase {}
13
11
  /**
14
12
  * A handler refused one delivery.
@@ -23,7 +21,7 @@ type ActionSinkDeliveryFailureSchema = Schema.TaggedStruct<"ActionSinkDeliveryFa
23
21
  readonly detail: Schema.String;
24
22
  readonly retryable: Schema.Boolean;
25
23
  }>;
26
- declare const ActionSinkDeliveryFailureBase: Schema.Class<ActionSinkDeliveryFailure, ActionSinkDeliveryFailureSchema, Cause$1.YieldableError>;
24
+ declare const ActionSinkDeliveryFailureBase: Schema.Class<ActionSinkDeliveryFailure, ActionSinkDeliveryFailureSchema, Cause.YieldableError>;
27
25
  export declare class ActionSinkDeliveryFailure extends ActionSinkDeliveryFailureBase {
28
26
  static retryable(handler: string, idempotencyKey: string, detail: string): ActionSinkDeliveryFailure;
29
27
  static permanent(handler: string, idempotencyKey: string, detail: string): ActionSinkDeliveryFailure;
@@ -31,5 +29,4 @@ export declare class ActionSinkDeliveryFailure extends ActionSinkDeliveryFailure
31
29
  /** Why one outbox entry stopped being retried. */
32
30
  export declare const DEAD_LETTER_REASONS: readonly ["attempts-exhausted", "permanent", "payload-poison"];
33
31
  export type DeadLetterReason = (typeof DEAD_LETTER_REASONS)[number];
34
- //#endregion
35
32
  export {};
@@ -1,5 +1,5 @@
1
1
  import { Schema } from "effect";
2
- //#region src/action/errors.ts
2
+ // src/action/errors.ts
3
3
  /**
4
4
  * Typed action-sink failures.
5
5
  *
@@ -47,5 +47,4 @@ const DEAD_LETTER_REASONS = [
47
47
  "permanent",
48
48
  "payload-poison"
49
49
  ];
50
- //#endregion
51
50
  export { ACTION_SINK_ERROR_TAGS, ActionSinkDeliveryFailure, DEAD_LETTER_REASONS, OutboxUnavailable };
@@ -1,6 +1,5 @@
1
1
  import { DeadLetterReason, OutboxUnavailable } from "./errors.js";
2
2
  import { Context, Effect, Layer, Option } from "effect";
3
- //#region src/action/outbox.d.ts
4
3
  export type OutboxEntryState = "pending" | "delivered" | "dead";
5
4
  export interface OutboxEntry {
6
5
  /** Monotonic enqueue order. Delivery walks a lane in this order. */
@@ -63,5 +62,4 @@ export declare const outboxStoreLayer: (backing: OutboxBacking) => Layer.Layer<O
63
62
  * host behaves the same way on the durable one.
64
63
  */
65
64
  export declare function makeMemoryOutboxBacking(): OutboxBacking;
66
- //#endregion
67
65
  export {};
@@ -1,6 +1,6 @@
1
1
  import "./errors.js";
2
- import { Context, Effect, Layer, Option } from "effect";
3
- //#region src/action/outbox.ts
2
+ import { Array, Context, Effect, Layer, Option, Order } from "effect";
3
+ // src/action/outbox.ts
4
4
  /**
5
5
  * The durable outbox.
6
6
  *
@@ -35,7 +35,7 @@ function makeMemoryOutboxBacking() {
35
35
  ...patch
36
36
  });
37
37
  };
38
- const ordered = () => [...entries.values()].toSorted((left, right) => left.id - right.id);
38
+ const ordered = () => Array.sort([...entries.values()], Order.mapInput(Order.Number, (entry) => entry.id));
39
39
  return {
40
40
  enqueue: (drafts) => Effect.sync(() => {
41
41
  let enqueued = 0;
@@ -72,11 +72,11 @@ function makeMemoryOutboxBacking() {
72
72
  };
73
73
  }),
74
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));
75
+ const pending = Array.filter(ordered(), (entry) => entry.sink === sink && entry.state === "pending" && (partitionId === void 0 || entry.partitionId === partitionId));
76
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));
77
+ return first === void 0 ? Option.none() : Option.some(inOrder ? first.nextAttemptAtMs : Array.reduce(pending, Infinity, (at, entry) => Math.min(at, entry.nextAttemptAtMs)));
78
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)),
79
+ claimDue: (sink, partitionId, nowMs, limit, inOrder = false) => Effect.sync(() => Array.filter(ordered(), (entry) => entry.sink === sink && entry.state === "pending" && (inOrder || entry.nextAttemptAtMs <= nowMs) && (partitionId === void 0 || entry.partitionId === partitionId)).slice(0, limit)),
80
80
  markDelivered: (id, attempts, atMs) => Effect.sync(() => {
81
81
  replace(id, {
82
82
  state: "delivered",
@@ -102,8 +102,7 @@ function makeMemoryOutboxBacking() {
102
102
  settledAtMs: atMs
103
103
  });
104
104
  }),
105
- list: (sink, partitionId) => Effect.sync(() => ordered().filter((entry) => entry.sink === sink && (partitionId === void 0 || entry.partitionId === partitionId)))
105
+ list: (sink, partitionId) => Effect.sync(() => Array.filter(ordered(), (entry) => entry.sink === sink && (partitionId === void 0 || entry.partitionId === partitionId)))
106
106
  };
107
107
  }
108
- //#endregion
109
108
  export { OutboxStore, makeMemoryOutboxBacking, outboxStore, outboxStoreLayer };
@@ -1,9 +1,8 @@
1
1
  import { ActionSinkRelation, CheckedActionSink } from "./contract.js";
2
2
  import { ActionSinkDeliveryFailure, OutboxUnavailable } from "./errors.js";
3
3
  import { OutboxDraft, OutboxStore } from "./outbox.js";
4
- import { t as Wakeup } from "../wakeup-BtnMEjLv.js";
4
+ import { t as Wakeup } from "../wakeup-Cy6pvhmx.js";
5
5
  import { Effect } from "effect";
6
- //#region src/action/runtime.d.ts
7
6
  /** One attempt at one delivery, as the handler sees it. */
8
7
  export interface ActionSinkDelivery<Payload> {
9
8
  readonly entryId: number;
@@ -76,5 +75,4 @@ export declare const drain: <Payload, From extends ActionSinkRelation, Requireme
76
75
  */
77
76
  export declare const OutboxWakeup: {
78
77
  make: <Payload, From extends ActionSinkRelation, R = never>(sink: CheckedActionSink<Payload, From>, handler: ActionSinkHandler<Payload, R>, options?: DrainOptions) => Wakeup<OutboxStore | R>;
79
- };
80
- //#endregion
78
+ };
@@ -1,10 +1,10 @@
1
1
  import { backoffAfter } from "./contract.js";
2
2
  import { ActionSinkDeliveryFailure } from "./errors.js";
3
3
  import { OutboxStore } from "./outbox.js";
4
- import { n as Wakeup } from "../wakeup-CWKg8pts.js";
5
- import { Cause, Clock, Effect, Exit, Semaphore } from "effect";
4
+ import { n as Wakeup } from "../wakeup-eIs0xIUh.js";
5
+ import { Array, Cause, Clock, Effect, Exit, Option, Semaphore } from "effect";
6
6
  import { StorageFault } from "@streamsy/core";
7
- //#region src/action/runtime.ts
7
+ // src/action/runtime.ts
8
8
  /**
9
9
  * The delivery runtime: one bounded, serialized pass over a sink's outbox.
10
10
  *
@@ -41,7 +41,7 @@ function actionSinkHandler(sink, deliver) {
41
41
  const DEFAULT_DRAIN_LIMIT = 100;
42
42
  /** Lower declared payloads to outbox drafts. Pure, so a caller can write them transactionally. */
43
43
  function draftsFor(sink, payloads, enqueuedAtMs) {
44
- return payloads.map((payload) => ({
44
+ return Array.map(payloads, (payload) => ({
45
45
  sink: sink.name,
46
46
  partitionId: sink.partitionBy(payload),
47
47
  idempotencyKey: sink.idempotencyKey(payload),
@@ -147,8 +147,8 @@ function decodePayload(sink, entry) {
147
147
  }
148
148
  /** Every non-interrupt cause becomes one retryable refusal; a thrown handler is still a handler. */
149
149
  function failureOf(sink, entry, cause) {
150
- const declared = cause.reasons.find(Cause.isFailReason);
151
- if (declared !== void 0) return declared.error;
150
+ const declared = Array.findFirst(cause.reasons, Cause.isFailReason);
151
+ if (Option.isSome(declared)) return declared.value.error;
152
152
  return ActionSinkDeliveryFailure.retryable(sink.handler.name, entry.idempotencyKey, Cause.pretty(cause).slice(0, 500));
153
153
  }
154
154
  function deadLetterReason(sink, attempt, failure) {
@@ -178,5 +178,4 @@ function outboxWakeupName(sink, partitionId) {
178
178
  const lane = partitionId === void 0 ? "" : `/${encodeURIComponent(partitionId)}`;
179
179
  return `streamsy/outbox/${encodeURIComponent(sink)}${lane}`;
180
180
  }
181
- //#endregion
182
181
  export { DEFAULT_DRAIN_LIMIT, OutboxWakeup, actionSinkHandler, draftsFor, drain };
@@ -1,8 +1,6 @@
1
1
  import { OutboxUnavailable } from "./errors.js";
2
2
  import { OutboxBacking } from "./outbox.js";
3
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
4
+ import { SqlClient } from "effect/sql";
5
+ export declare function migrateOutbox(sql: SqlClient.SqlClient): Effect.Effect<void, OutboxUnavailable>;
6
+ export declare function createSqlOutboxBacking(sql: SqlClient.SqlClient): OutboxBacking;
@@ -1,6 +1,6 @@
1
1
  import { OutboxUnavailable } from "./errors.js";
2
- import { Effect, Option, Predicate, Schema } from "effect";
3
- //#region src/action/sql.ts
2
+ import { Array, Effect, Option, Predicate, Schema } from "effect";
3
+ // src/action/sql.ts
4
4
  const TABLE = "streamsy_effect_outbox";
5
5
  /** MySQL key columns. Their sum fits the 3072-byte InnoDB index limit at 4 bytes a character. */
6
6
  const MYSQL_SINK_LENGTH = 255;
@@ -80,7 +80,7 @@ const unsupported = (operation) => Effect.fail(new OutboxUnavailable({
80
80
  }));
81
81
  /** The names of the outbox indexes that exist in the current MySQL database. */
82
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))));
83
+ WHERE table_schema = DATABASE() AND table_name = ${TABLE}`.pipe(Effect.map((rows) => new Set(Array.map(rows, ({ name }) => name))));
84
84
  /** MySQL has no `CREATE INDEX IF NOT EXISTS`. A concurrent migration may win the create. */
85
85
  const migrateMysql = (sql) => Effect.gen(function* () {
86
86
  if (Option.isSome(yield* Effect.serviceOption(sql.transactionService))) return yield* new OutboxUnavailable({
@@ -94,7 +94,7 @@ const migrateMysql = (sql) => Effect.gen(function* () {
94
94
  }
95
95
  });
96
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 });
97
+ const statements = (dialect) => Effect.forEach([CREATE_TABLE[dialect], ...Array.map(INDEXES, (index) => createIndex(index, true))], (statement) => sql.unsafe(statement), { discard: true });
98
98
  return attempt("migrateOutbox", sql.onDialectOrElse({
99
99
  sqlite: () => statements("sqlite"),
100
100
  pg: () => statements("pg"),
@@ -106,7 +106,7 @@ function migrateOutbox(sql) {
106
106
  * MySQL readiness only reads the catalog. The backing may run inside a caller's
107
107
  * transaction, and DDL there would commit it early.
108
108
  */
109
- const mysqlReady = (sql) => Effect.flatMap(mysqlIndexes(sql), (names) => INDEXES.every((index) => names.has(index.name)) ? Effect.void : Effect.fail(new OutboxUnavailable({
109
+ const mysqlReady = (sql) => Effect.flatMap(mysqlIndexes(sql), (names) => Array.every(INDEXES, (index) => names.has(index.name)) ? Effect.void : Effect.fail(new OutboxUnavailable({
110
110
  operation: "ready",
111
111
  detail: `${TABLE} is not migrated; on MySQL, run migrateOutbox at placement start`
112
112
  })));
@@ -144,7 +144,7 @@ function createSqlOutboxBacking(sql) {
144
144
  }));
145
145
  const run = (operation, statement) => attempt(operation, Effect.andThen(ready, statement));
146
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)));
147
+ const rows = (operation, statement) => run(operation, statement).pipe(Effect.map((found) => Array.map(found, (row) => decodeRow(row))));
148
148
  /** Whether the draft was new. A draft with a known sink and key changes nothing. */
149
149
  const insert = (draft) => {
150
150
  const row = sql.insert({
@@ -225,5 +225,4 @@ function decodeReason(value) {
225
225
  if (value === "attempts-exhausted" || value === "permanent" || value === "payload-poison") return value;
226
226
  throw new TypeError(`unknown outbox dead-letter reason ${value}`);
227
227
  }
228
- //#endregion
229
228
  export { createSqlOutboxBacking, migrateOutbox };
package/dist/alchemy.d.ts CHANGED
@@ -1,25 +1,29 @@
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";
1
+ import { a as AlarmSlot, i as Wakeups, n as WakeupProgress, o as alarmSlotLayer, r as WakeupSource, t as Wakeup } from "./wakeup-Cy6pvhmx.js";
2
+ import { a as ObjectLinkOptions, c as ObjectStreamsOptions, d as ObjectApp, i as ObjectLink, l as Placement, n as LinkClient, o as ObjectFetch, r as LinkRoutes, s as ObjectStreams, t as LinkAccess, u as rule } from "./object-link-BbxO01Z5.js";
3
3
  import { Effect, Layer, Scope } from "effect";
4
- import { HttpServerRequest, HttpServerResponse } from "effect/http";
4
+ import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/http";
5
5
  import { Storage, StorageFault } from "@streamsy/core";
6
6
  import { DurableObject } from "alchemy/Cloudflare";
7
- import { HttpServerError } from "effect/http/HttpServerError";
8
- //#region src/alchemy.d.ts
9
7
  /** An Alchemy HttpEffect; authenticate before evaluating this router. */
10
8
  export declare const router: (options: {
11
9
  readonly objects: Pick<DurableObject, "getByName">;
12
10
  readonly prefix?: `/${string}`;
13
11
  readonly placement?: Placement;
14
- }) => Effect.Effect<HttpServerResponse.HttpServerResponse, HttpServerError, HttpServerRequest.HttpServerRequest>;
12
+ }) => Effect.Effect<HttpServerResponse.HttpServerResponse, HttpServerError.HttpServerError, HttpServerRequest.HttpServerRequest>;
13
+ /**
14
+ * An `ObjectFetch` over an Alchemy Durable Object binding, for
15
+ * `ObjectStreams.layer` and `ObjectLink.client`.
16
+ */
17
+ export declare const objectFetch: (objects: Pick<DurableObject, "getByName">) => ObjectFetch;
15
18
  export type ObjectHandlers = {
16
19
  readonly fetch: Effect.Effect<HttpServerResponse.HttpServerResponse, never, HttpServerRequest.HttpServerRequest>;
17
20
  readonly alarm: () => Effect.Effect<void>;
18
21
  };
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>>>;
22
+ export declare function objectHandlers<R = never, L = never>(options: {
23
+ readonly app: ObjectApp<R | L>;
24
+ readonly layer: Layer.Layer<Storage | AlarmSlot | R, StorageFault, L>;
25
+ readonly wakeups?: ReadonlyArray<WakeupSource<Storage | R | L>>;
26
+ /** Streams that live on other objects, through `link.client(objectFetch(binding))`. */
27
+ readonly links?: ReadonlyArray<LinkClient<L>>;
23
28
  }): Effect.Effect<ObjectHandlers, never, Scope.Scope>;
24
- //#endregion
25
- export { Placement, Wakeup, type WakeupProgress, Wakeups, alarmSlotLayer, rule };
29
+ export { type LinkAccess, type LinkClient, type LinkRoutes, type ObjectFetch, ObjectLink, type ObjectLinkOptions, ObjectStreams, type ObjectStreamsOptions, Placement, Wakeup, type WakeupProgress, type WakeupSource, Wakeups, alarmSlotLayer, rule };
package/dist/alchemy.js CHANGED
@@ -1,9 +1,9 @@
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";
1
+ import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-eIs0xIUh.js";
2
+ import { a as resolvePlacement, c as rule, d as unavailable, f as withLinks, i as isReserved, l as acquireObject, n as ObjectStreams, r as objectTransportError, s as Placement, t as ObjectLink, u as providedApp } from "./object-link-HD-tFY4o.js";
3
3
  import { Effect, Option } from "effect";
4
4
  import { HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/http";
5
5
  import { securityHeaders, streamPath } from "@streamsy/core/http";
6
- //#region src/alchemy.ts
6
+ // src/alchemy.ts
7
7
  /** An Alchemy HttpEffect; authenticate before evaluating this router. */
8
8
  const router = (options) => {
9
9
  const path = streamPath(options.prefix);
@@ -11,6 +11,10 @@ const router = (options) => {
11
11
  return Effect.gen(function* () {
12
12
  const request = yield* HttpServerRequest.HttpServerRequest;
13
13
  const pathname = new URL(request.url, "https://streamsy.internal").pathname;
14
+ if (isReserved(pathname)) return HttpServerResponse.empty({
15
+ status: 404,
16
+ headers: securityHeaders
17
+ });
14
18
  const streamPath = path.strip(pathname);
15
19
  if (!streamPath || streamPath === pathname) return HttpServerResponse.text(`Stream path required: ${path.requiredPathPattern()}`, {
16
20
  status: 400,
@@ -21,9 +25,17 @@ const router = (options) => {
21
25
  return yield* options.objects.getByName(child.name).fetch(request);
22
26
  });
23
27
  };
28
+ /**
29
+ * An `ObjectFetch` over an Alchemy Durable Object binding, for
30
+ * `ObjectStreams.layer` and `ObjectLink.client`.
31
+ */
32
+ const objectFetch = (objects) => Effect.fn("Alchemy.objectFetch")(function* (name, request) {
33
+ const response = yield* objects.getByName(name).fetch(HttpServerRequest.fromWeb(request)).pipe(Effect.mapError((cause) => objectTransportError(request, cause)));
34
+ return HttpServerResponse.toWeb(response);
35
+ });
24
36
  function objectHandlers(options) {
25
37
  return Effect.gen(function* () {
26
- const get = yield* acquireObject(options.layer, (context, alarm) => Effect.gen(function* () {
38
+ const get = yield* acquireObject(withLinks(options.layer, options.links), (context, alarm) => Effect.gen(function* () {
27
39
  return {
28
40
  fetch: (yield* HttpRouter.toHttpEffect(providedApp(options.app, context))).pipe(Effect.scoped, Effect.catch(HttpServerRespondable.toResponse)),
29
41
  alarm: alarm.pipe(Effect.provide(context))
@@ -35,5 +47,4 @@ function objectHandlers(options) {
35
47
  };
36
48
  });
37
49
  }
38
- //#endregion
39
- export { Placement, Wakeup, Wakeups, alarmSlotLayer, objectHandlers, router, rule };
50
+ export { ObjectLink, ObjectStreams, Placement, Wakeup, Wakeups, alarmSlotLayer, objectFetch, objectHandlers, router, rule };
package/dist/bun.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Duration } from "effect";
2
- //#region src/bun.d.ts
3
2
  export declare const DEFAULT_PORT = 3000;
4
3
  export declare const DEFAULT_HOSTNAME = "127.0.0.1";
5
4
  export interface ListenerOptions {
@@ -9,5 +8,4 @@ export interface ListenerOptions {
9
8
  readonly gracefulShutdownTimeout?: Duration.Input;
10
9
  }
11
10
  /** 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
11
+ 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>;
package/dist/bun.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BunHttpServer } from "@effect/platform-bun";
2
- //#region src/bun.ts
2
+ // src/bun.ts
3
3
  const DEFAULT_PORT = 3e3;
4
4
  const DEFAULT_HOSTNAME = "127.0.0.1";
5
5
  const listenerOptions = (options) => ({
@@ -10,5 +10,4 @@ const listenerOptions = (options) => ({
10
10
  });
11
11
  /** The Bun listener that keeps a parked long poll alive. */
12
12
  const listener = (options = {}) => BunHttpServer.layer(listenerOptions(options));
13
- //#endregion
14
13
  export { DEFAULT_HOSTNAME, DEFAULT_PORT, listener };
@@ -1,35 +1,37 @@
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";
1
+ import { i as Wakeups, n as WakeupProgress, o as alarmSlotLayer, r as WakeupSource, t as Wakeup } from "./wakeup-Cy6pvhmx.js";
2
+ import { a as ObjectLinkOptions, c as ObjectStreamsOptions, d as ObjectApp, i as ObjectLink, l as Placement, n as LinkClient, o as ObjectFetch, r as LinkRoutes, s as ObjectStreams, t as LinkAccess, u as rule } from "./object-link-BbxO01Z5.js";
3
3
  import { Layer } from "effect";
4
4
  import { Storage, StorageFault } from "@streamsy/core";
5
5
  import { DurableObject } from "cloudflare:workers";
6
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>;
7
+ interface ObjectConfiguration<Env, R = never, L = never> {
8
+ readonly app: ObjectApp<R | L>;
9
+ readonly wakeups?: ReadonlyArray<WakeupSource<Storage | R | L>>;
10
+ readonly layer: (state: DurableObjectState, env: Env) => Layer.Layer<Storage | R, StorageFault, L>;
11
+ /**
12
+ * Streams that live on other objects. Their services come from these links
13
+ * and never from `layer`. An object whose `layer` also provides a linked
14
+ * stream fails to start.
15
+ */
16
+ readonly links?: (env: Env) => ReadonlyArray<LinkClient<L>>;
12
17
  }
13
18
  interface ObjectInstance {
14
19
  fetch(request: Request): Promise<Response>;
15
20
  alarm(info?: AlarmInvocationInfo): Promise<void>;
16
21
  }
17
- declare class ObjectHost<Env, R> extends DurableObject<Env> {
22
+ declare class ObjectHost<Env, R, L> extends DurableObject<Env> {
18
23
  #private;
19
- constructor(state: DurableObjectState, env: Env, configuration: ObjectConfiguration<Env, R>);
24
+ constructor(state: DurableObjectState, env: Env, configuration: ObjectConfiguration<Env, R, L>);
20
25
  fetch(request: Request): Promise<Response>;
21
26
  alarm(_info?: AlarmInvocationInfo): Promise<void>;
22
27
  }
23
28
  export declare const StreamsyObject: {
24
- make: <Env = unknown, R = never>(configuration: ObjectConfiguration<Env, R>) => new (state: DurableObjectState, env: Env) => ObjectHost<Env, R>;
29
+ make: <Env = unknown, R = never, L = never>(configuration: ObjectConfiguration<Env, R, L>) => new (state: DurableObjectState, env: Env) => ObjectHost<Env, R, L>;
25
30
  };
26
- //#endregion
27
- //#region src/cloudflare/router.d.ts
28
31
  interface RouterOptions<Env> {
29
32
  readonly namespace: (env: Env) => DurableObjectNamespace;
30
33
  readonly placement?: Placement;
31
34
  readonly prefix?: `/${string}`;
32
35
  }
33
36
  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 };
37
+ export { type LinkAccess, type LinkClient, type LinkRoutes, type ObjectConfiguration, type ObjectFetch, type ObjectInstance, ObjectLink, type ObjectLinkOptions, ObjectStreams, type ObjectStreamsOptions, Placement, type RouterOptions, Wakeup, type WakeupProgress, type WakeupSource, Wakeups, alarmSlotLayer, rule };
@@ -1,15 +1,15 @@
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";
1
+ import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-eIs0xIUh.js";
2
+ import { c as rule, d as unavailable, f as withLinks, l as acquireObject, n as ObjectStreams, o as router, s as Placement, t as ObjectLink, u as providedApp } from "./object-link-HD-tFY4o.js";
3
3
  import { Context, Effect, Layer, ManagedRuntime, Option } from "effect";
4
4
  import { HttpRouter, HttpServerResponse } from "effect/http";
5
5
  import { DurableObject } from "cloudflare:workers";
6
- //#region src/cloudflare/object.ts
6
+ // src/cloudflare/object.ts
7
7
  var ObjectOwner = class extends Context.Service()("streamsy/ObjectOwner") {};
8
8
  var ObjectHost = class extends DurableObject {
9
9
  #runtime;
10
10
  constructor(state, env, configuration) {
11
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* () {
12
+ this.#runtime = ManagedRuntime.make(Layer.effect(ObjectOwner, Effect.map(acquireObject(Layer.suspend(() => Layer.merge(withLinks(configuration.layer(state, env), configuration.links?.(env)), alarmSlotLayer(state.storage))), (context, alarm) => Effect.gen(function* () {
13
13
  const web = HttpRouter.toWebHandler(providedApp(configuration.app, context), { disableLogger: true });
14
14
  yield* Effect.addFinalizer(() => Effect.promise(web.dispose));
15
15
  return {
@@ -36,5 +36,4 @@ const StreamsyObject = { make: (configuration) => class extends ObjectHost {
36
36
  super(state, env, configuration);
37
37
  }
38
38
  } };
39
- //#endregion
40
- export { Placement, StreamsyObject, Wakeup, Wakeups, alarmSlotLayer, router, rule };
39
+ export { ObjectLink, ObjectStreams, Placement, StreamsyObject, Wakeup, Wakeups, alarmSlotLayer, router, rule };
@@ -1,4 +1,3 @@
1
- //#region src/contract.d.ts
2
1
  /**
3
2
  * Canonical encoding and the change-detection digests served routes need.
4
3
  *
@@ -63,5 +62,4 @@ declare const PUBLIC_ERROR_TAGS: readonly ["InvalidParams", "ProtocolVersionUnsu
63
62
  type PublicErrorTag = PublicError["_tag"];
64
63
  /** The browser's JSON error boundary; unknown fields are discarded. */
65
64
  declare function decodePublicError(value: unknown): PublicError;
66
- //#endregion
67
65
  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 };
@@ -1,2 +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";
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-CFoMXKuH.js";
2
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 };
package/dist/contract.js CHANGED
@@ -1,7 +1,19 @@
1
- //#region src/contract.ts
1
+ import { Array, Order, Record } from "effect";
2
+ // src/contract.ts
3
+ /**
4
+ * Canonical encoding and the change-detection digests served routes need.
5
+ *
6
+ * A contract fingerprint and a value ETag answer the same question — "is
7
+ * this still the thing you were holding?" — so both are computed from one
8
+ * canonical encoding. Object keys are sorted and array order is preserved, so
9
+ * two values that differ only in property order encode identically and cannot
10
+ * invalidate a consumer's cache for no reason.
11
+ *
12
+ * These are change-detection identities, not security digests.
13
+ */
2
14
  /** Encode a value with object keys sorted, recursively. Array order is preserved. */
3
15
  function canonicalJson(value) {
4
- if (isCanonicalArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
16
+ if (isCanonicalArray(value)) return `[${Array.join(Array.map(value, (item) => canonicalJson(item)), ",")}]`;
5
17
  if (value === null) return "null";
6
18
  if (value instanceof Object) return canonicalObject(value);
7
19
  if (typeof value === "number" && !Number.isFinite(value)) throw new TypeError("Expected finite JSON number");
@@ -16,16 +28,12 @@ function isCanonicalArray(value) {
16
28
  }
17
29
  function canonicalObject(value) {
18
30
  const entries = [];
19
- for (const key of Object.keys(value).toSorted(compareKeys)) {
31
+ for (const key of Array.sort(Record.keys(value), Order.String)) {
20
32
  const member = value[key];
21
33
  if (member === void 0) continue;
22
34
  entries.push(`${JSON.stringify(key)}:${canonicalJson(member)}`);
23
35
  }
24
- return `{${entries.join(",")}}`;
25
- }
26
- function compareKeys(left, right) {
27
- if (left === right) return 0;
28
- return left < right ? -1 : 1;
36
+ return `{${Array.join(entries, ",")}}`;
29
37
  }
30
38
  /** Eight lowercase hex characters of FNV-1a over the canonical encoding's UTF-16 code units. */
31
39
  function contractFingerprint(value) {
@@ -120,5 +128,4 @@ function decodePublicError(value) {
120
128
  default: throw new TypeError("Unknown public error tag");
121
129
  }
122
130
  }
123
- //#endregion
124
131
  export { DURABLE_STATE_PROTOCOL, PUBLIC_ERROR_TAGS, STATE_CONTRACT_HEADER, STATE_VERSION_HEADER, STREAM_CONTRACT_HEADER, STREAM_VERSION_HEADER, VALUE_CONTRACT_HEADER, canonicalJson, contractFingerprint, decodePublicError, valueEtag };
package/dist/deno.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Duration, Effect } from "effect";
2
- //#region src/deno.d.ts
3
2
  export declare const DEFAULT_PORT = 3000;
4
3
  export declare const DEFAULT_HOSTNAME = "127.0.0.1";
5
4
  export interface ListenerOptions {
@@ -23,5 +22,4 @@ export declare const listener: (options?: ListenerOptions) => import("effect/Lay
23
22
  * );
24
23
  * ```
25
24
  */
26
- export declare const nodeSqliteCompat: Effect.Effect<void>;
27
- //#endregion
25
+ export declare const nodeSqliteCompat: Effect.Effect<void>;
package/dist/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Effect } from "effect";
2
2
  import { DenoHttpServer } from "@effect/platform-deno";
3
- //#region src/deno.ts
3
+ // src/deno.ts
4
4
  const DEFAULT_PORT = 3e3;
5
5
  const DEFAULT_HOSTNAME = "127.0.0.1";
6
6
  const listenerOptions = (options) => ({
@@ -28,5 +28,4 @@ const nodeSqliteCompat = Effect.promise(() => import("node:sqlite")).pipe(Effect
28
28
  const prototype = DatabaseSync.prototype;
29
29
  if (prototype.enableLoadExtension === void 0) prototype.enableLoadExtension = () => {};
30
30
  })));
31
- //#endregion
32
31
  export { DEFAULT_HOSTNAME, DEFAULT_PORT, listener, nodeSqliteCompat };