From 7bdc834ddfe177e7cd9371d718c83144850ad008 Mon Sep 17 00:00:00 2001 From: shamspias Date: Sun, 5 Jul 2026 13:32:27 +0600 Subject: [PATCH] =?UTF-8?q?feat(m7.5b):=20TypeScript=20ORM=20adapter=20?= =?UTF-8?q?=E2=80=94=20fromDrizzle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-expose your Drizzle models as intent-level capabilities — the TS mirror of Go's GORM FromORM and Python's from_orm. - reins/orm subpath (package.json exports + optional peerDependency drizzle-orm), so the core reins package never depends on an ORM. - src/orm/build.ts: the ORM-neutral generation engine (ZERO deps) — a faithful port of Go's orm.go behind a Backend interface. Intent-level names, natural-key-over-id lookups (unique string preferred, hint-ranked), lean params, trimmed responses, a schema index, and gated writes; every generated capability must pass the linter or it is dropped. Another ORM means implementing only introspection + the six ops. - src/orm/drizzle.ts: the Drizzle adapter. introspect() reflects a Drizzle table object via getTableColumns/getTableName (no database needed) into ORM-neutral ModelInfo (jsonType/nullable/autoincrement/hasDefault/unique); DrizzleBackend runs find/count/get/create/update/delete through Drizzle's query builder; fromDrizzle() wires it to buildCapabilities. Drizzle's db/table types are deeply generic, so they're treated structurally. - tests: orm-build.test.ts drives the engine with a fake Backend (read/write generation, natural-key vs pk fallback, keyless models, required-column inference, handler dispatch, not-found errors, untrusted-arg filtering); orm-drizzle.test.ts covers introspection (no DB) and a full create/get/count/find/update/delete round-trip against in-memory libsql. Verified: make js-check green — format, typecheck, 125 tests (124 pass, 1 skip); the reins/orm subpath builds and imports cleanly as ESM. --- README.md | 2 +- packages/js/package-lock.json | 396 ++++++++++++++++++++++++++ packages/js/package.json | 14 + packages/js/src/orm/build.ts | 348 ++++++++++++++++++++++ packages/js/src/orm/drizzle.ts | 171 +++++++++++ packages/js/src/orm/index.ts | 10 + packages/js/tests/orm-build.test.ts | 188 ++++++++++++ packages/js/tests/orm-drizzle.test.ts | 97 +++++++ 8 files changed, 1225 insertions(+), 1 deletion(-) create mode 100644 packages/js/src/orm/build.ts create mode 100644 packages/js/src/orm/drizzle.ts create mode 100644 packages/js/src/orm/index.ts create mode 100644 packages/js/tests/orm-build.test.ts create mode 100644 packages/js/tests/orm-drizzle.test.ts diff --git a/README.md b/README.md index 2203fd7..1e0aaed 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ before `1.0`. - [x] Instant web integration — `reins.endpoint`, ``, session-identity → RLS - [x] A flagship end-to-end example (FastAPI + SQLAlchemy, gated writes) — `make demo` - [x] **Go package** (`packages/go`) — same verbs, passes the shared conformance suite; capabilities + classification + linter, the `Ask`/`Run` loop, full safety (policy/approval/audit/RLS), progressive disclosure, an OpenAI-compatible adapter for all providers above, GORM `FromORM`, and a `reins inspect|ask|chat ` CLI. Remaining: a native Go code-mode sandbox. -- [x] **TypeScript package** (`packages/js`) — same verbs, **passes the shared conformance suite**; the core types + `Model`/`FakeModel`, capabilities + classification + linter, the async `ask`/`run` loop, full safety (policy/approval/audit/RLS), and dual progressive disclosure. Remaining: a native TS code-mode sandbox, `fromORM` (Prisma/Drizzle), a CLI, and ``. +- [x] **TypeScript package** (`packages/js`) — same verbs, **passes the shared conformance suite**; the core types + `Model`/`FakeModel`, capabilities + classification + linter, the async `ask`/`run` loop, full safety (policy/approval/audit/RLS), dual progressive disclosure, and `fromDrizzle` (auto-expose Drizzle models via the optional `reins/orm` subpath). Remaining: a native TS code-mode sandbox, a CLI, and ``. ## Development diff --git a/packages/js/package-lock.json b/packages/js/package-lock.json index 4fee611..8aec805 100644 --- a/packages/js/package-lock.json +++ b/packages/js/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0-alpha.1", "license": "MIT", "devDependencies": { + "@libsql/client": "^0.17.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", + "drizzle-orm": "^0.45.2", "js-yaml": "^5.2.1", "prettier": "^3.4.2", "typescript": "^5.7.2", @@ -469,6 +471,185 @@ "dev": true, "license": "MIT" }, + "node_modules/@libsql/client": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.4.tgz", + "integrity": "sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.17.4", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.4.tgz", + "integrity": "sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -900,6 +1081,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", @@ -1097,6 +1288,142 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "dev": true, + "license": "Apache-2.0", + "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", + "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 + } + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1199,6 +1526,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/js-base64": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.8.0.tgz", + "integrity": "sha512-65kvbemyZhj+ExQt1PEFyBEjL5vAHysu1lJdW1AwhhChkO8ZBPizYk/m9GVrpbS2Je1hF+UYZ+6KywqtZV8mHw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1229,6 +1563,39 @@ "js-yaml": "bin/js-yaml.mjs" } }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1354,6 +1721,13 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "dev": true, + "license": "ISC" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1712,6 +2086,28 @@ "engines": { "node": ">=8" } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/packages/js/package.json b/packages/js/package.json index 26df3e0..d946a9c 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -10,6 +10,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./orm": { + "types": "./dist/orm/index.d.ts", + "import": "./dist/orm/index.js" } }, "files": [ @@ -18,6 +22,14 @@ "engines": { "node": ">=20" }, + "peerDependencies": { + "drizzle-orm": ">=0.30" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + }, "scripts": { "typecheck": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.build.json", @@ -27,8 +39,10 @@ "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"" }, "devDependencies": { + "@libsql/client": "^0.17.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", + "drizzle-orm": "^0.45.2", "js-yaml": "^5.2.1", "prettier": "^3.4.2", "typescript": "^5.7.2", diff --git a/packages/js/src/orm/build.ts b/packages/js/src/orm/build.ts new file mode 100644 index 0000000..6c55af3 --- /dev/null +++ b/packages/js/src/orm/build.ts @@ -0,0 +1,348 @@ +import type { Capability, JSONSchema, SchemaIndex, TableInfo } from '../types.js'; +import type { BoundCapability, Handler } from '../capability.js'; +import { lintCapability } from '../linter.js'; +import { ReinsError } from '../errors.js'; + +/** + * The ORM-neutral capability-generation engine — the TypeScript mirror of Go's + * `orm.go`. It turns introspected models (`ModelInfo`) into intent-level, linted + * capabilities behind a `Backend`, so adding another ORM means implementing only + * introspection + the six data operations. It has ZERO ORM dependencies; the + * Drizzle adapter (drizzle.ts) plugs into it. + */ + +const MAX_PARAMS = 7; // keep generated capabilities lean (linter §2.15) +const MAX_COLUMNS_RETURNED = 12; // trim wide rows so raw tables never flood context +const DEFAULT_READ_LIMIT = 50; // bounded result sets (§2.13) + +/** Column names commonly used as human-facing keys, best first. */ +const naturalKeyHints = ['slug', 'email', 'username', 'code', 'key', 'name', 'title', 'uuid']; + +/** One column in ORM-neutral terms. */ +export interface ColumnInfo { + name: string; + jsonType: string; + nullable: boolean; + autoincrement: boolean; + hasDefault: boolean; + unique: boolean; +} + +/** One mapped model in ORM-neutral terms. `model` is the backend's opaque handle. */ +export interface ModelInfo { + model?: unknown; + singular: string; + plural: string; + columns: ColumnInfo[]; + primaryKey?: ColumnInfo; + description?: string; +} + +/** + * Runs the six data operations for one ORM, returning trimmed rows. `update` and + * `delete` throw a "not found" error when the row is missing — surfaced to the model + * as errors-as-data. + */ +export interface Backend { + find( + info: ModelInfo, + filters: Record, + limit: number, + ): Promise>>; + count(info: ModelInfo, filters: Record): Promise; + get(info: ModelInfo, key: string, value: unknown): Promise | null>; + create(info: ModelInfo, values: Record): Promise>; + update( + info: ModelInfo, + key: string, + value: unknown, + changes: Record, + ): Promise | null>; + delete(info: ModelInfo, key: string, value: unknown): Promise; +} + +function optionalOnCreate(c: ColumnInfo): boolean { + return c.nullable || c.autoincrement || c.hasDefault; +} + +function naturalKey(info: ModelInfo): ColumnInfo | undefined { + const pkName = info.primaryKey?.name; + const uniqueStrings = new Map(); + for (const c of info.columns) { + if (c.unique && c.jsonType === 'string' && c.name !== pkName) { + uniqueStrings.set(c.name, c); + } + } + if (uniqueStrings.size === 0) { + return undefined; + } + for (const hint of naturalKeyHints) { + const c = uniqueStrings.get(hint); + if (c) { + return c; + } + } + return [...uniqueStrings.values()][0]; +} + +function lookupKey(info: ModelInfo): ColumnInfo | undefined { + return naturalKey(info) ?? info.primaryKey; +} + +/** + * Turn introspected models into linted capabilities plus a schema index. Reads are + * always generated; writes only when `canWrite` is true. A generated capability + * that fails the linter is dropped, never shipped (§2.15). + */ +export function buildCapabilities( + backend: Backend, + infos: ModelInfo[], + canWrite: boolean, + readLimit: number, +): { capabilities: BoundCapability[]; schema: SchemaIndex } { + const limit = readLimit > 0 ? readLimit : DEFAULT_READ_LIMIT; + const capabilities: BoundCapability[] = []; + const tables: TableInfo[] = []; + for (const info of infos) { + const candidates = [ + ...readCaps(backend, info, limit), + ...(canWrite ? writeCaps(backend, info) : []), + ]; + for (const c of candidates) { + if (lintCapability(c.spec, c.returnsRaw).ok) { + capabilities.push(c); + } + } + tables.push(tableInfo(info)); + } + return { capabilities, schema: { tables } }; +} + +// --- read capabilities ------------------------------------------------------------- + +function readCaps(backend: Backend, info: ModelInfo, readLimit: number): BoundCapability[] { + const caps = [findCap(backend, info, readLimit), countCap(backend, info)]; + if (lookupKey(info)) { + caps.push(getCap(backend, info)); + } + return caps; +} + +function findCap(backend: Backend, info: ModelInfo, readLimit: number): BoundCapability { + const filters = filterColumns(info); + const spec: Capability = { + name: `find_${info.plural}`, + description: `Find ${info.plural} matching optional filters (returns up to ${readLimit} rows, trimmed).`, + input_schema: objectSchema(columnTypes(filters), []), + access: 'read', + confirm: false, + idempotent: false, + }; + const handler: Handler = (_ctx, args) => backend.find(info, known(args, filters), readLimit); + return { spec, handler, returnsRaw: false }; +} + +function countCap(backend: Backend, info: ModelInfo): BoundCapability { + const filters = filterColumns(info); + const spec: Capability = { + name: `count_${info.plural}`, + description: `Count ${info.plural} matching optional filters.`, + input_schema: objectSchema(columnTypes(filters), []), + access: 'read', + confirm: false, + idempotent: false, + }; + const handler: Handler = (_ctx, args) => backend.count(info, known(args, filters)); + return { spec, handler, returnsRaw: false }; +} + +function getCap(backend: Backend, info: ModelInfo): BoundCapability { + const key = lookupKey(info)!; + const spec: Capability = { + name: `get_${info.singular}_by_${key.name}`, + description: `Get one ${info.singular} by its ${key.name} (returns the row, trimmed, or nothing if not found).`, + input_schema: objectSchema({ [key.name]: key.jsonType }, [key.name]), + access: 'read', + confirm: false, + idempotent: false, + }; + const handler: Handler = async (_ctx, args) => + (await backend.get(info, key.name, args[key.name])) ?? null; + return { spec, handler, returnsRaw: false }; +} + +// --- write capabilities (gated) ---------------------------------------------------- + +function writeCaps(backend: Backend, info: ModelInfo): BoundCapability[] { + const caps = [createCap(backend, info)]; + if (lookupKey(info)) { + caps.push(updateCap(backend, info), deleteCap(backend, info)); + } + return caps; +} + +function createCap(backend: Backend, info: ModelInfo): BoundCapability { + const settable = settableColumns(info); + const required = settable.filter((c) => !optionalOnCreate(c)).map((c) => c.name); + const spec: Capability = { + name: `create_${info.singular}`, + description: `Create a new ${info.singular} (returns the created row, trimmed).`, + input_schema: objectSchema(columnTypes(settable), required), + access: 'write', + confirm: false, + idempotent: false, + }; + const handler: Handler = (_ctx, args) => backend.create(info, known(args, settable)); + return { spec, handler, returnsRaw: false }; +} + +function updateCap(backend: Backend, info: ModelInfo): BoundCapability { + const key = lookupKey(info)!; + // Leave room for the required key under MAX_PARAMS. + const nonKey = info.columns.filter((c) => !c.autoincrement && c.name !== key.name); + let settable = prioritize(info, nonKey); + if (settable.length > MAX_PARAMS - 1) { + settable = settable.slice(0, MAX_PARAMS - 1); + } + const props: Record = { [key.name]: key.jsonType }; + for (const c of settable) { + props[c.name] = c.jsonType; + } + const spec: Capability = { + name: `update_${info.singular}`, + description: `Update an existing ${info.singular}, found by its ${key.name} (returns the updated row, trimmed).`, + input_schema: objectSchema(props, [key.name]), + access: 'write', + confirm: false, + idempotent: false, + }; + const handler: Handler = (_ctx, args) => + backend.update(info, key.name, args[key.name], known(args, settable)); + return { spec, handler, returnsRaw: false }; +} + +function deleteCap(backend: Backend, info: ModelInfo): BoundCapability { + const key = lookupKey(info)!; + const spec: Capability = { + name: `delete_${info.singular}`, + description: `Delete a ${info.singular}, found by its ${key.name}.`, + input_schema: objectSchema({ [key.name]: key.jsonType }, [key.name]), + access: 'destructive', + confirm: false, + idempotent: false, + }; + const handler: Handler = async (_ctx, args) => { + await backend.delete(info, key.name, args[key.name]); + return { deleted: args[key.name] }; + }; + return { spec, handler, returnsRaw: false }; +} + +// --- column selection + helpers ---------------------------------------------------- + +function filterColumns(info: ModelInfo): ColumnInfo[] { + return cap7(prioritize(info, info.columns)); +} + +function settableColumns(info: ModelInfo): ColumnInfo[] { + return cap7( + prioritize( + info, + info.columns.filter((c) => !c.autoincrement), + ), + ); +} + +function cap7(cols: ColumnInfo[]): ColumnInfo[] { + return cols.length > MAX_PARAMS ? cols.slice(0, MAX_PARAMS) : cols; +} + +function prioritize(info: ModelInfo, columns: ColumnInfo[]): ColumnInfo[] { + const keyNames = new Set(); + const nk = naturalKey(info); + if (nk) { + keyNames.add(nk.name); + } + if (info.primaryKey) { + keyNames.add(info.primaryKey.name); + } + const rank = (c: ColumnInfo): number => (keyNames.has(c.name) ? 0 : c.nullable ? 2 : 1); + return [...columns].sort( + (a, b) => rank(a) - rank(b) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0), + ); +} + +function known(args: Record, columns: ColumnInfo[]): Record { + const allowed = new Set(columns.map((c) => c.name)); + const out: Record = {}; + for (const [k, v] of Object.entries(args)) { + if (allowed.has(k) && v !== undefined && v !== null) { + out[k] = v; + } + } + return out; +} + +function columnTypes(columns: ColumnInfo[]): Record { + const out: Record = {}; + for (const c of columns) { + out[c.name] = c.jsonType; + } + return out; +} + +function objectSchema(props: Record, required: string[]): JSONSchema { + const properties: Record = {}; + for (const [name, jsonType] of Object.entries(props)) { + properties[name] = { type: jsonType }; + } + return { type: 'object', properties, required, additionalProperties: false }; +} + +function tableInfo(info: ModelInfo): TableInfo { + const description = (info.description ?? '').trim() || `The ${info.plural} table.`; + return { name: info.plural, description, columns: info.columns.map((c) => c.name) }; +} + +/** A "not found" error for update/delete of a missing row (errors-as-data via the loop). */ +export function missing(info: ModelInfo, key: string, value: unknown): ReinsError { + return new ReinsError( + `no ${info.singular} with ${key}=${String(value)}`, + 'check the key, or find_ the row first', + ); +} + +/** Trim a row to the first {@link MAX_COLUMNS_RETURNED} known columns, JSON-cleaned. */ +export function trimRow(info: ModelInfo, row: Record): Record { + const out: Record = {}; + for (const c of info.columns.slice(0, MAX_COLUMNS_RETURNED)) { + out[c.name] = jsonValue(row[c.name]); + } + return out; +} + +function jsonValue(v: unknown): unknown { + if (v instanceof Date) { + return v.toISOString(); + } + return v; +} + +/** + * A deliberately naive English de-pluralizer for capability names + * (`get_customer_by_id` from a `customers` table). A wrong guess is still a valid + * name — just less pretty. + */ +export function singularize(word: string): string { + const lower = word.toLowerCase(); + if (lower.endsWith('ies') && word.length > 3) { + return word.slice(0, -3) + 'y'; + } + if (/(ses|xes|zes|ches|shes)$/.test(lower)) { + return word.slice(0, -2); + } + if (lower.endsWith('s') && !lower.endsWith('ss') && word.length > 1) { + return word.slice(0, -1); + } + return word; +} diff --git a/packages/js/src/orm/drizzle.ts b/packages/js/src/orm/drizzle.ts new file mode 100644 index 0000000..4190cb7 --- /dev/null +++ b/packages/js/src/orm/drizzle.ts @@ -0,0 +1,171 @@ +import { getTableColumns, getTableName, eq, and, count } from 'drizzle-orm'; +import type { SchemaIndex } from '../types.js'; +import type { BoundCapability } from '../capability.js'; +import { + type Backend, + type ColumnInfo, + type ModelInfo, + buildCapabilities, + missing, + singularize, + trimRow, +} from './build.js'; + +/** + * The Drizzle adapter — the TypeScript mirror of Go's GORM `FromORM`. It introspects + * Drizzle table objects into ORM-neutral `ModelInfo` (no database needed) and runs + * the six data operations through Drizzle's query builder, feeding the shared + * `buildCapabilities` engine. + * + * Drizzle's `db` and table types are deeply generic (parameterized by driver and + * schema), so they are treated structurally as `any` here — the queries are dynamic + * by nature. This module lives under the `reins/orm` subpath so the core package + * never depends on drizzle-orm. + */ + +type DrizzleTable = any; +type DrizzleDb = any; +type Columns = Record; + +/** Options for {@link fromDrizzle}. Writes are only generated when `canWrite` is true. */ +export interface FromDrizzleOptions { + canWrite?: boolean; + readLimit?: number; +} + +/** + * Introspect Drizzle tables and return intent-level capabilities plus a schema + * index. Pass the tables as an array or as a Drizzle schema object + * (`{ users, orders }`). Reads are always generated; writes only with `canWrite` + * (and are then gated by the autonomy ladder like any capability). + */ +export function fromDrizzle( + db: DrizzleDb, + tables: Record | DrizzleTable[], + options: FromDrizzleOptions = {}, +): { capabilities: BoundCapability[]; schema: SchemaIndex } { + const list = Array.isArray(tables) ? tables : Object.values(tables); + const infos = list.map(introspect); + return buildCapabilities( + new DrizzleBackend(db), + infos, + options.canWrite ?? false, + options.readLimit ?? 0, + ); +} + +/** Reflect one Drizzle table object into ORM-neutral ModelInfo (no database access). */ +export function introspect(table: DrizzleTable): ModelInfo { + const columns: ColumnInfo[] = []; + let primaryKey: ColumnInfo | undefined; + // getTableColumns keys by the JS property name — the identity used to query and + // the identity returned in rows — so capabilities expose those names. + for (const [propKey, column] of Object.entries(getTableColumns(table) as Columns)) { + const col: ColumnInfo = { + name: propKey, + jsonType: jsonType(column), + nullable: !column.notNull && !column.primary, + autoincrement: column.primary && jsonType(column) === 'integer', + hasDefault: Boolean(column.hasDefault), + unique: Boolean(column.isUnique), + }; + columns.push(col); + if (column.primary && !primaryKey) { + primaryKey = { ...col }; + } + } + const plural = getTableName(table); + const info: ModelInfo = { model: table, singular: singularize(plural), plural, columns }; + if (primaryKey) { + info.primaryKey = primaryKey; + } + return info; +} + +function jsonType(column: any): string { + if (column.dataType === 'boolean') { + return 'boolean'; + } + if (column.dataType === 'number' || column.dataType === 'bigint') { + return /int|serial/i.test(String(column.columnType)) ? 'integer' : 'number'; + } + return 'string'; // string, date, json, and anything else +} + +/** Runs the six operations via Drizzle's query builder against the caller's `db`. */ +class DrizzleBackend implements Backend { + constructor(private readonly db: DrizzleDb) {} + + private table(info: ModelInfo): DrizzleTable { + return info.model; + } + + private condition(info: ModelInfo, filters: Record): unknown { + const cols = getTableColumns(this.table(info)) as Columns; + const conds = Object.entries(filters).map(([k, v]) => eq(cols[k], v)); + return conds.length > 0 ? and(...conds) : undefined; + } + + async find( + info: ModelInfo, + filters: Record, + limit: number, + ): Promise>> { + let q = this.db.select().from(this.table(info)).$dynamic(); + const cond = this.condition(info, filters); + if (cond) { + q = q.where(cond); + } + const rows: Array> = await q.limit(limit); + return rows.map((r) => trimRow(info, r)); + } + + async count(info: ModelInfo, filters: Record): Promise { + let q = this.db.select({ n: count() }).from(this.table(info)).$dynamic(); + const cond = this.condition(info, filters); + if (cond) { + q = q.where(cond); + } + const rows: Array<{ n: number }> = await q; + return Number(rows[0]?.n ?? 0); + } + + async get(info: ModelInfo, key: string, value: unknown): Promise | null> { + const cols = getTableColumns(this.table(info)) as Columns; + const rows: Array> = await this.db + .select() + .from(this.table(info)) + .where(eq(cols[key], value)) + .limit(1); + return rows.length > 0 ? trimRow(info, rows[0]!) : null; + } + + async create(info: ModelInfo, values: Record): Promise> { + await this.db.insert(this.table(info)).values(values); + return trimRow(info, values); + } + + async update( + info: ModelInfo, + key: string, + value: unknown, + changes: Record, + ): Promise | null> { + if (!(await this.get(info, key, value))) { + throw missing(info, key, value); + } + if (Object.keys(changes).length > 0) { + const cols = getTableColumns(this.table(info)) as Columns; + await this.db.update(this.table(info)).set(changes).where(eq(cols[key], value)); + } + return this.get(info, key, value); + } + + async delete(info: ModelInfo, key: string, value: unknown): Promise { + if (!(await this.get(info, key, value))) { + throw missing(info, key, value); + } + const cols = getTableColumns(this.table(info)) as Columns; + await this.db.delete(this.table(info)).where(eq(cols[key], value)); + } +} diff --git a/packages/js/src/orm/index.ts b/packages/js/src/orm/index.ts new file mode 100644 index 0000000..6c06965 --- /dev/null +++ b/packages/js/src/orm/index.ts @@ -0,0 +1,10 @@ +/** + * `reins/orm` — auto-expose your ORM models as intent-level Reins capabilities. + * Lives in a subpath so the core `reins` package never depends on an ORM. + * + * - {@link buildCapabilities} + {@link Backend} — the ORM-neutral engine (zero deps). + * - {@link fromDrizzle} — the Drizzle adapter (requires `drizzle-orm`). + */ + +export * from './build.js'; +export * from './drizzle.js'; diff --git a/packages/js/tests/orm-build.test.ts b/packages/js/tests/orm-build.test.ts new file mode 100644 index 0000000..6ffdbc1 --- /dev/null +++ b/packages/js/tests/orm-build.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest'; +import { lintCapability, type Context } from '../src/index.js'; +import { + buildCapabilities, + missing, + singularize, + type Backend, + type ColumnInfo, + type ModelInfo, +} from '../src/orm/build.js'; + +function col(name: string, jsonType: string, opts: Partial = {}): ColumnInfo { + return { + name, + jsonType, + nullable: opts.nullable ?? false, + autoincrement: opts.autoincrement ?? false, + hasDefault: opts.hasDefault ?? false, + unique: opts.unique ?? false, + }; +} + +const id = col('id', 'integer', { autoincrement: true }); +const customers: ModelInfo = { + singular: 'customer', + plural: 'customers', + columns: [ + id, + col('email', 'string', { unique: true }), + col('name', 'string', { nullable: true }), + col('active', 'integer', { hasDefault: true }), + ], + primaryKey: id, +}; + +/** A backend that records which operations ran and returns canned data. */ +class FakeBackend implements Backend { + calls: string[] = []; + async find(): Promise>> { + this.calls.push('find'); + return [{ id: 1, email: 'a@b.com', name: 'Ada' }]; + } + async count(): Promise { + this.calls.push('count'); + return 1; + } + async get( + _info: ModelInfo, + _key: string, + value: unknown, + ): Promise | null> { + this.calls.push('get'); + return value === 'a@b.com' ? { id: 1, email: 'a@b.com' } : null; + } + async create( + _info: ModelInfo, + values: Record, + ): Promise> { + this.calls.push('create'); + return values; + } + async update( + info: ModelInfo, + key: string, + value: unknown, + changes: Record, + ): Promise | null> { + this.calls.push('update'); + if (value === 'missing') throw missing(info, key, value); + return { email: value, ...changes }; + } + async delete(info: ModelInfo, key: string, value: unknown): Promise { + this.calls.push('delete'); + if (value === 'missing') throw missing(info, key, value); + } +} + +describe('buildCapabilities — read side', () => { + it('generates intent-level read capabilities that all pass the linter', () => { + const { capabilities, schema } = buildCapabilities(new FakeBackend(), [customers], false, 0); + const byAccess = capabilities.map((c) => [c.spec.name, c.spec.access] as const); + expect(byAccess).toEqual([ + ['find_customers', 'read'], + ['count_customers', 'read'], + ['get_customer_by_email', 'read'], // natural key (unique string) preferred over id + ]); + for (const c of capabilities) { + expect(lintCapability(c.spec, c.returnsRaw).ok).toBe(true); + } + expect(schema.tables?.[0]?.name).toBe('customers'); + expect(schema.tables?.[0]?.columns).toEqual(['id', 'email', 'name', 'active']); + }); + + it('falls back to the primary key when there is no natural key', () => { + const noNatural: ModelInfo = { + singular: 'event', + plural: 'events', + columns: [id, col('payload', 'string', { nullable: true })], + primaryKey: id, + }; + const { capabilities } = buildCapabilities(new FakeBackend(), [noNatural], false, 0); + expect(capabilities.map((c) => c.spec.name)).toContain('get_event_by_id'); + }); + + it('omits get/update/delete when a model has no lookup key', () => { + const keyless: ModelInfo = { + singular: 'log', + plural: 'logs', + columns: [col('message', 'string', { nullable: true })], + }; + const { capabilities } = buildCapabilities(new FakeBackend(), [keyless], true, 0); + const names = capabilities.map((c) => c.spec.name); + expect(names).toEqual(['find_logs', 'count_logs', 'create_log']); + }); +}); + +describe('buildCapabilities — write side (gated)', () => { + it('generates create/update/delete only when canWrite is true', () => { + const readOnly = buildCapabilities(new FakeBackend(), [customers], false, 0); + expect(readOnly.capabilities.some((c) => c.spec.access !== 'read')).toBe(false); + + const { capabilities } = buildCapabilities(new FakeBackend(), [customers], true, 0); + const byName = new Map(capabilities.map((c) => [c.spec.name, c.spec])); + expect(byName.get('create_customer')?.access).toBe('write'); + expect(byName.get('update_customer')?.access).toBe('write'); + expect(byName.get('delete_customer')?.access).toBe('destructive'); + }); + + it('requires only non-optional columns on create (id excluded, defaults/nullable optional)', () => { + const { capabilities } = buildCapabilities(new FakeBackend(), [customers], true, 0); + const create = capabilities.find((c) => c.spec.name === 'create_customer')!; + const required = create.spec.input_schema?.['required'] as string[]; + expect(required).toEqual(['email']); // email is unique+notNull; id/name/active are not required + }); +}); + +describe('generated handlers', () => { + const ctx: Context = {}; + + it('call through to the backend', async () => { + const backend = new FakeBackend(); + const { capabilities } = buildCapabilities(backend, [customers], true, 0); + const cap = (name: string) => capabilities.find((c) => c.spec.name === name)!; + + expect(await cap('find_customers').handler(ctx, {})).toEqual([ + { id: 1, email: 'a@b.com', name: 'Ada' }, + ]); + expect(await cap('get_customer_by_email').handler(ctx, { email: 'a@b.com' })).toEqual({ + id: 1, + email: 'a@b.com', + }); + expect(await cap('get_customer_by_email').handler(ctx, { email: 'nope' })).toBeNull(); + expect(await cap('delete_customer').handler(ctx, { email: 'a@b.com' })).toEqual({ + deleted: 'a@b.com', + }); + }); + + it('propagate a not-found error from update/delete (errors-as-data via the loop)', async () => { + const { capabilities } = buildCapabilities(new FakeBackend(), [customers], true, 0); + const update = capabilities.find((c) => c.spec.name === 'update_customer')!; + await expect(update.handler(ctx, { email: 'missing', name: 'X' })).rejects.toThrow( + /no customer/, + ); + }); + + it('drops unknown and null arguments before hitting the backend (§2.6)', async () => { + const { capabilities } = buildCapabilities(new FakeBackend(), [customers], true, 0); + const create = capabilities.find((c) => c.spec.name === 'create_customer')!; + // sneaky/undefined are not real columns / are nullish → filtered out. + const created = await create.handler(ctx, { email: 'a@b.com', sneaky: true, name: null }); + expect(created).toEqual({ email: 'a@b.com' }); + }); +}); + +describe('singularize', () => { + it('handles common plurals', () => { + const cases: Record = { + customers: 'customer', + categories: 'category', + boxes: 'box', + addresses: 'address', + data: 'data', + }; + for (const [input, want] of Object.entries(cases)) { + expect(singularize(input)).toBe(want); + } + }); +}); diff --git a/packages/js/tests/orm-drizzle.test.ts b/packages/js/tests/orm-drizzle.test.ts new file mode 100644 index 0000000..4d7f2e7 --- /dev/null +++ b/packages/js/tests/orm-drizzle.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +import { drizzle } from 'drizzle-orm/libsql'; +import { createClient } from '@libsql/client'; +import { sql } from 'drizzle-orm'; +import { fromDrizzle, introspect } from '../src/orm/index.js'; +import { type Context } from '../src/index.js'; + +const customers = sqliteTable('customers', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull().unique(), + name: text('name'), + active: integer('active').default(1), +}); + +const ctx: Context = {}; + +describe('fromDrizzle — introspection (no database)', () => { + it('shapes the table into intent-level capabilities', () => { + const { capabilities, schema } = fromDrizzle({}, { customers }, { canWrite: true }); + const byName = new Map(capabilities.map((c) => [c.spec.name, c.spec.access])); + + expect(byName.get('find_customers')).toBe('read'); + expect(byName.get('count_customers')).toBe('read'); + expect(byName.get('get_customer_by_email')).toBe('read'); // email is the natural key + expect(byName.get('create_customer')).toBe('write'); + expect(byName.get('update_customer')).toBe('write'); + expect(byName.get('delete_customer')).toBe('destructive'); + + expect(schema.tables?.[0]?.name).toBe('customers'); + }); + + it('classifies the id column as an autoincrementing integer primary key', () => { + const info = introspect(customers); + const id = info.columns.find((c) => c.name === 'id')!; + expect(id).toMatchObject({ jsonType: 'integer', autoincrement: true }); + expect(info.primaryKey?.name).toBe('id'); + const email = info.columns.find((c) => c.name === 'email')!; + expect(email).toMatchObject({ jsonType: 'string', unique: true, nullable: false }); + }); + + it('generates read-only capabilities when canWrite is omitted', () => { + const { capabilities } = fromDrizzle({}, [customers]); + expect(capabilities.every((c) => c.spec.access === 'read')).toBe(true); + }); +}); + +describe('fromDrizzle — end-to-end round-trip (in-memory libsql)', () => { + it('runs the generated handlers against a real database', async () => { + const client = createClient({ url: ':memory:' }); + const db = drizzle(client); + await db.run( + sql`CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT, active INTEGER DEFAULT 1)`, + ); + + const { capabilities } = fromDrizzle(db, { customers }, { canWrite: true }); + const cap = (name: string) => capabilities.find((c) => c.spec.name === name)!; + + await cap('create_customer').handler(ctx, { email: 'a@b.com', name: 'Ada', active: 1 }); + + const got = (await cap('get_customer_by_email').handler(ctx, { email: 'a@b.com' })) as Record< + string, + unknown + >; + expect(got).toMatchObject({ email: 'a@b.com', name: 'Ada' }); + + expect(await cap('count_customers').handler(ctx, {})).toBe(1); + + const found = (await cap('find_customers').handler(ctx, { active: 1 })) as unknown[]; + expect(found).toHaveLength(1); + + const updated = (await cap('update_customer').handler(ctx, { + email: 'a@b.com', + name: 'Ada L.', + })) as Record; + expect(updated).toMatchObject({ name: 'Ada L.' }); + + await cap('delete_customer').handler(ctx, { email: 'a@b.com' }); + expect(await cap('get_customer_by_email').handler(ctx, { email: 'a@b.com' })).toBeNull(); + + client.close(); + }); + + it('surfaces a not-found update as a thrown error', async () => { + const client = createClient({ url: ':memory:' }); + const db = drizzle(client); + await db.run( + sql`CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT, active INTEGER DEFAULT 1)`, + ); + const { capabilities } = fromDrizzle(db, { customers }, { canWrite: true }); + const update = capabilities.find((c) => c.spec.name === 'update_customer')!; + await expect(update.handler(ctx, { email: 'nobody@x.com', name: 'X' })).rejects.toThrow( + /no customer/, + ); + client.close(); + }); +});