@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,58 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
BuildFinished,
|
|
7
|
+
FinishBuild,
|
|
8
|
+
PipelineStatus,
|
|
9
|
+
StartBuild,
|
|
10
|
+
} from "./Builds.js";
|
|
11
|
+
import type { Build } from "./Builds.js";
|
|
12
|
+
import { BuildStore } from "./store.js";
|
|
13
|
+
|
|
14
|
+
// DEMO ONLY: each step passes at once. A runner's steps take their time, which is why a caller
|
|
15
|
+
// wants to see them.
|
|
16
|
+
const steps = ["install", "build", "test"];
|
|
17
|
+
|
|
18
|
+
export const builds = Action.implement(
|
|
19
|
+
[FinishBuild, StartBuild],
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const store = yield* BuildStore;
|
|
22
|
+
const events = yield* Event.Events;
|
|
23
|
+
|
|
24
|
+
// `build.finished` is a check source over the store: write the build there first, then wake
|
|
25
|
+
// its subscribers, who read it now rather than at their next poll. `pipeline.status` is a
|
|
26
|
+
// current source: the notify moves its pipeline's revision, so its subscribers alone read it.
|
|
27
|
+
const finished = (build: Effect.Effect<Build>) =>
|
|
28
|
+
Effect.tap(build, ({ pipeline }) =>
|
|
29
|
+
Effect.andThen(
|
|
30
|
+
events.notify(BuildFinished),
|
|
31
|
+
events.notify(PipelineStatus, { key: pipeline })
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
finishBuild: ({ pipeline, status }) =>
|
|
37
|
+
finished(store.finish(pipeline, status, [status])),
|
|
38
|
+
// `progress` never waits for the caller: the build runs at its own pace.
|
|
39
|
+
startBuild: ({ pipeline }, { progress }) =>
|
|
40
|
+
Effect.andThen(
|
|
41
|
+
Effect.forEach(
|
|
42
|
+
steps,
|
|
43
|
+
(step, done) => progress({ step, done, total: steps.length }),
|
|
44
|
+
{
|
|
45
|
+
discard: true,
|
|
46
|
+
}
|
|
47
|
+
),
|
|
48
|
+
finished(
|
|
49
|
+
store.finish(pipeline, "passed", [
|
|
50
|
+
...steps.map((step) => `${step}: passed`),
|
|
51
|
+
"passed",
|
|
52
|
+
])
|
|
53
|
+
)
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
}),
|
|
57
|
+
{ authorize: Action.allowAll }
|
|
58
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import { Effect, Stream } from "effect";
|
|
3
|
+
|
|
4
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
5
|
+
import { TailLog } from "./Builds.js";
|
|
6
|
+
import { BuildStore } from "./store.js";
|
|
7
|
+
|
|
8
|
+
export const logs = Action.implement(
|
|
9
|
+
TailLog,
|
|
10
|
+
Effect.gen(function* () {
|
|
11
|
+
const store = yield* BuildStore;
|
|
12
|
+
|
|
13
|
+
// The handler returns a `Stream`, run in a scope of its own that closes when the caller
|
|
14
|
+
// stops reading, so what it acquires is released then.
|
|
15
|
+
return ({ buildId }) =>
|
|
16
|
+
Stream.unwrap(
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const member = yield* CurrentMember;
|
|
19
|
+
// A failure before the first line, `BuildNotFound` here, fails the call as an action
|
|
20
|
+
// answering one value fails, a 404 over HTTP; one after a line ends the stream, the
|
|
21
|
+
// lines before it already read.
|
|
22
|
+
const log = yield* store.logOf(member.pipelines, buildId);
|
|
23
|
+
|
|
24
|
+
return Stream.fromIterable(log.map((line) => ({ line })));
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
}),
|
|
28
|
+
// Which builds a member may read is the store's check, by pipeline.
|
|
29
|
+
{ authorize: Action.allowAll }
|
|
30
|
+
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
5
|
+
import { PipelineStatus } from "./Builds.js";
|
|
6
|
+
import { BuildStore } from "./store.js";
|
|
7
|
+
|
|
8
|
+
// A current source: the value as it is, which the library sends first and again after each
|
|
9
|
+
// notify reaching the subscriber's key, keeping the revision that tells them apart, so the store
|
|
10
|
+
// keeps no cursor for it. The key is the pipeline: a build of another wakes no one here.
|
|
11
|
+
export const statusEvents = Event.implement(
|
|
12
|
+
PipelineStatus,
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const store = yield* BuildStore;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
current: ({ pipeline }) =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const member = yield* CurrentMember;
|
|
20
|
+
|
|
21
|
+
return { status: yield* store.statusOf(member.pipelines, pipeline) };
|
|
22
|
+
}),
|
|
23
|
+
key: ({ pipeline }) => pipeline,
|
|
24
|
+
};
|
|
25
|
+
}),
|
|
26
|
+
// Every member may watch; which pipelines each sees is the store's check.
|
|
27
|
+
{ authorize: Event.allowAll }
|
|
28
|
+
);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
import { BuildNotFound } from "./Builds.js";
|
|
4
|
+
import type { Build } from "./Builds.js";
|
|
5
|
+
|
|
6
|
+
/** One page of the builds finished since a cursor. */
|
|
7
|
+
export interface Finished {
|
|
8
|
+
readonly builds: readonly Build[];
|
|
9
|
+
readonly cursor: string;
|
|
10
|
+
readonly hasMore: boolean;
|
|
11
|
+
/** Whether builds before the page may be missing: the cursor was not one of this store's. */
|
|
12
|
+
readonly truncated: boolean;
|
|
13
|
+
/** The store's epoch: a build's id is unique within it, and a restarted memory store starts another. */
|
|
14
|
+
readonly epoch: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The position a cursor of `epoch` names, among `length` builds; none for another epoch's cursor
|
|
18
|
+
// or one that is not a cursor at all.
|
|
19
|
+
const positionOf = (
|
|
20
|
+
epoch: string,
|
|
21
|
+
cursor: string,
|
|
22
|
+
length: number
|
|
23
|
+
): number | undefined => {
|
|
24
|
+
const [of, at] = cursor.split(":");
|
|
25
|
+
const position = Number(at);
|
|
26
|
+
|
|
27
|
+
return of === epoch &&
|
|
28
|
+
Number.isInteger(position) &&
|
|
29
|
+
position >= 0 &&
|
|
30
|
+
position <= length
|
|
31
|
+
? position
|
|
32
|
+
: undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where finished builds are recorded, in the order they finished. Which pipelines a member may
|
|
37
|
+
* see is checked here, in the data access, so no subscription can skip it.
|
|
38
|
+
*/
|
|
39
|
+
export class BuildStore extends Context.Service<
|
|
40
|
+
BuildStore,
|
|
41
|
+
{
|
|
42
|
+
/** Record a build that finished, with the lines its log holds. */
|
|
43
|
+
readonly finish: (
|
|
44
|
+
pipeline: string,
|
|
45
|
+
status: Build["status"],
|
|
46
|
+
log: readonly string[]
|
|
47
|
+
) => Effect.Effect<Build>;
|
|
48
|
+
/** The log of build `buildId`, which must be one of `pipelines`. */
|
|
49
|
+
readonly logOf: (
|
|
50
|
+
pipelines: readonly string[],
|
|
51
|
+
buildId: string
|
|
52
|
+
) => Effect.Effect<readonly string[], BuildNotFound>;
|
|
53
|
+
/**
|
|
54
|
+
* The builds of `pipelines` finished after `cursor`, at most `limit`; a `null` cursor is
|
|
55
|
+
* the current position, with none.
|
|
56
|
+
*/
|
|
57
|
+
readonly finishedSince: (
|
|
58
|
+
pipelines: readonly string[],
|
|
59
|
+
cursor: string | null,
|
|
60
|
+
limit: number
|
|
61
|
+
) => Effect.Effect<Finished>;
|
|
62
|
+
/** How `pipeline`'s last build ended, `null` before any did or where it is not of `pipelines`. */
|
|
63
|
+
readonly statusOf: (
|
|
64
|
+
pipelines: readonly string[],
|
|
65
|
+
pipeline: string
|
|
66
|
+
) => Effect.Effect<Build["status"] | null>;
|
|
67
|
+
}
|
|
68
|
+
>()("build-events/BuildStore") {
|
|
69
|
+
// DEMO ONLY: builds live in this process's memory, so a restart forgets them and numbers them
|
|
70
|
+
// from 1 again. Its cursors carry an epoch of their own, so one from before the restart, or one
|
|
71
|
+
// it never gave, is answered from the start as `truncated`: the subscriber is sent a gap, never
|
|
72
|
+
// a silent loss. In a database the cursor is the position in its own change feed, such as a
|
|
73
|
+
// sequence column, so a subscriber resumes after a restart.
|
|
74
|
+
static readonly layerMemory = Layer.sync(BuildStore, () => {
|
|
75
|
+
const epoch = crypto.randomUUID();
|
|
76
|
+
const finished: Build[] = [];
|
|
77
|
+
const logs = new Map<string, readonly string[]>();
|
|
78
|
+
|
|
79
|
+
return BuildStore.of({
|
|
80
|
+
finish: (pipeline, status, log) =>
|
|
81
|
+
Effect.sync(() => {
|
|
82
|
+
const build = {
|
|
83
|
+
id: `build-${finished.length + 1}`,
|
|
84
|
+
pipeline,
|
|
85
|
+
status,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
finished.push(build);
|
|
89
|
+
logs.set(build.id, log);
|
|
90
|
+
|
|
91
|
+
return build;
|
|
92
|
+
}),
|
|
93
|
+
logOf: (pipelines, buildId) => {
|
|
94
|
+
const build = finished.find(({ id }) => id === buildId);
|
|
95
|
+
const log = logs.get(buildId);
|
|
96
|
+
|
|
97
|
+
return build === undefined ||
|
|
98
|
+
log === undefined ||
|
|
99
|
+
!pipelines.includes(build.pipeline)
|
|
100
|
+
? Effect.fail(new BuildNotFound({ buildId }))
|
|
101
|
+
: Effect.succeed(log);
|
|
102
|
+
},
|
|
103
|
+
finishedSince: (pipelines, cursor, limit) =>
|
|
104
|
+
Effect.sync(() => {
|
|
105
|
+
const at =
|
|
106
|
+
cursor === null
|
|
107
|
+
? finished.length
|
|
108
|
+
: positionOf(epoch, cursor, finished.length);
|
|
109
|
+
|
|
110
|
+
const from = at ?? 0;
|
|
111
|
+
const next = finished.slice(from, from + limit);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
builds: next.filter((build) => pipelines.includes(build.pipeline)),
|
|
115
|
+
cursor: `${epoch}:${from + next.length}`,
|
|
116
|
+
hasMore: from + next.length < finished.length,
|
|
117
|
+
truncated: at === undefined,
|
|
118
|
+
epoch,
|
|
119
|
+
};
|
|
120
|
+
}),
|
|
121
|
+
statusOf: (pipelines, pipeline) =>
|
|
122
|
+
Effect.sync(() =>
|
|
123
|
+
pipelines.includes(pipeline)
|
|
124
|
+
? (finished.findLast((build) => build.pipeline === pipeline)
|
|
125
|
+
?.status ?? null)
|
|
126
|
+
: null
|
|
127
|
+
),
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NodeHttpClient,
|
|
3
|
+
NodeRuntime,
|
|
4
|
+
NodeServices,
|
|
5
|
+
} from "@effect/platform-node";
|
|
6
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
7
|
+
import { Effect } from "effect";
|
|
8
|
+
import { Command } from "effect/cli";
|
|
9
|
+
|
|
10
|
+
import { commands } from "./commands.js";
|
|
11
|
+
|
|
12
|
+
// Entrypoint: `BUILDS_TOKEN=grace node --import tsx examples/build-events/src/cli.ts watch
|
|
13
|
+
// build.finished`, with server.ts running.
|
|
14
|
+
Command.run(commands, { version: "1.0.0" }).pipe(
|
|
15
|
+
Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
|
|
16
|
+
ActionCli.logToStderr,
|
|
17
|
+
NodeRuntime.runMain()
|
|
18
|
+
);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
2
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
3
|
+
import { Config, Effect } from "effect";
|
|
4
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
5
|
+
|
|
6
|
+
import { Http } from "./Api.js";
|
|
7
|
+
|
|
8
|
+
// The binding's client, connected to where the server is and as which member, both read from
|
|
9
|
+
// config when a command runs.
|
|
10
|
+
const api = Effect.gen(function* () {
|
|
11
|
+
const baseUrl = yield* Config.String("BUILDS_URL").pipe(
|
|
12
|
+
Config.withDefault("http://127.0.0.1:3000")
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const token = yield* Config.Redacted("BUILDS_TOKEN");
|
|
16
|
+
|
|
17
|
+
return yield* ActionHttp.client(Http, {
|
|
18
|
+
baseUrl,
|
|
19
|
+
transformClient: HttpClient.mapRequest(
|
|
20
|
+
HttpClientRequest.bearerToken(token)
|
|
21
|
+
),
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// A command per action, and `watch <event>` per event type: `builds watch build.finished
|
|
26
|
+
// --pipeline api` holds the event's stream open and prints each occurrence as a line of JSON,
|
|
27
|
+
// its `cursor` included, until it is stopped. `--cursor <cursor>` resumes after that line;
|
|
28
|
+
// omitted, the watch starts from now. The lines pipe into `jq` or a script as they come.
|
|
29
|
+
// `builds start-build --pipeline api` prints each step as a line of JSON on stderr as it
|
|
30
|
+
// starts, and the build on stdout, which a pipe reads alone. `builds tail-log --build-id
|
|
31
|
+
// build-1` prints each line of its log as a line of JSON as it arrives.
|
|
32
|
+
export const commands = ActionCli.remote(Http, { name: "builds", client: api });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
6
|
+
|
|
7
|
+
export const Environment = Schema.Literals(["staging", "production"]).annotate({
|
|
8
|
+
description: "Where to deploy.",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type Environment = typeof Environment.Type;
|
|
12
|
+
|
|
13
|
+
export const Deploy = Schema.Struct({
|
|
14
|
+
id: Schema.String,
|
|
15
|
+
environment: Environment,
|
|
16
|
+
version: Schema.String,
|
|
17
|
+
requestedBy: Schema.String,
|
|
18
|
+
approvers: Schema.Array(Schema.String).annotate({
|
|
19
|
+
description:
|
|
20
|
+
"The members watching its environment when it was requested, asked to approve it.",
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type Deploy = typeof Deploy.Type;
|
|
25
|
+
|
|
26
|
+
export const RequestDeploy = Action.make("requestDeploy", {
|
|
27
|
+
description: "Ask for a version to be deployed to an environment.",
|
|
28
|
+
input: { environment: Environment, version: Schema.String },
|
|
29
|
+
success: Deploy,
|
|
30
|
+
readOnly: false,
|
|
31
|
+
caller: CurrentMember,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const DeployRequested = Event.make("deploy.requested", {
|
|
35
|
+
description:
|
|
36
|
+
"Someone asked for a deploy. Watching an environment stands you by to approve its deploys.",
|
|
37
|
+
input: {
|
|
38
|
+
environment: Schema.optional(Environment).annotate({
|
|
39
|
+
description: "Only deploys to here.",
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
payload: Deploy,
|
|
43
|
+
caller: CurrentMember,
|
|
44
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { Scope } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { Environment } from "./Deploys.js";
|
|
5
|
+
|
|
6
|
+
/** Who stands by to approve deploys, and to which environment. */
|
|
7
|
+
export class Approvers extends Context.Service<
|
|
8
|
+
Approvers,
|
|
9
|
+
{
|
|
10
|
+
/** Stands `member` by to approve deploys to `environment`, every one where undefined, until the scope closes. */
|
|
11
|
+
readonly standBy: (
|
|
12
|
+
environment: Environment | undefined,
|
|
13
|
+
member: string
|
|
14
|
+
) => Effect.Effect<void, never, Scope.Scope>;
|
|
15
|
+
readonly standingBy: (
|
|
16
|
+
environment: Environment
|
|
17
|
+
) => Effect.Effect<readonly string[]>;
|
|
18
|
+
}
|
|
19
|
+
>()("build-events/Approvers") {
|
|
20
|
+
// DEMO ONLY: approvers live in this process's memory, where a real one is a rota in a store.
|
|
21
|
+
static readonly layerMemory = Layer.sync(Approvers, () => {
|
|
22
|
+
const approvers: Array<{
|
|
23
|
+
readonly environment: Environment | undefined;
|
|
24
|
+
readonly member: string;
|
|
25
|
+
}> = [];
|
|
26
|
+
|
|
27
|
+
return Approvers.of({
|
|
28
|
+
standBy: (environment, member) =>
|
|
29
|
+
Effect.acquireRelease(
|
|
30
|
+
Effect.sync(() => {
|
|
31
|
+
const standing = { environment, member };
|
|
32
|
+
|
|
33
|
+
approvers.push(standing);
|
|
34
|
+
|
|
35
|
+
return standing;
|
|
36
|
+
}),
|
|
37
|
+
(standing) =>
|
|
38
|
+
Effect.sync(() => approvers.splice(approvers.indexOf(standing), 1))
|
|
39
|
+
),
|
|
40
|
+
standingBy: (environment) =>
|
|
41
|
+
Effect.sync(() => [
|
|
42
|
+
...new Set(
|
|
43
|
+
approvers
|
|
44
|
+
.values()
|
|
45
|
+
.filter(
|
|
46
|
+
(each) =>
|
|
47
|
+
each.environment === undefined ||
|
|
48
|
+
each.environment === environment
|
|
49
|
+
)
|
|
50
|
+
.map((each) => each.member)
|
|
51
|
+
),
|
|
52
|
+
]),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
5
|
+
import { Approvers } from "./approvers.js";
|
|
6
|
+
import { DeployRequested } from "./Deploys.js";
|
|
7
|
+
|
|
8
|
+
// A log source, for occurrences nothing else keeps: the library keeps each one `requestDeploy`
|
|
9
|
+
// publishes, for 10 minutes and at most 10,000 of them by default, and gives each subscriber
|
|
10
|
+
// what its `match` accepts. A subscriber resuming from a cursor gets what the log still holds:
|
|
11
|
+
// after a restart, or once its occurrences are dropped, a gap first. An upstream you can ask
|
|
12
|
+
// "what since?", such as a table, is a check source.
|
|
13
|
+
export const deployEvents = Event.implement(
|
|
14
|
+
DeployRequested,
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const approvers = yield* Approvers;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
log: {},
|
|
20
|
+
match: ({ environment }, deploy) =>
|
|
21
|
+
environment === undefined || deploy.environment === environment,
|
|
22
|
+
// Set up while each subscription lasts, as the subscriber: a stream's as long as it is
|
|
23
|
+
// read, a poll's until 5 minutes after its last poll. It may run again for one
|
|
24
|
+
// subscription, after a restart, so standing by twice must be harmless.
|
|
25
|
+
subscription: ({ environment }) =>
|
|
26
|
+
Effect.flatMap(CurrentMember, (member) =>
|
|
27
|
+
approvers.standBy(environment, member.id)
|
|
28
|
+
),
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
{ authorize: Event.allowAll }
|
|
32
|
+
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
6
|
+
import { Approvers } from "./approvers.js";
|
|
7
|
+
import { DeployRequested, RequestDeploy } from "./Deploys.js";
|
|
8
|
+
|
|
9
|
+
export const deploys = Action.implement(
|
|
10
|
+
RequestDeploy,
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const approvers = yield* Approvers;
|
|
13
|
+
const events = yield* Event.Events;
|
|
14
|
+
|
|
15
|
+
return ({ environment, version }) =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const member = yield* CurrentMember;
|
|
18
|
+
|
|
19
|
+
const deploy = {
|
|
20
|
+
id: crypto.randomUUID(),
|
|
21
|
+
environment,
|
|
22
|
+
version,
|
|
23
|
+
requestedBy: member.id,
|
|
24
|
+
approvers: yield* approvers.standingBy(environment),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Nothing else keeps a deploy request, so `deploy.requested` is a log source: the
|
|
28
|
+
// library keeps what is published here, and delivers it to each subscriber at once.
|
|
29
|
+
yield* events.publish(DeployRequested, {
|
|
30
|
+
eventId: deploy.id,
|
|
31
|
+
data: deploy,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return deploy;
|
|
35
|
+
});
|
|
36
|
+
}),
|
|
37
|
+
{ authorize: Action.allowAll }
|
|
38
|
+
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
2
|
+
import { Context, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
/** A member, and the pipelines whose builds it may see. */
|
|
5
|
+
export const Member = Schema.Struct({
|
|
6
|
+
id: Schema.String,
|
|
7
|
+
pipelines: Schema.Array(Schema.String),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type Member = typeof Member.Type;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Who is subscribing or calling. Every event and action here declares it, `caller:
|
|
14
|
+
* CurrentMember`; authentication provides it per request.
|
|
15
|
+
*/
|
|
16
|
+
export class CurrentMember extends Context.Service<CurrentMember, Member>()(
|
|
17
|
+
"build-events/CurrentMember"
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
// How a remote caller proves it is a member: a bearer token. Clients import this descriptor
|
|
21
|
+
// with the contracts; its verifier stays on the server. `principal` names a member as one
|
|
22
|
+
// subscriber: a poll arrives as a request among others, and a surface keys what it holds
|
|
23
|
+
// between polls, deploy.requested's standing by, by it. Without it, MCP and HTTP refuse to
|
|
24
|
+
// serve that event, and no event is offered by webhook. `schema` keeps a webhook subscriber's
|
|
25
|
+
// identity, so the deliveries a restart restores run as that member.
|
|
26
|
+
export const Login = Authentication.make("build-events.Login", CurrentMember, {
|
|
27
|
+
principal: (member) => member.id,
|
|
28
|
+
schema: Member,
|
|
29
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Member } from "./Identity.js";
|
|
2
|
+
|
|
3
|
+
// DEMO ONLY: the members who may sign in, each under a token that is their name.
|
|
4
|
+
export const members = {
|
|
5
|
+
ada: { id: "ada", pipelines: ["api", "web"] },
|
|
6
|
+
grace: { id: "grace", pipelines: ["web"] },
|
|
7
|
+
} as const satisfies Readonly<Record<string, Member>>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
3
|
+
import { Effect, Redacted } from "effect";
|
|
4
|
+
|
|
5
|
+
import { Login } from "./Identity.js";
|
|
6
|
+
import { members } from "./members.js";
|
|
7
|
+
|
|
8
|
+
const isMember = (token: string): token is keyof typeof members =>
|
|
9
|
+
Object.hasOwn(members, token);
|
|
10
|
+
|
|
11
|
+
// DEMO ONLY: a token is a member's name. A real verifier checks a signed token with your
|
|
12
|
+
// authorization server's library. An unknown token is the built-in `Unauthenticated`, which
|
|
13
|
+
// every client decodes, a watch's included.
|
|
14
|
+
export const authenticate = Authentication.layer(Login, (token) => {
|
|
15
|
+
const name = Redacted.value(token);
|
|
16
|
+
|
|
17
|
+
return isMember(name)
|
|
18
|
+
? Effect.succeed(members[name])
|
|
19
|
+
: Effect.fail(
|
|
20
|
+
new Action.Unauthenticated({ message: "Unknown demo token." })
|
|
21
|
+
);
|
|
22
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { Build } from "../builds/Builds.js";
|
|
4
|
+
|
|
5
|
+
/** The failed builds the notifier was told of, in the order it was told. */
|
|
6
|
+
export class Notices extends Context.Service<
|
|
7
|
+
Notices,
|
|
8
|
+
{
|
|
9
|
+
readonly add: (build: Build) => Effect.Effect<void>;
|
|
10
|
+
readonly all: Effect.Effect<readonly Build[]>;
|
|
11
|
+
}
|
|
12
|
+
>()("build-events/Notices") {
|
|
13
|
+
// DEMO ONLY: notices live in this process's memory; a notifier sends each on, to a chat or a
|
|
14
|
+
// pager, as it arrives.
|
|
15
|
+
static readonly layerMemory = Layer.sync(Notices, () => {
|
|
16
|
+
const notices: Build[] = [];
|
|
17
|
+
|
|
18
|
+
return Notices.of({
|
|
19
|
+
add: (build) =>
|
|
20
|
+
Effect.sync(() => {
|
|
21
|
+
notices.push(build);
|
|
22
|
+
}),
|
|
23
|
+
all: Effect.sync(() => [...notices]),
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
2
|
+
import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
|
|
3
|
+
import { Effect, Layer, Predicate, Stream } from "effect";
|
|
4
|
+
import type { Redacted } from "effect";
|
|
5
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
6
|
+
import { KeyValueStore } from "effect/persistence";
|
|
7
|
+
|
|
8
|
+
import { Http } from "../Api.js";
|
|
9
|
+
import { BuildFinished } from "../builds/Builds.js";
|
|
10
|
+
import { Notices } from "./Notices.js";
|
|
11
|
+
|
|
12
|
+
/** Where build-events is, who the notifier signs in as there, and where it is reached itself. */
|
|
13
|
+
export interface NotifierOptions {
|
|
14
|
+
readonly buildEvents: string;
|
|
15
|
+
readonly token: string | Redacted.Redacted;
|
|
16
|
+
/** This process's public https origin, which build-events POSTs to. */
|
|
17
|
+
readonly publicUrl: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The route build-events POSTs each failed build to, where the watch below reads them: the
|
|
21
|
+
// watch's delivery appends its path to this process's public URL.
|
|
22
|
+
const receiver = Webhook.layerReceiver({ path: "/hooks/builds" });
|
|
23
|
+
|
|
24
|
+
// A second service, the notifier: it watches build.finished for failures by webhook, the
|
|
25
|
+
// server POSTing each to it, rather than holding a stream open to build-events.
|
|
26
|
+
const watcher = ({ buildEvents, token, publicUrl }: NotifierOptions) =>
|
|
27
|
+
Layer.effectDiscard(
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const notices = yield* Notices;
|
|
30
|
+
|
|
31
|
+
const builds = yield* ActionHttp.client(Http, {
|
|
32
|
+
baseUrl: buildEvents,
|
|
33
|
+
transformClient: HttpClient.mapRequest(
|
|
34
|
+
HttpClientRequest.bearerToken(token)
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
yield* Effect.forkScoped(
|
|
39
|
+
builds
|
|
40
|
+
.watch(
|
|
41
|
+
BuildFinished,
|
|
42
|
+
{ status: "failed" },
|
|
43
|
+
{
|
|
44
|
+
mode: Webhook.delivery({ baseUrl: publicUrl }),
|
|
45
|
+
// Where it reached is kept, so a restart resumes there; failures the server no
|
|
46
|
+
// longer keeps arrive as a Gap instead.
|
|
47
|
+
resume: {
|
|
48
|
+
store: yield* KeyValueStore.KeyValueStore,
|
|
49
|
+
key: "failed-builds",
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
.pipe(
|
|
54
|
+
// A Gap says failures were missed, which no notice can name: it is logged, for an
|
|
55
|
+
// operator to look the builds up.
|
|
56
|
+
Stream.runForEach((received) =>
|
|
57
|
+
Predicate.isTagged(received, "Occurrence")
|
|
58
|
+
? notices.add(received.data)
|
|
59
|
+
: Effect.logWarning("Failed builds were missed", received)
|
|
60
|
+
),
|
|
61
|
+
// A failure no reconnect outlasts, such as a wrong PUBLIC_URL or a revoked token,
|
|
62
|
+
// ends the watch: it is logged here, for a deployment to alert on or restart by.
|
|
63
|
+
Effect.tapCause(Effect.logError)
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** The notifier's routes: the receiver, and the watch reading from that same receiver. */
|
|
70
|
+
export const notifier = (options: NotifierOptions) =>
|
|
71
|
+
watcher(options).pipe(Layer.provideMerge(receiver));
|