@gjermundgaraba/effect-actions 0.20.0 → 0.21.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 +19 -41
- package/dist/Action.d.mts +140 -46
- package/dist/Action.mjs +87 -42
- package/dist/ActionCli.d.mts +69 -33
- package/dist/ActionCli.mjs +51 -32
- package/dist/ActionHttp.d.mts +69 -35
- package/dist/ActionHttp.mjs +75 -23
- package/dist/ActionMcp.d.mts +56 -22
- package/dist/ActionMcp.mjs +94 -69
- package/dist/ActionRpc.d.mts +222 -55
- package/dist/ActionRpc.mjs +299 -46
- package/dist/ActionToolkit.d.mts +22 -5
- package/dist/ActionToolkit.mjs +36 -12
- package/dist/Authentication.d.mts +42 -10
- package/dist/Authentication.mjs +37 -30
- package/dist/Event.d.mts +370 -0
- package/dist/Event.mjs +103 -0
- package/dist/Testing.mjs +1 -1
- package/dist/Webhook.d.mts +146 -0
- package/dist/Webhook.mjs +72 -0
- package/dist/authentication/provider.d.mts +35 -10
- package/dist/authentication/provider.mjs +1 -1
- package/dist/authentication/refusal.mjs +2 -2
- package/dist/cli/commands.d.mts +9 -5
- package/dist/cli/commands.mjs +84 -33
- package/dist/contract/ast.mjs +4 -1
- package/dist/contract/authorizer.d.mts +8 -0
- package/dist/contract/authorizer.mjs +20 -0
- package/dist/contract/call.d.mts +9 -6
- package/dist/contract/call.mjs +48 -6
- package/dist/contract/callback.d.mts +7 -0
- package/dist/contract/callback.mjs +27 -0
- package/dist/contract/caller.d.mts +13 -2
- package/dist/contract/caller.mjs +22 -3
- package/dist/contract/canonical.mjs +10 -0
- package/dist/contract/codec.d.mts +9 -0
- package/dist/contract/codec.mjs +9 -0
- package/dist/contract/codes.mjs +81 -0
- package/dist/contract/declared.mjs +25 -9
- package/dist/contract/digest.mjs +6 -0
- package/dist/contract/errors.d.mts +9 -3
- package/dist/contract/errors.mjs +1 -1
- package/dist/contract/feed.d.mts +127 -0
- package/dist/contract/feed.mjs +249 -0
- package/dist/contract/hub.d.mts +94 -0
- package/dist/contract/hub.mjs +133 -0
- package/dist/contract/idempotency.d.mts +47 -0
- package/dist/contract/idempotency.mjs +99 -0
- package/dist/contract/implementation.d.mts +73 -47
- package/dist/contract/implementation.mjs +92 -58
- package/dist/contract/input-check.d.mts +15 -0
- package/dist/contract/input-check.mjs +16 -0
- package/dist/contract/lease.mjs +28 -0
- package/dist/contract/listed.d.mts +4 -0
- package/dist/contract/listed.mjs +22 -0
- package/dist/contract/log.d.mts +41 -0
- package/dist/contract/log.mjs +144 -0
- package/dist/contract/names.mjs +14 -4
- package/dist/contract/occurrence.d.mts +29 -0
- package/dist/contract/occurrence.mjs +45 -0
- package/dist/contract/pages.d.mts +13 -0
- package/dist/contract/pages.mjs +39 -0
- package/dist/contract/progress.d.mts +26 -0
- package/dist/contract/progress.mjs +72 -0
- package/dist/contract/push.d.mts +20 -0
- package/dist/contract/push.mjs +96 -0
- package/dist/contract/receiving.d.mts +29 -0
- package/dist/contract/receiving.mjs +42 -0
- package/dist/contract/recent.mjs +12 -0
- package/dist/contract/reported.mjs +16 -0
- package/dist/contract/serving.mjs +63 -0
- package/dist/contract/status.mjs +14 -5
- package/dist/contract/stream.d.mts +34 -0
- package/dist/contract/stream.mjs +31 -0
- package/dist/contract/subscribe.d.mts +45 -0
- package/dist/contract/subscribe.mjs +50 -0
- package/dist/contract/transient.mjs +28 -0
- package/dist/contract/watch.d.mts +18 -0
- package/dist/contract/watch.mjs +105 -0
- package/dist/contract/webhooks.d.mts +42 -0
- package/dist/contract/webhooks.mjs +53 -0
- package/dist/http/client.d.mts +37 -12
- package/dist/http/client.mjs +51 -12
- package/dist/http/events.d.mts +56 -0
- package/dist/http/events.mjs +102 -0
- package/dist/http/placed.d.mts +36 -0
- package/dist/http/placed.mjs +71 -0
- package/dist/http/schema-errors.mjs +1 -1
- package/dist/http/streamed.d.mts +21 -0
- package/dist/http/streamed.mjs +94 -0
- package/dist/http/watch.mjs +50 -0
- package/dist/mcp/bridge.mjs +283 -0
- package/dist/mcp/client.d.mts +30 -97
- package/dist/mcp/client.mjs +59 -132
- package/dist/mcp/events.mjs +142 -0
- package/dist/mcp/progress.mjs +34 -0
- package/dist/mcp/protocol.mjs +39 -13
- package/dist/mcp/reply.d.mts +79 -0
- package/dist/mcp/reply.mjs +99 -0
- package/dist/mcp/request.d.mts +40 -0
- package/dist/mcp/request.mjs +51 -0
- package/dist/mcp/watch.mjs +115 -0
- package/dist/rpc/watch.mjs +39 -0
- package/dist/stdio/console.mjs +13 -5
- package/dist/testing/memory.d.mts +1 -1
- package/dist/testing/memory.mjs +3 -3
- package/dist/webhook/address.mjs +47 -0
- package/dist/webhook/attempt.mjs +65 -0
- package/dist/webhook/receiver.mjs +67 -0
- package/dist/webhook/signing.mjs +28 -0
- package/dist/webhook/stored.mjs +57 -0
- package/dist/webhook/subscriptions.mjs +342 -0
- package/docs/Action-failures.md +2 -2
- package/docs/Action-streams.md +115 -0
- package/docs/Action.md +58 -124
- package/docs/ActionCli-failures.md +1 -2
- package/docs/ActionCli.md +83 -88
- package/docs/ActionHttp-failures.md +3 -3
- package/docs/ActionHttp.md +90 -70
- package/docs/ActionMcp-failures.md +2 -1
- package/docs/ActionMcp.md +120 -92
- package/docs/ActionRpc-events.md +126 -0
- package/docs/ActionRpc-failures.md +1 -2
- package/docs/ActionRpc.md +108 -79
- package/docs/ActionToolkit-failures.md +1 -2
- package/docs/ActionToolkit.md +33 -90
- package/docs/Authentication-advanced.md +52 -60
- package/docs/Authentication-failures.md +5 -4
- package/docs/Authentication.md +23 -58
- package/docs/Event-failures.md +15 -0
- package/docs/Event.md +281 -0
- package/docs/README.md +51 -82
- package/docs/Testing-failures.md +1 -5
- package/docs/Testing.md +126 -86
- package/docs/Webhook-failures.md +8 -0
- package/docs/Webhook.md +319 -0
- package/docs/setup-failures.md +2 -3
- package/docs/setup.md +32 -52
- package/examples/README.md +12 -37
- package/examples/assistant/README.md +21 -26
- package/examples/assistant/src/agent.ts +66 -24
- package/examples/assistant/src/cli.ts +7 -4
- package/examples/assistant/src/commands.ts +9 -4
- package/examples/assistant/src/identity/Identity.ts +5 -4
- package/examples/assistant/src/identity/authorize.ts +13 -5
- package/examples/assistant/src/identity/handlers.ts +3 -2
- package/examples/assistant/src/identity/permissions.ts +9 -3
- package/examples/assistant/src/platform/tasks-file.ts +15 -11
- package/examples/assistant/src/scripted.ts +7 -4
- package/examples/assistant/src/tasks/Tasks.ts +20 -6
- package/examples/assistant/src/tasks/handlers.ts +9 -5
- package/examples/assistant/src/tasks/store.ts +38 -11
- package/examples/assistant/src/toolkit.ts +11 -7
- package/examples/assistant/test/commands.test.ts +53 -27
- package/examples/assistant/test/identity/identity.test.ts +27 -11
- package/examples/assistant/test/platform/tasks-file.test.ts +39 -23
- package/examples/assistant/test/tasks/tasks.test.ts +62 -34
- package/examples/assistant/test/toolkit.test.ts +104 -75
- package/examples/build-events/README.md +83 -0
- package/examples/build-events/src/Api.ts +27 -0
- package/examples/build-events/src/Rpc.ts +26 -0
- package/examples/build-events/src/builds/Builds.ts +108 -0
- package/examples/build-events/src/builds/events.ts +54 -0
- package/examples/build-events/src/builds/handlers.ts +58 -0
- package/examples/build-events/src/builds/log.ts +30 -0
- package/examples/build-events/src/builds/status.ts +28 -0
- package/examples/build-events/src/builds/store.ts +130 -0
- package/examples/build-events/src/cli.ts +18 -0
- package/examples/build-events/src/commands.ts +32 -0
- package/examples/build-events/src/deploys/Deploys.ts +44 -0
- package/examples/build-events/src/deploys/approvers.ts +55 -0
- package/examples/build-events/src/deploys/events.ts +32 -0
- package/examples/build-events/src/deploys/handlers.ts +38 -0
- package/examples/build-events/src/identity/Identity.ts +29 -0
- package/examples/build-events/src/identity/members.ts +7 -0
- package/examples/build-events/src/identity/verifier.ts +22 -0
- package/examples/build-events/src/notifier/Notices.ts +26 -0
- package/examples/build-events/src/notifier/routes.ts +71 -0
- package/examples/build-events/src/notifier.ts +48 -0
- package/examples/build-events/src/routes.ts +71 -0
- package/examples/build-events/src/server.ts +62 -0
- package/examples/build-events/test/builds/events.test.ts +68 -0
- package/examples/build-events/test/builds/log.test.ts +71 -0
- package/examples/build-events/test/builds/progress.test.ts +57 -0
- package/examples/build-events/test/builds/status.test.ts +42 -0
- package/examples/build-events/test/commands.test.ts +103 -0
- package/examples/build-events/test/deploys/events.test.ts +52 -0
- package/examples/build-events/test/http.test.ts +95 -0
- package/examples/build-events/test/notifier.test.ts +80 -0
- package/examples/build-events/test/routes.test.ts +96 -0
- package/examples/build-events/test/rpc.test.ts +84 -0
- package/examples/build-events/test/support/as.ts +8 -0
- package/examples/build-events/test/support/memory.ts +43 -0
- package/examples/build-events/test/webhooks.test.ts +143 -0
- package/examples/kitchen-sink/README.md +178 -166
- package/examples/kitchen-sink/src/Actions.ts +11 -3
- package/examples/kitchen-sink/src/Api.ts +12 -6
- package/examples/kitchen-sink/src/Rpc.ts +7 -5
- package/examples/kitchen-sink/src/admin/Admin.ts +21 -10
- package/examples/kitchen-sink/src/admin/authenticate.ts +8 -4
- package/examples/kitchen-sink/src/admin/authorize.ts +13 -5
- package/examples/kitchen-sink/src/admin/grants.ts +5 -3
- package/examples/kitchen-sink/src/admin/handlers.ts +9 -5
- package/examples/kitchen-sink/src/agent.ts +14 -6
- package/examples/kitchen-sink/src/assistant/Assistant.ts +21 -15
- package/examples/kitchen-sink/src/assistant/conversations.ts +14 -9
- package/examples/kitchen-sink/src/assistant/demo-model.ts +2 -2
- package/examples/kitchen-sink/src/assistant/handlers.ts +42 -22
- package/examples/kitchen-sink/src/attachments/Attachments.ts +6 -6
- package/examples/kitchen-sink/src/attachments/download.ts +7 -6
- package/examples/kitchen-sink/src/attachments/handlers.ts +9 -4
- package/examples/kitchen-sink/src/attachments/store.ts +25 -18
- package/examples/kitchen-sink/src/browser.ts +10 -4
- package/examples/kitchen-sink/src/cli.ts +28 -12
- package/examples/kitchen-sink/src/config.ts +1 -1
- package/examples/kitchen-sink/src/identity/Identity.ts +24 -10
- package/examples/kitchen-sink/src/identity/authenticate.ts +19 -11
- package/examples/kitchen-sink/src/identity/authorize.ts +14 -4
- package/examples/kitchen-sink/src/identity/directory.ts +16 -7
- package/examples/kitchen-sink/src/identity/handlers.ts +1 -0
- package/examples/kitchen-sink/src/identity/members.ts +7 -2
- package/examples/kitchen-sink/src/identity/quota.ts +23 -20
- package/examples/kitchen-sink/src/issues/Issues.ts +91 -15
- package/examples/kitchen-sink/src/issues/assistant.ts +4 -2
- package/examples/kitchen-sink/src/issues/events.ts +46 -0
- package/examples/kitchen-sink/src/issues/export.ts +35 -0
- package/examples/kitchen-sink/src/issues/guide.ts +4 -3
- package/examples/kitchen-sink/src/issues/handlers.ts +20 -7
- package/examples/kitchen-sink/src/issues/store.ts +156 -67
- package/examples/kitchen-sink/src/issues/write-limit.ts +44 -18
- package/examples/kitchen-sink/src/memory.ts +11 -2
- package/examples/kitchen-sink/src/ops.ts +10 -5
- package/examples/kitchen-sink/src/request-policy.ts +21 -9
- package/examples/kitchen-sink/src/routes.ts +67 -27
- package/examples/kitchen-sink/src/sdk.ts +22 -12
- package/examples/kitchen-sink/src/seed.ts +15 -9
- package/examples/kitchen-sink/src/server.ts +24 -10
- package/examples/kitchen-sink/src/stdio.ts +9 -3
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +10 -7
- package/examples/kitchen-sink/src/workspaces/handlers.ts +3 -2
- package/examples/kitchen-sink/src/workspaces/registry.ts +16 -10
- package/examples/kitchen-sink/src/workspaces/tenant.ts +12 -5
- package/examples/kitchen-sink/test/admin/admin.test.ts +38 -23
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +471 -299
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +135 -94
- package/examples/kitchen-sink/test/identity/identity.test.ts +110 -82
- package/examples/kitchen-sink/test/identity/session.test.ts +65 -43
- package/examples/kitchen-sink/test/issues/assistant.test.ts +50 -31
- package/examples/kitchen-sink/test/issues/events.test.ts +273 -0
- package/examples/kitchen-sink/test/issues/export.test.ts +120 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +105 -75
- package/examples/kitchen-sink/test/request-policy.test.ts +108 -47
- package/examples/kitchen-sink/test/routes.test.ts +102 -57
- package/examples/kitchen-sink/test/support/as.ts +9 -0
- package/examples/kitchen-sink/test/support/scripted.ts +6 -5
- package/examples/kitchen-sink/test/support/services.ts +17 -0
- package/examples/live-rpc/README.md +33 -35
- package/examples/live-rpc/src/Rpc.ts +9 -5
- package/examples/live-rpc/src/browser.ts +39 -11
- package/examples/live-rpc/src/chat/Chat.ts +30 -16
- package/examples/live-rpc/src/chat/authorize.ts +2 -1
- package/examples/live-rpc/src/chat/events.ts +74 -0
- package/examples/live-rpc/src/chat/handlers.ts +13 -5
- package/examples/live-rpc/src/chat/room.ts +9 -5
- package/examples/live-rpc/src/chat/store.ts +51 -14
- package/examples/live-rpc/src/client.ts +22 -5
- package/examples/live-rpc/src/identity/Identity.ts +3 -3
- package/examples/live-rpc/src/identity/verifier.ts +8 -4
- package/examples/live-rpc/src/page.ts +58 -12
- package/examples/live-rpc/src/routes.ts +14 -10
- package/examples/live-rpc/src/server.ts +15 -5
- package/examples/live-rpc/test/browser.test.ts +127 -50
- package/examples/live-rpc/test/chat/chat.test.ts +215 -23
- package/examples/live-rpc/test/routes.test.ts +130 -46
- package/examples/live-rpc/test/server.test.ts +94 -33
- package/examples/mcp-tools/README.md +24 -38
- package/examples/mcp-tools/src/platform/fake-farm.ts +63 -24
- package/examples/mcp-tools/src/platform/print-farm.ts +59 -29
- package/examples/mcp-tools/src/printers/Printers.ts +18 -7
- package/examples/mcp-tools/src/printers/farm.ts +15 -5
- package/examples/mcp-tools/src/printers/handlers.ts +12 -6
- package/examples/mcp-tools/src/routes.ts +8 -6
- package/examples/mcp-tools/src/server.ts +12 -5
- package/examples/mcp-tools/src/stdio.ts +3 -2
- package/examples/mcp-tools/test/printers/printers.test.ts +132 -89
- package/examples/mcp-tools/test/routes.test.ts +140 -81
- package/examples/notes-service/README.md +30 -45
- package/examples/notes-service/src/Api.ts +1 -0
- package/examples/notes-service/src/browser.ts +15 -14
- package/examples/notes-service/src/cli.ts +8 -3
- package/examples/notes-service/src/commands.ts +46 -20
- package/examples/notes-service/src/identity/Identity.ts +2 -2
- package/examples/notes-service/src/identity/authorize.ts +6 -2
- package/examples/notes-service/src/identity/handlers.ts +3 -2
- package/examples/notes-service/src/identity/users.ts +2 -1
- package/examples/notes-service/src/identity/verifier.ts +12 -6
- package/examples/notes-service/src/notes/Notes.ts +46 -22
- package/examples/notes-service/src/notes/handlers.ts +18 -9
- package/examples/notes-service/src/notes/store.ts +70 -43
- package/examples/notes-service/src/routes.ts +7 -5
- package/examples/notes-service/src/server.ts +12 -5
- package/examples/notes-service/src/status/Status.ts +1 -1
- package/examples/notes-service/src/status/handlers.ts +5 -3
- package/examples/notes-service/src/stdio.ts +7 -3
- package/examples/notes-service/src/worker.ts +6 -3
- package/examples/notes-service/test/commands.test.ts +164 -91
- package/examples/notes-service/test/notes/as.ts +3 -1
- package/examples/notes-service/test/notes/idempotency.test.ts +97 -67
- package/examples/notes-service/test/notes/in-process.test.ts +26 -15
- package/examples/notes-service/test/notes/notes.test.ts +49 -31
- package/examples/notes-service/test/routes.test.ts +106 -71
- package/examples/notes-service/test/worker.test.ts +21 -13
- package/examples/quickstart/README.md +2 -6
- package/examples/quickstart/src/Api.ts +1 -0
- package/examples/quickstart/src/client.ts +6 -3
- package/examples/quickstart/src/greeting/Greeting.ts +1 -1
- package/examples/quickstart/src/greeting/handlers.ts +5 -2
- package/examples/quickstart/src/routes.ts +3 -2
- package/examples/quickstart/src/server.ts +6 -2
- package/examples/quickstart/test/routes.test.ts +3 -2
- package/package.json +10 -4
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { canonicalInput } from "../contract/canonical.mjs";
|
|
2
|
+
import { reported } from "../contract/reported.mjs";
|
|
3
|
+
import { encodeUntaggedOf } from "../contract/occurrence.mjs";
|
|
4
|
+
import { CallbackFailed, NotSubscribed, SubscriptionError } from "../Event.mjs";
|
|
5
|
+
import { sentError } from "../contract/codes.mjs";
|
|
6
|
+
import { randomToken, subscriptionId } from "./signing.mjs";
|
|
7
|
+
import { pushed, refusalIn } from "../contract/push.mjs";
|
|
8
|
+
import { checkedHost } from "./address.mjs";
|
|
9
|
+
import { attempt, challenged } from "./attempt.mjs";
|
|
10
|
+
import { Cause, Clock, Context, DateTime, Deferred, Duration, Effect, Exit, Fiber, FiberMap, Match, Option, RcMap, Schedule, Schema, Scope, Semaphore, Stream, Struct } from "effect";
|
|
11
|
+
import { HttpRouter, HttpServerRequest } from "effect/http";
|
|
12
|
+
// src/webhook/subscriptions.ts
|
|
13
|
+
const minTtl = Duration.minutes(1);
|
|
14
|
+
const rotationGrace = Duration.minutes(5);
|
|
15
|
+
const maxBody = 262144;
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
const lastAttempts = Schedule.exponential(Duration.seconds(1)).pipe(Schedule.upTo({ times: 3 }));
|
|
18
|
+
const iso = (millis) => DateTime.formatIso(DateTime.makeUnsafe(millis));
|
|
19
|
+
const active = (status) => status.active ? status : {
|
|
20
|
+
...Struct.omit(status, ["failedSince"]),
|
|
21
|
+
active: true
|
|
22
|
+
};
|
|
23
|
+
const grantOf = (ttlMs, policy) => Duration.clamp(ttlMs === null || ttlMs === void 0 ? policy.maxTtl : Duration.millis(ttlMs), {
|
|
24
|
+
minimum: minTtl,
|
|
25
|
+
maximum: policy.maxTtl
|
|
26
|
+
});
|
|
27
|
+
const trusted = (url, prefixes) => prefixes.some(({ origin, pathname }) => url.origin === origin && (url.pathname === pathname || url.pathname.startsWith(pathname.endsWith("/") ? pathname : `${pathname}/`)));
|
|
28
|
+
const snapshotOf = Context.omit(Scope.Scope, HttpServerRequest.HttpServerRequest, HttpServerRequest.ParsedSearchParams, HttpRouter.RouteContext);
|
|
29
|
+
const unencoded = Effect.die(/* @__PURE__ */ new Error("A webhook subscriber's identity did not encode by its authentication's schema (give Authentication.make a schema that encodes every identity its verifier provides)"));
|
|
30
|
+
/**
|
|
31
|
+
* The webhook subscriptions of one `Webhook.layer`: each a worker reading its feed as the
|
|
32
|
+
* subscriber and POSTing what it reads to the callback, until it expires, is unsubscribed, or
|
|
33
|
+
* is refused.
|
|
34
|
+
*/
|
|
35
|
+
const confirmedAt = (cursor) => Effect.tap(Deferred.make(), (confirmation) => Deferred.succeed(confirmation, {
|
|
36
|
+
cursor,
|
|
37
|
+
truncated: false
|
|
38
|
+
}));
|
|
39
|
+
const failedCallback = (feed, category) => SubscriptionError.of(feed.event.name, new CallbackFailed({ category }));
|
|
40
|
+
const idOf = ({ feed, principal, input, delivery: { url } }) => Effect.flatMap(canonicalInput(feed.event.input, input), (encoded) => subscriptionId(`${principal}\u0000${url.href}\u0000${feed.event.name}\u0000${encoded}`));
|
|
41
|
+
const named = (feeds) => feeds.map(({ event }) => `"${event.name}"`).join(", ");
|
|
42
|
+
const makeWebhooks = (policy) => Effect.gen(function* () {
|
|
43
|
+
const built = yield* Effect.context();
|
|
44
|
+
const workers = yield* FiberMap.make();
|
|
45
|
+
const held = /* @__PURE__ */ new Map();
|
|
46
|
+
const served = /* @__PURE__ */ new Map();
|
|
47
|
+
const { storage } = policy;
|
|
48
|
+
const holds = (subscription) => held.get(subscription.id) === subscription;
|
|
49
|
+
const kept = (subscription) => Effect.sync(() => {
|
|
50
|
+
if (storage !== void 0 && holds(subscription)) storage.mark(subscription.id, {
|
|
51
|
+
...subscription.kept,
|
|
52
|
+
url: subscription.url,
|
|
53
|
+
event: subscription.feed.event.name,
|
|
54
|
+
secret: subscription.secret,
|
|
55
|
+
expiresAt: subscription.expiresAt,
|
|
56
|
+
watermark: subscription.watermark,
|
|
57
|
+
status: subscription.status
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
const dropped = (id) => Effect.sync(() => storage?.mark(id, void 0));
|
|
61
|
+
const verified = /* @__PURE__ */ new Map();
|
|
62
|
+
const locks = yield* RcMap.make({ lookup: (_id) => Semaphore.make(1) });
|
|
63
|
+
const retryForMillis = Duration.toMillis(policy.retryFor);
|
|
64
|
+
const failingFor = ({ status }) => Effect.map(Clock.currentTimeMillis, (now) => status.failedSince === void 0 ? 0 : now - Date.parse(status.failedSince));
|
|
65
|
+
const retrying = (subscription) => ({
|
|
66
|
+
schedule: Schedule.modifyDelay(Schedule.forever, () => Effect.map(failingFor(subscription), (failing) => Duration.clamp(Duration.millis(failing), {
|
|
67
|
+
minimum: Duration.seconds(1),
|
|
68
|
+
maximum: Duration.minutes(5)
|
|
69
|
+
}))),
|
|
70
|
+
while: (failure) => failure.final ? Effect.succeed(false) : Effect.map(failingFor(subscription), (failing) => failing < retryForMillis)
|
|
71
|
+
});
|
|
72
|
+
const locked = (id) => (effect) => Effect.scoped(Effect.flatMap(RcMap.get(locks, id), (lock) => lock.withPermits(1)(effect)));
|
|
73
|
+
const asSubscriber = (subscription, effect) => Effect.updateContext(effect, () => subscription.context);
|
|
74
|
+
const post = (subscription, webhookId, body) => Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.provideContext(attempt({
|
|
75
|
+
url: subscription.url,
|
|
76
|
+
subscriptionId: subscription.id,
|
|
77
|
+
webhookId,
|
|
78
|
+
body,
|
|
79
|
+
keys: subscription.previous !== void 0 && now < subscription.previous.until ? [subscription.secret.key, subscription.previous.key] : [subscription.secret.key]
|
|
80
|
+
}, trusted(subscription.url, policy.allowedCallbacks) ? Effect.void : checkedHost(subscription.url, policy.dns)), built));
|
|
81
|
+
const recorded = (subscription, failure) => Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.suspend(() => {
|
|
82
|
+
const { status } = subscription;
|
|
83
|
+
subscription.status = failure === void 0 ? {
|
|
84
|
+
active: true,
|
|
85
|
+
lastDeliveryAt: iso(now),
|
|
86
|
+
lastError: null
|
|
87
|
+
} : {
|
|
88
|
+
...status,
|
|
89
|
+
lastError: failure.category,
|
|
90
|
+
failedSince: status.failedSince ?? iso(now)
|
|
91
|
+
};
|
|
92
|
+
return failure !== void 0 && status.lastError === failure.category && status.failedSince !== void 0 ? Effect.void : kept(subscription);
|
|
93
|
+
}));
|
|
94
|
+
const suspended = (subscription) => Effect.andThen(Effect.andThen(Effect.sync(() => {
|
|
95
|
+
subscription.status = {
|
|
96
|
+
...subscription.status,
|
|
97
|
+
active: false
|
|
98
|
+
};
|
|
99
|
+
}), kept(subscription)), Effect.never);
|
|
100
|
+
const abandoned = (subscription) => Effect.andThen(Effect.sync(() => {
|
|
101
|
+
subscription.status = Struct.omit(subscription.status, ["failedSince"]);
|
|
102
|
+
}), kept(subscription));
|
|
103
|
+
const delivered = (subscription, webhookId, body) => post(subscription, webhookId, body).pipe(Effect.tapError((failure) => recorded(subscription, failure)), Effect.retry(retrying(subscription)), Effect.matchEffect({
|
|
104
|
+
onSuccess: () => Effect.as(recorded(subscription, void 0), true),
|
|
105
|
+
onFailure: (failure) => failure.final ? Effect.as(abandoned(subscription), false) : suspended(subscription)
|
|
106
|
+
}));
|
|
107
|
+
const gapped = (subscription, cursor) => Effect.flatMap(randomToken, (token) => delivered(subscription, `msg_gap_${token}`, JSON.stringify({
|
|
108
|
+
type: "gap",
|
|
109
|
+
cursor
|
|
110
|
+
})));
|
|
111
|
+
const deliverTo = (subscription, input, confirmation) => {
|
|
112
|
+
const encode = encodeUntaggedOf(subscription.feed.event);
|
|
113
|
+
const moved = (cursor) => cursor === null || cursor === subscription.watermark ? Effect.void : Effect.andThen(Effect.sync(() => {
|
|
114
|
+
subscription.watermark = cursor;
|
|
115
|
+
}), kept(subscription));
|
|
116
|
+
const confirmed = (cursor, truncated) => Effect.andThen(moved(cursor), Deferred.succeed(confirmation, {
|
|
117
|
+
cursor,
|
|
118
|
+
truncated
|
|
119
|
+
}));
|
|
120
|
+
const skipped = (cursor) => Effect.andThen(gapped(subscription, cursor), moved(cursor));
|
|
121
|
+
return (sent) => Match.valueTags(sent, {
|
|
122
|
+
Checked: ({ cursor }) => Deferred.isDoneUnsafe(confirmation) ? moved(cursor) : confirmed(cursor, false),
|
|
123
|
+
Gap: ({ cursor }) => Deferred.isDoneUnsafe(confirmation) ? skipped(cursor) : confirmed(cursor, true),
|
|
124
|
+
Occurrence: (occurrence) => Effect.gen(function* () {
|
|
125
|
+
const body = yield* asSubscriber(subscription, subscription.feed.admit(input)).pipe(Effect.andThen(Effect.map(encode(occurrence), JSON.stringify)));
|
|
126
|
+
if (encoder.encode(body).length > maxBody) {
|
|
127
|
+
yield* Effect.logWarning(`Webhook delivery of ${occurrence.eventId} abandoned: its body is over 256 KiB`);
|
|
128
|
+
return yield* skipped(occurrence.cursor);
|
|
129
|
+
}
|
|
130
|
+
if (yield* delivered(subscription, occurrence.eventId, body)) return yield* moved(occurrence.cursor);
|
|
131
|
+
return yield* skipped(occurrence.cursor);
|
|
132
|
+
}),
|
|
133
|
+
Failed: () => Effect.void
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
const expired = ({ expiresAt }) => Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.sleep(Duration.millis(Math.max(expiresAt - now, 0))));
|
|
137
|
+
const terminated = (subscription, cause) => {
|
|
138
|
+
const refusal = refusalIn(cause);
|
|
139
|
+
if (refusal === void 0) return Cause.hasInterruptsOnly(cause) ? Effect.void : reported(cause);
|
|
140
|
+
return Effect.flatMap(randomToken, (token) => post(subscription, `msg_terminated_${token}`, JSON.stringify({
|
|
141
|
+
type: "terminated",
|
|
142
|
+
error: sentError(refusal)
|
|
143
|
+
})).pipe(Effect.retry({
|
|
144
|
+
schedule: lastAttempts,
|
|
145
|
+
while: (failure) => !failure.final
|
|
146
|
+
}), Effect.ignore));
|
|
147
|
+
};
|
|
148
|
+
const ended = (subscription) => Effect.sync(() => {
|
|
149
|
+
if (holds(subscription)) held.delete(subscription.id);
|
|
150
|
+
});
|
|
151
|
+
const finished = (subscription) => Effect.suspend(() => holds(subscription) ? Effect.andThen(ended(subscription), dropped(subscription.id)) : Effect.void);
|
|
152
|
+
const workerOf = (subscription, input, cursor, confirmation) => Effect.raceFirst(pushed((from) => Stream.unwrap(Effect.map(subscription.feed.stream(input), (read) => read(from))), cursor, deliverTo(subscription, input, confirmation), {
|
|
153
|
+
inOrder: true,
|
|
154
|
+
resumesUnconfirmed: Deferred.isDoneUnsafe(confirmation)
|
|
155
|
+
}), expired(subscription)).pipe(Effect.exit, Effect.tap(() => finished(subscription)), Effect.flatMap((exit) => {
|
|
156
|
+
if (Deferred.isDoneUnsafe(confirmation)) return Exit.isFailure(exit) ? terminated(subscription, exit.cause) : Effect.void;
|
|
157
|
+
return Exit.isFailure(exit) ? Deferred.failCause(confirmation, exit.cause) : Deferred.succeed(confirmation, {
|
|
158
|
+
cursor,
|
|
159
|
+
truncated: false
|
|
160
|
+
});
|
|
161
|
+
}), Effect.onInterrupt(() => Effect.andThen(ended(subscription), Deferred.interrupt(confirmation))), (worker) => asSubscriber(subscription, worker));
|
|
162
|
+
const remembered = ({ verifiedAs, expiresAt }, now) => Effect.sync(() => {
|
|
163
|
+
for (const [each, expiry] of verified) if (expiry <= now) verified.delete(each);
|
|
164
|
+
verified.set(verifiedAs, Math.max(expiresAt, verified.get(verifiedAs) ?? 0));
|
|
165
|
+
});
|
|
166
|
+
const verify = ({ verifiedAs, url, id, secret, feed }, now) => {
|
|
167
|
+
if ((verified.get(verifiedAs) ?? 0) > now || trusted(url, policy.allowedCallbacks)) return Effect.void;
|
|
168
|
+
return Effect.gen(function* () {
|
|
169
|
+
const challenge = yield* randomToken;
|
|
170
|
+
if ((yield* Effect.provideContext(challenged({
|
|
171
|
+
url,
|
|
172
|
+
subscriptionId: id,
|
|
173
|
+
webhookId: `msg_verification_${yield* randomToken}`,
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
type: "verification",
|
|
176
|
+
challenge
|
|
177
|
+
}),
|
|
178
|
+
keys: [secret.key]
|
|
179
|
+
}, checkedHost(url, policy.dns)), built).pipe(Effect.mapError(({ category }) => failedCallback(feed, category)))) !== challenge) return yield* failedCallback(feed, "challenge_failed");
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
const handover = (id, next, worker) => Effect.uninterruptible(Effect.gen(function* () {
|
|
183
|
+
const previous = yield* FiberMap.get(workers, id);
|
|
184
|
+
if (next === void 0) held.delete(id);
|
|
185
|
+
else held.set(id, next);
|
|
186
|
+
yield* FiberMap.run(workers, id, Option.match(previous, {
|
|
187
|
+
onNone: () => worker,
|
|
188
|
+
onSome: (fiber) => Effect.andThen(Fiber.await(fiber), worker)
|
|
189
|
+
}));
|
|
190
|
+
}));
|
|
191
|
+
const refreshed = (subscription, granted, input, now) => Effect.gen(function* () {
|
|
192
|
+
const { status, watermark } = subscription;
|
|
193
|
+
const renewed = {
|
|
194
|
+
...subscription,
|
|
195
|
+
...granted,
|
|
196
|
+
...granted.secret.text === subscription.secret.text ? {} : { previous: {
|
|
197
|
+
key: subscription.secret.key,
|
|
198
|
+
until: now + Duration.toMillis(rotationGrace)
|
|
199
|
+
} },
|
|
200
|
+
status: active(status)
|
|
201
|
+
};
|
|
202
|
+
const confirmation = yield* confirmedAt(watermark);
|
|
203
|
+
yield* handover(renewed.id, renewed, workerOf(renewed, input, watermark, confirmation));
|
|
204
|
+
yield* remembered(renewed, now);
|
|
205
|
+
yield* kept(renewed);
|
|
206
|
+
return {
|
|
207
|
+
id: renewed.id,
|
|
208
|
+
refreshBefore: iso(renewed.expiresAt),
|
|
209
|
+
cursor: watermark,
|
|
210
|
+
truncated: false,
|
|
211
|
+
deliveryStatus: status
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
const heldOf = (fields) => ({
|
|
215
|
+
...fields,
|
|
216
|
+
verifiedAs: `${fields.principal}\u0000${fields.url.href}`,
|
|
217
|
+
previous: void 0
|
|
218
|
+
});
|
|
219
|
+
const keptOf = ({ feed, keyed, input }) => Effect.gen(function* () {
|
|
220
|
+
const { schema } = keyed;
|
|
221
|
+
if (storage === void 0 || schema === void 0) return {
|
|
222
|
+
input: null,
|
|
223
|
+
identity: null
|
|
224
|
+
};
|
|
225
|
+
const identity = yield* Effect.service(feed.event.caller);
|
|
226
|
+
return {
|
|
227
|
+
input: yield* Effect.orDie(Schema.encodeUnknownEffect(Schema.toCodecJson(feed.event.input))(input)),
|
|
228
|
+
identity: yield* Effect.catch(Schema.encodeUnknownEffect(Schema.toCodecJson(schema))(identity), () => unencoded)
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
const restoredFrom = (store, feeds, keyed, schema, id) => Effect.gen(function* () {
|
|
232
|
+
const record = Option.getOrUndefined(yield* store.get(id));
|
|
233
|
+
if (record === void 0) return yield* dropped(id);
|
|
234
|
+
const feed = feeds.find(({ event }) => event.name === record.event);
|
|
235
|
+
if (feed === void 0) return;
|
|
236
|
+
const { expiresAt, url, secret } = record;
|
|
237
|
+
if (expiresAt <= (yield* Clock.currentTimeMillis)) return yield* dropped(id);
|
|
238
|
+
const input = yield* Schema.decodeEffect(Schema.toCodecJson(feed.event.input))(record.input);
|
|
239
|
+
const identity = yield* Schema.decodeEffect(Schema.toCodecJson(schema))(record.identity);
|
|
240
|
+
const principal = keyed.principal(identity);
|
|
241
|
+
const current = yield* idOf({
|
|
242
|
+
feed,
|
|
243
|
+
principal,
|
|
244
|
+
input,
|
|
245
|
+
delivery: { url }
|
|
246
|
+
});
|
|
247
|
+
if (current !== id) yield* dropped(id);
|
|
248
|
+
if (held.has(current)) return;
|
|
249
|
+
const subscription = heldOf({
|
|
250
|
+
id: current,
|
|
251
|
+
url,
|
|
252
|
+
feed,
|
|
253
|
+
principal,
|
|
254
|
+
kept: {
|
|
255
|
+
input: record.input,
|
|
256
|
+
identity: record.identity
|
|
257
|
+
},
|
|
258
|
+
secret,
|
|
259
|
+
context: Context.add(built, feed.event.caller, identity),
|
|
260
|
+
expiresAt,
|
|
261
|
+
status: record.status,
|
|
262
|
+
watermark: record.watermark
|
|
263
|
+
});
|
|
264
|
+
const confirmation = yield* confirmedAt(record.watermark);
|
|
265
|
+
yield* handover(current, subscription, record.status.active ? workerOf(subscription, input, record.watermark, confirmation) : Effect.andThen(expired(subscription), finished(subscription)));
|
|
266
|
+
if (current !== id) yield* kept(subscription);
|
|
267
|
+
}).pipe(Effect.catchCause(reported));
|
|
268
|
+
const serve = (feeds, keyed) => Effect.gen(function* () {
|
|
269
|
+
const { schema } = keyed;
|
|
270
|
+
const namesake = feeds.filter(({ event }) => served.has(event.name) && served.get(event.name)?.event !== event);
|
|
271
|
+
if (namesake.length > 0) return yield* Effect.die(/* @__PURE__ */ new Error(`Webhook.layer serves ${named(namesake)} for two event types of that name (give each event type a name of its own, or each surface its own Webhook.layer)`));
|
|
272
|
+
if (storage !== void 0 && feeds.length > 0 && schema === void 0) return yield* Effect.die(/* @__PURE__ */ new Error(`Webhook.layer persists webhook subscriptions, but the authentication of ${named(feeds)} has no schema to keep a subscriber's identity by (give its Authentication.make a schema)`));
|
|
273
|
+
const twice = feeds.filter(({ event }) => served.has(event.name) && served.get(event.name)?.schema !== schema);
|
|
274
|
+
if (storage !== void 0 && twice.length > 0) return yield* Effect.die(/* @__PURE__ */ new Error(`Webhook.layer persists webhook subscriptions, but ${named(twice)} is served by two authentications with different schemas (serve each event with one authentication, or give them one schema)`));
|
|
275
|
+
for (const { event } of feeds) served.set(event.name, {
|
|
276
|
+
event,
|
|
277
|
+
schema
|
|
278
|
+
});
|
|
279
|
+
if (storage === void 0 || schema === void 0) return;
|
|
280
|
+
for (const id of storage.ids()) yield* locked(id)(Effect.suspend(() => held.has(id) ? Effect.void : restoredFrom(storage, feeds, keyed, schema, id)));
|
|
281
|
+
});
|
|
282
|
+
const refused = (id, cause) => Effect.gen(function* () {
|
|
283
|
+
const existing = held.get(id);
|
|
284
|
+
if (existing !== void 0 && refusalIn(cause) !== void 0 && (yield* Clock.currentTimeMillis) < existing.expiresAt) {
|
|
285
|
+
yield* handover(id, void 0, terminated(existing, cause));
|
|
286
|
+
yield* dropped(id);
|
|
287
|
+
}
|
|
288
|
+
return yield* Effect.failCause(cause);
|
|
289
|
+
});
|
|
290
|
+
const subscribe = (subscribing) => Effect.gen(function* () {
|
|
291
|
+
const { feed, principal, input, delivery: { url, secret }, cursor, ttlMs } = subscribing;
|
|
292
|
+
const id = yield* idOf(subscribing);
|
|
293
|
+
return yield* locked(id)(Effect.gen(function* () {
|
|
294
|
+
yield* Effect.catchCause(feed.admit(input), (cause) => refused(id, cause));
|
|
295
|
+
const now = yield* Clock.currentTimeMillis;
|
|
296
|
+
const granted = {
|
|
297
|
+
secret,
|
|
298
|
+
context: Context.merge(built, snapshotOf(yield* Effect.context())),
|
|
299
|
+
kept: yield* keptOf(subscribing),
|
|
300
|
+
expiresAt: now + Duration.toMillis(grantOf(ttlMs, policy))
|
|
301
|
+
};
|
|
302
|
+
const existing = held.get(id);
|
|
303
|
+
if (existing !== void 0 && now < existing.expiresAt) return yield* refreshed(existing, granted, input, now);
|
|
304
|
+
const subscription = heldOf({
|
|
305
|
+
...granted,
|
|
306
|
+
id,
|
|
307
|
+
url,
|
|
308
|
+
feed,
|
|
309
|
+
principal,
|
|
310
|
+
status: {
|
|
311
|
+
active: true,
|
|
312
|
+
lastDeliveryAt: null,
|
|
313
|
+
lastError: null
|
|
314
|
+
},
|
|
315
|
+
watermark: cursor
|
|
316
|
+
});
|
|
317
|
+
yield* verify(subscription, now);
|
|
318
|
+
yield* remembered(subscription, now);
|
|
319
|
+
const confirmation = yield* Deferred.make();
|
|
320
|
+
yield* handover(id, subscription, workerOf(subscription, input, cursor, confirmation));
|
|
321
|
+
const started = yield* Deferred.await(confirmation);
|
|
322
|
+
yield* kept(subscription);
|
|
323
|
+
return {
|
|
324
|
+
id,
|
|
325
|
+
refreshBefore: iso(subscription.expiresAt),
|
|
326
|
+
cursor: started.cursor,
|
|
327
|
+
truncated: started.truncated
|
|
328
|
+
};
|
|
329
|
+
}));
|
|
330
|
+
});
|
|
331
|
+
const unsubscribe = (subscription) => Effect.flatMap(idOf(subscription), (id) => locked(id)(Effect.gen(function* () {
|
|
332
|
+
if ((yield* Clock.currentTimeMillis) < (held.get(id)?.expiresAt ?? 0)) return yield* Effect.andThen(handover(id, void 0, Effect.void), dropped(id));
|
|
333
|
+
if (storage?.ids().includes(id) === true) return yield* dropped(id);
|
|
334
|
+
return yield* SubscriptionError.of(subscription.feed.event.name, new NotSubscribed());
|
|
335
|
+
})));
|
|
336
|
+
return {
|
|
337
|
+
serve,
|
|
338
|
+
subscribe,
|
|
339
|
+
unsubscribe
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
export { makeWebhooks };
|
package/docs/Action-failures.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# Action failure modes
|
|
2
2
|
|
|
3
|
-
What you see when something is wrong, and the fix. Search for a fragment of the message.
|
|
4
|
-
The tasks are in [Action.md](Action.md).
|
|
3
|
+
What you see when something is wrong, and the fix. Search for a fragment of the message. The tasks are in [Action.md](Action.md).
|
|
5
4
|
|
|
6
5
|
## At `make`
|
|
7
6
|
|
|
@@ -30,6 +29,7 @@ The tasks are in [Action.md](Action.md).
|
|
|
30
29
|
- `HttpRouter.Request<"Requires", X>` still owed, or `Type 'X' is not assignable to type 'never'` at `runMain`, although `X`, a store the authorizer reads, is provided at startup: what a plain authorizer yields is a request-time requirement, and this one yields `X` on every call. Make `authorize` an Effect that yields `X` once and returns the function ([Authorize calls](Action.md#authorize-calls)).
|
|
31
30
|
- `unknown` among a surface's requirements, and a type error where it is served or run, such as `Argument of type 'Layer<never, unknown, unknown>' is not assignable` at `HttpRouter.toWebHandler` or `Type 'unknown' is not assignable to type 'never'`: the implementations are typed `Action.AnyImplementation`, as a helper's parameter or a list's annotation. Make the helper generic over them, `<const Apps extends Action.AnyImplementation | ReadonlyArray<Action.AnyImplementation>>(apps: Apps)`, or drop the annotation.
|
|
32
31
|
- `Request<"Requires", unknown>` where an implementation is served, or `unknown` among the services a call or a run still requires: TypeScript inferred no requirements for one handler of a record, as a data-first call whose type takes a parameter from no argument causes, `Effect.catchTag(effect, tag, f)` for one. Write that handler's Effect `effect.pipe(Effect.catchTag(tag, f))`, or as a generator.
|
|
32
|
+
- `Service not found: effect-actions/Action/Idempotency`, a defect where an action naming `idempotencyKey` is built: its actions were typed `Action.Any`, so no type required `Action.Idempotency`. Provide `Action.layerIdempotency` around the surfaces.
|
|
33
33
|
|
|
34
34
|
## At `client` and its calls
|
|
35
35
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Progress and streamed answers
|
|
2
|
+
|
|
3
|
+
An action answers once, at the end. Two options let a caller see more while it runs: `progress`, values the handler reports along the way, which a caller may miss some of, and `Action.Stream`, an answer of many elements, each arriving as it is produced. Both are declared on the contract ([Action.md](Action.md#define-a-contract)) and carried by every surface its own way: the declaration's JSDoc says how.
|
|
4
|
+
|
|
5
|
+
A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search [Action-failures.md](Action-failures.md). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`.
|
|
6
|
+
|
|
7
|
+
## Report progress
|
|
8
|
+
|
|
9
|
+
An action declaring `progress`, a schema or struct fields, gets a second argument in its handler, `{ progress }`, which reports a value to the caller without waiting for it; a client's call takes `{ onProgress }` after its input ([build-events/test/builds/progress.test.ts](../examples/build-events/test/builds/progress.test.ts)). The contract is `StartBuild` in [build-events/src/builds/Builds.ts](../examples/build-events/src/builds/Builds.ts).
|
|
10
|
+
|
|
11
|
+
```ts example=build-events/src/builds/handlers.ts
|
|
12
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
13
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
14
|
+
import { Effect } from "effect";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
BuildFinished,
|
|
18
|
+
FinishBuild,
|
|
19
|
+
PipelineStatus,
|
|
20
|
+
StartBuild,
|
|
21
|
+
} from "./Builds.js";
|
|
22
|
+
import type { Build } from "./Builds.js";
|
|
23
|
+
import { BuildStore } from "./store.js";
|
|
24
|
+
|
|
25
|
+
// DEMO ONLY: each step passes at once. A runner's steps take their time, which is why a caller
|
|
26
|
+
// wants to see them.
|
|
27
|
+
const steps = ["install", "build", "test"];
|
|
28
|
+
|
|
29
|
+
export const builds = Action.implement(
|
|
30
|
+
[FinishBuild, StartBuild],
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const store = yield* BuildStore;
|
|
33
|
+
const events = yield* Event.Events;
|
|
34
|
+
|
|
35
|
+
// `build.finished` is a check source over the store: write the build there first, then wake
|
|
36
|
+
// its subscribers, who read it now rather than at their next poll. `pipeline.status` is a
|
|
37
|
+
// current source: the notify moves its pipeline's revision, so its subscribers alone read it.
|
|
38
|
+
const finished = (build: Effect.Effect<Build>) =>
|
|
39
|
+
Effect.tap(build, ({ pipeline }) =>
|
|
40
|
+
Effect.andThen(
|
|
41
|
+
events.notify(BuildFinished),
|
|
42
|
+
events.notify(PipelineStatus, { key: pipeline })
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
finishBuild: ({ pipeline, status }) =>
|
|
48
|
+
finished(store.finish(pipeline, status, [status])),
|
|
49
|
+
// `progress` never waits for the caller: the build runs at its own pace.
|
|
50
|
+
startBuild: ({ pipeline }, { progress }) =>
|
|
51
|
+
Effect.andThen(
|
|
52
|
+
Effect.forEach(
|
|
53
|
+
steps,
|
|
54
|
+
(step, done) => progress({ step, done, total: steps.length }),
|
|
55
|
+
{
|
|
56
|
+
discard: true,
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
finished(
|
|
60
|
+
store.finish(pipeline, "passed", [
|
|
61
|
+
...steps.map((step) => `${step}: passed`),
|
|
62
|
+
"passed",
|
|
63
|
+
])
|
|
64
|
+
)
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
}),
|
|
68
|
+
{ authorize: Action.allowAll }
|
|
69
|
+
);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The rules are on `Action.Options.progress`; how each surface carries the values, on `ActionHttp.layer`, `ActionRpc.make`, `ActionMcp.layerHttp`, `ActionToolkit.make` and `ActionCli.command`.
|
|
73
|
+
|
|
74
|
+
- **Report a snapshot to show, never an event to count.** A caller falling behind loses the oldest values, never the answer: what a caller must receive each time is the answer, or an [event](Event.md).
|
|
75
|
+
|
|
76
|
+
## Answer a stream
|
|
77
|
+
|
|
78
|
+
An action whose `success` is `Action.Stream(element)` has a handler returning a `Stream` of elements, and every client's call returns one ([build-events/test/builds/log.test.ts](../examples/build-events/test/builds/log.test.ts)). The contract is `TailLog` in [Builds.ts](../examples/build-events/src/builds/Builds.ts).
|
|
79
|
+
|
|
80
|
+
```ts example=build-events/src/builds/log.ts
|
|
81
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
82
|
+
import { Effect, Stream } from "effect";
|
|
83
|
+
|
|
84
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
85
|
+
import { TailLog } from "./Builds.js";
|
|
86
|
+
import { BuildStore } from "./store.js";
|
|
87
|
+
|
|
88
|
+
export const logs = Action.implement(
|
|
89
|
+
TailLog,
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const store = yield* BuildStore;
|
|
92
|
+
|
|
93
|
+
// The handler returns a `Stream`, run in a scope of its own that closes when the caller
|
|
94
|
+
// stops reading, so what it acquires is released then.
|
|
95
|
+
return ({ buildId }) =>
|
|
96
|
+
Stream.unwrap(
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const member = yield* CurrentMember;
|
|
99
|
+
// A failure before the first line, `BuildNotFound` here, fails the call as an action
|
|
100
|
+
// answering one value fails, a 404 over HTTP; one after a line ends the stream, the
|
|
101
|
+
// lines before it already read.
|
|
102
|
+
const log = yield* store.logOf(member.pipelines, buildId);
|
|
103
|
+
|
|
104
|
+
return Stream.fromIterable(log.map((line) => ({ line })));
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
}),
|
|
108
|
+
// Which builds a member may read is the store's check, by pipeline.
|
|
109
|
+
{ authorize: Action.allowAll }
|
|
110
|
+
);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
How each surface carries it is on `ActionHttp.layer`, `ActionRpc.make`, `ActionMcp.layerHttp`, `ActionToolkit.make` and `ActionCli.command`.
|
|
114
|
+
|
|
115
|
+
- **A tool collects it whole.** A tool answers one result, so an MCP endpoint and a toolkit answer the elements as one array once the stream ends, and `ActionMcp.client`'s method streams that array again: a stream that never ends never answers a tool, and a long one fills a model's context, so [kitchen-sink/src/Actions.ts](../examples/kitchen-sink/src/Actions.ts) leaves `exportIssues` out of its tools.
|