diff --git a/README.md b/README.md index 97a9c6e..8952317 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A from-scratch type-safe ORM and a lab for the relational designs that break nai ## What this demonstrates -Most ORM tutorials stop at `findMany`. This repo goes the other way. It builds the internals of a small ORM (query builder, parameter binding, migrations, relations, and later a unit of work) and then uses that machinery against schemas that are actually hard: multi-tenant isolation, bitemporal history, trees, money, and soft delete. The point is to see where typed query construction earns its keep and where you should write SQL by hand. +Most ORM tutorials stop at `findMany`. This repo goes the other way. It builds the internals of a small ORM (query builder, parameter binding, migrations, relations, and a unit of work) and then uses that machinery against schemas that are actually hard: multi-tenant isolation, bitemporal history, trees, money, and soft delete. The point is to see where typed query construction earns its keep and where you should write SQL by hand. The catalog schema is that lab: every lookup is `(tenantId, id)`, the category tree is a closure table, prices are integer minor units, and live rows are `deletedAt IS NULL`. ## Concepts demonstrated @@ -32,6 +32,14 @@ Most ORM tutorials stop at `findMany`. This repo goes the other way. It builds t - Snapshot-based dirty checking (diff the live object against its load-time snapshot, no proxies) - Nested transactions via SQL `SAVEPOINT`/`RELEASE`/`ROLLBACK TO`, since real nested `BEGIN` isn't a thing - Partial rollback: an inner failure unwinds to its savepoint without discarding the outer transaction +- Multi-tenant isolation via composite identity `(tenant_id, id)` on every row +- Tenant-scoped lookups so a foreign id is not found (no cross-tenant existence leak) +- Closure table (transitive closure) for trees: ancestor, descendant, depth +- Cycle detection on reparent: refuse a parent that already sits in the subtree +- Integer money (minor units + ISO 4217); never `REAL` / IEEE-754 for currency +- Largest-remainder allocation so split pennies still sum to the original amount +- Soft delete (`deleted_at` NULL vs timestamp) and SQL three-valued logic (`IS NULL`) +- Partial unique index `WHERE deleted_at IS NULL` so a live SKU can be reused after delete ## What's implemented @@ -39,6 +47,7 @@ Most ORM tutorials stop at `findMany`. This repo goes the other way. It builds t - Declarative schema DSL + migration runner with up/down and a diff generator - hasMany/belongsTo relations with eager loading to kill the N+1 problem - Unit-of-work / identity map for change tracking, with nested transactions via savepoints +- A complex reference schema: multi-tenant + hierarchies + money type + soft-delete ## Usage @@ -114,6 +123,23 @@ uow.flush() // one BEGIN/COMMIT: an UPDATE for pat, an INSERT for the order `TransactionManager.run()` nests correctly: SQLite and Postgres don't support a second real `BEGIN`, so past the outermost call it issues `SAVEPOINT`/`RELEASE SAVEPOINT` instead. If the inner block throws, `ROLLBACK TO SAVEPOINT` undoes only that block; the outer transaction is still live and can catch the error and keep going, or commit what came before it. +The catalog is the hard schema. `categories_tenant_id_uq` and `products_tenant_id_uq` are the tenant-local identity the DSL can declare. A composite foreign key `(tenant_id, category_id)` is what you would add in hand-written Postgres; this DSL still emits single-column FKs, so `Catalog` loads parents with `WHERE tenantId = ? AND id = ?` and treats a cross-tenant id as missing. Trees use `category_tree` (closure table) instead of `LIKE '/1/1/%'`, which would also match `/1/11/`. Money is `{ minor, currency }`. Live uniqueness is a partial unique index, which the DSL cannot express, so `applyCatalogSchema` runs that `CREATE UNIQUE INDEX ... WHERE "deletedAt" IS NULL` as extra SQL. + +```ts +import { applyCatalogSchema, Catalog, liveProductsQuery, money } from 'orm-workbench' + +applyCatalogSchema(session, 'sqlite') +const catalog = new Catalog(session, 'sqlite') +catalog.createTenant(1, 'acme') +catalog.createCategory(10, 1, 'root') +catalog.createCategory(11, 1, 'shoes', 10) +catalog.createProduct(1, 1, 11, 'SKU-1', money(1999, 'USD')) +catalog.softDeleteProduct(1, 1) + +liveProductsQuery(1).compile('postgres').sql +// WHERE "products"."tenantId" = $1 AND "products"."deletedAt" IS NULL +``` + ```bash pnpm install pnpm run typecheck diff --git a/src/catalog.ts b/src/catalog.ts new file mode 100644 index 0000000..c847174 --- /dev/null +++ b/src/catalog.ts @@ -0,0 +1,278 @@ +import { col, defineSchema, table } from './ddl' +import { diffSchema, Migrator, type SqlSession } from './migrate' +import { and, eq, from, gt, isNull, type Dialect } from './query' +import { defineTable, integer, text, timestamptz } from './schema' + +const ISO4217 = /^[A-Z]{3}$/ + +export type Money = { readonly minor: number; readonly currency: string } + +export function money(minor: number, currency: string): Money { + if (!Number.isSafeInteger(minor)) throw new Error('Money minor units must be a safe integer') + if (!ISO4217.test(currency)) throw new Error(`Invalid ISO 4217 currency: ${JSON.stringify(currency)}`) + return { minor, currency } +} + +export function addMoney(a: Money, b: Money): Money { + if (a.currency !== b.currency) throw new Error(`Currency mismatch ${a.currency} vs ${b.currency}`) + return money(a.minor + b.minor, a.currency) +} + +export function allocateMoney(total: Money, weights: readonly number[]): Money[] { + if (weights.length === 0) throw new Error('allocateMoney needs at least one weight') + let sum = 0 + for (const weight of weights) { + if (!Number.isFinite(weight) || weight < 0) throw new Error('Weights must be finite and non-negative') + sum += weight + } + if (sum === 0) throw new Error('Weights must sum to a positive number') + const exact = weights.map((weight) => (total.minor * weight) / sum) + const shares = exact.map((value) => Math.trunc(value)) + let leftover = total.minor - shares.reduce((acc, share) => acc + share, 0) + const order = exact + .map((value, index) => ({ index, rem: Math.abs(value - (shares[index] ?? 0)) })) + .sort((a, b) => b.rem - a.rem || a.index - b.index) + const step = leftover > 0 ? 1 : leftover < 0 ? -1 : 0 + let cursor = 0 + while (leftover !== 0) { + const index = order[cursor % order.length]?.index + if (index === undefined) break + shares[index] = (shares[index] ?? 0) + step + leftover -= step + cursor += 1 + } + return shares.map((minor) => money(minor, total.currency)) +} + +export const catalogSchema = defineSchema({ + tenants: table({ + id: col.integer().primaryKey(), + name: col.text(), + }), + categories: table({ + id: col.integer().primaryKey(), + tenantId: col.integer().references('tenants', 'id', { onDelete: 'cascade' }), + parentId: col.integer().nullable().references('categories', 'id', { onDelete: 'restrict' }), + name: col.text(), + deletedAt: col.timestamptz().nullable(), + }) + .index('categories_tenant_id_uq', ['tenantId', 'id'], { unique: true }) + .index('categories_tenant_parent_idx', ['tenantId', 'parentId']), + category_tree: table({ + tenantId: col.integer().references('tenants', 'id', { onDelete: 'cascade' }), + ancestorId: col.integer().references('categories', 'id', { onDelete: 'cascade' }), + descendantId: col.integer().references('categories', 'id', { onDelete: 'cascade' }), + depth: col.integer(), + }) + .index('category_tree_edge_uq', ['tenantId', 'ancestorId', 'descendantId'], { unique: true }) + .index('category_tree_desc_idx', ['tenantId', 'descendantId']), + products: table({ + id: col.integer().primaryKey(), + tenantId: col.integer().references('tenants', 'id', { onDelete: 'cascade' }), + categoryId: col.integer().references('categories', 'id', { onDelete: 'restrict' }), + sku: col.text(), + priceMinor: col.integer(), + currency: col.text(), + deletedAt: col.timestamptz().nullable(), + }) + .index('products_tenant_id_uq', ['tenantId', 'id'], { unique: true }) + .index('products_tenant_category_idx', ['tenantId', 'categoryId']), +}) + +export const catalogLiveSkuIndex = + 'CREATE UNIQUE INDEX IF NOT EXISTS "products_live_sku_uq" ON "products" ("tenantId", "sku") WHERE "deletedAt" IS NULL' + +export const tenants = defineTable('tenants', { id: integer(), name: text() }) +export const categories = defineTable('categories', { + id: integer(), + tenantId: integer(), + parentId: integer(), + name: text(), + deletedAt: timestamptz(), +}) +export const categoryTree = defineTable('category_tree', { + tenantId: integer(), + ancestorId: integer(), + descendantId: integer(), + depth: integer(), +}) +export const products = defineTable('products', { + id: integer(), + tenantId: integer(), + categoryId: integer(), + sku: text(), + priceMinor: integer(), + currency: text(), + deletedAt: timestamptz(), +}) + +export function liveProductsQuery(tenantId: number) { + return from(products) + .where(and(eq(products.tenantId, tenantId), isNull(products.deletedAt))) + .select({ + id: products.id, + sku: products.sku, + priceMinor: products.priceMinor, + currency: products.currency, + categoryId: products.categoryId, + }) + .orderBy(products.id) +} + +export function descendantsQuery(tenantId: number, ancestorId: number) { + return from(categoryTree) + .innerJoin( + categories, + and(eq(categories.id, categoryTree.descendantId), eq(categories.tenantId, categoryTree.tenantId)), + ) + .where( + and( + eq(categoryTree.tenantId, tenantId), + eq(categoryTree.ancestorId, ancestorId), + gt(categoryTree.depth, 0), + isNull(categories.deletedAt), + ), + ) + .select({ id: categories.id, name: categories.name, depth: categoryTree.depth }) + .orderBy(categoryTree.depth) + .orderBy(categories.id) +} + +export function applyCatalogSchema(session: SqlSession, dialect: Dialect): void { + const empty = defineSchema({}) + new Migrator(session, dialect).migrateUp([ + { id: '001_catalog', up: diffSchema(empty, catalogSchema), down: diffSchema(catalogSchema, empty) }, + ]) + session.exec(catalogLiveSkuIndex) +} + +export class Catalog { + constructor( + private readonly session: SqlSession, + private readonly dialect: Dialect, + ) {} + + private ph(n: number): string { + return this.dialect === 'sqlite' ? '?' : `$${n}` + } + + createTenant(id: number, name: string): void { + this.session.exec(`INSERT INTO "tenants" ("id", "name") VALUES (${this.ph(1)}, ${this.ph(2)})`, [id, name]) + } + + createCategory(id: number, tenantId: number, name: string, parentId: number | null = null): void { + if (parentId === id) throw new Error('Cycle: new parent is inside the subtree') + if (parentId !== null) this.requireLiveCategory(tenantId, parentId) + this.session.exec( + `INSERT INTO "categories" ("id", "tenantId", "parentId", "name", "deletedAt") VALUES (${this.ph(1)}, ${this.ph(2)}, ${this.ph(3)}, ${this.ph(4)}, NULL)`, + [id, tenantId, parentId, name], + ) + this.session.exec( + `INSERT INTO "category_tree" ("tenantId", "ancestorId", "descendantId", "depth") VALUES (${this.ph(1)}, ${this.ph(2)}, ${this.ph(3)}, 0)`, + [tenantId, id, id], + ) + if (parentId === null) return + this.session.exec( + `INSERT INTO "category_tree" ("tenantId", "ancestorId", "descendantId", "depth") SELECT "tenantId", "ancestorId", ${this.ph(1)}, "depth" + 1 FROM "category_tree" WHERE "tenantId" = ${this.ph(2)} AND "descendantId" = ${this.ph(3)} AND "depth" >= 0`, + [id, tenantId, parentId], + ) + } + + reparentCategory(tenantId: number, id: number, parentId: number | null): void { + this.requireLiveCategory(tenantId, id) + if (parentId !== null) { + this.requireLiveCategory(tenantId, parentId) + const cycle = this.session.all( + `SELECT 1 AS ok FROM "category_tree" WHERE "tenantId" = ${this.ph(1)} AND "ancestorId" = ${this.ph(2)} AND "descendantId" = ${this.ph(3)}`, + [tenantId, id, parentId], + ) + if (cycle[0]) throw new Error('Cycle: new parent is inside the subtree') + } + // SQLite forbids reading category_tree in the same DELETE; wrap the subtree ids. + this.session.exec( + `DELETE FROM "category_tree" WHERE "tenantId" = ${this.ph(1)} AND "descendantId" IN (SELECT "descendantId" FROM (SELECT "descendantId" FROM "category_tree" WHERE "tenantId" = ${this.ph(2)} AND "ancestorId" = ${this.ph(3)})) AND "ancestorId" NOT IN (SELECT "descendantId" FROM (SELECT "descendantId" FROM "category_tree" WHERE "tenantId" = ${this.ph(4)} AND "ancestorId" = ${this.ph(5)}))`, + [tenantId, tenantId, id, tenantId, id], + ) + if (parentId !== null) { + this.session.exec( + `INSERT INTO "category_tree" ("tenantId", "ancestorId", "descendantId", "depth") SELECT a."tenantId", a."ancestorId", d."descendantId", a."depth" + d."depth" + 1 FROM "category_tree" a JOIN "category_tree" d ON a."tenantId" = d."tenantId" WHERE a."tenantId" = ${this.ph(1)} AND a."descendantId" = ${this.ph(2)} AND d."ancestorId" = ${this.ph(3)}`, + [tenantId, parentId, id], + ) + } + this.session.exec( + `UPDATE "categories" SET "parentId" = ${this.ph(1)} WHERE "tenantId" = ${this.ph(2)} AND "id" = ${this.ph(3)}`, + [parentId, tenantId, id], + ) + } + + descendants(tenantId: number, ancestorId: number): { id: number; name: string; depth: number }[] { + const compiled = descendantsQuery(tenantId, ancestorId).compile(this.dialect) + return this.session.all(compiled.sql, compiled.params).map((row) => ({ + id: Number(row.id), + name: String(row.name), + depth: Number(row.depth), + })) + } + + createProduct(id: number, tenantId: number, categoryId: number, sku: string, price: Money): void { + this.requireLiveCategory(tenantId, categoryId) + this.session.exec( + `INSERT INTO "products" ("id", "tenantId", "categoryId", "sku", "priceMinor", "currency", "deletedAt") VALUES (${this.ph(1)}, ${this.ph(2)}, ${this.ph(3)}, ${this.ph(4)}, ${this.ph(5)}, ${this.ph(6)}, NULL)`, + [id, tenantId, categoryId, sku, price.minor, price.currency], + ) + } + + softDeleteProduct(tenantId: number, id: number): void { + const n = this.session.all( + `UPDATE "products" SET "deletedAt" = ${this.ph(1)} WHERE "tenantId" = ${this.ph(2)} AND "id" = ${this.ph(3)} AND "deletedAt" IS NULL RETURNING "id"`, + [new Date().toISOString(), tenantId, id], + ) + if (!n[0]) throw new Error(`Live product ${id} not found in tenant ${tenantId}`) + } + + restoreProduct(tenantId: number, id: number): void { + const n = this.session.all( + `UPDATE "products" SET "deletedAt" = NULL WHERE "tenantId" = ${this.ph(1)} AND "id" = ${this.ph(2)} AND "deletedAt" IS NOT NULL RETURNING "id"`, + [tenantId, id], + ) + if (!n[0]) throw new Error(`Deleted product ${id} not found in tenant ${tenantId}`) + } + + liveProducts(tenantId: number): { id: number; sku: string; price: Money; categoryId: number }[] { + const compiled = liveProductsQuery(tenantId).compile(this.dialect) + return this.session.all(compiled.sql, compiled.params).map((row) => ({ + id: Number(row.id), + sku: String(row.sku), + categoryId: Number(row.categoryId), + price: money(Number(row.priceMinor), String(row.currency)), + })) + } + + totalLivePrice(tenantId: number, currency: string): Money { + money(0, currency) + return this.liveProducts(tenantId) + .filter((row) => row.price.currency === currency) + .reduce((sum, row) => addMoney(sum, row.price), money(0, currency)) + } + + softDeleteCategory(tenantId: number, id: number): void { + this.requireLiveCategory(tenantId, id) + if (this.descendants(tenantId, id).length > 0) throw new Error('Cannot delete a category that still has live descendants') + const productsHere = this.liveProducts(tenantId).filter((row) => row.categoryId === id) + if (productsHere.length > 0) throw new Error('Cannot delete a category that still has live products') + const n = this.session.all( + `UPDATE "categories" SET "deletedAt" = ${this.ph(1)} WHERE "tenantId" = ${this.ph(2)} AND "id" = ${this.ph(3)} AND "deletedAt" IS NULL RETURNING "id"`, + [new Date().toISOString(), tenantId, id], + ) + if (!n[0]) throw new Error(`Live category ${id} not found in tenant ${tenantId}`) + } + + private requireLiveCategory(tenantId: number, id: number): void { + const row = this.session.all( + `SELECT "deletedAt" FROM "categories" WHERE "tenantId" = ${this.ph(1)} AND "id" = ${this.ph(2)}`, + [tenantId, id], + )[0] + if (!row) throw new Error(`Category ${id} not found in tenant ${tenantId}`) + if (row.deletedAt != null) throw new Error(`Category ${id} is deleted`) + } +} diff --git a/src/index.ts b/src/index.ts index 0d66c9b..2932fa7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,4 @@ export * from './ddl' export * from './migrate' export * from './relations' export * from './unit-of-work' +export * from './catalog' diff --git a/test/catalog.test.ts b/test/catalog.test.ts new file mode 100644 index 0000000..9bd5388 --- /dev/null +++ b/test/catalog.test.ts @@ -0,0 +1,142 @@ +import { expect, test } from 'vitest' +import { + addMoney, + allocateMoney, + applyCatalogSchema, + Catalog, + catalogLiveSkuIndex, + catalogSchema, + compileOps, + defineSchema, + diffSchema, + liveProductsQuery, + money, + type SqlSession, +} from '../src/index' + +type SqliteDb = { + exec: (sql: string) => void + prepare: (sql: string) => { all: (...p: unknown[]) => unknown; run: (...p: unknown[]) => unknown } + close: () => void +} + +function sqlite(): { session: SqlSession; catalog: Catalog; close: () => void } { + const loaded = ( + globalThis as { process?: { getBuiltinModule?: (id: string) => { DatabaseSync: new (path: string) => SqliteDb } } } + ).process?.getBuiltinModule?.('node:sqlite') + if (!loaded) throw new Error('node:sqlite is not available in this runtime') + const db = new loaded.DatabaseSync(':memory:') + db.exec('PRAGMA foreign_keys = ON') + const session: SqlSession = { + exec: (sql, params) => (!params || params.length === 0 ? db.exec(sql) : db.prepare(sql).run(...params)), + all: (sql, params) => db.prepare(sql).all(...(params ?? [])) as Record[], + } + applyCatalogSchema(session, 'sqlite') + return { session, catalog: new Catalog(session, 'sqlite'), close: () => db.close() } +} + +test('schema uses composite tenant identity; live SKU uniqueness is a partial index', () => { + const sql = compileOps(diffSchema(defineSchema({}), catalogSchema), 'sqlite').join('\n') + expect(sql).toContain('CREATE UNIQUE INDEX "categories_tenant_id_uq" ON "categories" ("tenantId", "id")') + expect(sql).toContain('CREATE UNIQUE INDEX "products_tenant_id_uq" ON "products" ("tenantId", "id")') + expect(sql).toContain('CREATE UNIQUE INDEX "category_tree_edge_uq" ON "category_tree" ("tenantId", "ancestorId", "descendantId")') + expect(sql).toContain('FOREIGN KEY ("parentId") REFERENCES "categories" ("id") ON DELETE RESTRICT') + expect(catalogLiveSkuIndex).toContain('WHERE "deletedAt" IS NULL') + const compiled = liveProductsQuery(7).compile('sqlite') + expect(compiled.sql).toContain('"deletedAt" IS NULL') + expect(compiled.sql).not.toMatch(/"deletedAt" = /) + expect(compiled.params[0]).toBe(7) +}) + +test('money is integer minor units; allocate uses largest remainder', () => { + expect(() => money(1.5, 'USD')).toThrow(/safe integer/) + expect(() => money(1, 'usd')).toThrow(/ISO 4217/) + expect(() => money(1, 'US')).toThrow(/ISO 4217/) + expect(() => addMoney(money(1, 'USD'), money(1, 'EUR'))).toThrow(/mismatch/) + expect(addMoney(money(40, 'USD'), money(2, 'USD'))).toEqual({ minor: 42, currency: 'USD' }) + expect(() => addMoney(money(Number.MAX_SAFE_INTEGER, 'USD'), money(1, 'USD'))).toThrow(/safe integer/) + expect(allocateMoney(money(100, 'USD'), [1, 1, 1]).map((part) => part.minor)).toEqual([34, 33, 33]) + expect(allocateMoney(money(1, 'JPY'), [1, 1, 1]).map((part) => part.minor)).toEqual([1, 0, 0]) + expect(allocateMoney(money(-5, 'USD'), [1, 1]).map((part) => part.minor)).toEqual([-3, -2]) + expect(() => allocateMoney(money(10, 'USD'), [])).toThrow(/at least one/) + expect(() => allocateMoney(money(10, 'USD'), [0, 0])).toThrow(/positive/) + expect(() => allocateMoney(money(10, 'USD'), [-1])).toThrow(/non-negative/) +}) + +test('lookups are (tenantId, id); a foreign category is not found', () => { + const db = sqlite() + db.catalog.createTenant(1, 'acme') + db.catalog.createTenant(2, 'other') + db.catalog.createCategory(10, 1, 'root') + db.catalog.createCategory(11, 2, 'root') + expect(() => db.catalog.createCategory(12, 1, 'stolen', 11)).toThrow(/not found in tenant 1/) + expect(() => db.catalog.createProduct(1, 1, 11, 'SKU-1', money(100, 'USD'))).toThrow(/not found in tenant 1/) + db.catalog.createProduct(1, 1, 10, 'SKU-1', money(100, 'USD')) + expect(db.catalog.liveProducts(2)).toEqual([]) + expect(db.catalog.liveProducts(1).map((row) => row.sku)).toEqual(['SKU-1']) + db.close() +}) + +test('closure table descendants, reparent, and cycle detection stay inside the tenant', () => { + const db = sqlite() + db.catalog.createTenant(1, 'acme') + db.catalog.createTenant(2, 'other') + db.catalog.createCategory(1, 1, 'root') + db.catalog.createCategory(2, 1, 'a', 1) + db.catalog.createCategory(3, 1, 'b', 2) + db.catalog.createCategory(4, 1, 'c', 1) + db.catalog.createCategory(20, 2, 'noise') + expect(db.catalog.descendants(1, 1).map((row) => row.id)).toEqual([2, 4, 3]) + expect(db.catalog.descendants(1, 2).map((row) => ({ id: row.id, depth: row.depth }))).toEqual([{ id: 3, depth: 1 }]) + expect(db.catalog.descendants(2, 1)).toEqual([]) + expect(() => db.catalog.reparentCategory(1, 1, 3)).toThrow(/Cycle/) + expect(() => db.catalog.reparentCategory(1, 2, 2)).toThrow(/Cycle/) + db.catalog.reparentCategory(1, 2, 4) + expect(db.catalog.descendants(1, 4).map((row) => row.id)).toEqual([2, 3]) + expect(db.catalog.descendants(1, 1).map((row) => row.id)).toEqual([4, 2, 3]) + expect(db.catalog.descendants(1, 2).map((row) => row.id)).toEqual([3]) + db.close() +}) + +test('reparenting a live subtree to null makes a new root and keeps its descendants', () => { + const db = sqlite() + db.catalog.createTenant(1, 'acme') + db.catalog.createCategory(1, 1, 'root') + db.catalog.createCategory(2, 1, 'child', 1) + db.catalog.createCategory(3, 1, 'leaf', 2) + db.catalog.reparentCategory(1, 2, null) + expect(db.catalog.descendants(1, 1)).toEqual([]) + expect(db.catalog.descendants(1, 2)).toEqual([{ id: 3, name: 'leaf', depth: 1 }]) + expect( + db.session.all(`SELECT "parentId" FROM "categories" WHERE "tenantId" = ? AND "id" = ?`, [1, 2])[0], + ).toEqual({ parentId: null }) + db.catalog.reparentCategory(1, 1, null) + expect( + db.session.all(`SELECT "parentId" FROM "categories" WHERE "tenantId" = ? AND "id" = ?`, [1, 1])[0], + ).toEqual({ parentId: null }) + expect(db.catalog.descendants(1, 1)).toEqual([]) + db.close() +}) + +test('soft-delete hides live rows, frees the SKU, and blocks restore on a taken SKU', () => { + const db = sqlite() + db.catalog.createTenant(1, 'acme') + db.catalog.createCategory(1, 1, 'root') + db.catalog.createProduct(1, 1, 1, 'SKU-1', money(50, 'USD')) + db.catalog.createProduct(2, 1, 1, 'SKU-2', money(70, 'EUR')) + expect(() => db.catalog.createProduct(3, 1, 1, 'SKU-1', money(1, 'USD'))).toThrow(/UNIQUE|unique/) + expect(db.catalog.totalLivePrice(1, 'USD')).toEqual({ minor: 50, currency: 'USD' }) + db.catalog.softDeleteProduct(1, 1) + expect(db.catalog.liveProducts(1).map((row) => row.sku)).toEqual(['SKU-2']) + db.catalog.createProduct(3, 1, 1, 'SKU-1', money(10, 'USD')) + expect(() => db.catalog.restoreProduct(1, 1)).toThrow(/UNIQUE|unique/) + expect(() => db.catalog.softDeleteCategory(1, 1)).toThrow(/live products/) + db.catalog.softDeleteProduct(1, 2) + db.catalog.softDeleteProduct(1, 3) + db.catalog.createCategory(2, 1, 'child', 1) + expect(() => db.catalog.softDeleteCategory(1, 1)).toThrow(/live descendants/) + db.catalog.softDeleteCategory(1, 2) + db.catalog.softDeleteCategory(1, 1) + expect(() => db.catalog.createProduct(4, 1, 1, 'SKU-9', money(1, 'USD'))).toThrow(/deleted/) + db.close() +})