From 3c78bd97c1b5313bfee5e4f8ab0f0adf0975b1c4 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Mon, 2 Nov 2026 01:45:55 +0100 Subject: [PATCH] feat: MySQL, SQL Server and SQLite dialects; writes without RETURNING; one null order on every database --- .github/workflows/ci.yml | 11 +- CHANGELOG.md | 1 + README.md | 3 + conformance/fixtures/mssql.sql | 74 ++ conformance/fixtures/mysql.sql | 74 ++ conformance/fixtures/sqlite.sql | 57 ++ conformance/mssql.test.mjs | 31 + conformance/mysql.test.mjs | 36 + conformance/pages.mjs | 33 +- conformance/postgres.test.mjs | 5 +- conformance/sqlite.test.mjs | 28 + docs/dialects.md | 58 +- docs/getting-started.md | 3 + docs/postgrest.md | 2 +- docs/query-grammar.md | 9 + package-lock.json | 1475 +++++++++++++++++++++++++++ package.json | 6 + packages/core/src/catalog.ts | 2 + packages/core/src/dialect.ts | 4 + packages/core/src/read.ts | 7 +- packages/core/src/rest.ts | 2 +- packages/core/src/sql/renderer.ts | 13 + packages/core/src/write.ts | 36 +- packages/core/test/fake-dialect.mjs | 3 +- packages/mssql/package.json | 51 + packages/mssql/src/index.ts | 76 ++ packages/mssql/src/introspect.ts | 75 ++ packages/mssql/src/renderer.ts | 112 ++ packages/mssql/src/types.ts | 16 + packages/mssql/tsconfig.json | 5 + packages/mysql/package.json | 49 + packages/mysql/src/index.ts | 68 ++ packages/mysql/src/introspect.ts | 59 ++ packages/mysql/src/renderer.ts | 67 ++ packages/mysql/src/types.ts | 16 + packages/mysql/tsconfig.json | 5 + packages/postgres/src/index.ts | 3 +- packages/sqlite/package.json | 50 + packages/sqlite/src/index.ts | 69 ++ packages/sqlite/src/introspect.ts | 42 + packages/sqlite/src/renderer.ts | 43 + packages/sqlite/src/types.ts | 17 + packages/sqlite/tsconfig.json | 5 + 43 files changed, 2776 insertions(+), 25 deletions(-) create mode 100644 conformance/fixtures/mssql.sql create mode 100644 conformance/fixtures/mysql.sql create mode 100644 conformance/fixtures/sqlite.sql create mode 100644 conformance/mssql.test.mjs create mode 100644 conformance/mysql.test.mjs create mode 100644 conformance/sqlite.test.mjs create mode 100644 packages/mssql/package.json create mode 100644 packages/mssql/src/index.ts create mode 100644 packages/mssql/src/introspect.ts create mode 100644 packages/mssql/src/renderer.ts create mode 100644 packages/mssql/src/types.ts create mode 100644 packages/mssql/tsconfig.json create mode 100644 packages/mysql/package.json create mode 100644 packages/mysql/src/index.ts create mode 100644 packages/mysql/src/introspect.ts create mode 100644 packages/mysql/src/renderer.ts create mode 100644 packages/mysql/src/types.ts create mode 100644 packages/mysql/tsconfig.json create mode 100644 packages/sqlite/package.json create mode 100644 packages/sqlite/src/index.ts create mode 100644 packages/sqlite/src/introspect.ts create mode 100644 packages/sqlite/src/renderer.ts create mode 100644 packages/sqlite/src/types.ts create mode 100644 packages/sqlite/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7410ea4..f4e2ff6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,6 @@ # Builds every package and runs the unit tests and the conformance suite -# against each database. The organisation only allows its own actions, so +# against Postgres, MySQL and SQLite. SQL Server runs locally only: its +# image needs its licence accepted, which stays the maintainer's decision. The organisation only allows its own actions, so # checkout and Node setup are plain shell; databases are service containers. name: ci @@ -17,6 +18,7 @@ jobs: env: NODE_VERSION: v24.12.0 RESTLOOM_PG_URL: postgres://postgres@localhost:5432/postgres + RESTLOOM_MYSQL_URL: mysql://root@127.0.0.1:3306 services: postgres: image: postgres:17-alpine @@ -25,6 +27,13 @@ jobs: ports: ['5432:5432'] options: >- --health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 2s --health-retries 30 + mysql: + image: mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + ports: ['3306:3306'] + options: >- + --health-cmd "mysqladmin ping -uroot" --health-interval 3s --health-timeout 3s --health-retries 40 steps: - name: Check out run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4aad7..3f9f172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,4 +11,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Writes: create, update and delete by key, bulk by filter where enabled, scope applied, rows read back with `Prefer: return=representation`. - Procedures: database functions at declared paths, arguments checked by name and type. - `@crudy/restloom-postgres`: the Postgres dialect. +- `@crudy/restloom-mysql`, `@crudy/restloom-mssql` and `@crudy/restloom-sqlite`: MySQL 8, SQL Server 2022 and SQLite dialects, each passing the conformance suite. - `@crudy/restloom-fastify` and `@crudy/restloom-express`: framework adapters. diff --git a/README.md b/README.md index 2b0b71e..fec6421 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ Your backend stays in charge: |---|---| | `@crudy/restloom` | The core: grammar, discovery, `createREST`, a Node `http` listener | | `@crudy/restloom-postgres` | PostgreSQL | +| `@crudy/restloom-mysql` | MySQL | +| `@crudy/restloom-mssql` | SQL Server | +| `@crudy/restloom-sqlite` | SQLite | | `@crudy/restloom-fastify` | Fastify plugin | | `@crudy/restloom-express` | Express handler | diff --git a/conformance/fixtures/mssql.sql b/conformance/fixtures/mssql.sql new file mode 100644 index 0000000..904a8cf --- /dev/null +++ b/conformance/fixtures/mssql.sql @@ -0,0 +1,74 @@ +IF OBJECT_ID('demo.assets_tagged') IS NOT NULL DROP FUNCTION demo.assets_tagged; +IF OBJECT_ID('demo.incidents') IS NOT NULL DROP TABLE demo.incidents; +IF OBJECT_ID('demo.asset_tags') IS NOT NULL DROP TABLE demo.asset_tags; +IF OBJECT_ID('demo.tags') IS NOT NULL DROP TABLE demo.tags; +IF OBJECT_ID('demo.assets') IS NOT NULL DROP TABLE demo.assets; +IF OBJECT_ID('demo.users') IS NOT NULL DROP TABLE demo.users; +IF SCHEMA_ID('demo') IS NOT NULL DROP SCHEMA demo; +GO +CREATE SCHEMA demo; +GO +CREATE TABLE demo.users ( + id INT PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + email NVARCHAR(200), + password_hash NVARCHAR(200), + [limit] INT, + manager_id INT REFERENCES demo.users (id) +); + +CREATE TABLE demo.assets ( + id INT PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + status NVARCHAR(20) NOT NULL, + owner_id INT REFERENCES demo.users (id), + cost DECIMAL(10, 2), + active BIT NOT NULL DEFAULT 1, + created_at DATETIMEOFFSET(0) NOT NULL +); + +CREATE TABLE demo.tags ( + id INT PRIMARY KEY, + name NVARCHAR(100) NOT NULL UNIQUE +); + +CREATE TABLE demo.asset_tags ( + asset_id INT NOT NULL REFERENCES demo.assets (id), + tag_id INT NOT NULL REFERENCES demo.tags (id), + PRIMARY KEY (asset_id, tag_id) +); + +CREATE TABLE demo.incidents ( + id INT PRIMARY KEY, + title NVARCHAR(200) NOT NULL, + asset_id INT NOT NULL REFERENCES demo.assets (id), + severity INT NOT NULL, + opened_on DATE NOT NULL +); + +INSERT INTO demo.users (id, name, email, password_hash, [limit], manager_id) VALUES + (1, 'Ada', 'ada@example.com', 'x', 5, NULL), + (2, 'Grace', 'grace@example.com', 'x', NULL, 1), + (3, 'Linus', NULL, 'x', 2, 1); + +INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES + (1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02T10:00:00+00:00'), + (2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05T09:30:00+00:00'), + (3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'), + (4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11T16:45:00+00:00'); + +INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal'); + +INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2); + +INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES + (1, 'VPN outage', 1, 3, '2026-01-10'), + (2, 'Spoofed mail', 2, 2, '2026-02-01'), + (3, 'Brute force', 1, 4, '2026-03-05'); +GO +CREATE FUNCTION demo.assets_tagged(@tag NVARCHAR(100)) RETURNS TABLE AS RETURN ( + SELECT a.* FROM demo.assets a + JOIN demo.asset_tags t ON t.asset_id = a.id + JOIN demo.tags g ON g.id = t.tag_id + WHERE g.name = @tag +); diff --git a/conformance/fixtures/mysql.sql b/conformance/fixtures/mysql.sql new file mode 100644 index 0000000..9334674 --- /dev/null +++ b/conformance/fixtures/mysql.sql @@ -0,0 +1,74 @@ +SET GLOBAL log_bin_trust_function_creators = 1; +DROP DATABASE IF EXISTS demo; +CREATE DATABASE demo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +CREATE TABLE demo.users ( + id INT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(200), + password_hash VARCHAR(200), + `limit` INT, + manager_id INT, + FOREIGN KEY (manager_id) REFERENCES demo.users (id) +); + +CREATE TABLE demo.assets ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL, + owner_id INT, + cost DECIMAL(10, 2), + active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (owner_id) REFERENCES demo.users (id) +); + +CREATE TABLE demo.tags ( + id INT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE +); + +CREATE TABLE demo.asset_tags ( + asset_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (asset_id, tag_id), + FOREIGN KEY (asset_id) REFERENCES demo.assets (id), + FOREIGN KEY (tag_id) REFERENCES demo.tags (id) +); + +CREATE TABLE demo.incidents ( + id INT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + asset_id INT NOT NULL, + severity INT NOT NULL, + opened_on DATE NOT NULL, + FOREIGN KEY (asset_id) REFERENCES demo.assets (id) +); + +SET time_zone = '+00:00'; + +INSERT INTO demo.users (id, name, email, password_hash, `limit`, manager_id) VALUES + (1, 'Ada', 'ada@example.com', 'x', 5, NULL), + (2, 'Grace', 'grace@example.com', 'x', NULL, 1), + (3, 'Linus', NULL, 'x', 2, 1); + +INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES + (1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02 10:00:00'), + (2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05 09:30:00'), + (3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01 00:00:00'), + (4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11 16:45:00'); + +INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal'); + +INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2); + +INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES + (1, 'VPN outage', 1, 3, '2026-01-10'), + (2, 'Spoofed mail', 2, 2, '2026-02-01'), + (3, 'Brute force', 1, 4, '2026-03-05'); + +CREATE FUNCTION demo.retire_asset(asset INT, reason TEXT) RETURNS INT MODIFIES SQL DATA +BEGIN + UPDATE demo.assets SET status = 'retired' WHERE id = asset; + RETURN asset; +END; diff --git a/conformance/fixtures/sqlite.sql b/conformance/fixtures/sqlite.sql new file mode 100644 index 0000000..7b54434 --- /dev/null +++ b/conformance/fixtures/sqlite.sql @@ -0,0 +1,57 @@ +CREATE TABLE demo.users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + password_hash TEXT, + "limit" INTEGER, + manager_id INTEGER REFERENCES users (id) +); + +CREATE TABLE demo.assets ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL, + owner_id INTEGER REFERENCES users (id), + cost NUMERIC, + active BOOLEAN NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL +); + +CREATE TABLE demo.tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE demo.asset_tags ( + asset_id INTEGER NOT NULL REFERENCES assets (id), + tag_id INTEGER NOT NULL REFERENCES tags (id), + PRIMARY KEY (asset_id, tag_id) +); + +CREATE TABLE demo.incidents ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + asset_id INTEGER NOT NULL REFERENCES assets (id), + severity INTEGER NOT NULL, + opened_on DATE NOT NULL +); + +INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VALUES + (1, 'Ada', 'ada@example.com', 'x', 5, NULL), + (2, 'Grace', 'grace@example.com', 'x', NULL, 1), + (3, 'Linus', NULL, 'x', 2, 1); + +INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES + (1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02T10:00:00+00:00'), + (2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05T09:30:00+00:00'), + (3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'), + (4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11T16:45:00+00:00'); + +INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal'); + +INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2); + +INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES + (1, 'VPN outage', 1, 3, '2026-01-10'), + (2, 'Spoofed mail', 2, 2, '2026-02-01'), + (3, 'Brute force', 1, 4, '2026-03-05'); diff --git a/conformance/mssql.test.mjs b/conformance/mssql.test.mjs new file mode 100644 index 0000000..37771ed --- /dev/null +++ b/conformance/mssql.test.mjs @@ -0,0 +1,31 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { createREST } from '../packages/core/dist/index.js'; +import { mssql } from '../packages/mssql/dist/index.js'; +import { examples, run } from './examples.mjs'; +import { PAGES, optionsFor, skipFor } from './pages.mjs'; + +const require = createRequire(new URL('../packages/mssql/package.json', import.meta.url)); +const driver = require('mssql'); +const url = process.env.RESTLOOM_MSSQL_URL; +const batches = readFileSync(new URL('./fixtures/mssql.sql', import.meta.url), 'utf8').split(/^GO$/m); + +let pool; +before(async () => { if (url) pool = await new driver.ConnectionPool(url).connect(); }); +after(async () => { await pool?.close(); }); + +for (const page of PAGES) { + describe(`mssql ${page}`, { skip: !url && 'RESTLOOM_MSSQL_URL is not set' }, () => { + let rest; + before(async () => { + for (const batch of batches) await pool.request().batch(batch); + const db = mssql(pool); + rest = createREST({ db, ...optionsFor(db) }); + }); + for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) { + test(example.name, { skip: skipFor('mssql', example) }, () => run(rest, example, assert)); + } + }); +} diff --git a/conformance/mysql.test.mjs b/conformance/mysql.test.mjs new file mode 100644 index 0000000..fc7ec2d --- /dev/null +++ b/conformance/mysql.test.mjs @@ -0,0 +1,36 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { createREST } from '../packages/core/dist/index.js'; +import { mysql } from '../packages/mysql/dist/index.js'; +import { examples, run } from './examples.mjs'; +import { PAGES, optionsFor, skipFor } from './pages.mjs'; + +const require = createRequire(new URL('../packages/mysql/package.json', import.meta.url)); +const { createPool } = require('mysql2/promise'); +const url = process.env.RESTLOOM_MYSQL_URL; +const fixture = readFileSync(new URL('./fixtures/mysql.sql', import.meta.url), 'utf8'); + +let pool; +let admin; +before(() => { + if (!url) return; + pool = createPool({ uri: url, connectionLimit: 4 }); + admin = createPool({ uri: url, connectionLimit: 1, multipleStatements: true }); +}); +after(async () => { await pool?.end(); await admin?.end(); }); + +for (const page of PAGES) { + describe(`mysql ${page}`, { skip: !url && 'RESTLOOM_MYSQL_URL is not set' }, () => { + let rest; + before(async () => { + await admin.query(fixture); + const db = mysql(pool); + rest = createREST({ db, ...optionsFor(db) }); + }); + for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) { + test(example.name, { skip: skipFor('mysql', example) }, () => run(rest, example, assert)); + } + }); +} diff --git a/conformance/pages.mjs b/conformance/pages.mjs index 2bd2840..0cfb019 100644 --- a/conformance/pages.mjs +++ b/conformance/pages.mjs @@ -1,6 +1,22 @@ /** The docs pages whose examples are conformance cases, and the options every database runs them with. */ export const PAGES = ['query-grammar.md', 'writes.md']; +const PROCEDURES = { + 'assets/{asset}/retire': { function: 'demo.retire_asset' }, + 'tags/{tag}/assets': { function: 'demo.assets_tagged' }, +}; + +/** + * The procedures each dialect's fixture defines: SQLite has no functions, + * MySQL none returning a set, and a SQL Server function cannot change data. + */ +const AVAILABLE = { + postgres: Object.keys(PROCEDURES), + mssql: ['tags/{tag}/assets'], + mysql: ['assets/{asset}/retire'], + sqlite: [], +}; + export const OPTIONS = { expose: { schemas: ['demo'] }, resources: { @@ -9,8 +25,17 @@ export const OPTIONS = { assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } }, incidents: { write: { create: true, update: true, delete: true, bulk: true } }, }, - procedures: { - 'assets/{asset}/retire': { function: 'demo.retire_asset' }, - 'tags/{tag}/assets': { function: 'demo.assets_tagged' }, - }, }; + +export function optionsFor(dialect) { + const procedures = Object.fromEntries(AVAILABLE[dialect.name].map((t) => [t, PROCEDURES[t]])); + return Object.keys(procedures).length ? { ...OPTIONS, procedures } : OPTIONS; +} + +const pattern = (t) => new RegExp(`^/${t.replace(/\{[^}]+\}/g, '[^/]+')}$`); + +/** Why a dialect skips an example, or false. */ +export function skipFor(name, example) { + const missing = Object.keys(PROCEDURES).filter((t) => !AVAILABLE[name].includes(t)); + return missing.some((t) => pattern(t).test(example.path)) && `${name} has no such function`; +} diff --git a/conformance/postgres.test.mjs b/conformance/postgres.test.mjs index 082b677..f1d9286 100644 --- a/conformance/postgres.test.mjs +++ b/conformance/postgres.test.mjs @@ -5,7 +5,7 @@ import { createRequire } from 'node:module'; import { createREST } from '../packages/core/dist/index.js'; import { postgres } from '../packages/postgres/dist/index.js'; import { examples, run } from './examples.mjs'; -import { PAGES, OPTIONS } from './pages.mjs'; +import { PAGES, optionsFor } from './pages.mjs'; const require = createRequire(new URL('../packages/postgres/package.json', import.meta.url)); const { Pool } = require('pg'); @@ -21,7 +21,8 @@ for (const page of PAGES) { let rest; before(async () => { await pool.query(fixture); - rest = createREST({ db: postgres(pool), ...OPTIONS }); + const dialect = postgres(pool); + rest = createREST({ db: dialect, ...optionsFor(dialect) }); }); for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) { test(example.name, () => run(rest, example, assert)); diff --git a/conformance/sqlite.test.mjs b/conformance/sqlite.test.mjs new file mode 100644 index 0000000..3b02596 --- /dev/null +++ b/conformance/sqlite.test.mjs @@ -0,0 +1,28 @@ +import { describe, test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { createREST } from '../packages/core/dist/index.js'; +import { sqlite } from '../packages/sqlite/dist/index.js'; +import { examples, run } from './examples.mjs'; +import { PAGES, optionsFor, skipFor } from './pages.mjs'; + +const require = createRequire(new URL('../packages/sqlite/package.json', import.meta.url)); +const Database = require('better-sqlite3'); +const fixture = readFileSync(new URL('./fixtures/sqlite.sql', import.meta.url), 'utf8'); + +for (const page of PAGES) { + describe(`sqlite ${page}`, () => { + const db = new Database(':memory:'); + const dialect = sqlite(db); + let rest; + before(() => { + db.exec("ATTACH ':memory:' AS demo"); + db.exec(fixture); + rest = createREST({ db: dialect, ...optionsFor(dialect) }); + }); + for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) { + test(example.name, { skip: skipFor('sqlite', example) }, () => run(rest, example, assert)); + } + }); +} diff --git a/docs/dialects.md b/docs/dialects.md index bfed003..7915a71 100644 --- a/docs/dialects.md +++ b/docs/dialects.md @@ -2,9 +2,14 @@ A dialect is the package that knows one database: how to read its catalog, how to write its SQL, how to run a transaction and what its errors mean. -| Package | Database | Embeds | Writes return rows | Estimated count | Row security | -|---|---|---|---|---|---| -| `@crudy/restloom-postgres` | PostgreSQL 13+ | `json_build_object`, `json_agg` | `RETURNING` | planner estimate | yes | +| Package | Database | Embeds | Written keys | Estimated count | Row security | Procedures | +|---|---|---|---|---|---|---| +| `@crudy/restloom-postgres` | PostgreSQL 13+ | `json_build_object`, `json_agg` | `RETURNING` | planner estimate | yes | functions, scalar or set | +| `@crudy/restloom-mysql` | MySQL 8.0+ | `JSON_OBJECT`, `GROUP_CONCAT` | locked and read before writing | no | no | scalar functions | +| `@crudy/restloom-mssql` | SQL Server 2022+ | `JSON_OBJECT`, `STRING_AGG` | `OUTPUT` | no | security policies | scalar and table functions | +| `@crudy/restloom-sqlite` | SQLite 3.44+ (better-sqlite3) | `json_object`, `json_group_array` | `RETURNING` | no | no | none | + +Every dialect passes the same conformance suite, so a request answers the same rows on each. Where they differ, the sections below say how. ## Postgres @@ -19,6 +24,53 @@ const db = postgres(pool); - **Timestamps:** they are written in the session's time zone, as Postgres writes them in JSON. - **Procedures:** calls use named arguments, so the order in the body does not matter. +## MySQL + +```ts +import { createPool } from 'mysql2/promise'; +import { mysql } from '@crudy/restloom-mysql'; + +const db = mysql(createPool({ uri: process.env.DATABASE_URL })); +``` + +- **Schemas:** MySQL's schemas are its databases: `expose: { schemas: ['shop'] }`. +- **Session:** every request runs in UTC. `MAX_EXECUTION_TIME` applies the timeout, which MySQL enforces on reads only. +- **Writes without RETURNING:** an update or delete first locks and reads the keys it will reach, then writes by key. An insert takes its keys from the body, or from `AUTO_INCREMENT` for a single-column key. +- **Text comparison:** text compares with each column's collation, so with a case-insensitive collation `eq` ignores case. `like` is always case-sensitive and `ilike` never is. +- **Timestamps:** written as ISO 8601. A `TIMESTAMP` carries `+00:00`; a `DATETIME`, which has no zone, carries none. +- **Procedures:** stored functions take every argument, in any order in the body; MySQL has none returning a set. + +## SQL Server + +```ts +import sql from 'mssql'; +import { mssql } from '@crudy/restloom-mssql'; + +const db = mssql(await new sql.ConnectionPool(process.env.DATABASE_URL).connect()); +``` + +- **Version:** SQL Server 2022 or later, for `JSON_OBJECT`. +- **Timeout:** the statement timeout is the pool's `requestTimeout`, since the driver sets it per pool, not per request. +- **Text comparison:** as for MySQL, it follows the column's collation. `like` is case-sensitive, `ilike` is not. +- **Timestamps:** written to the second. A `datetimeoffset` is written in UTC with `+00:00`. +- **Row security:** security policies read `SESSION_CONTEXT`; set it in the `transaction` hook with `sp_set_session_context`. +- **Procedures:** scalar and table-valued functions; T-SQL does not expose parameter defaults, so every argument is required. A T-SQL function cannot change data. + +## SQLite + +```ts +import Database from 'better-sqlite3'; +import { sqlite } from '@crudy/restloom-sqlite'; + +const db = sqlite(new Database('app.db')); +``` + +- **Schemas:** the main database is `main`, and an attached database is a schema by its name. +- **Connection settings:** foreign keys and case-sensitive `LIKE` are turned on for the connection. +- **One connection:** requests run one transaction at a time, queued. +- **Limits of SQLite:** it cannot stop a running statement, so `limits.timeoutMs` does not apply. It has no functions to call as procedures. +- **Booleans:** stored as 0 and 1, answered as `true` and `false`. + ## Changing how SQL is written Every piece of SQL comes from one overridable method of the dialect's renderer. To change one, subclass it and pass it in: diff --git a/docs/getting-started.md b/docs/getting-started.md index 4c5e804..9ce883f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,6 +11,9 @@ npm install @crudy/restloom @crudy/restloom-postgres pg | Database | Package | Driver | |---|---|---| | PostgreSQL 13+ | `@crudy/restloom-postgres` | `pg` | +| MySQL 8.0+ | `@crudy/restloom-mysql` | `mysql2` | +| SQL Server 2022+ | `@crudy/restloom-mssql` | `mssql` | +| SQLite 3.44+ | `@crudy/restloom-sqlite` | `better-sqlite3` | ## Serve a schema diff --git a/docs/postgrest.md b/docs/postgrest.md index abd7ba8..ff7d85f 100644 --- a/docs/postgrest.md +++ b/docs/postgrest.md @@ -5,7 +5,7 @@ restloom keeps what makes PostgREST pleasant, the catalog as the source and a qu | | PostgREST | restloom | |---|---|---| | Runs as | a separate server | a library inside your backend | -| Databases | PostgreSQL | PostgreSQL; more through dialects | +| Databases | PostgreSQL | PostgreSQL, MySQL, SQL Server, SQLite | | What is served | everything the role can reach in the schema | what you name in `expose`, minus hidden columns | | Sensitive columns | served unless the role cannot read them | hidden by name unless shown | | Writes | everything the role may write | off until a resource enables them, by column | diff --git a/docs/query-grammar.md b/docs/query-grammar.md index ef0acf0..dc85f71 100644 --- a/docs/query-grammar.md +++ b/docs/query-grammar.md @@ -173,6 +173,15 @@ GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus) - **To-one paths:** sort by a related column: `order=owner.name.desc`. - **To-many relations:** sort by how many related rows each row has: `order=incidents.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values. - **Stable pages:** the key is appended to every order. +- **Nulls:** they sort last ascending and first descending, as in Postgres, on every database. + +```http +GET /assets?select=id,cost&order=cost.desc +``` + +```json +[{ "id": 3, "cost": null }, { "id": 4, "cost": 4000 }, { "id": 1, "cost": 1200.5 }, { "id": 2, "cost": 300 }] +``` ## Pages and counts diff --git a/package-lock.json b/package-lock.json index 64ceb9e..e6c334c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "workspaces": [ "packages/core", "packages/postgres", + "packages/sqlite", + "packages/mysql", + "packages/mssql", "packages/fastify", "packages/express" ], @@ -15,6 +18,275 @@ "node": ">=20" } }, + "node_modules/@azure-rest/core-client": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.9.0.tgz", + "integrity": "sha512-6933vNLqh06RR7rumnrq3UZIeBtRLeBHDPMrieDPIljiCaMMwt0nRw++nd3TOxiohtasNtm29rC+b/9ZVKCOqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.24.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", + "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.7.0.tgz", + "integrity": "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.14.1.tgz", + "integrity": "sha512-HHuuFrmN1uuF9D5A6aAo5eHzNFNX4Q+P4SksZqDKse5YaJFFy/zDLG3wXHEV2FVGgetZ3QM8jGURNBtnQYf1NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^7.1.0", + "open": "^11.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.1.tgz", + "integrity": "sha512-SxpomJRTBfDTQnuii3w10WnZNQJGPNi15tRPdPUQz7BqyB3yk976knEueS7R0H9kSLtXbP0wJZXRh0EbRTQ0lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.2.tgz", + "integrity": "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.1.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.27.0.tgz", + "integrity": "sha512-i2CKKyuCuOZEBZJiAoFK/viYZAP6CRaqqlkeHl3ViteDHOUE/tkI/Slq1cdGnwhkIB+k1OE0g00QGl9O0gAFNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.15.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.15.0.tgz", + "integrity": "sha512-lwdLb1B9Q5nEhPLVIKe78XH5wFMN15fCiDkY7ylPDSWZuWcD93G3jpJajrKhO3CQld8SLKz0sHiOi24mxdvtsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-7.1.2.tgz", + "integrity": "sha512-52hKQLGpEQzwNufIXG1SPVU+wQ3RIe7Rp2+g+VdAmZojCAZ6yCdGEffzQEQ6RnBZGBEs40nl14rWBmfJpCoHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.15.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@crudy/restloom": { "resolved": "packages/core", "link": true @@ -27,10 +299,22 @@ "resolved": "packages/fastify", "link": true }, + "node_modules/@crudy/restloom-mssql": { + "resolved": "packages/mssql", + "link": true + }, + "node_modules/@crudy/restloom-mysql": { + "resolved": "packages/mysql", + "link": true + }, "node_modules/@crudy/restloom-postgres": { "resolved": "packages/postgres", "link": true }, + "node_modules/@crudy/restloom-sqlite": { + "resolved": "packages/sqlite", + "link": true + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.7.tgz", @@ -165,6 +449,13 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@js-joda/core": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-6.3.2.tgz", + "integrity": "sha512-BVsYu84Gut8YTNR1v5/PnifmqXv4SVcfA/hVIM/mXADZfIocip/DfNppBtv+4am5hGH1XuH6FSoU8OHkBvZMeA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -172,6 +463,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@tediousjs/connection-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-1.1.0.tgz", + "integrity": "sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -225,6 +533,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mssql": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-12.3.0.tgz", + "integrity": "sha512-+jy+AJtfuTDI5+nhh0hDNcir1p8P+pf+qsHXpUpYvg7EikxUUePBe+a+Kr6j/Xs89o4EbHlVzrh0HOxbqWM31Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "tarn": "^3.0.1", + "tedious": "*" + } + }, "node_modules/@types/node": { "version": "24.19.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", @@ -261,6 +581,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/readable-stream": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.25.tgz", + "integrity": "sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -282,6 +612,34 @@ "@types/node": "*" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -303,6 +661,16 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -386,6 +754,74 @@ "fastq": "^1.17.1" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", @@ -411,6 +847,54 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -452,6 +936,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/content-disposition": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", @@ -518,6 +1019,75 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -538,6 +1108,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -553,6 +1133,16 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -574,6 +1164,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -624,6 +1224,36 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", @@ -778,6 +1408,13 @@ "reusify": "^1.0.4" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -835,6 +1472,13 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -845,6 +1489,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -884,6 +1538,13 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -944,6 +1605,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -961,6 +1650,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -968,6 +1678,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", @@ -978,6 +1695,54 @@ "node": ">= 10" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -985,6 +1750,36 @@ "dev": true, "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz", @@ -1012,6 +1807,52 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/light-my-request": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.8.0.tgz", @@ -1049,6 +1890,78 @@ "url": "https://opencollective.com/express" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1113,6 +2026,36 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1120,6 +2063,75 @@ "dev": true, "license": "MIT" }, + "node_modules/mssql": { + "version": "12.7.10", + "resolved": "https://registry.npmjs.org/mssql/-/mssql-12.7.10.tgz", + "integrity": "sha512-vTmKBsogfFhd8dMhFjwn2QiPZuUP2/zpqt19L8eEJpQFwRa19bR+n3pFBjRtGdz+YfuC5jgj4YixILSAAF3ovQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^1.0.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "tarn": "^3.0.2", + "tedious": "^19.2.2 || ^20.0.0" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=18.19.0" + } + }, + "node_modules/mysql2": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz", + "integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", @@ -1137,6 +2149,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/node-abi": { + "version": "3.101.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.101.0.tgz", + "integrity": "sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1187,6 +2212,27 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1388,6 +2434,57 @@ "node": ">=0.10.0" } }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-warning": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", @@ -1433,6 +2530,17 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.17.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", @@ -1487,6 +2595,37 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/real-require": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", @@ -1549,6 +2688,40 @@ "node": ">= 18" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", @@ -1756,6 +2929,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -1776,6 +2996,22 @@ "node": ">= 10.x" } }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1786,6 +3022,142 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tarn": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", + "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tedious": { + "version": "20.3.8", + "resolved": "https://registry.npmjs.org/tedious/-/tedious-20.3.8.tgz", + "integrity": "sha512-ZrJiwyGn340Sa0V0g37Xqj5bvmJ1riYwvEtg7zdm/lpw70f2ZNIQ2fvHmntU5fEOorWDa48Jo1oKYAuUP+rh7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.10.1", + "@azure/identity": "^4.13.1", + "@azure/keyvault-keys": "^4.10.2", + "@js-joda/core": "^6.0.1", + "@types/node": ">=22", + "bl": "^6.1.4", + "iconv-lite": "^0.7.0", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/tedious/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/tedious/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/tedious/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -1819,6 +3191,26 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -1869,6 +3261,13 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1886,6 +3285,23 @@ "dev": true, "license": "ISC" }, + "node_modules/wsl-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -1947,6 +3363,45 @@ "fastify": "^5.0.0" } }, + "packages/mssql": { + "name": "@crudy/restloom-mssql", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "devDependencies": { + "@types/mssql": "^12.0.0", + "@types/node": "^24.0.0", + "mssql": "12.7.10", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "mssql": "^12.0.0" + } + }, + "packages/mysql": { + "name": "@crudy/restloom-mysql", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "mysql2": "^3.24.5", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "mysql2": "^3.11.0" + } + }, "packages/postgres": { "name": "@crudy/restloom-postgres", "version": "0.1.0", @@ -1966,6 +3421,26 @@ "peerDependencies": { "pg": "^8.11.0" } + }, + "packages/sqlite": { + "name": "@crudy/restloom-sqlite", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.0", + "@types/node": "^24.0.0", + "better-sqlite3": "^12.11.1", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "better-sqlite3": ">=12" + } } } } diff --git a/package.json b/package.json index a7046f3..62fbaf3 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "workspaces": [ "packages/core", "packages/postgres", + "packages/sqlite", + "packages/mysql", + "packages/mssql", "packages/fastify", "packages/express" ], @@ -15,5 +18,8 @@ "build": "npm run build --workspaces --if-present", "test": "npm run build && node --test 'packages/*/test/*.test.mjs' 'conformance/*.test.mjs'", "check": "npm run build && npm test" + }, + "allowScripts": { + "better-sqlite3@12.11.1": true } } diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index a79ba52..a7ece0c 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -37,6 +37,8 @@ export interface CatalogFunction { name: string; args: { name: string; type: LogicalType; nativeType: string; hasDefault: boolean }[]; returnsSet: boolean; + /** The columns of each returned row, when the dialect can read them. */ + columns?: CatalogColumn[]; } export interface Catalog { diff --git a/packages/core/src/dialect.ts b/packages/core/src/dialect.ts index 15aea07..761853c 100644 --- a/packages/core/src/dialect.ts +++ b/packages/core/src/dialect.ts @@ -15,6 +15,8 @@ export interface Capabilities { estimatedCount: boolean; /** Row security enforced by the database for the session's role. */ rowSecurity: boolean; + /** Database functions can be called as procedures. */ + procedures: boolean; } export interface Dialect { @@ -25,6 +27,8 @@ export interface Dialect { introspect(scope: { schemas: string[]; tables: string[] }): Promise; /** Runs fn in one transaction with a statement timeout; read-only when asked. */ transaction(fn: (tx: Tx) => Promise, options: { timeoutMs: number; readOnly: boolean }): Promise; + /** The JSON value of a statement's `r` column as a JavaScript value: drivers that parse JSON return it as is, others parse text. */ + decode(value: unknown): unknown; /** A database error the client caused, as a RestError; null for anything else, which stays a 500. */ mapError(err: unknown): RestError | null; /** The planner's row estimate for a count query, when estimatedCount is supported. */ diff --git a/packages/core/src/read.ts b/packages/core/src/read.ts index 52dcbbd..33404ef 100644 --- a/packages/core/src/read.ts +++ b/packages/core/src/read.ts @@ -6,11 +6,6 @@ import type { Filter, ReadPlan } from './plan.js'; import type { Resource } from './resources.js'; import { typed } from './grammar/values.js'; -function decode(row: Record): unknown { - const r = row.r; - return typeof r === 'string' ? JSON.parse(r) : r; -} - async function total(db: Dialect, tx: Tx, plan: ReadPlan, scope: (r: Resource) => Filter[]): Promise { if (plan.count === 'none') return null; const stmt = db.renderer.count(plan, db.renderer.context(scope)); @@ -42,7 +37,7 @@ export async function readRows(db: Dialect, tx: Tx, resource: Resource, request: plan.count = 'none'; } const stmt = db.renderer.read(plan, db.renderer.context(scope)); - const rows = (await tx.query(stmt.sql, stmt.params)).map(decode); + const rows = (await tx.query(stmt.sql, stmt.params)).map((row) => db.decode(row.r)); if (id !== undefined) { if (rows.length === 0) throw notFound(); return { status: 200, headers: {}, body: rows[0] }; diff --git a/packages/core/src/rest.ts b/packages/core/src/rest.ts index 1464b94..f493dee 100644 --- a/packages/core/src/rest.ts +++ b/packages/core/src/rest.ts @@ -67,7 +67,7 @@ export function createREST(options: RestOptions): Rest { const args = procedureArgs(call.procedure.fn, call.values, request.body); return inTransaction(request, false, async (tx) => { const stmt = db.renderer.call(call.procedure.fn, args, db.renderer.context(scope)); - const rows = (await tx.query(stmt.sql, stmt.params)).map((r) => (typeof r.r === 'string' ? JSON.parse(r.r) : r.r)); + const rows = (await tx.query(stmt.sql, stmt.params)).map((row) => db.decode(row.r)); return { status: 200, headers: {}, body: call.procedure.fn.returnsSet ? rows : rows[0] ?? null }; }); } diff --git a/packages/core/src/sql/renderer.ts b/packages/core/src/sql/renderer.ts index d968197..954cb77 100644 --- a/packages/core/src/sql/renderer.ts +++ b/packages/core/src/sql/renderer.ts @@ -22,6 +22,19 @@ export class SqlRenderer extends SqlReader { return ctx.params.finalize(sql, (i) => this.placeholder(i)); } + /** For databases without RETURNING: the keys a write will reach, locked until the transaction ends. */ + selectKeys(resource: Resource, where: Filter[], ctx: RenderContext): Statement { + const alias = ctx.alias(); + const conditions = this.filters(resource, alias, where, ctx); + const sql = `SELECT ${resource.key.map((k) => `${this.column(alias, k)} AS ${this.quote(k)}`).join(', ')} FROM ${this.table(resource.schema, resource.table)} ${alias}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} FOR UPDATE`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } + + /** For databases without RETURNING: the first generated key of the last insert, as `id`. */ + lastInsertId(): string { + return 'SELECT LAST_INSERT_ID() AS id'; + } + writeTarget(resource: Resource, alias: string): string { return `${this.table(resource.schema, resource.table)} AS ${alias}`; } diff --git a/packages/core/src/write.ts b/packages/core/src/write.ts index e423407..000e568 100644 --- a/packages/core/src/write.ts +++ b/packages/core/src/write.ts @@ -47,6 +47,34 @@ async function readBack(c: WriteContext, keys: Record[], status return { status, headers: {}, body: read.body }; } +/** + * Runs an update or delete and returns the keys it reached. A database + * without RETURNING locks and reads the keys first, then writes by key, which + * also keeps it from reading the table it is changing. + */ +async function writeByKeys(c: WriteContext, where: Filter[], write: (where: Filter[]) => { sql: string; params: unknown[] }): Promise[]> { + if (c.db.capabilities.returning) { + const stmt = write(where); + return c.tx.query(stmt.sql, stmt.params); + } + const sel = c.db.renderer.selectKeys(c.resource, where, c.db.renderer.context(c.scope)); + const keys = await c.tx.query(sel.sql, sel.params); + if (keys.length === 0) return keys; + const stmt = write([keysFilter(c.resource, keys)]); + await c.tx.query(stmt.sql, stmt.params); + return keys; +} + +/** The keys of inserted rows: returned by the database, given in the body, or generated in sequence. */ +async function insertedKeys(c: WriteContext, returned: Record[], columns: string[], values: unknown[][]): Promise[]> { + if (c.db.capabilities.returning) return returned; + const key = c.resource.key; + if (key.every((k) => columns.includes(k))) return values.map((row) => Object.fromEntries(key.map((k) => [k, row[columns.indexOf(k)]]))); + if (key.length !== 1) return []; + const [first] = await c.tx.query(c.db.renderer.lastInsertId(), []); + return values.map((_, i) => ({ [key[0]]: Number(first.id) + i })); +} + /** The filters a PATCH or DELETE applies: the id, or the query's filters on a bulk-enabled resource. */ function target(c: WriteContext, id: string | undefined): Filter[] { if (id !== undefined) return [keyFilter(c.resource, id)]; @@ -66,7 +94,7 @@ export async function create(c: WriteContext): Promise { return scope.has(col) ? scope.get(col) : row.get(col); })); const stmt = c.db.renderer.insert(c.resource, columns, values, c.db.renderer.context(c.scope)); - const keys = await c.tx.query(stmt.sql, stmt.params); + const keys = await insertedKeys(c, await c.tx.query(stmt.sql, stmt.params), columns, values); return wantsRows(c.request) ? readBack(c, keys, 201) : { status: 201, headers: {}, body: undefined }; } @@ -76,8 +104,7 @@ export async function update(c: WriteContext, id: string | undefined): Promise c.db.renderer.update(c.resource, [...row], w, c.db.renderer.context(c.scope))); if (id !== undefined && keys.length === 0) throw notFound(); if (!wantsRows(c.request)) return { status: 204, headers: {}, body: undefined }; const back = await readBack(c, keys, 200); @@ -90,8 +117,7 @@ export async function remove(c: WriteContext, id: string | undefined): Promise c.db.renderer.delete(c.resource, w, c.db.renderer.context(c.scope))); if (id !== undefined && keys.length === 0) throw notFound(); if (!before) return { status: 204, headers: {}, body: undefined }; return { status: 200, headers: {}, body: id !== undefined ? (before.body as unknown[])[0] : before.body }; diff --git a/packages/core/test/fake-dialect.mjs b/packages/core/test/fake-dialect.mjs index b5068ae..2cd827f 100644 --- a/packages/core/test/fake-dialect.mjs +++ b/packages/core/test/fake-dialect.mjs @@ -6,7 +6,8 @@ export function fakeDialect(rows = [{ id: 1, name: 'one' }]) { return { statements, name: 'fake', - capabilities: { returning: true, estimatedCount: false, rowSecurity: false }, + capabilities: { returning: true, estimatedCount: false, rowSecurity: false, procedures: false }, + decode: (v) => v, renderer: new SqlRenderer(), async introspect() { const col = (name, type) => ({ name, type, nativeType: type, nullable: false, generated: false }); diff --git a/packages/mssql/package.json b/packages/mssql/package.json new file mode 100644 index 0000000..36e3bbf --- /dev/null +++ b/packages/mssql/package.json @@ -0,0 +1,51 @@ +{ + "name": "@crudy/restloom-mssql", + "version": "0.1.0", + "type": "module", + "description": "SQL Server dialect for restloom", + "keywords": [ + "restloom", + "mssql", + "sql-server", + "rest", + "api" + ], + "author": "Marc Villain", + "homepage": "https://restloom.crudy.fr", + "repository": { + "type": "git", + "url": "git+https://github.com/crudylabs/restloom.git", + "directory": "packages/mssql" + }, + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p .", + "prepack": "npm run build" + }, + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "peerDependencies": { + "mssql": "^12.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/mssql": "^12.0.0", + "mssql": "12.7.10", + "typescript": "^5.9.0" + } +} diff --git a/packages/mssql/src/index.ts b/packages/mssql/src/index.ts new file mode 100644 index 0000000..8f64a79 --- /dev/null +++ b/packages/mssql/src/index.ts @@ -0,0 +1,76 @@ +import { RestError, type Dialect, type SqlRenderer } from '@crudy/restloom'; +import mssqlDriver, { type ConnectionPool } from 'mssql'; +import { introspect } from './introspect.js'; +import { MssqlRenderer } from './renderer.js'; + +export { MssqlRenderer } from './renderer.js'; + +const CODES: Record = { + 2627: [409, 'duplicate'], + 2601: [409, 'duplicate'], + 515: [400, 'value_required'], + 245: [400, 'invalid_value'], + 241: [400, 'invalid_value'], + 242: [400, 'invalid_value'], + 8114: [400, 'invalid_value'], + 8115: [400, 'invalid_value'], + 2628: [400, 'invalid_value'], + 229: [403, 'forbidden'], + 230: [403, 'forbidden'], +}; + +/** Error 547 covers both foreign keys and checks; its message tells which. */ +export function mapError(err: unknown): RestError | null { + const e = err as { number?: unknown; code?: unknown; message?: unknown; originalError?: { info?: { number?: unknown; message?: unknown } } }; + if (e?.code === 'ETIMEOUT') return new RestError(503, 'timeout'); + const number = e?.number ?? e?.originalError?.info?.number; + if (number === 547) { + const text = String(e.message ?? e.originalError?.info?.message ?? ''); + return /CHECK constraint/i.test(text) ? new RestError(400, 'value_rejected') : new RestError(409, 'reference_missing'); + } + const hit = typeof number === 'number' ? CODES[number] : undefined; + return hit ? new RestError(hit[0], hit[1]) : null; +} + +export interface MssqlOptions { + renderer?: SqlRenderer; +} + +/** + * The SQL Server 2022 dialect over an mssql ConnectionPool. Each request is one + * transaction; the statement timeout is the pool's `requestTimeout`, since the + * driver sets it per pool. Row security is SQL Server's security policies, + * which a `transaction` hook feeds through sp_set_session_context. + */ +export function mssql(pool: ConnectionPool, options: MssqlOptions = {}): Dialect { + return { + name: 'mssql', + capabilities: { returning: true, estimatedCount: false, rowSecurity: true, procedures: true }, + renderer: options.renderer ?? new MssqlRenderer(), + decode: (value) => (typeof value === 'string' ? JSON.parse(value) : value), + introspect: (scope) => introspect(pool, scope), + async transaction(fn) { + const tx = new mssqlDriver.Transaction(pool); + await tx.begin(); + try { + const out = await fn({ + async query(sql, params) { + const req = new mssqlDriver.Request(tx); + params.forEach((v, i) => { + // Whole numbers beyond 32 bits go as BigInt, or the driver overflows them as Int. + if (typeof v === 'number' && Number.isInteger(v) && Math.abs(v) > 2147483647) req.input(`p${i + 1}`, mssqlDriver.BigInt, v); + else req.input(`p${i + 1}`, v); + }); + return ((await req.query(sql)).recordset ?? []) as Record[]; + }, + }); + await tx.commit(); + return out; + } catch (err) { + await tx.rollback().catch(() => undefined); + throw err; + } + }, + mapError, + }; +} diff --git a/packages/mssql/src/introspect.ts b/packages/mssql/src/introspect.ts new file mode 100644 index 0000000..ea71b3f --- /dev/null +++ b/packages/mssql/src/introspect.ts @@ -0,0 +1,75 @@ +import type { Catalog, CatalogTable } from '@crudy/restloom'; +import type { ConnectionPool } from 'mssql'; +import { logical } from './types.js'; + +type Row = Record; + +const OBJECTS = ` +SELECT o.object_id AS id, SCHEMA_NAME(o.schema_id) AS s, o.name AS n, RTRIM(o.type) AS t + FROM sys.objects o + WHERE o.type IN ('U', 'V', 'FN', 'IF', 'TF') + AND (SCHEMA_NAME(o.schema_id) IN (SELECT value FROM STRING_SPLIT(@schemas, ',')) + OR (o.type IN ('U', 'V') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@tables, ','))))`; + +const COLUMNS = ` +SELECT c.object_id AS id, c.name, TYPE_NAME(c.user_type_id) AS type, c.is_nullable AS nullable, + CAST(CASE WHEN c.is_computed = 1 OR c.is_identity = 1 THEN 1 ELSE 0 END AS bit) AS generated + FROM sys.columns c WHERE c.object_id IN (SELECT value FROM STRING_SPLIT(@ids, ',')) + ORDER BY c.object_id, c.column_id`; + +const PRIMARY_KEYS = ` +SELECT i.object_id AS id, c.name FROM sys.indexes i + JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id + JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id + WHERE i.is_primary_key = 1 AND i.object_id IN (SELECT value FROM STRING_SPLIT(@ids, ',')) + ORDER BY i.object_id, ic.key_ordinal`; + +const FOREIGN_KEYS = ` +SELECT fk.object_id AS fk, fk.parent_object_id AS id, pc.name AS col, SCHEMA_NAME(t.schema_id) AS ts, t.name AS tt, rc.name AS rcol + FROM sys.foreign_keys fk + JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id + JOIN sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id + JOIN sys.columns rc ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id + JOIN sys.tables t ON t.object_id = fk.referenced_object_id + WHERE fk.parent_object_id IN (SELECT value FROM STRING_SPLIT(@ids, ',')) + ORDER BY fk.object_id, fkc.constraint_column_id`; + +const PARAMETERS = ` +SELECT p.object_id AS id, SUBSTRING(p.name, 2, 200) AS name, TYPE_NAME(p.user_type_id) AS type + FROM sys.parameters p WHERE p.parameter_id > 0 AND p.object_id IN (SELECT value FROM STRING_SPLIT(@ids, ',')) + ORDER BY p.object_id, p.parameter_id`; + +/** Reads tables, views and functions from the catalog views. T-SQL does not expose parameter defaults, so every argument is required. */ +export async function introspect(pool: ConnectionPool, scope: { schemas: string[]; tables: string[] }): Promise { + const run = async (sql: string, params: Record) => { + const req = pool.request(); + for (const [k, v] of Object.entries(params)) req.input(k, v); + return (await req.query(sql)).recordset as Row[]; + }; + const objects = await run(OBJECTS, { schemas: scope.schemas.join(','), tables: scope.tables.join(',') }); + const ids = { ids: objects.map((o) => String(o.id)).join(',') }; + const [columns, keys, fks, params] = await Promise.all([run(COLUMNS, ids), run(PRIMARY_KEYS, ids), run(FOREIGN_KEYS, ids), run(PARAMETERS, ids)]); + const columnsOf = (id: unknown) => columns.filter((c) => c.id === id).map((c) => ({ + name: c.name as string, nativeType: c.type as string, type: logical(c.type as string), nullable: !!c.nullable, generated: !!c.generated, + })); + const tables = new Map(); + for (const o of objects) { + if (o.t !== 'U' && o.t !== 'V') continue; + tables.set(o.id as number, { schema: o.s as string, name: o.n as string, kind: o.t === 'V' ? 'view' : 'table', columns: [], primaryKey: [], foreignKeys: [] }); + } + for (const [id, table] of tables) table.columns = columnsOf(id); + for (const k of keys) tables.get(k.id as number)?.primaryKey.push(k.name as string); + const byFk = new Map(); + for (const f of fks) byFk.set(f.fk as number, [...(byFk.get(f.fk as number) ?? []), f]); + for (const parts of byFk.values()) { + tables.get(parts[0].id as number)?.foreignKeys.push({ + columns: parts.map((p) => p.col as string), targetColumns: parts.map((p) => p.rcol as string), + target: { schema: parts[0].ts as string, table: parts[0].tt as string }, + }); + } + const functions = objects.filter((o) => o.t === 'FN' || o.t === 'IF' || o.t === 'TF').map((o) => ({ + schema: o.s as string, name: o.n as string, returnsSet: o.t !== 'FN', columns: o.t === 'FN' ? undefined : columnsOf(o.id), + args: params.filter((p) => p.id === o.id).map((p) => ({ name: p.name as string, type: logical(p.type as string), nativeType: p.type as string, hasDefault: false })), + })); + return { tables: [...tables.values()], functions }; +} diff --git a/packages/mssql/src/renderer.ts b/packages/mssql/src/renderer.ts new file mode 100644 index 0000000..a0ef242 --- /dev/null +++ b/packages/mssql/src/renderer.ts @@ -0,0 +1,112 @@ +import { SqlRenderer, type CatalogColumn, type CatalogFunction, type Filter, type OrderTerm, type RenderContext, type Resource, type Statement } from '@crudy/restloom'; + +/** + * SQL Server 2022: bracketed names, @p placeholders, JSON_OBJECT for rows and + * STRING_AGG for arrays (JSON_ARRAYAGG is not in 2022), OFFSET/FETCH paging, + * OUTPUT in place of RETURNING. + */ +export class MssqlRenderer extends SqlRenderer { + quote(identifier: string): string { + return `[${identifier.replace(/]/g, ']]')}]`; + } + + literal(text: string): string { + return `N'${text.replace(/'/g, "''")}'`; + } + + placeholder(index: number): string { + return `@p${index}`; + } + + jsonObject(pairs: [string, string][]): string { + return `JSON_OBJECT(${pairs.map(([k, v]) => `${this.literal(k)}: ${v}`).join(', ')} NULL ON NULL)`; + } + + jsonArray(rowExpr: string, orderExpr: string): string { + return `COALESCE(N'[' + STRING_AGG(CAST(${rowExpr} AS nvarchar(max)), N',') WITHIN GROUP (ORDER BY ${orderExpr}) + N']', N'[]')`; + } + + nested(expr: string): string { + return `JSON_QUERY(${expr})`; + } + + jsonValue(expr: string, column: CatalogColumn): string { + if (column.type === 'boolean') return `CAST(${expr} AS bit)`; + if (column.nativeType === 'datetimeoffset') return `CONVERT(varchar(19), SWITCHOFFSET(${expr}, '+00:00'), 126) + '+00:00'`; + if (column.type === 'timestamp') return `CONVERT(varchar(19), ${expr}, 126)`; + if (column.type === 'date') return `CONVERT(varchar(10), ${expr}, 23)`; + return expr; + } + + like(expr: string, pattern: string, insensitive: boolean): string { + return insensitive + ? `LOWER(${expr}) LIKE LOWER(${pattern}) ESCAPE '\\'` + : `${expr} COLLATE Latin1_General_100_CS_AS LIKE ${pattern} ESCAPE '\\'`; + } + + isBoolean(expr: string, value: boolean): string { + return `${expr} = ${value ? 1 : 0}`; + } + + /** SQL Server sorts nulls first ascending; this keeps the Postgres order: last ascending, first descending. */ + orderBy(expr: string, desc: boolean, nulls?: 'first' | 'last'): string { + const first = (nulls ?? (desc ? 'first' : 'last')) === 'first'; + return `CASE WHEN ${expr} IS NULL THEN ${first ? 0 : 1} ELSE ${first ? 1 : 0} END, ${expr} ${desc ? 'DESC' : 'ASC'}`; + } + + /** OFFSET/FETCH needs an ORDER BY, so an unordered read orders by nothing in particular. */ + order(terms: OrderTerm[], alias: string, ctx: RenderContext): string { + return super.order(terms, alias, ctx) || '(SELECT NULL)'; + } + + page(limit: number | undefined, offset: number | undefined): string { + if (limit === undefined && !offset) return ''; + return ` OFFSET ${offset ?? 0} ROWS${limit !== undefined ? ` FETCH NEXT ${limit} ROWS ONLY` : ''}`; + } + + first(selectSql: string): string { + return selectSql.replace(/^SELECT /, 'SELECT TOP 1 '); + } + + aggregate(fn: Parameters[0], expr: string | null): string { + return fn === 'count' ? 'COUNT_BIG(*)' : super.aggregate(fn, expr); + } + + private output(resource: Resource, side: 'INSERTED' | 'DELETED'): string { + return ` OUTPUT ${resource.key.map((k) => `${side}.${this.quote(k)}`).join(', ')}`; + } + + insert(resource: Resource, columns: string[], rows: unknown[][], ctx: RenderContext): Statement { + const values = rows.map((row) => `(${row.map((v) => ctx.params.add(v)).join(', ')})`).join(', '); + const sql = `INSERT INTO ${this.table(resource.schema, resource.table)} (${columns.map((c) => this.quote(c)).join(', ')})${this.output(resource, 'INSERTED')} VALUES ${values}`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } + + update(resource: Resource, set: [string, unknown][], where: Filter[], ctx: RenderContext): Statement { + const alias = ctx.alias(); + const assignments = set.map(([c, v]) => `${this.column(alias, c)} = ${ctx.params.add(v)}`).join(', '); + const conditions = this.filters(resource, alias, where, ctx); + const sql = `UPDATE ${alias} SET ${assignments}${this.output(resource, 'INSERTED')} FROM ${this.table(resource.schema, resource.table)} ${alias}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } + + delete(resource: Resource, where: Filter[], ctx: RenderContext): Statement { + const alias = ctx.alias(); + const conditions = this.filters(resource, alias, where, ctx); + const sql = `DELETE ${alias}${this.output(resource, 'DELETED')} FROM ${this.table(resource.schema, resource.table)} ${alias}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } + + /** T-SQL calls by position. A scalar comes back as JSON text through JSON_ARRAY, a table function row by row. */ + call(fn: CatalogFunction, args: [string, unknown][], ctx: RenderContext): Statement { + const byName = new Map(args); + const call = `${this.table(fn.schema, fn.name)}(${fn.args.map((a) => ctx.params.add(byName.get(a.name))).join(', ')})`; + const row = fn.columns?.length + ? this.jsonObject(fn.columns.map((c) => [c.name, this.jsonValue(this.column('x', c.name), c)])) + : '(SELECT x.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUES)'; + const sql = fn.returnsSet + ? `SELECT ${row} AS r FROM ${call} x` + : `SELECT SUBSTRING(j, 2, LEN(j + N'x') - 3) AS r FROM (SELECT JSON_ARRAY(${call} NULL ON NULL) AS j) v`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } +} diff --git a/packages/mssql/src/types.ts b/packages/mssql/src/types.ts new file mode 100644 index 0000000..259b152 --- /dev/null +++ b/packages/mssql/src/types.ts @@ -0,0 +1,16 @@ +import type { LogicalType } from '@crudy/restloom'; + +/** A SQL Server type name as a logical type. JSON lives in text columns, so it reads as text. */ +export function logical(type: string): LogicalType { + const t = type.toLowerCase(); + if (['tinyint', 'smallint', 'int', 'bigint'].includes(t)) return 'integer'; + if (['decimal', 'numeric', 'money', 'smallmoney', 'float', 'real'].includes(t)) return 'number'; + if (t === 'bit') return 'boolean'; + if (t === 'uniqueidentifier') return 'uuid'; + if (t === 'date') return 'date'; + if (['datetime', 'datetime2', 'smalldatetime', 'datetimeoffset'].includes(t)) return 'timestamp'; + if (t === 'time') return 'time'; + if (['char', 'varchar', 'nchar', 'nvarchar', 'text', 'ntext', 'sysname'].includes(t)) return 'text'; + if (['binary', 'varbinary', 'image', 'rowversion', 'timestamp'].includes(t)) return 'binary'; + return 'other'; +} diff --git a/packages/mssql/tsconfig.json b/packages/mssql/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/mssql/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/mysql/package.json b/packages/mysql/package.json new file mode 100644 index 0000000..b5ffafc --- /dev/null +++ b/packages/mysql/package.json @@ -0,0 +1,49 @@ +{ + "name": "@crudy/restloom-mysql", + "version": "0.1.0", + "type": "module", + "description": "MySQL dialect for restloom", + "keywords": [ + "restloom", + "mysql", + "rest", + "api" + ], + "author": "Marc Villain", + "homepage": "https://restloom.crudy.fr", + "repository": { + "type": "git", + "url": "git+https://github.com/crudylabs/restloom.git", + "directory": "packages/mysql" + }, + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p .", + "prepack": "npm run build" + }, + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "peerDependencies": { + "mysql2": "^3.11.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "mysql2": "^3.24.5", + "typescript": "^5.9.0" + } +} diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts new file mode 100644 index 0000000..b841c0a --- /dev/null +++ b/packages/mysql/src/index.ts @@ -0,0 +1,68 @@ +import { RestError, type Dialect, type SqlRenderer } from '@crudy/restloom'; +import type { Pool } from 'mysql2/promise'; +import { introspect } from './introspect.js'; +import { MysqlRenderer } from './renderer.js'; + +export { MysqlRenderer } from './renderer.js'; + +const CODES: Record = { + 1062: [409, 'duplicate'], + 1452: [409, 'reference_missing'], + 1451: [409, 'still_referenced'], + 1048: [400, 'value_required'], + 1364: [400, 'value_required'], + 3819: [400, 'value_rejected'], + 1292: [400, 'invalid_value'], + 1264: [400, 'invalid_value'], + 1366: [400, 'invalid_value'], + 1406: [400, 'invalid_value'], + 1142: [403, 'forbidden'], + 3024: [503, 'timeout'], +}; + +export function mapError(err: unknown): RestError | null { + const errno = (err as { errno?: unknown })?.errno; + const hit = typeof errno === 'number' ? CODES[errno] : undefined; + return hit ? new RestError(hit[0], hit[1]) : null; +} + +export interface MysqlOptions { + renderer?: SqlRenderer; +} + +/** + * The MySQL 8 dialect over a mysql2/promise Pool. Each request is one + * transaction on one connection, in UTC, with MAX_EXECUTION_TIME for reads. + * Text compares with each column's collation, so with a case-insensitive + * collation `eq` ignores case. + */ +export function mysql(pool: Pool, options: MysqlOptions = {}): Dialect { + return { + name: 'mysql', + capabilities: { returning: false, estimatedCount: false, rowSecurity: false, procedures: true }, + renderer: options.renderer ?? new MysqlRenderer(), + decode: (value) => value, + introspect: (scope) => introspect(pool, scope), + async transaction(fn, { timeoutMs, readOnly }) { + const conn = await pool.getConnection(); + try { + await conn.query(`SET SESSION time_zone = '+00:00', SESSION group_concat_max_len = 1073741824, SESSION MAX_EXECUTION_TIME = ${Math.max(1, Math.floor(timeoutMs))}`); + await conn.query(readOnly ? 'START TRANSACTION READ ONLY' : 'START TRANSACTION'); + const out = await fn({ + async query(sql, params) { + const [rows] = await conn.query(sql, params); + return Array.isArray(rows) ? (rows as Record[]) : []; + }, + }); + await conn.query('COMMIT'); + return out; + } catch (err) { + await conn.query('ROLLBACK').catch(() => undefined); + throw err; + } finally { + conn.release(); + } + }, + mapError, + }; +} diff --git a/packages/mysql/src/introspect.ts b/packages/mysql/src/introspect.ts new file mode 100644 index 0000000..c193fb9 --- /dev/null +++ b/packages/mysql/src/introspect.ts @@ -0,0 +1,59 @@ +import type { Catalog, CatalogTable } from '@crudy/restloom'; +import type { Pool } from 'mysql2/promise'; +import { logical } from './types.js'; + +type Row = Record; + +const IN_SCOPE = '(t.TABLE_SCHEMA IN (?) OR CONCAT(t.TABLE_SCHEMA, \'.\', t.TABLE_NAME) IN (?))'; + +/** Reads tables, views, columns, keys and functions from information_schema. MySQL's schemas are its databases. */ +export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[] }): Promise { + const schemas = scope.schemas.length ? scope.schemas : ['']; + const tablesIn = scope.tables.length ? scope.tables : ['']; + const q = async (sql: string, params: unknown[]) => (await pool.query(sql, params))[0] as Row[]; + const [tables, columns, keys, params, functions] = await Promise.all([ + q(`SELECT TABLE_SCHEMA AS s, TABLE_NAME AS n, TABLE_TYPE AS k FROM information_schema.TABLES t WHERE ${IN_SCOPE}`, [schemas, tablesIn]), + q(`SELECT c.TABLE_SCHEMA AS s, c.TABLE_NAME AS n, c.COLUMN_NAME AS name, c.DATA_TYPE AS dt, c.COLUMN_TYPE AS ct, c.IS_NULLABLE AS nullable, c.EXTRA AS extra + FROM information_schema.COLUMNS c JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME + WHERE ${IN_SCOPE} ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION`, [schemas, tablesIn]), + q(`SELECT k.TABLE_SCHEMA AS s, k.TABLE_NAME AS n, k.CONSTRAINT_NAME AS c, k.COLUMN_NAME AS col, + k.REFERENCED_TABLE_SCHEMA AS rs, k.REFERENCED_TABLE_NAME AS rt, k.REFERENCED_COLUMN_NAME AS rc + FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = k.TABLE_SCHEMA AND t.TABLE_NAME = k.TABLE_NAME + WHERE ${IN_SCOPE} AND (k.CONSTRAINT_NAME = 'PRIMARY' OR k.REFERENCED_TABLE_NAME IS NOT NULL) + ORDER BY k.CONSTRAINT_NAME, k.ORDINAL_POSITION`, [schemas, tablesIn]), + q(`SELECT SPECIFIC_SCHEMA AS s, SPECIFIC_NAME AS n, PARAMETER_NAME AS name, DATA_TYPE AS dt, DTD_IDENTIFIER AS ct + FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA IN (?) AND ORDINAL_POSITION > 0 ORDER BY ORDINAL_POSITION`, [schemas]), + q(`SELECT ROUTINE_SCHEMA AS s, ROUTINE_NAME AS n FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA IN (?) AND ROUTINE_TYPE = 'FUNCTION'`, [schemas]), + ]); + const byName = new Map(); + for (const t of tables) { + byName.set(`${t.s}.${t.n}`, { schema: t.s as string, name: t.n as string, kind: t.k === 'VIEW' ? 'view' : 'table', columns: [], primaryKey: [], foreignKeys: [] }); + } + for (const c of columns) { + byName.get(`${c.s}.${c.n}`)?.columns.push({ + name: c.name as string, nativeType: c.ct as string, type: logical(c.dt as string, c.ct as string), + nullable: c.nullable === 'YES', generated: /GENERATED/i.test(c.extra as string), + }); + } + const fks = new Map(); + for (const k of keys) { + const table = byName.get(`${k.s}.${k.n}`); + if (!table) continue; + if (k.c === 'PRIMARY') { table.primaryKey.push(k.col as string); continue; } + const id = `${k.s}.${k.n}.${k.c}`; + const fk = fks.get(id) ?? { table, columns: [], targetColumns: [], target: { schema: k.rs as string, table: k.rt as string } }; + fk.columns.push(k.col as string); + fk.targetColumns.push(k.rc as string); + fks.set(id, fk); + } + for (const { table, ...fk } of fks.values()) table.foreignKeys.push(fk); + return { + tables: [...byName.values()], + functions: functions.map((f) => ({ + schema: f.s as string, name: f.n as string, returnsSet: false, + args: params.filter((p) => p.s === f.s && p.n === f.n).map((p) => ({ + name: p.name as string, type: logical(p.dt as string, p.ct as string), nativeType: p.ct as string, hasDefault: false, + })), + })), + }; +} diff --git a/packages/mysql/src/renderer.ts b/packages/mysql/src/renderer.ts new file mode 100644 index 0000000..ac35366 --- /dev/null +++ b/packages/mysql/src/renderer.ts @@ -0,0 +1,67 @@ +import { SqlRenderer, type CatalogColumn, type CatalogFunction, type RenderContext, type Statement } from '@crudy/restloom'; + +/** + * MySQL 8: backquoted names, positional placeholders, no RETURNING. Arrays are + * built with GROUP_CONCAT, which keeps an order where JSON_ARRAYAGG does not. + */ +export class MysqlRenderer extends SqlRenderer { + quote(identifier: string): string { + return `\`${identifier.replace(/`/g, '``')}\``; + } + + literal(text: string): string { + return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; + } + + placeholder(_index: number): string { + return '?'; + } + + jsonObject(pairs: [string, string][]): string { + return `JSON_OBJECT(${pairs.map(([k, v]) => `${this.literal(k)}, ${v}`).join(', ')})`; + } + + jsonArray(rowExpr: string, orderExpr: string): string { + return `COALESCE(CAST(CONCAT('[', GROUP_CONCAT(${rowExpr} ORDER BY ${orderExpr} SEPARATOR ','), ']') AS JSON), JSON_ARRAY())`; + } + + nested(expr: string): string { + return `CAST(${expr} AS JSON)`; + } + + jsonValue(expr: string, column: CatalogColumn): string { + if (column.type === 'boolean') return `IF(${expr} IS NULL, NULL, CAST(IF(${expr}, 'true', 'false') AS JSON))`; + if (column.type === 'timestamp') return `DATE_FORMAT(${expr}, '%Y-%m-%dT%H:%i:%s${/^timestamp/i.test(column.nativeType) ? '+00:00' : ''}')`; + return expr; + } + + like(expr: string, pattern: string, insensitive: boolean): string { + return insensitive + ? `LOWER(${expr}) LIKE LOWER(${pattern}) ESCAPE '\\\\'` + : `${expr} COLLATE utf8mb4_bin LIKE ${pattern} ESCAPE '\\\\'`; + } + + isBoolean(expr: string, value: boolean): string { + return `${expr} = ${value ? 1 : 0}`; + } + + /** MySQL sorts nulls first ascending; this keeps the Postgres order: last ascending, first descending. */ + orderBy(expr: string, desc: boolean, nulls?: 'first' | 'last'): string { + const first = (nulls ?? (desc ? 'first' : 'last')) === 'first'; + return `(${expr} IS NULL) ${first ? 'DESC' : 'ASC'}, ${expr} ${desc ? 'DESC' : 'ASC'}`; + } + + page(limit: number | undefined, offset: number | undefined): string { + return limit === undefined && !offset ? '' : ` LIMIT ${limit ?? '18446744073709551615'}${offset ? ` OFFSET ${offset}` : ''}`; + } + + returning(): string { + return ''; + } + + call(fn: CatalogFunction, args: [string, unknown][], ctx: RenderContext): Statement { + const byName = new Map(args); + const call = `${this.table(fn.schema, fn.name)}(${fn.args.map((a) => ctx.params.add(byName.get(a.name))).join(', ')})`; + return ctx.params.finalize(`SELECT JSON_EXTRACT(JSON_ARRAY(${call}), '$[0]') AS r`, (i) => this.placeholder(i)); + } +} diff --git a/packages/mysql/src/types.ts b/packages/mysql/src/types.ts new file mode 100644 index 0000000..1ff17b4 --- /dev/null +++ b/packages/mysql/src/types.ts @@ -0,0 +1,16 @@ +import type { LogicalType } from '@crudy/restloom'; + +/** A MySQL column type as a logical type. TINYINT(1) is MySQL's boolean. */ +export function logical(dataType: string, columnType: string): LogicalType { + const t = dataType.toLowerCase(); + if (columnType.toLowerCase().startsWith('tinyint(1)') || t === 'boolean') return 'boolean'; + if (['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'year'].includes(t)) return 'integer'; + if (['decimal', 'numeric', 'float', 'double', 'real'].includes(t)) return 'number'; + if (['char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set'].includes(t)) return 'text'; + if (t === 'date') return 'date'; + if (t === 'datetime' || t === 'timestamp') return 'timestamp'; + if (t === 'time') return 'time'; + if (t === 'json') return 'json'; + if (['binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'bit'].includes(t)) return 'binary'; + return 'other'; +} diff --git a/packages/mysql/tsconfig.json b/packages/mysql/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/mysql/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts index e48e0f2..1afc695 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -20,7 +20,8 @@ export interface PostgresOptions { export function postgres(pool: Pool, options: PostgresOptions = {}): Dialect { return { name: 'postgres', - capabilities: { returning: true, estimatedCount: true, rowSecurity: true }, + capabilities: { returning: true, estimatedCount: true, rowSecurity: true, procedures: true }, + decode: (value) => value, renderer: options.renderer ?? new PostgresRenderer(), introspect: (scope) => introspect(pool, scope), async transaction(fn, { timeoutMs, readOnly }) { diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json new file mode 100644 index 0000000..5886f0f --- /dev/null +++ b/packages/sqlite/package.json @@ -0,0 +1,50 @@ +{ + "name": "@crudy/restloom-sqlite", + "version": "0.1.0", + "type": "module", + "description": "SQLite dialect for restloom", + "keywords": [ + "restloom", + "sqlite", + "rest", + "api" + ], + "author": "Marc Villain", + "homepage": "https://restloom.crudy.fr", + "repository": { + "type": "git", + "url": "git+https://github.com/crudylabs/restloom.git", + "directory": "packages/sqlite" + }, + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p .", + "prepack": "npm run build" + }, + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "peerDependencies": { + "better-sqlite3": ">=12" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/better-sqlite3": "^7.6.0", + "better-sqlite3": "^12.11.1", + "typescript": "^5.9.0" + } +} diff --git a/packages/sqlite/src/index.ts b/packages/sqlite/src/index.ts new file mode 100644 index 0000000..79ef951 --- /dev/null +++ b/packages/sqlite/src/index.ts @@ -0,0 +1,69 @@ +import { RestError, type Dialect, type SqlRenderer } from '@crudy/restloom'; +import type { Database } from 'better-sqlite3'; +import { introspect } from './introspect.js'; +import { SqliteRenderer } from './renderer.js'; + +export { SqliteRenderer } from './renderer.js'; + +const CODES: Record = { + SQLITE_CONSTRAINT_UNIQUE: [409, 'duplicate'], + SQLITE_CONSTRAINT_PRIMARYKEY: [409, 'duplicate'], + SQLITE_CONSTRAINT_FOREIGNKEY: [409, 'reference_missing'], + SQLITE_CONSTRAINT_NOTNULL: [400, 'value_required'], + SQLITE_CONSTRAINT_CHECK: [400, 'value_rejected'], + SQLITE_MISMATCH: [400, 'invalid_value'], +}; + +export function mapError(err: unknown): RestError | null { + const code = (err as { code?: unknown })?.code; + const hit = typeof code === 'string' ? CODES[code] : undefined; + return hit ? new RestError(hit[0], hit[1]) : null; +} + +const bindable = (v: unknown) => (v === true ? 1 : v === false ? 0 : v); + +export interface SqliteOptions { + renderer?: SqlRenderer; +} + +/** + * The SQLite dialect over a better-sqlite3 Database. It turns on foreign keys + * and case-sensitive LIKE for the connection. One connection runs one + * transaction at a time, so requests are queued. SQLite cannot stop a running + * statement, so `limits.timeoutMs` does not apply, and it has no functions to + * call as procedures. + */ +export function sqlite(db: Database, options: SqliteOptions = {}): Dialect { + db.pragma('foreign_keys = ON'); + db.pragma('case_sensitive_like = ON'); + let queue: Promise = Promise.resolve(); + return { + name: 'sqlite', + capabilities: { returning: true, estimatedCount: false, rowSecurity: false, procedures: false }, + renderer: options.renderer ?? new SqliteRenderer(), + decode: (value) => (typeof value === 'string' ? JSON.parse(value) : value), + introspect: async (scope) => introspect(db, scope), + transaction(fn) { + const run = queue.then(async () => { + db.exec('BEGIN'); + try { + const out = await fn({ + async query(sql, params) { + const stmt = db.prepare(sql); + const bound = params.map(bindable); + return (stmt.reader ? stmt.all(...bound) : (stmt.run(...bound), [])) as Record[]; + }, + }); + db.exec('COMMIT'); + return out; + } catch (err) { + if (db.inTransaction) db.exec('ROLLBACK'); + throw err; + } + }); + queue = run.catch(() => undefined); + return run; + }, + mapError, + }; +} diff --git a/packages/sqlite/src/introspect.ts b/packages/sqlite/src/introspect.ts new file mode 100644 index 0000000..62a8c0d --- /dev/null +++ b/packages/sqlite/src/introspect.ts @@ -0,0 +1,42 @@ +import type { Catalog, CatalogTable } from '@crudy/restloom'; +import type { Database } from 'better-sqlite3'; +import { logical } from './types.js'; + +type Row = Record; + +/** Reads tables and views of the main database or of attached ones; SQLite's schemas are its attached databases. */ +export function introspect(db: Database, scope: { schemas: string[]; tables: string[] }): Catalog { + const schemas = new Set([...scope.schemas, ...scope.tables.map((t) => t.split('.')[0])]); + const attached = new Set((db.prepare('PRAGMA database_list').all() as Row[]).map((r) => r.name as string)); + const tables: CatalogTable[] = []; + for (const schema of schemas) { + if (!attached.has(schema)) continue; + const q = (s: string) => `"${s.replace(/"/g, '""')}"`; + const rows = db.prepare(`SELECT name, type FROM ${q(schema)}.sqlite_schema WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'`).all() as Row[]; + for (const r of rows) { + const name = r.name as string; + if (!scope.schemas.includes(schema) && !scope.tables.includes(`${schema}.${name}`)) continue; + const cols = db.prepare(`PRAGMA ${q(schema)}.table_xinfo(${q(name)})`).all() as Row[]; + const fks = db.prepare(`PRAGMA ${q(schema)}.foreign_key_list(${q(name)})`).all() as Row[]; + const byId = new Map(); + for (const f of fks) byId.set(f.id as number, [...(byId.get(f.id as number) ?? []), f]); + tables.push({ + schema, name, kind: r.type === 'view' ? 'view' : 'table', + columns: cols.filter((c) => c.hidden !== 1).map((c) => ({ + name: c.name as string, nativeType: c.type as string, type: logical(c.type as string), + nullable: !c.notnull, generated: c.hidden === 2 || c.hidden === 3, + })), + primaryKey: cols.filter((c) => (c.pk as number) > 0).sort((a, b) => (a.pk as number) - (b.pk as number)).map((c) => c.name as string), + foreignKeys: [...byId.values()].map((parts) => { + const ordered = parts.sort((a, b) => (a.seq as number) - (b.seq as number)); + return { + columns: ordered.map((p) => p.from as string), + target: { schema, table: ordered[0].table as string }, + targetColumns: ordered.map((p) => p.to as string), + }; + }), + }); + } + } + return { tables, functions: [] }; +} diff --git a/packages/sqlite/src/renderer.ts b/packages/sqlite/src/renderer.ts new file mode 100644 index 0000000..f52112f --- /dev/null +++ b/packages/sqlite/src/renderer.ts @@ -0,0 +1,43 @@ +import { SqlRenderer, type CatalogColumn } from '@crudy/restloom'; + +/** SQLite: JSON through json_object and json_group_array, booleans as 0 and 1, positional placeholders. */ +export class SqliteRenderer extends SqlRenderer { + placeholder(_index: number): string { + return '?'; + } + + jsonObject(pairs: [string, string][]): string { + return `json_object(${pairs.map(([k, v]) => `${this.literal(k)}, ${v}`).join(', ')})`; + } + + jsonArray(rowExpr: string, orderExpr: string): string { + return `COALESCE(json_group_array(json(${rowExpr}) ORDER BY ${orderExpr}), json('[]'))`; + } + + nested(expr: string): string { + return `json(${expr})`; + } + + jsonValue(expr: string, column: CatalogColumn): string { + if (column.type === 'boolean') return `CASE WHEN ${expr} IS NULL THEN NULL WHEN ${expr} THEN json('true') ELSE json('false') END`; + if (column.type === 'json') return `json(${expr})`; + return expr; + } + + like(expr: string, pattern: string, insensitive: boolean): string { + return insensitive ? `lower(${expr}) LIKE lower(${pattern}) ESCAPE '\\'` : `${expr} LIKE ${pattern} ESCAPE '\\'`; + } + + isBoolean(expr: string, value: boolean): string { + return `${expr} = ${value ? 1 : 0}`; + } + + /** SQLite sorts nulls first ascending; this keeps the Postgres order: last ascending, first descending. */ + orderBy(expr: string, desc: boolean, nulls?: 'first' | 'last'): string { + return super.orderBy(expr, desc, nulls ?? (desc ? 'first' : 'last')); + } + + page(limit: number | undefined, offset: number | undefined): string { + return limit === undefined && !offset ? '' : ` LIMIT ${limit ?? -1}${offset ? ` OFFSET ${offset}` : ''}`; + } +} diff --git a/packages/sqlite/src/types.ts b/packages/sqlite/src/types.ts new file mode 100644 index 0000000..31f5772 --- /dev/null +++ b/packages/sqlite/src/types.ts @@ -0,0 +1,17 @@ +import type { LogicalType } from '@crudy/restloom'; + +/** A declared SQLite column type as a logical type, by the same keyword rules SQLite uses for affinity, refined for common names. */ +export function logical(declared: string): LogicalType { + const t = declared.toUpperCase(); + if (t === 'BOOLEAN' || t === 'BOOL') return 'boolean'; + if (t === 'UUID') return 'uuid'; + if (t === 'DATE') return 'date'; + if (t.includes('DATETIME') || t.includes('TIMESTAMP')) return 'timestamp'; + if (t === 'TIME') return 'time'; + if (t === 'JSON' || t === 'JSONB') return 'json'; + if (t.includes('INT')) return 'integer'; + if (t.includes('CHAR') || t.includes('CLOB') || t.includes('TEXT')) return 'text'; + if (t.includes('BLOB') || t === '') return t === '' ? 'other' : 'binary'; + if (t.includes('REAL') || t.includes('FLOA') || t.includes('DOUB') || t.includes('NUMERIC') || t.includes('DECIMAL')) return 'number'; + return 'other'; +} diff --git a/packages/sqlite/tsconfig.json b/packages/sqlite/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/sqlite/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +}