@zackbart/connecta 0.38.0 → 0.40.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/CHANGELOG.md +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
package/dist/routes/ui-data.js
CHANGED
|
@@ -9,14 +9,19 @@ import { describedTools } from "../described.js";
|
|
|
9
9
|
import { Effect } from "effect";
|
|
10
10
|
import { bindActivityRequest } from "../activity-request.js";
|
|
11
11
|
import { resolveDiscoveryConcurrency } from "../concurrency.js";
|
|
12
|
+
import { credentialReads } from "../credential-reads.js";
|
|
12
13
|
import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape } from "../credential-rules.js";
|
|
14
|
+
import { readVerification } from "../credential-verification.js";
|
|
13
15
|
import { failureRecord, failureStatus, logFailure, recordedToolName, statusFailure } from "../operator-record.js";
|
|
14
16
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
17
|
+
import { requestReads } from "../runtime/request-scope.js";
|
|
15
18
|
import { withDeadlineEffect } from "../runtime/run.js";
|
|
16
19
|
import { uiProblemFor } from "../ui.js";
|
|
17
20
|
import { CONNECTA_VERSION } from "../version.js";
|
|
21
|
+
import { prepareStatusSlot, readStatusObservation, storeStatusObservation, } from "../connector-status.js";
|
|
18
22
|
const DETAIL_TIMEOUT_MS = 30_000;
|
|
19
23
|
const DETAIL_TIMED_OUT = "Connection details timed out. Retry this connection.";
|
|
24
|
+
const OBSERVE_TIMED_OUT = "Connector did not answer before the operator page's deadline.";
|
|
20
25
|
function attempt(operation) {
|
|
21
26
|
return Effect.tryPromise({ try: operation, catch: (error) => error });
|
|
22
27
|
}
|
|
@@ -38,6 +43,35 @@ export function uiData(registry, baseUrl, options) {
|
|
|
38
43
|
const logger = registry.contextFor(id, baseUrl, requestScope).logger;
|
|
39
44
|
logFailure(logger, "operator status", failureRecord({ connector: id, state }, failure), state === "auth_required" ? "info" : "warn");
|
|
40
45
|
};
|
|
46
|
+
// A stored row: the observation's state and problem, and no tools. They
|
|
47
|
+
// stay in the catalog cache (INV-8), and the browser's live read, which
|
|
48
|
+
// follows in its own request, shows them. Reading them here could connect,
|
|
49
|
+
// and the row's scope would wait for that listing to end.
|
|
50
|
+
// The credential's stored verification is local, so it is read fresh here too.
|
|
51
|
+
const observeStored = (c, stored, drift, requestScope) => drift
|
|
52
|
+
? Effect.succeed({ status: { state: "auth_required", message: drift }, tools: [], catalogFailed: false })
|
|
53
|
+
: Effect.map(verificationFor(c, requestScope), (verification) => ({
|
|
54
|
+
status: { state: stored.state },
|
|
55
|
+
tools: [],
|
|
56
|
+
catalogFailed: stored.problem === "catalog_failed",
|
|
57
|
+
...(stored.problem === "connector_timeout" ? { timedOut: true } : {}),
|
|
58
|
+
verification,
|
|
59
|
+
}));
|
|
60
|
+
/**
|
|
61
|
+
* The stored last verification of an operator-managed credential
|
|
62
|
+
* (src/credential-verification.ts). Read only, never probed (INV-10). A
|
|
63
|
+
* record counts only for the save it observed, whose revision the card's
|
|
64
|
+
* memoized vault read already holds; a vault without revisions has none
|
|
65
|
+
* to match.
|
|
66
|
+
*/
|
|
67
|
+
const verificationFor = (c, requestScope) => c.credential && vault
|
|
68
|
+
? attempt(async () => {
|
|
69
|
+
const metadata = await credentialReads(vault, requestReads(requestScope)).metadata(c.id, c.authScope === "personal" ? owner : undefined);
|
|
70
|
+
return metadata?.revision
|
|
71
|
+
? readVerification(registry.contextFor(c.id, baseUrl, requestScope).storage, metadata.revision)
|
|
72
|
+
: undefined;
|
|
73
|
+
}).pipe(Effect.orElseSucceed(() => undefined))
|
|
74
|
+
: Effect.succeed(undefined);
|
|
41
75
|
// Status first, then the catalog only when status is ok. A failed status
|
|
42
76
|
// read is an error status, not a failed row: the row still reports the
|
|
43
77
|
// connector's credential.
|
|
@@ -54,8 +88,9 @@ export function uiData(registry, baseUrl, options) {
|
|
|
54
88
|
signal,
|
|
55
89
|
...(options.defer ? { defer: options.defer } : {}),
|
|
56
90
|
}));
|
|
91
|
+
const verification = yield* verificationFor(c, requestScope);
|
|
57
92
|
if (status.state !== "ok" || signal.aborted) {
|
|
58
|
-
return { status, tools: [], catalogFailed: false };
|
|
93
|
+
return { status, tools: [], catalogFailed: false, verification };
|
|
59
94
|
}
|
|
60
95
|
// A catalog is configuration the operator chose to load, and its
|
|
61
96
|
// descriptions are the agent's too, so the page shows them as text.
|
|
@@ -71,10 +106,10 @@ export function uiData(registry, baseUrl, options) {
|
|
|
71
106
|
name: recordedToolName(t),
|
|
72
107
|
address: `${c.id}.${recordedToolName(t)}`,
|
|
73
108
|
classification: t.classification === "read" ? "read" : "write",
|
|
74
|
-
}))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
|
|
109
|
+
}))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false, verification })),
|
|
75
110
|
// The registry owns the failed catalog observation; the page only
|
|
76
111
|
// needs to know there was one.
|
|
77
|
-
Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted })),
|
|
112
|
+
Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted, verification })),
|
|
78
113
|
// Loading the catalog may replace an earlier clean observation.
|
|
79
114
|
// Read this view's current bounded facts without another status probe.
|
|
80
115
|
Effect.map((observed) => ({
|
|
@@ -87,9 +122,23 @@ export function uiData(registry, baseUrl, options) {
|
|
|
87
122
|
catalogFailed: false,
|
|
88
123
|
})));
|
|
89
124
|
};
|
|
125
|
+
// The observation under its own deadline. Its abort reaches the status
|
|
126
|
+
// and catalog reads through `inner`, which then settle as an error or a
|
|
127
|
+
// failed catalog; either way the reason on `inner` says the deadline
|
|
128
|
+
// ended them, so the row is a timeout rather than a failure.
|
|
129
|
+
const observeWithin = (c, drift, signal, requestScope, timeoutMs) => {
|
|
130
|
+
const timeoutError = new Error(OBSERVE_TIMED_OUT);
|
|
131
|
+
const timedOut = () => ({
|
|
132
|
+
status: failureStatus(c.id, timeoutError),
|
|
133
|
+
tools: [],
|
|
134
|
+
catalogFailed: false,
|
|
135
|
+
timedOut: true,
|
|
136
|
+
});
|
|
137
|
+
return withDeadlineEffect((inner) => observe(c, drift, inner, requestScope).pipe(Effect.map((observed) => (inner.reason === timeoutError ? timedOut() : observed))), { timeoutMs, signal, timeoutError }).pipe(Effect.catch((error) => (error === timeoutError ? Effect.succeed(timedOut()) : Effect.fail(error))));
|
|
138
|
+
};
|
|
90
139
|
// The credential card, for someone who may manage this connector's auth.
|
|
91
140
|
// Never fails: a vault that cannot be read is the card's own error.
|
|
92
|
-
const credentialFor = (c) => {
|
|
141
|
+
const credentialFor = (c, requestScope) => {
|
|
93
142
|
const mayManageAuth = options.mayManage?.(c.id) ?? (c.authScope === "personal" ? Boolean(owner) : options.oauthManagement);
|
|
94
143
|
const declared = c.credential;
|
|
95
144
|
if (!declared || !vault || !mayManageAuth)
|
|
@@ -121,7 +170,9 @@ export function uiData(registry, baseUrl, options) {
|
|
|
121
170
|
...(declared.placeholder ? { placeholder: declared.placeholder } : {}),
|
|
122
171
|
};
|
|
123
172
|
return attempt(async () => {
|
|
124
|
-
|
|
173
|
+
// Memoized per request: the card only displays what the drift and
|
|
174
|
+
// status reads of this same detail already read.
|
|
175
|
+
const metadata = await credentialReads(vault, requestReads(requestScope)).metadata(c.id, c.authScope === "personal" ? owner : undefined);
|
|
125
176
|
const fields = credentialFields(metadata);
|
|
126
177
|
const shape = storedCredentialShape(declared, metadata?.fields ?? null);
|
|
127
178
|
return {
|
|
@@ -152,18 +203,52 @@ export function uiData(registry, baseUrl, options) {
|
|
|
152
203
|
});
|
|
153
204
|
}));
|
|
154
205
|
};
|
|
155
|
-
|
|
156
|
-
|
|
206
|
+
// Where this row records what it observed. A live read prepares only the
|
|
207
|
+
// generation; a stored read looks the observation up and, finding none,
|
|
208
|
+
// records the live read it falls back to.
|
|
209
|
+
const statusLookup = (c, requestScope) => options.statusObservation === "stored"
|
|
210
|
+
? readStatusObservation(registry, c.id, baseUrl, requestScope)
|
|
211
|
+
: options.statusObservation === "record"
|
|
212
|
+
? Effect.map(prepareStatusSlot(registry, c.id, baseUrl, requestScope), (slot) => (slot ? { slot } : {}))
|
|
213
|
+
: Effect.succeed({});
|
|
214
|
+
const detail = (c, signal, requestScope, recording) => Effect.gen(function* () {
|
|
215
|
+
// The status slot is read before the probe starts, so an invalidation
|
|
216
|
+
// during it leaves the observation under the generation it replaced.
|
|
217
|
+
const [drift, stored] = yield* Effect.all([attempt(() => registry.credentialDriftFor(c.id, requestScope)), statusLookup(c, requestScope)], { concurrency: 2 });
|
|
218
|
+
const observation = stored.observation;
|
|
219
|
+
const statusSlot = observation ? undefined : stored.slot;
|
|
220
|
+
// The row's own deadline may end it below; it records from here.
|
|
221
|
+
if (statusSlot)
|
|
222
|
+
recording.slot = statusSlot;
|
|
157
223
|
// One deadline covers the whole row. The status and catalog reads once
|
|
158
224
|
// had a second one of the same length inside it, which could only fire
|
|
159
225
|
// after this one had already ended the row.
|
|
160
|
-
|
|
161
|
-
|
|
226
|
+
// The operator page's shorter observation deadline, when it has one,
|
|
227
|
+
// bounds only the downstream: the credential card below is local
|
|
228
|
+
// storage and still renders, so a slow vendor's slot stays fixable.
|
|
229
|
+
const { status, tools, catalogFailed, timedOut, verification } = observation
|
|
230
|
+
? yield* observeStored(c, observation, drift, requestScope)
|
|
231
|
+
: options.observeTimeoutMs === undefined
|
|
232
|
+
? yield* observe(c, drift, signal, requestScope)
|
|
233
|
+
: yield* observeWithin(c, drift, signal, requestScope, options.observeTimeoutMs);
|
|
234
|
+
const credential = yield* credentialFor(c, requestScope);
|
|
162
235
|
logStatus(c.id, status.state, statusFailure(status), requestScope);
|
|
163
|
-
const problem =
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
236
|
+
const problem = timedOut
|
|
237
|
+
? "connector_timeout"
|
|
238
|
+
: uiProblemFor(c, status.state, {
|
|
239
|
+
credentialDrift: Boolean(drift),
|
|
240
|
+
catalogFailed,
|
|
241
|
+
});
|
|
242
|
+
const toolCount = observation ? observation.toolCount : tools.length;
|
|
243
|
+
// Started only while the caller is still here, then awaited to its
|
|
244
|
+
// end even if the row's deadline or the caller ends it meanwhile.
|
|
245
|
+
if (statusSlot && !signal.aborted && !options.signal?.aborted)
|
|
246
|
+
yield* Effect.uninterruptible(storeStatusObservation(statusSlot, {
|
|
247
|
+
state: status.state,
|
|
248
|
+
...(problem ? { problem } : {}),
|
|
249
|
+
toolCount,
|
|
250
|
+
observedAt: Date.now(),
|
|
251
|
+
}));
|
|
167
252
|
return {
|
|
168
253
|
id: c.id,
|
|
169
254
|
authScope: c.authScope ?? "shared",
|
|
@@ -171,7 +256,7 @@ export function uiData(registry, baseUrl, options) {
|
|
|
171
256
|
...(c.description !== undefined ? { description: c.description } : {}),
|
|
172
257
|
status: status.state,
|
|
173
258
|
...(problem ? { problem } : {}),
|
|
174
|
-
toolCount
|
|
259
|
+
toolCount,
|
|
175
260
|
tools: tools.map((tool) => ({
|
|
176
261
|
name: tool.name,
|
|
177
262
|
address: tool.address,
|
|
@@ -186,7 +271,9 @@ export function uiData(registry, baseUrl, options) {
|
|
|
186
271
|
...(status.registrationPath ? { auth: { registrationPath: status.registrationPath } } : {}),
|
|
187
272
|
catalogAgeMs: registry.catalogAgeMs(c.id),
|
|
188
273
|
lastCall: null,
|
|
274
|
+
...(observation ? { observedAgeMs: Math.max(0, Date.now() - observation.observedAt) } : {}),
|
|
189
275
|
},
|
|
276
|
+
...(observation ? { observedAgeMs: Math.max(0, Date.now() - observation.observedAt) } : {}),
|
|
190
277
|
// Counts only, and only when a refresh in this runtime produced them.
|
|
191
278
|
// `Registry.withCatalogObservations` rebuilt the report through
|
|
192
279
|
...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
|
|
@@ -196,43 +283,62 @@ export function uiData(registry, baseUrl, options) {
|
|
|
196
283
|
: {}),
|
|
197
284
|
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
198
285
|
...(status.registrationPath ? { registrationPath: status.registrationPath } : {}),
|
|
286
|
+
...(verification ? { verification } : {}),
|
|
199
287
|
...(credential ? { credential } : {}),
|
|
200
288
|
};
|
|
201
289
|
});
|
|
202
|
-
const unavailable = (c, error, requestScope) => Effect.sync(() => {
|
|
290
|
+
const unavailable = (c, error, requestScope, timeoutError) => Effect.sync(() => {
|
|
203
291
|
logStatus(c.id, "failed", error, requestScope);
|
|
292
|
+
// The row's own deadline is a timeout too, whichever ran out first.
|
|
293
|
+
const problem = error === timeoutError ? "connector_timeout" : "connector_unavailable";
|
|
204
294
|
return {
|
|
205
295
|
id: c.id,
|
|
206
296
|
...(c.title ? { title: c.title } : {}),
|
|
207
297
|
authScope: c.authScope ?? "shared",
|
|
208
298
|
status: "error",
|
|
209
|
-
problem
|
|
299
|
+
problem,
|
|
210
300
|
oauth: Boolean(c.startAuth && c.disconnectAuth),
|
|
211
301
|
toolCount: 0,
|
|
212
302
|
tools: [],
|
|
213
303
|
overlay: {
|
|
214
304
|
id: c.id,
|
|
215
305
|
status: "error",
|
|
216
|
-
problem
|
|
306
|
+
problem,
|
|
217
307
|
tools: [],
|
|
218
308
|
catalogAgeMs: registry.catalogAgeMs(c.id),
|
|
219
309
|
lastCall: null,
|
|
220
310
|
},
|
|
221
311
|
};
|
|
222
312
|
});
|
|
313
|
+
// The row's own deadline ended the read: record that timeout before the
|
|
314
|
+
// response, so the next stored read parks this row too. A caller who has
|
|
315
|
+
// already left records nothing. Once started, the write is awaited to its
|
|
316
|
+
// end, never raced or detached: storage writes cannot be withdrawn once
|
|
317
|
+
// sent, so the request settles only after it does, bounded by the storage
|
|
318
|
+
// layer like every other awaited write (INV-7).
|
|
319
|
+
const recordRowTimeout = (slot) => !slot || options.signal?.aborted
|
|
320
|
+
? Effect.void
|
|
321
|
+
: Effect.uninterruptible(storeStatusObservation(slot, {
|
|
322
|
+
state: "error",
|
|
323
|
+
problem: "connector_timeout",
|
|
324
|
+
toolCount: 0,
|
|
325
|
+
observedAt: Date.now(),
|
|
326
|
+
}));
|
|
223
327
|
const row = (c) => Effect.suspend(() => {
|
|
224
328
|
// A row ends independently. Revoking its probe must not revoke a live
|
|
225
329
|
// sibling that happens to belong to the same operator payload.
|
|
226
330
|
const requestScope = {};
|
|
227
331
|
if (options.activityContext)
|
|
228
332
|
bindActivityRequest(requestScope, options.activityContext);
|
|
229
|
-
|
|
333
|
+
const timeoutError = new Error(DETAIL_TIMED_OUT);
|
|
334
|
+
const recording = {};
|
|
335
|
+
return withDeadlineEffect((signal) => detail(c, signal, requestScope, recording), {
|
|
230
336
|
timeoutMs: options.timeoutMs ?? DETAIL_TIMEOUT_MS,
|
|
231
337
|
...(options.signal ? { signal: options.signal } : {}),
|
|
232
|
-
timeoutError
|
|
338
|
+
timeoutError,
|
|
233
339
|
}).pipe(
|
|
234
340
|
// A hook that throws where it should reject fails its own row too.
|
|
235
|
-
Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch((error) => unavailable(c, error, requestScope)), Effect.ensuring(Effect.suspend(() => closeScope(c, registry.contextFor(c.id, baseUrl, requestScope), options.defer))));
|
|
341
|
+
Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch((error) => Effect.andThen(recordRowTimeout(error === timeoutError ? recording.slot : undefined), unavailable(c, error, requestScope, timeoutError))), Effect.ensuring(Effect.suspend(() => closeScope(c, registry.contextFor(c.id, baseUrl, requestScope), options.defer))));
|
|
236
342
|
});
|
|
237
343
|
return Effect.forEach(registry.listConnectors(), row, {
|
|
238
344
|
concurrency: resolveDiscoveryConcurrency(options.discoveryConcurrency),
|
package/dist/routes/ui.js
CHANGED
|
@@ -134,6 +134,16 @@ function operatorView({ opts }, authz, registry) {
|
|
|
134
134
|
: "requires_operator";
|
|
135
135
|
return { visible, mayManage, permissions, activityEnabled, credentialManagement };
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* How long a detail read waits on the downstream before its row reports
|
|
139
|
+
* `connector_timeout`. A cold remote MCP handshake with an OAuth refresh is
|
|
140
|
+
* about five sequential round trips, so 8 s allows ~1.5 s each; anything
|
|
141
|
+
* slower is shown as slow or unreachable rather than holding one of the
|
|
142
|
+
* page's few detail slots for the full discovery deadline. A deployment that
|
|
143
|
+
* set a shorter `discovery.probeTimeoutMs` keeps that one deadline, which also
|
|
144
|
+
* ends in `connector_timeout`.
|
|
145
|
+
*/
|
|
146
|
+
const DETAIL_OBSERVE_TIMEOUT_MS = 8000;
|
|
137
147
|
/** One connector's probed row, the page's second request per card. */
|
|
138
148
|
function connectorDetail(context, id) {
|
|
139
149
|
const { request, baseUrl, opts, defer } = context;
|
|
@@ -158,7 +168,13 @@ function connectorDetail(context, id) {
|
|
|
158
168
|
discoveryConcurrency: 1,
|
|
159
169
|
personalCredentialOwner: authz.principalKey,
|
|
160
170
|
mayManage,
|
|
171
|
+
// A live read records its observation; `?observation=stored` answers
|
|
172
|
+
// from the current one instead and leaves the live read to the browser.
|
|
173
|
+
statusObservation: context.url.searchParams.get("observation") === "stored" ? "stored" : "record",
|
|
161
174
|
timeoutMs: opts.config.discovery.probeTimeoutMs,
|
|
175
|
+
...(opts.config.discovery.probeTimeoutMs > DETAIL_OBSERVE_TIMEOUT_MS
|
|
176
|
+
? { observeTimeoutMs: DETAIL_OBSERVE_TIMEOUT_MS }
|
|
177
|
+
: {}),
|
|
162
178
|
signal: request.signal,
|
|
163
179
|
});
|
|
164
180
|
const detail = data.connectors[0];
|
package/dist/runtime/call.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Operation and call services. Only the Promise view bridge is keyed on a
|
|
2
2
|
// context object; authority lives in Context values, shared by derived views.
|
|
3
|
+
import { Routes, routesOf } from "../agent-routes.js";
|
|
3
4
|
import { Context, Effect } from "effect";
|
|
4
5
|
import { ConnectorCallError } from "../errors.js";
|
|
5
6
|
import { dispatchProviderRequest, } from "../provider-call.js";
|
|
6
7
|
import { SentSecrets, sentSecretsForRequest, registerCredentialValues } from "../sent-secrets.js";
|
|
7
8
|
import { recordRecovery } from "../call-recovery.js";
|
|
8
9
|
import { fromSignal, runEdge } from "./run.js";
|
|
9
|
-
import { RequestScope, requestOwner as requestOf } from "./request-scope.js";
|
|
10
|
+
import { RequestScope, requestOwner as requestOf, requestReads } from "./request-scope.js";
|
|
10
11
|
/** Who admitted the operation; absent for a probe or a callback no request admitted. */
|
|
11
12
|
export class Caller extends Context.Service()("connecta/call/Caller") {
|
|
12
13
|
}
|
|
@@ -197,7 +198,7 @@ function openCall(inputs) {
|
|
|
197
198
|
throw ended();
|
|
198
199
|
(inputs.ledger ?? detachedLedger).dispatch(request, headers, method);
|
|
199
200
|
},
|
|
200
|
-
}), Context.add(CallMemo, new Map()), Context.add(ProviderLedger, inputs.providerLedger), Context.add(LogicalLedger, inputs.operation ? serviceFor(inputs.operation, LogicalLedger) : undefined));
|
|
201
|
+
}), Context.add(CallMemo, new Map()), Context.add(ProviderLedger, inputs.providerLedger), Context.add(LogicalLedger, inputs.operation ? serviceFor(inputs.operation, LogicalLedger) : undefined), Context.add(Routes, routesOf(inputs.operation)));
|
|
201
202
|
const lease = {
|
|
202
203
|
get services() {
|
|
203
204
|
return Context.add(Context.add(services, ActiveCall, lease), Operation, inputs.operation);
|
|
@@ -338,6 +339,10 @@ export function chargeView(view) {
|
|
|
338
339
|
const budget = recordedService(view, Budget);
|
|
339
340
|
return budget ? runEdge(budget.charge) : Promise.reject(ended());
|
|
340
341
|
}
|
|
342
|
+
/** The read memo of the request this context belongs to (request-reads.ts). */
|
|
343
|
+
export function viewReads(view) {
|
|
344
|
+
return requestReads(views.get(view)?.request ?? view.requestScope ?? view);
|
|
345
|
+
}
|
|
341
346
|
/** Admission is sticky until request close; ending a call never makes its copies standalone. */
|
|
342
347
|
export function callScopeKnown(view) {
|
|
343
348
|
const scope = requestOf(views.get(view)?.request ?? view.requestScope ?? view);
|
|
@@ -23,8 +23,20 @@ function current(receiver, key, own, args, native) {
|
|
|
23
23
|
export function registerNativeStorage(edge, native) {
|
|
24
24
|
const own = { get: edge.get, set: edge.set, delete: edge.delete, list: edge.list, compareAndSet: edge.compareAndSet };
|
|
25
25
|
storageMethods.set(edge, own);
|
|
26
|
+
const getMany = native.getMany;
|
|
26
27
|
storage.set(edge, {
|
|
28
|
+
accessToken: native.accessToken
|
|
29
|
+
? (hash) => typed(() => (getNativeStorage(edge) ? native.accessToken(hash) : Effect.succeed(undefined)))
|
|
30
|
+
: undefined,
|
|
27
31
|
get: (key) => current(edge, "get", own.get, [key], () => native.get(key)),
|
|
32
|
+
// A replaced `get` answers each key itself, in order, as before.
|
|
33
|
+
...(getMany
|
|
34
|
+
? {
|
|
35
|
+
getMany: (keys) => typed(() => Reflect.get(edge, "get") === own.get
|
|
36
|
+
? getMany(keys)
|
|
37
|
+
: Effect.forEach(keys, (key) => current(edge, "get", own.get, [key], () => native.get(key)))),
|
|
38
|
+
}
|
|
39
|
+
: {}),
|
|
28
40
|
set: (key, value, options) => current(edge, "set", own.set, [key, value, options], () => native.set(key, value, options)),
|
|
29
41
|
delete: (key) => current(edge, "delete", own.delete, [key], () => native.delete(key)),
|
|
30
42
|
list: (prefix) => current(edge, "list", own.list, [prefix], () => native.list(prefix)),
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Request-scoped memo of storage reads (INV-7).
|
|
2
|
+
//
|
|
3
|
+
// One request often reads the same key many times: a catalog listing reads its
|
|
4
|
+
// generation at each step, and status, drift and detail views each open the
|
|
5
|
+
// same credential. A call site that has argued staleness
|
|
6
|
+
// within one request is safe reads through `RequestReads.read`; every other
|
|
7
|
+
// read stays a plain storage read. The memo belongs to one request owner,
|
|
8
|
+
// holds plain strings only, and is emptied when the request closes. Nothing in
|
|
9
|
+
// it crosses requests: each request owner has its own. Entries are keyed by
|
|
10
|
+
// the physical key in the underlying store, through any namespaced views, so
|
|
11
|
+
// one principal's partition never answers another's and two views of the same
|
|
12
|
+
// partition share one entry.
|
|
13
|
+
//
|
|
14
|
+
// Freshness. Every write through a store connecta can observe — the SQL
|
|
15
|
+
// drivers, `memoryStorage()`, and namespaced views over them — tells each open
|
|
16
|
+
// memo over that store, in this isolate, that its key is changing: before the
|
|
17
|
+
// write is submitted and again when it settles. The memo drops that key, and
|
|
18
|
+
// a read of it already in flight is not recorded, since it may or may not
|
|
19
|
+
// include the write. So a write by this request, or by any other request in
|
|
20
|
+
// this isolate, is never hidden by a memoized value. A store whose writes
|
|
21
|
+
// connecta cannot observe (a custom adapter, or a built-in one whose methods
|
|
22
|
+
// were replaced) is never memoized. Writes in another isolate or process, and
|
|
23
|
+
// expiry within the request, are invisible here: a memoized key can lag them
|
|
24
|
+
// for the rest of the request. So no read that gates authority, sends a
|
|
25
|
+
// credential or decides a write may be memoized; a call site that memoizes
|
|
26
|
+
// must say why that staleness is safe.
|
|
27
|
+
//
|
|
28
|
+
// A `fresh` read bypasses the lookup and records what it saw, so a memoized
|
|
29
|
+
// reader never returns a value older than this request's latest fresh read of
|
|
30
|
+
// the same key. Only the latest read of a key this request started records:
|
|
31
|
+
// an earlier read that settles later, possibly from before another isolate's
|
|
32
|
+
// write, never replaces a newer observation.
|
|
33
|
+
//
|
|
34
|
+
// The registry of open memos holds weak references, so a request owner that is
|
|
35
|
+
// never closed cannot pin its memo. A runtime without `WeakRef` and
|
|
36
|
+
// `FinalizationRegistry` (workerd before compatibility date 2025-05-05 without
|
|
37
|
+
// `enable_weak_ref`) memoizes nothing: every read goes to storage.
|
|
38
|
+
import { Effect } from "effect";
|
|
39
|
+
const roots = new WeakMap();
|
|
40
|
+
const observed = new WeakMap();
|
|
41
|
+
/**
|
|
42
|
+
* Open memos holding or reading entries of each store. Weak, so a request
|
|
43
|
+
* owner that is never closed still lets its memo be collected with it.
|
|
44
|
+
*/
|
|
45
|
+
const open = new WeakMap();
|
|
46
|
+
function eachOpen(store, visit) {
|
|
47
|
+
const memos = open.get(store);
|
|
48
|
+
if (!memos)
|
|
49
|
+
return;
|
|
50
|
+
for (const ref of memos) {
|
|
51
|
+
const memo = ref.deref();
|
|
52
|
+
if (memo)
|
|
53
|
+
visit(memo);
|
|
54
|
+
else
|
|
55
|
+
memos.delete(ref);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** A write to `key` of `store` is about to be submitted, or has settled. */
|
|
59
|
+
export function noteStorageWrite(store, key) {
|
|
60
|
+
eachOpen(store, (memo) => memo.changed(store, key));
|
|
61
|
+
}
|
|
62
|
+
/** Wrap one write so open memos see it begin and settle, however it ends. */
|
|
63
|
+
export function observedWrite(store, key, write) {
|
|
64
|
+
return Effect.suspend(() => {
|
|
65
|
+
noteStorageWrite(store, key);
|
|
66
|
+
return write;
|
|
67
|
+
}).pipe(Effect.ensuring(Effect.sync(() => noteStorageWrite(store, key))));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Declare that every write through `storage` reaches `noteStorageWrite` for
|
|
71
|
+
* `root`, as long as its methods stay the ones it has now; or that `storage`
|
|
72
|
+
* stores `key` at `prefix + key` in another store, observed only while that
|
|
73
|
+
* one is.
|
|
74
|
+
*/
|
|
75
|
+
export function observeStorageWrites(storage, layer) {
|
|
76
|
+
if ("root" in layer)
|
|
77
|
+
roots.set(layer.root, storage);
|
|
78
|
+
observed.set(storage, {
|
|
79
|
+
own: {
|
|
80
|
+
get: storage.get,
|
|
81
|
+
set: storage.set,
|
|
82
|
+
delete: storage.delete,
|
|
83
|
+
list: storage.list,
|
|
84
|
+
compareAndSet: storage.compareAndSet,
|
|
85
|
+
},
|
|
86
|
+
layer,
|
|
87
|
+
});
|
|
88
|
+
return storage;
|
|
89
|
+
}
|
|
90
|
+
/** The underlying store and key a read of `key` reaches, when every layer is observed. */
|
|
91
|
+
function physical(storage, key) {
|
|
92
|
+
const entry = observed.get(storage);
|
|
93
|
+
if (!entry)
|
|
94
|
+
return undefined;
|
|
95
|
+
const { own, layer } = entry;
|
|
96
|
+
if (own.get !== storage.get ||
|
|
97
|
+
own.set !== storage.set ||
|
|
98
|
+
own.delete !== storage.delete ||
|
|
99
|
+
own.list !== storage.list ||
|
|
100
|
+
own.compareAndSet !== storage.compareAndSet)
|
|
101
|
+
return undefined;
|
|
102
|
+
return "root" in layer ? { store: layer.root, key } : physical(layer.storage, layer.prefix + key);
|
|
103
|
+
}
|
|
104
|
+
const weakReferences = () => typeof WeakRef === "function" && typeof FinalizationRegistry === "function";
|
|
105
|
+
export class RequestReads {
|
|
106
|
+
closed = false;
|
|
107
|
+
stores = new Map();
|
|
108
|
+
ref = weakReferences() ? new WeakRef(this) : undefined;
|
|
109
|
+
entriesFor(store) {
|
|
110
|
+
let entries = this.stores.get(store);
|
|
111
|
+
if (!entries) {
|
|
112
|
+
entries = { values: new Map(), versions: new Map(), started: new Map() };
|
|
113
|
+
this.stores.set(store, entries);
|
|
114
|
+
eachOpen(store, () => { }); // prune collected memos
|
|
115
|
+
let memos = open.get(store);
|
|
116
|
+
if (!memos)
|
|
117
|
+
open.set(store, (memos = new Set()));
|
|
118
|
+
memos.add(this.ref);
|
|
119
|
+
}
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
/** A write notice: forget `key` and refuse any read of it in flight. */
|
|
123
|
+
changed(store, key) {
|
|
124
|
+
const entries = this.stores.get(store);
|
|
125
|
+
if (!entries)
|
|
126
|
+
return;
|
|
127
|
+
entries.values.delete(key);
|
|
128
|
+
entries.versions.set(key, (entries.versions.get(key) ?? 0) + 1);
|
|
129
|
+
}
|
|
130
|
+
/** A memoized answer, or how to record the value a storage read returns. */
|
|
131
|
+
lookup(storage, key, fresh) {
|
|
132
|
+
const address = this.closed || !this.ref ? undefined : physical(storage, key);
|
|
133
|
+
if (!address)
|
|
134
|
+
return undefined;
|
|
135
|
+
const entries = this.entriesFor(address.store);
|
|
136
|
+
if (!fresh && entries.values.has(address.key))
|
|
137
|
+
return { hit: true, value: entries.values.get(address.key) };
|
|
138
|
+
const before = entries.versions.get(address.key) ?? 0;
|
|
139
|
+
const read = (entries.started.get(address.key) ?? 0) + 1;
|
|
140
|
+
entries.started.set(address.key, read);
|
|
141
|
+
return {
|
|
142
|
+
hit: false,
|
|
143
|
+
record: (value) => {
|
|
144
|
+
if (!this.closed &&
|
|
145
|
+
entries.started.get(address.key) === read &&
|
|
146
|
+
(entries.versions.get(address.key) ?? 0) === before)
|
|
147
|
+
entries.values.set(address.key, value);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `load` reads `key` from `storage`. Without `fresh`, a value this request
|
|
153
|
+
* already read, with no observed write to that key since, answers instead.
|
|
154
|
+
*/
|
|
155
|
+
read(storage, key, load, fresh = false) {
|
|
156
|
+
return Effect.suspend(() => {
|
|
157
|
+
const found = this.lookup(storage, key, fresh);
|
|
158
|
+
if (!found)
|
|
159
|
+
return load;
|
|
160
|
+
if (found.hit)
|
|
161
|
+
return Effect.succeed(found.value);
|
|
162
|
+
return Effect.tap(load, (value) => Effect.sync(() => found.record(value)));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Prefetch only keys whose callers already permit request-local staleness. */
|
|
166
|
+
prefetch(requests, load) {
|
|
167
|
+
return Effect.suspend(() => {
|
|
168
|
+
const pending = requests.map(({ storage, key }) => ({
|
|
169
|
+
address: physical(storage, key),
|
|
170
|
+
found: this.lookup(storage, key, false),
|
|
171
|
+
}));
|
|
172
|
+
const root = pending[0]?.address?.store;
|
|
173
|
+
const storage = root && roots.get(root);
|
|
174
|
+
if (!storage || pending.some(({ address, found }) => !found || address?.store !== root))
|
|
175
|
+
return Effect.void;
|
|
176
|
+
const misses = pending.filter(({ found }) => found && !found.hit);
|
|
177
|
+
if (!misses.length)
|
|
178
|
+
return Effect.void;
|
|
179
|
+
return Effect.map(load(storage, misses.map(({ address }) => address.key)), (values) => {
|
|
180
|
+
misses.forEach(({ found }, i) => {
|
|
181
|
+
if (found && !found.hit)
|
|
182
|
+
found.record(values[i] ?? null);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/** `read` for a Promise-shaped caller, without another Effect edge. */
|
|
188
|
+
async readPromise(storage, key, load, fresh = false) {
|
|
189
|
+
const found = this.lookup(storage, key, fresh);
|
|
190
|
+
if (!found)
|
|
191
|
+
return load();
|
|
192
|
+
if (found.hit)
|
|
193
|
+
return found.value;
|
|
194
|
+
const value = await load();
|
|
195
|
+
found.record(value);
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
close() {
|
|
199
|
+
if (this.closed)
|
|
200
|
+
return;
|
|
201
|
+
this.closed = true;
|
|
202
|
+
for (const store of this.stores.keys())
|
|
203
|
+
if (this.ref)
|
|
204
|
+
open.get(store)?.delete(this.ref);
|
|
205
|
+
this.stores.clear();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// One owner per existing upstream request identity. Facts survive revocation;
|
|
2
2
|
// retaining them never reconstitutes a call capability or a live operation.
|
|
3
3
|
import { Context, Exit, Scope } from "effect";
|
|
4
|
+
import { RequestReads } from "./request-reads.js";
|
|
4
5
|
import { detach } from "./run.js";
|
|
5
6
|
export class RequestOwner {
|
|
6
7
|
identity;
|
|
@@ -18,6 +19,7 @@ export class RequestOwner {
|
|
|
18
19
|
secrets: undefined,
|
|
19
20
|
invocations: { entered: [], authFailures: new Map(), retryReads: new Map() },
|
|
20
21
|
handoffs: undefined,
|
|
22
|
+
reads: undefined,
|
|
21
23
|
};
|
|
22
24
|
constructor(identity) {
|
|
23
25
|
this.identity = identity;
|
|
@@ -51,6 +53,7 @@ export class RequestOwner {
|
|
|
51
53
|
}
|
|
52
54
|
this.calls.clear();
|
|
53
55
|
this.entries.clear();
|
|
56
|
+
this.retained.reads?.close();
|
|
54
57
|
attempt(() => this.controller.abort(new Error("The request ended.")));
|
|
55
58
|
for (const finalizer of this.finalizers.splice(0).reverse())
|
|
56
59
|
attempt(finalizer);
|
|
@@ -61,6 +64,19 @@ export class RequestOwner {
|
|
|
61
64
|
throw new AggregateError(failures, `Closing the request: ${failures.length} cleanup step(s) failed.`);
|
|
62
65
|
}
|
|
63
66
|
}
|
|
67
|
+
/** This request's read memo; a closed request's memo answers nothing. */
|
|
68
|
+
export function requestReads(identity) {
|
|
69
|
+
return ownerReads(requestOwner(identity));
|
|
70
|
+
}
|
|
71
|
+
export function ownerReads(owner) {
|
|
72
|
+
const retained = owner.retained;
|
|
73
|
+
if (!retained.reads) {
|
|
74
|
+
retained.reads = new RequestReads();
|
|
75
|
+
if (!owner.open)
|
|
76
|
+
retained.reads.close();
|
|
77
|
+
}
|
|
78
|
+
return retained.reads;
|
|
79
|
+
}
|
|
64
80
|
export class RequestScope extends Context.Service()("connecta/request/Scope") {
|
|
65
81
|
}
|
|
66
82
|
const requests = new WeakMap();
|
package/dist/runtime/storage.js
CHANGED
|
@@ -32,6 +32,18 @@ export function storageGet(key) {
|
|
|
32
32
|
catch: (error) => error,
|
|
33
33
|
}));
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Read several keys, in one round trip when the store can; otherwise one
|
|
37
|
+
* `get` after another. Values come back in `keys` order.
|
|
38
|
+
*/
|
|
39
|
+
export function storageGetMany(keys) {
|
|
40
|
+
return Storage.use((storage) => {
|
|
41
|
+
const native = getNativeStorage(storage);
|
|
42
|
+
if (native?.getMany)
|
|
43
|
+
return native.getMany(keys);
|
|
44
|
+
return Effect.forEach(keys, (key) => storageGet(key).pipe(Effect.provideService(Storage, storage)));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
35
47
|
/** Write one key, with the adapter's optional TTL. */
|
|
36
48
|
export function storageSet(key, value, options) {
|
|
37
49
|
return Storage.use((storage) => getNativeStorage(storage)?.set(key, value, options) ??
|