Data Layer

Dune's data layer lets you define typed data models in schemas/*.yaml, then work with them through a generated Repository API backed by SQLite, Deno KV, or PostgreSQL. Optionally, dune codegen generates REST API endpoints for your models.

This is distinct from Flex Objects — Flex Objects are CMS-managed records edited through the admin UI; the data layer is for application data that your code creates and queries directly.

Defining a schema

Create a YAML file in schemas/ at your site root. The model field becomes the TypeScript type name; table defaults to a snake_case plural of the model name.

# schemas/comments.yaml
model: Comment
table: comments      # optional — defaults to "comments" (snake_case plural)

fields:
  pageRoute:
    type: string
    required: true
    index: true        # creates a secondary index for fast lookups

  authorEmail:
    type: string
    required: true

  body:
    type: text         # "text" = long string (TEXT in SQL, no maxLength limit)
    required: true

  approved:
    type: boolean
    default: false

  createdAt:
    type: datetime
    default: "now"     # auto-set on insert

  updatedAt:
    type: datetime
    onUpdate: "now"    # auto-set on every update

Field types

Type TypeScript SQL Notes
string string TEXT Short strings; use maxLength to enforce
text string TEXT Long text; no length limit
integer number INTEGER Whole numbers
number number REAL Floating point
boolean boolean INTEGER 0/1
datetime Date TEXT (ISO 8601) Use default: "now" and/or onUpdate: "now"
json unknown TEXT (JSON string) Arbitrary JSON, serialised on write

Field options

Option Type Description
required boolean Cannot be null. Required fields become non-optional in the TypeScript interface.
default any Default value on insert. Use "now" for datetime auto-timestamps.
onUpdate "now" Set field to current timestamp on every update.
maxLength number Max string length (validated before write).
index boolean Create a secondary index on this field (SQLite/Postgres only).
enum string[] Restrict to these string values. The TypeScript type becomes a union of string literals.

Code generation

Run dune codegen to generate TypeScript types and a repository index from your schemas:

dune codegen
# Generated 3 file(s):
#   src/db/types/Comment.ts
#   src/db/types/Post.ts
#   src/db/index.ts

src/db/types/Comment.ts (example output):

// GENERATED — do not edit. Run `dune codegen` to regenerate.

export interface Comment {
  id: string;
  pageRoute: string;
  authorEmail: string;
  body: string;
  approved: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export interface CommentCreate {
  pageRoute: string;
  authorEmail: string;
  body: string;
  approved?: boolean;
  // createdAt and updatedAt excluded — auto-managed
}

export interface CommentUpdate {
  pageRoute?: string;
  authorEmail?: string;
  body?: string;
  approved?: boolean;
}

src/db/index.ts exports typed repositories for every schema:

// GENERATED — do not edit.
import { createRepository } from "@dune/core/db";
import type { Comment, CommentCreate, CommentUpdate } from "./types/Comment.ts";
export type { Comment, CommentCreate, CommentUpdate };

export const Comments = createRepository<Comment, CommentCreate, CommentUpdate>("comments");

Re-run dune codegen whenever you change a schema file.

Repository API

Import the generated repository and query your data:

import { Comments } from "./src/db/index.ts";

// Create
const comment = await Comments.create({
  pageRoute: "/blog/hello-world",
  authorEmail: "alice@example.com",
  body: "Great post!",
});

// Find all
const all = await Comments.find();

// Find with conditions
const approved = await Comments.find({
  where: { approved: true, pageRoute: "/blog/hello-world" },
  orderBy: ["createdAt", "desc"],
  limit: 10,
});

// Find one
const one = await Comments.findOne({ where: { id: "abc123" } });

// Update
await Comments.update(comment.id, { approved: true });

// Delete
await Comments.delete(comment.id);

// Count
const total = await Comments.count({ where: { approved: false } });

// Upsert
const upserted = await Comments.upsert(
  { pageRoute: "/blog/hello", authorEmail: "alice@example.com" },
  { pageRoute: "/blog/hello", authorEmail: "alice@example.com", body: "Updated" },
);

WhereClause operators

Use operator objects for range and membership queries:

// Greater/less than
await Comments.find({ where: { createdAt: { $gt: new Date("2026-01-01") } } });

// In set
await Comments.find({ where: { pageRoute: { $in: ["/blog/a", "/blog/b"] } } });

// Not in set
await Comments.find({ where: { approved: { $notIn: [false] } } });

// Contains (substring match)
await Comments.find({ where: { body: { $contains: "hello" } } });

// Starts with
await Comments.find({ where: { authorEmail: { $startsWith: "alice" } } });

// Null check
await Comments.find({ where: { updatedAt: { $isNull: true } } });

// OR clause
await Comments.find({
  where: {
    $or: [{ approved: true }, { authorEmail: "alice@example.com" }],
  },
});

Adapter selection

Dune auto-selects the database backend from environment variables:

Condition Adapter
DUNE_DB_URL starts with postgres:// or postgresql:// PostgreSQL
DENO_DEPLOYMENT_ID is set Deno KV
Otherwise SQLite (file at DUNE_DB_PATH, default data/dune.db)

You can force a specific adapter by setting the environment variable before starting the server:

# PostgreSQL
DUNE_DB_URL=postgresql://user:pass@localhost/mydb dune serve

# SQLite at a custom path
DUNE_DB_PATH=/var/data/site.db dune serve

Migrations

Generate and apply SQL migrations when schemas change:

dune migrate:generate    # diff current schemas against DB, emit SQL migration files
dune migrate:run         # apply pending migration files
dune migrate:status      # show which migrations are applied and which are pending

Migration files are written to migrations/ and should be committed to version control. The _dune_migrations table in the database tracks which have been applied.

Typical workflow after changing a schema:

# 1. Edit schemas/comments.yaml
# 2. Generate the migration
dune migrate:generate
# → migrations/001_create_comments.sql

# 3. Review the generated SQL
cat migrations/001_create_comments.sql

# 4. Apply it
dune migrate:run

Migrations are not auto-applied on startup — run dune migrate:run as part of your deploy process.

CRUD API generation

Add an api: block to a schema to generate REST endpoints for it:

# schemas/posts.yaml
model: Post
fields:
  title:
    type: string
    required: true
  body:
    type: text
  authorId:
    type: string
    required: true
    index: true

api:
  enabled: true
  auth: "owner"          # "none" | "required" | "owner"
  ownerField: "authorId" # required when auth: "owner"
  methods:               # default: all five
    - list
    - get
    - create
    - update
    - delete

dune codegen generates route handlers in src/routes/api/{model}/:

Route Method Description
/api/posts GET List records (paginated via ?limit=&offset=)
/api/posts POST Create record
/api/posts/{id} GET Get single record
/api/posts/{id} PATCH Update record
/api/posts/{id} DELETE Delete record

Authentication modes

Mode Behaviour
none No authentication required
required Any logged-in SiteUser can access all records
owner Reads/writes are scoped to the current user's own records (matched via ownerField)

owner mode automatically filters list results to the current user's records and rejects updates/deletes on records owned by others.

Raw adapter access

For queries the Repository API doesn't cover, use getAdapter():

const adapter = Comments.getAdapter();
const rows = await adapter.query<{ count: number }>(
  "SELECT COUNT(*) as count FROM comments WHERE approved = ?",
  [1],
);

Use with care — raw queries bypass the Repository's type safety and datetime serialisation.