Shopify app notes · 05

Prisma & Your Database

Designing tables, connecting them safely, and the handful of read and write commands that cover almost everything you will ever need.

Prisma MySQL / Postgres

What Prisma is

Prisma lets you talk to a database using JavaScript instead of writing SQL. You describe your tables in one file, and Prisma generates a client with autocomplete for every table and column.

schema.prisma → prisma generate → db.product.findMany() → SQL

The schema file is the single source of truth. Change it, regenerate, and your code updates.

Three words you need

Model = a table. Field = a column. Migration = a recorded change to the table structure, so every environment can apply the same change in the same order.


The schema file

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"     // generate a JS client
}

datasource db {
  provider = "mysql"                // or "postgresql"
  url      = env("DATABASE_URL")    // read from .env
}

model Widget {
  id        Int      @id @default(autoincrement())
  title     String
  enabled   Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

That model creates a Widget table and gives you db.widget in code — the model name, lowercased.

The template you start from uses provider = "sqlite" instead — one local file, no server to set up. Its README notes that SQLite works in production only if your app runs as a single instance, and suggests MySQL or PostgreSQL otherwise. This note's examples use MySQL.

Field types and modifiers

WriteMeaning
StringText. Short by default — see the trap below.
Int / FloatNumbers.
Booleantrue / false.
DateTimeA date and time.
JsonAny structure. Handy, with a catch — see below.
String?The ? means optional (can be null).
@idThe primary key.
@default(...)Value used when you do not provide one.
@uniqueNo two rows may share this value.
@updatedAtAutomatically set to "now" on every update.
@db.TextLong text on MySQL and PostgreSQL. Use for URLs. Not on SQLite — see the trap below.
Trap — "Data too long for column"

On MySQL, a plain String becomes VARCHAR(191). That is fine for names, and far too short for a Shopify CDN URL, which regularly exceeds it.

You will not notice in testing with short values. It fails in production with a raw database error.

imageUrl  String                // breaks on real URLs
imageUrl  String?  @db.Text     // correct

Rule: any column holding a URL, a long description, or pasted content gets @db.Text.

Except on SQLite. The template starts on SQLite (provider = "sqlite", a local dev.sqlite file), where a plain String has no length limit and @db.Text is an error: Native type Text is not supported for sqlite connector. Leave it off while you are on SQLite, and add it when you move to MySQL or PostgreSQL.


Connecting tables

In a Shopify app nearly everything belongs to a shop, so nearly every table gets a shop column holding the shop's domain — the same value your code reads from session.shop. When one of your tables belongs to another of your tables, you link them with a relation.

model Widget {
  id       Int            @id @default(autoincrement())
  shop     String                            // e.g. "my-store.myshopify.com"
  title    String
  options  WidgetOption[]                    // one widget has many options
}

model WidgetOption {
  id       Int     @id @default(autoincrement())
  widgetId Int                               // the link
  label    String

  widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade)
}

Three pieces make a relation work:

  1. widgetId — a normal column holding the parent's id.
  2. widget Widget @relation(...) — tells Prisma how they connect.
  3. options WidgetOption[] on the parent — the other direction.

Only the first is a real column. The other two exist so you can write option.widget or widget.options in code. onDelete: Cascade is explained in the next section.

Unique combinations

Sometimes a value must be unique per shop, not globally:

model Widget {
  shop String
  slug String

  @@unique([shop, slug])    // two shops may both use "blue"
}

onDelete — the setting that deletes your data

This decides what happens to child rows when the parent is deleted.

SettingWhen the parent is deleted
CascadeAll children are deleted too, automatically and silently.
RestrictThe delete is blocked while children exist. This is what you get if you write no onDelete at all.
SetNullChildren are kept; their link is set to null (needs an optional field).
Trap — never link your tables to Session

It is tempting to give every table a relation to the template's Session model. Don't. The template deletes a shop's session row every time the app is uninstalled, and any relation to it turns that into a problem:

With onDelete: Cascade, that single delete removes every row the shop ever created — instantly, with no warning and no undo. With no onDelete, the database refuses the delete and the uninstall webhook crashes with Foreign key constraint violated, on every retry Shopify sends.

So tie shop data to a plain shop column, and keep relations for links between your own tables. There, Cascade is genuinely useful: deleting a widget should take its options with it.


Migrations

A migration is a saved instruction such as "add a column called enabled". Prisma writes them for you and keeps them in order, so your laptop, a teammate's laptop and production all end up with identical tables.

# after editing schema.prisma — development only
npx prisma migrate dev --name add_enabled_flag

# on production — applies pending migrations, nothing else
npx prisma migrate deploy

# rebuild the client after pulling someone else's changes
npx prisma generate
The bug that will confuse you once

db.myNewTable is undefined, even though it is clearly in the schema. The generated client is stale — usually after switching git branches.

npx prisma generate fixes it. Remember this one; it costs people hours.

Never on production

migrate dev can decide your database is out of sync and offer to reset it. migrate reset deletes every row. db push changes tables with no migration record and can drop columns silently.

Production gets migrate deploy and nothing else.


Reading data

import db from "../db.server";

// many rows
const widgets = await db.widget.findMany({
  where:   { shop: session.shop, enabled: true },
  orderBy: { createdAt: "desc" },
  take: 20,          // limit
  skip: 0,           // offset — page 2 would be skip: 20
});

