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