QUESTPIE

Infrastructure

Storage, search, realtime, KV and the rest. Each one is a service your handlers call and an adapter you pick behind it, so moving from local disk to S3 or from a Map to Redis is one line in one file.

View markdown

Every page in this group covers one concern the same way. What the service does, what runs behind it when you configure nothing, which adapters ship, and the interface to implement when none of them fit.

The default

db is the one thing QUESTPIE cannot invent for you. Every slot below has a fallback, which is why a fresh app runs with no infrastructure to provision.

SlotWith no configurationOutgrow it when
storageFiles SDK fs() rooted at ./uploadsA second instance, or a disk a deploy wipes
searchPostgresSearchAdapter, full-text plus trigramYou want ranking by meaning rather than by words
realtimePgNotifyChangeBroker on your db.url, delivered over SSEYou want the wake-ups off Postgres
kvMemoryKVAdapter, a Map in this processTwo processes have to agree on a key
loggerPinoLoggerAdapter at level infoYour platform wants a different sink
observabilityFrozen no-op tracer and meterYou want traces and metrics
emailConsoleAdapter, and only off productionMail has to leave the process
queueNothingYou write your first job
executorDisabledYou run code that was not in the deploy

Three of those rows are refusals rather than backends. Outside NODE_ENV=production the mailer builds a ConsoleAdapter on first send and prints the message instead of delivering it, and in production that same send throws. With no queue block the queue service hands back an empty client. Codegen still types app.queue.<job>, but the property is not there, so dispatching throws. ctx.executor.run() throws while executor is unset, and again for isolation: "sandboxed" with no executor.sandboxed.

Realtime has one soft edge. The default broker needs a direct Postgres connection string, which db: { url } supplies and a prebuilt db: { drizzle } client may not. Without one there is no broker, and delivery falls back to reconciliation polling every two seconds.

Where each concern lives

One page per concern, carrying its interface and its adapters together.

ConcernAdapters that shipPage
StorageAny Files SDK adapter, over forty of themStorage
Searchpostgres, pgvectorSearch
Realtimepg-notify, redis-streams, SSE or Pusher deliveryRealtime
Key-valuememory-kv, redis-kv, cloudflare-kvKey-value store
Dynamic codeIn-process trusted, HTTP sandboxed from @questpie/sandboxSandbox
Background workpg-boss, bullmq, cloudflare-queuesQueue
Emailconsole, smtp, resend, plunkEmail
Traces and logsOTLP from @questpie/observabilityObservability

Configuring and swapping

Every slot lives in one runtimeConfig call, and swapping one moves nothing else. The route that reads kv, the collection that indexes into search and the hook that publishes a job are all written against the service. None of them names an adapter. That is why this file is the only thing that changes.

src/questpie/server/questpie.config.ts
import { s3 } from "files-sdk/s3";
import { runtimeConfig } from "questpie/app";
import { pgBossAdapter } from "questpie/adapters/pg-boss";
import { redisKVAdapter } from "questpie/adapters/redis-kv";
import { resendAdapter } from "questpie/adapters/resend";

import { getRedisClient } from "@/lib/redis";

const db = process.env.DATABASE_URL!;

export default runtimeConfig({
	app: { url: process.env.APP_URL! },
	db: { url: db },
	storage: { adapter: s3({ bucket: "uploads", region: "eu-central-1" }) },
	// a connected client, or a function returning one, resolved on first use
	kv: { adapter: redisKVAdapter({ client: getRedisClient }) },
	email: { adapter: resendAdapter({ apiKey: process.env.RESEND_API_KEY! }) },
	queue: { adapter: pgBossAdapter({ connectionString: db }) },
});

Each adapter sits behind its own entry point, so you pull in only the client you use. Every provider SDK QUESTPIE imports itself is an optional peer dependency. The Redis-backed adapters import no SDK at all. They take a connected client, or a function that returns one. search is the odd slot, taking the adapter instance directly rather than an object around it, because it has nothing else to configure.

`runtimeConfig` falls back to the environment

app.url reads QUESTPIE_APP_URL, then APP_URL, then http://localhost:3000. db.url reads QUESTPIE_DB, then DATABASE_URL, then throws. An explicit value always wins. See Configuration.

Storage from the environment

Storage is the one slot that can configure itself. Set all four of QUESTPIE_STORAGE_ENDPOINT, QUESTPIE_STORAGE_BUCKET, QUESTPIE_STORAGE_ACCESS_KEY and QUESTPIE_STORAGE_SECRET_KEY, and leave storage out of your config. QUESTPIE then builds an S3-compatible Files SDK adapter for you. QUESTPIE_STORAGE_REGION defaults to auto. Set the endpoint but miss one of the other three and it warns and stays on local disk.

The contracts

Every slot below takes a published type, and the built-ins have no privileged access. Import the contract, implement it, hand your instance to the same slot a built-in would take.

SlotContractImport
storage.adapterAdapterquestpie/storage
searchSearchAdapterquestpie/search
realtime.changeBrokerChangeBrokerquestpie/realtime
realtime.clientTransportClientTransportquestpie/realtime
kv.adapterKVAdapterquestpie/kv
email.adapterMailAdapterquestpie/mailer
queue.adapterQueueAdapterquestpie/queue
executor.sandboxedExecutorAdapterquestpie/executor
observability.adapterObservabilityAdapterquestpie/observability

MailAdapter is an abstract class you extend and ClientTransport is a union of two transport shapes, one local-session and one shared-provider. The rest are plain interfaces. Adapter is the Files SDK type, re-exported so storage has one import path with the others. Realtime is two independent seams rather than one adapter, and a driver must not implement both in one object.

logger.adapter is the slot the table cannot list. It takes a LoggerAdapter, and the built-in Pino one satisfies it. That type is not re-exported from any entry point today. There is no contract for you to import.

Capability flags

Three contracts carry a capability object, because those three have backends that genuinely cannot do the same things.

QueueAdapter.capabilities is optional and partial, covering longRunningConsumer, runOnceConsumer, pushConsumer, scheduling, singleton and executionTerminalState. Omit one of the first four and it is inferred from whether the matching method is present. singleton falls back to false. executionTerminalState needs both an explicit true and the method. The queue service resolves that set once and reads it before dispatching. queue.<job>.schedule() throws on an adapter reporting scheduling: false. That is how Cloudflare Queues says it has no cron.

SearchAdapter.capabilities is required and carries lexical, trigram, semantic, hybrid and facets. It is there for introspection rather than enforcement. PostgresSearchAdapter reports semantic: false. It also throws on a mode it does not implement. An unsupported query fails loudly instead of quietly turning into keyword matching.

Storage inherits the Files SDK's flags rather than declaring its own. An adapter sets supportsRange, supportsMetadata and their siblings. app.storage.capabilities derives a resolved object from them on every read. A download({ range }) against an adapter without supportsRange throws before the provider is called. KV and mail carry no flags, being the same handful of calls on every backend.

  • Configuration, every key runtimeConfig accepts and how it resolves.
  • Building a plugin, the loop for shipping an adapter of your own.
  • Deploying, which of these defaults survive contact with a second replica.

On this page