knext

Data SDK (@getknext/db)

A thin, typed drizzle-orm layer over your Postgres DATABASE_URL — schema, migrations, and read/write clients.

@getknext/db is knext's typed data-access layer: a thin drizzle-orm wrapper over the Postgres connection pools built from the DATABASE_URL your app is bound to. It re-exports drizzle's whole query surface and adds only the scale-to-zero ergonomics — you keep drizzle's own docs and lose no power, and the SDK provisions nothing and mutates no cluster resource.

knext is engine-agnostic about databases: you bring your own Postgres and bind its DSN from a Secret; @getknext/db is a plain client over that DATABASE_URL, giving your database a typed schema, migrations, and queries. It works against any Postgres your app can reach.

The package has three public subpaths:

SubpathWhat it is
@getknext/dbRe-exports drizzle-orm (query builder, eq/and/or/desc/sql, …) plus the client accessors getDb() / getDbRO().
@getknext/db/schemaThe schema surface — a thin re-export of drizzle's pg-core builders plus relations/sql, and the extension helpers.
@getknext/db/migratedefineDrizzleConfig() for your drizzle.config.ts, plus the engine behind kn-next db migrate.
npm i @getknext/db
npm i -D drizzle-kit        # dev-only: generates SQL migrations

@getknext/db depends on drizzle-orm and re-exports it, so your app has a single pinned-compatible drizzle. drizzle-kit is a dev tool only — it generates migrations and ships no runtime code.

@getknext/db is optional. It is a plain client library — install it only if you want the typed schema and migration ergonomics; the rest of knext works without it. See Getting started for the full package list.

Define your schema

Put your tables in src/db/schema.ts (the knext convention) and import the builders from @getknext/db/schema. It is a plain re-export of drizzle's pg-core — no bespoke DSL — so drizzle's schema docs apply directly.

src/db/schema.ts
import { pgTable, serial, text, timestamp } from '@getknext/db/schema';

