@pithy-sh/core 0.1.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/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `pithy add controlplane` surface — **not a barrel over `@pithy-sh/core`.**
|
|
6
|
+
*
|
|
7
|
+
* `pithy add` writes `import { <name> } from "<package>/src/index";` into a Worker's `pithy.config.ts`,
|
|
8
|
+
* so a capability has to be reachable at its package's `src/index`. Core ships one capability — the
|
|
9
|
+
* `control-plane` seam — and this file exists to name it and nothing else.
|
|
10
|
+
*
|
|
11
|
+
* Everything else in core stays deep-imported (`@pithy-sh/core/src/error/pithyError`,
|
|
12
|
+
* `@pithy-sh/core/src/data/codecs`), which is the rule and which nothing here relaxes. The exports
|
|
13
|
+
* below are the ones an adopter's own code plausibly touches: the factory their config calls, the
|
|
14
|
+
* config schema behind it, the table and row schemas they may read against, the audit actions their
|
|
15
|
+
* queries filter on, and — because the seam is MIT and never gated — the minting side, so "build your
|
|
16
|
+
* own management client" is a real option rather than a slogan.
|
|
17
|
+
*
|
|
18
|
+
* `@pithy-sh/storage/src/index.ts` documents itself the same way, for the same reason.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { type ControlPlaneAuditAction, ControlPlaneAuditActions } from "./controlPlane/audit/actions";
|
|
22
|
+
export {
|
|
23
|
+
CONTROL_PLANE_KV_BINDING,
|
|
24
|
+
CONTROLPLANE_MIGRATION_ORDER,
|
|
25
|
+
type ControlPlaneCapability,
|
|
26
|
+
type ControlPlaneOptions,
|
|
27
|
+
controlplane,
|
|
28
|
+
isControlPlaneCapability,
|
|
29
|
+
} from "./controlPlane/capability";
|
|
30
|
+
export { ControlPlaneConfig, type ControlPlaneConfigInput } from "./controlPlane/config/config";
|
|
31
|
+
export { ControlPlaneContext } from "./controlPlane/context";
|
|
32
|
+
export { ControlPlaneConnection, Ed25519PublicJwk, RegisteredKey } from "./controlPlane/data/connection";
|
|
33
|
+
export {
|
|
34
|
+
CONTROL_PLANE_CONNECTIONS_TABLE,
|
|
35
|
+
type ControlPlaneDatabase,
|
|
36
|
+
controlPlaneDatabase,
|
|
37
|
+
} from "./controlPlane/data/tables";
|
|
38
|
+
// The manifest a management client parses, and the shape a capability declares its admin surface in.
|
|
39
|
+
// Both are exported for the same reason the minting side is: writing your own client has to be a real
|
|
40
|
+
// option, and a client that had to re-derive this contract from the docs would drift from it.
|
|
41
|
+
export {
|
|
42
|
+
AdminRoute,
|
|
43
|
+
type AdminRouteMethod,
|
|
44
|
+
CapabilityDeclaration,
|
|
45
|
+
CapabilityDescriptor,
|
|
46
|
+
ControlPlaneManifest,
|
|
47
|
+
type ControlPlaneManifestWire,
|
|
48
|
+
} from "./controlPlane/discovery/adminRoute";
|
|
49
|
+
// The configured-fact seam: a capability states a decision an adopter already made — what this project
|
|
50
|
+
// bills — and a management client respects it rather than guessing at it (#422). Exported whole, for the
|
|
51
|
+
// same reason health is: a client reads the values through the declarations that travel with them, and
|
|
52
|
+
// one that had to re-derive the contract would drift from it.
|
|
53
|
+
export {
|
|
54
|
+
type CapabilityManifestConfig,
|
|
55
|
+
type CapabilityManifestConfigInput,
|
|
56
|
+
defineManifestConfig,
|
|
57
|
+
ManifestConfigKey,
|
|
58
|
+
ManifestConfigValue,
|
|
59
|
+
ManifestConfigValues,
|
|
60
|
+
type NamedConfigValue,
|
|
61
|
+
namedConfigValues,
|
|
62
|
+
} from "./controlPlane/discovery/configuration";
|
|
63
|
+
export { type AdminRouteDrift, missingAdminRoutes } from "./controlPlane/discovery/drift";
|
|
64
|
+
// The health seam: a capability declares a bounded set of scalars about its own state, and the manifest
|
|
65
|
+
// carries them — so a client renders "3 secrets need rotating" from the read it already made rather than
|
|
66
|
+
// spending a round trip per number (#317). Exported whole, because a client renders the values through
|
|
67
|
+
// the declarations that travel with them.
|
|
68
|
+
// Split across two modules and exported as one surface: the seam a capability implements needs Hono
|
|
69
|
+
// and the `Capability` contract, and the vocabulary a client renders needs neither. A browser importing
|
|
70
|
+
// the second must not compile the first (#430).
|
|
71
|
+
export {
|
|
72
|
+
type CapabilityHealth,
|
|
73
|
+
type CapabilityHealthInput,
|
|
74
|
+
type CapabilityHealthSource,
|
|
75
|
+
capabilityHealthSources,
|
|
76
|
+
defineCapabilityHealth,
|
|
77
|
+
readCapabilityHealth,
|
|
78
|
+
} from "./controlPlane/discovery/health";
|
|
79
|
+
export {
|
|
80
|
+
CapabilityHealthReport,
|
|
81
|
+
type CapabilityHealthWire,
|
|
82
|
+
HealthCountNominal,
|
|
83
|
+
HealthNominal,
|
|
84
|
+
type HealthStanding,
|
|
85
|
+
HealthSummary,
|
|
86
|
+
HealthSummaryKey,
|
|
87
|
+
HealthSummaryValue,
|
|
88
|
+
HealthValueCost,
|
|
89
|
+
HealthValueKind,
|
|
90
|
+
healthAttention,
|
|
91
|
+
healthReport,
|
|
92
|
+
healthWire,
|
|
93
|
+
type NamedHealthValue,
|
|
94
|
+
namedHealthValues,
|
|
95
|
+
standingOf,
|
|
96
|
+
} from "./controlPlane/discovery/healthSummary";
|
|
97
|
+
export { requireControlPlane } from "./controlPlane/http/guard";
|
|
98
|
+
export {
|
|
99
|
+
ANY_VERIFIED_CALLER,
|
|
100
|
+
type ControlPlaneRequirement,
|
|
101
|
+
ControlPlaneScope,
|
|
102
|
+
KEYS_ROTATE_SCOPE,
|
|
103
|
+
MANIFEST_READ_SCOPE,
|
|
104
|
+
SEAM_SCOPES,
|
|
105
|
+
} from "./controlPlane/scope/scope";
|
|
106
|
+
export { exportPublicJwk, type MintControlPlaneToken, mintControlPlaneToken } from "./controlPlane/token/mint";
|
|
107
|
+
// The wire contract, from the module that imports nothing: a management client is often a browser
|
|
108
|
+
// calling the adopter's Worker directly, and it must be able to name these headers without dragging
|
|
109
|
+
// the verifier — and WebCrypto with it — into a DOM-typed build.
|
|
110
|
+
export {
|
|
111
|
+
CONTROL_PLANE_HEADER,
|
|
112
|
+
CONTROL_PLANE_VERSION_CREATED_HEADER,
|
|
113
|
+
CONTROL_PLANE_VERSION_HEADER,
|
|
114
|
+
type WorkerBuild,
|
|
115
|
+
workerBuildChanged,
|
|
116
|
+
} from "./controlPlane/wire";
|
package/src/kv/kv.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { KVNamespace, KVNamespacePutOptions } from "@cloudflare/workers-types";
|
|
5
|
+
import type { z } from "zod";
|
|
6
|
+
import { InternalError, messageOf, NotFoundError } from "../error/pithyError";
|
|
7
|
+
import type { Logger } from "../logger/logger";
|
|
8
|
+
import { noopLogger } from "../logger/logger";
|
|
9
|
+
|
|
10
|
+
/** Cloudflare's hard limit on a KV entry's metadata, in bytes. */
|
|
11
|
+
export const KV_METADATA_MAX_BYTES = 1024;
|
|
12
|
+
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
|
|
15
|
+
function byteLength(value: string): number {
|
|
16
|
+
return encoder.encode(value).length;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wrap a metadata schema with KV's size limit.
|
|
21
|
+
*
|
|
22
|
+
* WARNING: metadata travels with **every** `list` entry and is capped at
|
|
23
|
+
* {@link KV_METADATA_MAX_BYTES} bytes serialized. It is for small, list-time
|
|
24
|
+
* fields you want without reading each value — a status flag, a type, a
|
|
25
|
+
* timestamp — never a payload. This refinement fails a value that would exceed
|
|
26
|
+
* the cap, turning a late KV rejection into an early, clear validation error.
|
|
27
|
+
*/
|
|
28
|
+
export function kvMetadata<T extends z.ZodType>(schema: T) {
|
|
29
|
+
return schema.refine((value) => byteLength(JSON.stringify(value)) <= KV_METADATA_MAX_BYTES, {
|
|
30
|
+
message: `KV metadata exceeds ${KV_METADATA_MAX_BYTES} bytes serialized. Keep it tiny — it rides on every list entry.`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Configuration for a {@link TypedKv} store. */
|
|
35
|
+
export interface TypedKvConfig<V extends z.ZodType, K extends z.ZodObject, M extends z.ZodType> {
|
|
36
|
+
/** Capability namespace, the fixed first key segment (e.g. `auth`, `assets`). */
|
|
37
|
+
prefix: string;
|
|
38
|
+
/**
|
|
39
|
+
* The ordered key segments after the prefix, as a Zod object. Declared field
|
|
40
|
+
* order **is** key order, and each segment is validated before a key is built
|
|
41
|
+
* (e.g. `z.object({ assetType: z.enum([...]), uuid: z.uuid() })` →
|
|
42
|
+
* `assets:photo:<uuid>`). Segment values must serialize to a string that does
|
|
43
|
+
* not contain the separator.
|
|
44
|
+
*/
|
|
45
|
+
key: K;
|
|
46
|
+
/** Schema validating the stored value on every read and write. */
|
|
47
|
+
value: V;
|
|
48
|
+
/** Optional schema for metadata. Wrap with {@link kvMetadata} to bound its size. */
|
|
49
|
+
metadata?: M;
|
|
50
|
+
/**
|
|
51
|
+
* Derive metadata from the value, making the value the single source of truth.
|
|
52
|
+
*
|
|
53
|
+
* When set (requires `metadata`), `put` computes metadata from the value
|
|
54
|
+
* automatically — callers need not pass it — and `list` **self-heals**: any
|
|
55
|
+
* entry whose metadata is missing has its value read, metadata re-derived, and
|
|
56
|
+
* written back (value bytes and expiration preserved). This protects against
|
|
57
|
+
* external edits — notably the Cloudflare dashboard, which drops metadata when
|
|
58
|
+
* a value is hand-edited. Note: self-heal performs a read and a write per
|
|
59
|
+
* missing entry during `list`.
|
|
60
|
+
*/
|
|
61
|
+
deriveMetadata?: (value: z.output<V>) => z.input<M>;
|
|
62
|
+
/** Key segment separator. Defaults to `:`. */
|
|
63
|
+
separator?: string;
|
|
64
|
+
/** Default TTL, in seconds, applied to every `put` unless overridden. KV's floor is 60. */
|
|
65
|
+
ttlSeconds?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate a {@link TypedKvConfig} at construction — config is a boundary, so it
|
|
70
|
+
* is checked, not trusted. Catches the misconfigurations the type system can't
|
|
71
|
+
* (or won't, for JS callers and casts): an empty namespace, a separator that
|
|
72
|
+
* collides with the prefix, a key with no segments, `deriveMetadata` without a
|
|
73
|
+
* `metadata` schema to validate against, or a non-positive default TTL.
|
|
74
|
+
*/
|
|
75
|
+
export function assertValidConfig(config: TypedKvConfig<z.ZodType, z.ZodObject, z.ZodType>): void {
|
|
76
|
+
// A bad TypedKv config is a capability-author mistake caught at construction — an internal
|
|
77
|
+
// misconfiguration (500), never a client-facing fault. The message names the exact problem.
|
|
78
|
+
if (config.prefix.length === 0) {
|
|
79
|
+
throw new InternalError({ message: "TypedKv config: prefix must be a non-empty capability namespace." });
|
|
80
|
+
}
|
|
81
|
+
const separator = config.separator ?? ":";
|
|
82
|
+
if (separator.length === 0) {
|
|
83
|
+
throw new InternalError({ message: "TypedKv config: separator must be a non-empty string." });
|
|
84
|
+
}
|
|
85
|
+
if (config.prefix.includes(separator)) {
|
|
86
|
+
throw new InternalError({
|
|
87
|
+
message: `TypedKv config: prefix "${config.prefix}" must not contain the separator "${separator}".`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (Object.keys(config.key.shape).length === 0) {
|
|
91
|
+
throw new InternalError({ message: "TypedKv config: key must declare at least one segment." });
|
|
92
|
+
}
|
|
93
|
+
if (config.deriveMetadata !== undefined && config.metadata === undefined) {
|
|
94
|
+
throw new InternalError({
|
|
95
|
+
message: "TypedKv config: deriveMetadata requires a metadata schema to validate the derived value.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (config.ttlSeconds !== undefined && config.ttlSeconds <= 0) {
|
|
99
|
+
throw new InternalError({ message: "TypedKv config: ttlSeconds must be positive." });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Construction options for a {@link TypedKv} store beyond its config. */
|
|
104
|
+
export interface TypedKvOptions {
|
|
105
|
+
/**
|
|
106
|
+
* The logger a store routes its observable best-effort failures through — notably the `list`
|
|
107
|
+
* self-heal metadata write-back, which must not fail the surrounding list but should be seen.
|
|
108
|
+
* `createBackend` passes the per-request `c.var.log`; defaults to the no-op logger.
|
|
109
|
+
*/
|
|
110
|
+
logger?: Logger;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Per-call options for {@link TypedKv.put}. */
|
|
114
|
+
export interface PutOptions<M extends z.ZodType> {
|
|
115
|
+
/** TTL, in seconds, for this value. Overrides the store default. KV's floor is 60. */
|
|
116
|
+
ttlSeconds?: number;
|
|
117
|
+
/** Metadata to store. Validated against the schema; overrides `deriveMetadata` for this write. */
|
|
118
|
+
metadata?: z.input<M>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Options for {@link TypedKv.list}. */
|
|
122
|
+
export interface ListOptions<K extends z.ZodObject> {
|
|
123
|
+
/**
|
|
124
|
+
* A leading run of key segments to scope the listing — `{ assetType: "photo" }`
|
|
125
|
+
* lists `assets:photo:*`. Must be contiguous from the first segment; omit to
|
|
126
|
+
* list the whole namespace.
|
|
127
|
+
*/
|
|
128
|
+
prefix?: Partial<z.input<K>>;
|
|
129
|
+
/** Maximum entries to return in this page. */
|
|
130
|
+
limit?: number;
|
|
131
|
+
/** Cursor from a previous page's result. */
|
|
132
|
+
cursor?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One entry from {@link TypedKv.list}: the key, its parsed segments, and validated metadata. */
|
|
136
|
+
export interface ListEntry<K extends z.ZodObject, M extends z.ZodType> {
|
|
137
|
+
/** The full physical KV key. */
|
|
138
|
+
name: string;
|
|
139
|
+
/** The key's segments, split back out by name. */
|
|
140
|
+
key: Record<keyof z.input<K>, string>;
|
|
141
|
+
/** Validated metadata, or `null` when none was stored or no metadata schema is configured. */
|
|
142
|
+
metadata: z.output<M> | null;
|
|
143
|
+
/** Absolute expiration time (seconds since epoch), if the entry has a TTL. */
|
|
144
|
+
expiration?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A page of {@link TypedKv.list} results. */
|
|
148
|
+
export interface ListResult<K extends z.ZodObject, M extends z.ZodType> {
|
|
149
|
+
/** The entries on this page. */
|
|
150
|
+
keys: ListEntry<K, M>[];
|
|
151
|
+
/** Cursor for the next page, or `undefined` when the listing is complete. */
|
|
152
|
+
cursor?: string;
|
|
153
|
+
/** Whether this page completed the listing. */
|
|
154
|
+
listComplete: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Typed, validated access to one Workers KV namespace.
|
|
159
|
+
*
|
|
160
|
+
* Keys are structured: a fixed capability `prefix` followed by ordered,
|
|
161
|
+
* Zod-validated segments — so a single store models `pages:<uuid>` or
|
|
162
|
+
* `assets:<assetType>:<uuid>` and `list` can scope by any leading run of those
|
|
163
|
+
* segments. Values are validated on the way in and out; there is no untyped
|
|
164
|
+
* `JSON.parse`. Metadata is validated too and rides along with `list`; with
|
|
165
|
+
* `deriveMetadata` the value is the source of truth and `list` rebuilds metadata
|
|
166
|
+
* that external edits have dropped.
|
|
167
|
+
*/
|
|
168
|
+
export class TypedKv<V extends z.ZodType, K extends z.ZodObject, M extends z.ZodType = z.ZodNever> {
|
|
169
|
+
readonly #namespace: KVNamespace;
|
|
170
|
+
readonly #prefix: string;
|
|
171
|
+
readonly #keySchema: K;
|
|
172
|
+
readonly #valueSchema: V;
|
|
173
|
+
readonly #metadataSchema: M | undefined;
|
|
174
|
+
readonly #deriveMetadata: ((value: z.output<V>) => z.input<M>) | undefined;
|
|
175
|
+
readonly #separator: string;
|
|
176
|
+
readonly #defaultTtlSeconds: number | undefined;
|
|
177
|
+
readonly #segmentNames: string[];
|
|
178
|
+
readonly #logger: Logger;
|
|
179
|
+
|
|
180
|
+
constructor(namespace: KVNamespace, config: TypedKvConfig<V, K, M>, options?: TypedKvOptions) {
|
|
181
|
+
assertValidConfig(config);
|
|
182
|
+
this.#namespace = namespace;
|
|
183
|
+
this.#prefix = config.prefix;
|
|
184
|
+
this.#keySchema = config.key;
|
|
185
|
+
this.#valueSchema = config.value;
|
|
186
|
+
this.#metadataSchema = config.metadata;
|
|
187
|
+
this.#deriveMetadata = config.deriveMetadata;
|
|
188
|
+
this.#separator = config.separator ?? ":";
|
|
189
|
+
this.#defaultTtlSeconds = config.ttlSeconds;
|
|
190
|
+
// Declared field order is key order.
|
|
191
|
+
this.#segmentNames = Object.keys(config.key.shape);
|
|
192
|
+
this.#logger = options?.logger ?? noopLogger;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Build the full physical KV key (name) from validated segments. */
|
|
196
|
+
name(key: z.input<K>): string {
|
|
197
|
+
const parsed = this.#keySchema.parse(key) as Record<string, unknown>;
|
|
198
|
+
const segments = this.#segmentNames.map((segment) => this.#segment(segment, parsed[segment]));
|
|
199
|
+
return [this.#prefix, ...segments].join(this.#separator);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read and validate the value at `key`. Returns `null` on a miss. The stored
|
|
204
|
+
* JSON is parsed and validated against the schema before it is returned.
|
|
205
|
+
*/
|
|
206
|
+
async get(key: z.input<K>): Promise<z.output<V> | null> {
|
|
207
|
+
const raw = await this.#namespace.get(this.name(key), "text");
|
|
208
|
+
return raw === null ? null : this.#parseValue(raw);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Read the value at `key` together with its validated metadata. `null` on a miss. */
|
|
212
|
+
async getWithMetadata(key: z.input<K>): Promise<{ value: z.output<V>; metadata: z.output<M> | null } | null> {
|
|
213
|
+
const result = await this.#namespace.getWithMetadata(this.name(key), "text");
|
|
214
|
+
if (result.value === null) return null;
|
|
215
|
+
return { value: this.#parseValue(result.value), metadata: this.#parseMetadata(result.metadata) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Validate `value` (and any `metadata`) against their schemas, then store the
|
|
220
|
+
* value as JSON under the namespaced key. With `deriveMetadata`, metadata is
|
|
221
|
+
* computed from the value unless overridden per call. Throws (before writing)
|
|
222
|
+
* on an invalid value, invalid metadata, or metadata over
|
|
223
|
+
* {@link KV_METADATA_MAX_BYTES}. A per-call `ttlSeconds` overrides the default.
|
|
224
|
+
*/
|
|
225
|
+
async put(key: z.input<K>, value: z.input<V>, options: PutOptions<M> = {}): Promise<void> {
|
|
226
|
+
const validated = this.#valueSchema.parse(value) as z.output<V>;
|
|
227
|
+
const putOptions: KVNamespacePutOptions = {};
|
|
228
|
+
|
|
229
|
+
const ttlSeconds = options.ttlSeconds ?? this.#defaultTtlSeconds;
|
|
230
|
+
if (ttlSeconds !== undefined) putOptions.expirationTtl = ttlSeconds;
|
|
231
|
+
|
|
232
|
+
const metadata = this.#resolveMetadata(validated, options.metadata);
|
|
233
|
+
if (metadata !== undefined) putOptions.metadata = metadata;
|
|
234
|
+
|
|
235
|
+
await this.#namespace.put(this.name(key), JSON.stringify(validated), putOptions);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Partially update the value at `key`: read it, merge `changes` over the
|
|
240
|
+
* current fields, validate the merged result, and write it back. Returns the
|
|
241
|
+
* merged value. Throws if the key does not exist — there is nothing to merge.
|
|
242
|
+
*
|
|
243
|
+
* Metadata stays correct: a `deriveMetadata` store recomputes it from the
|
|
244
|
+
* merged value; an explicit-metadata store preserves the current metadata
|
|
245
|
+
* (pass `metadata` to override). TTL resets to the store default unless
|
|
246
|
+
* `ttlSeconds` is given.
|
|
247
|
+
*
|
|
248
|
+
* WARNING: KV has no compare-and-set, so this read-modify-write is
|
|
249
|
+
* last-write-wins. Concurrent patches to the same key can lose updates — do
|
|
250
|
+
* not use it where atomicity matters.
|
|
251
|
+
*/
|
|
252
|
+
async patch(key: z.input<K>, changes: Partial<z.output<V>>, options: PutOptions<M> = {}): Promise<z.output<V>> {
|
|
253
|
+
const existing = await this.getWithMetadata(key);
|
|
254
|
+
if (existing === null) {
|
|
255
|
+
// Patching a key that isn't there is a genuine not-found, not a server fault.
|
|
256
|
+
throw new NotFoundError({ message: `Cannot patch ${this.name(key)}: no value to update.` });
|
|
257
|
+
}
|
|
258
|
+
const merged = this.#valueSchema.parse({ ...(existing.value as object), ...(changes as object) }) as z.output<V>;
|
|
259
|
+
const putOptions: PutOptions<M> = { ...options };
|
|
260
|
+
// Carry existing metadata forward only when it isn't recomputed from the value.
|
|
261
|
+
if (this.#deriveMetadata === undefined && options.metadata === undefined && existing.metadata !== null) {
|
|
262
|
+
putOptions.metadata = existing.metadata as z.input<M>;
|
|
263
|
+
}
|
|
264
|
+
await this.put(key, merged as unknown as z.input<V>, putOptions);
|
|
265
|
+
return merged;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Delete the value at `key`. A miss is a no-op. */
|
|
269
|
+
async delete(key: z.input<K>): Promise<void> {
|
|
270
|
+
await this.#namespace.delete(this.name(key));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* List entries under a leading run of key segments. Each entry carries its
|
|
275
|
+
* parsed key segments and validated metadata — no value reads, unless
|
|
276
|
+
* `deriveMetadata` is configured and an entry's metadata is missing, in which
|
|
277
|
+
* case it is rebuilt from the value and written back. Pass `options.prefix` to
|
|
278
|
+
* scope (e.g. `{ assetType: "photo" }`); omit to list the whole namespace.
|
|
279
|
+
*/
|
|
280
|
+
async list(options: ListOptions<K> = {}): Promise<ListResult<K, M>> {
|
|
281
|
+
const result = await this.#namespace.list({
|
|
282
|
+
prefix: this.#listPrefix(options.prefix),
|
|
283
|
+
limit: options.limit,
|
|
284
|
+
cursor: options.cursor,
|
|
285
|
+
});
|
|
286
|
+
const keys = await Promise.all(
|
|
287
|
+
result.keys.map(async (entry) => ({
|
|
288
|
+
name: entry.name,
|
|
289
|
+
key: this.#splitKey(entry.name),
|
|
290
|
+
metadata: await this.#resolveListMetadata(entry),
|
|
291
|
+
expiration: entry.expiration,
|
|
292
|
+
})),
|
|
293
|
+
);
|
|
294
|
+
return {
|
|
295
|
+
keys,
|
|
296
|
+
cursor: result.list_complete ? undefined : result.cursor,
|
|
297
|
+
listComplete: result.list_complete,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Resolve one list entry's metadata: parse what is stored, else self-heal it
|
|
303
|
+
* from the value when `deriveMetadata` is configured. A single entry with
|
|
304
|
+
* corrupt or schema-incompatible metadata/value must not abort the whole
|
|
305
|
+
* listing — it degrades to `null` metadata while the key is still returned.
|
|
306
|
+
*/
|
|
307
|
+
async #resolveListMetadata(entry: {
|
|
308
|
+
name: string;
|
|
309
|
+
expiration?: number;
|
|
310
|
+
metadata?: unknown;
|
|
311
|
+
}): Promise<z.output<M> | null> {
|
|
312
|
+
try {
|
|
313
|
+
const stored = this.#parseMetadata(entry.metadata);
|
|
314
|
+
if (stored !== null) return stored;
|
|
315
|
+
const derive = this.#deriveMetadata;
|
|
316
|
+
const metadataSchema = this.#metadataSchema;
|
|
317
|
+
if (derive === undefined || metadataSchema === undefined) return null;
|
|
318
|
+
return await this.#rebuildMetadata(entry.name, entry.expiration, derive, metadataSchema);
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Resolve the metadata to store: explicit wins, else derive, else none. */
|
|
325
|
+
#resolveMetadata(value: z.output<V>, explicit: unknown): z.output<M> | undefined {
|
|
326
|
+
if (this.#metadataSchema === undefined) return undefined;
|
|
327
|
+
let candidate: unknown;
|
|
328
|
+
if (explicit !== undefined) candidate = explicit;
|
|
329
|
+
else if (this.#deriveMetadata !== undefined) candidate = this.#deriveMetadata(value);
|
|
330
|
+
else return undefined;
|
|
331
|
+
return this.#validateMetadata(this.#metadataSchema, candidate);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Read the value, re-derive metadata, and write it back — preserving value bytes and expiration. */
|
|
335
|
+
async #rebuildMetadata(
|
|
336
|
+
name: string,
|
|
337
|
+
expiration: number | undefined,
|
|
338
|
+
derive: (value: z.output<V>) => z.input<M>,
|
|
339
|
+
metadataSchema: M,
|
|
340
|
+
): Promise<z.output<M> | null> {
|
|
341
|
+
const raw = await this.#namespace.get(name, "text");
|
|
342
|
+
if (raw === null) return null;
|
|
343
|
+
const derived = derive(this.#parseValue(raw));
|
|
344
|
+
// If derive yields nothing, there is genuinely no metadata for this entry —
|
|
345
|
+
// skip the write so we never persist empty metadata (and never throw on a
|
|
346
|
+
// required schema). The entry stays metadata-less; nothing is written back.
|
|
347
|
+
if (derived === null || derived === undefined) return null;
|
|
348
|
+
const metadata = this.#validateMetadata(metadataSchema, derived);
|
|
349
|
+
// Persist best-effort. A near-expiry `expiration` (KV requires ≥60s ahead)
|
|
350
|
+
// or a transient write error must not fail the surrounding list — return the
|
|
351
|
+
// healed metadata regardless; a later list retries the write if it didn't land.
|
|
352
|
+
try {
|
|
353
|
+
const putOptions: KVNamespacePutOptions = { metadata };
|
|
354
|
+
if (expiration !== undefined) putOptions.expiration = expiration;
|
|
355
|
+
await this.#namespace.put(name, raw, putOptions);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
// Best-effort heal — the list still returns the in-memory metadata. But a persistent write
|
|
358
|
+
// failure means every list re-heals the same entry, so it is observable, not ignorable: route
|
|
359
|
+
// it through the logger. A near-expiry `expiration` (KV requires ≥60s ahead) is the usual cause.
|
|
360
|
+
this.#logger.warn("kv metadata heal write-back failed", { key: name, detail: messageOf(error) });
|
|
361
|
+
}
|
|
362
|
+
return metadata;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Decode a stored value: JSON-parse, then validate against the value schema. */
|
|
366
|
+
#parseValue(raw: string): z.output<V> {
|
|
367
|
+
return this.#valueSchema.parse(JSON.parse(raw)) as z.output<V>;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Validate a metadata candidate against the schema and the KV size cap. */
|
|
371
|
+
#validateMetadata(schema: M, candidate: unknown): z.output<M> {
|
|
372
|
+
const parsed = schema.parse(candidate) as z.output<M>;
|
|
373
|
+
const bytes = byteLength(JSON.stringify(parsed));
|
|
374
|
+
if (bytes > KV_METADATA_MAX_BYTES) {
|
|
375
|
+
throw new InternalError({
|
|
376
|
+
message: `KV metadata is ${bytes} bytes; the limit is ${KV_METADATA_MAX_BYTES}. Keep metadata small.`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return parsed;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Stringify and guard one key segment. */
|
|
383
|
+
#segment(name: string, value: unknown): string {
|
|
384
|
+
const segment = String(value);
|
|
385
|
+
if (segment.includes(this.#separator)) {
|
|
386
|
+
// `segment` is a caller-supplied runtime value; it goes in `detail` (internal), not the
|
|
387
|
+
// public `message`, so the HTTP encoder can't echo a caller's input back in a 500 body.
|
|
388
|
+
throw new InternalError({
|
|
389
|
+
message: `Key segment "${name}" must not contain the separator "${this.#separator}".`,
|
|
390
|
+
detail: `Offending value for "${name}": ${segment}`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return segment;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Build the physical prefix string for a `list`, enforcing a proper, contiguous leading run. */
|
|
397
|
+
#listPrefix(parts?: Partial<z.input<K>>): string {
|
|
398
|
+
const trailing = `${this.#prefix}${this.#separator}`;
|
|
399
|
+
if (!parts) return trailing;
|
|
400
|
+
|
|
401
|
+
const provided = this.#keySchema.partial().parse(parts) as Record<string, unknown>;
|
|
402
|
+
const present = this.#segmentNames.filter((name) => provided[name] !== undefined);
|
|
403
|
+
if (present.length === 0) return trailing;
|
|
404
|
+
|
|
405
|
+
if (present.length === this.#segmentNames.length) {
|
|
406
|
+
throw new InternalError({
|
|
407
|
+
message: "list prefix is a full key; use get() or getWithMetadata() for an exact key.",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const leading = this.#segmentNames.slice(0, present.length);
|
|
412
|
+
if (present.join(this.#separator) !== leading.join(this.#separator)) {
|
|
413
|
+
throw new InternalError({
|
|
414
|
+
message: `list prefix must be a leading run of key segments [${this.#segmentNames.join(", ")}]; got [${present.join(", ")}].`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const segments = leading.map((name) => this.#segment(name, provided[name]));
|
|
418
|
+
return `${[this.#prefix, ...segments].join(this.#separator)}${this.#separator}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Split a physical key back into its named segments. Safe: segments never contain the separator. */
|
|
422
|
+
#splitKey(name: string): Record<keyof z.input<K>, string> {
|
|
423
|
+
const body = name.slice(this.#prefix.length + this.#separator.length);
|
|
424
|
+
const segments = body.split(this.#separator);
|
|
425
|
+
const out = {} as Record<keyof z.input<K>, string>;
|
|
426
|
+
this.#segmentNames.forEach((segmentName, index) => {
|
|
427
|
+
out[segmentName as keyof z.input<K>] = segments[index] ?? "";
|
|
428
|
+
});
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Validate stored metadata against the schema; `null` when absent or unconfigured. */
|
|
433
|
+
#parseMetadata(raw: unknown): z.output<M> | null {
|
|
434
|
+
if (this.#metadataSchema === undefined || raw === null || raw === undefined) return null;
|
|
435
|
+
return this.#metadataSchema.parse(raw) as z.output<M>;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { KVNamespace } from "@cloudflare/workers-types";
|
|
5
|
+
import type { z } from "zod";
|
|
6
|
+
import type { Capability } from "../capability/capability";
|
|
7
|
+
import { type BindingGroup, bindingGroupsFrom, composeBindingGroups } from "../capability/compose";
|
|
8
|
+
import type { Logger } from "../logger/logger";
|
|
9
|
+
import { noopLogger } from "../logger/logger";
|
|
10
|
+
import { TypedKv, type TypedKvConfig } from "./kv";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One typed store within a KV namespace — a {@link TypedKvConfig} (prefix, key, value, metadata).
|
|
14
|
+
* The peer of a D1 table: the binding lives on the enclosing namespace, not the store.
|
|
15
|
+
*/
|
|
16
|
+
export type KvStoreSpec<
|
|
17
|
+
V extends z.ZodType = z.ZodType,
|
|
18
|
+
K extends z.ZodObject = z.ZodObject,
|
|
19
|
+
M extends z.ZodType = z.ZodType,
|
|
20
|
+
> = TypedKvConfig<V, K, M>;
|
|
21
|
+
|
|
22
|
+
/** A capability's stores for one namespace: store name → {@link KvStoreSpec}. */
|
|
23
|
+
export type KvStoreSpecMap = Record<string, KvStoreSpec>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One named KV namespace a capability registers — the peer of a {@link DatabaseSpec}: the `binding`
|
|
27
|
+
* it lives in plus the named stores it holds. `createBackend` serves each store as a live
|
|
28
|
+
* {@link TypedKv} on `c.var.kv.<namespace>.<store>`. Capabilities targeting the same namespace name
|
|
29
|
+
* merge their stores.
|
|
30
|
+
*/
|
|
31
|
+
export interface KvNamespaceSpec<Stores extends KvStoreSpecMap = KvStoreSpecMap> {
|
|
32
|
+
/** The KV binding name in the Worker env this namespace lives in. */
|
|
33
|
+
binding: string;
|
|
34
|
+
/** The named typed stores in this namespace (store name → spec). */
|
|
35
|
+
stores: Stores;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A capability's KV namespaces: namespace name → {@link KvNamespaceSpec}. */
|
|
39
|
+
export type KvNamespaceSpecMap = Record<string, KvNamespaceSpec>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The typed KV registry exposed on `c.var.kv`: namespace name → store name → live {@link TypedKv},
|
|
43
|
+
* preserving each store's value/key/metadata types — so `c.var.kv.cms.pages.get(...)` returns the
|
|
44
|
+
* page type, validated, and `c.var.kv.audit.pages` is a distinct store with no name conflict.
|
|
45
|
+
*/
|
|
46
|
+
export type KvRegistry<Namespaces extends KvNamespaceSpecMap> = {
|
|
47
|
+
[Ns in keyof Namespaces]: Namespaces[Ns] extends KvNamespaceSpec<infer Stores>
|
|
48
|
+
? { [S in keyof Stores]: Stores[S] extends KvStoreSpec<infer V, infer K, infer M> ? TypedKv<V, K, M> : never }
|
|
49
|
+
: never;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The merged namespaces: each namespace name → its binding and the union of every capability's stores. */
|
|
53
|
+
export type MergedKvNamespaces = Record<string, BindingGroup<KvStoreSpec>>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge every capability's `kvNamespaces` into one map keyed by namespace name. Capabilities
|
|
57
|
+
* targeting the same name union their stores; a store claimed twice in one namespace, or a namespace
|
|
58
|
+
* name bound to two bindings, throws at assembly. Mirrors `composeDatabases`.
|
|
59
|
+
*/
|
|
60
|
+
export function composeKv(capabilities: Capability[]): MergedKvNamespaces {
|
|
61
|
+
return composeBindingGroups<KvStoreSpec>(
|
|
62
|
+
capabilities,
|
|
63
|
+
(cap) => bindingGroupsFrom(cap.kvNamespaces, (ns) => ns.stores),
|
|
64
|
+
"KV namespace",
|
|
65
|
+
"store",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A registry of live stores keyed by namespace then store name; the per-request value of `c.var.kv`. */
|
|
70
|
+
type LiveStore = TypedKv<z.ZodType, z.ZodObject, z.ZodType>;
|
|
71
|
+
type LiveKvRegistry = Record<string, Record<string, LiveStore>>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the per-request KV registry from the merged namespaces and the request's env. Each store is
|
|
75
|
+
* constructed lazily on first access — a request that never touches `c.var.kv.cms.fragments` never
|
|
76
|
+
* builds it (mirroring Leed's lazy `KVProvider` getters) — and the namespace's binding is read from
|
|
77
|
+
* the per-request env at access time. `logger` is the request logger (`c.var.log`) each store routes
|
|
78
|
+
* its observable best-effort failures through — notably the `list` self-heal write-back; it defaults to
|
|
79
|
+
* the no-op logger so a store built outside a request still constructs.
|
|
80
|
+
*/
|
|
81
|
+
export function buildKvRegistry(
|
|
82
|
+
env: Record<string, unknown>,
|
|
83
|
+
namespaces: MergedKvNamespaces,
|
|
84
|
+
logger: Logger = noopLogger,
|
|
85
|
+
): LiveKvRegistry {
|
|
86
|
+
const registry: LiveKvRegistry = {};
|
|
87
|
+
for (const [namespace, group] of Object.entries(namespaces)) {
|
|
88
|
+
const stores: Record<string, LiveStore> = {};
|
|
89
|
+
for (const [name, spec] of Object.entries(group.items)) {
|
|
90
|
+
let instance: LiveStore | undefined;
|
|
91
|
+
Object.defineProperty(stores, name, {
|
|
92
|
+
enumerable: true,
|
|
93
|
+
get(): LiveStore {
|
|
94
|
+
if (!instance) instance = new TypedKv(env[group.binding] as KVNamespace, spec, { logger });
|
|
95
|
+
return instance;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
registry[namespace] = stores;
|
|
100
|
+
}
|
|
101
|
+
return registry;
|
|
102
|
+
}
|