Kynetra FX

Documentation

D1 adapter

@kynetra/fx-cloudflare ships two D1 adapters: d1Sql for raw parameterised SQL queries and d1Store for a collection-based document-store abstraction. Both implement open port interfaces, so the same application can run against Cloudflare D1 in production and a Postgres database when self-hosted.

Overview#

Cloudflare D1 is a serverless SQLite database that runs at the edge alongside your Workers. Kynetra FX wraps it through two typed adapters that share a common interface contract with the rest of the framework's port system.

  • d1Sql(db) returns a SqlPort — a thin, typed wrapper over D1's prepared-statement API that handles parameter binding and result mapping.
  • d1Store(db) returns a StorePort — a schemaless document store backed by a single fx_store table; suitable for the SaaS kernel, feature flags, or any JSON-document workload.
  • D1_STORE_MIGRATION is a string constant containing the DDL to create the fx_store table. Apply it once via Wrangler before using d1Store.

Note

Both adapters accept a raw D1Database binding, which is available on ctx.env.DB (or whatever name you give the binding in wrangler.toml). See Cloudflare Workers for binding setup.

Installation#

npm install @kynetra/fx @kynetra/fx-cloudflare

Declare a D1 database binding in your wrangler.toml. The binding name becomes the property name on ctx.env.

wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

d1Sql — raw SQL#

d1Sql(db) wraps a D1Database binding and returns a SqlPort. Use it when you need full control over your schema, joins, or aggregation queries.

SqlPort interface

NameTypeDescription
query<T>(sql, params?)Promise<T[]>Run a SELECT query with optional positional parameters. Returns all matching rows typed as T.
execute(sql, params?)Promise<void>Run an INSERT, UPDATE, DELETE, or DDL statement. Returns nothing.
src/index.ts
import { createFX } from '@kynetra/fx'
import { cloudflare, d1Sql } from '@kynetra/fx-cloudflare'
 
const app = createFX({ runtime: 'cloudflare' })
 
app.get('/users', async (ctx) => {
const sql = d1Sql(ctx.env.DB)
const users = await sql.query<{ id: string; email: string }>(
'SELECT id, email FROM users WHERE active = ?',
[1]
)
return ctx.json(users)
})
 
app.post('/users', async (ctx) => {
const body = await ctx.jsonBody<{ email: string }>()
const sql = d1Sql(ctx.env.DB)
await sql.execute(
'INSERT INTO users (id, email, active) VALUES (?, ?, ?)',
[crypto.randomUUID(), body.email, 1]
)
return ctx.json({ ok: true }, { status: 201 })
})
 
return cloudflare(app)

Tip

D1 uses SQLite under the hood. All standard SQLite syntax is supported, including CTEs, window functions, and JSON functions (json_extract, json_object, etc.). Positional parameters use ? placeholders.

d1Store — document store#

d1Store(db) returns a StorePort — a collection-based, schemaless document API backed by a single fx_store table. Documents are stored as JSON strings and deserialised automatically on read. This is the persistence layer consumed by the FX SaaS kernel.

StorePort interface

NameTypeDescription
get<T>(collection, id)Promise<T | null>Fetch a single document by collection name and ID. Returns null if not found.
list<T>(collection, filter?)Promise<T[]>List all documents in a collection. The optional filter is an object whose keys are matched against top-level document fields.
put<T>(collection, record)Promise<T>Insert or replace a document. The record must include an id field. Returns the stored document.
delete(collection, id)Promise<void>Delete a document by collection name and ID. No-ops if the document does not exist.
src/index.ts
import { createFX } from '@kynetra/fx'
import { cloudflare, d1Store } from '@kynetra/fx-cloudflare'
 
interface Post { id: string; title: string; body: string }
 
const app = createFX({ runtime: 'cloudflare' })
 
app.post('/posts', async (ctx) => {
const store = d1Store(ctx.env.DB)
const body = await ctx.jsonBody<Omit<Post, 'id'>>()
const post = await store.put<Post>('posts', { id: crypto.randomUUID(), ...body })
return ctx.json(post, { status: 201 })
})
 
app.get('/posts', async (ctx) => {
const store = d1Store(ctx.env.DB)
const posts = await store.list<Post>('posts')
return ctx.json(posts)
})
 
app.get('/posts/:id', async (ctx) => {
const store = d1Store(ctx.env.DB)
const post = await store.get<Post>('posts', ctx.params.id)
if (!post) return ctx.json({ error: 'not found' }, { status: 404 })
return ctx.json(post)
})
 
app.delete('/posts/:id', async (ctx) => {
const store = d1Store(ctx.env.DB)
await store.delete('posts', ctx.params.id)
return ctx.json({ ok: true })
})
 
return cloudflare(app)

Running the SaaS kernel on D1#

The FX SaaS kernel (@kynetra/fx-saas) is built on the StorePort abstraction. Pass d1Store(env.DB) as the store option to createSaasKernel and you get a fully-featured multi-tenant kernel backed by Cloudflare D1.

src/index.ts
import { createFX } from '@kynetra/fx'
import { cloudflare, d1Store } from '@kynetra/fx-cloudflare'
import { createSaasKernel } from '@kynetra/fx-saas'
 