export const messages = pgTable('messages', {
  id: serial('id').primaryKey(),
  author: text('author').notNull(),
  body: text('body').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

// Typed rows for free:
export type Message = typeof messages.$inferSelect;
export type NewMessage = typeof messages.$inferInsert;

Available from @getknext/db/schema: pgTable, the column builders (serial/text/integer/timestamp/jsonb/uuid/vector/…), index/uniqueIndex, primaryKey/foreignKey, pgEnum/pgSchema, and relations/sql.

Migrations

Migrations are one-shot and writer-only, run out of the request path — the answer to "who migrates a single-writer, scale-to-zero database?".

Config

drizzle.config.ts
import { defineDrizzleConfig } from '@getknext/db/migrate';

// dialect: 'postgresql' · schema: './src/db/schema.ts' · out: './drizzle'
// dbCredentials.url: process.env.DATABASE_URL (the WRITER, injected by the operator)
export default defineDrizzleConfig();

defineDrizzleConfig() wires drizzle-kit to the writer DATABASE_URL — never the read replica. It returns a plain drizzle-kit Config object, so you can spread it to add any field drizzle-kit supports, and override schema, out, or url when needed.

Generate (dev time — no database needed)

npx drizzle-kit generate     # diff schema → ./drizzle/*.sql — commit these

generate needs no live database (an unset DATABASE_URL still produces SQL). Commit ./drizzle — the generated SQL is your migration history.

Apply — kn-next db migrate

kn-next db migrate                       # apply ./drizzle against the writer DATABASE_URL
kn-next db migrate --dir ./migrations    # custom migrations directory
kn-next db migrate --url "$WRITER_DSN"   # explicit writer DSN override
  • Writer-only. The runner resolves DATABASE_URL and refuses a read-replica DSN — a single-writer database forbids writes on the replica.
  • Once per deploy, out of the request path. Run it as a CI step or a pre-deploy Job — not on pod boot (that races N migrators and penalises cold start).
  • Idempotent + fail-loud. Applied migrations are recorded in __drizzle_migrations, so a re-run is a no-op; an error exits non-zero so the Job fails instead of a half-applied schema going live.
  • Cold-wake tolerant. Connecting wakes a scale-to-zero compute; the runner's 15s connect timeout absorbs the ~2.5s cold wake.

Run it as a one-shot Job

Run the migration as a Kubernetes Job on the same image and Secret the app uses, gated on the database being ready and completed before app pods serve:

migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: acme-migrate
  namespace: my-apps
spec:
  backoffLimit: 2               # fail loud — never ship a half-applied schema
  ttlSecondsAfterFinished: 300  # reap the finished Job
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example/acme:<same-digest-as-the-deploy>
          command: ['kn-next', 'db', 'migrate']
          env:
            # The WRITER DSN — the same Secret the operator injects into the app.
            # Writer only: never wire DATABASE_URL_RO here (the runner refuses it).
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: acme-db
                  key: DATABASE_URL
kubectl apply -f migrate-job.yaml -n my-apps
kubectl wait --for=condition=Complete job/acme-migrate -n my-apps --timeout=300s

kubectl wait --for=condition=Complete returns non-zero if the Job fails — wire it into your pipeline so a failed migration blocks the rollout.

Queries: getDb() vs getDbRO()

Two clients, and you pick per query — the SDK never parses SQL or auto-routes statements (auto-routing would silently serve stale reads and break read-your-writes):

getDb()getDbRO()
DSNDATABASE_URL (writer)DATABASE_URL_RO (read-only pool)
ConsistencyRead-your-writes, single writerBounded-stale (~9s), no read-your-writes
Use forEvery write; any read that must see its own writeDashboards / analytics / fan-out reads that tolerate a few seconds of lag

Both are one-client-per-pod singletons over the app's connection pools. If DATABASE_URL_RO is unset (no read replica), getDbRO() falls back to the writer with a one-time warning — the app still works, it just reads from the primary.

src/db/queries.ts
import { desc, getDb, getDbRO } from '@getknext/db';
import { type Message, type NewMessage, messages } from './schema';

// staleness-tolerant list → the reader
export function listMessages(limit = 50): Promise<Message[]> {
  return getDbRO({ messages })
    .select()
    .from(messages)
    .orderBy(desc(messages.createdAt))
    .limit(limit);
}

// insert → the writer; the returned row is read-your-writes
export async function addMessage(input: NewMessage): Promise<Message> {
  const [row] = await getDb({ messages }).insert(messages).values(input).returning();
  return row;
}

Reads run in server components / route handlers; wrap writes in 'use server' actions:

src/app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { addMessage } from '@/db/queries';

export async function postMessage(formData: FormData): Promise<void> {
  const body = String(formData.get('body') ?? '').trim();
  if (!body) return;
  await addMessage({ author: 'anonymous', body });
  revalidatePath('/');
}

Rule of thumb: write, or read-your-own-write → getDb(); read that tolerates a few seconds of staleness → getDbRO().

Pooling, wake, and graceful drain

The pools are tuned for scale-to-zero out of the box:

  • Pool idle timeout (10s) < the database gateway's 60s idle window — the app never holds an idle socket that keeps a scale-to-zero database awake.
  • Connect timeout 15s ≥ the ~2.5s cold wake — no app-side wake-retry logic is needed.
  • Graceful drain on scale-down. On SIGTERM (Knative scaling a replica down) the runtime drains both pools — the writer and the read-only pool — after in-flight requests finish, so a terminating replica releases its connections cleanly instead of severing them mid-write or leaking idle sockets. Each close is idempotent (a no-op if the pool was never opened), and the whole drain is bounded by the shutdown grace cap so a slow database can never wedge shutdown. You get this for free — nothing to wire in app code.

Override the writer pool with DB_POOL_* env vars and the reader with DB_POOL_RO_* if you must; the defaults already satisfy the contract.

Extensions: TimescaleDB & pgvector

Two common Postgres extensions have first-class schema helpers. They are opt-in: your app enables the extension it needs itself, once, over its own DATABASE_URL. They work against any Postgres that has the extension available (for example, a self-hosted cluster or a managed provider that offers it).

The helpers below are migration SQL emitters — drizzle-kit cannot model these statements, so the helpers return the exact SQL to paste into (or append to) your generated migration.

TimescaleDB (time-series)

import {
  pgTable, timestamp, text, doublePrecision,
  hypertable, dropChunks, createTimescaleExtension,
} from '@getknext/db/schema';

export const metrics = pgTable('metrics', {
  ts: timestamp('ts', { withTimezone: true }).notNull(),
  device: text('device').notNull(),
  value: doublePrecision('value').notNull(),
});

// Emit these into your migration:
createTimescaleExtension();
// → CREATE EXTENSION IF NOT EXISTS timescaledb;
hypertable(metrics, { by: 'ts', chunkInterval: '7 days' });
// → SELECT create_hypertable('metrics', 'ts',
//     chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);

// Retention — a ONE-SHOT drop, run by your migration/CI on a schedule you own:
dropChunks(metrics, { olderThan: '30 days' });
// → SELECT drop_chunks('metrics', INTERVAL '30 days');

Two honest bounds. First, you get the Apache-licensed tier only: hypertables, time_bucket(), chunk pruning, and one-shot dropChunks() retention. Columnar compression, continuous aggregates, and add_retention_policy() all rely on background policy jobs, which cannot run on a compute that scales to zero — run dropChunks() from a schedule you control instead. Second, a version caveat: hypertable() currently emits the classic create_hypertable(table, column, …) call form, which TimescaleDB 2.24+ no longer accepts (newer versions require the dimension-builder form). A fix is planned; on TimescaleDB ≥ 2.24, write the create_hypertable statement by hand in your migration for now.

The vector(n) column type is drizzle's own (already re-exported from @getknext/db/schema); the helpers add the index DDL drizzle-kit can't generate, and re-export the typed distance operators.

import {
  pgTable, serial, text, vector,
  hnsw, ivfflat, createVectorExtension,
} from '@getknext/db/schema';

export const docs = pgTable('docs', {
  id: serial('id').primaryKey(),
  body: text('body').notNull(),
  embedding: vector('embedding', { dimensions: 1536 }),
});

// Emit into your migration:
createVectorExtension();
// → CREATE EXTENSION IF NOT EXISTS vector;
hnsw('docs_embedding_idx', docs.embedding, { m: 16, efConstruction: 64 });
// → CREATE INDEX IF NOT EXISTS "docs_embedding_idx" ON "docs"
//     USING hnsw ("embedding" vector_cosine_ops) WITH (m = 16, ef_construction = 64);

Query with the distance operators, and match the operator to the index's ops class: cosineDistance (<=>) ⇄ vector_cosine_ops, l2Distance (<->) ⇄ vector_l2_ops, innerProduct (<#>) ⇄ vector_ip_ops.

import { getDbRO, cosineDistance } from '@getknext/db';
import { docs } from '@/db/schema';

const nearest = await getDbRO({ docs })
  .select()
  .from(docs)
  .orderBy(cosineDistance(docs.embedding, queryEmbedding))
  .limit(5);

Early-stage. The pgvector helpers are new. They are pure SQL emitters and work against any Postgres that has the vector extension available to enable.

On this page