diff --git a/packages/orm-drizzle/LICENSE b/packages/orm-drizzle/LICENSE
deleted file mode 100644
index a2785e1..0000000
--- a/packages/orm-drizzle/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 3748c64..0000000
--- a/packages/orm-drizzle/README.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# @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
deleted file mode 100644
index a2b1482..0000000
--- a/packages/orm-drizzle/package.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "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
deleted file mode 100644
index abd8533..0000000
--- a/packages/orm-drizzle/src/index.js
+++ /dev/null
@@ -1,156 +0,0 @@
-// 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
deleted file mode 100644
index 15e7004..0000000
--- a/packages/orm-drizzle/test/drizzle.test.js
+++ /dev/null
@@ -1,153 +0,0 @@
-// 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
deleted file mode 100644
index a2785e1..0000000
--- a/packages/orm-memory/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index aa2032d..0000000
--- a/packages/orm-memory/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# @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
deleted file mode 100644
index 9412b6d..0000000
--- a/packages/orm-memory/package.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "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
deleted file mode 100644
index 419cf85..0000000
--- a/packages/orm-memory/src/index.js
+++ /dev/null
@@ -1,72 +0,0 @@
-// 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
deleted file mode 100644
index 6946993..0000000
--- a/packages/orm-memory/test/memory.test.js
+++ /dev/null
@@ -1,117 +0,0 @@
-// 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
deleted file mode 100644
index a2785e1..0000000
--- a/packages/orm/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 5a172e9..0000000
--- a/packages/orm/README.md
+++ /dev/null
@@ -1,120 +0,0 @@
-# @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
deleted file mode 100644
index 2edc862..0000000
--- a/packages/orm/package.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "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
deleted file mode 100644
index 0fd445b..0000000
--- a/packages/orm/src/filter.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// 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
deleted file mode 100644
index 1d605c1..0000000
--- a/packages/orm/src/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-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
deleted file mode 100644
index 6ce2b45..0000000
--- a/packages/orm/src/list.js
+++ /dev/null
@@ -1,68 +0,0 @@
-// 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
deleted file mode 100644
index 05109b4..0000000
--- a/packages/orm/src/registry.js
+++ /dev/null
@@ -1,39 +0,0 @@
-// 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
deleted file mode 100644
index 323dffe..0000000
--- a/packages/orm/src/repository.js
+++ /dev/null
@@ -1,123 +0,0 @@
-// 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
deleted file mode 100644
index 7b176ab..0000000
--- a/packages/orm/test/filter.test.js
+++ /dev/null
@@ -1,37 +0,0 @@
-// 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
deleted file mode 100644
index fe36fc3..0000000
--- a/packages/orm/test/memory-adapter.js
+++ /dev/null
@@ -1,60 +0,0 @@
-// 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
deleted file mode 100644
index 61714a8..0000000
--- a/packages/orm/test/registry.test.js
+++ /dev/null
@@ -1,47 +0,0 @@
-// 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
deleted file mode 100644
index ffdb3a0..0000000
--- a/packages/orm/test/repository.test.js
+++ /dev/null
@@ -1,154 +0,0 @@
-// 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
deleted file mode 100644
index a2785e1..0000000
--- a/packages/schema/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 7a44cdd..0000000
--- a/packages/schema/README.md
+++ /dev/null
@@ -1,105 +0,0 @@
-# @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
deleted file mode 100644
index 7b340ed..0000000
--- a/packages/schema/package.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "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
deleted file mode 100644
index 47e053e..0000000
--- a/packages/schema/src/compilers.js
+++ /dev/null
@@ -1,140 +0,0 @@
-// 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
deleted file mode 100644
index e118f5e..0000000
--- a/packages/schema/src/define.js
+++ /dev/null
@@ -1,201 +0,0 @@
-// 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
deleted file mode 100644
index b893be7..0000000
--- a/packages/schema/src/generate.js
+++ /dev/null
@@ -1,106 +0,0 @@
-// 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
deleted file mode 100644
index 1bea0d1..0000000
--- a/packages/schema/src/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-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
deleted file mode 100644
index 6117f12..0000000
--- a/packages/schema/src/merge.js
+++ /dev/null
@@ -1,277 +0,0 @@
-// 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
deleted file mode 100644
index 63b27d4..0000000
--- a/packages/schema/test/compilers.test.js
+++ /dev/null
@@ -1,120 +0,0 @@
-// 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
deleted file mode 100644
index 706d8f5..0000000
--- a/packages/schema/test/composite-fk.test.js
+++ /dev/null
@@ -1,166 +0,0 @@
-// 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
deleted file mode 100644
index 96abbb8..0000000
--- a/packages/schema/test/composite-m2m.test.js
+++ /dev/null
@@ -1,187 +0,0 @@
-// 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
deleted file mode 100644
index fb79071..0000000
--- a/packages/schema/test/define.test.js
+++ /dev/null
@@ -1,132 +0,0 @@
-// 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
deleted file mode 100644
index 9591c39..0000000
--- a/packages/schema/test/generate.test.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// 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
deleted file mode 100644
index ddffb05..0000000
--- a/packages/schema/test/merge.test.js
+++ /dev/null
@@ -1,277 +0,0 @@
-// 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
deleted file mode 100644
index 578b3d5..0000000
--- a/packages/schema/test/relations.test.js
+++ /dev/null
@@ -1,128 +0,0 @@
-// 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 834d655..a64c6e0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -166,37 +166,6 @@ 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':
@@ -394,9 +363,6 @@ 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'}
@@ -1208,98 +1174,6 @@ 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'}
@@ -2634,8 +2508,6 @@ snapshots:
'@docsearch/sidepanel-js@4.6.3': {}
- '@electric-sql/pglite@0.5.3': {}
-
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -3324,10 +3196,6 @@ 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