Schema Ownership Follows Services

There is no central models file. Each service owns its tables in a local schema.ts, and the database package simply re-exports them:

// server/src/db/schema.ts
export * from "../services/accounts/schema"
export * from "../services/catalogs/schema"
export * from "../services/books/schema"

export * from "./relations"

Migrations are generated by drizzle-kit and applied on boot when config.migrate is set — so a fresh checkout is one command away from a working database.

One Table, Every Category

Books store their attributes in a single JSON meta column. A fiction book carries { title, author, isbn, genre }; another category can carry entirely different fields — same table, no migration:

// server/src/services/books/schema.ts
export const books = table("books", {
  price: real("price").notNull(),

  catalogId: text("catalog_id")
    .notNull()
    .references(() => catalogs.id, { onDelete: "cascade" }),

  // JSON-stringified metadata (e.g. { title, author, isbn, genre })
  meta: text("meta").notNull().default("{}"),

  id,
  createdAt,
  updatedAt,
})

The catalog names which rule set governs its books:

// server/src/services/catalogs/schema.ts (excerpt)
export const catalogs = table("catalogs", {
  id: text("id").unique().primaryKey().default(uniqId),
  name: text("name").notNull(),
  owner: text("owner").notNull(),
  fee: real("fee").default(0),
  // name of a rules file (e.g. "fiction") used to validate this catalog's books
  rules: text("rules"),
  createdAt,
  updatedAt,
})

The Rules Engine

Schema flexibility without validation anarchy: before a book is written, the operation loads the catalog’s YAML rule set and enforces it. Data, not code — a new category is a new file:

# server/src/rules/fiction.yaml
price:
  min: 0.99
  max: 500
meta:
  fields:
    title:  { required: true, maxLen: 200 }
    author: { required: true, maxLen: 120 }
    isbn:   { required: true, regex: "^[0-9-]{10,17}$" }
    genre:  { enum: [fiction, sci-fi, fantasy, mystery, romance] }

validateItem walks the rules — price bounds, then per-field required / regex / enum / minLen / maxLen / contains — and throws a typed InvalidError with a human-readable message that travels all the way to the UI form:

// server/src/rules/validate.ts (excerpt)
if (rule.regex !== undefined && !new RegExp(rule.regex).test(str)) {
  throw new InvalidError(
    `${prefix}Field "${key}" does not match pattern ${rule.regex}`
  )
}

Enforcement at the Operation Boundary

books/add shows the full picture — authentication, ownership, rules, then the write:

// server/src/services/books/add.ts (excerpt)
if (!io.identity) throw new UnauthenticatedError("Must be authenticated")

const catalog = await db.query.catalogs.findFirst({
  where: eq(db.catalogs.id, data.catalogId),
})
if (!catalog) throw new NotFoundError("Catalog not found")
if (!io.isSystem && catalog.owner !== io.identity.accountId)
  throw new UnauthorizedError("Not the owner of this catalog")

// enforce the catalog's rule set, if any
if (catalog.rules) {
  validateItem(loadRules(catalog.rules), { price: data.price, meta: data.meta })
}

const [record] = await db
  .insert(db.books)
  .values({
    price: data.price,
    catalogId: data.catalogId,
    meta: JSON.stringify(data.meta ?? {}),
  })
  .returning()

The database stays schema-light where the domain is flexible (book metadata) and strict where it matters (ownership, referential integrity with cascade deletes, typed enums on accounts). Validation lives at the boundary where the best error messages can be produced.