@ontrails/cloudflare 0.2.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.
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Cloudflare KV resource for Trails.
3
+ *
4
+ * `cloudflareKv` authors an ordinary `resource()` definition wrapping a KV
5
+ * namespace binding. On Workers, the env bridge (see `../env.ts`) resolves
6
+ * the binding per env so trails read live KV through `flags.from(ctx)`. In
7
+ * tests, the in-memory mock keeps `testAll(app)` configuration-free.
8
+ */
9
+
10
+ import { InternalError, Result, resource } from '@ontrails/core';
11
+ import type { Resource } from '@ontrails/core';
12
+
13
+ import { registerEnvBinding } from '../env.js';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Client shape
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Options accepted by {@link CloudflareKv.put}. */
20
+ export interface CloudflareKvPutOptions {
21
+ /** Absolute expiration as a Unix timestamp in seconds. */
22
+ readonly expiration?: number | undefined;
23
+ /** Relative expiration in seconds from now. Wins over `expiration`. */
24
+ readonly expirationTtl?: number | undefined;
25
+ }
26
+
27
+ /** Options accepted by {@link CloudflareKv.list}. */
28
+ export interface CloudflareKvListOptions {
29
+ /** Opaque cursor from a previous page's result. */
30
+ readonly cursor?: string | undefined;
31
+ /** Maximum keys per page. Defaults to 1000, matching the KV binding. */
32
+ readonly limit?: number | undefined;
33
+ /** Restrict results to keys starting with this prefix. */
34
+ readonly prefix?: string | undefined;
35
+ }
36
+
37
+ /** One key entry in a {@link CloudflareKvListResult}. */
38
+ export interface CloudflareKvListKey {
39
+ /** Absolute expiration as a Unix timestamp in seconds, when set. */
40
+ readonly expiration?: number | undefined;
41
+ readonly name: string;
42
+ }
43
+
44
+ /** Result shape of {@link CloudflareKv.list}, matching the KV binding. */
45
+ export interface CloudflareKvListResult {
46
+ readonly cursor?: string | undefined;
47
+ readonly keys: readonly CloudflareKvListKey[];
48
+ readonly list_complete: boolean;
49
+ }
50
+
51
+ /**
52
+ * The KV surface trails consume. A real `KVNamespace` binding satisfies this
53
+ * shape structurally, so the env bridge passes bindings through unchanged.
54
+ */
55
+ export interface CloudflareKv {
56
+ delete(key: string): Promise<void>;
57
+ get(key: string): Promise<string | null>;
58
+ list(options?: CloudflareKvListOptions): Promise<CloudflareKvListResult>;
59
+ put(
60
+ key: string,
61
+ value: string,
62
+ options?: CloudflareKvPutOptions
63
+ ): Promise<void>;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // In-memory mock
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Options for {@link createMemoryKv}. */
71
+ export interface CreateMemoryKvOptions {
72
+ /** Clock override for TTL tests. Defaults to `Date.now`. */
73
+ readonly now?: (() => number) | undefined;
74
+ }
75
+
76
+ interface MemoryKvEntry {
77
+ readonly expiresAtMs: number | undefined;
78
+ readonly value: string;
79
+ }
80
+
81
+ const DEFAULT_LIST_LIMIT = 1000;
82
+ const MS_PER_SECOND = 1000;
83
+
84
+ const isExpired = (entry: MemoryKvEntry, nowMs: number): boolean =>
85
+ entry.expiresAtMs !== undefined && entry.expiresAtMs <= nowMs;
86
+
87
+ const resolveExpiresAtMs = (
88
+ nowMs: number,
89
+ options: CloudflareKvPutOptions | undefined
90
+ ): number | undefined => {
91
+ if (options?.expirationTtl !== undefined) {
92
+ return nowMs + options.expirationTtl * MS_PER_SECOND;
93
+ }
94
+ if (options?.expiration !== undefined) {
95
+ return options.expiration * MS_PER_SECOND;
96
+ }
97
+ return undefined;
98
+ };
99
+
100
+ /**
101
+ * Create an in-memory {@link CloudflareKv} backed by a `Map`.
102
+ *
103
+ * This is the mock factory behind every `cloudflareKv` resource, exported for
104
+ * direct use in tests. TTL semantics mirror the KV binding (lazy expiry,
105
+ * seconds granularity) but the 60-second minimum TTL is intentionally not
106
+ * enforced so tests can use short expirations.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * import { createMemoryKv } from '@ontrails/cloudflare/kv';
111
+ *
112
+ * const kv = createMemoryKv();
113
+ * await kv.put('color', 'red', { expirationTtl: 60 });
114
+ * await kv.get('color'); // 'red'
115
+ * ```
116
+ */
117
+ export const createMemoryKv = (
118
+ options: CreateMemoryKvOptions = {}
119
+ ): CloudflareKv => {
120
+ const now = options.now ?? Date.now;
121
+ const entries = new Map<string, MemoryKvEntry>();
122
+
123
+ const liveEntry = (key: string): MemoryKvEntry | undefined => {
124
+ const entry = entries.get(key);
125
+ if (entry === undefined) {
126
+ return undefined;
127
+ }
128
+ if (isExpired(entry, now())) {
129
+ entries.delete(key);
130
+ return undefined;
131
+ }
132
+ return entry;
133
+ };
134
+
135
+ return {
136
+ delete: (key) => {
137
+ entries.delete(key);
138
+ return Promise.resolve();
139
+ },
140
+ get: (key) => Promise.resolve(liveEntry(key)?.value ?? null),
141
+ list: (listOptions) => {
142
+ const limit = listOptions?.limit ?? DEFAULT_LIST_LIMIT;
143
+ const prefix = listOptions?.prefix ?? '';
144
+ const nowMs = now();
145
+ const names = [...entries.keys()]
146
+ .filter((name) => {
147
+ const entry = entries.get(name);
148
+ return (
149
+ entry !== undefined &&
150
+ !isExpired(entry, nowMs) &&
151
+ name.startsWith(prefix)
152
+ );
153
+ })
154
+ .toSorted();
155
+ const startIndex =
156
+ listOptions?.cursor === undefined
157
+ ? 0
158
+ : names.findIndex((name) => name > (listOptions.cursor ?? ''));
159
+ const pageStart = startIndex === -1 ? names.length : startIndex;
160
+ const page = names.slice(pageStart, pageStart + limit);
161
+ const listComplete = pageStart + page.length >= names.length;
162
+ const lastName = page.at(-1);
163
+ return Promise.resolve({
164
+ ...(listComplete || lastName === undefined ? {} : { cursor: lastName }),
165
+ keys: page.map((name) => {
166
+ const entry = entries.get(name);
167
+ const expiresAtMs = entry?.expiresAtMs;
168
+ return {
169
+ ...(expiresAtMs === undefined
170
+ ? {}
171
+ : { expiration: Math.floor(expiresAtMs / MS_PER_SECOND) }),
172
+ name,
173
+ };
174
+ }),
175
+ list_complete: listComplete,
176
+ });
177
+ },
178
+ put: (key, value, putOptions) => {
179
+ entries.set(key, {
180
+ expiresAtMs: resolveExpiresAtMs(now(), putOptions),
181
+ value,
182
+ });
183
+ return Promise.resolve();
184
+ },
185
+ };
186
+ };
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Resource factory
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /** Options for {@link cloudflareKv}. */
193
+ export interface CloudflareKvOptions {
194
+ /** The wrangler binding name (a `kv_namespaces` entry's `binding`). */
195
+ readonly binding: string;
196
+ readonly description?: string | undefined;
197
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
198
+ }
199
+
200
+ const isKvBinding = (value: unknown): value is CloudflareKv => {
201
+ if (typeof value !== 'object' || value === null) {
202
+ return false;
203
+ }
204
+ const candidate = value as Partial<Record<keyof CloudflareKv, unknown>>;
205
+ return (
206
+ typeof candidate.get === 'function' &&
207
+ typeof candidate.put === 'function' &&
208
+ typeof candidate.delete === 'function' &&
209
+ typeof candidate.list === 'function'
210
+ );
211
+ };
212
+
213
+ /**
214
+ * Author a Trails resource wrapping a Cloudflare KV namespace binding.
215
+ *
216
+ * The instance arrives through the Workers env bridge — `create` refuses to
217
+ * run outside a Worker because KV bindings only exist there. The in-memory
218
+ * mock keeps `testAll(app)` configuration-free.
219
+ *
220
+ * @example
221
+ * ```ts
222
+ * import { cloudflareKv } from '@ontrails/cloudflare/kv';
223
+ * import { trail, Result } from '@ontrails/core';
224
+ * import { z } from 'zod';
225
+ *
226
+ * const flags = cloudflareKv('flags', { binding: 'FLAGS' });
227
+ *
228
+ * const showFlag = trail('flag.show', {
229
+ * implementation: async (input, ctx) => {
230
+ * const value = await flags.from(ctx).get(input.key);
231
+ * return Result.ok({ value });
232
+ * },
233
+ * input: z.object({ key: z.string() }),
234
+ * intent: 'read',
235
+ * output: z.object({ value: z.string().nullable() }),
236
+ * resources: [flags],
237
+ * });
238
+ * ```
239
+ */
240
+ export const cloudflareKv = (
241
+ id: string,
242
+ options: CloudflareKvOptions
243
+ ): Resource<CloudflareKv> => {
244
+ const definition = resource<CloudflareKv>(id, {
245
+ create: () =>
246
+ Result.err(
247
+ new InternalError(
248
+ `Resource "${id}" wraps Cloudflare KV binding "${options.binding}", which only exists on a Workers env. Serve the topo with createWorkersHandler from @ontrails/cloudflare/workers, or rely on the in-memory mock in tests.`,
249
+ { context: { binding: options.binding, resourceId: id } }
250
+ )
251
+ ),
252
+ description:
253
+ options.description ??
254
+ `Cloudflare KV namespace bound to "${options.binding}"`,
255
+ meta: {
256
+ ...options.meta,
257
+ 'cloudflare.binding': options.binding,
258
+ 'cloudflare.service': 'kv',
259
+ },
260
+ mock: () => createMemoryKv(),
261
+ });
262
+ registerEnvBinding(definition, {
263
+ binding: options.binding,
264
+ fromEnv: (value) =>
265
+ isKvBinding(value)
266
+ ? Result.ok(value)
267
+ : Result.err(
268
+ new InternalError(
269
+ `Worker env binding "${options.binding}" for resource "${id}" is not a KV namespace. Check the kv_namespaces entry in your wrangler configuration.`,
270
+ { context: { binding: options.binding, resourceId: id } }
271
+ )
272
+ ),
273
+ });
274
+ return definition;
275
+ };