@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.
- package/CHANGELOG.md +160 -0
- package/README.md +269 -0
- package/package.json +57 -0
- package/src/d1/index.ts +1087 -0
- package/src/env.ts +309 -0
- package/src/facts.ts +112 -0
- package/src/index.ts +96 -0
- package/src/kv/index.ts +275 -0
- package/src/queues/index.ts +746 -0
- package/src/r2/index.ts +736 -0
- package/src/workers/index.ts +280 -0
package/src/d1/index.ts
ADDED
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare D1 store resource for Trails.
|
|
3
|
+
*
|
|
4
|
+
* `cloudflareD1` binds an `@ontrails/store` definition to a Cloudflare D1
|
|
5
|
+
* database binding. The Workers env bridge resolves the binding per env, then
|
|
6
|
+
* each table accessor persists full entities as JSON rows in D1.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ConflictError,
|
|
11
|
+
InternalError,
|
|
12
|
+
Result,
|
|
13
|
+
resource,
|
|
14
|
+
ValidationError,
|
|
15
|
+
} from '@ontrails/core';
|
|
16
|
+
import type { Resource, Signal, TrailContext } from '@ontrails/core';
|
|
17
|
+
import { versionFieldName } from '@ontrails/store';
|
|
18
|
+
import type {
|
|
19
|
+
AnyStoreDefinition,
|
|
20
|
+
AnyStoreTable,
|
|
21
|
+
EntityOf,
|
|
22
|
+
FiltersOf,
|
|
23
|
+
FixtureInputOf,
|
|
24
|
+
StoreAccessor,
|
|
25
|
+
StoreAdapterOptions,
|
|
26
|
+
StoreConnection,
|
|
27
|
+
StoreIdentifierOf,
|
|
28
|
+
StoreListOptions,
|
|
29
|
+
StoreMockSeed,
|
|
30
|
+
UpsertOf,
|
|
31
|
+
} from '@ontrails/store';
|
|
32
|
+
import { bindStoreDefinition } from '@ontrails/store/adapter-support';
|
|
33
|
+
|
|
34
|
+
import { registerEnvBinding } from '../env.js';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// D1 binding shape
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Result shape returned by D1 prepared statement `all()`. */
|
|
41
|
+
export interface CloudflareD1AllResult<TRow> {
|
|
42
|
+
readonly results?: readonly TRow[] | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Minimal structural shape returned by D1 prepared statement `run()`. */
|
|
46
|
+
export interface CloudflareD1RunResult {
|
|
47
|
+
readonly meta?: { readonly changes?: number | undefined } | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Minimal structural shape used from a D1 prepared statement. */
|
|
51
|
+
export interface CloudflareD1PreparedStatement {
|
|
52
|
+
bind(...values: unknown[]): CloudflareD1PreparedStatement;
|
|
53
|
+
first<TRow = Record<string, unknown>>(): Promise<TRow | null>;
|
|
54
|
+
all<TRow = Record<string, unknown>>(): Promise<CloudflareD1AllResult<TRow>>;
|
|
55
|
+
run(): Promise<CloudflareD1RunResult>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Minimal structural shape used from a Cloudflare D1 database binding. */
|
|
59
|
+
export interface CloudflareD1Database {
|
|
60
|
+
exec(query: string): Promise<unknown>;
|
|
61
|
+
prepare(query: string): CloudflareD1PreparedStatement;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Public types
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** Connection shape returned by {@link connectD1}. */
|
|
69
|
+
export type CloudflareD1Connection<TStore extends AnyStoreDefinition> =
|
|
70
|
+
StoreConnection<TStore>;
|
|
71
|
+
|
|
72
|
+
/** Options for {@link connectD1}. */
|
|
73
|
+
export interface ConnectD1Options<
|
|
74
|
+
TStore extends AnyStoreDefinition = AnyStoreDefinition,
|
|
75
|
+
> {
|
|
76
|
+
/**
|
|
77
|
+
* Optional identity generator for generated identity fields. Defaults to
|
|
78
|
+
* `crypto.randomUUID()`.
|
|
79
|
+
*/
|
|
80
|
+
readonly generateIdentity?: (() => string) | undefined;
|
|
81
|
+
/** Optional runtime seed rows inserted during lazy schema initialization. */
|
|
82
|
+
readonly seed?: StoreMockSeed<TStore> | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* Optional D1 table-name prefix. Defaults to `"store"`.
|
|
85
|
+
*
|
|
86
|
+
* `cloudflareD1` sets this to the resource id so multiple store resources
|
|
87
|
+
* can share one D1 database without colliding on table names.
|
|
88
|
+
*/
|
|
89
|
+
readonly tablePrefix?: string | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Options for {@link cloudflareD1}. */
|
|
93
|
+
export interface CloudflareD1Options<
|
|
94
|
+
TStore extends AnyStoreDefinition = AnyStoreDefinition,
|
|
95
|
+
>
|
|
96
|
+
extends StoreAdapterOptions<TStore>, ConnectD1Options<TStore> {
|
|
97
|
+
/** The wrangler binding name (a `d1_databases` entry's `binding`). */
|
|
98
|
+
readonly binding: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resource shape returned by {@link cloudflareD1}. */
|
|
102
|
+
export type CloudflareD1Resource<TStore extends AnyStoreDefinition> = Resource<
|
|
103
|
+
CloudflareD1Connection<TStore>
|
|
104
|
+
> & {
|
|
105
|
+
readonly access: 'readwrite';
|
|
106
|
+
readonly signals: TStore['signals'];
|
|
107
|
+
readonly store: TStore;
|
|
108
|
+
from(ctx: TrailContext): CloudflareD1Connection<TStore>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Shared row helpers
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
const defaultResourceId = 'store';
|
|
116
|
+
|
|
117
|
+
const defaultGenerateIdentity = (): string => {
|
|
118
|
+
if (globalThis.crypto?.randomUUID !== undefined) {
|
|
119
|
+
return globalThis.crypto.randomUUID();
|
|
120
|
+
}
|
|
121
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const encodeIdentifier = (id: unknown): string =>
|
|
125
|
+
JSON.stringify(id) ?? String(id);
|
|
126
|
+
|
|
127
|
+
const quoteIdentifier = (value: string): string =>
|
|
128
|
+
`"${value.replaceAll('"', '""')}"`;
|
|
129
|
+
|
|
130
|
+
const storageTableName = (scope: string, table: AnyStoreTable): string =>
|
|
131
|
+
`${scope}.${table.name}`;
|
|
132
|
+
|
|
133
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
134
|
+
typeof value === 'object' && value !== null;
|
|
135
|
+
|
|
136
|
+
const deepEqual = (left: unknown, right: unknown): boolean => {
|
|
137
|
+
if (Object.is(left, right)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (left instanceof Date || right instanceof Date) {
|
|
141
|
+
return (
|
|
142
|
+
left instanceof Date &&
|
|
143
|
+
right instanceof Date &&
|
|
144
|
+
left.getTime() === right.getTime()
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
148
|
+
return (
|
|
149
|
+
Array.isArray(left) &&
|
|
150
|
+
Array.isArray(right) &&
|
|
151
|
+
left.length === right.length &&
|
|
152
|
+
left.every((value, index) => deepEqual(value, right[index]))
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!isRecord(left) || !isRecord(right)) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const leftKeys = Object.keys(left).toSorted();
|
|
159
|
+
const rightKeys = Object.keys(right).toSorted();
|
|
160
|
+
return (
|
|
161
|
+
leftKeys.length === rightKeys.length &&
|
|
162
|
+
leftKeys.every(
|
|
163
|
+
(key, index) =>
|
|
164
|
+
key === rightKeys[index] && deepEqual(left[key], right[key])
|
|
165
|
+
)
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const matchesFilters = (
|
|
170
|
+
entity: Record<string, unknown>,
|
|
171
|
+
filters: Record<string, unknown>
|
|
172
|
+
): boolean => {
|
|
173
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
174
|
+
if (!deepEqual(entity[key], value)) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const applyPagination = <T>(
|
|
182
|
+
items: readonly T[],
|
|
183
|
+
options?: StoreListOptions
|
|
184
|
+
): readonly T[] => {
|
|
185
|
+
if (options === undefined) {
|
|
186
|
+
return items;
|
|
187
|
+
}
|
|
188
|
+
const offset = options.offset ?? 0;
|
|
189
|
+
const limit = options.limit ?? items.length;
|
|
190
|
+
return items.slice(offset, offset + limit);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const cloneEntity = <TTable extends AnyStoreTable>(
|
|
194
|
+
entity: EntityOf<TTable>
|
|
195
|
+
): EntityOf<TTable> => structuredClone(entity);
|
|
196
|
+
|
|
197
|
+
const formatIssues = (issues: readonly { readonly message: string }[]) =>
|
|
198
|
+
issues.map((issue) => issue.message).join('; ');
|
|
199
|
+
|
|
200
|
+
const parseStoredEntity = <TTable extends AnyStoreTable>(
|
|
201
|
+
table: TTable,
|
|
202
|
+
raw: unknown
|
|
203
|
+
): EntityOf<TTable> => {
|
|
204
|
+
const parsed = table.schema.safeParse(raw);
|
|
205
|
+
if (!parsed.success) {
|
|
206
|
+
throw new InternalError(
|
|
207
|
+
`D1 table "${table.name}" contains a row that does not match the store schema: ${formatIssues(parsed.error.issues)}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return parsed.data as EntityOf<TTable>;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const parseWrittenEntity = <TTable extends AnyStoreTable>(
|
|
214
|
+
table: TTable,
|
|
215
|
+
raw: unknown
|
|
216
|
+
): EntityOf<TTable> => {
|
|
217
|
+
const parsed = table.schema.safeParse(raw);
|
|
218
|
+
if (!parsed.success) {
|
|
219
|
+
throw new ValidationError(
|
|
220
|
+
`D1 table "${table.name}" received an invalid entity: ${formatIssues(parsed.error.issues)}`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return parsed.data as EntityOf<TTable>;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const readIdentity = <TTable extends AnyStoreTable>(
|
|
227
|
+
table: TTable,
|
|
228
|
+
entity: EntityOf<TTable>
|
|
229
|
+
): StoreIdentifierOf<TTable> =>
|
|
230
|
+
(entity as Record<string, unknown>)[
|
|
231
|
+
table.identity
|
|
232
|
+
] as StoreIdentifierOf<TTable>;
|
|
233
|
+
|
|
234
|
+
const assignIdentity = (
|
|
235
|
+
payload: Record<string, unknown>,
|
|
236
|
+
identityField: string,
|
|
237
|
+
generate: () => string
|
|
238
|
+
): void => {
|
|
239
|
+
if (payload[identityField] === undefined) {
|
|
240
|
+
payload[identityField] = generate();
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const assignTimestamp = (
|
|
245
|
+
payload: Record<string, unknown>,
|
|
246
|
+
generatedFields: ReadonlySet<string>,
|
|
247
|
+
isNew: boolean
|
|
248
|
+
): void => {
|
|
249
|
+
if (generatedFields.has('createdAt') && isNew) {
|
|
250
|
+
payload['createdAt'] = new Date().toISOString();
|
|
251
|
+
}
|
|
252
|
+
if (generatedFields.has('updatedAt')) {
|
|
253
|
+
payload['updatedAt'] = new Date().toISOString();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const resolveNextVersion = (
|
|
258
|
+
existing: Record<string, unknown> | undefined
|
|
259
|
+
): number =>
|
|
260
|
+
existing === undefined ? 1 : (existing[versionFieldName] as number) + 1;
|
|
261
|
+
|
|
262
|
+
const assignVersion = (
|
|
263
|
+
payload: Record<string, unknown>,
|
|
264
|
+
isVersioned: boolean,
|
|
265
|
+
existing: Record<string, unknown> | undefined
|
|
266
|
+
): void => {
|
|
267
|
+
if (isVersioned) {
|
|
268
|
+
payload[versionFieldName] = resolveNextVersion(existing);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const checkVersionConflict = (
|
|
273
|
+
tableName: string,
|
|
274
|
+
isVersioned: boolean,
|
|
275
|
+
input: Record<string, unknown>,
|
|
276
|
+
existing: Record<string, unknown> | undefined
|
|
277
|
+
): void => {
|
|
278
|
+
if (!isVersioned) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const inputVersion = input[versionFieldName] as number | undefined;
|
|
282
|
+
if (inputVersion === undefined) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (existing === undefined) {
|
|
286
|
+
throw new ConflictError(
|
|
287
|
+
`Version conflict on "${tableName}": expected ${String(inputVersion)}, actual missing`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const currentVersion = existing[versionFieldName] as number;
|
|
291
|
+
if (inputVersion !== currentVersion) {
|
|
292
|
+
throw new ConflictError(
|
|
293
|
+
`Version conflict on "${tableName}": expected ${String(inputVersion)}, actual ${String(currentVersion)}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const buildUpsertEntity = <TTable extends AnyStoreTable>(
|
|
299
|
+
table: TTable,
|
|
300
|
+
input: UpsertOf<TTable>,
|
|
301
|
+
existing: EntityOf<TTable> | undefined,
|
|
302
|
+
generateIdentity: () => string
|
|
303
|
+
): EntityOf<TTable> => {
|
|
304
|
+
const raw = input as Record<string, unknown>;
|
|
305
|
+
const merged =
|
|
306
|
+
existing === undefined
|
|
307
|
+
? { ...raw }
|
|
308
|
+
: { ...(existing as Record<string, unknown>), ...raw };
|
|
309
|
+
checkVersionConflict(
|
|
310
|
+
table.name,
|
|
311
|
+
table.versioned,
|
|
312
|
+
raw,
|
|
313
|
+
existing as Record<string, unknown> | undefined
|
|
314
|
+
);
|
|
315
|
+
assignIdentity(merged, table.identity, generateIdentity);
|
|
316
|
+
assignTimestamp(merged, new Set(table.generated), existing === undefined);
|
|
317
|
+
assignVersion(merged, table.versioned, existing as Record<string, unknown>);
|
|
318
|
+
return parseWrittenEntity(table, merged);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const buildSeedEntity = <TTable extends AnyStoreTable>(
|
|
322
|
+
table: TTable,
|
|
323
|
+
input: FixtureInputOf<TTable>
|
|
324
|
+
): EntityOf<TTable> => {
|
|
325
|
+
const entity = { ...(input as Record<string, unknown>) };
|
|
326
|
+
const generatedFields = new Set(table.generated);
|
|
327
|
+
if (entity[table.identity] === undefined) {
|
|
328
|
+
throw new ValidationError(
|
|
329
|
+
`D1 runtime seed rows for "${table.name}" must define the stable identity field "${table.identity}"`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const now = new Date().toISOString();
|
|
333
|
+
if (generatedFields.has('createdAt') && entity['createdAt'] === undefined) {
|
|
334
|
+
entity['createdAt'] = now;
|
|
335
|
+
}
|
|
336
|
+
if (generatedFields.has('updatedAt') && entity['updatedAt'] === undefined) {
|
|
337
|
+
entity['updatedAt'] = now;
|
|
338
|
+
}
|
|
339
|
+
if (table.versioned && entity[versionFieldName] === undefined) {
|
|
340
|
+
entity[versionFieldName] = 1;
|
|
341
|
+
}
|
|
342
|
+
return parseWrittenEntity(table, entity);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const fixtureRowsFor = (
|
|
346
|
+
tableName: string,
|
|
347
|
+
table: AnyStoreTable,
|
|
348
|
+
seed: StoreMockSeed<AnyStoreDefinition> | undefined,
|
|
349
|
+
includeTableFixtures: boolean
|
|
350
|
+
): readonly FixtureInputOf<AnyStoreTable>[] => {
|
|
351
|
+
const seeded = seed?.[tableName] as
|
|
352
|
+
| readonly FixtureInputOf<AnyStoreTable>[]
|
|
353
|
+
| undefined;
|
|
354
|
+
if (seeded !== undefined) {
|
|
355
|
+
return seeded;
|
|
356
|
+
}
|
|
357
|
+
return includeTableFixtures ? table.fixtures : [];
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const insertSeed = Symbol('cloudflare.d1.insertSeed');
|
|
361
|
+
|
|
362
|
+
type SeedAwareAccessor<TTable extends AnyStoreTable> = StoreAccessor<TTable> & {
|
|
363
|
+
[insertSeed](input: FixtureInputOf<TTable>): Promise<void>;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const seedConnection = async <TStore extends AnyStoreDefinition>(
|
|
367
|
+
connection: CloudflareD1Connection<TStore>,
|
|
368
|
+
definition: TStore,
|
|
369
|
+
seed: StoreMockSeed<TStore> | undefined,
|
|
370
|
+
includeTableFixtures: boolean
|
|
371
|
+
): Promise<void> => {
|
|
372
|
+
const accessors = connection as unknown as Record<
|
|
373
|
+
string,
|
|
374
|
+
SeedAwareAccessor<AnyStoreTable>
|
|
375
|
+
>;
|
|
376
|
+
for (const tableName of definition.tableNames) {
|
|
377
|
+
const table = definition.tables[tableName];
|
|
378
|
+
const accessor = accessors[tableName];
|
|
379
|
+
if (table === undefined || accessor === undefined) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
for (const fixture of fixtureRowsFor(
|
|
383
|
+
tableName,
|
|
384
|
+
table,
|
|
385
|
+
seed as StoreMockSeed<AnyStoreDefinition> | undefined,
|
|
386
|
+
includeTableFixtures
|
|
387
|
+
)) {
|
|
388
|
+
await accessor[insertSeed](fixture);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// D1 storage implementation
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
interface D1TableRow {
|
|
398
|
+
readonly entity: string;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const ensureD1Table = async (
|
|
402
|
+
database: CloudflareD1Database,
|
|
403
|
+
tableName: string
|
|
404
|
+
): Promise<void> => {
|
|
405
|
+
await database.exec(
|
|
406
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(tableName)} (id TEXT PRIMARY KEY, entity TEXT NOT NULL, version INTEGER)`
|
|
407
|
+
);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const getD1Row = async <TTable extends AnyStoreTable>(
|
|
411
|
+
database: CloudflareD1Database,
|
|
412
|
+
tableName: string,
|
|
413
|
+
table: TTable,
|
|
414
|
+
id: StoreIdentifierOf<TTable>
|
|
415
|
+
): Promise<EntityOf<TTable> | null> => {
|
|
416
|
+
const row = await database
|
|
417
|
+
.prepare(`SELECT entity FROM ${quoteIdentifier(tableName)} WHERE id = ?`)
|
|
418
|
+
.bind(encodeIdentifier(id))
|
|
419
|
+
.first<D1TableRow>();
|
|
420
|
+
return row === null ? null : parseStoredEntity(table, JSON.parse(row.entity));
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const listD1Rows = async <TTable extends AnyStoreTable>(
|
|
424
|
+
database: CloudflareD1Database,
|
|
425
|
+
tableName: string,
|
|
426
|
+
table: TTable,
|
|
427
|
+
filters?: FiltersOf<TTable>,
|
|
428
|
+
options?: StoreListOptions
|
|
429
|
+
): Promise<readonly EntityOf<TTable>[]> => {
|
|
430
|
+
const rows = await database
|
|
431
|
+
.prepare(`SELECT entity FROM ${quoteIdentifier(tableName)} ORDER BY id`)
|
|
432
|
+
.all<D1TableRow>();
|
|
433
|
+
const entities = (rows.results ?? []).map((row) =>
|
|
434
|
+
parseStoredEntity(table, JSON.parse(row.entity))
|
|
435
|
+
);
|
|
436
|
+
const filtered =
|
|
437
|
+
filters === undefined
|
|
438
|
+
? entities
|
|
439
|
+
: entities.filter((entity) =>
|
|
440
|
+
matchesFilters(
|
|
441
|
+
entity as Record<string, unknown>,
|
|
442
|
+
filters as Record<string, unknown>
|
|
443
|
+
)
|
|
444
|
+
);
|
|
445
|
+
return applyPagination(filtered, options).map((entity) =>
|
|
446
|
+
cloneEntity(entity)
|
|
447
|
+
);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const d1ChangeCount = (result: CloudflareD1RunResult): number =>
|
|
451
|
+
result.meta?.changes ?? 0;
|
|
452
|
+
|
|
453
|
+
const entityVersion = <TTable extends AnyStoreTable>(
|
|
454
|
+
entity: EntityOf<TTable>
|
|
455
|
+
): number | undefined =>
|
|
456
|
+
(entity as Record<string, unknown>)[versionFieldName] as number | undefined;
|
|
457
|
+
|
|
458
|
+
interface D1UpsertOutcome<TTable extends AnyStoreTable> {
|
|
459
|
+
readonly entity: EntityOf<TTable>;
|
|
460
|
+
readonly previous: EntityOf<TTable> | null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
interface D1RemoveOutcome<TTable extends AnyStoreTable> {
|
|
464
|
+
readonly deleted: boolean;
|
|
465
|
+
readonly entity: EntityOf<TTable> | null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const upsertWithOutcome = Symbol('cloudflare.d1.upsertWithOutcome');
|
|
469
|
+
const removeWithOutcome = Symbol('cloudflare.d1.removeWithOutcome');
|
|
470
|
+
|
|
471
|
+
type OutcomeAwareAccessor<TTable extends AnyStoreTable> =
|
|
472
|
+
StoreAccessor<TTable> & {
|
|
473
|
+
[removeWithOutcome](
|
|
474
|
+
id: StoreIdentifierOf<TTable>
|
|
475
|
+
): Promise<D1RemoveOutcome<TTable>>;
|
|
476
|
+
[upsertWithOutcome](
|
|
477
|
+
input: UpsertOf<TTable>
|
|
478
|
+
): Promise<D1UpsertOutcome<TTable>>;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const throwVersionConflict = <TTable extends AnyStoreTable>(
|
|
482
|
+
table: TTable,
|
|
483
|
+
expectedVersion: number,
|
|
484
|
+
current: EntityOf<TTable> | null
|
|
485
|
+
): never => {
|
|
486
|
+
const actualVersion =
|
|
487
|
+
current === null ? 'missing' : String(entityVersion(current));
|
|
488
|
+
throw new ConflictError(
|
|
489
|
+
`Version conflict on "${table.name}": expected ${String(expectedVersion)}, actual ${actualVersion}`
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const updateD1RowIfUnchanged = async <TTable extends AnyStoreTable>(
|
|
494
|
+
database: CloudflareD1Database,
|
|
495
|
+
tableName: string,
|
|
496
|
+
table: TTable,
|
|
497
|
+
entity: EntityOf<TTable>,
|
|
498
|
+
previous: EntityOf<TTable>,
|
|
499
|
+
expectedVersion: number | undefined
|
|
500
|
+
): Promise<boolean> => {
|
|
501
|
+
const id = readIdentity(table, entity);
|
|
502
|
+
const expectedVersionClause =
|
|
503
|
+
expectedVersion === undefined ? '' : ' AND version = ?';
|
|
504
|
+
const result = await database
|
|
505
|
+
.prepare(
|
|
506
|
+
`UPDATE ${quoteIdentifier(tableName)} SET entity = ?, version = ? WHERE id = ? AND entity = ?${expectedVersionClause}`
|
|
507
|
+
)
|
|
508
|
+
.bind(
|
|
509
|
+
JSON.stringify(entity),
|
|
510
|
+
entityVersion(entity) ?? null,
|
|
511
|
+
encodeIdentifier(id),
|
|
512
|
+
JSON.stringify(previous),
|
|
513
|
+
...(expectedVersion === undefined ? [] : [expectedVersion])
|
|
514
|
+
)
|
|
515
|
+
.run();
|
|
516
|
+
return d1ChangeCount(result) > 0;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const insertD1RowIfMissing = async <TTable extends AnyStoreTable>(
|
|
520
|
+
database: CloudflareD1Database,
|
|
521
|
+
tableName: string,
|
|
522
|
+
table: TTable,
|
|
523
|
+
entity: EntityOf<TTable>
|
|
524
|
+
): Promise<boolean> => {
|
|
525
|
+
const result = await database
|
|
526
|
+
.prepare(
|
|
527
|
+
`INSERT INTO ${quoteIdentifier(tableName)} (id, entity, version) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING`
|
|
528
|
+
)
|
|
529
|
+
.bind(
|
|
530
|
+
encodeIdentifier(readIdentity(table, entity)),
|
|
531
|
+
JSON.stringify(entity),
|
|
532
|
+
entityVersion(entity) ?? null
|
|
533
|
+
)
|
|
534
|
+
.run();
|
|
535
|
+
return d1ChangeCount(result) > 0;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const seedD1Tables = async <TStore extends AnyStoreDefinition>(
|
|
539
|
+
database: CloudflareD1Database,
|
|
540
|
+
definition: TStore,
|
|
541
|
+
tablePrefix: string,
|
|
542
|
+
seed: StoreMockSeed<TStore> | undefined,
|
|
543
|
+
includeTableFixtures: boolean
|
|
544
|
+
): Promise<void> => {
|
|
545
|
+
for (const tableName of definition.tableNames) {
|
|
546
|
+
const table = definition.tables[tableName];
|
|
547
|
+
if (table === undefined) {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
const d1TableName = storageTableName(tablePrefix, table);
|
|
551
|
+
for (const fixture of fixtureRowsFor(
|
|
552
|
+
tableName,
|
|
553
|
+
table,
|
|
554
|
+
seed as StoreMockSeed<AnyStoreDefinition> | undefined,
|
|
555
|
+
includeTableFixtures
|
|
556
|
+
)) {
|
|
557
|
+
await insertD1RowIfMissing(
|
|
558
|
+
database,
|
|
559
|
+
d1TableName,
|
|
560
|
+
table,
|
|
561
|
+
buildSeedEntity(table, fixture)
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const removeD1Row = async <TTable extends AnyStoreTable>(
|
|
568
|
+
database: CloudflareD1Database,
|
|
569
|
+
tableName: string,
|
|
570
|
+
table: TTable,
|
|
571
|
+
id: StoreIdentifierOf<TTable>
|
|
572
|
+
): Promise<D1RemoveOutcome<TTable>> => {
|
|
573
|
+
const row = await database
|
|
574
|
+
.prepare(
|
|
575
|
+
`DELETE FROM ${quoteIdentifier(tableName)} WHERE id = ? RETURNING entity`
|
|
576
|
+
)
|
|
577
|
+
.bind(encodeIdentifier(id))
|
|
578
|
+
.first<D1TableRow>();
|
|
579
|
+
if (row === null) {
|
|
580
|
+
return { deleted: false, entity: null };
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
deleted: true,
|
|
584
|
+
entity: parseStoredEntity(table, JSON.parse(row.entity)),
|
|
585
|
+
};
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const createD1Accessor = <TTable extends AnyStoreTable>(
|
|
589
|
+
database: CloudflareD1Database,
|
|
590
|
+
tableName: string,
|
|
591
|
+
table: TTable,
|
|
592
|
+
ensureReady: () => Promise<void>,
|
|
593
|
+
generateIdentity: () => string
|
|
594
|
+
): StoreAccessor<TTable> => {
|
|
595
|
+
const writeWithOutcome = async (
|
|
596
|
+
input: UpsertOf<TTable>
|
|
597
|
+
): Promise<D1UpsertOutcome<TTable>> => {
|
|
598
|
+
await ensureReady();
|
|
599
|
+
const raw = input as Record<string, unknown>;
|
|
600
|
+
const inputId = raw[table.identity] as
|
|
601
|
+
| StoreIdentifierOf<TTable>
|
|
602
|
+
| undefined;
|
|
603
|
+
const expectedVersion = table.versioned
|
|
604
|
+
? (raw[versionFieldName] as number | undefined)
|
|
605
|
+
: undefined;
|
|
606
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
607
|
+
const observed =
|
|
608
|
+
inputId === undefined
|
|
609
|
+
? null
|
|
610
|
+
: await getD1Row(database, tableName, table, inputId);
|
|
611
|
+
const candidate = buildUpsertEntity(
|
|
612
|
+
table,
|
|
613
|
+
input,
|
|
614
|
+
observed ?? undefined,
|
|
615
|
+
generateIdentity
|
|
616
|
+
);
|
|
617
|
+
if (observed === null) {
|
|
618
|
+
if (await insertD1RowIfMissing(database, tableName, table, candidate)) {
|
|
619
|
+
return { entity: cloneEntity(candidate), previous: null };
|
|
620
|
+
}
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (
|
|
624
|
+
await updateD1RowIfUnchanged(
|
|
625
|
+
database,
|
|
626
|
+
tableName,
|
|
627
|
+
table,
|
|
628
|
+
candidate,
|
|
629
|
+
observed,
|
|
630
|
+
expectedVersion
|
|
631
|
+
)
|
|
632
|
+
) {
|
|
633
|
+
return {
|
|
634
|
+
entity: cloneEntity(candidate),
|
|
635
|
+
previous: observed,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (expectedVersion !== undefined) {
|
|
639
|
+
return throwVersionConflict(
|
|
640
|
+
table,
|
|
641
|
+
expectedVersion,
|
|
642
|
+
await getD1Row(
|
|
643
|
+
database,
|
|
644
|
+
tableName,
|
|
645
|
+
table,
|
|
646
|
+
readIdentity(table, observed)
|
|
647
|
+
)
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
throw new InternalError(
|
|
652
|
+
`D1 upsert for "${table.name}" could not commit after repeated concurrent writes`
|
|
653
|
+
);
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const removeAndReturn = async (
|
|
657
|
+
id: StoreIdentifierOf<TTable>
|
|
658
|
+
): Promise<D1RemoveOutcome<TTable>> => {
|
|
659
|
+
await ensureReady();
|
|
660
|
+
return await removeD1Row(database, tableName, table, id);
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const accessor: OutcomeAwareAccessor<TTable> = {
|
|
664
|
+
async get(id) {
|
|
665
|
+
await ensureReady();
|
|
666
|
+
const entity = await getD1Row(database, tableName, table, id);
|
|
667
|
+
return entity === null ? null : cloneEntity(entity);
|
|
668
|
+
},
|
|
669
|
+
async list(filters, options) {
|
|
670
|
+
await ensureReady();
|
|
671
|
+
return await listD1Rows(database, tableName, table, filters, options);
|
|
672
|
+
},
|
|
673
|
+
async remove(id) {
|
|
674
|
+
const outcome = await removeAndReturn(id);
|
|
675
|
+
return { deleted: outcome.deleted };
|
|
676
|
+
},
|
|
677
|
+
async upsert(input) {
|
|
678
|
+
const outcome = await writeWithOutcome(input);
|
|
679
|
+
return outcome.entity;
|
|
680
|
+
},
|
|
681
|
+
[removeWithOutcome]: removeAndReturn,
|
|
682
|
+
[upsertWithOutcome]: writeWithOutcome,
|
|
683
|
+
};
|
|
684
|
+
return accessor;
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
// In-memory mock implementation
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
const sortedMemoryRows = <TTable extends AnyStoreTable>(
|
|
692
|
+
rows: ReadonlyMap<string, EntityOf<TTable>>
|
|
693
|
+
): readonly EntityOf<TTable>[] =>
|
|
694
|
+
[...rows.entries()]
|
|
695
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
696
|
+
.map(([, value]) => value);
|
|
697
|
+
|
|
698
|
+
const createMemoryAccessor = <TTable extends AnyStoreTable>(
|
|
699
|
+
table: TTable,
|
|
700
|
+
generateIdentity: () => string
|
|
701
|
+
): StoreAccessor<TTable> => {
|
|
702
|
+
const rows = new Map<string, EntityOf<TTable>>();
|
|
703
|
+
|
|
704
|
+
const writeWithOutcome = (
|
|
705
|
+
input: UpsertOf<TTable>
|
|
706
|
+
): Promise<D1UpsertOutcome<TTable>> => {
|
|
707
|
+
const raw = input as Record<string, unknown>;
|
|
708
|
+
const inputId = raw[table.identity] as
|
|
709
|
+
| StoreIdentifierOf<TTable>
|
|
710
|
+
| undefined;
|
|
711
|
+
const existing =
|
|
712
|
+
inputId === undefined ? undefined : rows.get(encodeIdentifier(inputId));
|
|
713
|
+
const entity = buildUpsertEntity(table, input, existing, generateIdentity);
|
|
714
|
+
rows.set(encodeIdentifier(readIdentity(table, entity)), entity);
|
|
715
|
+
return Promise.resolve({
|
|
716
|
+
entity: cloneEntity(entity),
|
|
717
|
+
previous: existing === undefined ? null : cloneEntity(existing),
|
|
718
|
+
});
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const accessor: OutcomeAwareAccessor<TTable> & SeedAwareAccessor<TTable> = {
|
|
722
|
+
get: (id) => {
|
|
723
|
+
const entity = rows.get(encodeIdentifier(id));
|
|
724
|
+
return Promise.resolve(entity === undefined ? null : cloneEntity(entity));
|
|
725
|
+
},
|
|
726
|
+
list: (filters, options) => {
|
|
727
|
+
const all = sortedMemoryRows(rows);
|
|
728
|
+
const filtered =
|
|
729
|
+
filters === undefined
|
|
730
|
+
? all
|
|
731
|
+
: all.filter((entity) =>
|
|
732
|
+
matchesFilters(
|
|
733
|
+
entity as Record<string, unknown>,
|
|
734
|
+
filters as Record<string, unknown>
|
|
735
|
+
)
|
|
736
|
+
);
|
|
737
|
+
return Promise.resolve(
|
|
738
|
+
applyPagination(filtered, options).map((entity) => cloneEntity(entity))
|
|
739
|
+
);
|
|
740
|
+
},
|
|
741
|
+
remove: async (id) => {
|
|
742
|
+
const outcome = await accessor[removeWithOutcome](id);
|
|
743
|
+
return { deleted: outcome.deleted };
|
|
744
|
+
},
|
|
745
|
+
async upsert(input) {
|
|
746
|
+
const outcome = await writeWithOutcome(input);
|
|
747
|
+
return outcome.entity;
|
|
748
|
+
},
|
|
749
|
+
[removeWithOutcome]: (id) => {
|
|
750
|
+
const key = encodeIdentifier(id);
|
|
751
|
+
const existing = rows.get(key);
|
|
752
|
+
const deleted = rows.delete(key);
|
|
753
|
+
return Promise.resolve({
|
|
754
|
+
deleted,
|
|
755
|
+
entity: existing === undefined ? null : cloneEntity(existing),
|
|
756
|
+
});
|
|
757
|
+
},
|
|
758
|
+
[insertSeed]: (input) => {
|
|
759
|
+
const entity = buildSeedEntity(table, input);
|
|
760
|
+
const key = encodeIdentifier(readIdentity(table, entity));
|
|
761
|
+
if (!rows.has(key)) {
|
|
762
|
+
rows.set(key, entity);
|
|
763
|
+
}
|
|
764
|
+
return Promise.resolve();
|
|
765
|
+
},
|
|
766
|
+
[upsertWithOutcome]: writeWithOutcome,
|
|
767
|
+
};
|
|
768
|
+
return accessor;
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const buildConnection = <TStore extends AnyStoreDefinition>(
|
|
772
|
+
definition: TStore,
|
|
773
|
+
createAccessor: <TTable extends AnyStoreTable>(
|
|
774
|
+
table: TTable,
|
|
775
|
+
tableName: string
|
|
776
|
+
) => StoreAccessor<TTable>
|
|
777
|
+
): CloudflareD1Connection<TStore> => {
|
|
778
|
+
const connection = {} as Record<string, StoreAccessor<AnyStoreTable>>;
|
|
779
|
+
for (const tableName of definition.tableNames) {
|
|
780
|
+
const table = definition.tables[tableName];
|
|
781
|
+
if (table !== undefined) {
|
|
782
|
+
connection[tableName] = createAccessor(table, tableName);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return Object.freeze(connection) as CloudflareD1Connection<TStore>;
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const createMemoryConnection = <TStore extends AnyStoreDefinition>(
|
|
789
|
+
definition: TStore,
|
|
790
|
+
options: ConnectD1Options<TStore> = {}
|
|
791
|
+
): CloudflareD1Connection<TStore> =>
|
|
792
|
+
buildConnection(definition, (table) =>
|
|
793
|
+
createMemoryAccessor(
|
|
794
|
+
table,
|
|
795
|
+
options.generateIdentity ?? defaultGenerateIdentity
|
|
796
|
+
)
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
// Signals
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
type BoundFireFn = NonNullable<TrailContext['fire']>;
|
|
804
|
+
|
|
805
|
+
const fireDerivedSignal = async <TTable extends AnyStoreTable>(
|
|
806
|
+
fire: BoundFireFn,
|
|
807
|
+
signal: Signal<unknown>,
|
|
808
|
+
entity: EntityOf<TTable>
|
|
809
|
+
): Promise<void> => {
|
|
810
|
+
try {
|
|
811
|
+
await fire(signal, entity);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
console.warn(
|
|
814
|
+
`[cloudflare:d1] signal "${signal.id}" emission threw:`,
|
|
815
|
+
error
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const inputIdentity = <TTable extends AnyStoreTable>(
|
|
821
|
+
table: TTable,
|
|
822
|
+
input: UpsertOf<TTable>
|
|
823
|
+
): StoreIdentifierOf<TTable> | undefined =>
|
|
824
|
+
input[table.identity as keyof UpsertOf<TTable> & string] as
|
|
825
|
+
| StoreIdentifierOf<TTable>
|
|
826
|
+
| undefined;
|
|
827
|
+
|
|
828
|
+
const changedEntity = <TTable extends AnyStoreTable>(
|
|
829
|
+
previous: EntityOf<TTable> | null,
|
|
830
|
+
next: EntityOf<TTable> | null
|
|
831
|
+
): next is EntityOf<TTable> =>
|
|
832
|
+
previous !== null && next !== null && !deepEqual(previous, next);
|
|
833
|
+
|
|
834
|
+
const isOutcomeAwareAccessor = <TTable extends AnyStoreTable>(
|
|
835
|
+
accessor: StoreAccessor<TTable>
|
|
836
|
+
): accessor is OutcomeAwareAccessor<TTable> => upsertWithOutcome in accessor;
|
|
837
|
+
|
|
838
|
+
const bindWritableAccessorSignals = <TTable extends AnyStoreTable>(
|
|
839
|
+
table: TTable,
|
|
840
|
+
accessor: StoreAccessor<TTable>,
|
|
841
|
+
fire: BoundFireFn
|
|
842
|
+
): StoreAccessor<TTable> =>
|
|
843
|
+
Object.freeze({
|
|
844
|
+
...accessor,
|
|
845
|
+
async remove(id: StoreIdentifierOf<TTable>) {
|
|
846
|
+
if (isOutcomeAwareAccessor(accessor)) {
|
|
847
|
+
const outcome = await accessor[removeWithOutcome](id);
|
|
848
|
+
if (outcome.entity !== null) {
|
|
849
|
+
await fireDerivedSignal(fire, table.signals.removed, outcome.entity);
|
|
850
|
+
}
|
|
851
|
+
return { deleted: outcome.deleted };
|
|
852
|
+
}
|
|
853
|
+
const existing = await accessor.get(id);
|
|
854
|
+
const removed = await accessor.remove(id);
|
|
855
|
+
if (removed.deleted && existing !== null) {
|
|
856
|
+
await fireDerivedSignal(fire, table.signals.removed, existing);
|
|
857
|
+
}
|
|
858
|
+
return removed;
|
|
859
|
+
},
|
|
860
|
+
async upsert(input: UpsertOf<TTable>) {
|
|
861
|
+
if (isOutcomeAwareAccessor(accessor)) {
|
|
862
|
+
const outcome = await accessor[upsertWithOutcome](input);
|
|
863
|
+
if (outcome.previous === null) {
|
|
864
|
+
await fireDerivedSignal(fire, table.signals.created, outcome.entity);
|
|
865
|
+
} else if (changedEntity(outcome.previous, outcome.entity)) {
|
|
866
|
+
await fireDerivedSignal(fire, table.signals.updated, outcome.entity);
|
|
867
|
+
}
|
|
868
|
+
return outcome.entity;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const existingId = inputIdentity(table, input);
|
|
872
|
+
const existing =
|
|
873
|
+
existingId === undefined ? null : await accessor.get(existingId);
|
|
874
|
+
const written = await accessor.upsert(input);
|
|
875
|
+
|
|
876
|
+
if (existing === null) {
|
|
877
|
+
await fireDerivedSignal(fire, table.signals.created, written);
|
|
878
|
+
return written;
|
|
879
|
+
}
|
|
880
|
+
if (changedEntity(existing, written)) {
|
|
881
|
+
await fireDerivedSignal(fire, table.signals.updated, written);
|
|
882
|
+
}
|
|
883
|
+
return written;
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
const bindConnectionSignals = <TStore extends AnyStoreDefinition>(
|
|
888
|
+
definition: TStore,
|
|
889
|
+
connection: CloudflareD1Connection<TStore>,
|
|
890
|
+
fire: BoundFireFn
|
|
891
|
+
): CloudflareD1Connection<TStore> => {
|
|
892
|
+
const bound = {} as Record<string, StoreAccessor<AnyStoreTable>>;
|
|
893
|
+
for (const tableName of definition.tableNames) {
|
|
894
|
+
const table = definition.tables[tableName];
|
|
895
|
+
const accessor = connection[tableName];
|
|
896
|
+
if (table !== undefined && accessor !== undefined) {
|
|
897
|
+
bound[tableName] = bindWritableAccessorSignals(
|
|
898
|
+
table,
|
|
899
|
+
accessor as StoreAccessor<typeof table>,
|
|
900
|
+
fire
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return Object.freeze(bound) as CloudflareD1Connection<TStore>;
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const bindResourceConnection = <TStore extends AnyStoreDefinition>(
|
|
908
|
+
definition: TStore,
|
|
909
|
+
connection: CloudflareD1Connection<TStore>,
|
|
910
|
+
fire: TrailContext['fire']
|
|
911
|
+
): CloudflareD1Connection<TStore> =>
|
|
912
|
+
fire === undefined
|
|
913
|
+
? connection
|
|
914
|
+
: bindConnectionSignals(definition, connection, fire);
|
|
915
|
+
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
// Public API
|
|
918
|
+
// ---------------------------------------------------------------------------
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Connect a store definition to a D1 database binding.
|
|
922
|
+
*
|
|
923
|
+
* The returned connection is synchronous to create so it can flow through the
|
|
924
|
+
* Workers env bridge. Schema creation and optional seeding run lazily before
|
|
925
|
+
* the first accessor operation.
|
|
926
|
+
*
|
|
927
|
+
* @example
|
|
928
|
+
* ```ts
|
|
929
|
+
* import { connectD1 } from '@ontrails/cloudflare/d1';
|
|
930
|
+
*
|
|
931
|
+
* const conn = connectD1(definition, env.DB);
|
|
932
|
+
* const note = await conn.notes.upsert({ id: 'n1', title: 'Hello' });
|
|
933
|
+
* ```
|
|
934
|
+
*/
|
|
935
|
+
export const connectD1 = <TStore extends AnyStoreDefinition>(
|
|
936
|
+
definition: TStore,
|
|
937
|
+
database: CloudflareD1Database,
|
|
938
|
+
options: ConnectD1Options<TStore> = {}
|
|
939
|
+
): CloudflareD1Connection<TStore> => {
|
|
940
|
+
const generateIdentity = options.generateIdentity ?? defaultGenerateIdentity;
|
|
941
|
+
const tablePrefix = options.tablePrefix ?? defaultResourceId;
|
|
942
|
+
let ready: Promise<void> | undefined;
|
|
943
|
+
|
|
944
|
+
const initialize = async (): Promise<void> => {
|
|
945
|
+
for (const tableName of definition.tableNames) {
|
|
946
|
+
const table = definition.tables[tableName];
|
|
947
|
+
if (table !== undefined) {
|
|
948
|
+
await ensureD1Table(database, storageTableName(tablePrefix, table));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
await seedD1Tables(database, definition, tablePrefix, options.seed, false);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const initializeReady = async (): Promise<void> => {
|
|
955
|
+
try {
|
|
956
|
+
await initialize();
|
|
957
|
+
} catch (error) {
|
|
958
|
+
ready = undefined;
|
|
959
|
+
throw error;
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
const ensureReady = (): Promise<void> => {
|
|
964
|
+
ready ??= initializeReady();
|
|
965
|
+
return ready;
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
return buildConnection(definition, (table) =>
|
|
969
|
+
createD1Accessor(
|
|
970
|
+
database,
|
|
971
|
+
storageTableName(tablePrefix, table),
|
|
972
|
+
table,
|
|
973
|
+
ensureReady,
|
|
974
|
+
generateIdentity
|
|
975
|
+
)
|
|
976
|
+
);
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const isD1Binding = (value: unknown): value is CloudflareD1Database => {
|
|
980
|
+
if (typeof value !== 'object' || value === null) {
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
const candidate = value as Partial<
|
|
984
|
+
Record<keyof CloudflareD1Database, unknown>
|
|
985
|
+
>;
|
|
986
|
+
return (
|
|
987
|
+
typeof candidate.exec === 'function' &&
|
|
988
|
+
typeof candidate.prepare === 'function'
|
|
989
|
+
);
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
const createD1Mock = async <TStore extends AnyStoreDefinition>(
|
|
993
|
+
definition: TStore,
|
|
994
|
+
options: CloudflareD1Options<TStore>
|
|
995
|
+
): Promise<CloudflareD1Connection<TStore>> => {
|
|
996
|
+
const connection = createMemoryConnection(definition, options);
|
|
997
|
+
await seedConnection(connection, definition, options.mockSeed, true);
|
|
998
|
+
return connection;
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Author a Trails resource wrapping a Cloudflare D1 database binding.
|
|
1003
|
+
*
|
|
1004
|
+
* The binding arrives through the Workers env bridge, so `create` refuses to
|
|
1005
|
+
* run outside a Worker. Tests use the in-memory mock factory, seeded from
|
|
1006
|
+
* table fixtures or `mockSeed`, and Miniflare can provide a real D1 binding
|
|
1007
|
+
* without a Cloudflare account.
|
|
1008
|
+
*
|
|
1009
|
+
* @example
|
|
1010
|
+
* ```ts
|
|
1011
|
+
* import { cloudflareD1 } from '@ontrails/cloudflare/d1';
|
|
1012
|
+
* import { store } from '@ontrails/store';
|
|
1013
|
+
* import { trail, Result } from '@ontrails/core';
|
|
1014
|
+
* import { z } from 'zod';
|
|
1015
|
+
*
|
|
1016
|
+
* const definition = store({
|
|
1017
|
+
* notes: {
|
|
1018
|
+
* identity: 'id',
|
|
1019
|
+
* schema: z.object({ id: z.string(), title: z.string() }),
|
|
1020
|
+
* },
|
|
1021
|
+
* });
|
|
1022
|
+
* const db = cloudflareD1(definition, { binding: 'DB', id: 'notes.store' });
|
|
1023
|
+
*
|
|
1024
|
+
* const saveNote = trail('note.save', {
|
|
1025
|
+
* implementation: async (input, ctx) => Result.ok(await db.from(ctx).notes.upsert(input)),
|
|
1026
|
+
* input: z.object({ id: z.string(), title: z.string() }),
|
|
1027
|
+
* intent: 'write',
|
|
1028
|
+
* output: z.object({ id: z.string(), title: z.string() }),
|
|
1029
|
+
* resources: [db],
|
|
1030
|
+
* });
|
|
1031
|
+
* ```
|
|
1032
|
+
*/
|
|
1033
|
+
export const cloudflareD1 = <TStore extends AnyStoreDefinition>(
|
|
1034
|
+
definition: TStore,
|
|
1035
|
+
options: CloudflareD1Options<TStore>
|
|
1036
|
+
): CloudflareD1Resource<TStore> => {
|
|
1037
|
+
const scope = options.id ?? defaultResourceId;
|
|
1038
|
+
const store = bindStoreDefinition(definition, scope) as TStore;
|
|
1039
|
+
const base = resource<CloudflareD1Connection<TStore>>(scope, {
|
|
1040
|
+
create: () =>
|
|
1041
|
+
Result.err(
|
|
1042
|
+
new InternalError(
|
|
1043
|
+
`Resource "${scope}" wraps Cloudflare D1 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.`,
|
|
1044
|
+
{ context: { binding: options.binding, resourceId: scope } }
|
|
1045
|
+
)
|
|
1046
|
+
),
|
|
1047
|
+
description:
|
|
1048
|
+
options.description ??
|
|
1049
|
+
`Cloudflare D1 database bound to "${options.binding}"`,
|
|
1050
|
+
meta: {
|
|
1051
|
+
...options.meta,
|
|
1052
|
+
'cloudflare.binding': options.binding,
|
|
1053
|
+
'cloudflare.service': 'd1',
|
|
1054
|
+
},
|
|
1055
|
+
mock: () => createD1Mock(store, options),
|
|
1056
|
+
signals: store.signals,
|
|
1057
|
+
});
|
|
1058
|
+
const d1Resource = Object.freeze({
|
|
1059
|
+
...base,
|
|
1060
|
+
access: 'readwrite' as const,
|
|
1061
|
+
from(ctx: TrailContext) {
|
|
1062
|
+
return bindResourceConnection(store, base.from(ctx), ctx.fire);
|
|
1063
|
+
},
|
|
1064
|
+
signals: store.signals,
|
|
1065
|
+
store,
|
|
1066
|
+
}) as CloudflareD1Resource<TStore>;
|
|
1067
|
+
|
|
1068
|
+
registerEnvBinding(d1Resource, {
|
|
1069
|
+
binding: options.binding,
|
|
1070
|
+
fromEnv: (value) =>
|
|
1071
|
+
isD1Binding(value)
|
|
1072
|
+
? Result.ok(
|
|
1073
|
+
connectD1(store, value, {
|
|
1074
|
+
generateIdentity: options.generateIdentity,
|
|
1075
|
+
seed: options.seed,
|
|
1076
|
+
tablePrefix: options.tablePrefix ?? scope,
|
|
1077
|
+
})
|
|
1078
|
+
)
|
|
1079
|
+
: Result.err(
|
|
1080
|
+
new InternalError(
|
|
1081
|
+
`Worker env binding "${options.binding}" for resource "${scope}" is not a D1 database. Check the d1_databases entry in your wrangler configuration.`,
|
|
1082
|
+
{ context: { binding: options.binding, resourceId: scope } }
|
|
1083
|
+
)
|
|
1084
|
+
),
|
|
1085
|
+
});
|
|
1086
|
+
return d1Resource;
|
|
1087
|
+
};
|