@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,125 @@
1
+ import { Clock, Context, Duration, Effect, Exit, Layer, Option, Predicate, Semaphore } from "effect";
2
+ import { Storage } from "@streamsy/core";
3
+ import { Protocol } from "@streamsy/core/protocol";
4
+ //#region src/cloudflare/alarm.ts
5
+ var AlarmSlot = class extends Context.Service()("@streamsy/serve/cloudflare/AlarmSlot") {};
6
+ const alarmSlotLayer = (storage) => Layer.succeed(AlarmSlot, {
7
+ current: Effect.promise(() => storage.getAlarm()).pipe(Effect.map(Option.fromNullishOr)),
8
+ arm: (at) => Effect.promise(() => storage.setAlarm(at)),
9
+ clear: Effect.promise(() => storage.deleteAlarm())
10
+ });
11
+ //#endregion
12
+ //#region src/cloudflare/wakeup.ts
13
+ const Permits = Context.Reference("@streamsy/serve/cloudflare/WakeupPermits", { defaultValue: () => void 0 });
14
+ const Guarded = Symbol.for("@streamsy/serve/cloudflare/WakeupPermit");
15
+ const reportsMore = (value) => Predicate.hasProperty(value, "more") && value.more;
16
+ const Wakeup = { make: (wakeup) => {
17
+ if (Predicate.hasProperty(wakeup.run, Guarded) && wakeup.run[Guarded] === wakeup.name) return wakeup;
18
+ const run = Effect.flatMap(Permits, (permits) => {
19
+ const permit = permits?.get(wakeup.name);
20
+ return permit === void 0 ? wakeup.run : permit.withPermit(wakeup.run);
21
+ });
22
+ const protectedRun = Object.defineProperty(run, Guarded, { value: wakeup.name });
23
+ return {
24
+ ...wakeup,
25
+ run: protectedRun
26
+ };
27
+ } };
28
+ var Wakeups = class extends Context.Service()("@streamsy/serve/cloudflare/Wakeups") {};
29
+ /** Request-local marker: simultaneous requests cannot consume each other's touch. */
30
+ const RequestTouch = Context.Reference("@streamsy/serve/cloudflare/RequestTouch", { defaultValue: () => void 0 });
31
+ const expiry = Wakeup.make({
32
+ name: "streamsy/expiry",
33
+ next: Storage.use((storage) => storage.nextExpiry).pipe(Effect.map(Option.map((deadline) => deadline.at))),
34
+ run: Protocol.expireDue().pipe(Effect.asVoid)
35
+ });
36
+ /** Internal object owner. Its context closes over the same services as fetch. */
37
+ const makeWakeups = (context, definitions = []) => Effect.gen(function* () {
38
+ const wakeups = [expiry, ...definitions].map(Wakeup.make);
39
+ const names = /* @__PURE__ */ new Set();
40
+ for (const wakeup of wakeups) {
41
+ if (names.has(wakeup.name)) return yield* Effect.die(/* @__PURE__ */ new Error(`Duplicate wakeup name: ${wakeup.name}`));
42
+ names.add(wakeup.name);
43
+ }
44
+ const permits = new Map(wakeups.map((wakeup) => [wakeup.name, Semaphore.makeUnsafe(1)]));
45
+ const reconcilePermit = yield* Semaphore.make(1);
46
+ const turnPermit = yield* Semaphore.make(1);
47
+ const states = wakeups.map((wakeup) => ({
48
+ wakeup,
49
+ failures: 0,
50
+ floor: 0,
51
+ initial: Duration.toMillis(wakeup.retry?.initial ?? "1 second"),
52
+ max: Duration.toMillis(wakeup.retry?.max ?? "1 hour")
53
+ }));
54
+ const slot = Context.get(context, AlarmSlot);
55
+ const provided = () => Context.add(Context.add(context, Permits, permits), Wakeups, service);
56
+ const backOff = (state, at) => {
57
+ state.failures += 1;
58
+ state.floor = at + Math.min(state.max, state.initial * 2 ** (state.failures - 1));
59
+ };
60
+ const recover = (state) => {
61
+ state.failures = 0;
62
+ state.floor = 0;
63
+ };
64
+ const status = Effect.forEach(states, (state) => state.wakeup.next.pipe(Effect.map((next) => ({
65
+ name: state.wakeup.name,
66
+ next,
67
+ failures: state.failures,
68
+ retryAt: state.floor > 0 ? Option.some(state.floor) : Option.none()
69
+ }))));
70
+ const deadlineOf = (state, now) => state.wakeup.next.pipe(Effect.map(Option.map((next) => Math.max(next, state.floor))), Effect.catchCause((cause) => {
71
+ if (state.floor <= now) backOff(state, now);
72
+ return Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause).pipe(Effect.as(Option.some(state.floor)));
73
+ }));
74
+ const reconcileWith = (force) => reconcilePermit.withPermit(Effect.gen(function* () {
75
+ const now = yield* Clock.currentTimeMillis;
76
+ let target = Infinity;
77
+ for (const state of states) {
78
+ const next = yield* deadlineOf(state, now);
79
+ if (Option.isSome(next)) target = Math.min(target, next.value);
80
+ }
81
+ if (target === Infinity) return yield* slot.clear;
82
+ const current = yield* slot.current;
83
+ if (!force && Option.isSome(current) && now < current.value && current.value <= target) return void 0;
84
+ if (Option.isSome(current) && current.value <= now) yield* slot.clear;
85
+ return yield* slot.arm(Math.max(target, now + 1));
86
+ }));
87
+ const reconcile = reconcileWith(false);
88
+ const service = Wakeups.of({
89
+ touch: Effect.flatMap(RequestTouch, (marker) => Effect.sync(() => {
90
+ if (marker !== void 0) marker.touched = true;
91
+ })),
92
+ reconcile: Effect.suspend(() => reconcile.pipe(Effect.provide(provided()), Effect.orDie)),
93
+ status: Effect.suspend(() => status.pipe(Effect.provide(provided()), Effect.orDie))
94
+ });
95
+ const alarm = turnPermit.withPermit(Effect.gen(function* () {
96
+ const now = yield* Clock.currentTimeMillis;
97
+ yield* slot.arm(now + 3e4);
98
+ for (const state of states) yield* Effect.gen(function* () {
99
+ const at = yield* Clock.currentTimeMillis;
100
+ if (state.floor > at) return;
101
+ const next = yield* state.wakeup.next;
102
+ if (Option.isNone(next) || next.value > at) return;
103
+ const exit = yield* Effect.exit(state.wakeup.run);
104
+ if (Exit.isFailure(exit)) {
105
+ backOff(state, yield* Clock.currentTimeMillis);
106
+ yield* Effect.logError(`Wakeup ${state.wakeup.name} failed`, exit.cause);
107
+ return;
108
+ }
109
+ const after = yield* state.wakeup.next;
110
+ if (!reportsMore(exit.value) && Option.isSome(after) && after.value <= at) backOff(state, yield* Clock.currentTimeMillis);
111
+ else recover(state);
112
+ }).pipe(Effect.catchCause((cause) => Clock.currentTimeMillis.pipe(Effect.flatMap((at) => {
113
+ backOff(state, at);
114
+ return Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause);
115
+ }))));
116
+ }).pipe(Effect.ensuring(reconcileWith(true).pipe(Effect.catchCause((cause) => Effect.logError("Wakeup reconciliation failed", cause)))), Effect.catchCause((cause) => Effect.logError("Wakeup alarm failed", cause))));
117
+ const complete = provided();
118
+ yield* reconcile.pipe(Effect.provide(complete), Effect.catchCause((cause) => Effect.logError("Wakeup reconciliation at acquire failed", cause)));
119
+ return {
120
+ context: complete,
121
+ alarm: alarm.pipe(Effect.provide(complete))
122
+ };
123
+ });
124
+ //#endregion
125
+ export { alarmSlotLayer as a, makeWakeups as i, Wakeup as n, Wakeups as r, RequestTouch as t };
package/package.json CHANGED
@@ -1,6 +1,134 @@
1
1
  {
2
2
  "name": "@streamsy/serve",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "Effect HttpRouter routes, Bun, Node, Deno and Cloudflare hosts, and durable action delivery.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/gingerhendrix/streamsy.git",
9
+ "directory": "packages/serve"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ "./action": {
20
+ "types": "./dist/action/contract.d.ts",
21
+ "import": "./dist/action/contract.js",
22
+ "default": "./dist/action/contract.js"
23
+ },
24
+ "./action/errors": {
25
+ "types": "./dist/action/errors.d.ts",
26
+ "import": "./dist/action/errors.js",
27
+ "default": "./dist/action/errors.js"
28
+ },
29
+ "./action/outbox": {
30
+ "types": "./dist/action/outbox.d.ts",
31
+ "import": "./dist/action/outbox.js",
32
+ "default": "./dist/action/outbox.js"
33
+ },
34
+ "./action/runtime": {
35
+ "types": "./dist/action/runtime.d.ts",
36
+ "import": "./dist/action/runtime.js",
37
+ "default": "./dist/action/runtime.js"
38
+ },
39
+ "./action/sql": {
40
+ "types": "./dist/action/sql.d.ts",
41
+ "import": "./dist/action/sql.js",
42
+ "default": "./dist/action/sql.js"
43
+ },
44
+ "./bun": {
45
+ "types": "./dist/bun.d.ts",
46
+ "import": "./dist/bun.js",
47
+ "default": "./dist/bun.js"
48
+ },
49
+ "./node": {
50
+ "types": "./dist/node.d.ts",
51
+ "import": "./dist/node.js",
52
+ "default": "./dist/node.js"
53
+ },
54
+ "./deno": {
55
+ "types": "./dist/deno.d.ts",
56
+ "import": "./dist/deno.js",
57
+ "default": "./dist/deno.js"
58
+ },
59
+ "./cloudflare": {
60
+ "types": "./dist/cloudflare.d.ts",
61
+ "import": "./dist/cloudflare.js",
62
+ "default": "./dist/cloudflare.js"
63
+ },
64
+ "./alchemy": {
65
+ "types": "./dist/alchemy.d.ts",
66
+ "import": "./dist/alchemy.js",
67
+ "default": "./dist/alchemy.js"
68
+ },
69
+ ".": {
70
+ "types": "./dist/index.d.ts",
71
+ "import": "./dist/index.js",
72
+ "default": "./dist/index.js"
73
+ },
74
+ "./contract": {
75
+ "types": "./dist/contract.d.ts",
76
+ "import": "./dist/contract.js",
77
+ "default": "./dist/contract.js"
78
+ }
79
+ },
80
+ "publishConfig": {
81
+ "access": "public"
82
+ },
83
+ "scripts": {
84
+ "build": "tsdown src/index.ts src/contract.ts src/action/contract.ts src/action/errors.ts src/action/outbox.ts src/action/runtime.ts src/action/sql.ts src/bun.ts src/node.ts src/deno.ts src/cloudflare.ts src/alchemy.ts --format esm --dts --out-dir dist --clean --deps.never-bundle effect --deps.never-bundle bun:sqlite --deps.never-bundle cloudflare:workers --deps.never-bundle @cloudflare/workers-types",
85
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.cloudflare.json --noEmit && tsc -p tsconfig.cloudflare-tests.json --noEmit",
86
+ "test:unit": "bun --bun vitest run --dir test",
87
+ "test:dialects": "bun --bun vitest run test/action/outbox-dialects.test.ts"
88
+ },
89
+ "dependencies": {
90
+ "@streamsy/core": "^0.4.0"
91
+ },
92
+ "devDependencies": {
93
+ "@cloudflare/workers-types": "4.20251228.0",
94
+ "@effect/platform-bun": "4.0.5",
95
+ "@effect/platform-deno": "4.0.5",
96
+ "@effect/platform-node": "4.0.5",
97
+ "@effect/sql-mysql2": "4.0.5",
98
+ "@effect/sql-pg": "4.0.5",
99
+ "@effect/sql-sqlite-do": "4.0.5",
100
+ "@effect/vitest": "4.0.5",
101
+ "@streamsy/storage": "0.4.0",
102
+ "@types/bun": "^1.4.3",
103
+ "alchemy": "2.0.0-beta.83",
104
+ "effect": "4.0.5",
105
+ "miniflare": "4.20260730.0",
106
+ "typescript": "^7.0.2",
107
+ "vitest": "5.0.5"
108
+ },
109
+ "peerDependencies": {
110
+ "@effect/platform-bun": "^4.0.5",
111
+ "@effect/platform-deno": "^4.0.5",
112
+ "@effect/platform-node": "^4.0.5",
113
+ "alchemy": "^2.0.0-beta.83",
114
+ "effect": "^4.0.5"
115
+ },
116
+ "peerDependenciesMeta": {
117
+ "@effect/platform-bun": {
118
+ "optional": true
119
+ },
120
+ "@effect/platform-deno": {
121
+ "optional": true
122
+ },
123
+ "@effect/platform-node": {
124
+ "optional": true
125
+ },
126
+ "alchemy": {
127
+ "optional": true
128
+ }
129
+ },
130
+ "tsdown": {
131
+ "fixedExtension": false,
132
+ "tsconfig": "tsconfig.build.json"
133
+ }
134
+ }