Database
A Swirls-managed PostgreSQL database, declared with a Prisma schema, migrated automatically, and queried from workflows through a generated typed client.
What it is. A PostgreSQL database Swirls provisions and operates for your project, declared with a Prisma schema instead of a connection string.
Use it when you want a relational database without provisioning one yourself, and you want to query it from workflows with a typed client instead of hand-written SQL.
Works with type: database nodes: every read and write goes through one, narrowed to the operation it declares, so it's governed, reviewable, and traced; migration blocks declare data transforms a schema change can't express; nested connection blocks provision named connection strings when an application outside Swirls needs to reach the same database. For a database you already run yourself, use Postgres instead.
database blocks declare a Swirls-managed Postgres: Swirls provisions it on deploy, migrates its schema when it changes, and holds the owner connection encrypted with your project's keyset. Query the database from a type: database node's run: block through a generated, fully typed Prisma client, so you write context.db.my_db.user.findMany({ where: { role: "ADMIN" } }) instead of raw SQL and a matching row schema. When code outside your workflows needs the same database, declare a named connection and copy its connection string.
Managed vs. external
database | postgres | |
|---|---|---|
| Ownership | Swirls provisions and operates it | You supply a connection string to a database you run |
| Schema | Prisma schema language, migrated as real DDL | Hand-written JSON Schema per table, validation only |
| Queries | Generated typed Prisma client (context.db.<name>), from a database node | Raw @sql with {{key}} placeholders |
| Connection | Held by Swirls, encrypted with your project's keyset; declare named connections for direct access | A secret reference or literal you provide |
Use database for a new relational store you want Swirls to run. Use postgres for a database you already operate.
Declaring a database block
There is no type: field: the keyword database identifies the block. Its schema is Prisma schema language inside a @prisma { } island.
| Field | Required | Description |
|---|---|---|
label | No | Human-readable label. |
description | No | Human-readable description. |
schema | Yes | @prisma { } island: models and enums, written in the Prisma schema language. |
database my_db {
label: "App database"
schema: @prisma {
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
email String @unique
role Role @default(USER)
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String @db.VarChar(255)
author User @relation(fields: [authorId], references: [id])
authorId Int
}
enum Role {
USER
ADMIN
}
}
}The @prisma island holds models and enums only. Don't add a datasource or generator block, and never write a connection URL: Swirls wraps your schema with its own datasource and generator before validating it, so a user-supplied one collides and fails validation. On deploy, Swirls provisions the database if it doesn't exist yet, then converges its schema to match what you declared.
Querying and mutating: the database node
Every read and write against a managed database goes through a type: database node. run: gets context.db.<name>, a generated, fully typed Prisma client for the declared database block: every model, every query method, no SQL to write and no schema to keep in sync by hand. The node is visible in flow { }, gateable with review:, and traced as its own step, so nothing touches the database invisibly.
A code node cannot see context.db. Reading or writing the managed database from a code node throws at runtime: move the query into a type: database node and read its result from downstream nodes with context.nodes.<name>.output.
| Field | Required | Description |
|---|---|---|
database | Yes | Bare identifier naming a top-level database block. |
operation | Yes | One of query, insert, update, delete, transaction. Narrows which client methods run can call. |
condition | No | @ts block returning a boolean; if false, the node is skipped. |
run | Yes | @ts block: the typed Prisma body. |
The declared operation mints a capability-narrowed client. A call outside that capability is rejected at runtime, not just flagged by a type:
operation | Client exposes |
|---|---|
query | findMany, findFirst, findUnique, count, aggregate, groupBy (and the *OrThrow read variants) |
insert | create, createMany, createManyAndReturn |
update | update, updateMany, updateManyAndReturn, upsert |
delete | delete, deleteMany |
transaction | The full client, inside one atomic $transaction |
node purge_stale {
type: database
label: "Purge stale users"
database: my_db
operation: delete
review: { enabled: true }
condition: @ts {
return context.nodes.root.output.confirmed === true
}
run: @ts {
return context.db.my_db.user.deleteMany({
where: { lastSeen: { lt: context.nodes.root.output.cutoff } },
})
}
}operation: transaction is the exception: its run body gets the full client, opened inside $transaction, for the atomic multi-step case a single narrowed operation can't express.
node settle_invoice {
type: database
label: "Settle invoice"
database: my_db
operation: transaction
review: { enabled: true }
run: @ts {
return context.db.my_db.$transaction(async (tx) => {
const invoice = await tx.invoice.update({
where: { id: context.nodes.root.output.invoiceId },
data: { status: "PAID" },
})
await tx.ledgerEntry.create({
data: { invoiceId: invoice.id, amount: invoice.total },
})
return invoice
})
}
}Because a transaction node spans every operation class, it's governed at the node grain rather than per method: review: and traces treat the whole transaction as one step.
There is no raw SQL surface on context.db: .query() does not exist. Use the typed model methods. Data round-trips with real types: DateTime fields come back as JavaScript Date objects, BigInt and byte columns round-trip as BigInt and bytes, and Decimal values come back as strings.
Output is whatever run returns. When condition is false, the node is skipped and its output is { skipped: true }.
Schema migrations
When the @prisma schema changes, the next deploy migrates the managed database to match it:
- Additive changes apply automatically. Adding a model, a field, or an index doesn't require approval.
- Destructive or unclassifiable changes are gated. Dropping a column, changing a type, or any change Swirls can't classify as safe is held for approval rather than applied silently. The deploy's migration parks as gated, whether you deployed with
swirls deployorgit push.
Approve a gated migration with swirls migrate apply, or from the Databases page in the cloud dashboard. swirls migrate status shows what is pending.
Data transforms: the migration block
A schema diff can express "add a column," but not "collapse first_name and last_name into name." A migration block declares that kind of data transform, and runs it after its schema migration, in order, exactly once.
| Field | Required | Description |
|---|---|---|
database | Yes | Bare identifier naming the target database block. |
order | Yes | Non-negative integer, unique per target database. Migrations for the same database run in ascending order. |
operation | Yes | @ts block: the typed Prisma data-migration body. |
migration collapse_names {
database: my_db
order: 1
operation: @ts {
const users = await context.db.my_db.user.findMany({
where: { name: null },
})
for (const user of users) {
await context.db.my_db.user.update({
where: { id: user.id },
data: { name: `${user.firstName} ${user.lastName}` },
})
}
}
}The operation body uses the same full client as a transaction-operation database node: model methods like findMany and update, awaited one call at a time. A pending data migration gates its deploy the same way a destructive schema change does, until approved.
Direct access: named connections
Workflows reach a managed database through database nodes, and that stays the governed path. When something outside Swirls needs the same data, such as an application backend, a BI tool, or a one-off script, declare a nested connection block inside the database block. Each declared connection is provisioned on deploy with its own credentials and its own connection string, so you can hand one to each consumer and rotate or remove them independently.
| Field | Required | Description |
|---|---|---|
label | No | Human-readable label. |
description | No | Human-readable description. |
database app_db {
schema: @prisma {
model User {
id Int @id @default(autoincrement())
email String @unique
}
}
connection app {
label: "Application connection"
}
connection analytics {
description: "Analytics workloads"
}
}Connection names must be unique within their database block and match ^[a-zA-Z0-9_]+$. The name owner and names starting with pg_ are reserved. label and description are display metadata only.
Getting a connection string
Reveal a connection string from the Connections tab of the database in the cloud dashboard, or from the CLI:
swirls db connections
swirls db connection app --database app_dbswirls db connections lists every provisioned connection across your managed databases. swirls db connection prints the direct connection URL, plus a pooled URL when the database offers one; use the pooled URL for serverless or high-concurrency clients. Treat every connection string like a password: it grants direct access to the database, outside workflow governance.
Rotation and removal
Rotate a connection from the Connections tab, or with swirls db connection app --database app_db --rotate, which mints fresh credentials before printing the new string; the previous string stops working. Removing a connection block from your .swirls file revokes its credentials and deletes the connection on the next deploy, and renaming one is a removal plus a creation, so the renamed connection comes back with new credentials.
Named database connections are scoped to the database block that declares them. The top-level connection primitive is a separate concept: it brokers OAuth to outside providers for integration nodes and agents.
Browsing data
The cloud dashboard includes a read-only table and row browser and a read-only SQL console for each managed database, so you can inspect data without leaving Swirls. Writes through the console are rejected.
Choosing between memory primitives
- Streams: structured workflow output you want to query and reuse. Swirls-managed.
- Disks: unstructured files and shared working space. You control the layout.
- Database: a relational store Swirls provisions and migrates for you.
- Postgres: the relational database you already have.
Further reading
- Node types: the
databasenode's fields - Context:
context.dband where it's in scope - Reviews: gating a
databasenode withreview: - CLI:
swirls db: listing and revealing named connections