const app = createFX({ runtime: 'cloudflare' })
 
// Build a fresh kernel per request so each request gets its own
// DB binding reference from ctx.env
app.use(async (ctx, next) => {
const kernel = createSaasKernel({ store: d1Store(ctx.env.DB) })
ctx.decorate('kernel', kernel)
return next()
})
 
app.get('/users/:id', async (ctx) => {
const kernel = ctx.get<ReturnType<typeof createSaasKernel>>('kernel')
const user = await kernel.users.get(ctx.params.id)
if (!user) return ctx.json({ error: 'not found' }, { status: 404 })
return ctx.json(user)
})
 
app.get('/tenants/:tenantId/members', async (ctx) => {
const kernel = ctx.get<ReturnType<typeof createSaasKernel>>('kernel')
const members = await kernel.members.list({ tenantId: ctx.params.tenantId })
return ctx.json(members)
})
 
return cloudflare(app)

Tip

If your application is small or traffic is low, a module-level kernel instance is fine. For high-throughput Workers where you want to avoid any chance of request bleed-over, constructing the kernel in middleware (as above) is the safest pattern.

Migration steps#

Before using d1Store, you need to create the fx_store table in your D1 database. The D1_STORE_MIGRATION constant exported from @kynetra/fx-cloudflare contains the exact DDL.

inspect migration DDL
import { D1_STORE_MIGRATION } from '@kynetra/fx-cloudflare'
 
// D1_STORE_MIGRATION is a string you can log or pass to d1 execute:
console.log(D1_STORE_MIGRATION)
// CREATE TABLE IF NOT EXISTS fx_store (
// collection TEXT NOT NULL,
// id TEXT NOT NULL,
// data TEXT NOT NULL,
// PRIMARY KEY (collection, id)
// )
1

Install @kynetra/fx-cloudflare

npm install @kynetra/fx-cloudflare
2

Add the D1 binding to wrangler.toml

Declare the database under [[d1_databases]] as shown in the Installation section. Note the binding name — it becomes ctx.env.DB (or whatever you choose).
3

Create the database (if it does not exist yet)

npx wrangler d1 create my-db
Copy the database_id from the output and paste it into wrangler.toml.
4

Apply the fx_store migration

Run the DDL against your D1 database using Wrangler. Use --local for the local dev database and omit it for production.
# Local (dev)
npx wrangler d1 execute my-db --local --command="CREATE TABLE IF NOT EXISTS fx_store (collection TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY (collection, id))"
 
# Production
npx wrangler d1 execute my-db --command="CREATE TABLE IF NOT EXISTS fx_store (collection TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY (collection, id))"
5

Use d1Store in your application

Pass d1Store(ctx.env.DB) directly to your handlers or to createSaasKernel({ store }). No further setup is needed.

Note

You can also apply migrations using a dedicated migration file and wrangler d1 migrations apply if you prefer a versioned migration workflow. Create a migrations/ directory and add 0001_fx_store.sql containing the DDL above, then run npx wrangler d1 migrations apply my-db.

Self-hosted Postgres alternative#

Because both d1Sql and d1Store implement open port interfaces (SqlPort and StorePort), swapping the persistence layer requires only a one-line change: replace the adapter, keep everything else.

Using a community pgStore adapter

When deploying to your own infrastructure (Node.js, Bun, or Deno), use a communitypgStore adapter that wraps a Postgres connection pool:

src/kernel.ts
import { createSaasKernel } from '@kynetra/fx-saas'
import { pgStore } from '@kynetra/fx-postgres' // community adapter
 
import { pool } from './db' // your pg Pool instance
 
// Same kernel API — only the store implementation changes
export const kernel = createSaasKernel({ store: pgStore(pool) })

Implementing StorePort yourself

You can also implement StorePort directly for any backend — Redis, DynamoDB, libSQL, or anything else — by satisfying the four-method interface:

src/my-store.ts
import type { StorePort } from '@kynetra/fx-cloudflare'
 
export function myStore(client: MyDatabaseClient): StorePort {
return {
async get<T>(collection: string, id: string): Promise<T | null> {
// fetch and deserialise document
const row = await client.findOne(collection, id)
return row ? (JSON.parse(row.data) as T) : null
},
async list<T>(collection: string, filter?: Record<string, unknown>): Promise<T[]> {
const rows = await client.findAll(collection, filter)
return rows.map((r) => JSON.parse(r.data) as T)
},
async put<T>(collection: string, record: T & { id: string }): Promise<T> {
await client.upsert(collection, record.id, JSON.stringify(record))
return record
},
async delete(collection: string, id: string): Promise<void> {
await client.remove(collection, id)
},
}
}

Pass the result to createSaasKernel exactly as you would d1Store:

src/kernel.ts
import { createSaasKernel } from '@kynetra/fx-saas'
import { myStore } from './my-store'
import { client } from './db'
 
export const kernel = createSaasKernel({ store: myStore(client) })

Note

See Cloudflare Workers for binding and deployment setup, SaaS kernel for the full kernel API, Ports for the port/adapter pattern used throughout the framework, and self-hosting for deploying to your own infrastructure.