@emdash-cms/cloudflare 0.0.1

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.
Files changed (67) hide show
  1. package/dist/auth/index.d.mts +81 -0
  2. package/dist/auth/index.mjs +147 -0
  3. package/dist/cache/config.d.mts +52 -0
  4. package/dist/cache/config.mjs +55 -0
  5. package/dist/cache/runtime.d.mts +40 -0
  6. package/dist/cache/runtime.mjs +191 -0
  7. package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
  8. package/dist/db/d1.d.mts +43 -0
  9. package/dist/db/d1.mjs +74 -0
  10. package/dist/db/do.d.mts +96 -0
  11. package/dist/db/do.mjs +489 -0
  12. package/dist/db/playground-middleware.d.mts +20 -0
  13. package/dist/db/playground-middleware.mjs +533 -0
  14. package/dist/db/playground.d.mts +39 -0
  15. package/dist/db/playground.mjs +26 -0
  16. package/dist/do-class-DY2Ba2RJ.mjs +174 -0
  17. package/dist/do-class-x5Xh_G62.d.mts +73 -0
  18. package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
  19. package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
  20. package/dist/do-types-CY0G0oyh.d.mts +14 -0
  21. package/dist/images-4RT9Ag8_.d.mts +76 -0
  22. package/dist/index.d.mts +200 -0
  23. package/dist/index.mjs +214 -0
  24. package/dist/media/images-runtime.d.mts +10 -0
  25. package/dist/media/images-runtime.mjs +215 -0
  26. package/dist/media/stream-runtime.d.mts +10 -0
  27. package/dist/media/stream-runtime.mjs +218 -0
  28. package/dist/plugins/index.d.mts +32 -0
  29. package/dist/plugins/index.mjs +163 -0
  30. package/dist/sandbox/index.d.mts +255 -0
  31. package/dist/sandbox/index.mjs +945 -0
  32. package/dist/storage/r2.d.mts +31 -0
  33. package/dist/storage/r2.mjs +116 -0
  34. package/dist/stream-DdbcvKi0.d.mts +78 -0
  35. package/package.json +109 -0
  36. package/src/auth/cloudflare-access.ts +303 -0
  37. package/src/auth/index.ts +16 -0
  38. package/src/cache/config.ts +81 -0
  39. package/src/cache/runtime.ts +328 -0
  40. package/src/cloudflare.d.ts +31 -0
  41. package/src/db/d1-introspector.ts +120 -0
  42. package/src/db/d1.ts +112 -0
  43. package/src/db/do-class.ts +275 -0
  44. package/src/db/do-dialect.ts +125 -0
  45. package/src/db/do-playground-routes.ts +65 -0
  46. package/src/db/do-preview-routes.ts +48 -0
  47. package/src/db/do-preview-sign.ts +100 -0
  48. package/src/db/do-preview.ts +268 -0
  49. package/src/db/do-types.ts +12 -0
  50. package/src/db/do.ts +62 -0
  51. package/src/db/playground-middleware.ts +340 -0
  52. package/src/db/playground-toolbar.ts +341 -0
  53. package/src/db/playground.ts +49 -0
  54. package/src/db/preview-toolbar.ts +220 -0
  55. package/src/index.ts +285 -0
  56. package/src/media/images-runtime.ts +353 -0
  57. package/src/media/images.ts +114 -0
  58. package/src/media/stream-runtime.ts +392 -0
  59. package/src/media/stream.ts +118 -0
  60. package/src/plugins/index.ts +7 -0
  61. package/src/plugins/vectorize-search.ts +393 -0
  62. package/src/sandbox/bridge.ts +1008 -0
  63. package/src/sandbox/index.ts +13 -0
  64. package/src/sandbox/runner.ts +357 -0
  65. package/src/sandbox/types.ts +181 -0
  66. package/src/sandbox/wrapper.ts +238 -0
  67. package/src/storage/r2.ts +200 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Cloudflare Cache API route cache provider - CONFIG ENTRY
