@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,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
+ };