diff --git a/packages/orm-drizzle/LICENSE b/packages/orm-drizzle/LICENSE
new file mode 100644
index 0000000..a2785e1
--- /dev/null
+++ b/packages/orm-drizzle/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Suleiman Shahbari
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/orm-drizzle/README.md b/packages/orm-drizzle/README.md
new file mode 100644
index 0000000..3748c64
--- /dev/null
+++ b/packages/orm-drizzle/README.md
@@ -0,0 +1,39 @@
+# @gemstack/orm-drizzle
+
+The Drizzle adapter for [`@gemstack/orm`](../universal-orm) — the **real
+glue** that executes the neutral repository calls as Drizzle queries against the
+app's connection. The app installs this one adapter; extensions never import an ORM
+(same shape as `@universal-middleware/*`).
+
+```js
+import { drizzle } from 'drizzle-orm/node-postgres'
+import { createRepository } from '@gemstack/orm'
+import { createDrizzleAdapter } from '@gemstack/orm-drizzle'
+import * as schema from './drizzle/schema.generated.ts' // generated by universal-schema
+
+const db = createRepository({ tables }, createDrizzleAdapter(drizzle(pool), schema))
+await db.subscriptions.upsert({ subject, plan, status }, { onConflict: 'subject' })
+```
+
+`createDrizzleAdapter(db, tables)` takes the Drizzle connection and the generated
+Drizzle tables — either an **array** (keyed by their SQL name) or an **object map**
+of `name -> table` (e.g. `import * as schema`).
+
+## The one real translation
+
+universal-orm speaks the **schema's column names** (snake_case, `password_hash`),
+while a Drizzle table is keyed by **JS property names** (camelCase, `passwordHash`).
+For each table the adapter reads `getTableColumns` (whose entries are
+`[propertyKey, column]` with `column.name` the DB name) and builds the maps to
+translate rows in (`name -> prop` for `.values()`/`.set()`) and back out
+(`prop -> name`). WHERE clauses are built from the column objects, so they are
+dialect-correct. Filters are equality + `in` only — the same narrow surface as the
+memory adapter; anything richer drops to raw Drizzle.
+
+No transactions yet (the common op is a single, atomic upsert).
+
+## Requirements
+
+`drizzle-orm` is a **peer dependency** (the app already has it). Tests run against
+[pglite](https://github.com/electric-sql/pglite) — an in-process Postgres, no server
+— so the translation is proven against real SQL, not a mock.
diff --git a/packages/orm-drizzle/package.json b/packages/orm-drizzle/package.json
new file mode 100644
index 0000000..a2b1482
--- /dev/null
+++ b/packages/orm-drizzle/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@gemstack/orm-drizzle",
+ "version": "0.1.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "description": "Drizzle adapter for @gemstack/orm: translates the neutral insert/find/upsert/update/delete calls into Drizzle queries against the app-provided connection. The app installs this one adapter; extensions never import an ORM.",
+ "exports": {
+ ".": "./src/index.js"
+ },
+ "scripts": {
+ "test": "node --test"
+ },
+ "dependencies": {
+ "@gemstack/orm": "workspace:*"
+ },
+ "peerDependencies": {
+ "drizzle-orm": ">=0.30.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.3",
+ "drizzle-orm": "^0.45.2"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/gemstack-land/gemstack",
+ "directory": "packages/orm-drizzle"
+ },
+ "bugs": {
+ "url": "https://github.com/gemstack-land/gemstack/issues"
+ },
+ "homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/orm-drizzle#readme",
+ "author": "Suleiman Shahbari"
+}
diff --git a/packages/orm-drizzle/src/index.js b/packages/orm-drizzle/src/index.js
new file mode 100644
index 0000000..abd8533
--- /dev/null
+++ b/packages/orm-drizzle/src/index.js
@@ -0,0 +1,156 @@
+// The Drizzle adapter (#46): the REAL glue that runs the neutral universal-orm
+// operations against a Drizzle connection. The app constructs its Drizzle `db` and
+// hands it here together with the generated Drizzle tables; extensions keep calling
+// `db.
.` and never import an ORM. Same shape as `@universal-middleware/*`:
+// one adapter, installed by the app.
+//
+// import { drizzle } from 'drizzle-orm/node-postgres'
+// import * as schema from './drizzle/schema.generated.ts'
+// const adapter = createDrizzleAdapter(drizzle(pool), schema)
+//
+// The one real translation problem this solves: universal-orm speaks the SCHEMA's
+// column names (snake_case, e.g. `password_hash`), while a Drizzle table is keyed by
+// JS PROPERTY names (camelCase, e.g. `passwordHash`). For each table we read
+// `getTableColumns`, whose entries are `[propKey, column]` with `column.name` the DB
+// name, and build the maps to translate rows in (name -> prop) and out (prop -> name).
+// Filters/where-clauses are built from the column OBJECTS, so they are dialect-correct.
+
+import { getTableColumns, getTableName, eq, isNull, inArray, and, asc, desc, count } from 'drizzle-orm'
+import { normalizeOrderBy, isInCondition } from '@gemstack/orm'
+
+// Per-table name<->property maps + the column objects used to build WHERE clauses.
+function metaOf(table) {
+ const byName = {} // DB column name -> Drizzle column object (for eq/inArray)
+ const nameToProp = {} // DB column name -> JS property key (for .values()/.set())
+ const propToName = {} // JS property key -> DB column name (for mapping rows back)
+ for (const [prop, col] of Object.entries(getTableColumns(table))) {
+ byName[col.name] = col
+ nameToProp[col.name] = prop
+ propToName[prop] = col.name
+ }
+ return { byName, nameToProp, propToName }
+}
+
+export function createDrizzleAdapter(db, tables) {
+ // `tables` may be an array of Drizzle tables (keyed by their SQL name) or an
+ // object map of neutral-name -> table (e.g. `import * as schema`).
+ const entries = Array.isArray(tables)
+ ? tables.map((t) => [getTableName(t), t])
+ : Object.entries(tables)
+ const registry = new Map(entries.map(([name, table]) => [name, { table, meta: metaOf(table) }]))
+
+ const resolve = (name) => {
+ const entry = registry.get(name)
+ if (!entry) throw new Error(`@gemstack/orm-drizzle: no Drizzle table registered for "${name}"`)
+ return entry
+ }
+
+ // Resolve a neutral DB-name to its Drizzle column object (for eq/inArray/orderBy/
+ // onConflict), throwing a clear, role-specific error on a typo. `role` names the
+ // role in the error (e.g. 'orderBy ', 'conflict ') so the message points at the
+ // exact place the unknown column came from.
+ const columnOf = (meta, name, role = '') => {
+ const col = meta.byName[name]
+ if (!col) throw new Error(`@gemstack/orm-drizzle: unknown ${role}column "${name}"`)
+ return col
+ }
+
+ // Translate a neutral row/patch (DB-name keys) into Drizzle input (property keys).
+ const toInput = (obj, meta) =>
+ Object.fromEntries(
+ Object.entries(obj).map(([name, value]) => {
+ const prop = meta.nameToProp[name]
+ if (!prop) throw new Error(`@gemstack/orm-drizzle: unknown column "${name}"`)
+ return [prop, value]
+ }),
+ )
+
+ // Translate a Drizzle result row (property keys) back to the neutral shape (DB names).
+ const fromRow = (row, meta) =>
+ Object.fromEntries(Object.entries(row).map(([prop, value]) => [meta.propToName[prop] ?? prop, value]))
+
+ // Build a WHERE clause from a neutral filter. Equality + `in` only — the same
+ // narrow surface the memory adapter honours. Empty filter => no WHERE (all rows).
+ // `{ col: null }` is IS NULL, not `col = NULL` (which is UNKNOWN and matches no
+ // row): the in-process matcher treats `null` as equality against a null column,
+ // so the SQL adapters must too, or the soft-delete read `find({ deleted_at: null })`
+ // silently returns zero rows here while working on memory/rudder.
+ const whereOf = (filter, meta) => {
+ const conds = []
+ for (const [name, cond] of Object.entries(filter ?? {})) {
+ const col = columnOf(meta, name)
+ conds.push(isInCondition(cond) ? inArray(col, cond.in) : cond === null ? isNull(col) : eq(col, cond))
+ }
+ return conds.length ? and(...conds) : undefined
+ }
+
+ return {
+ async insert(table, row) {
+ const { table: t, meta } = resolve(table)
+ const [r] = await db.insert(t).values(toInput(row, meta)).returning()
+ return fromRow(r, meta)
+ },
+
+ async find(table, filter, opts = {}) {
+ const { table: t, meta } = resolve(table)
+ const where = whereOf(filter, meta)
+ // Build incrementally: where -> orderBy -> limit -> offset, so a query with
+ // none of them is byte-for-byte the original "select all" path.
+ let query = db.select().from(t)
+ if (where) query = query.where(where)
+ const order = normalizeOrderBy(opts.orderBy)
+ if (order.length) {
+ query = query.orderBy(
+ ...order.map(({ column, dir }) => {
+ const col = columnOf(meta, column, 'orderBy ')
+ return dir === 'desc' ? desc(col) : asc(col)
+ }),
+ )
+ }
+ if (opts.limit != null) query = query.limit(Number(opts.limit))
+ if (opts.offset) query = query.offset(Number(opts.offset))
+ const rows = await query
+ return rows.map((r) => fromRow(r, meta))
+ },
+
+ async count(table, filter) {
+ const { table: t, meta } = resolve(table)
+ const where = whereOf(filter, meta)
+ const query = db.select({ value: count() }).from(t)
+ const [row] = await (where ? query.where(where) : query)
+ return Number(row.value)
+ },
+
+ async upsert(table, row, { onConflict } = {}) {
+ const { table: t, meta } = resolve(table)
+ const values = toInput(row, meta)
+ let query = db.insert(t).values(values)
+ if (onConflict && onConflict.length) {
+ const target = onConflict.map((name) => columnOf(meta, name, 'conflict '))
+ // On conflict, update every NON-conflict column to the incoming value.
+ const set = {}
+ for (const [prop, value] of Object.entries(values)) {
+ if (!onConflict.includes(meta.propToName[prop])) set[prop] = value
+ }
+ query = query.onConflictDoUpdate({ target, set: Object.keys(set).length ? set : values })
+ }
+ const [r] = await query.returning()
+ return fromRow(r, meta)
+ },
+
+ async update(table, filter, patch) {
+ const { table: t, meta } = resolve(table)
+ const where = whereOf(filter, meta)
+ const query = db.update(t).set(toInput(patch, meta))
+ const rows = await (where ? query.where(where) : query).returning()
+ return rows.map((r) => fromRow(r, meta))
+ },
+
+ async delete(table, filter) {
+ const { table: t, meta } = resolve(table)
+ const where = whereOf(filter, meta)
+ const rows = await (where ? db.delete(t).where(where) : db.delete(t)).returning()
+ return rows.length
+ },
+ }
+}
diff --git a/packages/orm-drizzle/test/drizzle.test.js b/packages/orm-drizzle/test/drizzle.test.js
new file mode 100644
index 0000000..15e7004
--- /dev/null
+++ b/packages/orm-drizzle/test/drizzle.test.js
@@ -0,0 +1,153 @@
+// The Drizzle adapter against a REAL Postgres (pglite, in-process — no server, no
+// network) so the translation is proven, not mocked. It is held to the SAME
+// contract as `@gemstack/orm-memory`, plus the one thing only this adapter has to
+// get right: the schema's snake_case column names (`password_hash`) mapping to
+// Drizzle's camelCase property keys (`passwordHash`).
+
+import { test, before, beforeEach } from 'node:test'
+import assert from 'node:assert/strict'
+import { PGlite } from '@electric-sql/pglite'
+import { drizzle } from 'drizzle-orm/pglite'
+import { pgTable, uuid, varchar, boolean } from 'drizzle-orm/pg-core'
+import { createRepository } from '@gemstack/orm'
+import { createDrizzleAdapter } from '../src/index.js'
+
+// camelCase property keys, snake_case DB columns — the real generated-schema shape.
+const users = pgTable('users', {
+ id: uuid('id').primaryKey(),
+ email: varchar('email', { length: 255 }).unique(),
+ name: varchar('name', { length: 255 }),
+ passwordHash: varchar('password_hash', { length: 255 }),
+ active: boolean('active'),
+})
+
+// The neutral schema the repository is built from speaks DB names (snake_case).
+const schema = {
+ tables: [
+ {
+ table: 'users',
+ columns: ['id', 'email', 'name', 'password_hash', 'active'].map((name) => ({ name })),
+ },
+ ],
+}
+
+const ID = '00000000-0000-0000-0000-000000000001'
+let client
+let db
+
+before(async () => {
+ client = new PGlite()
+})
+
+beforeEach(async () => {
+ await client.exec('drop table if exists users;')
+ await client.exec(
+ 'create table users (id uuid primary key, email varchar(255) unique, name varchar(255), password_hash varchar(255), active boolean);',
+ )
+ db = createRepository(schema, createDrizzleAdapter(drizzle(client), [users]))
+})
+
+test('insert returns the row in neutral (snake_case) shape', async () => {
+ const row = await db.users.insert({ id: ID, email: 'a@b.com', name: 'A', password_hash: 'h', active: true })
+ assert.equal(row.password_hash, 'h') // snake_case out, not Drizzle's `passwordHash`
+ assert.equal(row.email, 'a@b.com')
+})
+
+test('find by snake_case column, equality and `in`', async () => {
+ await db.users.insert({ id: ID, email: 'a@b.com', password_hash: 'h', active: true })
+ assert.equal((await db.users.find({ password_hash: 'h' })).length, 1)
+ assert.equal((await db.users.find({ password_hash: 'nope' })).length, 0)
+ assert.equal((await db.users.find({ email: { in: ['a@b.com', 'x@y.com'] } })).length, 1)
+ assert.equal((await db.users.find()).length, 1)
+})
+
+test('findOne returns the first match or null', async () => {
+ assert.equal(await db.users.findOne({ id: ID }), null)
+ await db.users.insert({ id: ID, email: 'a@b.com', active: true })
+ assert.equal((await db.users.findOne({ id: ID })).email, 'a@b.com')
+})
+
+test('upsert inserts then updates the conflicting row in place', async () => {
+ await db.users.upsert({ id: ID, email: 'a@b.com', name: 'A', active: true }, { onConflict: 'email' })
+ await db.users.upsert({ id: ID, email: 'a@b.com', name: 'A2', active: true }, { onConflict: 'email' })
+ const all = await db.users.find()
+ assert.equal(all.length, 1)
+ assert.equal(all[0].name, 'A2')
+})
+
+test('update patches matching rows and returns them in neutral shape', async () => {
+ await db.users.insert({ id: ID, email: 'a@b.com', password_hash: 'h', active: true })
+ const updated = await db.users.update({ id: ID }, { password_hash: 'h2', active: false })
+ assert.equal(updated.length, 1)
+ assert.equal(updated[0].password_hash, 'h2')
+ assert.equal(updated[0].active, false)
+})
+
+test('delete removes matching rows and returns the count', async () => {
+ await db.users.insert({ id: ID, email: 'a@b.com', active: true })
+ await db.users.insert({ id: '00000000-0000-0000-0000-000000000002', email: 'b@b.com', active: false })
+ assert.equal(await db.users.delete({ active: false }), 1)
+ assert.deepEqual((await db.users.find()).map((r) => r.email), ['a@b.com'])
+})
+
+// `{ col: null }` must mean IS NULL, not `col = NULL` (which matches no row in SQL).
+// This is the soft-delete read `find({ deleted_at: null })` — it has to return the
+// same rows here as on the memory/rudder adapters, not silently zero.
+test('`{ col: null }` filters by IS NULL across find/count/update/delete', async () => {
+ await db.users.insert({ id: ID, email: 'a@b.com', name: 'A', active: true })
+ await db.users.insert({ id: '00000000-0000-0000-0000-000000000002', email: 'b@b.com', name: null, active: true })
+
+ // find: the null row matches IS NULL; the named row matches equality (no false positive).
+ assert.deepEqual((await db.users.find({ name: null })).map((r) => r.email), ['b@b.com'])
+ assert.deepEqual((await db.users.find({ name: 'A' })).map((r) => r.email), ['a@b.com'])
+ // count honours the same null filter.
+ assert.equal(await db.users.count({ name: null }), 1)
+ // update reaches the null row.
+ const patched = await db.users.update({ name: null }, { name: 'filled' })
+ assert.deepEqual(patched.map((r) => r.email), ['b@b.com'])
+ assert.equal(await db.users.count({ name: null }), 0)
+ // delete reaches it too.
+ await db.users.update({ email: 'b@b.com' }, { name: null })
+ assert.equal(await db.users.delete({ name: null }), 1)
+ assert.deepEqual((await db.users.find()).map((r) => r.email), ['a@b.com'])
+})
+
+test('an unregistered table is a clear error', async () => {
+ await assert.rejects(createDrizzleAdapter(drizzle(client), []).insert('ghost', {}), /no Drizzle table registered for "ghost"/)
+})
+
+// Seed 4 users (a@..d@) for the real ORDER BY / LIMIT / OFFSET / COUNT paths.
+async function seed4() {
+ const letters = ['a', 'b', 'c', 'd']
+ for (let i = 0; i < letters.length; i++) {
+ await db.users.insert({ id: `00000000-0000-0000-0000-00000000000${i + 1}`, email: `${letters[i]}@b.com`, active: i % 2 === 0 })
+ }
+}
+
+test('find orders by a column (real ORDER BY), asc and desc', async () => {
+ await seed4()
+ assert.deepEqual((await db.users.find({}, { orderBy: 'email' })).map((r) => r.email), ['a@b.com', 'b@b.com', 'c@b.com', 'd@b.com'])
+ assert.deepEqual(
+ (await db.users.find({}, { orderBy: { column: 'email', dir: 'desc' } })).map((r) => r.email),
+ ['d@b.com', 'c@b.com', 'b@b.com', 'a@b.com'],
+ )
+})
+
+test('find pages with limit + offset (real LIMIT/OFFSET)', async () => {
+ await seed4()
+ const page2 = await db.users.find({}, { orderBy: 'email', limit: 2, offset: 2 })
+ assert.deepEqual(page2.map((r) => r.email), ['c@b.com', 'd@b.com'])
+})
+
+test('limit/offset compose with a where clause', async () => {
+ await seed4()
+ const active = await db.users.find({ active: true }, { orderBy: { column: 'email', dir: 'desc' }, limit: 1 })
+ assert.deepEqual(active.map((r) => r.email), ['c@b.com'])
+})
+
+test('count returns a number (real COUNT), honouring the filter', async () => {
+ await seed4()
+ assert.equal(await db.users.count(), 4)
+ assert.equal(await db.users.count({ active: true }), 2)
+ assert.equal(await db.users.count({ active: false }), 2)
+})
diff --git a/packages/orm-memory/LICENSE b/packages/orm-memory/LICENSE
new file mode 100644
index 0000000..a2785e1
--- /dev/null
+++ b/packages/orm-memory/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Suleiman Shahbari
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/orm-memory/README.md b/packages/orm-memory/README.md
new file mode 100644
index 0000000..aa2032d
--- /dev/null
+++ b/packages/orm-memory/README.md
@@ -0,0 +1,23 @@
+# @gemstack/orm-memory
+
+The in-process adapter for [`@gemstack/orm`](../universal-orm). It runs
+the neutral repository calls against plain in-memory `Map`s — no database, no ORM.
+It is the adapter the tests, the demo app, and the proof run on.
+
+```js
+import { createRepository } from '@gemstack/orm'
+import { createMemoryAdapter } from '@gemstack/orm-memory'
+
+const db = createRepository({ tables }, createMemoryAdapter())
+await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+```
+
+It honours the same six-operation contract every adapter must, and reuses
+universal-orm's shared `matchesFilter` and `applyListOpts`, so its notion of a
+filter and of orderBy/limit/offset paging is identical to
+every other in-process adapter. Swapping in [`@gemstack/orm-drizzle`](../universal-orm-drizzle)
+for a real database changes only this one line — the extension code calling
+`db..` does not change.
+
+Rows are copied on the way in and out, so mutating a caller's object (or a returned
+row) never reaches back into the store.
diff --git a/packages/orm-memory/package.json b/packages/orm-memory/package.json
new file mode 100644
index 0000000..9412b6d
--- /dev/null
+++ b/packages/orm-memory/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@gemstack/orm-memory",
+ "version": "0.1.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "description": "In-process memory adapter for @gemstack/orm: executes the neutral repository calls against plain Maps. Zero database, zero ORM \u2014 for tests, demos, and the proof.",
+ "exports": {
+ ".": "./src/index.js"
+ },
+ "scripts": {
+ "test": "node --test"
+ },
+ "dependencies": {
+ "@gemstack/orm": "workspace:*"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/gemstack-land/gemstack",
+ "directory": "packages/orm-memory"
+ },
+ "bugs": {
+ "url": "https://github.com/gemstack-land/gemstack/issues"
+ },
+ "homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/orm-memory#readme",
+ "author": "Suleiman Shahbari",
+ "devDependencies": {
+ "@gemstack/schema": "workspace:*"
+ }
+}
diff --git a/packages/orm-memory/src/index.js b/packages/orm-memory/src/index.js
new file mode 100644
index 0000000..419cf85
--- /dev/null
+++ b/packages/orm-memory/src/index.js
@@ -0,0 +1,72 @@
+// The in-process adapter (#46): executes the neutral universal-orm operations
+// against plain in-memory Maps. No database, no ORM — the adapter the tests, the
+// demo app and the proof run on. A real deployment swaps in `@gemstack/orm-drizzle`
+// (or a future Prisma/native adapter); the extension code calling `db..`
+// does not change.
+//
+// It honours the same contract every adapter must (the six operations, each taking
+// the table name first) and reuses universal-orm's shared `matchesFilter` /
+// `applyListOpts`, so its notion of a filter — and of paging/ordering — is identical
+// to every other in-process adapter.
+
+import { matchesFilter, applyListOpts } from '@gemstack/orm'
+
+export function createMemoryAdapter() {
+ const store = new Map() // table name -> row[]
+ const rowsOf = (table) => {
+ if (!store.has(table)) store.set(table, [])
+ return store.get(table)
+ }
+
+ return {
+ async insert(table, row) {
+ rowsOf(table).push({ ...row })
+ return { ...row }
+ },
+
+ async find(table, filter, opts) {
+ const matched = rowsOf(table)
+ .filter((r) => matchesFilter(r, filter))
+ .map((r) => ({ ...r }))
+ // applyListOpts orders/slices in process — the shared semantics every
+ // in-process adapter honours (mirror of `matchesFilter` for filtering).
+ return applyListOpts(matched, opts)
+ },
+
+ async count(table, filter) {
+ return rowsOf(table).filter((r) => matchesFilter(r, filter)).length
+ },
+
+ async upsert(table, row, { onConflict } = {}) {
+ const rows = rowsOf(table)
+ if (onConflict && onConflict.length) {
+ const existing = rows.find((r) => onConflict.every((c) => r[c] === row[c]))
+ if (existing) {
+ Object.assign(existing, row)
+ return { ...existing }
+ }
+ }
+ rows.push({ ...row })
+ return { ...row }
+ },
+
+ async update(table, filter, patch) {
+ const updated = []
+ for (const r of rowsOf(table)) {
+ if (matchesFilter(r, filter)) {
+ Object.assign(r, patch)
+ updated.push({ ...r })
+ }
+ }
+ return updated
+ },
+
+ async delete(table, filter) {
+ const rows = rowsOf(table)
+ const keep = rows.filter((r) => !matchesFilter(r, filter))
+ const removed = rows.length - keep.length
+ store.set(table, keep)
+ return removed
+ },
+ }
+}
diff --git a/packages/orm-memory/test/memory.test.js b/packages/orm-memory/test/memory.test.js
new file mode 100644
index 0000000..6946993
--- /dev/null
+++ b/packages/orm-memory/test/memory.test.js
@@ -0,0 +1,117 @@
+// The memory adapter, exercised through the real repository (createRepository)
+// over a composed schema. This is the same contract `@gemstack/orm-drizzle` is
+// held to — the two adapters are interchangeable behind `db..`.
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema, mergeSchemas } from '@gemstack/schema'
+import { createRepository } from '@gemstack/orm'
+import { createMemoryAdapter } from '../src/index.js'
+
+function makeDb() {
+ const { tables } = mergeSchemas([
+ defineSchema('users', (t) => {
+ t.uuid('id').primary()
+ t.string('email').unique()
+ t.string('name').nullable()
+ t.boolean('active')
+ }),
+ ])
+ return createRepository({ tables }, createMemoryAdapter())
+}
+
+test('insert + find round-trips, with equality and `in` filters', async () => {
+ const db = makeDb()
+ await db.users.insert({ id: 'u1', email: 'a@b.com', name: 'A', active: true })
+ await db.users.insert({ id: 'u2', email: 'b@b.com', name: 'B', active: false })
+ assert.equal((await db.users.find()).length, 2)
+ assert.deepEqual((await db.users.find({ active: true })).map((r) => r.id), ['u1'])
+ assert.deepEqual(
+ (await db.users.find({ id: { in: ['u1', 'u2'] } })).map((r) => r.id).sort(),
+ ['u1', 'u2'],
+ )
+})
+
+test('findOne returns first match or null', async () => {
+ const db = makeDb()
+ assert.equal(await db.users.findOne({ id: 'x' }), null)
+ await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+ assert.equal((await db.users.findOne({ id: 'u1' })).email, 'a@b.com')
+})
+
+test('upsert inserts then updates the conflicting row in place', async () => {
+ const db = makeDb()
+ await db.users.upsert({ id: 'u1', email: 'a@b.com', name: 'A', active: true }, { onConflict: 'email' })
+ await db.users.upsert({ id: 'u1', email: 'a@b.com', name: 'A2', active: true }, { onConflict: 'email' })
+ const all = await db.users.find()
+ assert.equal(all.length, 1)
+ assert.equal(all[0].name, 'A2')
+})
+
+test('update patches matching rows and returns them', async () => {
+ const db = makeDb()
+ await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+ const updated = await db.users.update({ id: 'u1' }, { active: false })
+ assert.deepEqual(updated.map((r) => r.active), [false])
+})
+
+test('delete removes matching rows and returns the count', async () => {
+ const db = makeDb()
+ await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+ await db.users.insert({ id: 'u2', email: 'b@b.com', active: false })
+ assert.equal(await db.users.delete({ active: false }), 1)
+ assert.deepEqual((await db.users.find()).map((r) => r.id), ['u1'])
+})
+
+test('rows are copied in and out (no shared mutable references)', async () => {
+ const db = makeDb()
+ const input = { id: 'u1', email: 'a@b.com', active: true }
+ await db.users.insert(input)
+ input.email = 'mutated@b.com' // mutating the caller's object must not affect the store
+ const fetched = await db.users.findOne({ id: 'u1' })
+ assert.equal(fetched.email, 'a@b.com')
+ fetched.email = 'also-mutated' // mutating a returned row must not affect the store
+ assert.equal((await db.users.findOne({ id: 'u1' })).email, 'a@b.com')
+})
+
+// Seed N users (a@, b@, c@... / A, B, C...) for the paging/order/count tests.
+async function seedUsers(db, n) {
+ const letters = 'abcdefghijklmnopqrstuvwxyz'
+ for (let i = 0; i < n; i++) {
+ await db.users.insert({ id: `u${i}`, email: `${letters[i]}@b.com`, name: letters[i].toUpperCase(), active: true })
+ }
+}
+
+test('find orders by a column, ascending and descending', async () => {
+ const db = makeDb()
+ await seedUsers(db, 4)
+ assert.deepEqual((await db.users.find({}, { orderBy: 'email' })).map((r) => r.id), ['u0', 'u1', 'u2', 'u3'])
+ assert.deepEqual(
+ (await db.users.find({}, { orderBy: { column: 'email', dir: 'desc' } })).map((r) => r.id),
+ ['u3', 'u2', 'u1', 'u0'],
+ )
+})
+
+test('find applies limit and offset (a page slice)', async () => {
+ const db = makeDb()
+ await seedUsers(db, 5)
+ const page2 = await db.users.find({}, { orderBy: 'email', limit: 2, offset: 2 })
+ assert.deepEqual(page2.map((r) => r.id), ['u2', 'u3'])
+})
+
+test('limit/offset compose with a filter', async () => {
+ const db = makeDb()
+ await seedUsers(db, 4)
+ await db.users.insert({ id: 'x', email: 'z@b.com', name: 'Z', active: false })
+ const rows = await db.users.find({ active: true }, { orderBy: { column: 'email', dir: 'desc' }, limit: 2 })
+ assert.deepEqual(rows.map((r) => r.id), ['u3', 'u2'])
+})
+
+test('count returns the number of matching rows, honouring the filter', async () => {
+ const db = makeDb()
+ await seedUsers(db, 3)
+ await db.users.insert({ id: 'x', email: 'z@b.com', name: 'Z', active: false })
+ assert.equal(await db.users.count(), 4)
+ assert.equal(await db.users.count({ active: true }), 3)
+ assert.equal(await db.users.count({ active: false }), 1)
+})
diff --git a/packages/orm/LICENSE b/packages/orm/LICENSE
new file mode 100644
index 0000000..a2785e1
--- /dev/null
+++ b/packages/orm/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Suleiman Shahbari
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/orm/README.md b/packages/orm/README.md
new file mode 100644
index 0000000..5a172e9
--- /dev/null
+++ b/packages/orm/README.md
@@ -0,0 +1,120 @@
+# @gemstack/orm
+
+The **runtime** half of the data layer: how an extension reads and writes its data
+**without importing an ORM**. It is the runtime twin of
+[`@gemstack/schema`](../schema) (the shape half) and the
+missing member of the `universal-*` family:
+
+| Universal layer | One ... | runs on any ... |
+|---|---|---|
+| `universal-middleware` | request handler | server (Hono / Express / CF) |
+| `universal-schema` | schema (DDL — the shape) | ORM (Prisma / Drizzle / Rudder) |
+| **`universal-orm`** | repository call (DML — the data) | ORM |
+
+An extension declares its tables once with the schema DSL, then talks to a neutral
+repository. A per-ORM **adapter** executes the calls against the app's database.
+`vike-stripe` just does `db.subscriptions.upsert(...)` — no ORM import.
+
+## The surface is narrow on purpose
+
+```js
+db.users.insert(row) // -> inserted row
+db.users.find(filter) // -> matching rows (array)
+db.users.findOne(filter) // -> first match | null
+db.users.upsert(row, { onConflict }) // -> upserted row
+db.users.update(filter, patch) // -> updated rows (array)
+db.users.delete(filter) // -> number of rows deleted
+```
+
+Filters are simple **equality** or **`in`** conditions — nothing more:
+
+```js
+db.users.find({ active: true }) // equality
+db.users.find({ id: { in: ['u1', 'u2'] } })// membership
+db.users.find() // all rows
+```
+
+Joins, aggregates, ranges, raw SQL — deliberately **out of scope**. Drop to the
+underlying ORM for those, the same escape hatch as DB-specific column types. This
+is not a query language (that is Kysely's job); it is the 90%-case repository.
+
+## Using it
+
+```js
+import { createRepository } from '@gemstack/orm'
+import { defineSchema, mergeSchemas } from '@gemstack/schema'
+
+const { tables } = mergeSchemas([
+ defineSchema('users', (t) => {
+ t.uuid('id').primary()
+ t.string('email').unique()
+ t.boolean('active')
+ }),
+])
+
+const db = createRepository({ tables }, adapter) // adapter: see below
+await db.users.upsert({ id: 'u1', email: 'a@b.com', active: true }, { onConflict: 'email' })
+```
+
+Tables and their columns come from the **merged schema** (the output of
+`mergeSchemas`), the same single source the ORM artifacts are generated from. A
+typo'd column or an unknown table is a clear error, not a silent no-op.
+
+## The adapter contract
+
+The app installs **one** adapter and hands it the connection; extensions never
+import an ORM (same shape as `@universal-middleware/*`). An adapter implements six
+operations, each taking the table **name** first:
+
+```js
+const adapter = {
+ insert(table, row), // -> inserted row
+ find(table, filter, opts), // -> rows[] (opts: { limit, offset, orderBy })
+ count(table, filter), // -> number of matching rows
+ upsert(table, row, { onConflict }), // -> upserted row (onConflict: column names)
+ update(table, filter, patch), // -> updated rows[]
+ delete(table, filter), // -> number deleted
+}
+```
+
+`findOne` is not an adapter op; the repository derives it from `find` (with `limit: 1`).
+
+In-process adapters can reuse the shared filter matcher so every adapter agrees on
+what a filter means; SQL adapters translate the same shape into a `WHERE` clause:
+
+```js
+import { matchesFilter } from '@gemstack/orm'
+matchesFilter(row, { active: true })
+```
+
+The shippable adapters — an in-memory one for tests/demos and
+`@gemstack/orm-drizzle` for real — are tracked in
+[#46](https://github.com/suliemandev/vike-data/issues/46). No transactions yet: the
+common operation is a single (atomic) upsert.
+
+## The adapter registry: one adapter, every extension
+
+So the **app picks the backend once** and every extension routes through it, the
+core holds a tiny runtime registry. The app calls `setAdapter(...)` at server start;
+each extension reads the same adapter via `getAdapter()` and builds its repository
+over its own schema. No extension hardcodes a backend, and none imports an ORM.
+
+```js
+// the app, once at server start:
+import { setAdapter } from '@gemstack/orm'
+import { createDrizzleAdapter } from '@gemstack/orm-drizzle'
+setAdapter(createDrizzleAdapter(drizzle(pool), schema)) // or createMemoryAdapter()
+
+// an extension, anywhere:
+import { getAdapter, createRepository } from '@gemstack/orm'
+import { createMemoryAdapter } from '@gemstack/orm-memory'
+const adapter = getAdapter() ?? createMemoryAdapter() // app's choice, else zero-config memory
+const db = createRepository({ tables }, adapter)
+```
+
+`getAdapter()` returns `null` until the app sets one, so an extension falls back to
+the memory adapter for zero-config dev/demo/proof. `setAdapter` validates the six-op
+contract up front, and the registry is cached on `globalThis` so pointer-import / HMR
+double-eval can't fork it. `clearAdapter()` resets it (tests).
+
+> **Zero Vike, zero ORM imports.** Usable standalone by any framework or ORM.
diff --git a/packages/orm/package.json b/packages/orm/package.json
new file mode 100644
index 0000000..2edc862
--- /dev/null
+++ b/packages/orm/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@gemstack/orm",
+ "version": "0.1.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "description": "Framework-agnostic runtime data access: a neutral, narrow repository (db..insert/find/upsert/update/delete) over a composed schema, plus the adapter contract a per-ORM adapter implements. Zero framework and zero ORM imports.",
+ "exports": {
+ ".": "./src/index.js"
+ },
+ "scripts": {
+ "test": "node --test"
+ },
+ "keywords": [
+ "orm",
+ "data",
+ "repository",
+ "database",
+ "query-builder",
+ "framework-agnostic",
+ "gemstack"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/gemstack-land/gemstack",
+ "directory": "packages/orm"
+ },
+ "bugs": {
+ "url": "https://github.com/gemstack-land/gemstack/issues"
+ },
+ "homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/orm#readme",
+ "author": "Suleiman Shahbari",
+ "devDependencies": {
+ "@gemstack/schema": "workspace:*"
+ }
+}
diff --git a/packages/orm/src/filter.js b/packages/orm/src/filter.js
new file mode 100644
index 0000000..0fd445b
--- /dev/null
+++ b/packages/orm/src/filter.js
@@ -0,0 +1,32 @@
+// A filter is a plain object of column -> condition. Two condition forms only —
+// the narrow surface (#44):
+//
+// { col: value } equality (col === value)
+// { col: { in: [...] } } membership (value of col is one of the list)
+//
+// An empty filter `{}` matches every row. Anything richer (ranges, OR, LIKE,
+// joins) is deliberately out of scope: drop to the underlying ORM for those, the
+// same escape hatch as DB-specific column types.
+//
+// Adapters that filter IN PROCESS (the memory adapter, a future native one) share
+// this matcher so every adapter agrees on exactly what a filter means. SQL-backed
+// adapters (`@gemstack/orm-drizzle`) translate the same shape into a WHERE clause
+// instead, but must honour the identical semantics.
+// True when a filter condition is the membership form `{ in: [...] }` rather than a plain
+// equality value. The one predicate that tells the two condition forms apart, shared so the
+// in-process matcher here and the SQL adapters (drizzle/rudder) can't drift on what an `in`
+// condition is.
+export function isInCondition(cond) {
+ return cond !== null && typeof cond === 'object' && Array.isArray(cond.in)
+}
+
+export function matchesFilter(row, filter = {}) {
+ for (const [col, cond] of Object.entries(filter)) {
+ if (isInCondition(cond)) {
+ if (!cond.in.includes(row[col])) return false
+ } else if (row[col] !== cond) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/packages/orm/src/index.js b/packages/orm/src/index.js
new file mode 100644
index 0000000..1d605c1
--- /dev/null
+++ b/packages/orm/src/index.js
@@ -0,0 +1,4 @@
+export { createRepository, ADAPTER_OPS } from './repository.js'
+export { matchesFilter, isInCondition } from './filter.js'
+export { normalizeOrderBy, applyListOpts } from './list.js'
+export { setAdapter, getAdapter, clearAdapter } from './registry.js'
diff --git a/packages/orm/src/list.js b/packages/orm/src/list.js
new file mode 100644
index 0000000..6ce2b45
--- /dev/null
+++ b/packages/orm/src/list.js
@@ -0,0 +1,68 @@
+// List options for `find` — the narrow paging/ordering surface (#44, #86). A
+// `find` call may take a second `opts` argument:
+//
+// { limit, offset, orderBy }
+//
+// limit max rows to return (a non-negative integer)
+// offset rows to skip first (a non-negative integer)
+// orderBy a single sort key or an array, evaluated in order:
+// 'col' ascending by col
+// { column, dir } dir is 'asc' (default) or 'desc'
+//
+// No opts (or an empty object) = every matching row, unordered: the original
+// behaviour, so existing callers are unaffected. Anything richer (multi-column
+// expressions, NULLS FIRST, cursor paging) stays out of scope — drop to the ORM.
+//
+// SQL-backed adapters (`@gemstack/orm-drizzle`) translate `orderBy`/limit/offset
+// into ORDER BY / LIMIT / OFFSET; in-process adapters (memory, a future native one)
+// share `applyListOpts` below so every adapter agrees on exactly what they mean.
+
+// Normalize `orderBy` into an array of `{ column, dir }`. Throws on a missing
+// column or an invalid direction so a typo is a clear error, not a silent no-op.
+export function normalizeOrderBy(orderBy) {
+ if (orderBy == null) return []
+ const list = Array.isArray(orderBy) ? orderBy : [orderBy]
+ return list.map((o) => {
+ const column = typeof o === 'string' ? o : o?.column
+ const dir = typeof o === 'object' && o?.dir != null ? o.dir : 'asc'
+ if (!column || typeof column !== 'string') throw new Error('orderBy: each entry needs a column name')
+ if (dir !== 'asc' && dir !== 'desc') throw new Error(`orderBy: invalid direction "${dir}" (use 'asc' or 'desc')`)
+ return { column, dir }
+ })
+}
+
+// Coerce a limit/offset to a non-negative integer, or undefined when absent.
+// Rejects negatives and non-numbers so a bad `?page=` can't silently page weirdly.
+export function asCount(value, what) {
+ if (value == null) return undefined
+ const n = Number(value)
+ if (!Number.isInteger(n) || n < 0) throw new Error(`${what}: expected a non-negative integer, got ${JSON.stringify(value)}`)
+ return n
+}
+
+// Apply orderBy then offset then limit to an in-memory array (the in-process
+// adapters' shared implementation). Sorts a COPY; nulls sort last in either
+// direction. Returns a new array.
+export function applyListOpts(rows, opts = {}) {
+ let out = rows
+ const order = normalizeOrderBy(opts.orderBy)
+ if (order.length) {
+ out = [...out].sort((a, b) => {
+ for (const { column, dir } of order) {
+ const av = a[column]
+ const bv = b[column]
+ if (av === bv) continue
+ if (av == null) return 1 // nulls last, regardless of direction
+ if (bv == null) return -1
+ const cmp = av < bv ? -1 : 1
+ return dir === 'desc' ? -cmp : cmp
+ }
+ return 0
+ })
+ }
+ const offset = asCount(opts.offset, 'offset')
+ if (offset) out = out.slice(offset)
+ const limit = asCount(opts.limit, 'limit')
+ if (limit != null) out = out.slice(0, limit)
+ return out
+}
diff --git a/packages/orm/src/registry.js b/packages/orm/src/registry.js
new file mode 100644
index 0000000..05109b4
--- /dev/null
+++ b/packages/orm/src/registry.js
@@ -0,0 +1,39 @@
+// The runtime adapter registry — the seam that makes the 3-package split pay off
+// (#66, part of #44). Rom's design: "the app installs one adapter and hands it the
+// connection; extensions stay ORM-free." The app calls `setAdapter(...)` ONCE at
+// server start with its chosen backend — `@gemstack/orm-memory` for dev/test, or
+// `@gemstack/orm-drizzle` for real — and every extension reads the SAME adapter via
+// `getAdapter()` and builds its repository over its own schema. One app-owned
+// connection serves them all; no extension imports an ORM.
+//
+// Cached on `globalThis` so duplicate module evaluation (pointer imports, dev HMR)
+// can't fork the registry into two adapters.
+import { ADAPTER_OPS } from './repository.js'
+
+const KEY = Symbol.for('universal-orm.adapter')
+
+// Register the app's single runtime adapter. Validated against the same ADAPTER_OPS
+// contract `createRepository` enforces, so a malformed adapter fails here — at the
+// app's call site — with a clear message, not later inside an extension.
+export function setAdapter(adapter) {
+ if (!adapter || typeof adapter !== 'object') {
+ throw new Error('setAdapter: expected an adapter object (e.g. createMemoryAdapter() or createDrizzleAdapter(...))')
+ }
+ for (const op of ADAPTER_OPS) {
+ if (typeof adapter[op] !== 'function') {
+ throw new Error(`setAdapter: adapter is missing the "${op}" operation`)
+ }
+ }
+ globalThis[KEY] = adapter
+}
+
+// The registered adapter, or null if the app has not set one (callers fall back to
+// the memory adapter for zero-config dev/demo/proof).
+export function getAdapter() {
+ return globalThis[KEY] ?? null
+}
+
+// Clear the registry (tests).
+export function clearAdapter() {
+ delete globalThis[KEY]
+}
diff --git a/packages/orm/src/repository.js b/packages/orm/src/repository.js
new file mode 100644
index 0000000..323dffe
--- /dev/null
+++ b/packages/orm/src/repository.js
@@ -0,0 +1,123 @@
+// The neutral, ORM-agnostic runtime data surface (#45) — the runtime twin of
+// universal-schema. An extension declares its tables ONCE with the schema DSL
+// (defineSchema/extendSchema) and reads/writes them through `db..(...)`
+// here, never importing an ORM. A per-ORM ADAPTER (#46) executes the calls against
+// the app's database; the SAME `db` runs on the memory adapter (tests/demo) or
+// `@gemstack/orm-drizzle` (real) unchanged. Same shape as `@universal-middleware/*`:
+// the app installs one adapter, extensions stay ORM-free.
+//
+// The surface is NARROW on purpose (#44): insert / find / findOne / count /
+// upsert / update / delete with simple equality + `in` filters (see ./filter.js)
+// and simple limit / offset / orderBy paging (see ./list.js). Joins, aggregates
+// and raw SQL are out — drop to the underlying ORM for those.
+//
+// `schema` is the MERGED schema (the output of universal-schema's mergeSchemas):
+// `{ tables: [{ table, columns: [{ name, ... }] }] }`. Tables and their columns
+// come from that single source, so the surface stays consistent with — and
+// typeable from — the same schema the ORM artifacts are generated from.
+
+// The operations an adapter must implement. Each takes the table NAME first, so a
+// single adapter instance serves every table in the composed schema:
+//
+// insert(table, row) -> the inserted row
+// find(table, filter, opts) -> matching rows (array); opts = { limit, offset, orderBy }
+// count(table, filter) -> number of matching rows (for paging)
+// upsert(table, row, { onConflict }) -> the upserted row
+// update(table, filter, patch) -> the updated rows (array)
+// delete(table, filter) -> number of rows deleted
+//
+// `find`'s `opts` is optional — an adapter that ignores it still returns all
+// matching rows, so paging degrades gracefully. `findOne` is NOT an adapter op —
+// the core derives it from `find` (with `limit: 1`).
+export const ADAPTER_OPS = ['insert', 'find', 'count', 'upsert', 'update', 'delete']
+
+import { normalizeOrderBy, asCount } from './list.js'
+
+// Property names that must NOT be treated as table lookups, so the returned `db`
+// behaves like a normal (non-thenable) object: `await db`, structured-clone and
+// console inspection probe these and must get `undefined`, not a "no such table"
+// throw.
+const RESERVED = new Set(['then', 'catch', 'finally', 'toJSON', 'constructor'])
+
+export function createRepository(schema, adapter) {
+ const tables = new Map((schema?.tables ?? []).map((t) => [t.table, t]))
+ if (tables.size === 0) throw new Error('createRepository: schema has no tables')
+ for (const op of ADAPTER_OPS) {
+ if (typeof adapter?.[op] !== 'function') {
+ throw new Error(`createRepository: adapter is missing the "${op}" operation`)
+ }
+ }
+
+ const columnsOf = (table) => new Set(tables.get(table).columns.map((c) => c.name))
+
+ // Reject unknown columns in a row/filter/patch up front. The schema is the
+ // single source of truth, so a typo'd column ('emial') is a clear error here
+ // rather than a silent no-op or a deep ORM error later.
+ const assertColumns = (table, obj, what) => {
+ if (!obj) return
+ const known = columnsOf(table)
+ for (const key of Object.keys(obj)) {
+ if (!known.has(key)) throw new Error(`${table}.${what}: unknown column "${key}"`)
+ }
+ }
+
+ const repoFor = (table) => ({
+ insert(row) {
+ assertColumns(table, row, 'insert')
+ return adapter.insert(table, row)
+ },
+ find(filter = {}, opts = {}) {
+ assertColumns(table, filter, 'find')
+ // Validate orderBy columns against the schema too (a typo'd sort column is
+ // an error, not a silent no-op), then pass the normalized opts to the adapter.
+ const orderBy = normalizeOrderBy(opts.orderBy)
+ assertColumns(table, Object.fromEntries(orderBy.map((o) => [o.column, true])), 'find orderBy')
+ // Coerce limit/offset HERE (not only in the in-process applyListOpts) so a SQL
+ // adapter, which builds LIMIT/OFFSET straight from opts, is guarded against a
+ // negative or non-integer value (e.g. a bad `?page=`) just like the memory one.
+ const limit = asCount(opts.limit, 'limit')
+ const offset = asCount(opts.offset, 'offset')
+ return adapter.find(table, filter, { ...opts, orderBy, limit, offset })
+ },
+ async findOne(filter = {}, opts = {}) {
+ // Only one row is needed, so cap the adapter at 1 (cheaper on SQL adapters).
+ const rows = await this.find(filter, { ...opts, limit: 1 })
+ return rows[0] ?? null
+ },
+ count(filter = {}) {
+ assertColumns(table, filter, 'count')
+ return adapter.count(table, filter)
+ },
+ upsert(row, opts = {}) {
+ assertColumns(table, row, 'upsert')
+ // Normalize onConflict to an array of column names (accepts a string too).
+ const onConflict = opts.onConflict == null ? undefined : [].concat(opts.onConflict)
+ if (onConflict) assertColumns(table, Object.fromEntries(onConflict.map((c) => [c, true])), 'upsert onConflict')
+ return adapter.upsert(table, row, { onConflict })
+ },
+ update(filter, patch) {
+ assertColumns(table, filter, 'update filter')
+ assertColumns(table, patch, 'update patch')
+ return adapter.update(table, filter, patch)
+ },
+ delete(filter = {}) {
+ assertColumns(table, filter, 'delete')
+ return adapter.delete(table, filter)
+ },
+ })
+
+ const repos = new Map()
+ return new Proxy(
+ {},
+ {
+ get(_target, prop) {
+ if (typeof prop !== 'string' || RESERVED.has(prop)) return undefined
+ if (!tables.has(prop)) {
+ throw new Error(`db.${prop}: no table "${prop}" in the composed schema`)
+ }
+ if (!repos.has(prop)) repos.set(prop, repoFor(prop))
+ return repos.get(prop)
+ },
+ },
+ )
+}
diff --git a/packages/orm/test/filter.test.js b/packages/orm/test/filter.test.js
new file mode 100644
index 0000000..7b176ab
--- /dev/null
+++ b/packages/orm/test/filter.test.js
@@ -0,0 +1,37 @@
+// The filter shape: the `isInCondition` membership predicate (shared by every adapter) and the
+// in-process matchesFilter that uses it.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { isInCondition, matchesFilter } from '../src/index.js'
+
+test('isInCondition: true only for the { in: [...] } membership form', () => {
+ assert.equal(isInCondition({ in: [1, 2] }), true)
+ assert.equal(isInCondition({ in: [] }), true)
+})
+
+test('isInCondition: false for equality values and lookalikes', () => {
+ assert.equal(isInCondition('x'), false)
+ assert.equal(isInCondition(5), false)
+ assert.equal(isInCondition(null), false)
+ assert.equal(isInCondition(undefined), false)
+ assert.equal(isInCondition({ in: 'not-an-array' }), false)
+ assert.equal(isInCondition({ eq: [1] }), false)
+ assert.equal(isInCondition([1, 2]), false) // an array is not an { in } condition
+})
+
+test('matchesFilter: equality matches the exact value', () => {
+ assert.equal(matchesFilter({ id: 'a', n: 1 }, { id: 'a' }), true)
+ assert.equal(matchesFilter({ id: 'a' }, { id: 'b' }), false)
+})
+
+test('matchesFilter: membership matches when the value is in the list', () => {
+ assert.equal(matchesFilter({ id: 'b' }, { id: { in: ['a', 'b'] } }), true)
+ assert.equal(matchesFilter({ id: 'c' }, { id: { in: ['a', 'b'] } }), false)
+ assert.equal(matchesFilter({ id: 'a' }, { id: { in: [] } }), false)
+})
+
+test('matchesFilter: an empty filter matches every row, multiple keys are AND', () => {
+ assert.equal(matchesFilter({ a: 1, b: 2 }, {}), true)
+ assert.equal(matchesFilter({ a: 1, b: 2 }, { a: 1, b: { in: [2, 3] } }), true)
+ assert.equal(matchesFilter({ a: 1, b: 9 }, { a: 1, b: { in: [2, 3] } }), false)
+})
diff --git a/packages/orm/test/memory-adapter.js b/packages/orm/test/memory-adapter.js
new file mode 100644
index 0000000..fe36fc3
--- /dev/null
+++ b/packages/orm/test/memory-adapter.js
@@ -0,0 +1,60 @@
+// A minimal in-process adapter used to PIN the adapter contract in this package's
+// tests. It is intentionally tiny and lives under test/ — the shippable memory
+// adapter and `@gemstack/orm-drizzle` are #46. Any real adapter must honour the
+// same six operations and the filter / list semantics from ../src/filter.js and
+// ../src/list.js.
+
+import { matchesFilter, applyListOpts } from '../src/index.js'
+
+export function createMemoryAdapter() {
+ const store = new Map() // table -> row[]
+ const rows = (table) => {
+ if (!store.has(table)) store.set(table, [])
+ return store.get(table)
+ }
+
+ return {
+ async insert(table, row) {
+ rows(table).push({ ...row })
+ return { ...row }
+ },
+ async find(table, filter, opts) {
+ const matched = rows(table)
+ .filter((r) => matchesFilter(r, filter))
+ .map((r) => ({ ...r }))
+ return applyListOpts(matched, opts)
+ },
+ async count(table, filter) {
+ return rows(table).filter((r) => matchesFilter(r, filter)).length
+ },
+ async upsert(table, row, { onConflict } = {}) {
+ const all = rows(table)
+ if (onConflict && onConflict.length) {
+ const existing = all.find((r) => onConflict.every((c) => r[c] === row[c]))
+ if (existing) {
+ Object.assign(existing, row)
+ return { ...existing }
+ }
+ }
+ all.push({ ...row })
+ return { ...row }
+ },
+ async update(table, filter, patch) {
+ const updated = []
+ for (const r of rows(table)) {
+ if (matchesFilter(r, filter)) {
+ Object.assign(r, patch)
+ updated.push({ ...r })
+ }
+ }
+ return updated
+ },
+ async delete(table, filter) {
+ const all = rows(table)
+ const keep = all.filter((r) => !matchesFilter(r, filter))
+ const removed = all.length - keep.length
+ store.set(table, keep)
+ return removed
+ },
+ }
+}
diff --git a/packages/orm/test/registry.test.js b/packages/orm/test/registry.test.js
new file mode 100644
index 0000000..61714a8
--- /dev/null
+++ b/packages/orm/test/registry.test.js
@@ -0,0 +1,47 @@
+// The adapter registry (#66): the app sets one runtime adapter, every extension
+// reads the same one. Each test file runs in its own process, so the globalThis
+// registry starts clean; clearAdapter() resets between cases within this file.
+
+import { test, afterEach } from 'node:test'
+import assert from 'node:assert/strict'
+import { setAdapter, getAdapter, clearAdapter, ADAPTER_OPS } from '../src/index.js'
+
+const noop = async () => {}
+const fakeAdapter = () => Object.fromEntries(ADAPTER_OPS.map((op) => [op, noop]))
+
+afterEach(() => clearAdapter())
+
+test('getAdapter is null until the app sets one', () => {
+ assert.equal(getAdapter(), null)
+})
+
+test('setAdapter then getAdapter returns the same instance', () => {
+ const a = fakeAdapter()
+ setAdapter(a)
+ assert.equal(getAdapter(), a)
+})
+
+test('setAdapter replaces a previously registered adapter', () => {
+ const a = fakeAdapter()
+ const b = fakeAdapter()
+ setAdapter(a)
+ setAdapter(b)
+ assert.equal(getAdapter(), b)
+})
+
+test('clearAdapter resets the registry', () => {
+ setAdapter(fakeAdapter())
+ clearAdapter()
+ assert.equal(getAdapter(), null)
+})
+
+test('a non-object is rejected with a clear error', () => {
+ assert.throws(() => setAdapter(null), /expected an adapter object/)
+ assert.throws(() => setAdapter('drizzle'), /expected an adapter object/)
+})
+
+test('an adapter missing an operation is rejected, naming the op', () => {
+ const incomplete = fakeAdapter()
+ delete incomplete.upsert
+ assert.throws(() => setAdapter(incomplete), /missing the "upsert" operation/)
+})
diff --git a/packages/orm/test/repository.test.js b/packages/orm/test/repository.test.js
new file mode 100644
index 0000000..ffdb3a0
--- /dev/null
+++ b/packages/orm/test/repository.test.js
@@ -0,0 +1,154 @@
+// The narrow repository contract (#45), exercised over a REAL composed schema
+// (built with universal-schema's defineSchema + mergeSchemas) on the test memory
+// adapter. This pins the surface every adapter (#46) must satisfy.
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema, mergeSchemas } from '@gemstack/schema'
+import { createRepository } from '../src/index.js'
+import { createMemoryAdapter } from './memory-adapter.js'
+
+// A small two-table schema, merged exactly as a binding would hand it over.
+function makeDb() {
+ const { tables } = mergeSchemas([
+ defineSchema('users', (t) => {
+ t.uuid('id').primary()
+ t.string('email').unique()
+ t.string('name').nullable()
+ t.boolean('active')
+ }),
+ defineSchema('posts', (t) => {
+ t.uuid('id').primary()
+ t.uuid('author_id').references('users.id')
+ t.string('title')
+ }),
+ ])
+ return createRepository({ tables }, createMemoryAdapter())
+}
+
+test('createRepository validates schema and adapter', () => {
+ const adapter = createMemoryAdapter()
+ assert.throws(() => createRepository({ tables: [] }, adapter), /no tables/)
+ assert.throws(() => createRepository(undefined, adapter), /no tables/)
+
+ const schema = { tables: [{ table: 'users', columns: [{ name: 'id' }] }] }
+ assert.throws(() => createRepository(schema, { insert() {} }), /missing the "find" operation/)
+})
+
+test('insert then find round-trips a row', async () => {
+ const db = makeDb()
+ const u = await db.users.insert({ id: 'u1', email: 'a@b.com', name: 'A', active: true })
+ assert.deepEqual(u, { id: 'u1', email: 'a@b.com', name: 'A', active: true })
+ assert.deepEqual(await db.users.find(), [u])
+ assert.deepEqual(await db.users.find({ email: 'a@b.com' }), [u])
+ assert.deepEqual(await db.users.find({ email: 'nobody@b.com' }), [])
+})
+
+test('find supports the `in` filter form', async () => {
+ const db = makeDb()
+ await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+ await db.users.insert({ id: 'u2', email: 'b@b.com', active: true })
+ await db.users.insert({ id: 'u3', email: 'c@b.com', active: false })
+ const found = await db.users.find({ id: { in: ['u1', 'u3'] } })
+ assert.deepEqual(found.map((r) => r.id).sort(), ['u1', 'u3'])
+})
+
+test('findOne returns the first match or null', async () => {
+ const db = makeDb()
+ assert.equal(await db.users.findOne({ id: 'missing' }), null)
+ await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+ assert.equal((await db.users.findOne({ id: 'u1' })).email, 'a@b.com')
+})
+
+test('upsert inserts, then updates the conflicting row in place', async () => {
+ const db = makeDb()
+ await db.users.upsert({ id: 'u1', email: 'a@b.com', name: 'A', active: true }, { onConflict: 'email' })
+ await db.users.upsert({ id: 'u1', email: 'a@b.com', name: 'A2', active: true }, { onConflict: 'email' })
+ const all = await db.users.find()
+ assert.equal(all.length, 1)
+ assert.equal(all[0].name, 'A2')
+})
+
+test('update patches matching rows and returns them', async () => {
+ const db = makeDb()
+ await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+ await db.users.insert({ id: 'u2', email: 'b@b.com', active: true })
+ const updated = await db.users.update({ id: 'u1' }, { active: false })
+ assert.equal(updated.length, 1)
+ assert.equal(updated[0].active, false)
+ assert.equal((await db.users.findOne({ id: 'u2' })).active, true)
+})
+
+test('delete removes matching rows and returns the count', async () => {
+ const db = makeDb()
+ await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+ await db.users.insert({ id: 'u2', email: 'b@b.com', active: false })
+ assert.equal(await db.users.delete({ active: false }), 1)
+ assert.deepEqual((await db.users.find()).map((r) => r.id), ['u1'])
+})
+
+test('unknown table throws a clear error', () => {
+ const db = makeDb()
+ assert.throws(() => db.comments, /no table "comments" in the composed schema/)
+})
+
+test('unknown column in a row/filter/patch throws (fail-fast, synchronous)', () => {
+ const db = makeDb()
+ assert.throws(() => db.users.insert({ id: 'u1', emial: 'typo' }), /unknown column "emial"/)
+ assert.throws(() => db.users.find({ nope: 1 }), /unknown column "nope"/)
+ assert.throws(() => db.users.update({ id: 'u1' }, { bogus: 1 }), /update patch: unknown column "bogus"/)
+})
+
+test('the db handle is a plain, non-thenable object', () => {
+ const db = makeDb()
+ // Probing thenable/inspection props must not throw "no such table".
+ assert.equal(db.then, undefined)
+ assert.equal(db.toJSON, undefined)
+})
+
+test('find threads limit/offset/orderBy to the adapter', async () => {
+ const db = makeDb()
+ for (const id of ['u3', 'u1', 'u2']) await db.users.insert({ id, email: `${id}@b.com`, active: true })
+ const page = await db.users.find({}, { orderBy: { column: 'id', dir: 'asc' }, limit: 1, offset: 1 })
+ assert.deepEqual(page.map((r) => r.id), ['u2'])
+})
+
+test('find validates limit/offset at the repository (so SQL adapters are guarded too)', async () => {
+ // Regression: the negative/non-integer guard lived only in the in-process applyListOpts,
+ // so a SQL adapter would get a raw bad value (e.g. a bad `?page=`). Coercing in find()
+ // makes every adapter reject it the same way.
+ const db = makeDb()
+ assert.throws(() => db.users.find({}, { limit: -1 }), /limit: expected a non-negative integer/)
+ assert.throws(() => db.users.find({}, { offset: 1.5 }), /offset: expected a non-negative integer/)
+ assert.deepEqual(await db.users.find({}, { limit: 0 }), []) // 0 is valid -> no rows
+})
+
+test('findOne orders then takes the first (orderBy honoured)', async () => {
+ const db = makeDb()
+ for (const id of ['u3', 'u1', 'u2']) await db.users.insert({ id, email: `${id}@b.com`, active: true })
+ assert.equal((await db.users.findOne({}, { orderBy: { column: 'id', dir: 'desc' } })).id, 'u3')
+})
+
+test('orderBy on an unknown column throws (fail-fast)', () => {
+ const db = makeDb()
+ assert.throws(() => db.users.find({}, { orderBy: 'nope' }), /find orderBy: unknown column "nope"/)
+})
+
+test('orderBy with an invalid direction throws', () => {
+ const db = makeDb()
+ assert.throws(() => db.users.find({}, { orderBy: { column: 'id', dir: 'sideways' } }), /invalid direction "sideways"/)
+})
+
+test('count returns the number of matching rows', async () => {
+ const db = makeDb()
+ await db.users.insert({ id: 'u1', email: 'a@b.com', active: true })
+ await db.users.insert({ id: 'u2', email: 'b@b.com', active: false })
+ assert.equal(await db.users.count(), 2)
+ assert.equal(await db.users.count({ active: true }), 1)
+})
+
+test('an adapter missing count fails createRepository', () => {
+ const schema = { tables: [{ table: 'users', columns: [{ name: 'id' }] }] }
+ const partial = { insert() {}, find() {}, upsert() {}, update() {}, delete() {} }
+ assert.throws(() => createRepository(schema, partial), /missing the "count" operation/)
+})
diff --git a/packages/schema/LICENSE b/packages/schema/LICENSE
new file mode 100644
index 0000000..a2785e1
--- /dev/null
+++ b/packages/schema/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Suleiman Shahbari
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/schema/README.md b/packages/schema/README.md
new file mode 100644
index 0000000..7a44cdd
--- /dev/null
+++ b/packages/schema/README.md
@@ -0,0 +1,105 @@
+# @gemstack/schema
+
+> **This repo is a read-only mirror.** The source of truth is the `vike-data`
+> monorepo (`packages/universal-schema`); this standalone copy is regenerated from it
+> via `git subtree split`. Do not edit it here. (It exists so the core can stand
+> alone and to claim its place ahead of a future standalone publish.)
+
+> **Status: early experiment / spike.** The API is still throwaway and the per-ORM
+> compilers emit representative output (they don't run against real databases yet).
+
+The **framework-agnostic core** of the vike-data data layer. Zero Vike imports, zero
+ORM imports - usable standalone by any framework or ORM.
+
+You describe your tables **once** as plain, declarative data; this package merges
+contributions from independent sources, derives the migration order, and compiles
+the result to Prisma, Drizzle, or a Rudder-engine artifact.
+
+```bash
+pnpm add @gemstack/schema
+```
+
+## What's in the box
+
+- **A neutral schema DSL** - `defineSchema` / `extendSchema`. No ORM imported; the
+ result is plain data (an IR).
+- **Merge + derive** - `mergeSchemas` folds creates and 3rd-party column adds into
+ final tables (and flags column-edit conflicts); `deriveMigrations` produces the
+ ordered migration names from the contributions.
+- **Relations / foreign keys** - declare an FK with `.references('table.column', { onDelete })`;
+ `deriveRelations` computes both ends (forward + inverse) so the ORMs that model
+ navigation (Prisma relation fields, Drizzle `relations()`) get them. Self-references,
+ per-FK relation-field naming (`as` / `inverseAs`), composite primary keys
+ (`t.primaryKey(a, b)`), and many-to-many via `defineJoinTable(a, b)` are all supported.
+- **Per-ORM compilers** - `toPrisma`, `toDrizzle`, `toRudder` (and the `COMPILERS`
+ map) turn one merged table into that ORM's schema.
+- **File generation** - `generateArtifacts({ tables, fragments }, orm)` returns
+ `[{ path, contents }]` ready to write to disk, each with a "generated, don't
+ edit" header. Pure: it performs no filesystem access itself.
+
+## Usage
+
+```js
+import {
+ defineSchema,
+ extendSchema,
+ defineJoinTable,
+ mergeSchemas,
+ deriveMigrations,
+ generateArtifacts,
+} from '@gemstack/schema'
+
+// 1. Declare tables once (this is the source of truth).
+const auth = defineSchema('users', (t) => {
+ t.uuid('id').primary()
+ t.string('email').unique()
+ t.timestamps()
+})
+
+const roles = defineSchema('roles', (t) => {
+ t.uuid('id').primary()
+ t.string('name').unique()
+})
+
+// 2. A different source can ADD columns to a table it didn't create.
+const billing = extendSchema('users', (t) => {
+ t.string('stripe_customer_id').nullable()
+})
+
+// 3. Many-to-many is the join table that links two tables (two FKs + composite PK).
+const userRoles = defineJoinTable('users', 'roles')
+// -> `roles_users` { user_id -> users.id, role_id -> roles.id, primaryKey(both) }
+
+// 4. Merge + derive.
+const fragments = [auth, roles, billing, userRoles]
+const { tables, conflicts } = mergeSchemas(fragments)
+const migrations = deriveMigrations(fragments)
+
+// 5. Generate committable artifacts for the ORM of your choice.
+const files = generateArtifacts({ tables, fragments }, 'prisma')
+// -> [{ path: 'prisma/schema.generated.prisma', contents: '// GENERATED ...' }]
+```
+
+## Design
+
+- **Declarative (desired-state) is the right shape.** Prisma and Drizzle diff state,
+ and the Rudder engine generates an ordered migration from it, so authoring desired
+ state and deriving everything downstream fits all three.
+- **Migrations are an output, not authored by hand.** Schema is the single source of
+ truth; the ORM schema becomes generated output (the usual model, inverted).
+- **Vike-free on purpose.** The Vike binding lives in a separate package
+ (`@vike-data/vike-schema`); this core is the part meant to be reusable anywhere.
+
+## Scope / deferred (the interesting hard parts)
+
+- Types: `uuid` / `string` / `text` / `integer` / `boolean` / `timestamp` plus
+ `nullable` / `unique` / `primary` / `default` / `timestamps()`.
+- **Relations / foreign keys** - single-column FKs, `onDelete`, cross-extension
+ reference validation, self-references, relation-field naming, composite primary
+ keys, and many-to-many sugar all ship (see above). Still deferred: composite
+ (multi-column) foreign keys, and one-to-one inference beyond a `unique` FK.
+- **Type escape hatches** - DB-specific types (pg arrays, enums, JSON) want a
+ per-adapter override so the neutral layer isn't lowest-common-denominator.
+- **Declarative -> ordered migration reconciliation** - real diffing/ordering.
+- **Column-edit policy** - conflicts are detected; resolution is unspecified.
+- Compilers emit representative artifacts; they don't run against real DBs yet.
diff --git a/packages/schema/package.json b/packages/schema/package.json
new file mode 100644
index 0000000..7b340ed
--- /dev/null
+++ b/packages/schema/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@gemstack/schema",
+ "version": "0.1.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "description": "Framework-agnostic schema core: the neutral schema IR + DSL (defineSchema/extendSchema), the merge/derive logic, and the per-ORM compilers. Zero Vike imports; usable standalone by any framework or ORM.",
+ "exports": {
+ ".": "./src/index.js"
+ },
+ "scripts": {
+ "test": "node --test"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/gemstack-land/gemstack",
+ "directory": "packages/schema"
+ },
+ "bugs": {
+ "url": "https://github.com/gemstack-land/gemstack/issues"
+ },
+ "homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/schema#readme",
+ "author": "Suleiman Shahbari",
+ "keywords": [
+ "schema",
+ "data-modeling",
+ "migrations",
+ "orm",
+ "framework-agnostic",
+ "gemstack"
+ ]
+}
diff --git a/packages/schema/src/compilers.js b/packages/schema/src/compilers.js
new file mode 100644
index 0000000..47e053e
--- /dev/null
+++ b/packages/schema/src/compilers.js
@@ -0,0 +1,140 @@
+// Per-ORM compilers. Each takes the same neutral IR (from defineSchema) and
+// emits that ORM's schema artifact. Representative output for a spike, not a
+// production-grade generator: enough to prove "define once, target any ORM".
+
+const pascal = (s) => s.replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase())
+const camel = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
+
+// Referential actions, mapped to each ORM's spelling.
+const PRISMA_ON_DELETE = { cascade: 'Cascade', 'set null': 'SetNull', restrict: 'Restrict', 'no action': 'NoAction', 'set default': 'SetDefault' }
+
+// ---------------------------------------------------------------- Prisma -----
+function prismaType(c) {
+ const base = { uuid: 'String', string: 'String', text: 'String', integer: 'Int', boolean: 'Boolean', timestamp: 'DateTime' }[c.type] || 'String'
+ return base + (c.nullable ? '?' : '')
+}
+function prismaAttrs(c) {
+ const a = []
+ if (c.primary) a.push('@id')
+ if (c.unique) a.push('@unique')
+ if (c.type === 'uuid' && c.primary && c.default === undefined) a.push('@default(uuid())')
+ if (c.default === 'now') a.push('@default(now())')
+ else if (typeof c.default === 'boolean') a.push(`@default(${c.default})`)
+ else if (c.default !== undefined) a.push(`@default(${JSON.stringify(c.default)})`)
+ if (c.type === 'text') a.push('@db.Text')
+ return a.join(' ')
+}
+// Prisma models the FK as a scalar column (kept below) PLUS a relation field, and
+// needs an inverse field on the referenced model. `rel` is that table's slice of
+// deriveRelations(); explicit @relation names make multiple/circular relations
+// between the same two models unambiguous.
+export function toPrisma(ir, rel = { forward: [], inverse: [] }) {
+ const rows = ir.columns.map((c) => ` ${c.name} ${prismaType(c)} ${prismaAttrs(c)}`.trimEnd())
+ const relRows = []
+ for (const r of rel.forward) {
+ const od = r.onDelete ? `, onDelete: ${PRISMA_ON_DELETE[r.onDelete] || 'NoAction'}` : ''
+ // a composite FK carries column ARRAYS; a single-column FK its scalar fields
+ const fields = (r.fkColumns ?? [r.fkColumn]).join(', ')
+ const refs = (r.refColumns ?? [r.refColumn]).join(', ')
+ relRows.push(` ${r.fieldName} ${pascal(r.target)}${r.nullable ? '?' : ''} @relation("${r.name}", fields: [${fields}], references: [${refs}]${od})`)
+ }
+ for (const r of rel.inverse) {
+ relRows.push(` ${r.inverseFieldName ?? r.name} ${pascal(r.owner)}${r.toOne ? '?' : '[]'} @relation("${r.name}")`)
+ }
+ const body = relRows.length ? `${rows.join('\n')}\n\n${relRows.join('\n')}` : rows.join('\n')
+ // Composite PK is a block-level @@id (single-column PKs use a field-level @id above).
+ const blockAttrs = ir.primaryKey ? `\n\n @@id([${ir.primaryKey.join(', ')}])` : ''
+ return `model ${pascal(ir.table)} {\n${body}${blockAttrs}\n\n @@map("${ir.table}")\n}`
+}
+
+// --------------------------------------------------------------- Drizzle -----
+const DRIZZLE_FN = { uuid: 'uuid', string: 'varchar', text: 'text', integer: 'integer', boolean: 'boolean', timestamp: 'timestamp' }
+function drizzleCol(c) {
+ let s
+ switch (c.type) {
+ case 'string': s = `varchar('${c.name}', { length: 255 })`; break
+ // mode: 'string' — universal-orm speaks ISO strings (its isoNow()), the same the
+ // memory adapter stores, so the Drizzle column must accept/return strings too.
+ // Without it Drizzle defaults to mode: 'date' and calls value.toISOString() on
+ // write, throwing for the string values universal-orm passes.
+ case 'timestamp': s = `timestamp('${c.name}', { mode: 'string' })`; break
+ default: s = `${DRIZZLE_FN[c.type] || 'text'}('${c.name}')`
+ }
+ if (c.primary) s += '.primaryKey()'
+ if (c.type === 'uuid' && c.primary && c.default === undefined) s += '.defaultRandom()'
+ if (!c.nullable) s += '.notNull()'
+ if (c.unique) s += '.unique()'
+ if (c.default === 'now') s += '.defaultNow()'
+ else if (typeof c.default === 'boolean') s += `.default(${c.default})`
+ else if (c.default !== undefined) s += `.default(${JSON.stringify(c.default)})`
+ // Column-level FK: Drizzle references the target table's exported column via a
+ // lazy thunk (so declaration order / circular refs don't matter).
+ if (c.references) {
+ const od = c.onDelete ? `, { onDelete: '${c.onDelete}' }` : ''
+ s += `.references(() => ${camel(c.references.table)}.${camel(c.references.column)}${od})`
+ }
+ return ` ${camel(c.name)}: ${s},`
+}
+export function toDrizzle(ir) {
+ const fns = [...new Set(ir.columns.map((c) => DRIZZLE_FN[c.type] || 'text'))]
+ // Composite PK / composite FK are Drizzle's table-extra config (a third pgTable
+ // arg) and need the `primaryKey` / `foreignKey` helpers imported alongside the
+ // column fns.
+ if (ir.primaryKey) fns.push('primaryKey')
+ if (ir.foreignKeys?.length) fns.push('foreignKey')
+ const body = ir.columns.map(drizzleCol).join('\n')
+ const extras = []
+ if (ir.primaryKey) extras.push(` pk: primaryKey({ columns: [${ir.primaryKey.map((n) => `table.${camel(n)}`).join(', ')}] }),`)
+ // A composite FK references the target table's exported columns directly (not a
+ // lazy thunk like the column-level single FK), so the target table must be
+ // defined earlier in the module — the generator emits tables in dependency order.
+ ;(ir.foreignKeys || []).forEach((fk, i) => {
+ const cols = fk.columns.map((n) => `table.${camel(n)}`).join(', ')
+ const fcols = fk.references.columns.map((n) => `${camel(fk.references.table)}.${camel(n)}`).join(', ')
+ const od = fk.onDelete ? `.onDelete('${fk.onDelete}')` : ''
+ extras.push(` fk${i || ''}: foreignKey({ columns: [${cols}], foreignColumns: [${fcols}] })${od},`)
+ })
+ const extra = extras.length ? `, (table) => ({\n${extras.join('\n')}\n})` : ''
+ return `import { pgTable, ${fns.join(', ')} } from 'drizzle-orm/pg-core'\n\nexport const ${camel(ir.table)} = pgTable('${ir.table}', {\n${body}\n}${extra})`
+}
+
+// ----------------------------------------------------------------- Rudder ----
+// Targets the Rudder database engine (@rudderjs/database); WE own its migrations.
+// Render one column as a Rudder schema-builder chain. `includePrimary` emits the
+// inline `.primary()` for a `create` (default); an `alter` adds columns to an
+// existing table and never re-declares the primary key, so generate.js passes
+// `false`. Everything else (unique / nullable / default / FK + onDelete) renders
+// identically, so both the create and alter paths share this one renderer.
+export function rudderCol(c, { includePrimary = true } = {}) {
+ let s = `t.${c.type}('${c.name}')`
+ if (includePrimary && c.primary) s += '.primary()'
+ if (c.unique) s += '.unique()'
+ if (c.nullable) s += '.nullable()'
+ if (c.default === 'now') s += '.useCurrent()'
+ else if (c.default !== undefined) s += `.default(${JSON.stringify(c.default)})`
+ // Column-level FK constraint (Laravel/Rudder style): WE own these migrations,
+ // so the constraint is emitted inline on the column.
+ if (c.references) {
+ s += `.references('${c.references.column}').on('${c.references.table}')`
+ if (c.onDelete) s += `.onDelete('${c.onDelete}')`
+ }
+ return ` ${s}`
+}
+export function toRudder(ir) {
+ const cols = ir.columns.map(rudderCol)
+ // Composite PK as a table-level constraint (single-column PKs use `.primary()` inline).
+ if (ir.primaryKey) cols.push(` t.primary([${ir.primaryKey.map((n) => `'${n}'`).join(', ')}])`)
+ // Composite FK as a table-level constraint: t.foreign([...]).references([...]).on(table)
+ // (single-column FKs are inline on the column above).
+ for (const fk of ir.foreignKeys || []) {
+ const c = fk.columns.map((n) => `'${n}'`).join(', ')
+ const r = fk.references.columns.map((n) => `'${n}'`).join(', ')
+ let s = ` t.foreign([${c}]).references([${r}]).on('${fk.references.table}')`
+ if (fk.onDelete) s += `.onDelete('${fk.onDelete}')`
+ cols.push(s)
+ }
+ const body = cols.join('\n')
+ return `import { Migration, Schema } from '@rudderjs/database'\n\nexport default class extends Migration {\n async up() {\n await Schema.create('${ir.table}', (t) => {\n${body}\n })\n }\n}`
+}
+
+export const COMPILERS = { prisma: toPrisma, drizzle: toDrizzle, rudder: toRudder }
diff --git a/packages/schema/src/define.js b/packages/schema/src/define.js
new file mode 100644
index 0000000..e118f5e
--- /dev/null
+++ b/packages/schema/src/define.js
@@ -0,0 +1,201 @@
+// Neutral, DECLARATIVE schema IR + a tiny builder DSL.
+//
+// An extension describes its tables ONCE; the result is plain data (no ORM
+// imported) that flows through Vike's cumulative config as a contribution.
+//
+// defineSchema('users', t => ...) -> create a new table
+// extendSchema('users', t => ...) -> add columns to an existing table
+// (possibly one ANOTHER extension created)
+//
+// Migrations are NOT authored here; they are derived from this schema (see
+// merge.js). Schema is the source of truth.
+
+function buildColumns(build) {
+ const columns = []
+ const meta = {} // table-level: { primaryKey?: string[], foreignKeys?: ForeignKey[] }
+ const col = (name, type) => {
+ const c = { name, type, nullable: false, unique: false, primary: false, default: undefined }
+ columns.push(c)
+ const api = {
+ primary() { c.primary = true; return api },
+ unique() { c.unique = true; return api },
+ nullable() { c.nullable = true; return api },
+ default(v) { c.default = v; return api },
+ // Semantic UI hint: what the column MEANS (email, file, enum, longtext, json,
+ // date, ...), separate from its storage `type` and orthogonal to it. `.as()`
+ // is a fact about the data, NOT a rendering instruction: an email column is
+ // still a string column, so this does NOT change DDL/migrations (the compilers
+ // only read storage `type`/flags and ignore `semantic`). It rides on the column
+ // as plain data so every consumer derives its OWN rendering from one shared
+ // declaration — vike-admin's field-widget registry today, a future read-only or
+ // email renderer tomorrow. The vocabulary is OPEN (any non-empty string): a
+ // consumer that doesn't recognize a semantic type falls back to the storage
+ // `type`, and an extension can introduce a new one alongside the widget that
+ // renders it (e.g. vike-storage adds `file`). `opts` carries per-semantic data,
+ // e.g. enum's allowed `{ values }`.
+ as(semantic, opts = {}) {
+ if (typeof semantic !== 'string' || semantic === '') {
+ throw new Error('.as(semantic) expects a non-empty string semantic type')
+ }
+ c.semantic = semantic
+ if (Object.keys(opts).length) c.semanticOptions = { ...opts }
+ return api
+ },
+ // Foreign key. `target` is 'table' (defaults to its `id` column) or
+ // 'table.column'. The reference is plain data: merge.js validates the
+ // target exists (even when another extension owns the table), and each ORM
+ // compiler renders it natively (Prisma relations / Drizzle .references /
+ // a Rudder FK constraint). `onDelete` is the referential action.
+ //
+ // Relation-field naming (Prisma navigation fields): by default the forward
+ // field strips a trailing `_id` (`user_id` -> `user`, else `_ref`) and
+ // the inverse field reuses the unique relation name. `as` overrides the
+ // forward field name, `inverseAs` the inverse one — useful for readability
+ // and ESSENTIAL for self-references (e.g. `invited_by` -> `inviter` /
+ // `invitees`), where the auto names are awkward.
+ references(target, opts = {}) {
+ const [table, column = 'id'] = String(target).split('.')
+ c.references = { table, column }
+ if (opts.onDelete) c.onDelete = opts.onDelete
+ if (opts.as) c.relationField = opts.as
+ if (opts.inverseAs) c.inverseField = opts.inverseAs
+ return api
+ },
+ onDelete(action) { c.onDelete = action; return api },
+ }
+ return api
+ }
+ const t = {
+ uuid: (n) => col(n, 'uuid'),
+ string: (n) => col(n, 'string'),
+ text: (n) => col(n, 'text'),
+ integer: (n) => col(n, 'integer'),
+ boolean: (n) => col(n, 'boolean'),
+ timestamp: (n) => col(n, 'timestamp'),
+ // sugar: created_at + updated_at, both defaulting to now. `updatedAt: false`
+ // omits `updated_at` for an APPEND-ONLY / immutable row (an event log, a charge
+ // record) where a mutable-row timestamp would be a lie — the row is recorded
+ // once and never updated.
+ timestamps(opts = {}) {
+ col('created_at', 'timestamp').default('now')
+ if (opts.updatedAt !== false) col('updated_at', 'timestamp').default('now')
+ },
+ // TABLE-LEVEL composite primary key over >=2 columns (e.g. a join table keyed
+ // on both its FKs). Single-column PKs stay column-level (`t.uuid('id').primary()`);
+ // this is the multi-column case Prisma/Drizzle/Rudder each spell differently
+ // (@@id / primaryKey() / t.primary([...])). The named columns must exist.
+ primaryKey(...names) {
+ meta.primaryKey = names
+ },
+ // TABLE-LEVEL composite (multi-column) FOREIGN KEY. Single-column FKs stay
+ // column-level (`t.uuid('user_id').references('users.id')`); a FK over >=2
+ // columns is table-level because it references a multi-column key AS A UNIT.
+ // The local + target column lists must be the same length, and the local
+ // columns must exist (target existence is a cross-extension check in merge.js,
+ // exactly like single-column `.references()`). Each ORM spells it differently
+ // (Prisma @relation fields:[a,b] / Drizzle foreignKey({columns,foreignColumns})
+ // / Rudder t.foreign([...]).references([...]).on(...)). `as`/`inverseAs` name the
+ // Prisma navigation fields (recommended here — the `_id`-strip heuristic that
+ // single-column FKs use doesn't apply to a multi-column key).
+ //
+ // t.foreignKey(['org_id', 'tenant_id'], 'organizations', ['id', 'tenant_id'],
+ // { onDelete: 'cascade', as: 'organization', inverseAs: 'memberships' })
+ foreignKey(columns, table, references, opts = {}) {
+ const cols = Array.isArray(columns) ? columns : [columns]
+ const refs = Array.isArray(references) ? references : [references]
+ const fk = { columns: cols, references: { table, columns: refs } }
+ if (opts.onDelete) fk.onDelete = opts.onDelete
+ if (opts.as) fk.relationField = opts.as
+ if (opts.inverseAs) fk.inverseField = opts.inverseAs
+ ;(meta.foreignKeys ||= []).push(fk)
+ },
+ }
+ build(t)
+ if (meta.primaryKey) {
+ for (const name of meta.primaryKey) {
+ if (!columns.some((c) => c.name === name)) {
+ throw new Error(`primaryKey references unknown column "${name}"`)
+ }
+ }
+ }
+ for (const fk of meta.foreignKeys || []) {
+ for (const name of fk.columns) {
+ if (!columns.some((c) => c.name === name)) {
+ throw new Error(`foreignKey references unknown column "${name}"`)
+ }
+ }
+ if (fk.columns.length !== fk.references.columns.length) {
+ throw new Error(
+ `foreignKey column count mismatch: [${fk.columns.join(', ')}] -> ${fk.references.table}.[${fk.references.columns.join(', ')}]`,
+ )
+ }
+ }
+ return { columns, meta }
+}
+
+export function defineSchema(table, build) {
+ const { columns, meta } = buildColumns(build)
+ return {
+ table,
+ mode: 'create',
+ columns,
+ ...(meta.primaryKey ? { primaryKey: meta.primaryKey } : {}),
+ ...(meta.foreignKeys ? { foreignKeys: meta.foreignKeys } : {}),
+ }
+}
+
+export function extendSchema(table, build) {
+ const { columns } = buildColumns(build)
+ return { table, mode: 'extend', columns }
+}
+
+// Many-to-many sugar: derive the join table that links two existing tables.
+// m2m has no first-class column model — it IS a join table with two FKs and a
+// composite PK over them. This helper emits exactly that as a normal `create`
+// fragment, so it flows through merge/relations/codegen like any other table
+// (deriveRelations sees two non-unique FKs -> two one-to-many legs = the m2m).
+//
+// defineJoinTable('users', 'roles')
+// -> table `roles_users` { user_id -> users.id, role_id -> roles.id, PK(both) }
+//
+// Options: `table` overrides the derived name; `columns` overrides a derived FK
+// column name ({ users: 'member_id' }); `type` sets the FK column type (default
+// 'uuid', matching the repo's `t.uuid('id').primary()` convention); `onDelete`
+// sets the referential action on both FKs (default 'cascade' — drop the link row
+// when either side is deleted, the usual join-table semantics).
+export function defineJoinTable(tableA, tableB, opts = {}) {
+ const name = opts.table || [tableA, tableB].slice().sort().join('_')
+ const type = opts.type || 'uuid'
+ const onDelete = opts.onDelete || 'cascade'
+ const fk = (t) => opts.columns?.[t] || `${singularize(t)}_id`
+ const [colA, colB] = [fk(tableA), fk(tableB)]
+ // The two FK columns must be distinct, else we'd emit a table with a duplicated column
+ // and a `[col, col]` primary key — an invalid artifact, silently. Throw with an accurate
+ // remedy for each cause.
+ if (colA === colB) {
+ // A self-referential m2m (`defineJoinTable('users','users')` — friendships/followers)
+ // can't be fixed by `columns` (keyed by table name -> same key both sides), so point at
+ // defineSchema. Two DIFFERENT tables that singularize identically CAN use `columns`.
+ const remedy =
+ tableA === tableB
+ ? `self-referential many-to-many isn't supported here — define the join table directly with ` +
+ `defineSchema('${name}', t => { ... }) and name its two foreign-key columns yourself.`
+ : `pass distinct names via { columns: { '${tableA}': 'a_id', '${tableB}': 'b_id' } }.`
+ throw new Error(`defineJoinTable('${tableA}', '${tableB}'): both foreign keys resolve to the column "${colA}". ${remedy}`)
+ }
+ return defineSchema(name, (t) => {
+ t[type](colA).references(`${tableA}.id`, { onDelete })
+ t[type](colB).references(`${tableB}.id`, { onDelete })
+ t.primaryKey(colA, colB)
+ })
+}
+
+// Minimal English singularization for FK naming (users -> user, companies ->
+// company). Good enough for the convention here; pass `columns` to override when
+// a table name pluralizes irregularly.
+function singularize(word) {
+ if (word.endsWith('ies')) return `${word.slice(0, -3)}y`
+ if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
+ if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
+ return word
+}
diff --git a/packages/schema/src/generate.js b/packages/schema/src/generate.js
new file mode 100644
index 0000000..b893be7
--- /dev/null
+++ b/packages/schema/src/generate.js
@@ -0,0 +1,106 @@
+// File generation: turn the merged schema into committable, on-disk artifacts.
+//
+// Schema is authored once (defineSchema/extendSchema); the artifacts below are
+// DERIVED. They are meant to be committed to the user's repo with a "generated,
+// don't edit" header (the Prisma/Cloudflare precedent), so diffs are visible and
+// CI is reproducible, while making clear the file is output, not source.
+//
+// Division of labour matches each ORM's model:
+// - Prisma / Drizzle are DECLARATIVE: we emit ONE schema file (desired state);
+// their own tooling (prisma migrate / drizzle-kit) derives the migrations.
+// - The Rudder engine is the exception: WE own migrations, so we emit one
+// ordered migration file per contributed fragment (create / alter).
+//
+// Pure: returns [{ path, contents }] pairs. No filesystem access here — the
+// binding/CLI decides where to write them.
+
+import { toPrisma, toDrizzle, toRudder, rudderCol } from './compilers.js'
+import { deriveRelations } from './merge.js'
+
+const DONT_EDIT = [
+ 'GENERATED by @gemstack/schema — do not edit by hand.',
+ 'Your schema is declared with defineSchema()/extendSchema() across your',
+ 'extensions; this file is derived from it. Re-run generation to update it.',
+]
+const header = (prefix) => DONT_EDIT.map((l) => `${prefix} ${l}`).join('\n') + '\n'
+
+const camel = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
+const DRIZZLE_FN = { uuid: 'uuid', string: 'varchar', text: 'text', integer: 'integer', boolean: 'boolean', timestamp: 'timestamp' }
+const pad = (x) => String(x).padStart(3, '0')
+
+// ---------------------------------------------------------------- Prisma -----
+// One declarative schema.prisma: a datasource/generator preamble + every model.
+function prismaFile(tables) {
+ const preamble =
+ 'datasource db {\n provider = "postgresql"\n url = env("DATABASE_URL")\n}\n\n' +
+ 'generator client {\n provider = "prisma-client-js"\n}'
+ const rels = deriveRelations(tables)
+ const models = tables.map((t) => toPrisma(t, rels.get(t.table))).join('\n\n')
+ return `${header('//')}\n${preamble}\n\n${models}\n`
+}
+
+// --------------------------------------------------------------- Drizzle -----
+// One declarative schema.ts. toDrizzle emits a per-table import line; assembling
+// a file means hoisting a SINGLE import (the union of column fns) and keeping
+// only the table exports.
+function drizzleFile(tables) {
+ const fns = [...new Set(tables.flatMap((t) => t.columns.map((c) => DRIZZLE_FN[c.type] || 'text')))]
+ if (tables.some((t) => t.primaryKey)) fns.push('primaryKey') // composite-PK helper
+ if (tables.some((t) => t.foreignKeys?.length)) fns.push('foreignKey') // composite-FK helper
+ const importLine = `import { pgTable, ${fns.join(', ')} } from 'drizzle-orm/pg-core'`
+ const bodies = tables.map((t) => {
+ const out = toDrizzle(t)
+ return out.slice(out.indexOf('export const')) // drop toDrizzle's own import line
+ })
+ return `${header('//')}\n${importLine}\n\n${bodies.join('\n\n')}\n`
+}
+
+// ----------------------------------------------------------------- Rudder ----
+// We own migrations here, so emit one ordered file per fragment. Creates fold in
+// only their own columns; a later `extend` becomes its own alter migration, so
+// the ordered ledger mirrors how the columns were actually contributed.
+function rudderAlter(frag) {
+ // Reuse the column renderer from the create compiler (rudderCol), but never
+ // re-declare the primary key on an add-column alter.
+ const body = frag.columns.map((c) => rudderCol(c, { includePrimary: false })).join('\n')
+ return `import { Migration, Schema } from '@rudderjs/database'\n\nexport default class extends Migration {\n async up() {\n await Schema.table('${frag.table}', (t) => {\n${body}\n })\n }\n}`
+}
+
+function rudderFiles(fragments) {
+ const seenCreate = new Set()
+ const files = []
+ for (const f of fragments) {
+ if (f.mode === 'create') {
+ if (seenCreate.has(f.table)) continue // dedupe a shared extension's repeated create
+ seenCreate.add(f.table)
+ const name = `${pad(files.length + 1)}_create_${f.table}_table`
+ files.push({ path: `database/migrations/${name}.generated.ts`, contents: `${header('//')}\n${toRudder(f)}\n` })
+ } else {
+ const cols = f.columns.map((c) => c.name).join('_')
+ const name = `${pad(files.length + 1)}_alter_${f.table}_add_${cols}`
+ files.push({ path: `database/migrations/${name}.generated.ts`, contents: `${header('//')}\n${rudderAlter(f)}\n` })
+ }
+ }
+ return files
+}
+
+// Output path per ORM. Every artifact carries the "generated, don't edit" header
+// AND a `.generated.` filename suffix, matching the Vike-wide convention (cf. Vike's
+// own `vike.generated.d.ts`, vikejs/vike#698) that brillout asked for: the header is
+// the portable signal, the suffix makes generated files obvious at a glance.
+//
+// Note: `prisma/schema.generated.prisma` isn't Prisma's default path, so point Prisma
+// at it via package.json `"prisma": { "schema": "prisma/schema.generated.prisma" }`
+// (or `--schema`). Drizzle's schema path is configured in `drizzle.config.ts` anyway.
+export function generateArtifacts({ tables, fragments }, orm) {
+ switch (orm) {
+ case 'prisma':
+ return [{ path: 'prisma/schema.generated.prisma', contents: prismaFile(tables) }]
+ case 'drizzle':
+ return [{ path: 'drizzle/schema.generated.ts', contents: drizzleFile(tables) }]
+ case 'rudder':
+ return rudderFiles(fragments)
+ default:
+ throw new Error(`generateArtifacts: unknown ORM "${orm}"`)
+ }
+}
diff --git a/packages/schema/src/index.js b/packages/schema/src/index.js
new file mode 100644
index 0000000..1bea0d1
--- /dev/null
+++ b/packages/schema/src/index.js
@@ -0,0 +1,4 @@
+export { defineSchema, extendSchema, defineJoinTable } from './define.js'
+export { mergeSchemas, deriveMigrations, deriveRelations, resolveSchemas, dedupeFragments, orderFragments } from './merge.js'
+export { toPrisma, toDrizzle, toRudder, COMPILERS } from './compilers.js'
+export { generateArtifacts } from './generate.js'
diff --git a/packages/schema/src/merge.js b/packages/schema/src/merge.js
new file mode 100644
index 0000000..6117f12
--- /dev/null
+++ b/packages/schema/src/merge.js
@@ -0,0 +1,277 @@
+// Normalize the cumulative `schemas` contributions into a flat fragment list.
+// Each source contributes EITHER a static array of fragments OR a function of the
+// resolved config (a COMPUTED contribution: the schema an extension declares can
+// depend on a config the app set, e.g. billing keyed per-user vs per-org). Vike
+// delivers a computed contribution as a live function (defined via a pointer-import
+// / +file, since inline functions can't be serialized into a runtime config);
+// here we just call it with the resolved config.
+export function resolveSchemas(contributions, config) {
+ const fragments = (contributions || []).flatMap((entry) =>
+ typeof entry === 'function' ? entry(config) || [] : entry || [],
+ )
+ return dedupeFragments(fragments)
+}
+
+// Drop structurally-IDENTICAL fragments. When several extensions each
+// self-install a shared extension, a Vike without idempotent installation
+// (pre-#3355) includes that extension's cumulative `schemas` once per occurrence,
+// so the exact same fragment arrives multiple times (vike-schema's `_migrations`,
+// vike-auth's `users`, etc.). Deduping by structural identity here collapses those
+// to one — the generalized form of the `_migrations` dedupe — so every consumer
+// (runtime render AND the build-time generator) sees a clean list. A genuine
+// conflict (the same table defined DIFFERENTLY) has a different key, survives, and
+// is still flagged by mergeSchemas. Defense-in-depth + back-compat; on a Vike with
+// #3355 there are no duplicates and this is a no-op.
+export function dedupeFragments(fragments) {
+ const seen = new Set()
+ const out = []
+ for (const f of fragments) {
+ const key = `${f.mode}:${f.table}:${JSON.stringify(f.columns)}:${JSON.stringify(f.primaryKey ?? null)}:${JSON.stringify(f.foreignKeys ?? null)}`
+ if (seen.has(key)) continue
+ seen.add(key)
+ out.push(f)
+ }
+ return out
+}
+
+// Order fragments so a table is created AFTER the tables its foreign keys point
+// at. Vike's cumulative config hands contributions back in config-specificity
+// order, which is NOT dependency-aware — so e.g. billing's `subscriptions`
+// (FK -> organizations) can arrive before teams' `organizations`. That is
+// harmless for the declarative ORMs (Prisma/Drizzle desired-state), but a broken
+// order for Rudder migrations (a create referencing a not-yet-created table).
+//
+// This is a stable topological sort: creates come out in dependency order
+// (FK target before dependant), alters come after their own table's create, and
+// ties keep original order. Self-references and FKs whose target isn't a create
+// in this set are ignored (a users<->organizations cycle stays acyclic here
+// because the back-reference, current_organization_id, is contributed as an
+// alter, not part of either create). Any genuine residual cycle falls back to
+// original order rather than dropping fragments.
+export function orderFragments(fragments) {
+ const creates = fragments.filter((f) => f.mode === 'create')
+ const createTables = new Set(creates.map((f) => f.table))
+ const deps = (f) =>
+ new Set([
+ ...f.columns
+ .filter((c) => c.references && c.references.table !== f.table && createTables.has(c.references.table))
+ .map((c) => c.references.table),
+ // table-level composite FKs contribute dependencies too
+ ...(f.foreignKeys || [])
+ .filter((fk) => fk.references.table !== f.table && createTables.has(fk.references.table))
+ .map((fk) => fk.references.table),
+ ])
+
+ const emitted = new Set()
+ const out = []
+ const emittedTable = (t) => out.some((f) => f.mode === 'create' && f.table === t)
+
+ let progress = true
+ while (out.length < fragments.length && progress) {
+ progress = false
+ for (const f of fragments) {
+ if (emitted.has(f)) continue
+ // A create waits for the tables its FKs point at; an alter additionally
+ // waits for its own table's create (it can only add a column once the
+ // table exists).
+ const depsReady = [...deps(f)].every((t) => emittedTable(t))
+ const ready = f.mode === 'create' ? depsReady : depsReady && emittedTable(f.table)
+ if (ready) {
+ out.push(f)
+ emitted.add(f)
+ progress = true
+ }
+ }
+ }
+ // Residual cycle (shouldn't happen here): append the rest in original order.
+ for (const f of fragments) if (!emitted.has(f)) out.push(f)
+ return out
+}
+
+// Merge every contributed schema fragment into final tables, then derive the
+// migrations from the result. This is what a binding (e.g. vike-schema) does with
+// the contributions it collects through its cumulative config point.
+
+export function mergeSchemas(fragments) {
+ const tables = new Map()
+ const conflicts = []
+
+ // creates first: each establishes a table + its base columns
+ for (const f of fragments.filter((f) => f.mode === 'create')) {
+ if (tables.has(f.table)) {
+ conflicts.push({ kind: 'duplicate-table', table: f.table })
+ continue
+ }
+ // Clone the table-level `primaryKey` / `foreignKeys` arrays too, not just the columns:
+ // a fragment can arrive more than once across renders (see dedupeFragments), so the merged
+ // table must OWN its arrays — sharing the input instances would let any later mutation of
+ // `table.primaryKey` / `table.foreignKeys` corrupt the source fragment and every other
+ // merge that shares it (#143).
+ tables.set(f.table, {
+ table: f.table,
+ columns: f.columns.map((c) => ({ ...c })),
+ ...(f.primaryKey ? { primaryKey: [...f.primaryKey] } : {}),
+ ...(f.foreignKeys
+ ? {
+ foreignKeys: f.foreignKeys.map((fk) => ({
+ ...fk,
+ columns: [...fk.columns],
+ references: { ...fk.references, columns: [...fk.references.columns] },
+ })),
+ }
+ : {}),
+ })
+ }
+
+ // extends next: a 3rd-party extension ADDS columns to an existing table
+ for (const f of fragments.filter((f) => f.mode === 'extend')) {
+ const t = tables.get(f.table)
+ if (!t) {
+ conflicts.push({ kind: 'extend-missing-table', table: f.table })
+ continue
+ }
+ for (const c of f.columns) {
+ const existing = t.columns.find((x) => x.name === c.name)
+ if (existing) {
+ // EDIT of someone else's column: detected, NOT silently applied.
+ conflicts.push({ kind: 'column-edit', table: f.table, column: c.name })
+ continue
+ }
+ t.columns.push({ ...c, added: true }) // `added` = contributed by an extend
+ }
+ }
+
+ // references last: every foreign key must point at a table + column that
+ // actually exist in the MERGED schema. This is the cross-extension referential
+ // integrity check — e.g. vike-teams referencing vike-auth's `users.id` only
+ // validates once auth is installed; a dangling ref is a conflict, not a crash.
+ for (const t of tables.values()) {
+ for (const c of t.columns) {
+ if (!c.references) continue
+ const target = tables.get(c.references.table)
+ if (!target) {
+ conflicts.push({ kind: 'unknown-reference-table', table: t.table, column: c.name, target: c.references.table })
+ } else if (!target.columns.some((x) => x.name === c.references.column)) {
+ conflicts.push({ kind: 'unknown-reference-column', table: t.table, column: c.name, target: `${c.references.table}.${c.references.column}` })
+ }
+ }
+ // table-level composite FKs: same referential check, once per target column
+ for (const fk of t.foreignKeys || []) {
+ const cols = fk.columns.join(', ')
+ const target = tables.get(fk.references.table)
+ if (!target) {
+ conflicts.push({ kind: 'unknown-reference-table', table: t.table, column: cols, target: fk.references.table })
+ continue
+ }
+ for (const rc of fk.references.columns) {
+ if (!target.columns.some((x) => x.name === rc)) {
+ conflicts.push({ kind: 'unknown-reference-column', table: t.table, column: cols, target: `${fk.references.table}.${rc}` })
+ }
+ }
+ }
+ }
+
+ return { tables: [...tables.values()], conflicts }
+}
+
+// Derive the relation graph from the merged tables. A foreign key is a single
+// declaration on the owning column, but ORMs that model navigation (Prisma's
+// relation fields, Drizzle's relations()) need BOTH ends. So for each table we
+// compute the relations it OWNS (forward, one entry per FK column) and the ones
+// pointing AT it (inverse, contributed by other tables' FKs).
+//
+// Naming is mechanical and collision-free: the relation NAME is `_`
+// (globally unique), so Prisma can disambiguate multiple/circular relations
+// between the same two models without hand-authored @relation names. The forward
+// FIELD name strips a trailing `_id` (`user_id` -> `user`); the inverse field
+// defaults to the unique relation name. Both field names are OVERRIDABLE per FK
+// (`references(target, { as, inverseAs })`) — readability in general, and a
+// necessity for self-references, where forward + inverse land on the same model
+// and the auto names (`invited_by_ref` / `users_invited_by`) read poorly.
+// FKs are treated one-to-many unless the FK column is unique OR is itself the
+// table's (single-column) primary key — a shared-primary-key one-to-one. A column
+// that is only a MEMBER of a composite `t.primaryKey(...)` keeps `primary: false`,
+// so many-to-many join-table FKs stay one-to-many.
+// Table-level composite FKs (`t.foreignKey([...], target, [...])`) produce one
+// relation per declaration, carrying COLUMN ARRAYS (`fkColumns`/`refColumns`); the
+// single-column path keeps its scalar `fkColumn`/`refColumn`. Composite FKs are
+// one-to-many (there is no composite-unique concept yet) and their forward field
+// defaults to the target table name (`as`/`inverseAs` recommended).
+export function deriveRelations(tables) {
+ const byTable = new Map(tables.map((t) => [t.table, { forward: [], inverse: [] }]))
+ for (const t of tables) {
+ for (const c of t.columns) {
+ if (!c.references) continue
+ const name = `${t.table}_${c.name}`
+ const fieldName = c.relationField || (c.name.endsWith('_id') ? c.name.slice(0, -3) : `${c.name}_ref`)
+ const inverseFieldName = c.inverseField || name
+ const rel = {
+ name,
+ owner: t.table,
+ fkColumn: c.name,
+ target: c.references.table,
+ refColumn: c.references.column,
+ nullable: c.nullable,
+ toOne: !!c.unique || !!c.primary, // unique FK, or an FK that is also the PK => one-to-one
+ onDelete: c.onDelete,
+ fieldName, // forward field name on the owner
+ inverseFieldName, // field name on the referenced model (the back-reference)
+ }
+ byTable.get(t.table)?.forward.push(rel)
+ byTable.get(c.references.table)?.inverse.push(rel)
+ }
+ for (const fk of t.foreignKeys || []) {
+ const name = `${t.table}_${fk.columns.join('_')}`
+ const fieldName = fk.relationField || fk.references.table
+ const inverseFieldName = fk.inverseField || name
+ // Prisma needs the relation optional when ANY local scalar is nullable.
+ const nullable = fk.columns.some((cn) => t.columns.find((c) => c.name === cn)?.nullable)
+ const rel = {
+ name,
+ owner: t.table,
+ fkColumns: fk.columns,
+ fkColumn: fk.columns[0], // scalar accessor for back-compat consumers
+ target: fk.references.table,
+ refColumns: fk.references.columns,
+ refColumn: fk.references.columns[0],
+ nullable,
+ toOne: false, // no composite-unique inference yet
+ onDelete: fk.onDelete,
+ fieldName,
+ inverseFieldName,
+ }
+ byTable.get(t.table)?.forward.push(rel)
+ byTable.get(fk.references.table)?.inverse.push(rel)
+ }
+ }
+ return byTable
+}
+
+// Migrations are DERIVED from the schema, in contribution order. (Ordering is
+// contribution/specificity order, not dependency-aware: that reconciliation is a
+// known deferred hard part.)
+//
+// Duplicate `create`s are skipped: when several extensions each self-install a
+// shared extension (e.g. both auth and billing extend vike-schema), older Vike
+// included that extension's cumulative contributions once per occurrence, so its
+// tables arrived more than once. We dedupe by table name here.
+//
+// Vike fixed this upstream (extension installation is now idempotent, vikejs/vike
+// #3355, merged 2026-06-20). This dedupe stays as defense-in-depth and back-compat
+// for users on a Vike version without the fix.
+export function deriveMigrations(fragments) {
+ const pad = (x) => String(x).padStart(3, '0')
+ const seenCreate = new Set()
+ const out = []
+ for (const f of fragments) {
+ if (f.mode === 'create') {
+ if (seenCreate.has(f.table)) continue
+ seenCreate.add(f.table)
+ out.push(`${pad(out.length + 1)}_create_${f.table}_table`)
+ } else {
+ const cols = f.columns.map((c) => c.name).join('_')
+ out.push(`${pad(out.length + 1)}_alter_${f.table}_add_${cols}`)
+ }
+ }
+ return out
+}
diff --git a/packages/schema/test/compilers.test.js b/packages/schema/test/compilers.test.js
new file mode 100644
index 0000000..63b27d4
--- /dev/null
+++ b/packages/schema/test/compilers.test.js
@@ -0,0 +1,120 @@
+// Per-ORM compilers: the same neutral table IR -> each ORM's schema artifact.
+// These pin the representative output (column types, modifiers, FK rendering).
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema } from '../src/define.js'
+import { mergeSchemas, deriveRelations } from '../src/merge.js'
+import { toPrisma, toDrizzle, toRudder, COMPILERS } from '../src/compilers.js'
+
+const ir = (build) => mergeSchemas([defineSchema('users', build)]).tables[0]
+
+// ------------------------------------------------------------------ Prisma ----
+
+test('toPrisma maps types and the @id / @default(uuid()) primary key', () => {
+ const out = toPrisma(ir((t) => t.uuid('id').primary()))
+ assert.match(out, /id String @id @default\(uuid\(\)\)/)
+ assert.match(out, /model Users \{/)
+ assert.match(out, /@@map\("users"\)/)
+})
+
+test('.as() is a UI hint only — it never changes compiled output', () => {
+ const plain = ir((t) => {
+ t.string('email')
+ t.string('status')
+ t.text('bio')
+ })
+ const tagged = ir((t) => {
+ t.string('email').as('email')
+ t.string('status').as('enum', { values: ['a', 'b'] })
+ t.text('bio').as('longtext')
+ })
+ assert.equal(toPrisma(tagged), toPrisma(plain))
+ assert.equal(toDrizzle(tagged), toDrizzle(plain))
+ assert.equal(toRudder(tagged), toRudder(plain))
+})
+
+test('toPrisma renders nullable, unique, now-default and text', () => {
+ const out = toPrisma(
+ ir((t) => {
+ t.string('email').unique()
+ t.text('bio').nullable()
+ t.timestamp('created_at').default('now')
+ }),
+ )
+ assert.match(out, /email String @unique/)
+ assert.match(out, /bio String\? @db\.Text/)
+ assert.match(out, /created_at DateTime @default\(now\(\)\)/)
+})
+
+test('toPrisma emits a forward relation field + scalar FK column with onDelete', () => {
+ const posts = defineSchema('posts', (t) => t.uuid('author_id').references('users.id', { onDelete: 'cascade' }))
+ const { tables } = mergeSchemas([defineSchema('users', (t) => t.uuid('id').primary()), posts])
+ const rels = deriveRelations(tables)
+ const out = toPrisma(tables.find((t) => t.table === 'posts'), rels.get('posts'))
+ assert.match(out, /author_id String/) // scalar FK kept
+ assert.match(out, /author Users @relation\("posts_author_id", fields: \[author_id\], references: \[id\], onDelete: Cascade\)/)
+})
+
+test('toPrisma emits an inverse relation field on the referenced model', () => {
+ const posts = defineSchema('posts', (t) => t.uuid('author_id').references('users.id'))
+ const { tables } = mergeSchemas([defineSchema('users', (t) => t.uuid('id').primary()), posts])
+ const rels = deriveRelations(tables)
+ const out = toPrisma(tables.find((t) => t.table === 'users'), rels.get('users'))
+ assert.match(out, /posts_author_id Posts\[\] @relation\("posts_author_id"\)/)
+})
+
+// ----------------------------------------------------------------- Drizzle ----
+
+test('toDrizzle builds a pgTable with camelCased columns and modifiers', () => {
+ const out = toDrizzle(
+ ir((t) => {
+ t.uuid('id').primary()
+ t.string('display_name') // columns are non-nullable by default
+ }),
+ )
+ assert.match(out, /export const users = pgTable\('users', \{/)
+ assert.match(out, /id: uuid\('id'\)\.primaryKey\(\)\.defaultRandom\(\)\.notNull\(\)/)
+ assert.match(out, /displayName: varchar\('display_name', \{ length: 255 \}\)/)
+})
+
+test("toDrizzle renders timestamp columns with mode: 'string' (universal-orm speaks ISO strings)", () => {
+ const out = toDrizzle(
+ ir((t) => {
+ t.uuid('id').primary()
+ t.timestamp('created_at').default('now')
+ t.timestamp('expires_at')
+ }),
+ )
+ assert.match(out, /createdAt: timestamp\('created_at', \{ mode: 'string' \}\)\.notNull\(\)\.defaultNow\(\)/)
+ assert.match(out, /expiresAt: timestamp\('expires_at', \{ mode: 'string' \}\)\.notNull\(\)/)
+})
+
+test('toDrizzle renders a column FK as a lazy .references thunk with onDelete', () => {
+ const posts = defineSchema('posts', (t) => t.uuid('author_id').references('users.id', { onDelete: 'cascade' }))
+ const out = toDrizzle(mergeSchemas([defineSchema('users', (t) => t.uuid('id').primary()), posts]).tables.find((t) => t.table === 'posts'))
+ assert.match(out, /authorId: uuid\('author_id'\)\.notNull\(\)\.references\(\(\) => users\.id, \{ onDelete: 'cascade' \}\)/)
+})
+
+// ----------------------------------------------------------------- Rudder ----
+
+test('toRudder emits a Schema.create migration class', () => {
+ const out = toRudder(ir((t) => t.uuid('id').primary()))
+ assert.match(out, /import \{ Migration, Schema \} from '@rudderjs\/database'/)
+ assert.match(out, /await Schema\.create\('users', \(t\) => \{/)
+ assert.match(out, /t\.uuid\('id'\)\.primary\(\)/)
+})
+
+test('toRudder renders an inline FK constraint with onDelete', () => {
+ const posts = defineSchema('posts', (t) => t.uuid('author_id').references('users.id', { onDelete: 'restrict' }))
+ const out = toRudder(mergeSchemas([defineSchema('users', (t) => t.uuid('id').primary()), posts]).tables.find((t) => t.table === 'posts'))
+ assert.match(out, /t\.uuid\('author_id'\)\.references\('id'\)\.on\('users'\)\.onDelete\('restrict'\)/)
+})
+
+// -------------------------------------------------------------- COMPILERS map -
+
+test('COMPILERS exposes all three compilers by ORM key', () => {
+ assert.equal(COMPILERS.prisma, toPrisma)
+ assert.equal(COMPILERS.drizzle, toDrizzle)
+ assert.equal(COMPILERS.rudder, toRudder)
+})
diff --git a/packages/schema/test/composite-fk.test.js b/packages/schema/test/composite-fk.test.js
new file mode 100644
index 0000000..706d8f5
--- /dev/null
+++ b/packages/schema/test/composite-fk.test.js
@@ -0,0 +1,166 @@
+// relations v2 follow-ups (#130): composite (multi-column) foreign keys.
+//
+// A single-column FK is column-level (`t.uuid('user_id').references('users.id')`);
+// a FK over >=2 columns references a multi-column key as a unit, so it is declared
+// table-level (`t.foreignKey([...], target, [...])`) and each ORM spells it
+// differently (Prisma fields:[a,b] / Drizzle foreignKey({columns,foreignColumns}) /
+// Rudder t.foreign([...]).references([...]).on(...)).
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema } from '../src/define.js'
+import { mergeSchemas, deriveRelations, orderFragments } from '../src/merge.js'
+import { toPrisma, toDrizzle, toRudder } from '../src/compilers.js'
+import { generateArtifacts } from '../src/generate.js'
+
+// a composite-keyed parent + a child that references it as a unit
+const orgUnits = () =>
+ defineSchema('org_units', (t) => {
+ t.uuid('org_id')
+ t.uuid('unit_id')
+ t.string('name')
+ t.primaryKey('org_id', 'unit_id')
+ })
+const assignments = (opts = {}) =>
+ defineSchema('assignments', (t) => {
+ t.uuid('id').primary()
+ t.uuid('org_id')
+ t.uuid('unit_id')
+ t.foreignKey(['org_id', 'unit_id'], 'org_units', ['org_id', 'unit_id'], {
+ onDelete: 'cascade',
+ as: 'unit',
+ inverseAs: 'assignments',
+ ...opts,
+ })
+ })
+const composite = () => mergeSchemas([orgUnits(), assignments()])
+
+// ----------------------------------------------------- DSL --------------------
+
+test('t.foreignKey records a table-level composite FK on the fragment', () => {
+ const frag = assignments()
+ assert.deepEqual(frag.foreignKeys, [
+ {
+ columns: ['org_id', 'unit_id'],
+ references: { table: 'org_units', columns: ['org_id', 'unit_id'] },
+ onDelete: 'cascade',
+ relationField: 'unit',
+ inverseField: 'assignments',
+ },
+ ])
+ // the local columns themselves carry no per-column `references`
+ assert.ok(frag.columns.every((c) => !c.references))
+})
+
+test('a table with no composite FK carries no foreignKeys field (back-compat)', () => {
+ const frag = defineSchema('users', (t) => t.uuid('id').primary())
+ assert.equal('foreignKeys' in frag, false)
+})
+
+test('t.foreignKey rejects a local column that was not declared', () => {
+ assert.throws(
+ () => defineSchema('t', (t) => {
+ t.uuid('org_id')
+ t.foreignKey(['org_id', 'nope'], 'org_units', ['org_id', 'unit_id'])
+ }),
+ /unknown column "nope"/,
+ )
+})
+
+test('t.foreignKey rejects a local/target column count mismatch', () => {
+ assert.throws(
+ () => defineSchema('t', (t) => {
+ t.uuid('org_id')
+ t.foreignKey(['org_id'], 'org_units', ['org_id', 'unit_id'])
+ }),
+ /column count mismatch/,
+ )
+})
+
+// ----------------------------------------------- merge + validation -----------
+
+test('merge carries the composite FK onto the table, no conflicts when the target exists', () => {
+ const { tables, conflicts } = composite()
+ assert.deepEqual(conflicts, [])
+ const child = tables.find((t) => t.table === 'assignments')
+ assert.equal(child.foreignKeys.length, 1)
+ assert.deepEqual(child.foreignKeys[0].references.columns, ['org_id', 'unit_id'])
+})
+
+test('a composite FK to an unknown table is flagged', () => {
+ const { conflicts } = mergeSchemas([assignments()]) // no org_units
+ assert.ok(conflicts.some((c) => c.kind === 'unknown-reference-table' && c.target === 'org_units' && c.column === 'org_id, unit_id'))
+})
+
+test('a composite FK to an unknown target column is flagged per column', () => {
+ const child = defineSchema('assignments', (t) => {
+ t.uuid('org_id')
+ t.uuid('unit_id')
+ t.foreignKey(['org_id', 'unit_id'], 'org_units', ['org_id', 'missing'])
+ })
+ const { conflicts } = mergeSchemas([orgUnits(), child])
+ assert.ok(conflicts.some((c) => c.kind === 'unknown-reference-column' && c.target === 'org_units.missing'))
+})
+
+test('orderFragments puts a composite-FK target before its dependant', () => {
+ const ordered = orderFragments([assignments(), orgUnits()]) // wrong order in
+ const tables = ordered.map((f) => f.table)
+ assert.ok(tables.indexOf('org_units') < tables.indexOf('assignments'))
+})
+
+// ----------------------------------------------------- relations --------------
+
+test('deriveRelations emits one composite relation with column arrays + collision-free name', () => {
+ const { tables } = composite()
+ const rels = deriveRelations(tables)
+ const fwd = rels.get('assignments').forward
+ assert.equal(fwd.length, 1)
+ assert.equal(fwd[0].name, 'assignments_org_id_unit_id')
+ assert.deepEqual(fwd[0].fkColumns, ['org_id', 'unit_id'])
+ assert.deepEqual(fwd[0].refColumns, ['org_id', 'unit_id'])
+ assert.equal(fwd[0].toOne, false)
+ assert.equal(fwd[0].fieldName, 'unit') // `as`
+ // the parent sees it on its inverse side
+ assert.equal(rels.get('org_units').inverse.length, 1)
+ assert.equal(rels.get('org_units').inverse[0].inverseFieldName, 'assignments') // `inverseAs`
+})
+
+// ----------------------------------------------------- compilers --------------
+
+test('Prisma renders a composite FK as fields:[..] / references:[..] arrays + inverse', () => {
+ const { tables } = composite()
+ const rels = deriveRelations(tables)
+ const childOut = toPrisma(tables.find((t) => t.table === 'assignments'), rels.get('assignments'))
+ assert.match(childOut, /unit OrgUnits @relation\("assignments_org_id_unit_id", fields: \[org_id, unit_id\], references: \[org_id, unit_id\], onDelete: Cascade\)/)
+ const parentOut = toPrisma(tables.find((t) => t.table === 'org_units'), rels.get('org_units'))
+ assert.match(parentOut, /assignments Assignments\[\] @relation\("assignments_org_id_unit_id"\)/)
+})
+
+test('Drizzle renders a composite FK in the table-extra block + imports foreignKey', () => {
+ const { tables } = composite()
+ const out = toDrizzle(tables.find((t) => t.table === 'assignments'))
+ assert.match(out, /fk: foreignKey\(\{ columns: \[table\.orgId, table\.unitId\], foreignColumns: \[orgUnits\.orgId, orgUnits\.unitId\] \}\)\.onDelete\('cascade'\)/)
+ assert.match(out, /import \{ pgTable, .*foreignKey.* \} from 'drizzle-orm\/pg-core'/)
+})
+
+test('Rudder renders a composite FK as a table-level t.foreign([...]).references([...]).on(...)', () => {
+ const { tables } = composite()
+ const out = toRudder(tables.find((t) => t.table === 'assignments'))
+ assert.match(out, /t\.foreign\(\['org_id', 'unit_id'\]\)\.references\(\['org_id', 'unit_id'\]\)\.on\('org_units'\)\.onDelete\('cascade'\)/)
+})
+
+test('a table with no composite FK is unchanged: no foreignKey import / t.foreign(', () => {
+ const { tables } = mergeSchemas([defineSchema('users', (t) => t.uuid('id').primary())])
+ const users = tables[0]
+ assert.ok(!/\bforeignKey\b/.test(toDrizzle(users)))
+ assert.ok(!toRudder(users).includes('t.foreign('))
+})
+
+// ----------------------------------------------------- generate ---------------
+
+test('the generated Drizzle file hoists the foreignKey import when a composite FK exists', () => {
+ const { tables } = composite()
+ const [{ contents }] = generateArtifacts({ tables }, 'drizzle')
+ assert.match(contents, /import \{ pgTable, .*foreignKey.* \} from 'drizzle-orm\/pg-core'/)
+ assert.equal((contents.match(/^import .*\bforeignKey\b/gm) || []).length, 1)
+})
diff --git a/packages/schema/test/composite-m2m.test.js b/packages/schema/test/composite-m2m.test.js
new file mode 100644
index 0000000..96abbb8
--- /dev/null
+++ b/packages/schema/test/composite-m2m.test.js
@@ -0,0 +1,187 @@
+// relations v2 follow-ups (#17): composite primary keys + many-to-many sugar.
+//
+// Single-column PKs are column-level (`t.uuid('id').primary()`); a composite PK
+// over >=2 columns is table-level (`t.primaryKey(a, b)`) and each ORM spells it
+// differently (@@id / primaryKey() / t.primary([...])). Many-to-many has no
+// column model of its own — `defineJoinTable` derives the join table (two FKs +
+// a composite PK over them) that IS the relation.
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema, defineJoinTable } from '../src/define.js'
+import { mergeSchemas, deriveRelations } from '../src/merge.js'
+import { toPrisma, toDrizzle, toRudder } from '../src/compilers.js'
+import { generateArtifacts } from '../src/generate.js'
+
+// ----------------------------------------------------- composite PK: DSL ------
+
+test('t.primaryKey records a table-level composite key on the fragment', () => {
+ const frag = defineSchema('roles_users', (t) => {
+ t.uuid('user_id')
+ t.uuid('role_id')
+ t.primaryKey('user_id', 'role_id')
+ })
+ assert.deepEqual(frag.primaryKey, ['user_id', 'role_id'])
+ // the columns themselves stay non-primary (the PK is the table constraint)
+ assert.ok(frag.columns.every((c) => !c.primary))
+})
+
+test('a single-PK table carries no primaryKey field (back-compat)', () => {
+ const frag = defineSchema('users', (t) => t.uuid('id').primary())
+ assert.equal('primaryKey' in frag, false)
+})
+
+test('t.primaryKey rejects a column that was not declared', () => {
+ assert.throws(
+ () => defineSchema('t', (t) => {
+ t.uuid('a')
+ t.primaryKey('a', 'missing')
+ }),
+ /unknown column "missing"/,
+ )
+})
+
+// ---------------------------------------------- defineJoinTable: derivation ---
+
+test('defineJoinTable derives name, FK columns, references and the composite PK', () => {
+ const frag = defineJoinTable('users', 'roles')
+ assert.equal(frag.table, 'roles_users') // deterministic: alphabetical
+ assert.deepEqual(frag.primaryKey, ['user_id', 'role_id'])
+ const byName = Object.fromEntries(frag.columns.map((c) => [c.name, c]))
+ assert.deepEqual(byName.user_id.references, { table: 'users', column: 'id' })
+ assert.deepEqual(byName.role_id.references, { table: 'roles', column: 'id' })
+ assert.equal(byName.user_id.onDelete, 'cascade')
+ assert.equal(byName.role_id.onDelete, 'cascade')
+})
+
+test('defineJoinTable singularizes pluralized table names for FK columns', () => {
+ const frag = defineJoinTable('companies', 'addresses')
+ const names = frag.columns.map((c) => c.name).sort()
+ assert.deepEqual(names, ['address_id', 'company_id']) // companies->company, addresses->address
+})
+
+test('defineJoinTable throws when both FKs resolve to the same column', () => {
+ // Regression: a self-referential m2m (friendships/followers) made both FK columns
+ // `user_id`, silently emitting a duplicate column + a [user_id, user_id] PK. Now it
+ // throws — and points at defineSchema, since `columns` (keyed by table name) can't help a
+ // self-join.
+ assert.throws(
+ () => defineJoinTable('users', 'users'),
+ /both foreign keys resolve to the column "user_id".*self-referential many-to-many/s,
+ )
+ // Two DIFFERENT tables that singularize to the same column collide too, but there the
+ // `columns` override (distinct keys) resolves it.
+ assert.throws(() => defineJoinTable('boxes', 'box'), /both foreign keys resolve to the column "box_id"/)
+ const frag = defineJoinTable('boxes', 'box', { columns: { boxes: 'box_a', box: 'box_b' } })
+ assert.deepEqual(frag.columns.map((c) => c.name).sort(), ['box_a', 'box_b'])
+})
+
+test('defineJoinTable options override name, FK columns, type and onDelete', () => {
+ const frag = defineJoinTable('users', 'teams', {
+ table: 'memberships',
+ columns: { users: 'member_id' },
+ type: 'integer',
+ onDelete: 'restrict',
+ })
+ assert.equal(frag.table, 'memberships')
+ const byName = Object.fromEntries(frag.columns.map((c) => [c.name, c]))
+ assert.ok(byName.member_id) // overridden
+ assert.ok(byName.team_id) // derived
+ assert.equal(byName.member_id.type, 'integer')
+ assert.equal(byName.member_id.onDelete, 'restrict')
+ assert.deepEqual(frag.primaryKey, ['member_id', 'team_id'])
+})
+
+// ----------------------------------------------------- merge + relations ------
+
+const m2m = () =>
+ mergeSchemas([
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ defineSchema('roles', (t) => t.uuid('id').primary()),
+ defineJoinTable('users', 'roles'),
+ ])
+
+test('merge carries the composite PK onto the merged join table, no conflicts', () => {
+ const { tables, conflicts } = m2m()
+ assert.deepEqual(conflicts, [])
+ const join = tables.find((t) => t.table === 'roles_users')
+ assert.deepEqual(join.primaryKey, ['user_id', 'role_id'])
+})
+
+test('the join table FKs validate against both referenced tables', () => {
+ // drop `roles` -> the role_id FK should be flagged as a dangling reference
+ const { conflicts } = mergeSchemas([
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ defineJoinTable('users', 'roles'),
+ ])
+ assert.ok(conflicts.some((c) => c.kind === 'unknown-reference-table' && c.target === 'roles'))
+})
+
+test('m2m derives two one-to-many legs (the many-to-many) through the join', () => {
+ const { tables } = m2m()
+ const rels = deriveRelations(tables)
+ const join = rels.get('roles_users')
+ assert.equal(join.forward.length, 2)
+ assert.ok(join.forward.every((r) => r.toOne === false)) // non-unique FKs => many side
+ // each parent sees the join on its inverse side
+ assert.equal(rels.get('users').inverse.length, 1)
+ assert.equal(rels.get('roles').inverse.length, 1)
+})
+
+// ------------------------------------------------------------- compilers ------
+
+test('Prisma renders a composite PK as a block-level @@id, columns keep no @id', () => {
+ const { tables } = m2m()
+ const join = tables.find((t) => t.table === 'roles_users')
+ const out = toPrisma(join, deriveRelations(tables).get('roles_users'))
+ assert.match(out, /@@id\(\[user_id, role_id\]\)/)
+ assert.ok(!/ @id\b/.test(out)) // no field-level @id on a composite-PK model
+ assert.match(out, /@@map\("roles_users"\)/)
+})
+
+test('Drizzle renders a composite PK as the table-extra primaryKey() + imports it', () => {
+ const { tables } = m2m()
+ const join = tables.find((t) => t.table === 'roles_users')
+ const out = toDrizzle(join)
+ assert.match(out, /primaryKey\(\{ columns: \[table\.userId, table\.roleId\] \}\)/)
+ assert.match(out, /import \{ pgTable, .*primaryKey.* \} from 'drizzle-orm\/pg-core'/)
+})
+
+test('Rudder renders a composite PK as a table-level t.primary([...])', () => {
+ const { tables } = m2m()
+ const join = tables.find((t) => t.table === 'roles_users')
+ const out = toRudder(join)
+ assert.match(out, /t\.primary\(\['user_id', 'role_id'\]\)/)
+})
+
+test('single-PK tables are unchanged: no @@id / table-extra / t.primary([...])', () => {
+ const { tables } = mergeSchemas([defineSchema('users', (t) => t.uuid('id').primary())])
+ const users = tables[0]
+ assert.ok(!toPrisma(users).includes('@@id'))
+ // the column-level `.primaryKey()` is expected; the composite table-extra is not
+ assert.ok(!toDrizzle(users).includes('primaryKey({'))
+ assert.ok(!/import \{ pgTable,[^}]*\bprimaryKey\b/.test(toDrizzle(users))) // not imported
+ assert.ok(!toRudder(users).includes('t.primary(['))
+})
+
+// -------------------------------------------------------------- generate ------
+
+test('the generated Drizzle file hoists the primaryKey import when a join exists', () => {
+ const { tables } = m2m()
+ const [{ contents }] = generateArtifacts({ tables }, 'drizzle')
+ assert.match(contents, /import \{ pgTable, .*primaryKey.* \} from 'drizzle-orm\/pg-core'/)
+ assert.match(contents, /export const rolesUsers = pgTable\('roles_users'/)
+ // the helper is in the single hoisted import line exactly once
+ assert.equal((contents.match(/^import .*\bprimaryKey\b/gm) || []).length, 1)
+})
+
+test('the generated Rudder migration for the join carries the composite PK', () => {
+ const fragments = [
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ defineSchema('roles', (t) => t.uuid('id').primary()),
+ defineJoinTable('users', 'roles'),
+ ]
+ const files = generateArtifacts({ fragments }, 'rudder')
+ const join = files.find((f) => f.path.includes('roles_users'))
+ assert.match(join.contents, /t\.primary\(\['user_id', 'role_id'\]\)/)
+})
diff --git a/packages/schema/test/define.test.js b/packages/schema/test/define.test.js
new file mode 100644
index 0000000..fb79071
--- /dev/null
+++ b/packages/schema/test/define.test.js
@@ -0,0 +1,132 @@
+// The declarative DSL: defineSchema/extendSchema build plain-data fragments.
+// These tests pin the shape of a fragment + every column modifier, since the
+// merge/compiler layers all read this structure.
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema, extendSchema } from '../src/define.js'
+
+test('defineSchema produces a create fragment with the table name', () => {
+ const f = defineSchema('users', (t) => t.uuid('id').primary())
+ assert.equal(f.mode, 'create')
+ assert.equal(f.table, 'users')
+ assert.equal(f.columns.length, 1)
+})
+
+test('extendSchema produces an extend fragment', () => {
+ const f = extendSchema('users', (t) => t.string('nickname').nullable())
+ assert.equal(f.mode, 'extend')
+ assert.equal(f.table, 'users')
+})
+
+test('a fresh column has explicit, defaulted flags', () => {
+ const [c] = defineSchema('t', (t) => t.string('name')).columns
+ assert.deepEqual(c, {
+ name: 'name',
+ type: 'string',
+ nullable: false,
+ unique: false,
+ primary: false,
+ default: undefined,
+ })
+})
+
+test('modifiers chain and mutate the column', () => {
+ const [c] = defineSchema('t', (t) => t.uuid('id').primary().unique()).columns
+ assert.equal(c.primary, true)
+ assert.equal(c.unique, true)
+})
+
+test('.nullable() and .default() set their fields', () => {
+ const [c] = defineSchema('t', (t) => t.integer('age').nullable().default(0)).columns
+ assert.equal(c.nullable, true)
+ assert.equal(c.default, 0)
+})
+
+test('every column type maps to its IR type tag', () => {
+ const { columns } = defineSchema('t', (t) => {
+ t.uuid('a')
+ t.string('b')
+ t.text('c')
+ t.integer('d')
+ t.boolean('e')
+ t.timestamp('f')
+ })
+ assert.deepEqual(
+ columns.map((c) => c.type),
+ ['uuid', 'string', 'text', 'integer', 'boolean', 'timestamp'],
+ )
+})
+
+test('timestamps() sugar adds created_at + updated_at defaulting to now', () => {
+ const { columns } = defineSchema('t', (t) => t.timestamps())
+ assert.deepEqual(
+ columns.map((c) => [c.name, c.type, c.default]),
+ [
+ ['created_at', 'timestamp', 'now'],
+ ['updated_at', 'timestamp', 'now'],
+ ],
+ )
+})
+
+test('timestamps({ updatedAt: false }) adds created_at only (append-only row)', () => {
+ const { columns } = defineSchema('event__log', (t) => t.timestamps({ updatedAt: false }))
+ assert.deepEqual(
+ columns.map((c) => [c.name, c.type, c.default]),
+ [['created_at', 'timestamp', 'now']],
+ )
+})
+
+test('references("table") defaults the target column to id', () => {
+ const [c] = defineSchema('posts', (t) => t.uuid('author_id').references('users')).columns
+ assert.deepEqual(c.references, { table: 'users', column: 'id' })
+})
+
+test('references("table.column") parses an explicit target column', () => {
+ const [c] = defineSchema('posts', (t) => t.uuid('author').references('users.uuid')).columns
+ assert.deepEqual(c.references, { table: 'users', column: 'uuid' })
+})
+
+test('references() onDelete option records the referential action', () => {
+ const [c] = defineSchema('posts', (t) =>
+ t.uuid('author_id').references('users.id', { onDelete: 'cascade' }),
+ ).columns
+ assert.equal(c.onDelete, 'cascade')
+})
+
+test('.onDelete() modifier sets the action independently', () => {
+ const [c] = defineSchema('posts', (t) => t.uuid('author_id').references('users').onDelete('set null')).columns
+ assert.equal(c.onDelete, 'set null')
+})
+
+test('.as(semantic) tags the column without changing its storage type', () => {
+ const [c] = defineSchema('t', (t) => t.string('email').as('email')).columns
+ assert.equal(c.type, 'string')
+ assert.equal(c.semantic, 'email')
+})
+
+test('.as() carries per-semantic options (enum values) and chains', () => {
+ const [c] = defineSchema('t', (t) =>
+ t.string('status').as('enum', { values: ['draft', 'published'] }).nullable(),
+ ).columns
+ assert.equal(c.semantic, 'enum')
+ assert.deepEqual(c.semanticOptions, { values: ['draft', 'published'] })
+ assert.equal(c.nullable, true)
+})
+
+test('a column without .as() has no semantic field (fresh shape unchanged)', () => {
+ const [c] = defineSchema('t', (t) => t.string('name')).columns
+ assert.ok(!('semantic' in c))
+ assert.ok(!('semanticOptions' in c))
+})
+
+test('.as() with no options omits semanticOptions entirely', () => {
+ const [c] = defineSchema('t', (t) => t.text('bio').as('longtext')).columns
+ assert.equal(c.semantic, 'longtext')
+ assert.ok(!('semanticOptions' in c))
+})
+
+test('.as() rejects a non-string / empty semantic type', () => {
+ assert.throws(() => defineSchema('t', (t) => t.string('x').as('')), /non-empty string/)
+ assert.throws(() => defineSchema('t', (t) => t.string('x').as(42)), /non-empty string/)
+})
diff --git a/packages/schema/test/generate.test.js b/packages/schema/test/generate.test.js
new file mode 100644
index 0000000..9591c39
--- /dev/null
+++ b/packages/schema/test/generate.test.js
@@ -0,0 +1,71 @@
+// File generation: merged schema -> committable [{ path, contents }] artifacts.
+// Pure (no fs). Declarative ORMs emit ONE file; Rudder emits one per fragment.
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema, extendSchema } from '../src/define.js'
+import { mergeSchemas } from '../src/merge.js'
+import { generateArtifacts } from '../src/generate.js'
+
+const sample = () => {
+ const fragments = [
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ defineSchema('posts', (t) => t.uuid('author_id').references('users.id', { onDelete: 'cascade' })),
+ extendSchema('users', (t) => t.string('nickname')),
+ ]
+ const { tables } = mergeSchemas(fragments)
+ return { tables, fragments }
+}
+
+const GENERATED = /GENERATED by @gemstack\/schema/
+
+test('prisma generates one schema.generated.prisma with header + preamble', () => {
+ const [art, ...rest] = generateArtifacts(sample(), 'prisma')
+ assert.equal(rest.length, 0)
+ assert.equal(art.path, 'prisma/schema.generated.prisma')
+ assert.match(art.contents, GENERATED)
+ assert.match(art.contents, /datasource db \{/)
+ assert.match(art.contents, /model Users \{/)
+ assert.match(art.contents, /model Posts \{/)
+})
+
+test('drizzle generates one schema.generated.ts with a single hoisted import', () => {
+ const [art, ...rest] = generateArtifacts(sample(), 'drizzle')
+ assert.equal(rest.length, 0)
+ assert.equal(art.path, 'drizzle/schema.generated.ts')
+ assert.match(art.contents, GENERATED)
+ // exactly one import line for the whole file
+ assert.equal(art.contents.match(/^import /gm).length, 1)
+ assert.match(art.contents, /export const users = pgTable/)
+ assert.match(art.contents, /export const posts = pgTable/)
+})
+
+test('Rudder generates one ordered migration file per fragment', () => {
+ const files = generateArtifacts(sample(), 'rudder')
+ assert.deepEqual(files.map((f) => f.path), [
+ 'database/migrations/001_create_users_table.generated.ts',
+ 'database/migrations/002_create_posts_table.generated.ts',
+ 'database/migrations/003_alter_users_add_nickname.generated.ts',
+ ])
+ assert.ok(files.every((f) => GENERATED.test(f.contents)))
+})
+
+test('Rudder create uses Schema.create, alter uses Schema.table', () => {
+ const files = generateArtifacts(sample(), 'rudder')
+ assert.match(files[0].contents, /Schema\.create\('users'/)
+ assert.match(files[2].contents, /Schema\.table\('users'/)
+ assert.match(files[2].contents, /t\.string\('nickname'\)/)
+})
+
+test('Rudder dedupes a repeated create of a shared extension table', () => {
+ const dup = [
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ ]
+ const files = generateArtifacts({ tables: mergeSchemas(dup).tables, fragments: dup }, 'rudder')
+ assert.equal(files.length, 1)
+})
+
+test('generateArtifacts throws on an unknown ORM', () => {
+ assert.throws(() => generateArtifacts(sample(), 'mongoose'), /unknown ORM "mongoose"/)
+})
diff --git a/packages/schema/test/merge.test.js b/packages/schema/test/merge.test.js
new file mode 100644
index 0000000..ddffb05
--- /dev/null
+++ b/packages/schema/test/merge.test.js
@@ -0,0 +1,277 @@
+// The merge/derive core: resolve cumulative contributions into a flat fragment
+// list, merge fragments into tables while detecting cross-extension conflicts,
+// topologically order by FK, and derive relations + migration names.
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema, extendSchema } from '../src/define.js'
+import {
+ resolveSchemas,
+ dedupeFragments,
+ orderFragments,
+ mergeSchemas,
+ deriveRelations,
+ deriveMigrations,
+} from '../src/merge.js'
+
+const users = () => defineSchema('users', (t) => t.uuid('id').primary())
+const orgs = () => defineSchema('organizations', (t) => t.uuid('id').primary())
+
+// ---------------------------------------------------------- resolveSchemas ----
+
+test('resolveSchemas flattens static array contributions', () => {
+ const out = resolveSchemas([[users()], [orgs()]], {})
+ assert.deepEqual(out.map((f) => f.table), ['users', 'organizations'])
+})
+
+test('resolveSchemas calls function (computed) contributions with the config', () => {
+ let seen
+ const computed = (config) => {
+ seen = config
+ return [defineSchema(config.keyBy, (t) => t.uuid('id').primary())]
+ }
+ const out = resolveSchemas([computed], { keyBy: 'tenants' })
+ assert.deepEqual(seen, { keyBy: 'tenants' })
+ assert.equal(out[0].table, 'tenants')
+})
+
+test('resolveSchemas tolerates null / empty contributions', () => {
+ assert.deepEqual(resolveSchemas(undefined, {}), [])
+ assert.deepEqual(resolveSchemas([null, () => null, []], {}), [])
+})
+
+test('resolveSchemas dedupes structurally-identical fragments', () => {
+ const out = resolveSchemas([[users()], [users()]], {})
+ assert.equal(out.length, 1)
+})
+
+// --------------------------------------------------------- dedupeFragments ----
+
+test('dedupeFragments collapses identical fragments but keeps genuine variants', () => {
+ const a = users()
+ const b = users()
+ const different = defineSchema('users', (t) => t.uuid('id').primary().unique())
+ const out = dedupeFragments([a, b, different])
+ assert.equal(out.length, 2) // a==b collapse; `different` survives
+})
+
+// ---------------------------------------------------------- orderFragments ----
+
+test('orderFragments puts an FK target before its dependant', () => {
+ // subscriptions -> organizations, contributed in the WRONG order.
+ const subs = defineSchema('subscriptions', (t) => t.uuid('org_id').references('organizations'))
+ const ordered = orderFragments([subs, orgs()])
+ const names = ordered.map((f) => f.table)
+ assert.ok(names.indexOf('organizations') < names.indexOf('subscriptions'))
+})
+
+test('orderFragments places an alter after its own table create', () => {
+ const alter = extendSchema('users', (t) => t.string('nickname'))
+ const ordered = orderFragments([alter, users()])
+ const idx = ordered.map((f) => `${f.mode}:${f.table}`)
+ assert.ok(idx.indexOf('create:users') < idx.indexOf('extend:users'))
+})
+
+test('orderFragments ignores self-references (no false cycle)', () => {
+ const tree = defineSchema('nodes', (t) => {
+ t.uuid('id').primary()
+ t.uuid('parent_id').nullable().references('nodes.id')
+ })
+ const ordered = orderFragments([tree])
+ assert.equal(ordered.length, 1)
+})
+
+test('orderFragments is stable for independent fragments', () => {
+ const a = defineSchema('a', (t) => t.uuid('id').primary())
+ const b = defineSchema('b', (t) => t.uuid('id').primary())
+ assert.deepEqual(orderFragments([a, b]).map((f) => f.table), ['a', 'b'])
+})
+
+test('orderFragments never drops fragments on a residual cycle', () => {
+ // A genuine create<->create cycle: fall back to original order, lose nothing.
+ const a = defineSchema('a', (t) => t.uuid('b_id').references('b'))
+ const b = defineSchema('b', (t) => t.uuid('a_id').references('a'))
+ const ordered = orderFragments([a, b])
+ assert.equal(ordered.length, 2)
+})
+
+// ------------------------------------------------------------ mergeSchemas ----
+
+test('mergeSchemas merges creates + extends into one table', () => {
+ const { tables, conflicts } = mergeSchemas([
+ users(),
+ extendSchema('users', (t) => t.string('nickname')),
+ ])
+ assert.equal(conflicts.length, 0)
+ assert.deepEqual(tables[0].columns.map((c) => c.name), ['id', 'nickname'])
+})
+
+test('an extend marks its added columns', () => {
+ const { tables } = mergeSchemas([users(), extendSchema('users', (t) => t.string('nickname'))])
+ const nickname = tables[0].columns.find((c) => c.name === 'nickname')
+ assert.equal(nickname.added, true)
+})
+
+test('mergeSchemas preserves a column semantic hint through composition', () => {
+ const { tables } = mergeSchemas([
+ defineSchema('docs', (t) => {
+ t.uuid('id').primary()
+ t.string('status').as('enum', { values: ['draft', 'published'] })
+ }),
+ extendSchema('docs', (t) => t.string('cover').as('file')),
+ ])
+ const cols = tables[0].columns
+ assert.equal(cols.find((c) => c.name === 'status').semantic, 'enum')
+ assert.deepEqual(cols.find((c) => c.name === 'status').semanticOptions, { values: ['draft', 'published'] })
+ assert.equal(cols.find((c) => c.name === 'cover').semantic, 'file')
+})
+
+test('mergeSchemas flags a duplicate create of the same table', () => {
+ const { conflicts } = mergeSchemas([users(), users()])
+ assert.deepEqual(conflicts, [{ kind: 'duplicate-table', table: 'users' }])
+})
+
+test('mergeSchemas flags an extend of a table that does not exist', () => {
+ const { conflicts } = mergeSchemas([extendSchema('ghost', (t) => t.string('x'))])
+ assert.deepEqual(conflicts, [{ kind: 'extend-missing-table', table: 'ghost' }])
+})
+
+test('mergeSchemas flags an extend that EDITS an existing column', () => {
+ const { conflicts } = mergeSchemas([users(), extendSchema('users', (t) => t.string('id'))])
+ assert.deepEqual(conflicts, [{ kind: 'column-edit', table: 'users', column: 'id' }])
+})
+
+test('mergeSchemas flags a foreign key to an unknown table', () => {
+ const posts = defineSchema('posts', (t) => t.uuid('author_id').references('users'))
+ const { conflicts } = mergeSchemas([posts])
+ assert.deepEqual(conflicts, [
+ { kind: 'unknown-reference-table', table: 'posts', column: 'author_id', target: 'users' },
+ ])
+})
+
+test('mergeSchemas flags a foreign key to an unknown column', () => {
+ const posts = defineSchema('posts', (t) => t.uuid('author_id').references('users.missing'))
+ const { conflicts } = mergeSchemas([users(), posts])
+ assert.deepEqual(conflicts, [
+ { kind: 'unknown-reference-column', table: 'posts', column: 'author_id', target: 'users.missing' },
+ ])
+})
+
+test('a valid cross-table foreign key produces no conflict', () => {
+ const posts = defineSchema('posts', (t) => t.uuid('author_id').references('users.id'))
+ const { conflicts } = mergeSchemas([users(), posts])
+ assert.equal(conflicts.length, 0)
+})
+
+// --------------------------------------------------------- deriveRelations ----
+
+const relGraph = () => {
+ const posts = defineSchema('posts', (t) => t.uuid('author_id').references('users.id', { onDelete: 'cascade' }))
+ const { tables } = mergeSchemas([users(), posts])
+ return deriveRelations(tables)
+}
+
+test('deriveRelations records a forward relation on the owner', () => {
+ const [fwd] = relGraph().get('posts').forward
+ assert.equal(fwd.name, 'posts_author_id')
+ assert.equal(fwd.target, 'users')
+ assert.equal(fwd.fkColumn, 'author_id')
+ assert.equal(fwd.refColumn, 'id')
+ assert.equal(fwd.onDelete, 'cascade')
+})
+
+test('the forward field name strips a trailing _id', () => {
+ assert.equal(relGraph().get('posts').forward[0].fieldName, 'author')
+})
+
+test('deriveRelations records the inverse relation on the target', () => {
+ const [inv] = relGraph().get('users').inverse
+ assert.equal(inv.name, 'posts_author_id')
+ assert.equal(inv.owner, 'posts')
+})
+
+test('a unique FK is one-to-one (toOne), a plain FK is one-to-many', () => {
+ const oneToOne = defineSchema('profiles', (t) => t.uuid('user_id').unique().references('users.id'))
+ const { tables } = mergeSchemas([users(), oneToOne])
+ assert.equal(deriveRelations(tables).get('profiles').forward[0].toOne, true)
+ assert.equal(relGraph().get('posts').forward[0].toOne, false)
+})
+
+test('a non-_id FK column gets a _ref field name', () => {
+ const t1 = defineSchema('a', (t) => t.uuid('id').primary())
+ const t2 = defineSchema('b', (t) => t.uuid('owner').references('a.id'))
+ const { tables } = mergeSchemas([t1, t2])
+ assert.equal(deriveRelations(tables).get('b').forward[0].fieldName, 'owner_ref')
+})
+
+// -------------------------------------------------------- deriveMigrations ----
+
+test('deriveMigrations numbers and names creates + alters in order', () => {
+ const names = deriveMigrations([
+ users(),
+ extendSchema('users', (t) => t.string('nickname')),
+ ])
+ assert.deepEqual(names, ['001_create_users_table', '002_alter_users_add_nickname'])
+})
+
+test('deriveMigrations dedupes a repeated create by table', () => {
+ const names = deriveMigrations([users(), users(), orgs()])
+ assert.deepEqual(names, ['001_create_users_table', '002_create_organizations_table'])
+})
+
+test('deriveMigrations joins multiple altered columns into one name', () => {
+ const names = deriveMigrations([
+ users(),
+ extendSchema('users', (t) => {
+ t.string('a')
+ t.string('b')
+ }),
+ ])
+ assert.equal(names[1], '002_alter_users_add_a_b')
+})
+
+// A fragment can arrive more than once across renders, so the merged table must OWN its
+// table-level `primaryKey` / `foreignKeys` arrays (not share the input fragment's instances).
+// Otherwise a later mutation of the merged table would corrupt the source fragment and every
+// other merge that shares it (#143).
+test('mergeSchemas does not share the primaryKey / foreignKeys arrays with the input fragment', () => {
+ const assignments = defineSchema('assignments', (t) => {
+ t.uuid('org_id')
+ t.uuid('unit_id')
+ t.primaryKey('org_id', 'unit_id')
+ t.foreignKey(['org_id', 'unit_id'], 'org_units', ['org_id', 'unit_id'])
+ })
+ // org_units exists so the FK validates cleanly (referential check passes -> no conflicts).
+ const orgUnits = defineSchema('org_units', (t) => {
+ t.uuid('org_id')
+ t.uuid('unit_id')
+ t.primaryKey('org_id', 'unit_id')
+ })
+
+ const { tables, conflicts } = mergeSchemas([orgUnits, assignments])
+ assert.deepEqual(conflicts, [])
+ const merged = tables.find((t) => t.table === 'assignments')
+
+ // Different array instances...
+ assert.notEqual(merged.primaryKey, assignments.primaryKey)
+ assert.notEqual(merged.foreignKeys, assignments.foreignKeys)
+ assert.notEqual(merged.foreignKeys[0], assignments.foreignKeys[0])
+ assert.notEqual(merged.foreignKeys[0].columns, assignments.foreignKeys[0].columns)
+ assert.notEqual(merged.foreignKeys[0].references, assignments.foreignKeys[0].references)
+ assert.notEqual(merged.foreignKeys[0].references.columns, assignments.foreignKeys[0].references.columns)
+
+ // ...so mutating the merged table cannot bleed back into the source fragment.
+ merged.primaryKey.push('leaked')
+ merged.foreignKeys.push({ columns: ['x'], references: { table: 'y', columns: ['z'] } })
+ merged.foreignKeys[0].columns.push('leaked')
+ merged.foreignKeys[0].references.columns.push('leaked')
+ assert.deepEqual(assignments.primaryKey, ['org_id', 'unit_id'])
+ assert.equal(assignments.foreignKeys.length, 1)
+ assert.deepEqual(assignments.foreignKeys[0].columns, ['org_id', 'unit_id'])
+ assert.deepEqual(assignments.foreignKeys[0].references.columns, ['org_id', 'unit_id'])
+
+ // ...and two independent merges of the same fragment do not alias each other.
+ const second = mergeSchemas([orgUnits, assignments]).tables.find((t) => t.table === 'assignments')
+ assert.deepEqual(second.primaryKey, ['org_id', 'unit_id'])
+ assert.equal(second.foreignKeys.length, 1)
+})
diff --git a/packages/schema/test/relations.test.js b/packages/schema/test/relations.test.js
new file mode 100644
index 0000000..578b3d5
--- /dev/null
+++ b/packages/schema/test/relations.test.js
@@ -0,0 +1,128 @@
+// relations v2 follow-ups (#17): self-referential FKs and relation-field naming
+// control. Self-relations put both ends on the same model, so the forward +
+// inverse field names must stay distinct and the relation name unique; naming
+// control lets a declaration replace the auto-generated field names.
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { defineSchema, defineJoinTable } from '../src/define.js'
+import { mergeSchemas, deriveRelations } from '../src/merge.js'
+import { toPrisma } from '../src/compilers.js'
+
+const usersWith = (build) => {
+ const { tables } = mergeSchemas([defineSchema('users', (t) => {
+ t.uuid('id').primary()
+ build(t)
+ })])
+ return { table: tables[0], rels: deriveRelations(tables).get('users') }
+}
+
+// ----------------------------------------------------- self-referential FKs ---
+
+test('a self-referential FK derives both a forward and an inverse relation on the same model', () => {
+ const { rels } = usersWith((t) => t.uuid('invited_by').nullable().references('users.id'))
+ assert.equal(rels.forward.length, 1)
+ assert.equal(rels.inverse.length, 1)
+ assert.equal(rels.forward[0].owner, 'users')
+ assert.equal(rels.forward[0].target, 'users')
+})
+
+test('the self-relation renders valid Prisma: distinct field names, one shared relation name', () => {
+ const { table, rels } = usersWith((t) => t.uuid('invited_by').nullable().references('users.id', { onDelete: 'set null' }))
+ const out = toPrisma(table, rels)
+ assert.match(out, /invited_by_ref Users\? @relation\("users_invited_by", fields: \[invited_by\], references: \[id\], onDelete: SetNull\)/)
+ assert.match(out, /users_invited_by Users\[\] @relation\("users_invited_by"\)/)
+ // the two navigation field names must differ (Prisma rejects duplicates)
+ const fields = [...out.matchAll(/^ {2}(\w+) Users/gm)].map((m) => m[1])
+ assert.equal(new Set(fields).size, fields.length)
+})
+
+test('two self-referential FKs on one table stay collision-free', () => {
+ const { table, rels } = usersWith((t) => {
+ t.uuid('invited_by').nullable().references('users.id')
+ t.uuid('manager_id').nullable().references('users.id')
+ })
+ const out = toPrisma(table, rels)
+ const fields = [...out.matchAll(/^ {2}(\w+) Users/gm)].map((m) => m[1])
+ assert.equal(new Set(fields).size, fields.length) // all four distinct
+ const relationNames = [...out.matchAll(/@relation\("([^"]+)"/g)].map((m) => m[1])
+ assert.deepEqual([...new Set(relationNames)].sort(), ['users_invited_by', 'users_manager_id'])
+})
+
+// -------------------------------------------------- relation-field naming -----
+
+test('`as` overrides the forward relation field name', () => {
+ const { rels } = usersWith((t) => t.uuid('invited_by').nullable().references('users.id', { as: 'inviter' }))
+ assert.equal(rels.forward[0].fieldName, 'inviter')
+})
+
+test('`inverseAs` overrides the inverse relation field name', () => {
+ const { rels } = usersWith((t) => t.uuid('invited_by').nullable().references('users.id', { inverseAs: 'invitees' }))
+ assert.equal(rels.forward[0].inverseFieldName, 'invitees')
+})
+
+test('named self-relation reads cleanly in Prisma', () => {
+ const { table, rels } = usersWith((t) =>
+ t.uuid('invited_by').nullable().references('users.id', { as: 'inviter', inverseAs: 'invitees' }),
+ )
+ const out = toPrisma(table, rels)
+ assert.match(out, /inviter Users\? @relation\("users_invited_by"/)
+ assert.match(out, /invitees Users\[\] @relation\("users_invited_by"\)/)
+ assert.ok(!out.includes('invited_by_ref')) // the auto name is gone
+})
+
+test('naming control works across tables (inverse field on the referenced model)', () => {
+ const { tables } = mergeSchemas([
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ defineSchema('posts', (t) => t.uuid('author_id').references('users.id', { as: 'author', inverseAs: 'posts' })),
+ ])
+ const rels = deriveRelations(tables)
+ assert.equal(rels.get('posts').forward[0].fieldName, 'author')
+ const usersOut = toPrisma(tables.find((t) => t.table === 'users'), rels.get('users'))
+ assert.match(usersOut, /posts Posts\[\] @relation\("posts_author_id"\)/)
+})
+
+test('defaults are unchanged when no naming is given (back-compat)', () => {
+ const { rels } = usersWith((t) => t.uuid('manager_id').references('users.id'))
+ assert.equal(rels.forward[0].fieldName, 'manager') // _id stripped
+ assert.equal(rels.forward[0].inverseFieldName, 'users_manager_id') // relation name
+})
+
+// ------------------------------------------------ one-to-one inference (#129) --
+
+test('a unique FK is inferred one-to-one (Prisma inverse is `?`, not `[]`)', () => {
+ const { tables } = mergeSchemas([
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ defineSchema('subscriptions', (t) => t.uuid('user_id').unique().references('users.id')),
+ ])
+ const rels = deriveRelations(tables)
+ assert.equal(rels.get('subscriptions').forward[0].toOne, true)
+ const usersOut = toPrisma(tables.find((t) => t.table === 'users'), rels.get('users'))
+ assert.match(usersOut, /subscriptions_user_id Subscriptions\? @relation\("subscriptions_user_id"\)/)
+})
+
+test('a shared-primary-key FK (FK column is also the PK) is inferred one-to-one', () => {
+ const { tables } = mergeSchemas([
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ // profiles.id is BOTH the primary key and the FK to users.id => one-to-one
+ defineSchema('profiles', (t) => t.uuid('id').primary().references('users.id')),
+ ])
+ const rels = deriveRelations(tables)
+ assert.equal(rels.get('profiles').forward[0].toOne, true)
+ const usersOut = toPrisma(tables.find((t) => t.table === 'users'), rels.get('users'))
+ assert.match(usersOut, /profiles_id Profiles\? @relation\("profiles_id"\)/)
+})
+
+test('an FK inside a composite primary key stays one-to-many (m2m join table)', () => {
+ const { tables } = mergeSchemas([
+ defineSchema('users', (t) => t.uuid('id').primary()),
+ defineSchema('roles', (t) => t.uuid('id').primary()),
+ defineJoinTable('users', 'roles'),
+ ])
+ const rels = deriveRelations(tables)
+ // both legs of the join table are members of a composite PK, NOT single PKs
+ const join = rels.get('roles_users')
+ assert.ok(join.forward.every((r) => r.toOne === false))
+ const usersOut = toPrisma(tables.find((t) => t.table === 'users'), rels.get('users'))
+ assert.match(usersOut, /RolesUsers\[\] @relation/) // many, not one
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a64c6e0..834d655 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -166,6 +166,37 @@ importers:
specifier: ^5.4.0
version: 5.9.3
+ packages/orm:
+ devDependencies:
+ '@gemstack/schema':
+ specifier: workspace:*
+ version: link:../schema
+
+ packages/orm-drizzle:
+ dependencies:
+ '@gemstack/orm':
+ specifier: workspace:*
+ version: link:../orm
+ devDependencies:
+ '@electric-sql/pglite':
+ specifier: ^0.5.3
+ version: 0.5.3
+ drizzle-orm:
+ specifier: ^0.45.2
+ version: 0.45.2(@electric-sql/pglite@0.5.3)
+
+ packages/orm-memory:
+ dependencies:
+ '@gemstack/orm':
+ specifier: workspace:*
+ version: link:../orm
+ devDependencies:
+ '@gemstack/schema':
+ specifier: workspace:*
+ version: link:../schema
+
+ packages/schema: {}
+
packages:
'@anthropic-ai/sdk@0.105.0':
@@ -363,6 +394,9 @@ packages:
'@docsearch/sidepanel-js@4.6.3':
resolution: {integrity: sha512-grGSmvXzG0if+mrzdIKykvpIAuEQ9u0sEJ2eLRRCaQfJvsWqh2C2/aY04bIzWvDh7myi5rvl8D+tUNsVrjYQ3A==}
+ '@electric-sql/pglite@0.5.3':
+ resolution: {integrity: sha512-iTTYbA5Uesrl+N7zss0J5LopT7KE4j9aymYo+EZZh+rZbARQCUQOs+n2pay64JRUpc3fCkpfrniTNJnvYzOE+g==}
+
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -1174,6 +1208,98 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
+ drizzle-orm@0.45.2:
+ resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==}
+ peerDependencies:
+ '@aws-sdk/client-rds-data': '>=3'
+ '@cloudflare/workers-types': '>=4'
+ '@electric-sql/pglite': '>=0.2.0'
+ '@libsql/client': '>=0.10.0'
+ '@libsql/client-wasm': '>=0.10.0'
+ '@neondatabase/serverless': '>=0.10.0'
+ '@op-engineering/op-sqlite': '>=2'
+ '@opentelemetry/api': ^1.4.1
+ '@planetscale/database': '>=1.13'
+ '@prisma/client': '*'
+ '@tidbcloud/serverless': '*'
+ '@types/better-sqlite3': '*'
+ '@types/pg': '*'
+ '@types/sql.js': '*'
+ '@upstash/redis': '>=1.34.7'
+ '@vercel/postgres': '>=0.8.0'
+ '@xata.io/client': '*'
+ better-sqlite3: '>=7'
+ bun-types: '*'
+ expo-sqlite: '>=14.0.0'
+ gel: '>=2'
+ knex: '*'
+ kysely: '*'
+ mysql2: '>=2'
+ pg: '>=8'
+ postgres: '>=3'
+ prisma: '*'
+ sql.js: '>=1'
+ sqlite3: '>=5'
+ peerDependenciesMeta:
+ '@aws-sdk/client-rds-data':
+ optional: true
+ '@cloudflare/workers-types':
+ optional: true
+ '@electric-sql/pglite':
+ optional: true
+ '@libsql/client':
+ optional: true
+ '@libsql/client-wasm':
+ optional: true
+ '@neondatabase/serverless':
+ optional: true
+ '@op-engineering/op-sqlite':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@planetscale/database':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@tidbcloud/serverless':
+ optional: true
+ '@types/better-sqlite3':
+ optional: true
+ '@types/pg':
+ optional: true
+ '@types/sql.js':
+ optional: true
+ '@upstash/redis':
+ optional: true
+ '@vercel/postgres':
+ optional: true
+ '@xata.io/client':
+ optional: true
+ better-sqlite3:
+ optional: true
+ bun-types:
+ optional: true
+ expo-sqlite:
+ optional: true
+ gel:
+ optional: true
+ knex:
+ optional: true
+ kysely:
+ optional: true
+ mysql2:
+ optional: true
+ pg:
+ optional: true
+ postgres:
+ optional: true
+ prisma:
+ optional: true
+ sql.js:
+ optional: true
+ sqlite3:
+ optional: true
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -2508,6 +2634,8 @@ snapshots:
'@docsearch/sidepanel-js@4.6.3': {}
+ '@electric-sql/pglite@0.5.3': {}
+
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -3196,6 +3324,10 @@ snapshots:
dependencies:
path-type: 4.0.0
+ drizzle-orm@0.45.2(@electric-sql/pglite@0.5.3):
+ optionalDependencies:
+ '@electric-sql/pglite': 0.5.3
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2