// one row by a unique field
const widget = await db.widget.findUnique({ where: { id: 42 } });

// one row by any conditions
const mine = await db.widget.findFirst({
  where: { id: 42, shop: session.shop },
});

// how many
const total = await db.widget.count({ where: { shop: session.shop } });
findUnique vs findFirst

findUnique only accepts fields marked @id or @unique. The moment you want to add a safety condition like shop, switch to findFirst — which is exactly what you want for multi-shop safety.

Picking columns, and following relations

// only the fields you need (faster, and avoids leaking extra data)
await db.widget.findMany({
  select: { id: true, title: true },
});

// bring the children along
await db.widget.findFirst({
  where:   { id: 42, shop: session.shop },
  include: { options: true },
});

// filter by something on the parent
await db.widgetOption.findMany({
  where: { widget: { shop: session.shop } },
});

Useful conditions

where: {
  title:     { contains: "blue" },     // search
  id:        { in: [1, 2, 3] },        // any of these
  deletedAt: null,                     // is empty
  createdAt: { gte: someDate },        // on or after
  OR: [{ title: { contains: "a" } }, { slug: { contains: "a" } }],
}

Writing data

// create
await db.widget.create({
  data: { title: "New widget", shop: session.shop },
});

// update one row
await db.widget.update({
  where: { id: 42 },
  data:  { title: "Renamed" },
});

// update it if it exists, otherwise create it
// (Setting has shop String @unique — one settings row per shop)
await db.setting.upsert({
  where:  { shop: session.shop },
  update: { enabled: false },
  create: { shop: session.shop, enabled: false },
});

// many at once
await db.widget.createMany({ data: [{ ... }, { ... }] });
await db.widget.updateMany({ where: { shop: session.shop }, data: { enabled: true } });

upsert is worth knowing early. It removes the "check if it exists, then decide" dance you would otherwise write constantly.

Deleting safely

// WRONG — any shop can delete any row by guessing an id
await db.widget.delete({ where: { id: Number(form.get("id")) } });

// RIGHT — scoped to the shop that asked
await db.widget.deleteMany({
  where: { id: Number(form.get("id")), shop: session.shop },
});
Trap — delete cannot be scoped

delete and update only accept unique fields in where, so you cannot add shop as a safety check. deleteMany and updateMany accept any conditions.

So for anything triggered by user input, prefer deleteMany and updateMany. A shop then simply cannot touch another shop's row, even by guessing ids. As a bonus, they do not throw when nothing matches.


JSON columns: useful, with one catch

A Json field stores any shape without designing new tables. Good for flexible settings and structures that vary.

Trap — stringify it once and it stays a string

Prisma serialises the value for you. If you hand it an already-stringified value, Prisma stores a string, and reading it back gives you a string, not an object.

// Store the object directly — recommended
await db.widget.create({ data: { config: { color: "blue" } } });
widget.config.color;                // "blue" — an object

// Stringify first, and you get a string back
await db.widget.create({ data: { config: JSON.stringify({ color: "blue" }) } });
widget.config.color;                // undefined!

Neither is wrong — but be consistent, because mixed rows are painful. When reading data you did not write, handle both:

const config = typeof widget.config === "string"
  ? JSON.parse(widget.config)
  : widget.config ?? {};

JSON columns cannot be searched or indexed like normal columns. If you find yourself filtering by something inside the JSON, that something deserves its own column.


Designing a table: five questions

  • Does it belong to a shop? Then it needs a shop column — not a relation to Session.
  • Does it belong to another of your tables? Then it needs a relation — choose onDelete deliberately.
  • Could any field hold a URL or long text? Give it @db.Text.
  • Must anything be unique — globally, or only per shop (@@unique)?
  • Will you ever filter by it? Then it is a column, not a key inside a JSON blob.

Add createdAt and updatedAt to everything. They cost nothing and you will want them the first time you debug.


Common errors

Unique constraint failed

You inserted a duplicate of a @unique value. Either use upsert, or check first. Remember @@unique([a, b]) only blocks the combination.

Foreign key constraint violated

Either you referenced a parent row that does not exist, or you tried to delete a parent that still has children and the relation has no onDelete (Restrict). If it appears when the app is uninstalled, one of your tables is linked to Session — replace that relation with a shop column.

Record to update not found

update and delete throw when nothing matches. Use updateMany/deleteMany, which simply affect zero rows instead.

Data too long for column

A String received more than 191 characters. Add @db.Text and run a migration.

Too many connections

Usually a missing db.server.js singleton, so each dev reload opens a new connection pool. Import the shared client everywhere; never call new PrismaClient() in a route.


Cheat sheet

# schema
String @db.Text        long text and URLs
Json                   flexible structure (can't be filtered well)
@@unique([a, b])       unique per combination
onDelete: Cascade      deletes children with the parent

# commands
npx prisma migrate dev --name x   after editing the schema (dev)
npx prisma migrate deploy         production
npx prisma generate               client undefined? run this
npx prisma studio                 browse the data

# read
findMany   many rows      findUnique  one, by unique field
findFirst  one, any where count       how many

# write
create  update  upsert  createMany  updateMany  deleteMany

# always
where: { shop: session.shop }      on every shop-data query
deleteMany / updateMany            for anything user-triggered