Cloudflare has six data products, and prorm reaches all six. They are spread
across three parts of the library, because they are three different kinds of
thing — and the mapping is the first thing worth knowing:| Cloudflare | In prorm | Import |
|---|
| D1 | a SQL dialect | new Prorm({ dialect: 'd1', … }) |
| Hyperdrive | a connection to Postgres or MySQL | fromHyperdrive(env.HYPERDRIVE) |
| Workers KV | a store | ts-prorm-orm/nosql/cloudflare-kv |
| R2 | a store | ts-prorm-orm/nosql/r2 |
| Vectorize | a store | ts-prorm-orm/nosql/cloudflare-vectorize |
| Queues | a store | ts-prorm-orm/nosql/cloudflare-queues |
| Durable Objects | a store | ts-prorm-orm/nosql/cloudflare-do |
Inside a Worker, two of them are bindings the platform hands you and the rest
are reached over Cloudflare's REST API; Hyperdrive is a hop to a database that
lives somewhere else entirely:prorm packages lists them together under a Cloudflare heading, so you can
pick your whole stack in one pass:$ npx prorm packages --dialects d1 --stores cloudflare-kv,cloudflare-vectorize,r2
1 packages for what you picked:
npm install @aws-sdk/client-s3
Re-run with --run to execute it here.
Only R2 installs anything (@aws-sdk/client-s3, because R2 speaks the S3
protocol). Everything else is fetch or a Worker binding. So a D1-plus-R2
Worker needs exactly two packages:$ npm install ts-prorm-orm @aws-sdk/client-s3
$ pnpm add ts-prorm-orm @aws-sdk/client-s3
$ yarn add ts-prorm-orm @aws-sdk/client-s3
$ bun add ts-prorm-orm @aws-sdk/client-s3
D1 — SQL#
D1 is SQLite, so it is a dialect rather than a store, and the whole model layer
works against it. See the D1 dialect page for what differs
from SQLite — most importantly that D1 has no interactive transactions.A Worker on D1, end to end#
A small notes API: the Worker reaches D1 through its binding, and migrations
run from your machine or CI over the D1 HTTP API, with the same migration files
you would use anywhere else.notes-api/
migrations/
20260926120000-create-notes.js
scripts/
migrate-d1.ts # runs the migrations over the HTTP API
src/
worker.ts
.env # CF_ACCOUNT_ID, CF_D1_DATABASE_ID, CF_API_TOKEN (git-ignored)
wrangler.toml
Create the database and bind it#
$ npx wrangler d1 create notes
Copy the database_id it prints into wrangler.toml:name = "notes-api"
main = "src/worker.ts"
compatibility_date = "2026-09-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB" # env.DB in the Worker
database_name = "notes"
database_id = "<the id wrangler printed>"
Write the migration#
prorm migration:create -- --name create-notes makes the file; fill it in:Run it against D1 over HTTP#
prorm migration:run builds its connection from DATABASE_URL or the DB_*
variables, which have no slot for an account id and API token. So D1
migrations run through the same Migrator the CLI uses, from a script:CF_ACCOUNT_ID=0123456789abcdef0123456789abcdef
CF_D1_DATABASE_ID=6f1c2e1a-0000-4000-8000-000000000000
CF_API_TOKEN=... # a token with D1:Edit
import { Prorm, createMigrator } from 'ts-prorm-orm';
const prorm = new Prorm({
dialect: 'd1',
dialectOptions: {
accountId: process.env.CF_ACCOUNT_ID,
databaseId: process.env.CF_D1_DATABASE_ID,
token: process.env.CF_API_TOKEN,
},
});
await prorm.connect(); // checks all three are set; opens nothing
const migrator = createMigrator({ prorm, path: './migrations' });
await migrator.syncMigrationsTable();
const pending = await migrator.pending();
console.log(`Running ${pending.length} pending migration(s)...`);
const { executed, migrations } = await migrator.up();
console.log(`Executed ${executed} migration(s)`, migrations);
await prorm.close();
$ node --env-file=.env scripts/migrate-d1.ts # Node 22.18+ runs .ts directly
Running 1 pending migration(s)...
Executed 1 migration(s) [ '20260926120000-create-notes' ]
Use it from the Worker#
import { Prorm, DataTypes } from 'ts-prorm-orm';
interface Env {
DB: unknown; // a D1Database, typed by @cloudflare/workers-types if you use it
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const db = new Prorm({ dialect: 'd1', dialectOptions: { binding: env.DB } });
await db.connect();
const Note = db.define(
'Note',
{
title: { type: DataTypes.STRING, allowNull: false },
done: { type: DataTypes.BOOLEAN, defaultValue: false },
},
{ tableName: 'notes' },
);
if (request.method === 'POST') {
const { title } = (await request.json()) as { title: string };
const note = await Note.create({ title });
return Response.json(note.toJSON(), { status: 201 });
}
const open = await Note.findAll({
where: { done: false },
order: [['id', 'DESC']],
limit: 50,
});
return Response.json(open.map((n) => n.toJSON()));
},
};
Try it locally, then deploy#
$ npx wrangler dev # Miniflare's local D1 behind the same binding
$ npx wrangler deploy
[!WARNING]
db.transaction(async (t) => …) on D1 runs each statement as it comes and
cannot roll any of them back: D1 has no BEGIN that survives an await. When
two writes must land together, send them as one batch() on the binding,
which D1 applies atomically:
Hyperdrive — a connection, not a database#
Hyperdrive is the one Cloudflare data product that is not a store. It is a
pooler and cache in front of a Postgres or MySQL server you already have, and
what it hands your Worker is an ordinary connection to that server. There is no
Hyperdrive dialect and there should not be one:import { Prorm, fromHyperdrive } from 'ts-prorm-orm';
export default {
async fetch(request: Request, env: Env) {
const prorm = new Prorm(fromHyperdrive(env.HYPERDRIVE));
await prorm.connect();
// …everything else is ordinary Postgres.
},
};
Pool size. Hyperdrive is the pool. A Worker invocation is short-lived and
there may be thousands of them at once, so a driver-side pool of 10 per
invocation multiplies into connections Hyperdrive then holds open against your
database. fromHyperdrive defaults to pool: { max: 1, min: 0 } for that
reason; raise it only if you have measured a reason to.Which engine. The binding does not say whether it fronts Postgres or MySQL
— that was decided when the Hyperdrive config was created, and only you know
it. It defaults to postgres; pass { dialect: 'mysql' } when it is not.The binding comes from wrangler.toml:compatibility_flags = ["nodejs_compat"] # the pg and mysql2 drivers need Node's APIs
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "<the id from: npx wrangler hyperdrive create …>"
import { Prorm, fromHyperdrive } from 'ts-prorm-orm';
export default {
async fetch(request: Request, env: Env) {
const prorm = new Prorm(fromHyperdrive(env.HYPERDRIVE)); // postgres is the default
await prorm.connect();
try {
const rows = await prorm.query('SELECT now() AS at'); // a SELECT returns its rows
return Response.json(rows);
} finally {
await prorm.close();
}
},
};
import { Prorm, fromHyperdrive } from 'ts-prorm-orm';
export default {
async fetch(request: Request, env: Env) {
const prorm = new Prorm(fromHyperdrive(env.HYPERDRIVE, { dialect: 'mysql' }));
await prorm.connect();
try {
const rows = await prorm.query('SELECT NOW() AS at');
return Response.json(rows);
} finally {
await prorm.close();
}
},
};
Everything after the first argument is ordinary PrormOptions, so
fromHyperdrive(env.HYPERDRIVE, { logging: false, pool: { max: 2 } }) works,
and a pool you pass is merged over the { max: 1, min: 0 } default.Vectorize — vector search#
import { CloudflareVectorizeStore } from 'ts-prorm-orm/nosql/cloudflare-vectorize';
const vectors = new CloudflareVectorizeStore({ accountId, indexName: 'docs', apiToken });
await vectors.connect();
await vectors.upsert([{ id: '1', values: embedding, metadata: { url: '/a' } }]);
const { matches } = await vectors.query(queryEmbedding, { topK: 3, returnMetadata: 'all' });
Writes are queued, not applied: upsert() returns a mutationId and the
vectors become queryable a little later, so a query issued immediately after a
write can legitimately miss it. describe() reports the last processed
mutation. A filter on a metadata key matches nothing until
createMetadataIndex() has been called for that key.Queues — messages#
import { CloudflareQueuesStore } from 'ts-prorm-orm/nosql/cloudflare-queues';
const queue = new CloudflareQueuesStore({
accountId: process.env.CF_ACCOUNT_ID,
queueId: process.env.CF_QUEUE_ID,
apiToken: process.env.CF_API_TOKEN,
});
await queue.connect();
await queue.send({ orderId: 7 });
const { messages } = await queue.pull({ batchSize: 10 });
await queue.ack(messages.map((m) => m.lease_id));
pull() needs the queue to have an HTTP pull consumer. Against a queue
whose consumer is a Worker it returns nothing, which looks like an empty queue
rather than a misconfiguration.A pulled message is borrowed, not consumed: hold on to its lease_id and
acknowledge it, or it is redelivered and processed twice.Durable Objects — the one with a caveat#
There is no REST API for a Durable Object's storage, and there will not be one:
the storage is reachable only from inside the object. Cloudflare's API exposes
two read-only inventory endpoints and nothing else.So this store has two modes. With a binding, inside a Worker, it routes
requests to objects. With an account id and token, from anywhere, it lists
namespaces and object ids and reaches no data at all. Asking one mode for what
only the other can do throws and says why.const objects = new CloudflareDurableObjectsStore({ namespace: env.COUNTER });
await objects.connect();
await objects.fetch('room-42', new Request('https://do/increment'));
There is deliberately no get(name, key): it would have to invent a wire
protocol every one of your Durable Object classes had to implement, and any
that did not would answer 404 and look empty. You own the protocol; this
routes to the right instance.Workers KV and R2#
Both shipped earlier and are unchanged: Workers KV
is the edge key-value store over REST, and R2 is the
S3-compatible object store, built on @aws-sdk/client-s3 — the only Cloudflare
store here that installs a package. Modified at 2026-10-04 00:40:16