3
+ *
4
+ * This is the config-time helper. Import it in your astro.config.mjs:
5
+ *
6
+ * ```ts
7
+ * import { cloudflareCache } from "@emdash-cms/cloudflare";
8
+ *
9
+ * export default defineConfig({
10
+ * experimental: {
11
+ * cache: {
12
+ * provider: cloudflareCache(),
13
+ * },
14
+ * },
15
+ * });
16
+ * ```
17
+ *
18
+ * This module does NOT import cloudflare:workers and is safe to use at
19
+ * config time.
20
+ */
21
+
22
+ import type { CacheProviderConfig } from "astro";
23
+
24
+ import type { CloudflareCacheConfig } from "./runtime.js";
25
+
26
+ export type { CloudflareCacheConfig };
27
+
28
+ /**
29
+ * Cloudflare Cache API route cache provider.
30
+ *
31
+ * Uses the Workers Cache API (`cache.put()`/`cache.match()`) to cache
32
+ * rendered route responses at the edge. Invalidation uses the Cloudflare
33
+ * purge-by-tag REST API for global purge across all edge locations.
34
+ *
35
+ * This is a stopgap until CacheW provides native distributed caching
36
+ * for Workers. Worker responses can't go through the CDN cache today,
37
+ * so we use the Cache API directly. The standard `Cache-Tag` header is
38
+ * set on stored responses so the purge-by-tag API can find them.
39
+ *
40
+ * Tag-based invalidation requires a Zone ID and an API token with
41
+ * "Cache Purge" permission. These can be passed directly in the config
42
+ * or read from environment variables at runtime (default: `CF_ZONE_ID`
43
+ * and `CF_CACHE_PURGE_TOKEN`).
44
+ *
45
+ * @param config Optional configuration.
46
+ * @returns A {@link CacheProviderConfig} to pass to `experimental.cache.provider`.
47
+ *
48
+ * @example Basic usage (reads zone ID and token from env vars)
49
+ * ```ts
50
+ * import { defineConfig } from "astro/config";
51
+ * import cloudflare from "@astrojs/cloudflare";
52
+ * import { cloudflareCache } from "@emdash-cms/cloudflare";
53
+ *
54
+ * export default defineConfig({
55
+ * adapter: cloudflare(),
56
+ * experimental: {
57
+ * cache: {
58
+ * provider: cloudflareCache(),
59
+ * },
60
+ * },
61
+ * });
62
+ * ```
63
+ *
64
+ * @example With explicit config
65
+ * ```ts
66
+ * cloudflareCache({
67
+ * cacheName: "my-site",
68
+ * zoneId: "abc123...",
69
+ * apiToken: "xyz789...",
70
+ * })
71
+ * ```
72
+ */
73
+ export function cloudflareCache(
74
+ config: CloudflareCacheConfig = {},
75
+ ): CacheProviderConfig<CloudflareCacheConfig> {
76
+ return {
77
+ // Resolved by Vite/Astro at build time — points to the runtime module
78
+ entrypoint: "@emdash-cms/cloudflare/cache",
79
+ config,
80
+ };
81
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Cloudflare Cache API route cache provider - RUNTIME ENTRY
3
+ *
4
+ * Implements Astro's CacheProvider interface as a runtime provider using the
5
+ * Workers Cache API for storage and the Cloudflare purge-by-tag REST API for
6
+ * global invalidation.
7
+ *
8
+ * This is a temporary solution until CacheW exists. Workers responses can't
9
+ * go through the CDN cache, so we use cache.put()/cache.match() directly.
10
+ * The standard `Cache-Tag` header (set by Astro's default setHeaders) is
11
+ * preserved on cached responses so the purge-by-tag API works globally.
12
+ *
13
+ * We do NOT implement setHeaders() — Astro's defaultSetHeaders correctly
14
+ * emits CDN-Cache-Control and Cache-Tag. Our onRequest() reads those
15
+ * headers from the response that next() returns.
16
+ *
17
+ * Do NOT import this at config time. Use cloudflareCache() from
18
+ * "@emdash-cms/cloudflare" or "@emdash-cms/cloudflare/cache/config" instead.
19
+ */
20
+
21
+ import type { CacheProviderFactory } from "astro";
22
+ import { env, waitUntil } from "cloudflare:workers";
23
+
24
+ /**
25
+ * Internal headers stored on cached responses for freshness tracking.
26
+ * These are removed before returning to the client.
27
+ */
28
+ const STORED_AT_HEADER = "X-EmDash-Stored-At";
29
+ const MAX_AGE_HEADER = "X-EmDash-Max-Age";
30
+ const SWR_HEADER = "X-EmDash-SWR";
31
+
32
+ /** Cloudflare purge API base */
33
+ const CF_API_BASE = "https://api.cloudflare.com/client/v4";
34
+
35
+ /** Matches max-age in CDN-Cache-Control */
36
+ const MAX_AGE_REGEX = /max-age=(\d+)/;
37
+
38
+ /** Matches stale-while-revalidate in CDN-Cache-Control */
39
+ const SWR_REGEX = /stale-while-revalidate=(\d+)/;
40
+
41
+ /** Internal headers to strip before returning responses to the client */
42
+ const INTERNAL_HEADERS = [STORED_AT_HEADER, MAX_AGE_HEADER, SWR_HEADER];
43
+
44
+ /** Default D1 bookmark cookie name (from @emdash-cms/cloudflare d1 config) */
45
+ const DEFAULT_BOOKMARK_COOKIE = "__ec_d1_bookmark";
46
+
47
+ export interface CloudflareCacheConfig {
48
+ /**
49
+ * Name of the Cache API cache to use.
50
+ * @default "emdash"
51
+ */
52
+ cacheName?: string;
53
+
54
+ /**
55
+ * D1 bookmark cookie name. Responses whose only Set-Cookie is this
56
+ * bookmark will have it stripped before caching. Responses with any
57
+ * other Set-Cookie headers will not be cached.
58
+ * @default "__ec_d1_bookmark"
59
+ */
60
+ bookmarkCookie?: string;
61
+
62
+ /**
63
+ * Cloudflare Zone ID. Required for tag-based invalidation.
64
+ * If not provided, reads from `zoneIdEnvVar` at runtime.
65
+ */
66
+ zoneId?: string;
67
+
68
+ /**
69
+ * Environment variable name containing the Zone ID.
70
+ * @default "CF_ZONE_ID"
71
+ */
72
+ zoneIdEnvVar?: string;
73
+
74
+ /**
75
+ * Cloudflare API token with Cache Purge permission.
76
+ * If not provided, reads from `apiTokenEnvVar` at runtime.
77
+ */
78
+ apiToken?: string;
79
+
80
+ /**
81
+ * Environment variable name containing the API token.
82
+ * @default "CF_CACHE_PURGE_TOKEN"
83
+ */
84
+ apiTokenEnvVar?: string;
85
+ }
86
+
87
+ /**
88
+ * Parse CDN-Cache-Control header for max-age and stale-while-revalidate.
89
+ */
90
+ function parseCdnCacheControl(header: string | null): { maxAge: number; swr: number } {
91
+ let maxAge = 0;
92
+ let swr = 0;
93
+ if (!header) return { maxAge, swr };
94
+ const maxAgeMatch = MAX_AGE_REGEX.exec(header);
95
+ if (maxAgeMatch) maxAge = parseInt(maxAgeMatch[1]!, 10) || 0;
96
+ const swrMatch = SWR_REGEX.exec(header);
97
+ if (swrMatch) swr = parseInt(swrMatch[1]!, 10) || 0;
98
+ return { maxAge, swr };
99
+ }
100
+
101
+ /**
102
+ * Normalize a URL for use as a cache key.
103
+ * Strips common tracking query parameters and sorts the rest.
104
+ */
105
+ function normalizeCacheKey(url: URL): string {
106
+ const normalized = new URL(url.toString());
107
+
108
+ const trackingParams = [
109
+ "utm_source",
110
+ "utm_medium",
111
+ "utm_campaign",
112
+ "utm_term",
113
+ "utm_content",
114
+ "fbclid",
115
+ "gclid",
116
+ "gbraid",
117
+ "wbraid",
118
+ "dclid",
119
+ "msclkid",
120
+ "twclid",
121
+ "_ga",
122
+ "_gl",
123
+ ];
124
+ for (const param of trackingParams) {
125
+ normalized.searchParams.delete(param);
126
+ }
127
+ normalized.searchParams.sort();
128
+
129
+ return normalized.toString();
130
+ }
131
+
132
+ /**
133
+ * Read a config value, falling back to an env var.
134
+ */
135
+ function resolveEnvValue(explicit: string | undefined, envVarName: string): string | undefined {
136
+ if (explicit) return explicit;
137
+ return (env as Record<string, unknown>)[envVarName] as string | undefined;
138
+ }
139
+
140
+ /**
141
+ * Strip internal tracking headers from a response before returning to client.
142
+ */
143
+ function stripInternalHeaders(response: Response): void {
144
+ for (const header of INTERNAL_HEADERS) {
145
+ response.headers.delete(header);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Check whether all Set-Cookie headers on a response are only the D1
151
+ * bookmark cookie. Returns true if we can safely strip them for caching.
152
+ * Returns false if there are non-bookmark cookies (session, auth, etc.)
153
+ * which means the response should NOT be cached.
154
+ */
155
+ function hasOnlyBookmarkCookies(response: Response, bookmarkCookie: string): boolean {
156
+ const cookies = response.headers.getSetCookie();
157
+ if (cookies.length === 0) return true;
158
+ return cookies.every((c) => c.startsWith(`${bookmarkCookie}=`));
159
+ }
160
+
161
+ /**
162
+ * Prepare a response for storage in the Cache API.
163
+ * - Adds internal tracking headers (stored-at, max-age, swr)
164
+ * - Strips Set-Cookie (only called when cookies are safe to strip)
165
+ *
166
+ * Returns null if the response has non-bookmark Set-Cookie headers
167
+ * and should not be cached.
168
+ */
169
+ function prepareForCache(
170
+ response: Response,
171
+ maxAge: number,
172
+ swr: number,
173
+ bookmarkCookie: string,
174
+ ): Response | null {
175
+ if (!hasOnlyBookmarkCookies(response, bookmarkCookie)) {
176
+ return null;
177
+ }
178
+ const prepared = new Response(response.body, response);
179
+ prepared.headers.set(STORED_AT_HEADER, String(Date.now()));
180
+ prepared.headers.set(MAX_AGE_HEADER, String(maxAge));
181
+ prepared.headers.set(SWR_HEADER, String(swr));
182
+ prepared.headers.delete("Set-Cookie");
183
+ return prepared;
184
+ }
185
+
186
+ const factory: CacheProviderFactory<CloudflareCacheConfig> = (config) => {
187
+ const cacheName = config?.cacheName ?? "emdash";
188
+ const bookmarkCookie = config?.bookmarkCookie ?? DEFAULT_BOOKMARK_COOKIE;
189
+ const zoneIdEnvVar = config?.zoneIdEnvVar ?? "CF_ZONE_ID";
190
+ const apiTokenEnvVar = config?.apiTokenEnvVar ?? "CF_CACHE_PURGE_TOKEN";
191
+
192
+ async function getCache(): Promise<Cache> {
193
+ return caches.open(cacheName);
194
+ }
195
+
196
+ return {
197
+ name: "cloudflare-cache-api",
198
+
199
+ // No setHeaders() — we use Astro's defaultSetHeaders which correctly
200
+ // emits CDN-Cache-Control and Cache-Tag. Our onRequest() reads those.
201
+
202
+ async onRequest(context, next) {
203
+ // Only cache GET requests
204
+ if (context.request.method !== "GET") {
205
+ return next();
206
+ }
207
+
208
+ // Skip cache for authenticated users. Their responses may differ
209
+ // (edit toolbar, admin UI, draft content) and must not be served
210
+ // to other visitors. The Astro session cookie indicates a logged-in user.
211
+ const cookieHeader = context.request.headers.get("Cookie") ?? "";
212
+ if (cookieHeader.includes("astro-session=")) {
213
+ return next();
214
+ }
215
+
216
+ const cacheKey = normalizeCacheKey(context.url);
217
+ const cache = await getCache();
218
+
219
+ const cached = await cache.match(cacheKey);
220
+
221
+ if (cached) {
222
+ const storedAt = parseInt(cached.headers.get(STORED_AT_HEADER) ?? "0", 10);
223
+ const maxAge = parseInt(cached.headers.get(MAX_AGE_HEADER) ?? "0", 10);
224
+ const swr = parseInt(cached.headers.get(SWR_HEADER) ?? "0", 10);
225
+ const ageSeconds = (Date.now() - storedAt) / 1000;
226
+
227
+ if (ageSeconds < maxAge) {
228
+ // Fresh — serve from cache
229
+ const hit = new Response(cached.body, cached);
230
+ hit.headers.set("X-Astro-Cache", "HIT");
231
+ stripInternalHeaders(hit);
232
+ return hit;
233
+ }
234
+
235
+ if (swr > 0 && ageSeconds < maxAge + swr) {
236
+ // Stale but within SWR window — serve stale, revalidate in background
237
+ const stale = new Response(cached.body, cached);
238
+ stale.headers.set("X-Astro-Cache", "STALE");
239
+ stripInternalHeaders(stale);
240
+
241
+ waitUntil(
242
+ (async () => {
243
+ try {
244
+ const fresh = await next();
245
+ const cdnCC = fresh.headers.get("CDN-Cache-Control");
246
+ const parsed = parseCdnCacheControl(cdnCC);
247
+ if (parsed.maxAge > 0 && fresh.ok) {
248
+ const toStore = prepareForCache(fresh, parsed.maxAge, parsed.swr, bookmarkCookie);
249
+ if (toStore) {
250
+ await cache.put(cacheKey, toStore);
251
+ }
252
+ }
253
+ } catch {
254
+ // Non-fatal — next request will retry
255
+ }
256
+ })(),
257
+ );
258
+
259
+ return stale;
260
+ }
261
+
262
+ // Expired and past SWR window — delete and fall through
263
+ await cache.delete(cacheKey);
264
+ }
265
+
266
+ // Cache MISS — render
267
+ const response = await next();
268
+
269
+ // Read cache directives from CDN-Cache-Control (set by Astro's defaultSetHeaders)
270
+ const cdnCC = response.headers.get("CDN-Cache-Control");
271
+ const { maxAge, swr } = parseCdnCacheControl(cdnCC);
272
+
273
+ if (maxAge > 0 && response.ok) {
274
+ const toStore = prepareForCache(response.clone(), maxAge, swr, bookmarkCookie);
275
+ if (toStore) {
276
+ await cache.put(cacheKey, toStore);
277
+ }
278
+
279
+ const miss = new Response(response.body, response);
280
+ miss.headers.set("X-Astro-Cache", "MISS");
281
+ return miss;
282
+ }
283
+
284
+ // No cache directives — pass through without caching
285
+ return response;
286
+ },
287
+
288
+ async invalidate(options) {
289
+ if (options.tags) {
290
+ const zoneId = resolveEnvValue(config?.zoneId, zoneIdEnvVar);
291
+ const apiToken = resolveEnvValue(config?.apiToken, apiTokenEnvVar);
292
+
293
+ if (!zoneId || !apiToken) {
294
+ throw new Error(
295
+ `[cloudflare-cache-api] Tag-based invalidation requires a Zone ID and API token. ` +
296
+ `Set the ${zoneIdEnvVar} and ${apiTokenEnvVar} environment variables, ` +
297
+ `or pass zoneId/apiToken in the cloudflareCache() config.`,
298
+ );
299
+ }
300
+
301
+ const tags = Array.isArray(options.tags) ? options.tags : [options.tags];
302
+
303
+ const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/purge_cache`, {
304
+ method: "POST",
305
+ headers: {
306
+ Authorization: `Bearer ${apiToken}`,
307
+ "Content-Type": "application/json",
308
+ },
309
+ body: JSON.stringify({ tags }),
310
+ });
311
+
312
+ if (!response.ok) {
313
+ const body = await response.text().catch(() => "");
314
+ throw new Error(
315
+ `[cloudflare-cache-api] Cache purge failed (${response.status}): ${body}`,
316
+ );
317
+ }
318
+ }
319
+
320
+ if (options.path) {
321
+ const cache = await getCache();
322
+ await cache.delete(options.path);
323
+ }
324
+ },
325
+ };
326
+ };
327
+
328
+ export default factory;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Type declarations for Cloudflare virtual modules
3
+ *
4
+ * These are only available at runtime on Cloudflare Workers.
5
+ * The types here are minimal - just enough for our usage.
6
+ */
7
+
8
+ declare module "cloudflare:workers" {
9
+ /**
10
+ * Environment bindings object
11
+ * Contains all bindings defined in wrangler.toml (D1, R2, KV, etc.)
12
+ */
13
+ export const env: Record<string, unknown>;
14
+
15
+ /**
16
+ * Exports object for loopback bindings
17
+ */
18
+ export const exports: Record<string, unknown>;
19
+
20
+ /**
21
+ * Base class for Worker Entrypoints
22
+ */
23
+ export class WorkerEntrypoint<TEnv = unknown, TProps = unknown> {
24
+ env: TEnv;
25
+ ctx: ExecutionContext & { props: TProps };
26
+ }
27
+ }
28
+
29
+ declare module "cloudflare:email" {
30
+ // Email worker types if needed
31
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * D1-compatible SQLite Introspector
3
+ *
4
+ * D1 doesn't allow the correlated cross-join pattern that Kysely's default
5
+ * SqliteIntrospector uses: `FROM tl, pragma_table_info(tl.name)`
6
+ *
7
+ * This introspector queries tables individually instead.
8
+ */
9
+
10
+ import type { DatabaseIntrospector, DatabaseMetadata, SchemaMetadata, TableMetadata } from "kysely";
11
+ import { sql } from "kysely";
12
+
13
+ // Kysely's default migration table names
14
+ const DEFAULT_MIGRATION_TABLE = "kysely_migration";
15
+ const DEFAULT_MIGRATION_LOCK_TABLE = "kysely_migration_lock";
16
+
17
+ // Kysely's DatabaseIntrospector.createIntrospector receives Kysely<any>.
18
+ // We must use `any` here to match Kysely's own interface contract —
19
+ // it needs untyped schema access to query sqlite_master dynamically.
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ type AnyKysely = any;
22
+
23
+ // Regex patterns for parsing CREATE TABLE statements
24
+ const SPLIT_PARENS_PATTERN = /[(),]/;
25
+ const WHITESPACE_PATTERN = /\s+/;
26
+ const QUOTES_PATTERN = /["`]/g;
27
+
28
+ export class D1Introspector implements DatabaseIntrospector {
29
+ readonly #db: AnyKysely;
30
+
31
+ constructor(db: AnyKysely) {
32
+ this.#db = db;
33
+ }
34
+
35
+ async getSchemas(): Promise<SchemaMetadata[]> {
36
+ // SQLite doesn't support schemas
37
+ return [];
38
+ }
39
+
40
+ async getTables(options: { withInternalKyselyTables?: boolean } = {}): Promise<TableMetadata[]> {
41
+ // Get table names from sqlite_master
42
+ let query = this.#db
43
+ .selectFrom("sqlite_master")
44
+ .where("type", "in", ["table", "view"])
45
+ .where("name", "not like", "sqlite_%")
46
+ .where("name", "not like", "_cf_%") // Skip Cloudflare internal tables
47
+ .select(["name", "sql", "type"])
48
+ .orderBy("name");
49
+
50
+ if (!options.withInternalKyselyTables) {
51
+ query = query
52
+ .where("name", "!=", DEFAULT_MIGRATION_TABLE)
53
+ .where("name", "!=", DEFAULT_MIGRATION_LOCK_TABLE);
54
+ }
55
+
56
+ const tables = await query.execute();
57
+
58
+ // Query each table's columns individually (avoiding the problematic cross-join)
59
+ const result: TableMetadata[] = [];
60
+
61
+ for (const table of tables) {
62
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely's DatabaseIntrospector returns untyped results
63
+ const tableName = table.name as string;
64
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely's DatabaseIntrospector returns untyped results
65
+ const tableType = table.type as string;
66
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely's DatabaseIntrospector returns untyped results
67
+ const tableSql = table.sql as string | null;
68
+
69
+ // Get columns for this specific table
70
+ // Use sql.raw() to insert table name directly into query string
71
+ // D1 doesn't allow parameterized table names in pragma_table_info()
72
+ // Note: tableName comes from sqlite_master so it's safe
73
+ const columns = await sql<{
74
+ cid: number;
75
+ name: string;
76
+ type: string;
77
+ notnull: number;
78
+ dflt_value: string | null;
79
+ pk: number;
80
+ }>`SELECT * FROM pragma_table_info('${sql.raw(tableName)}')`.execute(this.#db);
81
+
82
+ // Try to find autoincrement column from CREATE TABLE statement
83
+ let autoIncrementCol = tableSql
84
+ ?.split(SPLIT_PARENS_PATTERN)
85
+ ?.find((it) => it.toLowerCase().includes("autoincrement"))
86
+ ?.trimStart()
87
+ ?.split(WHITESPACE_PATTERN)?.[0]
88
+ ?.replace(QUOTES_PATTERN, "");
89
+
90
+ // Otherwise, check for INTEGER PRIMARY KEY (implicit autoincrement)
91
+ if (!autoIncrementCol) {
92
+ const pkCols = columns.rows.filter((r) => r.pk > 0);
93
+ if (pkCols.length === 1 && pkCols[0]!.type.toLowerCase() === "integer") {
94
+ autoIncrementCol = pkCols[0]!.name;
95
+ }
96
+ }
97
+
98
+ result.push({
99
+ name: tableName,
100
+ isView: tableType === "view",
101
+ columns: columns.rows.map((col) => ({
102
+ name: col.name,
103
+ dataType: col.type,
104
+ isNullable: !col.notnull,
105
+ isAutoIncrementing: col.name === autoIncrementCol,
106
+ hasDefaultValue: col.dflt_value != null,
107
+ comment: undefined,
108
+ })),
109
+ });
110
+ }
111
+
112
+ return result;
113
+ }
114
+
115
+ async getMetadata(options?: { withInternalKyselyTables?: boolean }): Promise<DatabaseMetadata> {
116
+ return {
117
+ tables: await this.getTables(options),
118
+ };
119
+ }
120
+ }
package/src/db/d1.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Cloudflare D1 runtime adapter - RUNTIME ENTRY
3
+ *
4
+ * Creates a Kysely dialect for D1.
5
+ * Loaded at runtime via virtual module when database queries are needed.
6
+ *
7
+ * This module imports directly from cloudflare:workers to access the D1 binding.
8
+ * Do NOT import this at config time - use { d1 } from "@emdash-cms/cloudflare" instead.
9
+ */
10
+
11
+ import { env } from "cloudflare:workers";
12
+ import type { DatabaseIntrospector, Dialect, Kysely } from "kysely";
13
+ import { D1Dialect } from "kysely-d1";
14
+
15
+ import { D1Introspector } from "./d1-introspector.js";
16
+
17
+ /**
18
+ * D1 configuration (runtime type — matches the config-time type in index.ts)
19
+ */
20
+ interface D1Config {
21
+ binding: string;
22
+ session?: "disabled" | "auto" | "primary-first";
23
+ bookmarkCookie?: string;
24
+ }
25
+
26
+ /**
27
+ * Custom D1 Dialect that uses our D1-compatible introspector
28
+ *
29
+ * The default kysely-d1 dialect uses SqliteIntrospector which does a
30
+ * cross-join with pragma_table_info() that D1 doesn't allow.
31
+ */
32
+ class EmDashD1Dialect extends D1Dialect {
33
+ override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
34
+ return new D1Introspector(db);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Create a D1 dialect from config
40
+ *
41
+ * @param config - D1 configuration with binding name
42
+ */
43
+ export function createDialect(config: D1Config): Dialect {
44
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding accessed from untyped env object
45
+ const db = (env as Record<string, unknown>)[config.binding];
46
+
47
+ if (!db) {
48
+ throw new Error(
49
+ `D1 binding "${config.binding}" not found in environment. ` +
50
+ `Check your wrangler.toml configuration:\n\n` +
51
+ `[[d1_databases]]\n` +
52
+ `binding = "${config.binding}"\n` +
53
+ `database_name = "your-database-name"\n` +
54
+ `database_id = "your-database-id"`,
55
+ );
56
+ }
57
+
58
+ // Use our custom dialect with D1-compatible introspector
59
+ // db is unknown from env access; D1Dialect expects D1Database
60
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- D1Database binding from untyped env object
61
+ return new EmDashD1Dialect({ database: db as D1Database });
62
+ }
63
+
64
+ // =========================================================================
65
+ // D1 Read Replica Session Helpers
66
+ //
67
+ // These are exported through virtual:emdash/dialect so the middleware
68
+ // can create per-request D1 sessions without importing cloudflare:workers.
69
+ // =========================================================================
70
+
71
+ /**
72
+ * Whether D1 sessions are enabled in the config.
73
+ */
74
+ export function isSessionEnabled(config: D1Config): boolean {
75
+ return !!config.session && config.session !== "disabled";
76
+ }
77
+
78
+ /**
79
+ * Get the raw D1 binding for creating sessions.
80
+ * Returns null if sessions are disabled.
81
+ */
82
+ export function getD1Binding(config: D1Config): D1Database | null {
83
+ if (!isSessionEnabled(config)) return null;
84
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding accessed from untyped env object
85
+ const db = (env as Record<string, unknown>)[config.binding] as D1Database | undefined;
86
+ return db ?? null;
87
+ }
88
+
89
+ /**
90
+ * Get the default session constraint for the config's session mode.
91
+ */
92
+ export function getDefaultConstraint(config: D1Config): string {
93
+ if (config.session === "primary-first") return "first-primary";
94
+ return "first-unconstrained";
95
+ }
96
+
97
+ /**
98
+ * Get the cookie name used for storing D1 session bookmarks.
99
+ */
100
+ export function getBookmarkCookieName(config: D1Config): string {
101
+ return config.bookmarkCookie ?? "__ec_d1_bookmark";
102
+ }
103
+
104
+ /**
105
+ * Create a Kysely dialect from a D1 session object.
106
+ *
107
+ * D1DatabaseSession has the same `prepare()` / `batch()` interface
108
+ * as D1Database, so we pass it directly to D1Dialect.
109
+ */
110
+ export function createSessionDialect(session: D1Database): Dialect {
111
+ return new EmDashD1Dialect({ database: session });
112
+ }