feat: Prefer count header, procedures outside exposed schemas; 0.2.0

This commit is contained in:
2026-11-02 01:50:34 +01:00
parent 31f97fd392
commit 8735abfe79
18 changed files with 73 additions and 38 deletions
+11
View File
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased] ## [Unreleased]
## [0.2.0] - 2026-11-02
### Added
- `Prefer: count=exact` (or `estimated`) counts a list, as the `count` parameter does.
- A declared procedure's function is read wherever it lives, not only in an exposed schema.
### Changed
- `Dialect.introspect` takes the declared functions as `functions`.
## [0.1.0] - 2026-11-02
### Added ### Added
- `@crudy/restloom`: discovery from the catalog, relations from foreign keys and junction tables, the read grammar, the overridable SQL renderer, `createREST` and a Node `http` listener. - `@crudy/restloom`: discovery from the catalog, relations from foreign keys and junction tables, the read grammar, the overridable SQL renderer, `createREST` and a Node `http` listener.
- Writes: create, update and delete by key, bulk by filter where enabled, scope applied, rows read back with `Prefer: return=representation`. - Writes: create, update and delete by key, bulk by filter where enabled, scope applied, rows read back with `Prefer: return=representation`.
+10
View File
@@ -29,3 +29,13 @@ for (const page of PAGES) {
} }
}); });
} }
describe('postgres procedures outside the exposed schemas', { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => {
test('a declared function is read even when only tables are exposed', async () => {
await pool.query(fixture);
const rest = createREST({ db: postgres(pool), expose: { tables: ['demo.tags'] }, procedures: { 'assets/{asset}/retire': { function: 'demo.retire_asset' } } });
const res = await rest.handle({ method: 'POST', path: '/assets/3/retire', query: new URLSearchParams(), headers: {}, body: {}, context: undefined });
assert.equal(res.status, 200);
assert.equal(res.body, 3);
});
});
+13 -13
View File
@@ -3314,7 +3314,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@crudy/restloom", "name": "@crudy/restloom",
"version": "0.1.0", "version": "0.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -3326,10 +3326,10 @@
}, },
"packages/express": { "packages/express": {
"name": "@crudy/restloom-express", "name": "@crudy/restloom-express",
"version": "0.1.0", "version": "0.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
@@ -3346,10 +3346,10 @@
}, },
"packages/fastify": { "packages/fastify": {
"name": "@crudy/restloom-fastify", "name": "@crudy/restloom-fastify",
"version": "0.1.0", "version": "0.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -3365,10 +3365,10 @@
}, },
"packages/mssql": { "packages/mssql": {
"name": "@crudy/restloom-mssql", "name": "@crudy/restloom-mssql",
"version": "0.1.0", "version": "0.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/mssql": "^12.0.0", "@types/mssql": "^12.0.0",
@@ -3385,10 +3385,10 @@
}, },
"packages/mysql": { "packages/mysql": {
"name": "@crudy/restloom-mysql", "name": "@crudy/restloom-mysql",
"version": "0.1.0", "version": "0.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -3404,10 +3404,10 @@
}, },
"packages/postgres": { "packages/postgres": {
"name": "@crudy/restloom-postgres", "name": "@crudy/restloom-postgres",
"version": "0.1.0", "version": "0.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -3424,10 +3424,10 @@
}, },
"packages/sqlite": { "packages/sqlite": {
"name": "@crudy/restloom-sqlite", "name": "@crudy/restloom-sqlite",
"version": "0.1.0", "version": "0.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.0", "@types/better-sqlite3": "^7.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom", "name": "@crudy/restloom",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults", "description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults",
"keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"], "keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"],
+5 -2
View File
@@ -23,8 +23,11 @@ export interface Dialect {
readonly name: string; readonly name: string;
readonly capabilities: Capabilities; readonly capabilities: Capabilities;
readonly renderer: SqlRenderer; readonly renderer: SqlRenderer;
/** Reads the tables, views and functions of the given schemas and tables. */ /**
introspect(scope: { schemas: string[]; tables: string[] }): Promise<Catalog>; * Reads the tables and views of the given schemas and tables, and the
* functions of the given schemas plus the named ones (schema.name).
*/
introspect(scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise<Catalog>;
/** Runs fn in one transaction with a statement timeout; read-only when asked. */ /** Runs fn in one transaction with a statement timeout; read-only when asked. */
transaction<T>(fn: (tx: Tx) => Promise<T>, options: { timeoutMs: number; readOnly: boolean }): Promise<T>; transaction<T>(fn: (tx: Tx) => Promise<T>, options: { timeoutMs: number; readOnly: boolean }): Promise<T>;
/** The JSON value of a statement's `r` column as a JavaScript value: drivers that parse JSON return it as is, others parse text. */ /** The JSON value of a statement's `r` column as a JavaScript value: drivers that parse JSON return it as is, others parse text. */
+2
View File
@@ -30,6 +30,8 @@ export async function readRows(db: Dialect, tx: Tx, resource: Resource, request:
scope: (r: Resource) => Filter[], id?: string, extra: Filter[] = []): Promise<RestResponse> { scope: (r: Resource) => Filter[], id?: string, extra: Filter[] = []): Promise<RestResponse> {
const plan = parseRead(resource, request.query, limits); const plan = parseRead(resource, request.query, limits);
plan.where.push(...extra); plan.where.push(...extra);
const prefer = /\bcount=(exact|estimated)\b/.exec(request.headers.prefer ?? '');
if (plan.count === 'none' && prefer) plan.count = prefer[1] as 'exact' | 'estimated';
if (id !== undefined) { if (id !== undefined) {
plan.where.unshift(keyFilter(resource, id)); plan.where.unshift(keyFilter(resource, id));
plan.limit = 1; plan.limit = 1;
+2 -1
View File
@@ -43,7 +43,8 @@ export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
const db = options.db; const db = options.db;
let loading: Promise<Api> | null = null; let loading: Promise<Api> | null = null;
const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [] }) const functions = Object.values(options.procedures ?? {}).map((p) => p.function).filter((f) => f.includes('.'));
const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [], functions })
.then((catalog: Catalog) => ({ .then((catalog: Catalog) => ({
resources: buildResources(catalog, options as RestOptions<unknown>), resources: buildResources(catalog, options as RestOptions<unknown>),
procedures: buildProcedures(catalog, options.procedures ?? {}), procedures: buildProcedures(catalog, options.procedures ?? {}),
+6
View File
@@ -18,3 +18,9 @@ test('the node listener serves under its prefix and refuses bad bodies', async (
server.close(); server.close();
} }
}); });
test('Prefer: count=exact counts as count=exact does', async () => {
const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] } });
const res = await rest.handle({ method: 'GET', path: '/items', query: new URLSearchParams(), headers: { prefer: 'count=exact' }, context: undefined });
assert.equal(res.headers['content-range'], '0-0/1');
});
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-express", "name": "@crudy/restloom-express",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"description": "Express adapter for restloom", "description": "Express adapter for restloom",
"keywords": [ "keywords": [
@@ -36,7 +36,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"express": "^5.0.0" "express": "^5.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-fastify", "name": "@crudy/restloom-fastify",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"description": "Fastify adapter for restloom", "description": "Fastify adapter for restloom",
"keywords": [ "keywords": [
@@ -36,7 +36,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"fastify": "^5.0.0" "fastify": "^5.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-mssql", "name": "@crudy/restloom-mssql",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"description": "SQL Server dialect for restloom", "description": "SQL Server dialect for restloom",
"keywords": [ "keywords": [
@@ -37,7 +37,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"mssql": "^12.0.0" "mssql": "^12.0.0"
+3 -2
View File
@@ -9,6 +9,7 @@ SELECT o.object_id AS id, SCHEMA_NAME(o.schema_id) AS s, o.name AS n, RTRIM(o.ty
FROM sys.objects o FROM sys.objects o
WHERE o.type IN ('U', 'V', 'FN', 'IF', 'TF') WHERE o.type IN ('U', 'V', 'FN', 'IF', 'TF')
AND (SCHEMA_NAME(o.schema_id) IN (SELECT value FROM STRING_SPLIT(@schemas, ',')) AND (SCHEMA_NAME(o.schema_id) IN (SELECT value FROM STRING_SPLIT(@schemas, ','))
OR (o.type IN ('FN', 'IF', 'TF') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@functions, ',')))
OR (o.type IN ('U', 'V') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@tables, ','))))`; OR (o.type IN ('U', 'V') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@tables, ','))))`;
const COLUMNS = ` const COLUMNS = `
@@ -40,13 +41,13 @@ SELECT p.object_id AS id, SUBSTRING(p.name, 2, 200) AS name, TYPE_NAME(p.user_ty
ORDER BY p.object_id, p.parameter_id`; 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. */ /** 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<Catalog> { export async function introspect(pool: ConnectionPool, scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise<Catalog> {
const run = async (sql: string, params: Record<string, string>) => { const run = async (sql: string, params: Record<string, string>) => {
const req = pool.request(); const req = pool.request();
for (const [k, v] of Object.entries(params)) req.input(k, v); for (const [k, v] of Object.entries(params)) req.input(k, v);
return (await req.query(sql)).recordset as Row[]; return (await req.query(sql)).recordset as Row[];
}; };
const objects = await run(OBJECTS, { schemas: scope.schemas.join(','), tables: scope.tables.join(',') }); const objects = await run(OBJECTS, { schemas: scope.schemas.join(','), tables: scope.tables.join(','), functions: scope.functions.join(',') });
const ids = { ids: objects.map((o) => String(o.id)).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 [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) => ({ const columnsOf = (id: unknown) => columns.filter((c) => c.id === id).map((c) => ({
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-mysql", "name": "@crudy/restloom-mysql",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"description": "MySQL dialect for restloom", "description": "MySQL dialect for restloom",
"keywords": [ "keywords": [
@@ -36,7 +36,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"mysql2": "^3.11.0" "mysql2": "^3.11.0"
+4 -3
View File
@@ -7,9 +7,10 @@ type Row = Record<string, unknown>;
const IN_SCOPE = '(t.TABLE_SCHEMA IN (?) OR CONCAT(t.TABLE_SCHEMA, \'.\', t.TABLE_NAME) IN (?))'; 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. */ /** 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<Catalog> { export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise<Catalog> {
const schemas = scope.schemas.length ? scope.schemas : ['']; const schemas = scope.schemas.length ? scope.schemas : [''];
const tablesIn = scope.tables.length ? scope.tables : ['']; const tablesIn = scope.tables.length ? scope.tables : [''];
const functionsIn = scope.functions.length ? scope.functions : [''];
const q = async (sql: string, params: unknown[]) => (await pool.query(sql, params))[0] as Row[]; const q = async (sql: string, params: unknown[]) => (await pool.query(sql, params))[0] as Row[];
const [tables, columns, keys, params, functions] = await Promise.all([ 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 TABLE_SCHEMA AS s, TABLE_NAME AS n, TABLE_TYPE AS k FROM information_schema.TABLES t WHERE ${IN_SCOPE}`, [schemas, tablesIn]),
@@ -22,8 +23,8 @@ export async function introspect(pool: Pool, scope: { schemas: string[]; tables:
WHERE ${IN_SCOPE} AND (k.CONSTRAINT_NAME = 'PRIMARY' OR k.REFERENCED_TABLE_NAME IS NOT NULL) 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]), 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 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]), FROM information_schema.PARAMETERS WHERE (SPECIFIC_SCHEMA IN (?) OR CONCAT(SPECIFIC_SCHEMA, '.', SPECIFIC_NAME) IN (?)) AND ORDINAL_POSITION > 0 ORDER BY ORDINAL_POSITION`, [schemas, functionsIn]),
q(`SELECT ROUTINE_SCHEMA AS s, ROUTINE_NAME AS n FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA IN (?) AND ROUTINE_TYPE = 'FUNCTION'`, [schemas]), q(`SELECT ROUTINE_SCHEMA AS s, ROUTINE_NAME AS n FROM information_schema.ROUTINES WHERE (ROUTINE_SCHEMA IN (?) OR CONCAT(ROUTINE_SCHEMA, '.', ROUTINE_NAME) IN (?)) AND ROUTINE_TYPE = 'FUNCTION'`, [schemas, functionsIn]),
]); ]);
const byName = new Map<string, CatalogTable>(); const byName = new Map<string, CatalogTable>();
for (const t of tables) { for (const t of tables) {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-postgres", "name": "@crudy/restloom-postgres",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"description": "Postgres dialect for restloom", "description": "Postgres dialect for restloom",
"keywords": ["restloom", "postgres", "postgresql", "rest", "api"], "keywords": ["restloom", "postgres", "postgresql", "rest", "api"],
@@ -18,7 +18,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"pg": "^8.11.0" "pg": "^8.11.0"
+3 -3
View File
@@ -45,17 +45,17 @@ SELECT n.nspname AS schema, p.proname AS name, p.proretset AS returns_set, p.pro
(SELECT array_agg(b.typname::text || ':' || b.typtype::text || ':' || b.typcategory::text ORDER BY ord) (SELECT array_agg(b.typname::text || ':' || b.typtype::text || ':' || b.typcategory::text ORDER BY ord)
FROM unnest(p.proargtypes) WITH ORDINALITY u(t, ord) JOIN pg_type b ON b.oid = u.t) AS types FROM unnest(p.proargtypes) WITH ORDINALITY u(t, ord) JOIN pg_type b ON b.oid = u.t) AS types
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE p.prokind = 'f' AND n.nspname = ANY($1::text[])`; WHERE p.prokind = 'f' AND (n.nspname = ANY($1::text[]) OR n.nspname || '.' || p.proname = ANY($2::text[]))`;
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
/** Reads the tables, views, keys, foreign keys and functions of the exposed schemas and tables. */ /** Reads the tables, views, keys, foreign keys and functions of the exposed schemas and tables. */
export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[] }): Promise<Catalog> { export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise<Catalog> {
const { rows: tables } = await pool.query<Row>(TABLES, [scope.schemas, scope.tables]); const { rows: tables } = await pool.query<Row>(TABLES, [scope.schemas, scope.tables]);
const oids = tables.map((t) => t.oid as number); const oids = tables.map((t) => t.oid as number);
const [columns, keys, fks, fns] = await Promise.all([ const [columns, keys, fks, fns] = await Promise.all([
pool.query<Row>(COLUMNS, [oids]), pool.query<Row>(PRIMARY_KEYS, [oids]), pool.query<Row>(COLUMNS, [oids]), pool.query<Row>(PRIMARY_KEYS, [oids]),
pool.query<Row>(FOREIGN_KEYS, [oids]), pool.query<Row>(FUNCTIONS, [scope.schemas]), pool.query<Row>(FOREIGN_KEYS, [oids]), pool.query<Row>(FUNCTIONS, [scope.schemas, scope.functions]),
]); ]);
const byOid = new Map<number, CatalogTable>(); const byOid = new Map<number, CatalogTable>();
for (const t of tables) { for (const t of tables) {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-sqlite", "name": "@crudy/restloom-sqlite",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"description": "SQLite dialect for restloom", "description": "SQLite dialect for restloom",
"keywords": [ "keywords": [
@@ -36,7 +36,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.1.0" "@crudy/restloom": "^0.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"better-sqlite3": ">=12" "better-sqlite3": ">=12"
+1 -1
View File
@@ -5,7 +5,7 @@ import { logical } from './types.js';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
/** Reads tables and views of the main database or of attached ones; SQLite's schemas are its attached databases. */ /** 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 { export function introspect(db: Database, scope: { schemas: string[]; tables: string[]; functions: string[] }): Catalog {
const schemas = new Set([...scope.schemas, ...scope.tables.map((t) => t.split('.')[0])]); 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 attached = new Set((db.prepare('PRAGMA database_list').all() as Row[]).map((r) => r.name as string));
const tables: CatalogTable[] = []; const tables: CatalogTable[] = [];