dimah-survey
Server

Database

Add the survey tables to the database you already use.

Add dimah_survey and dimah_response to your schema, migrate with the tool you already use, and pass the same database to both server audiences. dimah-survey does not run migrations.

npm i @dimah-survey/db fumadb

Model keys are survey and response. Copy the tables into your application. Do not import them from @dimah-survey/db at runtime.

provider is "sqlite", "postgresql", or "mysql".

Add these tables to your Drizzle schema. This copy is SQLite. On PostgreSQL or MySQL, use that driver's table builder and set provider to match. Keep the partial unique index.

db/schema.ts
import { defineRelations, sql } from "drizzle-orm";
import {
  blob,
  check,
  foreignKey,
  index,
  integer,
  sqliteTable,
  text,
  uniqueIndex,
} from "drizzle-orm/sqlite-core";

export const survey = sqliteTable(
  "dimah_survey",
  {
    id: text("id", { length: 255 }).primaryKey().notNull(),
    slug: text("slug", { length: 255 }).notNull(),
    status: text("status").notNull(),
    draftJson: blob("draft_json", { mode: "json" }).notNull(),
    publishedJson: blob("published_json", { mode: "json" }),
    publishedAt: integer("published_at", { mode: "timestamp" }),
    settings: blob("settings", { mode: "json" }).notNull(),
    createdAt: integer("created_at", { mode: "timestamp" })
      .notNull()
      .defaultNow(),
    updatedAt: integer("updated_at", { mode: "timestamp" })
      .notNull()
      .defaultNow(),
  },
  (table) => [
    uniqueIndex("dimah_survey_slug_unique").on(table.slug),
    check(
      "dimah_survey_status_check",
      sql`${table.status} in ('draft', 'active', 'archived')`,
    ),
    index("dimah_survey_status_updated_at_idx").on(
      table.status,
      table.updatedAt,
    ),
  ],
);

export const response = sqliteTable(
  "dimah_response",
  {
    id: text("id", { length: 255 }).primaryKey().notNull(),
    surveyId: text("survey_id", { length: 255 }).notNull(),
    respondentId: text("respondent_id", { length: 255 }),
    status: text("status").notNull(),
    definition: blob("definition", { mode: "json" }).notNull(),
    data: blob("data", { mode: "json" }).notNull(),
    submittedAt: integer("submitted_at", { mode: "timestamp" }),
    createdAt: integer("created_at", { mode: "timestamp" })
      .notNull()
      .defaultNow(),
    updatedAt: integer("updated_at", { mode: "timestamp" })
      .notNull()
      .defaultNow(),
  },
  (table) => [
    foreignKey({
      columns: [table.surveyId],
      foreignColumns: [survey.id],
      name: "dimah_response_survey_fk",
    })
      .onUpdate("restrict")
      .onDelete("restrict"),
    check(
      "dimah_response_status_check",
      sql`${table.status} in ('draft', 'submitted', 'abandoned')`,
    ),
    index("dimah_response_survey_id_updated_at_idx").on(
      table.surveyId,
      table.updatedAt,
    ),
    index("dimah_response_respondent_lookup_idx").on(
      table.surveyId,
      table.respondentId,
      table.status,
    ),
    uniqueIndex("dimah_response_one_open_draft")
      .on(table.surveyId, table.respondentId)
      .where(
        sql`${table.status} = 'draft' and ${table.respondentId} is not null`,
      ),
  ],
);

export const privateDimahSurveySettings = sqliteTable(
  "private_dimah_survey_settings",
  {
    id: text("id", { length: 255 }).primaryKey().notNull(),
    version: text("version", { length: 255 }).notNull().default("1.0.0"),
  },
);

export const relations = defineRelations(
  { survey, response, privateDimahSurveySettings },
  (helpers) => ({
    survey: {
      responses: helpers.many.response({
        from: helpers.survey.id,
        to: helpers.response.surveyId,
      }),
    },
    response: {
      survey: helpers.one.survey({
        from: helpers.response.surveyId,
        to: helpers.survey.id,
      }),
    },
  }),
);
db/index.ts
import { DimahSurveyDB, db } from "@dimah-survey/db";
import { drizzleAdapter } from "fumadb/adapters/drizzle";

export const database = db(
  DimahSurveyDB.client(
    drizzleAdapter({ db: drizzleDb, provider: "sqlite" }),
  ),
);

drizzleDb is the Drizzle client you already open. Pass relations from the schema into that client.

The store accepts provider: "mysql", but this page does not include a MySQL schema. Translate the tables and indexes before connecting the adapter.

Next.js

Keep the store on the Node.js runtime. The last entry is the ORM package that opens the connection: drizzle-orm, @prisma/client, or kysely.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  serverExternalPackages: ["@dimah-survey/db", "fumadb", "drizzle-orm"],
};

export default nextConfig;

In memory

memoryAdapter() from @dimah-survey/server stores rows in the process. They disappear when the process exits. Use it for tests, or when you do not want the SQL tables. Pass it as database the same way as the SQL store.

import { memoryAdapter } from "@dimah-survey/server";

export const database = memoryAdapter();

Custom store

import type { SurveyStore } from "@dimah-survey/core";

Prop

Type

Start and submit callbacks are server-only. They are never accepted from the HTTP body:

import type {
  StartResponseLifecycle,
  SubmitResponseLifecycle,
} from "@dimah-survey/core";

Prop

Type

Prop

Type

A conforming store preserves these invariants:

Documents and snapshots

  • Keep draftJson, publishedJson, and settings independent.
  • Insert default settings with a new survey; only saveSurveySettings changes them afterward.
  • Copy publishedJson into response.definition at start and never update that column.
  • Partial save replaces data; persist the object passed by the server.

Atomic writes

  • Check expectedUpdatedAt inside the write.
  • Evaluate start reuse, collection windows, response caps, and one-open-draft rules in the same lock or transaction as the mutation.
  • Enforce one draft per survey and identified respondent in application logic; use the partial unique index as a database backstop.
  • Run the start and submit lifecycle callbacks inside that same boundary.

Queries and transitions

  • Anonymous start always inserts. Identified start follows "one-open" or "single" settings.
  • Start, partial save, and submit enforce the collection window.
  • New starts and submits enforce the response cap.
  • Reopen enforces reopen and refuses a second identified draft.
  • Lists honor pagination, sort by updatedAt descending, and omit definition and data from summaries.

On this page