@zackbart/connecta 0.24.4 → 0.26.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/AGENTS.md +43 -12
- package/CHANGELOG.md +524 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/advertised-schema.js +53 -0
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +152 -106
- package/dist/catalog.js +297 -38
- package/dist/concurrency.js +24 -23
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +330 -240
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +745 -282
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +145 -63
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +255 -212
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +72 -7
- package/dist/index.js +281 -26
- package/dist/invocation.js +371 -286
- package/dist/meta-tools.js +213 -94
- package/dist/module-contracts.d.ts +21 -1
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +103 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +128 -3
- package/dist/program-source.js +321 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +43 -7
- package/dist/registry.js +434 -288
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +74 -154
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +196 -163
- package/dist/routes/mcp.js +228 -112
- package/dist/routes/oauth-management.js +73 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +83 -0
- package/dist/routes/shared.d.ts +25 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +211 -0
- package/dist/routes/ui.js +76 -35
- package/dist/run-journal.js +347 -0
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +137 -89
- package/dist/skills.js +28 -9
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +39 -43
- package/dist/tool-safety.d.ts +27 -1
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +45 -7
- package/dist/ui.d.ts +25 -4
- package/dist/ui.js +77 -167
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +367 -16
- package/documentation/auth.md +163 -2
- package/documentation/code-mode.md +274 -56
- package/documentation/meta-tools.md +200 -44
- package/ethos.md +46 -47
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +224 -14
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +42 -7
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +21 -5
- package/package.json +16 -4
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The Effect face of connector scope teardown (src/connector-scope.ts).
|
|
2
|
+
//
|
|
3
|
+
// closeConnectorScope() is the Promise shell over closeScope() below. An
|
|
4
|
+
// Effect caller that owns a Scope registers the close as that scope's
|
|
5
|
+
// finalizer instead, so teardown runs however the work inside it ends.
|
|
6
|
+
import { Duration, Effect } from "effect";
|
|
7
|
+
import { detach } from "./run.js";
|
|
8
|
+
/** Enough for local transport abort/close without letting cleanup own latency. */
|
|
9
|
+
const CONNECTOR_SCOPE_CLOSE_BUDGET_MS = 100;
|
|
10
|
+
/**
|
|
11
|
+
* Bound on cleanup continued after the caller-facing window expires.
|
|
12
|
+
*
|
|
13
|
+
* `remoteMcp` spends at most one second asking the downstream to terminate its
|
|
14
|
+
* session, leaving another second for the local close. Custom hooks still get a
|
|
15
|
+
* finite background window: handing a never-settling promise to a Worker's
|
|
16
|
+
* `waitUntil` would otherwise keep the invocation alive until the platform cap.
|
|
17
|
+
*/
|
|
18
|
+
const CONNECTOR_SCOPE_DEFER_BUDGET_MS = 2_000;
|
|
19
|
+
/**
|
|
20
|
+
* Succeed when `work` settles or `budgetMs` expires, whichever is first; never
|
|
21
|
+
* fail. The loser is interrupted, so a close that finishes early leaves no
|
|
22
|
+
* timer behind. Interrupting the wait leaves `work` running: nothing here can
|
|
23
|
+
* cancel a connector's close, only stop waiting for it.
|
|
24
|
+
*/
|
|
25
|
+
function waitAtMost(work, budgetMs) {
|
|
26
|
+
return Effect.raceAllFirst([
|
|
27
|
+
Effect.promise(() => work),
|
|
28
|
+
Effect.sleep(Duration.millis(budgetMs)),
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Tell a connector that a scope owned by the core has ended, with the contract
|
|
33
|
+
* closeConnectorScope() documents: best-effort, never failing, waited on for a
|
|
34
|
+
* small fixed window, with a bounded tail handed to `defer` when the runtime
|
|
35
|
+
* has one. The hook is called when the effect runs, not when it is built.
|
|
36
|
+
*/
|
|
37
|
+
export function closeScope(connector, ctx, defer) {
|
|
38
|
+
return Effect.suspend(() => {
|
|
39
|
+
let settled;
|
|
40
|
+
try {
|
|
41
|
+
const closing = connector.closeScope?.(ctx);
|
|
42
|
+
if (!closing)
|
|
43
|
+
return Effect.void;
|
|
44
|
+
// Attach both handlers before either timer can win, so a late rejection
|
|
45
|
+
// is consumed rather than becoming an unhandled rejection.
|
|
46
|
+
settled = closing.then(() => { }, () => { });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// The scope is over whether or not the connector managed to clean it up.
|
|
50
|
+
return Effect.void;
|
|
51
|
+
}
|
|
52
|
+
if (defer) {
|
|
53
|
+
try {
|
|
54
|
+
detach(waitAtMost(settled, CONNECTOR_SCOPE_DEFER_BUDGET_MS), {
|
|
55
|
+
waitUntil: defer,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A runtime hook is best-effort too; the caller cap still applies.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return waitAtMost(settled, CONNECTOR_SCOPE_CLOSE_BUDGET_MS);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Close the connector scope when the enclosing Scope closes — on success,
|
|
67
|
+
* failure, or interruption alike. Callers still own the at-most-once
|
|
68
|
+
* guarantee: register the finalizer once per connector scope.
|
|
69
|
+
*/
|
|
70
|
+
export function closeScopeOnExit(connector, ctx, defer) {
|
|
71
|
+
return Effect.addFinalizer(() => closeScope(connector, ctx, defer));
|
|
72
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The one place an Effect becomes a Promise.
|
|
2
|
+
//
|
|
3
|
+
// Connecta's published surface is Promise-shaped and stays that way; Effect is
|
|
4
|
+
// an implementation detail behind it. Every edge where a fiber starts runs
|
|
5
|
+
// through this module, so the rules for that crossing live in one file instead
|
|
6
|
+
// of being re-derived at each call site — and test/purity.test.ts fails if any
|
|
7
|
+
// other file under src/ calls Effect.run*, runFork, forkDaemon, or
|
|
8
|
+
// ManagedRuntime.make.
|
|
9
|
+
//
|
|
10
|
+
// Two things the stock runners get wrong for this codebase:
|
|
11
|
+
//
|
|
12
|
+
// - The default MixedScheduler yields through setImmediate, which vitest's
|
|
13
|
+
// fake timers replace. A fiber that yields under vi.useFakeTimers() then
|
|
14
|
+
// waits for a timer tick nobody advances. Yielding through a microtask keeps
|
|
15
|
+
// fibers moving under fakes, and Workers never had setImmediate to begin
|
|
16
|
+
// with. The cost is that a fiber never yields to I/O, which is why CPU-heavy
|
|
17
|
+
// work stays out of Effect loops.
|
|
18
|
+
// - Effect.runPromise rejects an interrupted fiber with a generic "All fibers
|
|
19
|
+
// interrupted without error". Callers of withDeadline today see the abort
|
|
20
|
+
// reason the caller chose, and runEdge keeps it that way.
|
|
21
|
+
import { Cause, Context, Duration, Effect, Exit, ManagedRuntime, Scheduler, } from "effect";
|
|
22
|
+
/** Yield to the scheduler through the microtask queue, never a timer. */
|
|
23
|
+
function setMicrotask(task) {
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
queueMicrotask(() => {
|
|
26
|
+
if (!cancelled)
|
|
27
|
+
task();
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
cancelled = true;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** The scheduler every edge-run fiber uses; see the header for why. */
|
|
34
|
+
export const microtaskScheduler = new Scheduler.MixedScheduler("async", setMicrotask);
|
|
35
|
+
const runExit = Effect.runPromiseExitWith(Context.make(Scheduler.Scheduler, microtaskScheduler));
|
|
36
|
+
function abortReason(signal) {
|
|
37
|
+
return signal?.reason ?? new DOMException("aborted", "AbortError");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Turn a failed Exit back into the value a Promise caller would have seen.
|
|
41
|
+
*
|
|
42
|
+
* An interrupt caused by the caller's signal wins, as the abort does in
|
|
43
|
+
* withDeadline's race. Otherwise the first typed failure is rethrown as is,
|
|
44
|
+
* then the first defect — never a wrapper, because callers check `instanceof`,
|
|
45
|
+
* `name`, and `message` on connecta's own error classes.
|
|
46
|
+
*/
|
|
47
|
+
function rethrow(cause, signal) {
|
|
48
|
+
const reasons = cause.reasons;
|
|
49
|
+
if (signal?.aborted && reasons.some(Cause.isInterruptReason)) {
|
|
50
|
+
throw abortReason(signal);
|
|
51
|
+
}
|
|
52
|
+
const failure = reasons.find(Cause.isFailReason);
|
|
53
|
+
if (failure)
|
|
54
|
+
throw failure.error;
|
|
55
|
+
const defect = reasons.find(Cause.isDieReason);
|
|
56
|
+
if (defect)
|
|
57
|
+
throw defect.defect;
|
|
58
|
+
throw abortReason(signal);
|
|
59
|
+
}
|
|
60
|
+
const disposedRuntimes = new WeakSet();
|
|
61
|
+
/** Wrap a layer as an EdgeRuntime without building it. */
|
|
62
|
+
export function makeEdgeRuntime(layer) {
|
|
63
|
+
return ManagedRuntime.make(layer);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Release a runtime's layer resources. Idempotent, and never rejects: a
|
|
67
|
+
* finalizer that fails has nobody left to report to.
|
|
68
|
+
*/
|
|
69
|
+
export async function disposeEdgeRuntime(runtime) {
|
|
70
|
+
if (disposedRuntimes.has(runtime))
|
|
71
|
+
return;
|
|
72
|
+
disposedRuntimes.add(runtime);
|
|
73
|
+
// Through runExit rather than runtime.dispose(), whose runner yields on
|
|
74
|
+
// Effect's default scheduler — the one vitest's fake timers freeze.
|
|
75
|
+
await runExit(runtime.disposeEffect);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The effect with the runtime's services provided, building them first when
|
|
79
|
+
* no run has yet. The build runs on the calling fiber's scheduler, so it is
|
|
80
|
+
* the microtask scheduler here, as everything else is.
|
|
81
|
+
*/
|
|
82
|
+
function provideRuntime(effect, runtime) {
|
|
83
|
+
if (disposedRuntimes.has(runtime)) {
|
|
84
|
+
return Effect.die(new Error("This Connecta has been closed."));
|
|
85
|
+
}
|
|
86
|
+
const built = runtime.cachedContext;
|
|
87
|
+
if (built)
|
|
88
|
+
return Effect.provideContext(effect, built);
|
|
89
|
+
return Effect.flatMap(runtime.contextEffect, (context) => Effect.provideContext(effect, context));
|
|
90
|
+
}
|
|
91
|
+
export async function runEdge(effect, options = {}) {
|
|
92
|
+
const exit = await runExit(options.runtime
|
|
93
|
+
? provideRuntime(effect, options.runtime)
|
|
94
|
+
// Without a runtime the overloads admit only an effect that requires
|
|
95
|
+
// nothing, so there is nothing to provide.
|
|
96
|
+
: effect, options.signal ? { signal: options.signal } : undefined);
|
|
97
|
+
if (Exit.isSuccess(exit))
|
|
98
|
+
return exit.value;
|
|
99
|
+
return rethrow(exit.cause, options.signal);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fail with the signal's abort reason once it aborts; never succeed.
|
|
103
|
+
*
|
|
104
|
+
* Race it against work that should stop when the signal does. A signal
|
|
105
|
+
* aborted without a reason fails with an AbortError, as `fetch` would.
|
|
106
|
+
*/
|
|
107
|
+
export function fromSignal(signal) {
|
|
108
|
+
return Effect.callback((resume) => {
|
|
109
|
+
// Remove the listener on every path, firing included. `once` already
|
|
110
|
+
// drops it from a real AbortSignal, but the cleanup below runs only on
|
|
111
|
+
// interruption, and a signal-shaped wrapper sees no removal otherwise.
|
|
112
|
+
const onAbort = () => {
|
|
113
|
+
signal.removeEventListener("abort", onAbort);
|
|
114
|
+
resume(Effect.fail(abortReason(signal)));
|
|
115
|
+
};
|
|
116
|
+
if (signal.aborted) {
|
|
117
|
+
onAbort();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
return Effect.sync(() => signal.removeEventListener("abort", onAbort));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The Effect form of withDeadline (src/timeout.ts), with the same contract.
|
|
126
|
+
*
|
|
127
|
+
* The operation gets a signal of its own. The caller's signal is forwarded to
|
|
128
|
+
* it, and the deadline aborts it with the labelled `timeoutError` *before* the
|
|
129
|
+
* operation's fiber is interrupted, so work that honors the signal sees the
|
|
130
|
+
* same reason the caller does. Either abort fails the effect with that reason:
|
|
131
|
+
* the caller's, or `timeoutError`. The signal is always aborted on exit, as
|
|
132
|
+
* withDeadline's `finally` does.
|
|
133
|
+
*
|
|
134
|
+
* The error channel is `unknown` because an abort reason is whatever the
|
|
135
|
+
* caller chose.
|
|
136
|
+
*/
|
|
137
|
+
export function withDeadlineEffect(operation, options) {
|
|
138
|
+
return Effect.suspend(() => {
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const forwardAbort = () => controller.abort(options.signal?.reason);
|
|
141
|
+
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
142
|
+
if (options.signal?.aborted)
|
|
143
|
+
forwardAbort();
|
|
144
|
+
// One race, not a race nested in a race: every contender is a forked
|
|
145
|
+
// fiber, and forks are most of what a deadline costs (P1-S02 measured
|
|
146
|
+
// about 2µs per raceFirst). Fork order is unchanged — operation, abort,
|
|
147
|
+
// timer — and the timer still aborts the signal before anything is
|
|
148
|
+
// interrupted, so the abort contender wins with the labelled reason.
|
|
149
|
+
const contenders = [
|
|
150
|
+
Effect.suspend(() => operation(controller.signal)),
|
|
151
|
+
fromSignal(controller.signal),
|
|
152
|
+
];
|
|
153
|
+
if (options.timeoutMs !== undefined) {
|
|
154
|
+
contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.sync(() => controller.abort(options.timeoutError))), Effect.andThen(Effect.never)));
|
|
155
|
+
}
|
|
156
|
+
return Effect.raceAllFirst(contenders).pipe(Effect.ensuring(Effect.sync(() => {
|
|
157
|
+
controller.abort();
|
|
158
|
+
options.signal?.removeEventListener("abort", forwardAbort);
|
|
159
|
+
})));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The only sanctioned fire-and-forget.
|
|
164
|
+
*
|
|
165
|
+
* On Workers the work is handed to `ctx.waitUntil`, so the platform keeps the
|
|
166
|
+
* invocation alive until it settles rather than freezing it mid-flight. The
|
|
167
|
+
* returned promise never rejects: the outcome is the effect's own business,
|
|
168
|
+
* and an unhandled rejection from background work helps nobody.
|
|
169
|
+
*/
|
|
170
|
+
export function detach(effect, ctx) {
|
|
171
|
+
const settled = runExit(effect).then(() => { }, () => { });
|
|
172
|
+
ctx?.waitUntil?.(settled);
|
|
173
|
+
return settled;
|
|
174
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The core's services, as Effect code sees them.
|
|
2
|
+
//
|
|
3
|
+
// createConnecta resolves storage, the vault, activity history, logging, and
|
|
4
|
+
// the rest of its configuration once, at construction. This module gives each
|
|
5
|
+
// of those a service key and assembles them into the layer behind that
|
|
6
|
+
// Connecta's runtime (an EdgeRuntime, src/runtime/run.ts), so an Effect
|
|
7
|
+
// program reads them from its context rather than from arguments threaded
|
|
8
|
+
// through every call. The shells — Registry, CatalogService, and the rest —
|
|
9
|
+
// stay constructible from plain arguments, as the tests construct them. The
|
|
10
|
+
// registry's storage programs read Storage and Logger too, but from their own
|
|
11
|
+
// partition rather than this runtime (see src/runtime/storage.ts for why).
|
|
12
|
+
//
|
|
13
|
+
// Every import from an optional module is type-only. An omitted activity
|
|
14
|
+
// module is a no-op recorder that builds no event and calls nothing, and the
|
|
15
|
+
// UI, vault, and activity implementations stay out of the root import graph
|
|
16
|
+
// (test/purity.test.ts).
|
|
17
|
+
import { Context, Effect, Layer } from "effect";
|
|
18
|
+
import { makeEdgeRuntime } from "./run.js";
|
|
19
|
+
/**
|
|
20
|
+
* The deployment's KVStorage: `config.storage`, or the memoryStorage() that
|
|
21
|
+
* stands in for it. `compareAndSet` is present exactly when the adapter
|
|
22
|
+
* implements it, so a caller that needs atomicity checks for it here.
|
|
23
|
+
*/
|
|
24
|
+
export class Storage extends Context.Service()("connecta/Storage") {
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The credential vault, or undefined when the deployment configures none —
|
|
28
|
+
* a required service whose value may be absent, not a default, so a program
|
|
29
|
+
* run without it provided fails to typecheck instead of finding no vault.
|
|
30
|
+
*/
|
|
31
|
+
export class Vault extends Context.Service()("connecta/Vault") {
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Connecta's diagnostic logger, already resolved: the configured Logger, a
|
|
35
|
+
* no-op for `"silent"`, or console output prefixed `[connecta]`. Effect's
|
|
36
|
+
* own logger is never used (test/purity.test.ts), because it honors neither
|
|
37
|
+
* `"silent"` nor the line format a deployment greps for.
|
|
38
|
+
*/
|
|
39
|
+
export class Logger extends Context.Service()("connecta/Logger") {
|
|
40
|
+
}
|
|
41
|
+
/** Payload-free activity history, or a recorder that records nothing. */
|
|
42
|
+
export class ActivityRecorder extends Context.Service()("connecta/ActivityRecorder") {
|
|
43
|
+
}
|
|
44
|
+
/** What createConnecta resolved at construction. */
|
|
45
|
+
export class ResolvedConfig extends Context.Service()("connecta/ResolvedConfig") {
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The request's hook for work that may outlive its response (a Worker's
|
|
49
|
+
* `ctx.waitUntil`). Per request, not per Connecta, so it is never part of the
|
|
50
|
+
* runtime: a request provides it with `Effect.provideService`, and a program
|
|
51
|
+
* outside any request sees undefined and keeps its work in the foreground.
|
|
52
|
+
*/
|
|
53
|
+
export const DeferredWork = Context.Reference("connecta/DeferredWork", { defaultValue: () => undefined });
|
|
54
|
+
/**
|
|
55
|
+
* Resolve `ConnectaConfig.logger`. `"silent"` discards every line; an omitted
|
|
56
|
+
* logger writes to the console with the `[connecta]` prefix.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveLogger(logger) {
|
|
59
|
+
if (logger === "silent") {
|
|
60
|
+
return { debug() { }, info() { }, warn() { }, error() { } };
|
|
61
|
+
}
|
|
62
|
+
return logger ?? {
|
|
63
|
+
debug: (...a) => console.debug("[connecta]", ...a),
|
|
64
|
+
info: (...a) => console.info("[connecta]", ...a),
|
|
65
|
+
warn: (...a) => console.warn("[connecta]", ...a),
|
|
66
|
+
error: (...a) => console.error("[connecta]", ...a),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const NO_ACTIVITY = {
|
|
70
|
+
enabled: false,
|
|
71
|
+
recordTool: () => Effect.void,
|
|
72
|
+
recordDrift: () => Effect.void,
|
|
73
|
+
};
|
|
74
|
+
function activityRecorder(module, serverInfo, logger) {
|
|
75
|
+
const deployment = module.deploymentId !== undefined
|
|
76
|
+
? { deploymentId: module.deploymentId }
|
|
77
|
+
: {};
|
|
78
|
+
// A custom module's recorder that throws is logged like a store that
|
|
79
|
+
// throws: activity never changes the outcome of the work it describes.
|
|
80
|
+
const bestEffort = (record) => Effect.sync(() => {
|
|
81
|
+
try {
|
|
82
|
+
record();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
logger.warn("[connecta] activity record failed", error);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
enabled: true,
|
|
90
|
+
recordTool: (request, input) => DeferredWork.use((defer) => bestEffort(() => module.recordTool({
|
|
91
|
+
sink: module.store,
|
|
92
|
+
recordTool: module.recordTool,
|
|
93
|
+
actor: request.actor,
|
|
94
|
+
requestId: request.requestId,
|
|
95
|
+
serverInfo,
|
|
96
|
+
...deployment,
|
|
97
|
+
...(defer ? { defer } : {}),
|
|
98
|
+
logger,
|
|
99
|
+
}, input))),
|
|
100
|
+
recordDrift: (input) => bestEffort(() => module.recordDrift({ sink: module.store, serverInfo, ...deployment, logger }, input)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The layer behind a Connecta's runtime. Every service is a value resolved
|
|
105
|
+
* before this is called, so building the layer only assembles a context: it
|
|
106
|
+
* starts no fiber and touches no storage.
|
|
107
|
+
*/
|
|
108
|
+
function coreServices(options) {
|
|
109
|
+
const activity = options.activity
|
|
110
|
+
? activityRecorder(options.activity, options.config.serverInfo, options.logger)
|
|
111
|
+
: NO_ACTIVITY;
|
|
112
|
+
return Layer.succeedContext(Context.make(Storage, options.storage).pipe(Context.add(Vault, options.vault), Context.add(Logger, options.logger), Context.add(ActivityRecorder, activity), Context.add(ResolvedConfig, options.config)));
|
|
113
|
+
}
|
|
114
|
+
// Keyed by the root Registry: it is the per-Connecta object every internal
|
|
115
|
+
// caller already holds, and a Registry built directly (as the tests build
|
|
116
|
+
// them) simply has no runtime.
|
|
117
|
+
const runtimes = new WeakMap();
|
|
118
|
+
/**
|
|
119
|
+
* Create the runtime for the Connecta that owns `registry`. Runs nothing:
|
|
120
|
+
* the layer is built by the first run that needs it (see EdgeRuntime).
|
|
121
|
+
*/
|
|
122
|
+
export function createCoreRuntime(registry, options) {
|
|
123
|
+
const runtime = makeEdgeRuntime(coreServices(options));
|
|
124
|
+
runtimes.set(registry, runtime);
|
|
125
|
+
return runtime;
|
|
126
|
+
}
|
|
127
|
+
/** The runtime createConnecta made for this root registry, if it made one. */
|
|
128
|
+
export function coreRuntime(registry) {
|
|
129
|
+
return runtimes.get(registry);
|
|
130
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// One read that several readers in one request wait on, each on its own terms.
|
|
2
|
+
//
|
|
3
|
+
// Discovery and the registry both coalesce a connector's catalog read inside
|
|
4
|
+
// one request, so a search, a describe, and the call after them cost one
|
|
5
|
+
// downstream listing. The read used to run under whichever reader asked
|
|
6
|
+
// first: its signal was the read's signal, so a short-deadline call that
|
|
7
|
+
// happened to start the read failed a search that joined it with the call's
|
|
8
|
+
// timeout, and a cancelled search cancelled a read others were still waiting
|
|
9
|
+
// on (#571).
|
|
10
|
+
//
|
|
11
|
+
// So the read owns its signal. Each reader waits under its own cancellation,
|
|
12
|
+
// and the read is cancelled only when no reader is left to want it. Nothing
|
|
13
|
+
// here is shared across requests: a SharedRead lives in one request's map and
|
|
14
|
+
// is waited on only by that request's fibers, which is why a plain count of
|
|
15
|
+
// readers is enough and no edge is needed between them.
|
|
16
|
+
import { Deferred, Effect, Exit } from "effect";
|
|
17
|
+
export class SharedRead {
|
|
18
|
+
onEnd;
|
|
19
|
+
controller = new AbortController();
|
|
20
|
+
outcome = Deferred.makeUnsafe();
|
|
21
|
+
readers = 0;
|
|
22
|
+
ended = false;
|
|
23
|
+
/**
|
|
24
|
+
* Start `read` at once, under a signal the read owns. `onEnd` runs once:
|
|
25
|
+
* when the read settles, with whether it succeeded, or when it is cancelled
|
|
26
|
+
* first, with `false`. It runs before any reader hears the outcome, so an
|
|
27
|
+
* owner that evicts the read there sends a reader who retries on hearing of
|
|
28
|
+
* a failure to a fresh read, never back to this one.
|
|
29
|
+
*/
|
|
30
|
+
constructor(read, onEnd = () => { }) {
|
|
31
|
+
this.onEnd = onEnd;
|
|
32
|
+
// The executor turns a synchronous throw from `read` into a rejection.
|
|
33
|
+
new Promise((resolve) => resolve(read(this.controller.signal))).then((value) => this.settle(Exit.succeed(value)), (cause) => this.settle(Exit.fail(cause)));
|
|
34
|
+
}
|
|
35
|
+
settle(exit) {
|
|
36
|
+
this.end(Exit.isSuccess(exit));
|
|
37
|
+
Deferred.doneUnsafe(this.outcome, exit);
|
|
38
|
+
}
|
|
39
|
+
end(succeeded) {
|
|
40
|
+
if (this.ended)
|
|
41
|
+
return;
|
|
42
|
+
this.ended = true;
|
|
43
|
+
this.onEnd(succeeded);
|
|
44
|
+
}
|
|
45
|
+
// Cancel the read for good. After this it is no longer joinable: whatever
|
|
46
|
+
// it eventually settles with still reaches the readers already waiting, but
|
|
47
|
+
// its owner has evicted it and a newcomer starts afresh.
|
|
48
|
+
cancel(reason) {
|
|
49
|
+
if (this.ended)
|
|
50
|
+
return;
|
|
51
|
+
this.controller.abort(reason);
|
|
52
|
+
this.end(false);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Wait for the read under this reader's own cancellation.
|
|
56
|
+
*
|
|
57
|
+
* A reader whose signal aborts while others still wait fails at once with
|
|
58
|
+
* its own reason and leaves the read to them. The last reader's abort is the
|
|
59
|
+
* read's: it is forwarded to the read's signal and that reader waits for the
|
|
60
|
+
* read's answer, as a lone reader always has, so a connector that finishes
|
|
61
|
+
* its listing anyway still hands it over. A reader that is interrupted — its
|
|
62
|
+
* deadline, as `withDeadlineEffect` ends it — simply leaves, and when it was
|
|
63
|
+
* the last one the read is cancelled with its reason.
|
|
64
|
+
*/
|
|
65
|
+
join(signal) {
|
|
66
|
+
return Effect.suspend(() => {
|
|
67
|
+
this.readers++;
|
|
68
|
+
let waiting = true;
|
|
69
|
+
const leave = Effect.sync(() => {
|
|
70
|
+
if (!waiting)
|
|
71
|
+
return;
|
|
72
|
+
waiting = false;
|
|
73
|
+
this.readers--;
|
|
74
|
+
if (this.readers === 0)
|
|
75
|
+
this.cancel(signal?.reason);
|
|
76
|
+
});
|
|
77
|
+
const answer = Deferred.await(this.outcome);
|
|
78
|
+
const own = signal
|
|
79
|
+
? Effect.raceFirst(answer, this.leftBy(signal))
|
|
80
|
+
: answer;
|
|
81
|
+
return Effect.ensuring(own, leave);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Fails with the reader's abort reason when other readers remain; when it is
|
|
85
|
+
// the last, forwards the abort to the read and never completes, leaving the
|
|
86
|
+
// read's own answer to win the race.
|
|
87
|
+
leftBy(signal) {
|
|
88
|
+
return Effect.callback((resume) => {
|
|
89
|
+
const onAbort = () => {
|
|
90
|
+
signal.removeEventListener("abort", onAbort);
|
|
91
|
+
if (this.readers > 1) {
|
|
92
|
+
resume(Effect.fail(signal.reason));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.cancel(signal.reason);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (signal.aborted) {
|
|
99
|
+
onAbort();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
return Effect.sync(() => signal.removeEventListener("abort", onAbort));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The Storage service, as the registry's programs use it.
|
|
2
|
+
//
|
|
3
|
+
// KVStorage stays Promise-shaped: it is what a deployment author implements,
|
|
4
|
+
// and nobody should need Effect to write a storage adapter. The operations
|
|
5
|
+
// below lift one call each into an effect that reads the adapter from the
|
|
6
|
+
// Storage service and fails with whatever the adapter threw, unwrapped, so a
|
|
7
|
+
// warning built from it reads exactly as it did.
|
|
8
|
+
//
|
|
9
|
+
// Which Storage a program sees is the registry's decision, not the runtime's.
|
|
10
|
+
// The Connecta runtime (src/runtime/services.ts) is keyed by the root
|
|
11
|
+
// Registry, and that is the wrong key here: a personal registry's storage is
|
|
12
|
+
// the root's namespaced to `principal:<key>:`, so handing its programs the
|
|
13
|
+
// runtime's Storage would read and write another partition. Each Registry —
|
|
14
|
+
// root, personal, or one a test built from plain arguments — therefore
|
|
15
|
+
// provides its own storage and logger to the programs it runs
|
|
16
|
+
// (`runOnPartition`). For the root registry those are the very values the
|
|
17
|
+
// runtime holds, because createConnecta passes the same two objects to both.
|
|
18
|
+
// A scoped view holds no storage of its own: it reaches Storage through the
|
|
19
|
+
// registry it delegates to, and the result stash is always the root's,
|
|
20
|
+
// because its capacity is runtime-wide.
|
|
21
|
+
import { Context, Effect } from "effect";
|
|
22
|
+
import { runEdge } from "./run.js";
|
|
23
|
+
import { Logger, Storage } from "./services.js";
|
|
24
|
+
/** Read one key; null when absent. */
|
|
25
|
+
export function storageGet(key) {
|
|
26
|
+
return Storage.use((storage) => Effect.tryPromise({
|
|
27
|
+
// Promise.resolve keeps an adapter that answers synchronously working,
|
|
28
|
+
// as `await` did; a synchronous throw is caught either way.
|
|
29
|
+
try: () => Promise.resolve(storage.get(key)),
|
|
30
|
+
catch: (error) => error,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/** Write one key, with the adapter's optional TTL. */
|
|
34
|
+
export function storageSet(key, value, options) {
|
|
35
|
+
return Storage.use((storage) => Effect.tryPromise({
|
|
36
|
+
try: () => Promise.resolve(storage.set(key, value, options)),
|
|
37
|
+
catch: (error) => error,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
/** Delete one key. */
|
|
41
|
+
export function storageDelete(key) {
|
|
42
|
+
return Storage.use((storage) => Effect.tryPromise({
|
|
43
|
+
try: () => Promise.resolve(storage.delete(key)),
|
|
44
|
+
catch: (error) => error,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
// One context per partition object, built on first use. A registry's options
|
|
48
|
+
// object lives exactly as long as the registry, so the WeakMap never holds a
|
|
49
|
+
// partition past its owner.
|
|
50
|
+
const partitions = new WeakMap();
|
|
51
|
+
function partitionContext(partition) {
|
|
52
|
+
let context = partitions.get(partition);
|
|
53
|
+
if (!context) {
|
|
54
|
+
context = Context.make(Storage, partition.storage).pipe(Context.add(Logger, partition.logger));
|
|
55
|
+
partitions.set(partition, context);
|
|
56
|
+
}
|
|
57
|
+
return context;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Run a registry program against the registry's own storage and logger, at a
|
|
61
|
+
* Promise boundary. Resolves and rejects as runEdge does: the original error,
|
|
62
|
+
* never a wrapper.
|
|
63
|
+
*/
|
|
64
|
+
export function runOnPartition(effect, partition) {
|
|
65
|
+
return runEdge(Effect.provideContext(effect, partitionContext(partition)));
|
|
66
|
+
}
|