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