diff --git a/.changeset/slonik-plugin-public.md b/.changeset/slonik-plugin-public.md new file mode 100644 index 00000000..94435760 --- /dev/null +++ b/.changeset/slonik-plugin-public.md @@ -0,0 +1,8 @@ +--- +"@ts-safeql/eslint-plugin": patch +"@ts-safeql/plugin-utils": patch +"@ts-safeql/zod-annotator": patch +"@ts-safeql/plugin-slonik": minor +--- + +Publish the experimental Slonik plugin and tighten plugin resolution behavior. diff --git a/AGENTS.md b/AGENTS.md index 72184288..06819e66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,3 +18,4 @@ pnpm --filter test -- --run # single package tests ## References - [Creating a New Package](.agents/docs/creating-package.md) - [Authoring a Plugin](.agents/docs/authoring-plugin.md) + - [Coding Guidelines](.agents/docs/coding-guidelines.md) diff --git a/demos/plugin-slonik/eslint.config.js b/demos/plugin-slonik/eslint.config.js new file mode 100644 index 00000000..563ec381 --- /dev/null +++ b/demos/plugin-slonik/eslint.config.js @@ -0,0 +1,21 @@ +// @ts-check + +import safeql from "@ts-safeql/eslint-plugin/config"; +import slonik from "@ts-safeql/plugin-slonik"; +import tseslint from "typescript-eslint"; + +export default tseslint.config({ + files: ["src/**/*.ts"], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + projectService: true, + }, + }, + extends: [ + safeql.configs.connections({ + databaseUrl: "postgres://postgres:postgres@localhost:5432/postgres", + plugins: [slonik()], + }), + ], +}); diff --git a/demos/plugin-slonik/package.json b/demos/plugin-slonik/package.json new file mode 100644 index 00000000..7bca7bfb --- /dev/null +++ b/demos/plugin-slonik/package.json @@ -0,0 +1,24 @@ +{ + "name": "@ts-safeql-demos/plugin-slonik", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "lint": "eslint src" + }, + "devDependencies": { + "@eslint/js": "catalog:", + "@slonik/pg-driver": "^48.13.2", + "@types/node": "catalog:", + "eslint": "catalog:", + "slonik": "^48.13.2", + "typescript": "catalog:", + "typescript-eslint": "catalog:", + "zod": "^4.3.6" + }, + "dependencies": { + "@ts-safeql/eslint-plugin": "workspace:*", + "@ts-safeql/plugin-slonik": "workspace:*" + } +} diff --git a/demos/plugin-slonik/src/index.ts b/demos/plugin-slonik/src/index.ts new file mode 100644 index 00000000..33e0085a --- /dev/null +++ b/demos/plugin-slonik/src/index.ts @@ -0,0 +1,176 @@ +import { createPool, sql } from "slonik"; +import { createPgDriverFactory } from "@slonik/pg-driver"; +import { z } from "zod"; + +const pool = await createPool("postgres://", { driverFactory: createPgDriverFactory() }); + +section("sql.type — Zod schema validates result", () => { + // inline schema + pool.one(sql.type(z.object({ id: z.number(), version: z.string() }))` + SELECT oid::int4 AS id, typname::text AS version FROM pg_type LIMIT 1 + `); + + // referenced schema variable + const TypeRow = z.object({ id: z.number(), version: z.string() }); + pool.one(sql.type(TypeRow)` + SELECT oid::int4 AS id, typname::text AS version FROM pg_type LIMIT 1 + `); + + // nullable column + pool.one(sql.type(z.object({ v: z.string().nullable() }))`SELECT NULL::text AS v`); + + // multiple columns with mixed types + pool.one(sql.type(z.object({ n: z.number(), s: z.string(), b: z.boolean() }))` + SELECT 1::int4 AS n, 'hello'::text AS s, true AS b + `); +}); + +section("sql.typeAlias — alias→schema is runtime-only", () => { + // validates SQL but skips type annotations + pool.query(sql.typeAlias("id")`SELECT 1 AS id`); +}); + +section("sql.unsafe — opt-out of type checking", () => { + // no type annotation needed + pool.query(sql.unsafe`SELECT 1`); +}); + +section("sql.identifier", () => { + // single name + pool.query(sql.unsafe`SELECT typname::text FROM ${sql.identifier(["pg_type"])} LIMIT 1`); + + // schema-qualified + pool.one(sql.type(z.object({ oid: z.number() }))` + SELECT oid::int4 AS oid FROM ${sql.identifier(["pg_catalog", "pg_type"])} LIMIT 1 + `); +}); + +section("sql.json / sql.jsonb", () => { + // sql.json + pool.one(sql.type(z.object({ p: z.string().nullable() }))` + SELECT ${sql.json({ id: 1 })}::jsonb ->> 'id' AS p + `); + + // sql.jsonb + pool.one(sql.type(z.object({ p: z.string().nullable() }))` + SELECT ${sql.jsonb([1, 2, 3])}::jsonb ->> 0 AS p + `); +}); + +section("sql.binary", () => { + // bytea parameter + pool.query(sql.unsafe`SELECT ${sql.binary(Buffer.from("foo"))}`); +}); + +section("sql.date / sql.timestamp / sql.interval", () => { + // sql.date + pool.one(sql.type(z.object({ d: z.string().nullable() }))` + SELECT ${sql.date(new Date("2022-08-19T03:27:24.951Z"))}::text AS d + `); + + // sql.timestamp + pool.one(sql.type(z.object({ d: z.string().nullable() }))` + SELECT ${sql.timestamp(new Date("2022-08-19T03:27:24.951Z"))}::text AS d + `); + + // sql.interval + pool.one(sql.type(z.object({ i: z.string().nullable() }))` + SELECT ${sql.interval({ days: 3 })}::text AS i + `); +}); + +section("sql.uuid", () => { + // uuid parameter + pool.one(sql.type(z.object({ u: z.string().nullable() }))` + SELECT ${sql.uuid("00000000-0000-0000-0000-000000000000")}::text AS u + `); +}); + +section("sql.array", () => { + // typed array + pool.one(sql.type(z.object({ a: z.number().nullable() }))` + SELECT ${sql.array([1, 2, 3], "int4")} AS a + `); + + // ANY() pattern from README + pool.query(sql.typeAlias("id")` + SELECT oid::int4 AS id FROM pg_type + WHERE oid = ANY(${sql.array([1, 2, 3], "int4")}) + `); +}); + +section("sql.join — too dynamic, query skipped", () => { + // comma-separated values + pool.query(sql.unsafe`SELECT ${sql.join([1, 2, 3], sql.fragment`, `)}`); + + // boolean expressions + pool.query(sql.unsafe`SELECT ${sql.join([1, 2], sql.fragment` AND `)}`); + + // tuple list + pool.query(sql.unsafe` + SELECT ${sql.join( + [ + sql.fragment`(${sql.join([1, 2], sql.fragment`, `)})`, + sql.fragment`(${sql.join([3, 4], sql.fragment`, `)})`, + ], + sql.fragment`, `, + )} + `); +}); + +section("sql.unnest — too dynamic, query skipped", () => { + // bulk insert with string type names + pool.query(sql.unsafe` + SELECT bar, baz + FROM ${sql.unnest( + [ + [1, "foo"], + [2, "bar"], + ], + ["int4", "text"], + )} AS foo(bar, baz) + `); +}); + +section("sql.literalValue — too dynamic, query skipped", () => { + // raw literal interpolation + pool.query(sql.unsafe`SELECT ${sql.literalValue("foo")}`); +}); + +section("sql.fragment — composable pieces", () => { + // standalone fragment (not linted) + sql.fragment`WHERE 1 = 1`; + + // fragment as expression (query skipped) + const whereFragment = sql.fragment`WHERE typname = ${"bool"}`; + pool.query(sql.unsafe`SELECT typname FROM pg_type ${whereFragment}`); + + // nested fragments + const nestedCondition = sql.fragment`typname = ${"bool"}`; + const nestedWhereFragment = sql.fragment`WHERE ${nestedCondition}`; + pool.query(sql.unsafe`SELECT typname FROM pg_type ${nestedWhereFragment}`); +}); + +section("value placeholders — plain variables", () => { + // plain value becomes $N parameter + const name = "bool"; + pool.query(sql.unsafe`SELECT typname FROM pg_type WHERE typname = ${name}`); +}); + +section("invalid cases SafeQL catches", () => { + // bad column + // eslint-disable-next-line @ts-safeql/check-sql -- column "nonexistent" does not exist + pool.query(sql.unsafe`SELECT nonexistent FROM pg_type`); + + // bad table + // eslint-disable-next-line @ts-safeql/check-sql -- relation "nonexistent" does not exist + pool.query(sql.type(z.object({}))`SELECT 1 FROM nonexistent`); + + // wrong zod schema + // eslint-disable-next-line @ts-safeql/check-sql -- Expected: z.object({ id: z.number() }) + pool.one(sql.type(z.object({ id: z.string() }))`SELECT 1::int4 AS id`); +}); + +function section(_: string, fn: () => void) { + fn(); +} diff --git a/demos/plugin-slonik/tsconfig.json b/demos/plugin-slonik/tsconfig.json new file mode 100644 index 00000000..b9be15ae --- /dev/null +++ b/demos/plugin-slonik/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/docs/compatibility/postgres.js.md b/docs/compatibility/postgres.js.md index 1b8d6755..625f03db 100644 --- a/docs/compatibility/postgres.js.md +++ b/docs/compatibility/postgres.js.md @@ -86,4 +86,4 @@ const query = sql`SELECT id FROM users` // After: ✅ const query = sql<{ id: number; }[]>`SELECT id FROM users` -``` +``` \ No newline at end of file diff --git a/docs/compatibility/slonik.md b/docs/compatibility/slonik.md index e970dfc5..cfd995d2 100644 --- a/docs/compatibility/slonik.md +++ b/docs/compatibility/slonik.md @@ -4,7 +4,85 @@ layout: doc # SafeQL :heart: Slonik -SafeQL is compatible with [Slonik](https://github.com/gajus/slonik) as well with a few setting tweaks. +SafeQL is compatible with [Slonik](https://github.com/gajus/slonik) with full support for Slonik's SQL helpers and Zod schema validation. + +## Using the Slonik Plugin (Experimental) + +::: warning EXPERIMENTAL +The Slonik plugin is experimental and may change in future releases. +::: + +```bash +npm install @ts-safeql/plugin-slonik +``` + +```js +// eslint.config.js +import safeql from "@ts-safeql/eslint-plugin/config"; +import slonik from "@ts-safeql/plugin-slonik"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + // ... + safeql.configs.connections({ + databaseUrl: "postgres://user:pass@localhost:5432/db", + plugins: [slonik()], + }), +); +``` + +The Slonik plugin defaults `sql.type(...)` schema mismatches to suggestions instead of autofix. +If you want `--fix` to rewrite the schema automatically, set `enforceType: "fix"` in the connection config. + +### Zod Schema Validation + +SafeQL validates your Zod schemas against the actual query results: + +```typescript +import { z } from "zod"; +import { sql } from "slonik"; + +// Wrong field type → suggestion by default +const query = sql.type(z.object({ id: z.string() }))`SELECT id FROM users`; +// ~~~~~~~~~~ +// Error: Zod schema does not match query result. +// Expected: z.object({ id: z.number() }) + +// Correct ✅ +const query = sql.type(z.object({ id: z.number() }))`SELECT id FROM users`; +``` + +### Fragment Embedding + +Fragment variables are automatically inlined: + +```typescript +const where = sql.fragment`WHERE id = 1`; +const query = sql.unsafe`SELECT * FROM users ${where}`; +// Analyzed as: SELECT * FROM users WHERE id = 1 +``` + +### Support Matrix + +Legend: `✅` supported, `⚠️` partial support, `❌` unsupported. + +| Library syntax | Support | Notes | +| -------------- | ------- | ----- | +| `sql.unsafe` | ✅ | Validated as a query, type annotations skipped | +| `sql.type(schema)` | ✅ | Validated as a query, Zod schema checked against DB result types; suggestions by default, autofix with `enforceType: "fix"` | +| `sql.typeAlias("name")` | ✅ | Validated as a query, type annotations skipped | +| Embedded fragment variables like `${sql.fragment\`...\`}` | ✅ | Inlined into the outer query | +| Standalone `sql.fragment` | ⚠️ | Intentionally skipped because it is not a complete query on its own | +| `sql.identifier(["schema", "table"])` | ✅ | Inlined as escaped identifiers | +| `sql.json(...)`, `sql.jsonb(...)`, `sql.binary(...)`, `sql.date(...)`, `sql.timestamp(...)`, `sql.interval(...)`, `sql.uuid(...)` | ✅ | Rewritten to typed SQL placeholders | +| `sql.array([...], "type")` | ✅ | Rewritten as `type[]` | +| `sql.unnest([...], ["type1", "type2"])` | ✅ | Rewritten as `unnest(type1[], type2[])` | +| `sql.literalValue("foo")` | ✅ | Inlined as a SQL literal | +| `sql.join(...)` | ❌ | Query is skipped because the composition is too dynamic to analyze safely | + +## Manual Configuration + +If you prefer not to use the plugin, you can configure SafeQL manually: ::: tabs key:eslintrc @@ -22,14 +100,11 @@ export default tseslint.config( // ... (read more about configuration in the API docs) targets: [ { - // This will lint syntax that matches "sql.typeAlias()`...`", "sql.type()`...`" or "sql.unsafe`...`" tag: "sql.+(type\\(*\\)|typeAlias\\(*\\)|unsafe)", - // this will tell SafeQL to not suggest type annotations - // since we will be using our Zod schemas in slonik skipTypeAnnotations: true, }, ], - }) + }), ); ``` @@ -63,11 +138,7 @@ export default tseslint.config( // ... (read more about configuration in the API docs) "targets": [ { - // This will lint syntax that matches - // "sql.type`...`" or "sql.unsafe`...`" "tag": "sql.+(type\\(*\\)|unsafe)", - // this will tell safeql to not suggest type annotations - // since we will be using our Zod schemas in slonik "skipTypeAnnotations": true } ] @@ -79,16 +150,8 @@ export default tseslint.config( } ``` -Once you've set up your configuration, you can start linting your queries: +::: -```typescript -import { z } from 'zod'; -import { sql } from 'slonik'; - -// Before: -const query = sql.type(z.object({ id: z.number() }))`SELECT idd FROM users`; - ~~~ Error: column "idd" does not exist // [!code error] - -// After: ✅ -const query = sql.type(z.object({ id: z.number() }))`SELECT id FROM users`; -``` +::: warning Manual Configuration Limitations +The manual approach doesn't support Zod schema validation, helper translation, or fragment inlining. For full Slonik support, use the plugin. +::: diff --git a/packages/connection-manager/src/index.test.ts b/packages/connection-manager/src/index.test.ts index 532f1e79..8bea218d 100644 --- a/packages/connection-manager/src/index.test.ts +++ b/packages/connection-manager/src/index.test.ts @@ -1,5 +1,8 @@ +import fs from "fs"; import os from "os"; +import path from "path"; import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createConnectionManager } from "./index"; import { ConnectionManagerTestDriver } from "./test-driver"; describe("connection-manager plugins", () => { @@ -81,6 +84,63 @@ describe("connection-manager plugins", () => { }), ).rejects.toThrow("last plugin wins"); }); + + it("resolves relative plugin paths per project directory", async () => { + const manager = createConnectionManager(); + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "safeql-local-plugin-")); + + const createProject = (name: string) => { + const dir = path.join(tmpRoot, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), "{}"); + fs.writeFileSync( + path.join(dir, "plugin.ts"), + String.raw` +import postgres from "postgres"; +import { definePlugin } from "@ts-safeql/plugin-utils"; + +export default definePlugin({ + name: "${name}", + package: "./plugin.ts", + setup(config) { + return { + createConnection: { + cacheKey: "${name}://" + config.databaseUrl, + async handler() { + return postgres(config.databaseUrl); + }, + }, + }; + }, +}); +`, + ); + return dir; + }; + + const firstProject = createProject("first-local"); + const secondProject = createProject("second-local"); + const descriptors = [{ package: "./plugin.ts", config: { databaseUrl: driver.databaseUrl } }]; + + let first; + let second; + + try { + first = await manager.getOrCreateFromPlugins(descriptors, firstProject); + second = await manager.getOrCreateFromPlugins(descriptors, secondProject); + + expect(first.pluginName).toBe("safeql-plugin-first-local"); + expect(first.databaseUrl).toBe(`first-local://${driver.databaseUrl}`); + expect(second.pluginName).toBe("safeql-plugin-second-local"); + expect(second.databaseUrl).toBe(`second-local://${driver.databaseUrl}`); + } finally { + await first?.sql.end().catch(() => {}); + if (second?.sql && second.sql !== first?.sql) { + await second.sql.end().catch(() => {}); + } + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); }); describe("rainy", () => { diff --git a/packages/connection-manager/src/index.ts b/packages/connection-manager/src/index.ts index a4007cd2..15d3b282 100644 --- a/packages/connection-manager/src/index.ts +++ b/packages/connection-manager/src/index.ts @@ -35,6 +35,7 @@ export type ConnectionStrategy = | { type: "pluginsOnly"; plugins: PluginDescriptors[]; + projectDir: string; }; export function createConnectionManager() { @@ -138,8 +139,8 @@ function closeConnection( connectionMap.delete(connectionUrl); } }) - .with({ type: "pluginsOnly" }, ({ plugins }) => { - const cached = pluginManager.getCachedConnection(plugins); + .with({ type: "pluginsOnly" }, ({ plugins, projectDir }) => { + const cached = pluginManager.getCachedConnection(plugins, projectDir); if (cached) { const sql = connectionMap.get(cached.cacheKey); @@ -149,7 +150,7 @@ function closeConnection( } } - pluginManager.evictPlugins(plugins); + pluginManager.evictPlugins(plugins, projectDir); }) .exhaustive(); } diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 708d4489..e7ba4943 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -74,7 +74,7 @@ "synckit": "^0.10.3", "ts-pattern": "^5.6.2", "tsx": "catalog:", - "zod": "^4.1.12" + "zod": "^4.3.6" }, "peerDependencies": { "@typescript-eslint/utils": ">=8.0.0", diff --git a/packages/eslint-plugin/src/rules/check-sql.rule.ts b/packages/eslint-plugin/src/rules/check-sql.rule.ts index f8d41436..1cf73ec4 100644 --- a/packages/eslint-plugin/src/rules/check-sql.rule.ts +++ b/packages/eslint-plugin/src/rules/check-sql.rule.ts @@ -66,6 +66,7 @@ const messages = { incorrectTypeAnnotations: `Query has incorrect type annotation.\n\tExpected: {{expected}}\n\t Actual: {{actual}}`, invalidTypeAnnotations: `Query has invalid type annotation (SafeQL does not support it. If you think it should, please open an issue)`, pluginError: `{{error}}`, + pluginSuggestion: "Apply suggested plugin fix", }; export type RuleMessage = keyof typeof messages; @@ -167,6 +168,7 @@ function checkConnection(params: { projectDir: params.projectDir, baseNode: params.tag.tag, typeParameter: params.tag.typeArguments, + pluginCtx: params.pluginCtx, }); } @@ -231,12 +233,14 @@ function resolvePluginTargetMatch( ): TargetMatch | false | undefined { if (!targetCtx) return undefined; - let result: TargetMatch | false | undefined; for (const plugin of plugins) { - const r = plugin.onTarget?.({ node: tag, context: targetCtx }); - if (r !== undefined) result = r; + const result = plugin.onTarget?.({ node: tag, context: targetCtx }); + if (result !== undefined) { + return result; + } } - return result; + + return undefined; } function reportPluginTypeCheck(params: { @@ -252,6 +256,7 @@ function reportPluginTypeCheck(params: { const nullAsOptional = connection.nullAsOptional ?? false; const nullAsUndefined = connection.nullAsUndefined ?? false; + const enforceType = connection.enforceType ?? "fix"; const report = typeCheck({ node: tag, @@ -268,16 +273,37 @@ function reportPluginTypeCheck(params: { }), }); - if (report) { + if (!report) return; + + const reportNode = report.node ?? tag.tag; + const reportData = { error: report.message }; + const reportFixData = report.fix; + const reportFix = reportFixData + ? (fixer: TSESLint.RuleFixer) => fixer.replaceText(reportFixData.node, reportFixData.text) + : undefined; + + if (!reportFix || enforceType === "fix") { context.report({ - node: report.node ?? tag.tag, + node: reportNode, messageId: "pluginError", - data: { error: report.message }, - fix: report.fix - ? (fixer) => fixer.replaceText(report.fix!.node, report.fix!.text) - : undefined, + data: reportData, + fix: reportFix, }); + return; } + + context.report({ + node: reportNode, + messageId: "pluginError", + data: reportData, + suggest: [ + { + messageId: "pluginSuggestion", + data: reportData, + fix: reportFix, + }, + ], + }); } function reportCheck(params: { @@ -322,6 +348,7 @@ function reportCheck(params: { checker, params.connection, params.context.sourceCode, + params.pluginCtx?.plugins, ), ), E.bindW("result", ({ query }) => { diff --git a/packages/eslint-plugin/src/rules/check-sql.test.ts b/packages/eslint-plugin/src/rules/check-sql.test.ts index 7ea0deab..a06317a8 100644 --- a/packages/eslint-plugin/src/rules/check-sql.test.ts +++ b/packages/eslint-plugin/src/rules/check-sql.test.ts @@ -13,9 +13,11 @@ import { afterAll, beforeAll, describe, it } from "vitest"; import rules from "."; import { RuleOptionConnection, RuleOptions } from "./RuleOptions"; +const RULE_TEST_TIMEOUT_MS = 10_000; + RuleTester.describe = describe; -RuleTester.it = it; -RuleTester.itOnly = it.only; +RuleTester.it = (name, fn) => it(name, fn, RULE_TEST_TIMEOUT_MS); +RuleTester.itOnly = (name, fn) => it.only(name, fn, RULE_TEST_TIMEOUT_MS); RuleTester.afterAll = afterAll; const ruleTester = new RuleTester({ @@ -210,6 +212,30 @@ RuleTester.describe("check-sql", () => { targets: [{ tag: "sql" }], keepAlive: false, }, + withPluginSourcemap: { + databaseUrl: `postgres://postgres:postgres@localhost:5432/${databaseName}`, + plugins: [ + { + package: path.resolve(__dirname, "./ts-fixture/sourcemap-plugin.ts"), + config: {}, + }, + ], + keepAlive: false, + }, + withPluginTargetPriority: { + databaseUrl: `postgres://postgres:postgres@localhost:5432/${databaseName}`, + plugins: [ + { + package: path.resolve(__dirname, "./ts-fixture/skip-target-plugin.ts"), + config: {}, + }, + { + package: path.resolve(__dirname, "./ts-fixture/sourcemap-plugin.ts"), + config: {}, + }, + ], + keepAlive: false, + }, withMemberTag: { databaseUrl: `postgres://postgres:postgres@localhost:5432/${databaseName}`, targets: [{ tag: "Db.sql" }], @@ -963,14 +989,23 @@ RuleTester.describe("check-sql", () => { invalid: [], }); + ruleTester.run("pg type to ts type check (inline type)", rules["check-sql"], { + valid: typeColumnTsTypeEntries.map(([colName, colType]) => ({ + name: `select ${colName} from table as ${colType} (using type reference)`, + options: withConnection(connections.withTag), + code: `sql<{ ${colName}: ${colType} }>\`select ${colName} from all_types\``, + })), + invalid: [], + }); + ruleTester.run("position", rules["check-sql"], { valid: [ { name: "control", - options: withConnection(connections.base), + options: withConnection(connections.withTag), code: ` function run(cert1: "HHA" | "RN", cert2: "LPN" | "CNA") { - return sql<{ id: number }[]>\` + return sql<{ id: number }>\` select id from caregiver where true @@ -984,13 +1019,13 @@ RuleTester.describe("check-sql", () => { }, ], invalid: [ - invalidPositionTestCase({ + invalidQueryAt({ code: "sql`select idd from caregiver`", error: 'column "idd" does not exist', line: 1, columns: [12, 15], }), - invalidPositionTestCase({ + invalidQueryAt({ code: normalizeIndent` sql\` select @@ -1003,7 +1038,7 @@ RuleTester.describe("check-sql", () => { line: 5, columns: [5, 15], }), - invalidPositionTestCase({ + invalidQueryAt({ code: normalizeIndent` function run(expr1: "HHA" | "RN", expr2: "LPN" | "CNN") { sql\` @@ -1022,7 +1057,7 @@ RuleTester.describe("check-sql", () => { line: 9, columns: [27, 35], }), - invalidPositionTestCase({ + invalidQueryAt({ code: normalizeIndent` function run(cert1: "HHA" | "RNA") { return sql\`select id from caregiver where certification = \${cert1}\` @@ -1032,7 +1067,7 @@ RuleTester.describe("check-sql", () => { line: 2, columns: [61, 69], }), - invalidPositionTestCase({ + invalidQueryAt({ code: normalizeIndent` function run(cert: "HHA" | "RN'") { return sql\`select id from caregiver where certification = \${cert}\` @@ -1042,13 +1077,13 @@ RuleTester.describe("check-sql", () => { line: 2, columns: [61, 68], }), - invalidPositionTestCase({ + invalidQueryAt({ code: "sql`select id, id from caregiver`", error: `Duplicate columns: caregiver.id, caregiver.id`, line: 1, columns: [12, 14], }), - invalidPositionTestCase({ + invalidQueryAt({ code: "sql`select id sele, certification sele from caregiver`", error: `Duplicate columns: caregiver.id (alias: sele), caregiver.certification (alias: sele)`, line: 1, @@ -1057,12 +1092,80 @@ RuleTester.describe("check-sql", () => { ], }); - ruleTester.run("pg type to ts type check (inline type)", rules["check-sql"], { - valid: typeColumnTsTypeEntries.map(([colName, colType]) => ({ - name: `select ${colName} from table as ${colType} (using type reference)`, - options: withConnection(connections.withTag), - code: `sql<{ ${colName}: ${colType} }>\`select ${colName} from all_types\``, - })), + ruleTester.run("plugin position", rules["check-sql"], { + valid: [], + invalid: [ + invalidQueryAt({ + connection: connections.withPluginSourcemap, + code: normalizeIndent` + declare function sql(strings: TemplateStringsArray, ...values: unknown[]): unknown; + declare function ident(value: string): unknown; + + sql\`SELECT 1 FROM \${ident("missing_person")}\`; + `, + error: 'relation "missing_person" does not exist', + line: 3, + columns: [19, 45], + }), + invalidQueryAt({ + connection: connections.withPluginSourcemap, + code: normalizeIndent` + declare function sql(strings: TemplateStringsArray, ...values: unknown[]): unknown; + declare function ident(value: string): unknown; + + sql\`SELECT id FROM \${ident("caregiver")} WHERE nonexistent = 1\`; + `, + error: 'column "nonexistent" does not exist', + line: 3, + columns: [48, 59], + }), + invalidQueryAt({ + connection: connections.withPluginSourcemap, + code: normalizeIndent` + declare function sql(strings: TemplateStringsArray, ...values: unknown[]): unknown; + declare function unnest2(): unknown; + + sql\` + SELECT bar + FROM \${unnest2()} AS foo(bar, baz) + WHERE nope = 1 + \`; + `, + error: 'column "nope" does not exist', + line: 6, + columns: [9, 13], + }), + invalidQueryAt({ + connection: connections.withPluginSourcemap, + code: normalizeIndent` + declare function sql(strings: TemplateStringsArray, ...values: unknown[]): unknown; + declare function jsonb(value: unknown): unknown; + + sql\` + SELECT \${jsonb([1, 2, 3])}::jsonb ->> 0 AS p + UNION + SELE2CT \${jsonb([1, "f", 3])}::jsonb ->> 0 AS p + \`; + `, + error: 'syntax error at or near "SELE2CT"', + line: 6, + columns: [3, 10], + }), + ], + }); + + ruleTester.run("plugin priority", rules["check-sql"], { + valid: [ + { + name: "first target plugin match wins", + options: [{ connections: [connections.withPluginTargetPriority] }], + code: normalizeIndent` + declare function sql(strings: TemplateStringsArray, ...values: unknown[]): unknown; + + sql\`TOTAL GARBAGE\`; + `, + }, + ], invalid: [], }); @@ -2102,31 +2205,6 @@ RuleTester.describe("check-sql", () => { ], }); - function invalidPositionTestCase(params: { - only?: boolean; - line: number; - columns: [number, number]; - error: string; - code: string; - }): InvalidTestCase { - return { - name: `${params.line}:[${params.columns[0]}:${params.columns[1]}] - ${params.error}`, - only: params.only ?? false, - options: withConnection(connections.withTag), - code: params.code, - errors: [ - { - messageId: "invalidQuery", - data: { error: params.error }, - line: params.line, - endLine: params.line, - column: params.columns[0], - endColumn: params.columns[1], - }, - ], - }; - } - ruleTester.run("local classes", rules["check-sql"], { valid: [ { @@ -2366,4 +2444,36 @@ RuleTester.describe("check-sql", () => { }, ], }); + + function invalidQueryAt({ + line, + columns, + error, + code, + connection, + }: { + line: number; + columns: [number, number]; + error: string; + code: string; + connection?: RuleOptionConnection; + }): InvalidTestCase { + const [column, endColumn] = columns; + + return { + name: `${line}:[${column}:${endColumn}] - ${error}`, + options: withConnection(connection ?? connections.withTag), + code, + errors: [ + { + messageId: "invalidQuery", + data: { error }, + line, + endLine: line, + column, + endColumn, + }, + ], + }; + } }); diff --git a/packages/eslint-plugin/src/rules/check-sql.utils.ts b/packages/eslint-plugin/src/rules/check-sql.utils.ts index 19ff5f65..d98dc99a 100644 --- a/packages/eslint-plugin/src/rules/check-sql.utils.ts +++ b/packages/eslint-plugin/src/rules/check-sql.utils.ts @@ -322,7 +322,7 @@ export function getConnectionStartegyByRuleOptionConnection(params: { } if (connection.plugins && connection.plugins.length > 0) { - return { type: "pluginsOnly", plugins: connection.plugins }; + return { type: "pluginsOnly", plugins: connection.plugins, projectDir }; } throw new Error( @@ -540,6 +540,7 @@ function isNullableResolvedTarget(target: ExpectedResolvedTarget | ResolvedTarge interface GetWordRangeInPositionParams { error: { + message: string; position: number; sourcemaps: QuerySourceMapEntry[]; }; @@ -547,48 +548,102 @@ interface GetWordRangeInPositionParams { sourceCode: Readonly; } -function getQueryErrorPosition(params: GetWordRangeInPositionParams) { - const range: [number, number] = [params.error.position, params.error.position + 1]; +function getQueryErrorPosition({ error, tag, sourceCode }: GetWordRangeInPositionParams) { + const sourceMaps = error.sourcemaps; + const position = error.position; - for (const entry of params.error.sourcemaps) { - const generatedLength = Math.max(0, entry.generated.end - entry.generated.start); - const originalLength = Math.max(0, entry.original.end - entry.original.start); - const adjustment = originalLength - generatedLength; + const matchingSourceMap = sourceMaps.find( + (sourceMap) => position >= sourceMap.generated.start && position < sourceMap.generated.end, + ); - if (range[0] >= entry.generated.start && range[1] <= entry.generated.end) { - range[0] = entry.original.start + entry.offset; - range[1] = entry.original.start + entry.offset + 1; - continue; + const sourceRange: [number, number] = matchingSourceMap + ? [ + matchingSourceMap.original.start + matchingSourceMap.offset, + matchingSourceMap.original.start + matchingSourceMap.original.text.length, + ] + : getSourceRange(position, sourceMaps); + + const syntaxErrorToken = error.message.match(/syntax error at or near "([^"]+)"/)?.[1]; + + if (syntaxErrorToken) { + const templateText = sourceCode.text.slice(tag.quasi.range[0], tag.quasi.range[1]); + const tokenIndex = findNearestMatchIndex(templateText, syntaxErrorToken, sourceRange[0]); + + if (tokenIndex !== undefined) { + return { + sourceLocation: getSourceLocation(tag, sourceCode, [ + tokenIndex, + tokenIndex + syntaxErrorToken.length, + ]), + }; } + } - if (params.error.position >= entry.generated.start) { - range[0] += adjustment; + return { + sourceLocation: getSourceLocation( + tag, + sourceCode, + sourceRange, + matchingSourceMap === undefined, + ), + }; +} + +function getSourceRange(position: number, sourceMaps: QuerySourceMapEntry[]): [number, number] { + let positionOffset = 0; + + for (const sourceMap of sourceMaps) { + if (position < sourceMap.generated.end) { + continue; } - if (params.error.position >= entry.generated.end) { - range[1] += adjustment; + positionOffset += sourceMap.original.text.length - sourceMap.generated.text.length; + } + + return [position + positionOffset, position + positionOffset + 1]; +} + +function findNearestMatchIndex(text: string, searchText: string, position: number) { + let closestIndex: number | undefined; + + for ( + let currentIndex = text.indexOf(searchText); + currentIndex !== -1; + currentIndex = text.indexOf(searchText, currentIndex + 1) + ) { + if ( + closestIndex === undefined || + Math.abs(currentIndex - position) < Math.abs(closestIndex - position) + ) { + closestIndex = currentIndex; } } - const start = params.sourceCode.getLocFromIndex(params.tag.quasi.range[0] + range[0]); - const startLineText = params.sourceCode.getLines()[start.line - 1]; - const remainingLineText = startLineText.substring(start.column); - const remainingWordLength = (remainingLineText.match(/^[\w.{}'$"]+/)?.at(0)?.length ?? 1) - 1; + return closestIndex; +} + +function getSourceLocation( + tag: TSESTree.TaggedTemplateExpression, + sourceCode: Readonly, + range: [number, number], + extendToWord = false, +): TSESTree.SourceLocation { + const start = sourceCode.getLocFromIndex(tag.quasi.range[0] + range[0]); + const end = sourceCode.getLocFromIndex(tag.quasi.range[0] + range[1]); + + if (!extendToWord) { + return { start, end }; + } - const end = params.sourceCode.getLocFromIndex(params.tag.quasi.range[0] + range[1]); + const lineTail = sourceCode.getLines()[start.line - 1].substring(start.column); + const wordLength = (lineTail.match(/^[\w.{}'$"]+/)?.at(0)?.length ?? 1) - 1; - const sourceLocation: TSESTree.SourceLocation = { - start: start, + return { + start, end: { line: end.line, - column: end.column + remainingWordLength, + column: end.column + wordLength, }, }; - - return { - range, - sourceLocation: sourceLocation, - remainingLineText: remainingLineText, - remainingWordLength: remainingWordLength, - }; } + diff --git a/packages/eslint-plugin/src/rules/ts-fixture/skip-target-plugin.ts b/packages/eslint-plugin/src/rules/ts-fixture/skip-target-plugin.ts new file mode 100644 index 00000000..cfe6d4c6 --- /dev/null +++ b/packages/eslint-plugin/src/rules/ts-fixture/skip-target-plugin.ts @@ -0,0 +1,13 @@ +import { definePlugin } from "@ts-safeql/plugin-utils"; + +export default definePlugin({ + name: "skip-target-test", + package: "./src/rules/ts-fixture/skip-target-plugin.ts", + setup() { + return { + onTarget({ node }) { + return node.tag.type === "Identifier" && node.tag.name === "sql" ? false : undefined; + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/ts-fixture/sourcemap-plugin.ts b/packages/eslint-plugin/src/rules/ts-fixture/sourcemap-plugin.ts new file mode 100644 index 00000000..f885d640 --- /dev/null +++ b/packages/eslint-plugin/src/rules/ts-fixture/sourcemap-plugin.ts @@ -0,0 +1,43 @@ +import type { TSESTree } from "@typescript-eslint/utils"; +import { definePlugin } from "@ts-safeql/plugin-utils"; + +export default definePlugin({ + name: "sourcemap-test", + package: "./src/rules/ts-fixture/sourcemap-plugin.ts", + setup() { + return { + onTarget({ node }) { + return node.tag.type === "Identifier" && node.tag.name === "sql" + ? { skipTypeAnnotations: true } + : undefined; + }, + onExpression({ node }) { + if (isCall(node, "ident")) { + const argument = node.arguments[0]; + if (argument?.type === "Literal" && typeof argument.value === "string") { + return `"${argument.value.replaceAll('"', '""')}"`; + } + } + + if (isCall(node, "jsonb")) { + return "$N::jsonb"; + } + + if (isCall(node, "unnest2")) { + return "unnest($N::int4[], $N::text[])"; + } + + return undefined; + }, + }; + }, +}); + +function isCall( + node: TSESTree.Expression, + name: string, +): node is TSESTree.CallExpression & { callee: TSESTree.Identifier } { + return ( + node.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === name + ); +} diff --git a/packages/eslint-plugin/src/utils/ts-pg.utils.test.ts b/packages/eslint-plugin/src/utils/ts-pg.utils.test.ts new file mode 100644 index 00000000..4bee53b8 --- /dev/null +++ b/packages/eslint-plugin/src/utils/ts-pg.utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { replacePluginPlaceholders } from "./ts-pg.utils"; + +describe("replacePluginPlaceholders", () => { + it("replaces bare plugin placeholders", () => { + expect(replacePluginPlaceholders("$N::jsonb, unnest($N::int4[])", 0)).toEqual({ + text: "$1::jsonb, unnest($2::int4[])", + nextIndex: 2, + }); + }); + + it("leaves quoted literals and comments untouched", () => { + expect(replacePluginPlaceholders(`'$N' || $N /* $N */ -- $N\n"$N" || $N`, 1)).toEqual({ + text: `'$N' || $2 /* $N */ -- $N\n"$N" || $3`, + nextIndex: 3, + }); + }); + + it("leaves dollar-quoted bodies untouched", () => { + expect(replacePluginPlaceholders("$$$N$$ || $N || $tag$$N$tag$", 0)).toEqual({ + text: "$$$N$$ || $1 || $tag$$N$tag$", + nextIndex: 1, + }); + }); + + it("does not rewrite placeholder-like identifiers", () => { + expect(replacePluginPlaceholders("$Nfoo || $N", 4)).toEqual({ + text: "$Nfoo || $5", + nextIndex: 5, + }); + }); +}); diff --git a/packages/eslint-plugin/src/utils/ts-pg.utils.ts b/packages/eslint-plugin/src/utils/ts-pg.utils.ts index 8ec1205d..ccf40e7c 100644 --- a/packages/eslint-plugin/src/utils/ts-pg.utils.ts +++ b/packages/eslint-plugin/src/utils/ts-pg.utils.ts @@ -5,6 +5,7 @@ import { normalizeIndent, QuerySourceMapEntry, } from "@ts-safeql/shared"; +import type { SafeQLPlugin } from "@ts-safeql/plugin-utils"; import { TSESTreeToTSNode } from "@typescript-eslint/typescript-estree"; import { ParserServices, TSESLint, TSESTree } from "@typescript-eslint/utils"; import ts, { TypeChecker } from "typescript"; @@ -19,6 +20,7 @@ export function mapTemplateLiteralToQueryText( checker: ts.TypeChecker, options: RuleOptionConnection, sourceCode: Readonly, + plugins: SafeQLPlugin[] = [], ) { let $idx = 0; let $queryText = ""; @@ -33,8 +35,44 @@ export function mapTemplateLiteralToQueryText( const position = $queryText.length; const expression = quasi.expressions[quasiIdx]; + const expressionType = mapExpressionToTsTypeString({ expression, parser, checker }); + const pluginExpression = resolvePluginExpression({ + expression, + checker, + plugins, + precedingSQL: $queryText, + tsNode: expressionType.node, + tsType: expressionType.type, + }); + + if (pluginExpression === false) { + return E.right(null); + } + + if (typeof pluginExpression === "string") { + const generatedPlaceholder = replacePluginPlaceholders(pluginExpression, $idx); + $idx = generatedPlaceholder.nextIndex; + const generatedText = generatedPlaceholder.text; + $queryText += generatedText; + + sourcemaps.push({ + original: { + start: expression.range[0] - quasi.range[0] - 2, + end: expression.range[1] - quasi.range[0] + 1, + text: sourceCode.text.slice(expression.range[0] - 2, expression.range[1] + 1), + }, + generated: { + start: position, + end: position + generatedText.length, + text: generatedText, + }, + offset: 0, + }); - const pgType = pipe(mapExpressionToTsTypeString({ expression, parser, checker }), (params) => + continue; + } + + const pgType = pipe(expressionType, (params) => getPgTypeFromTsType({ ...params, checker, options }), ); @@ -126,7 +164,7 @@ export function mapTemplateLiteralToQueryText( sourcemaps.push({ original: { start: expression.range[0] - quasi.range[0] - 2, - end: expression.range[1] - quasi.range[0], + end: expression.range[1] - quasi.range[0] + 1, text: sourceCode.text.slice(expression.range[0] - 2, expression.range[1] + 1), }, generated: { @@ -141,6 +179,67 @@ export function mapTemplateLiteralToQueryText( return E.right({ text: $queryText, sourcemaps }); } +export function replacePluginPlaceholders( + text: string, + startIndex: number, +): { text: string; nextIndex: number } { + let nextIndex = startIndex; + let output = ""; + let cursor = 0; + + while (cursor < text.length) { + const dollarQuote = getDollarQuoteDelimiter(text, cursor); + if (dollarQuote) { + const end = text.indexOf(dollarQuote, cursor + dollarQuote.length); + const sliceEnd = end === -1 ? text.length : end + dollarQuote.length; + output += text.slice(cursor, sliceEnd); + cursor = sliceEnd; + continue; + } + + if (text.startsWith("--", cursor)) { + const end = text.indexOf("\n", cursor + 2); + const sliceEnd = end === -1 ? text.length : end; + output += text.slice(cursor, sliceEnd); + cursor = sliceEnd; + continue; + } + + if (text.startsWith("/*", cursor)) { + const end = text.indexOf("*/", cursor + 2); + const sliceEnd = end === -1 ? text.length : end + 2; + output += text.slice(cursor, sliceEnd); + cursor = sliceEnd; + continue; + } + + if (text[cursor] === "'") { + const end = findQuotedLiteralEnd(text, cursor, "'"); + output += text.slice(cursor, end); + cursor = end; + continue; + } + + if (text[cursor] === '"') { + const end = findQuotedLiteralEnd(text, cursor, '"'); + output += text.slice(cursor, end); + cursor = end; + continue; + } + + if (text.startsWith("$N", cursor) && !isIdentifierChar(text[cursor + 2])) { + output += `$${++nextIndex}`; + cursor += 2; + continue; + } + + output += text[cursor]; + cursor += 1; + } + + return { text: output, nextIndex }; +} + function mapExpressionToTsTypeString(params: { expression: TSESTree.Expression; parser: ParserServices; @@ -154,6 +253,63 @@ function mapExpressionToTsTypeString(params: { }; } +function resolvePluginExpression(params: { + expression: TSESTree.Expression; + checker: ts.TypeChecker; + plugins: SafeQLPlugin[]; + precedingSQL: string; + tsNode: ts.Node; + tsType: ts.Type; +}): string | false | undefined { + for (const plugin of params.plugins) { + const pluginResult = plugin.onExpression?.({ + node: params.expression, + context: { + precedingSQL: params.precedingSQL, + checker: params.checker, + tsNode: params.tsNode, + tsType: params.tsType, + tsTypeText: params.checker.typeToString(params.tsType), + }, + }); + + if (pluginResult !== undefined) { + return pluginResult; + } + } + + return undefined; +} + +function getDollarQuoteDelimiter(text: string, cursor: number): string | undefined { + const match = /^\$[A-Za-z_][A-Za-z0-9_]*?\$|^\$\$/.exec(text.slice(cursor)); + return match?.[0]; +} + +function findQuotedLiteralEnd(text: string, start: number, quote: "'" | '"'): number { + let cursor = start + 1; + + while (cursor < text.length) { + if (text[cursor] !== quote) { + cursor += 1; + continue; + } + + if (text[cursor + 1] === quote) { + cursor += 2; + continue; + } + + return cursor + 1; + } + + return text.length; +} + +function isIdentifierChar(char: string | undefined): boolean { + return char !== undefined && /[A-Za-z0-9_]/.test(char); +} + const tsTypeToPgTypeMap: Record = { number: "int", string: "text", diff --git a/packages/plugin-utils/build.config.ts b/packages/plugin-utils/build.config.ts index 1b35f355..c7f9ee8f 100644 --- a/packages/plugin-utils/build.config.ts +++ b/packages/plugin-utils/build.config.ts @@ -5,7 +5,7 @@ export default defineBuildConfig([ entries: ["src/index", "src/plugin-test-driver"], declaration: true, sourcemap: true, - externals: ["@typescript-eslint/parser", "@typescript-eslint/utils", "typescript"], + externals: ["@typescript-eslint/parser", "@typescript-eslint/utils", "typescript", "tsx"], rollup: { emitCJS: true, }, diff --git a/packages/plugin-utils/package.json b/packages/plugin-utils/package.json index d461edaf..59fa25b8 100644 --- a/packages/plugin-utils/package.json +++ b/packages/plugin-utils/package.json @@ -37,7 +37,8 @@ "unbuild": "catalog:" }, "dependencies": { - "postgres": "catalog:" + "postgres": "catalog:", + "tsx": "catalog:" }, "peerDependencies": { "@typescript-eslint/utils": "^8.0.0", diff --git a/packages/plugin-utils/src/resolve.ts b/packages/plugin-utils/src/resolve.ts index ccf54fc0..37315651 100644 --- a/packages/plugin-utils/src/resolve.ts +++ b/packages/plugin-utils/src/resolve.ts @@ -16,13 +16,16 @@ export class PluginManager { descriptors: PluginDescriptor[], projectDir: string, ): Promise { - const plugins = await Promise.all(descriptors.map((d) => this.resolveOne(d, projectDir))); + const plugins = descriptors.map((d) => this.resolveOne(d, projectDir)); return this.pickConnection(plugins); } - getCachedConnection(descriptors: PluginDescriptor[]): ResolvedConnection | undefined { + getCachedConnection( + descriptors: PluginDescriptor[], + projectDir: string, + ): ResolvedConnection | undefined { const plugins = descriptors - .map((d) => this.pluginCache.get(this.getCacheKey(d))) + .map((d) => this.pluginCache.get(this.getCacheKey(d, projectDir))) .filter((p): p is SafeQLPlugin => p !== undefined); return this.pickConnection(plugins); @@ -33,9 +36,9 @@ export class PluginManager { return descriptors.map((d) => this.resolveOneSync(d, projectDir)); } - evictPlugins(descriptors: PluginDescriptor[]): void { + evictPlugins(descriptors: PluginDescriptor[], projectDir: string): void { for (const descriptor of descriptors) { - this.pluginCache.delete(this.getCacheKey(descriptor)); + this.pluginCache.delete(this.getCacheKey(descriptor, projectDir)); } } @@ -55,54 +58,44 @@ export class PluginManager { return result; } - private getCacheKey(descriptor: PluginDescriptor): string { - return `${descriptor.package}:${stableStringify(descriptor.config ?? {})}`; + private getCacheKey(descriptor: PluginDescriptor, projectDir: string): string { + return `${getResolvedPackageKey(descriptor.package, projectDir)}:${stableStringify( + descriptor.config ?? {}, + )}`; } private resolveOneSync(descriptor: PluginDescriptor, projectDir: string): SafeQLPlugin { - const key = this.getCacheKey(descriptor); + const key = this.getCacheKey(descriptor, projectDir); const cached = this.pluginCache.get(key); if (cached) return cached; - const projectRequire = createRequire(path.resolve(projectDir, "package.json")); - - let mod: unknown; - try { - mod = projectRequire(descriptor.package); - } catch { - throw new Error( - `SafeQL plugin "${descriptor.package}" could not be loaded. Is it installed?`, - ); - } + const mod = this.loadModuleSync(descriptor.package, projectDir); const plugin = this.extractPlugin(descriptor, mod); this.pluginCache.set(key, plugin); return plugin; } - private async resolveOne( - descriptor: PluginDescriptor, - projectDir: string, - ): Promise { - const key = this.getCacheKey(descriptor); - const cached = this.pluginCache.get(key); - if (cached) return cached; + private resolveOne(descriptor: PluginDescriptor, projectDir: string): SafeQLPlugin { + return this.resolveOneSync(descriptor, projectDir); + } + private loadModuleSync(packageName: string, projectDir: string): unknown { const projectRequire = createRequire(path.resolve(projectDir, "package.json")); - let mod: unknown; try { - const resolved = projectRequire.resolve(descriptor.package); - mod = await import(resolved); - } catch { - throw new Error( - `SafeQL plugin "${descriptor.package}" could not be loaded. Is it installed?`, - ); - } + if (isLocalPath(packageName)) { + const pluginPath = path.resolve(projectDir, packageName); + const tsx = createRequire(import.meta.url)(`tsx/cjs/api`); + return tsx.require(pluginPath, import.meta.url); + } - const plugin = this.extractPlugin(descriptor, mod); - this.pluginCache.set(key, plugin); - return plugin; + return projectRequire(packageName); + } catch (cause) { + throw new Error(`SafeQL plugin "${packageName}" could not be loaded. Is it installed?`, { + cause, + }); + } } private extractPlugin(descriptor: PluginDescriptor, mod: unknown): SafeQLPlugin { @@ -173,3 +166,11 @@ function stableStringify(value: unknown): string { .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`); return `{${entries.join(",")}}`; } + +function isLocalPath(packageName: string): boolean { + return packageName.startsWith(".") || path.isAbsolute(packageName); +} + +function getResolvedPackageKey(packageName: string, projectDir: string): string { + return isLocalPath(packageName) ? path.resolve(projectDir, packageName) : packageName; +} diff --git a/packages/plugins/slonik/build.config.ts b/packages/plugins/slonik/build.config.ts new file mode 100644 index 00000000..8fa76346 --- /dev/null +++ b/packages/plugins/slonik/build.config.ts @@ -0,0 +1,18 @@ +import { defineBuildConfig } from "unbuild"; + +export default defineBuildConfig([ + { + entries: ["src/index"], + declaration: true, + sourcemap: true, + rollup: { + emitCJS: true, + }, + externals: [ + "@ts-safeql/plugin-utils", + "@ts-safeql/zod-annotator", + "@typescript-eslint/utils", + "typescript", + ], + }, +]); diff --git a/packages/plugins/slonik/package.json b/packages/plugins/slonik/package.json new file mode 100644 index 00000000..d4498196 --- /dev/null +++ b/packages/plugins/slonik/package.json @@ -0,0 +1,41 @@ +{ + "name": "@ts-safeql/plugin-slonik", + "version": "4.2.0", + "license": "MIT", + "type": "module", + "types": "dist/index.d.ts", + "module": "dist/index.mjs", + "main": "dist/index.cjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "unbuild", + "dev": "unbuild --stub", + "typecheck": "tsc -b", + "test": "vitest --pool=forks --run", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@ts-safeql/eslint-plugin": "workspace:*", + "@ts-safeql/plugin-utils": "workspace:*", + "@ts-safeql/test-utils": "workspace:*", + "@ts-safeql/zod-annotator": "workspace:*", + "@types/node": "catalog:", + "@typescript-eslint/parser": "catalog:", + "@typescript-eslint/rule-tester": "catalog:", + "postgres": "catalog:", + "slonik": "^48.13.2", + "zod": "^4.3.6", + "typescript": "catalog:", + "unbuild": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "slonik": "^48.13.2" + } +} diff --git a/packages/plugins/slonik/src/index.ts b/packages/plugins/slonik/src/index.ts new file mode 100644 index 00000000..3b878587 --- /dev/null +++ b/packages/plugins/slonik/src/index.ts @@ -0,0 +1 @@ +export { default } from "./plugin"; diff --git a/packages/plugins/slonik/src/plugin.integration.test.ts b/packages/plugins/slonik/src/plugin.integration.test.ts new file mode 100644 index 00000000..16b6bad0 --- /dev/null +++ b/packages/plugins/slonik/src/plugin.integration.test.ts @@ -0,0 +1,409 @@ +import { generateTestDatabaseName, setupTestDatabase } from "@ts-safeql/test-utils"; +import { RuleTester } from "@typescript-eslint/rule-tester"; +import parser from "@typescript-eslint/parser"; +import path from "path"; +import { Sql } from "postgres"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { rules } from "@ts-safeql/eslint-plugin"; +import type { PluginDescriptor } from "@ts-safeql/plugin-utils"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; +RuleTester.afterAll = afterAll; + +const ruleTester = new RuleTester({ + languageOptions: { + parser, + parserOptions: { + projectService: true, + tsconfigRootDir: path.resolve(__dirname, "./ts-fixture"), + }, + }, +}); + +RuleTester.describe("slonik integration", () => { + const databaseName = generateTestDatabaseName(); + let sql!: Sql; + let dropFn!: () => Promise; + + beforeAll(async () => { + const testDatabase = await setupTestDatabase({ + databaseName, + postgresUrl: "postgres://postgres:postgres@localhost:5432/postgres", + }); + + dropFn = testDatabase.drop; + sql = testDatabase.sql; + + await sql.unsafe(` + CREATE TABLE person ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL, + bio TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + }); + + RuleTester.afterAll(async () => { + await sql.end(); + await dropFn(); + }); + + const slonikConnection = { + databaseUrl: `postgres://postgres:postgres@localhost:5432/${databaseName}`, + plugins: [{ package: "@ts-safeql/plugin-slonik", config: {} }] satisfies PluginDescriptor[], + keepAlive: false, + }; + + function withConnection(overrides?: Record) { + return [{ connections: [{ ...slonikConnection, ...overrides }] }]; + } + + const s = (code: string) => `import { sql } from "slonik"; ${code}`; + const sz = (code: string) => `import { sql } from "slonik"; import { z } from "zod"; ${code}`; + + ruleTester.run("sql.unsafe", rules["check-sql"], { + valid: [ + { name: "valid query", options: withConnection(), code: s("sql.unsafe`SELECT 1 AS id`") }, + { + name: "with type annotation", + options: withConnection(), + code: s("sql.unsafe<{ id: number }>`SELECT 1 AS id`"), + }, + ], + invalid: [ + { + name: "invalid column", + options: withConnection(), + code: s("sql.unsafe`SELECT nonexistent FROM person`"), + errors: [{ messageId: "invalidQuery" }], + }, + { + name: "invalid table", + options: withConnection(), + code: s("sql.unsafe`SELECT 1 FROM nonexistent`"), + errors: [{ messageId: "invalidQuery" }], + }, + ], + }); + + ruleTester.run("sql.typeAlias", rules["check-sql"], { + valid: [ + { + name: "valid query, no type annotation needed", + options: withConnection(), + code: s('sql.typeAlias("id")`SELECT id FROM person`'), + }, + ], + invalid: [ + { + name: "invalid column still caught", + options: withConnection(), + code: s('sql.typeAlias("id")`SELECT nonexistent FROM person`'), + errors: [{ messageId: "invalidQuery" }], + }, + ], + }); + + ruleTester.run("sql.fragment", rules["check-sql"], { + valid: [ + { + name: "standalone — not linted", + options: withConnection(), + code: s("sql.fragment`WHERE id = 1`"), + }, + { + name: "invalid SQL inside fragment — not validated", + options: withConnection(), + code: s("sql.fragment`TOTAL GARBAGE`"), + }, + ], + invalid: [], + }); + + ruleTester.run("sql.type — inline schema", rules["check-sql"], { + valid: [ + { + name: "correct schema", + options: withConnection(), + code: sz( + "sql.type(z.object({ id: z.number(), name: z.string() }))`SELECT id, name FROM person`", + ), + }, + { + name: "nullable column", + options: withConnection(), + code: sz("sql.type(z.object({ bio: z.string().nullable() }))`SELECT bio FROM person`"), + }, + ], + invalid: [ + { + name: "wrong field type → suggestion by default", + options: withConnection(), + code: sz( + "sql.type(z.object({ id: z.string(), name: z.string() }))`SELECT id, name FROM person`", + ), + errors: [ + { + messageId: "pluginError", + suggestions: [ + { + messageId: "pluginSuggestion", + output: sz( + "sql.type(z.object({ id: z.number(), name: z.string() }))`SELECT id, name FROM person`", + ), + }, + ], + }, + ], + }, + { + name: "missing field → suggestion by default", + options: withConnection(), + code: sz("sql.type(z.object({ id: z.number() }))`SELECT id, name FROM person`"), + errors: [ + { + messageId: "pluginError", + suggestions: [ + { + messageId: "pluginSuggestion", + output: sz( + "sql.type(z.object({ id: z.number(), name: z.string() }))`SELECT id, name FROM person`", + ), + }, + ], + }, + ], + }, + { + name: "extra field → suggestion by default", + options: withConnection(), + code: sz( + "sql.type(z.object({ id: z.number(), name: z.string(), age: z.number() }))`SELECT id, name FROM person`", + ), + errors: [ + { + messageId: "pluginError", + suggestions: [ + { + messageId: "pluginSuggestion", + output: sz( + "sql.type(z.object({ id: z.number(), name: z.string() }))`SELECT id, name FROM person`", + ), + }, + ], + }, + ], + }, + { + name: "nullable mismatch → suggestion by default", + options: withConnection(), + code: sz("sql.type(z.object({ bio: z.string() }))`SELECT bio FROM person`"), + errors: [ + { + messageId: "pluginError", + suggestions: [ + { + messageId: "pluginSuggestion", + output: sz( + "sql.type(z.object({ bio: z.string().nullable() }))`SELECT bio FROM person`", + ), + }, + ], + }, + ], + }, + { + name: "wrong field type → auto-fix when enforceType is fix", + options: withConnection({ enforceType: "fix" }), + code: sz( + "sql.type(z.object({ id: z.string(), name: z.string() }))`SELECT id, name FROM person`", + ), + output: sz( + "sql.type(z.object({ id: z.number(), name: z.string() }))`SELECT id, name FROM person`", + ), + errors: [{ messageId: "pluginError" }], + }, + ], + }); + + ruleTester.run("sql.type — referenced schema variable", rules["check-sql"], { + valid: [ + { + name: "schema defined as const", + options: withConnection(), + code: sz( + "const PersonRow = z.object({ id: z.number(), name: z.string() }); sql.type(PersonRow)`SELECT id, name FROM person`", + ), + }, + ], + invalid: [], + }); + + ruleTester.run("expression helpers", rules["check-sql"], { + valid: [ + { + name: "sql.json → json parameter", + options: withConnection(), + code: s("sql.unsafe`SELECT ${sql.json({ a: 1 })}`"), + }, + { + name: "sql.jsonb → jsonb parameter", + options: withConnection(), + code: s("sql.unsafe`SELECT ${sql.jsonb([1, 2, 3])}`"), + }, + { + name: "sql.binary → bytea parameter", + options: withConnection(), + code: s('const buf = Buffer.from("x"); sql.unsafe`SELECT ${sql.binary(buf)}`'), + }, + { + name: "sql.date → date parameter", + options: withConnection(), + code: s("sql.unsafe`SELECT ${sql.date(new Date())}`"), + }, + { + name: "sql.timestamp → timestamptz parameter", + options: withConnection(), + code: s("sql.unsafe`SELECT ${sql.timestamp(new Date())}`"), + }, + { + name: "sql.interval → interval parameter", + options: withConnection(), + code: s("sql.unsafe`SELECT ${sql.interval({ days: 3 })}`"), + }, + { + name: "sql.uuid → uuid parameter", + options: withConnection(), + code: s('sql.unsafe`SELECT ${sql.uuid("00000000-0000-0000-0000-000000000000")}`'), + }, + { + name: "sql.array → typed array parameter", + options: withConnection(), + code: s('sql.unsafe`SELECT ${sql.array([1, 2, 3], "int4")}`'), + }, + { + name: "sql.array in ANY() pattern", + options: withConnection(), + code: s( + 'sql.unsafe`SELECT id FROM person WHERE id = ANY(${sql.array([1, 2, 3], "int4")})`', + ), + }, + { + name: "sql.identifier — single name", + options: withConnection(), + code: s('sql.unsafe`SELECT id, name FROM ${sql.identifier(["person"])}`'), + }, + { + name: "sql.identifier — schema-qualified", + options: withConnection(), + code: s( + 'sql.unsafe`SELECT typname::text FROM ${sql.identifier(["pg_catalog", "pg_type"])} LIMIT 1`', + ), + }, + { + name: "plain variable → default $N parameter", + options: withConnection(), + code: s("const x = 42; sql.unsafe`SELECT id FROM person WHERE id = ${x}`"), + }, + { + name: "multiple expressions in one query", + options: withConnection(), + code: s("sql.unsafe`SELECT ${sql.json({ a: 1 })} AS a, ${sql.json({ b: 2 })} AS b`"), + }, + ], + invalid: [], + }); + + ruleTester.run("fragment embedding", rules["check-sql"], { + valid: [ + { + name: "fragment variable → inline SQL", + options: withConnection(), + code: s( + "const where = sql.fragment`WHERE id = 1`; sql.unsafe`SELECT * FROM person ${where}`", + ), + }, + { + name: "nested fragments → inline SQL", + options: withConnection(), + code: s( + "const cond = sql.fragment`id = 1`; const where = sql.fragment`WHERE ${cond}`; sql.unsafe`SELECT * FROM person ${where}`", + ), + }, + { + name: "reused fragment in same template → inline SQL twice", + options: withConnection(), + code: s( + "const cond = sql.fragment`id = 1`; const where = sql.fragment`WHERE ${cond} OR ${cond}`; sql.unsafe`SELECT * FROM person ${where}`", + ), + }, + { + name: "fragment from parent block scope → inline SQL", + options: withConnection(), + code: s( + "function query() { const where = sql.fragment`WHERE id = 1`; if (true) return sql.unsafe`SELECT * FROM person ${where}`; throw new Error('unreachable'); }", + ), + }, + ], + invalid: [ + { + name: "fragment with invalid SQL is caught", + options: withConnection(), + code: s( + "const where = sql.fragment`WHERE nonexistent = 1`; sql.unsafe`SELECT * FROM person ${where}`", + ), + errors: [{ messageId: "invalidQuery" }], + }, + ], + }); + + ruleTester.run("sql.unnest type extraction", rules["check-sql"], { + valid: [ + { + name: "sql.unnest with string type names", + options: withConnection(), + code: s( + 'sql.unsafe`SELECT bar, baz FROM ${sql.unnest([[1, "foo"], [2, "bar"]], ["int4", "text"])} AS foo(bar, baz)`', + ), + }, + { + name: "dynamic identifier segment skips query", + options: withConnection(), + code: s( + 'const schema = "public"; sql.unsafe`SELE2CT * FROM ${sql.identifier([schema, "person"])}`', + ), + }, + ], + invalid: [], + }); + + ruleTester.run("sql.literalValue embedding", rules["check-sql"], { + valid: [ + { + name: "sql.literalValue → embed as quoted literal", + options: withConnection(), + code: s('sql.unsafe`SELECT ${sql.literalValue("foo")}`'), + }, + ], + invalid: [], + }); + + ruleTester.run("sql.join (query skipped)", rules["check-sql"], { + valid: [ + { + name: "sql.join — comma separated", + options: withConnection(), + code: s("sql.unsafe`SELECT ${sql.join([1, 2, 3], sql.fragment`, `)}`"), + }, + { + name: "sql.join — boolean expression", + options: withConnection(), + code: s("sql.unsafe`SELECT ${sql.join([1, 2], sql.fragment` AND `)}`"), + }, + ], + invalid: [], + }); +}); diff --git a/packages/plugins/slonik/src/plugin.test.ts b/packages/plugins/slonik/src/plugin.test.ts new file mode 100644 index 00000000..3ac94a25 --- /dev/null +++ b/packages/plugins/slonik/src/plugin.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { PluginTestDriver, type ToSQLResult } from "@ts-safeql/plugin-utils/testing"; +import plugin from "./plugin"; + +const driver = new PluginTestDriver({ + plugin: plugin.factory({}), + projectDir: process.cwd(), +}); + +afterAll(() => driver.teardown()); + +type Case = { + name: string; + /** Extra imports. Set to `null` to omit the default slonik import. */ + imports?: string[] | null; + input: string; + output: ToSQLResult; +}; + +const cases: Case[] = [ + { name: "sql.unsafe", input: "sql.unsafe`SELECT 1`", output: { sql: "SELECT 1" } }, + { + name: "aliased slonik import", + imports: ['import { sql as slonikSql } from "slonik"'], + input: "slonikSql.unsafe`SELECT 1`", + output: { sql: "SELECT 1" }, + }, + { name: "sql.prepared", input: "sql.prepared`SELECT 1`", output: { sql: "SELECT 1" } }, + { + name: "sql.typeAlias", + input: 'sql.typeAlias("id")`SELECT 1 AS id`', + output: { sql: "SELECT 1 AS id" }, + }, + { + name: "sql.type with zod schema", + imports: ['import { z } from "zod"'], + input: "sql.type(z.object({ id: z.number() }))`SELECT 1 AS id`", + output: { sql: "SELECT 1 AS id" }, + }, + { + name: "sql.fragment (skipped)", + input: "sql.fragment`WHERE id = 1`", + output: { skipped: true }, + }, + { + name: "non-slonik sql tag is ignored", + imports: null, + input: "const sql = { unsafe: String.raw }; sql.unsafe`TOTAL GARBAGE`", + output: { skipped: true }, + }, + { + name: "sql.identifier (multi)", + input: 'sql.unsafe`SELECT * FROM ${sql.identifier(["public", "users"])}`', + output: { sql: 'SELECT * FROM "public"."users"' }, + }, + { + name: "sql.identifier (single)", + input: 'sql.unsafe`SELECT * FROM ${sql.identifier(["users"])}`', + output: { sql: 'SELECT * FROM "users"' }, + }, + { + name: "sql.identifier escapes embedded quotes", + input: 'sql.unsafe`SELECT * FROM ${sql.identifier(["foo\\"bar"])}`', + output: { sql: 'SELECT * FROM "foo""bar"' }, + }, + { + name: "sql.identifier with dynamic segment → query skipped", + input: + 'const schema = "public"; sql.unsafe`SELECT * FROM ${sql.identifier([schema, "users"])}`', + output: { sql: "SELECT * FROM /* skipped */" }, + }, + { + name: "sql.json → ::json", + input: "sql.unsafe`SELECT ${sql.json({ a: 1 })}`", + output: { sql: "SELECT $N::json" }, + }, + { + name: "sql.jsonb → ::jsonb", + input: "sql.unsafe`SELECT ${sql.jsonb({ a: 1 })}`", + output: { sql: "SELECT $N::jsonb" }, + }, + { + name: "non-slonik helper methods are not translated", + input: + 'const helper = { jsonb(value: string) { return value; } }; sql.unsafe`SELECT ${helper.jsonb("x")}`', + output: { sql: "SELECT $1" }, + }, + { + name: "sql.binary → ::bytea", + input: 'const buf = Buffer.from("x"); sql.unsafe`SELECT ${sql.binary(buf)}`', + output: { sql: "SELECT $N::bytea" }, + }, + { + name: "sql.date → ::date", + input: "sql.unsafe`SELECT ${sql.date(new Date())}`", + output: { sql: "SELECT $N::date" }, + }, + { + name: "sql.timestamp → ::timestamptz", + input: "sql.unsafe`SELECT ${sql.timestamp(new Date())}`", + output: { sql: "SELECT $N::timestamptz" }, + }, + { + name: "sql.interval → ::interval", + input: "sql.unsafe`SELECT ${sql.interval({ hours: 1 })}`", + output: { sql: "SELECT $N::interval" }, + }, + { + name: "sql.uuid → ::uuid", + input: 'sql.unsafe`SELECT ${sql.uuid("a0ee-...")}`', + output: { sql: "SELECT $N::uuid" }, + }, + { + name: "sql.array (string type) → ::type[]", + input: 'sql.unsafe`SELECT ${sql.array([1, 2, 3], "int4")}`', + output: { sql: "SELECT $N::int4[]" }, + }, + { + name: "sql.array (fragment type) → untyped $N", + input: "sql.unsafe`SELECT ${sql.array([1, 2, 3], sql.fragment`int[]`)}`", + output: { sql: "SELECT $N" }, + }, + { + name: "sql.array (non-string literal type) → query skipped", + input: "sql.unsafe`SELECT ${sql.array([1, 2, 3], 42)}`", + output: { sql: "SELECT /* skipped */" }, + }, + { + name: "fragment variable → inline SQL", + input: "const where = sql.fragment`WHERE id = 1`; sql.unsafe`SELECT * FROM t ${where}`", + output: { sql: "SELECT * FROM t WHERE id = 1" }, + }, + { + name: "nested fragment → inline SQL", + input: + "const cond = sql.fragment`id = 1`; const where = sql.fragment`WHERE ${cond}`; sql.unsafe`SELECT * FROM t ${where}`", + output: { sql: "SELECT * FROM t WHERE id = 1" }, + }, + { + name: "reused fragment in same template → inline SQL twice", + input: + "const cond = sql.fragment`id = 1`; const where = sql.fragment`WHERE ${cond} OR ${cond}`; sql.unsafe`SELECT * FROM t ${where}`", + output: { sql: "SELECT * FROM t WHERE id = 1 OR id = 1" }, + }, + { + name: "fragment variable from parent block scope → inline SQL", + input: + "function query() { const where = sql.fragment`WHERE id = 1`; if (true) return sql.unsafe`SELECT * FROM t ${where}`; throw new Error('unreachable'); }", + output: { sql: "SELECT * FROM t WHERE id = 1" }, + }, + { + name: "destructured fragment variable → inline SQL", + input: + "const { where } = { where: sql.fragment`WHERE id = 1` }; sql.unsafe`SELECT * FROM t ${where}`", + output: { sql: "SELECT * FROM t WHERE id = 1" }, + }, + { + name: "fragment alias cycle falls back to placeholder", + input: "const a: any = b; const b: any = a; sql.unsafe`SELECT ${a}`", + output: { sql: "SELECT $1" }, + }, + { + name: "sql.unnest → unnest($N::type[], ...)", + input: 'sql.unsafe`SELECT * FROM ${sql.unnest([[1, "a"]], ["int4", "text"])}`', + output: { sql: "SELECT * FROM unnest($N::int4[], $N::text[])" }, + }, + { + name: "sql.unnest with dynamic type segment → query skipped", + input: + 'const textType = "text"; sql.unsafe`SELECT * FROM ${sql.unnest([[1, "a"]], ["int4", textType])}`', + output: { sql: "SELECT * FROM /* skipped */" }, + }, + { + name: "sql.literalValue → embed as quoted literal", + input: 'sql.unsafe`SELECT ${sql.literalValue("foo")}`', + output: { sql: "SELECT 'foo'" }, + }, + { + name: "sql.literalValue with quotes → escape embedded quotes", + input: `sql.unsafe\`SELECT \${sql.literalValue("foo'bar")}\``, + output: { sql: "SELECT 'foo''bar'" }, + }, + { + name: "sql.literalValue dynamic input → query skipped", + input: 'const value = "foo"; sql.unsafe`SELECT ${sql.literalValue(value)}`', + output: { sql: "SELECT /* skipped */" }, + }, + { + name: "fragment identifier with dynamic segment → query skipped", + input: + 'const schema = "public"; const from = sql.fragment`FROM ${sql.identifier([schema, "users"])}`; sql.unsafe`SELECT * ${from}`', + output: { sql: "SELECT * /* skipped */" }, + }, + { + name: "sql.join → query skipped", + input: "sql.unsafe`SELECT ${sql.join([1, 2], sql.fragment`, `)}`", + output: { sql: "SELECT /* skipped */" }, + }, + { + name: "plain variable (default $N)", + input: "const x = 42; sql.unsafe`SELECT ${x}`", + output: { sql: "SELECT $1" }, + }, + { + name: "multiple translated expressions", + input: "sql.unsafe`SELECT ${sql.json({ a: 1 })}, ${sql.json({ b: 2 })}`", + output: { sql: "SELECT $N::json, $N::json" }, + }, +]; + +describe("slonik plugin", () => { + for (const c of cases) { + it(c.name, () => { + // ARRANGE + const imports = + c.imports === null ? [] : ['import { sql } from "slonik"', ...(c.imports ?? [])]; + const source = imports.length > 0 ? `${imports.join("; ")}; ${c.input}` : c.input; + + // ACT + const result = driver.toSQL(source); + + // ASSERT + if ("skipped" in c.output) { + expect(result).toEqual({ skipped: true }); + } else if ("sql" in c.output && c.output.sql.includes("/* skipped */")) { + expect(result).toMatchObject({ sql: expect.stringContaining("/* skipped */") }); + } else { + expect(result).toEqual(c.output); + } + }); + } +}); diff --git a/packages/plugins/slonik/src/plugin.ts b/packages/plugins/slonik/src/plugin.ts new file mode 100644 index 00000000..8bfe4d76 --- /dev/null +++ b/packages/plugins/slonik/src/plugin.ts @@ -0,0 +1,474 @@ +import path from "path"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { definePlugin, type ExpressionContext } from "@ts-safeql/plugin-utils"; +import { createZodAnnotator } from "@ts-safeql/zod-annotator"; +import ts from "typescript"; + +const zodTypeCheck = createZodAnnotator({ schemaArgIndex: 0 }); + +const TYPE_CAST_MAP: Record = { + json: "$N::json", + jsonb: "$N::jsonb", + binary: "$N::bytea", + date: "$N::date", + timestamp: "$N::timestamptz", + interval: "$N::interval", + uuid: "$N::uuid", +}; + +export default definePlugin({ + name: "slonik", + package: "@ts-safeql/plugin-slonik", + setup() { + return { + connectionDefaults: { + enforceType: "suggest", + overrides: { + types: { + date: "DateSqlToken", + timestamp: "TimestampSqlToken", + timestamptz: "TimestampSqlToken", + interval: "IntervalSqlToken", + json: "JsonSqlToken", + jsonb: "JsonBinarySqlToken", + uuid: "UuidSqlToken", + }, + }, + }, + onTarget: ({ node, context }) => { + const rootId = getRootIdentifier(node.tag); + if (!rootId) return undefined; + + const tsNode = context.parser.esTreeNodeToTSNodeMap.get(rootId); + const symbol = tsNode ? context.checker.getSymbolAtLocation(tsNode) : undefined; + if (!isSlonikSymbol(context.checker, symbol)) return undefined; + + switch (getEstreeMethodName(node.tag)) { + case "fragment": + return false; + case "unsafe": + case "prepared": + case "typeAlias": + return { skipTypeAnnotations: true }; + case "type": + return { typeCheck: zodTypeCheck }; + default: + return undefined; + } + }, + onExpression: ({ node, context }) => { + const translated = translateCallExpression(node, context.tsNode, context.checker); + if (translated !== undefined) return translated; + + const inlinedFragment = resolveFragmentSql(context.tsNode, context.checker); + if (inlinedFragment !== undefined) return inlinedFragment; + + return isFragmentTokenType(context) ? false : undefined; + }, + }; + }, +}); + +function getIdentifierName( + node: TSESTree.Expression | TSESTree.PrivateIdentifier, +): string | undefined { + return node.type === "Identifier" ? node.name : undefined; +} + +function getEstreeMethodName(node: TSESTree.Expression): string | undefined { + if (node.type === "MemberExpression") return getIdentifierName(node.property); + if (node.type === "CallExpression" && node.callee.type === "MemberExpression") { + return getIdentifierName(node.callee.property); + } + + return undefined; +} + +function translateCallExpression( + node: TSESTree.Expression, + tsNode: ts.Node, + checker: ts.TypeChecker, +): string | false | undefined { + if (node.type !== "CallExpression") return undefined; + if (!isSlonikCallExpression(tsNode, checker)) return undefined; + + const method = getEstreeMethodName(node); + if (!method) return undefined; + + const typeCast = TYPE_CAST_MAP[method]; + if (typeCast) return typeCast; + + switch (method) { + case "identifier": + return translateIdentifierCall(node.arguments[0]); + case "array": + return translateArrayCall(node.arguments[1], tsNode, checker); + case "unnest": + return translateUnnestCall(node.arguments[1]); + case "literalValue": { + const arg = node.arguments[0]; + return arg?.type === "Literal" && typeof arg.value === "string" + ? quoteLiteral(arg.value) + : false; + } + case "join": + return false; + default: + return undefined; + } +} + +function translateIdentifierCall(arg: TSESTree.CallExpressionArgument | undefined): string | false { + if (arg?.type !== "ArrayExpression") return false; + + const parts = getStaticStringLiterals(arg.elements); + return parts ? joinIdentifierPath(parts) : false; +} + +function translateArrayCall( + arg: TSESTree.CallExpressionArgument | undefined, + tsNode: ts.Node, + checker: ts.TypeChecker, +): string | false { + if (arg?.type === "Literal") { + return typeof arg.value === "string" ? `$N::${arg.value}[]` : false; + } + + const current = unwrapTsNode(tsNode); + return ts.isCallExpression(current) && isSlonikFragmentExpression(current.arguments[1], checker) + ? "$N" + : false; +} + +function translateUnnestCall(arg: TSESTree.CallExpressionArgument | undefined): string | false { + if (arg?.type !== "ArrayExpression") return false; + + const parts = getStaticStringLiterals(arg.elements); + if (!parts) return false; + + const types = parts.map((part) => `$N::${part}[]`); + + return types.length > 0 ? `unnest(${types.join(", ")})` : false; +} + +function getRootIdentifier(node: TSESTree.Expression): TSESTree.Identifier | undefined { + if (node.type === "Identifier") return node; + if (node.type === "MemberExpression") return getRootIdentifier(node.object); + if (node.type === "CallExpression") return getRootIdentifier(node.callee); + return undefined; +} + +function isSlonikSymbol(checker: ts.TypeChecker, symbol: ts.Symbol | undefined): boolean { + if (!symbol) return false; + + const candidates = getSymbolCandidates(checker, symbol); + if (candidates.some((candidate) => isImportedFromModule(candidate, "slonik"))) return true; + + return candidates.some((candidate) => + (candidate.declarations ?? []).some((declaration) => + path.normalize(declaration.getSourceFile().fileName).split(path.sep).includes("slonik"), + ), + ); +} + +function isFragmentTokenType(context: ExpressionContext): boolean { + if ( + context.tsTypeText.includes("FragmentSqlToken") || + context.tsTypeText.includes("SqlFragmentToken") + ) { + return true; + } + + const propertyNames = new Set( + context.checker.getPropertiesOfType(context.tsType).map((property) => property.name), + ); + + return ( + ["sql", "values", "type"].every((name) => propertyNames.has(name)) && + !propertyNames.has("parser") + ); +} + +function resolveFragmentSql( + node: ts.Node, + checker: ts.TypeChecker, + visited = new Set(), +): string | false | undefined { + const expression = unwrapTsNode(node); + + if (ts.isIdentifier(expression)) { + return resolveIdentifierFragmentSql(expression, checker, visited); + } + + if (ts.isTaggedTemplateExpression(expression)) { + return getTsMethodName(expression.tag) === "fragment" && isSlonikMethod(expression.tag, checker) + ? buildFragmentTemplateSql(expression.template, checker, visited) + : undefined; + } + + if (ts.isCallExpression(expression)) { + return resolveFragmentFactoryCall(expression, checker); + } + + return undefined; +} + +function joinIdentifierPath(parts: readonly string[]): string | false { + return parts.length > 0 + ? parts.map((part) => `"${part.replaceAll('"', '""')}"`).join(".") + : false; +} + +function quoteLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function isImportedFromModule(symbol: ts.Symbol, moduleName: string): boolean { + return (symbol.declarations ?? []).some((declaration) => { + let current: ts.Node | undefined = declaration; + while (current && !ts.isImportDeclaration(current)) current = current.parent; + + return ( + current?.moduleSpecifier && + ts.isStringLiteralLike(current.moduleSpecifier) && + current.moduleSpecifier.text === moduleName + ); + }); +} + +function getSymbolCandidates(checker: ts.TypeChecker, symbol: ts.Symbol): ts.Symbol[] { + const resolved = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + return resolved === symbol ? [symbol] : [symbol, resolved]; +} + +function isSlonikCallExpression(node: ts.Node, checker: ts.TypeChecker): boolean { + const current = unwrapTsNode(node); + return ts.isCallExpression(current) && isSlonikMethod(current.expression, checker); +} + +function isSlonikFragmentExpression(node: ts.Node, checker: ts.TypeChecker): boolean { + const current = unwrapTsNode(node); + return ts.isTaggedTemplateExpression(current) + ? getTsMethodName(current.tag) === "fragment" && isSlonikMethod(current.tag, checker) + : false; +} + +function resolveIdentifierFragmentSql( + node: ts.Identifier, + checker: ts.TypeChecker, + visited: Set, +): string | false | undefined { + const symbol = checker.getSymbolAtLocation(node); + if (!symbol) return undefined; + + const candidates = getSymbolCandidates(checker, symbol); + if (candidates.some((candidate) => visited.has(candidate))) return undefined; + + const nextVisited = new Set(visited); + for (const candidate of candidates) { + nextVisited.add(candidate); + } + + for (const declaration of candidates.flatMap((candidate) => candidate.declarations ?? [])) { + if (ts.isVariableDeclaration(declaration)) { + const resolved = declaration.initializer + ? resolveFragmentSql(declaration.initializer, checker, new Set(nextVisited)) + : undefined; + if (resolved !== undefined) return resolved; + continue; + } + + if (!ts.isBindingElement(declaration)) continue; + + const initializer = resolveBindingElementInitializer(declaration); + if (!initializer) continue; + + const resolved = resolveFragmentSql(initializer, checker, new Set(nextVisited)); + if (resolved !== undefined) return resolved; + } + + return undefined; +} + +function resolveBindingElementInitializer(element: ts.BindingElement): ts.Expression | undefined { + if (element.dotDotDotToken) return undefined; + + const source = getBindingSource(element.parent); + if (!source) return undefined; + + if (ts.isObjectBindingPattern(element.parent)) { + return resolveObjectBindingElementInitializer(element, source); + } + + if (ts.isArrayBindingPattern(element.parent)) { + return resolveArrayBindingElementInitializer(element, source); + } + + return undefined; +} + +function getBindingSource(pattern: ts.BindingPattern): ts.Expression | undefined { + const owner = pattern.parent; + + if (ts.isVariableDeclaration(owner)) return owner.initializer; + if (ts.isBindingElement(owner)) return resolveBindingElementInitializer(owner); + + return undefined; +} + +function resolveObjectBindingElementInitializer( + element: ts.BindingElement, + source: ts.Expression, +): ts.Expression | undefined { + if (!ts.isObjectLiteralExpression(source)) return undefined; + + const propertyName = getTsPropertyName(element.propertyName ?? element.name); + if (!propertyName) return undefined; + + const property = source.properties.find( + (candidate): candidate is ts.PropertyAssignment | ts.ShorthandPropertyAssignment => + (ts.isPropertyAssignment(candidate) || ts.isShorthandPropertyAssignment(candidate)) && + getTsPropertyName(candidate.name) === propertyName, + ); + + if (!property) return undefined; + return ts.isPropertyAssignment(property) ? property.initializer : property.name; +} + +function resolveArrayBindingElementInitializer( + element: ts.BindingElement, + source: ts.Expression, +): ts.Expression | undefined { + if (!ts.isArrayLiteralExpression(source)) return undefined; + + const index = element.parent.elements.indexOf(element); + if (index < 0) return undefined; + + const value = source.elements[index]; + return value && !ts.isOmittedExpression(value) && !ts.isSpreadElement(value) ? value : undefined; +} + +function buildFragmentTemplateSql( + template: ts.TemplateLiteral, + checker: ts.TypeChecker, + visited: Set, +): string | false { + if (ts.isNoSubstitutionTemplateLiteral(template)) return template.text; + + let sql = template.head.text; + + for (const span of template.templateSpans) { + const resolved = resolveFragmentSql(span.expression, checker, new Set(visited)); + if (resolved === undefined || resolved === false) return false; + + sql += resolved; + sql += span.literal.text; + } + + return sql; +} + +function resolveFragmentFactoryCall( + node: ts.CallExpression, + checker: ts.TypeChecker, +): string | false | undefined { + const method = getTsMethodName(node); + if (!method || !isSlonikMethod(node.expression, checker)) return undefined; + + switch (method) { + case "literalValue": + return ts.isStringLiteralLike(node.arguments[0]) + ? quoteLiteral(node.arguments[0].text) + : false; + case "identifier": + return translateTsIdentifierCall(node.arguments[0]); + case "join": + return false; + default: + return undefined; + } +} + +function isSlonikMethod(node: ts.Expression, checker: ts.TypeChecker): boolean { + const root = getTsRootIdentifier(node); + return root ? isSlonikSymbol(checker, checker.getSymbolAtLocation(root)) : false; +} + +function getTsMethodName(node: ts.Node): string | undefined { + const current = unwrapTsNode(node); + + if (ts.isPropertyAccessExpression(current)) return current.name.text; + if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { + return current.expression.name.text; + } + + return undefined; +} + +function getTsRootIdentifier(node: ts.Expression): ts.Identifier | undefined { + const current = unwrapTsNode(node); + + if (ts.isIdentifier(current)) return current; + if (ts.isPropertyAccessExpression(current)) return getTsRootIdentifier(current.expression); + if (ts.isCallExpression(current)) return getTsRootIdentifier(current.expression); + + return undefined; +} + +function unwrapTsNode(node: ts.Node): ts.Node { + let current = node; + + while ( + ts.isAsExpression(current) || + ts.isNonNullExpression(current) || + ts.isParenthesizedExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + + return current; +} + +function getTsPropertyName(node: ts.PropertyName | ts.BindingName): string | undefined { + if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) return node.text; + if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) return node.text; + return undefined; +} + +function translateTsIdentifierCall(arg: ts.Expression | undefined): string | false { + if (!arg || !ts.isArrayLiteralExpression(arg)) return false; + + const parts = getStaticTsStrings(arg.elements); + return parts ? joinIdentifierPath(parts) : false; +} + +function getStaticStringLiterals( + elements: readonly (TSESTree.Expression | TSESTree.SpreadElement | null)[], +): string[] | undefined { + const parts: string[] = []; + + for (const element of elements) { + if (element?.type !== "Literal" || typeof element.value !== "string") { + return undefined; + } + + parts.push(element.value); + } + + return parts; +} + +function getStaticTsStrings(elements: readonly ts.Node[]): string[] | undefined { + const parts: string[] = []; + + for (const element of elements) { + if (!ts.isStringLiteralLike(element)) { + return undefined; + } + + parts.push(element.text); + } + + return parts; +} diff --git a/packages/plugins/slonik/src/ts-fixture/file.ts b/packages/plugins/slonik/src/ts-fixture/file.ts new file mode 100644 index 00000000..1c0c8cbc --- /dev/null +++ b/packages/plugins/slonik/src/ts-fixture/file.ts @@ -0,0 +1 @@ +// File needs to exist for @typescript-eslint/parser projectService diff --git a/packages/plugins/slonik/src/ts-fixture/tsconfig.json b/packages/plugins/slonik/src/ts-fixture/tsconfig.json new file mode 100644 index 00000000..27fd308e --- /dev/null +++ b/packages/plugins/slonik/src/ts-fixture/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true + }, + "include": ["**/*"] +} diff --git a/packages/plugins/slonik/tsconfig.json b/packages/plugins/slonik/tsconfig.json new file mode 100644 index 00000000..5bf50ec7 --- /dev/null +++ b/packages/plugins/slonik/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.node.json", + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/packages/zod-annotator/package.json b/packages/zod-annotator/package.json index 1e49c657..ec56cdcd 100644 --- a/packages/zod-annotator/package.json +++ b/packages/zod-annotator/package.json @@ -33,7 +33,7 @@ "typescript": "catalog:", "unbuild": "catalog:", "vitest": "catalog:", - "zod": "^4.1.12" + "zod": "^4.3.6" }, "dependencies": { "@ts-safeql/plugin-utils": "workspace:*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b83d2f32..7330187c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,40 @@ importers: specifier: 'catalog:' version: 8.57.1(eslint@10.0.3(jiti@2.4.2))(typescript@5.8.2) + demos/plugin-slonik: + dependencies: + '@ts-safeql/eslint-plugin': + specifier: workspace:* + version: link:../../packages/eslint-plugin + '@ts-safeql/plugin-slonik': + specifier: workspace:* + version: link:../../packages/plugins/slonik + devDependencies: + '@eslint/js': + specifier: 'catalog:' + version: 10.0.1(eslint@10.0.3(jiti@2.4.2)) + '@slonik/pg-driver': + specifier: ^48.13.2 + version: 48.13.2(zod@4.3.6) + '@types/node': + specifier: 'catalog:' + version: 24.12.0 + eslint: + specifier: 'catalog:' + version: 10.0.3(jiti@2.4.2) + slonik: + specifier: ^48.13.2 + version: 48.13.2 + typescript: + specifier: 'catalog:' + version: 5.8.2 + typescript-eslint: + specifier: 'catalog:' + version: 8.57.1(eslint@10.0.3(jiti@2.4.2))(typescript@5.8.2) + zod: + specifier: ^4.3.6 + version: 4.3.6 + demos/postgresjs-custom-types: dependencies: '@js-joda/core': @@ -489,7 +523,7 @@ importers: version: 0.2.2 typeorm: specifier: 0.3.21 - version: 0.3.21(pg@8.14.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)) + version: 0.3.21(pg-query-stream@4.14.0(pg@8.14.1))(pg@8.14.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)) devDependencies: '@eslint/js': specifier: 'catalog:' @@ -669,8 +703,8 @@ importers: specifier: 'catalog:' version: 4.19.3 zod: - specifier: ^4.1.12 - version: 4.1.12 + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@ts-safeql/generate': specifier: workspace:* @@ -766,6 +800,9 @@ importers: postgres: specifier: 'catalog:' version: 3.4.7 + tsx: + specifier: 'catalog:' + version: 4.19.3 devDependencies: '@types/node': specifier: 'catalog:' @@ -811,6 +848,48 @@ importers: specifier: 'catalog:' version: 3.0.9(@types/node@24.12.0)(jiti@2.4.2)(tsx@4.19.3) + packages/plugins/slonik: + devDependencies: + '@ts-safeql/eslint-plugin': + specifier: workspace:* + version: link:../../eslint-plugin + '@ts-safeql/plugin-utils': + specifier: workspace:* + version: link:../../plugin-utils + '@ts-safeql/test-utils': + specifier: workspace:* + version: link:../../test-utils + '@ts-safeql/zod-annotator': + specifier: workspace:* + version: link:../../zod-annotator + '@types/node': + specifier: 'catalog:' + version: 24.12.0 + '@typescript-eslint/parser': + specifier: 'catalog:' + version: 8.57.1(eslint@10.0.3(jiti@2.4.2))(typescript@5.8.2) + '@typescript-eslint/rule-tester': + specifier: 'catalog:' + version: 8.57.1(eslint@10.0.3(jiti@2.4.2))(typescript@5.8.2) + postgres: + specifier: 'catalog:' + version: 3.4.7 + slonik: + specifier: ^48.13.2 + version: 48.13.2 + typescript: + specifier: 'catalog:' + version: 5.8.2 + unbuild: + specifier: 'catalog:' + version: 3.5.0(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)) + vitest: + specifier: 'catalog:' + version: 3.0.9(@types/node@24.12.0)(jiti@2.4.2)(tsx@4.19.3) + zod: + specifier: ^4.3.6 + version: 4.3.6 + packages/shared: dependencies: '@typescript-eslint/utils': @@ -931,8 +1010,8 @@ importers: specifier: 'catalog:' version: 3.0.9(@types/node@24.12.0)(jiti@2.4.2)(tsx@4.19.3) zod: - specifier: ^4.1.12 - version: 4.1.12 + specifier: ^4.3.6 + version: 4.3.6 packages: @@ -1785,6 +1864,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@pgsql/types@17.6.1': resolution: {integrity: sha512-Hk51+nyOxS7Dy5oySWywyNZxo5HndX1VDXT4ZEBD+p+vvMFM2Vc+sKSuByCiI8banou4edbgdnOC251IOuq7QQ==} @@ -2004,6 +2087,38 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@slonik/driver@48.13.2': + resolution: {integrity: sha512-vNywhgnCvaHejW/bGsZglUXGf7LXxin6aZ+iIefuf6p1NWcT/nuyb38QrpB+cveLoho1iVaPdECnk5ksnFbKCw==} + engines: {node: '>=24'} + peerDependencies: + zod: ^3.25 || ^4 + + '@slonik/errors@48.13.2': + resolution: {integrity: sha512-rUeL6pMVRCK6TzYku0QxFvSmzk4bkbpg3348A7qaTzFEUtX9pmaD0aGXBpc1si9+cVscckRl/ezy9TTRDODPCw==} + engines: {node: '>=24'} + + '@slonik/pg-driver@48.13.2': + resolution: {integrity: sha512-ULEWssW52X4sxxqBWtKYw0T1sSPgPm58wDmYo8xocZC5JuXFHvMzHpUk3X0lMUUXdOXKGcIL/G5sWG74PWi1Hg==} + engines: {node: '>=24'} + peerDependencies: + zod: ^3.25 || ^4 + + '@slonik/sql-tag@48.13.2': + resolution: {integrity: sha512-PWHIOTV5r7QZB8E7LNPCPOH+gTocAKne6P/JpPbSa16xynsABTcWLaa3yF4Z6oAbwCgzAkQdJ1TIubd4hawQ1A==} + engines: {node: '>=24'} + + '@slonik/types@48.13.2': + resolution: {integrity: sha512-04iyM4iz68WoYN/9IcQdlwam2vF7QG1QuKfXYJLWTY6sIFMMQgTdlfPpOE7Q5N2UNy7twQXvjoyYJMGYNu/sCA==} + engines: {node: '>=24'} + peerDependencies: + zod: ^3.25 || ^4 + + '@slonik/utilities@48.13.2': + resolution: {integrity: sha512-KfAKm5P6dsQoTaq3GbJD6ybFS9H18zrwZ3FpNbs4u63NrpIMNas6ZWqdeEz+Pqy+OKpQWxiy1x06S/PBVNJSFA==} + engines: {node: '>=24'} + peerDependencies: + zod: ^3.25 || ^4 + '@smithy/abort-controller@4.2.12': resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} engines: {node: '>=18.0.0'} @@ -2179,6 +2294,9 @@ packages: '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -2931,6 +3049,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-printf@1.6.10: + resolution: {integrity: sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==} + engines: {node: '>=10.0'} + fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} @@ -3114,6 +3236,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + iso8601-duration@2.1.3: + resolution: {integrity: sha512-OwkROKDXYhqKTl9uyB+/+lQ/Tx+L9LVb9tNRcsO4LtCSBDrmYbzyJLg9rGjYKAPDabD6IVdjMyUnnULHpejrCg==} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -3334,6 +3459,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -3395,9 +3524,20 @@ packages: pg-cloudflare@1.1.1: resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + pg-connection-string@2.7.0: resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} + pg-cursor@2.19.0: + resolution: {integrity: sha512-J5cF1MUz7LRJ9emOqF/06QjabMHMZy587rSPF0UuA8rCwKeeYl2co8Pp+6k5UU9YrAYHMzWkLxilfZB0hqsWWw==} + peerDependencies: + pg: ^8 + pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -3406,14 +3546,27 @@ packages: resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} engines: {node: '>=4'} + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + pg-pool@3.8.0: resolution: {integrity: sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==} peerDependencies: pg: '>=8.0' + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + pg-protocol@1.8.0: resolution: {integrity: sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==} + pg-query-stream@4.14.0: + resolution: {integrity: sha512-B1LLxgqngAATPciOPYYKyaQfsw5wyP6BZq6nHqQOC5QaaEBsfW/0OBwWUga+knCAqENMeoow9I8Zgi2m3P9rWw==} + peerDependencies: + pg: ^8 + pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} @@ -3422,6 +3575,10 @@ packages: resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} engines: {node: '>=10'} + pg-types@4.1.0: + resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==} + engines: {node: '>=10'} + pg@8.14.1: resolution: {integrity: sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==} engines: {node: '>= 8.0.0'} @@ -3431,6 +3588,15 @@ packages: pg-native: optional: true + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} @@ -3670,6 +3836,10 @@ packages: resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} engines: {node: '>=12'} + postgres-interval@4.0.2: + resolution: {integrity: sha512-EMsphSQ1YkQqKZL2cuG0zHkmjCCzQqQ71l2GXITqRwjhRleCdv00bDk/ktaSi0LnlaPzAc3535KTrjXsTdtx7A==} + engines: {node: '>=12'} + postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} @@ -3767,6 +3937,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + roarr@7.21.4: + resolution: {integrity: sha512-qvfUKCrpPzhWmQ4NxRYnuwhkI5lwmObhBU06BCK/lpj6PID9nL4Hk6XDwek2foKI+TMaV+Yw//XZshGF2Lox/Q==} + engines: {node: '>=18.0'} + rollup-plugin-dts@6.2.1: resolution: {integrity: sha512-sR3CxYUl7i2CHa0O7bA45mCrgADyAQ0tVtGSqi3yvH28M+eg1+g5d7kQ9hLvEz5dorK3XVsH5L2jwHLQf72DzA==} engines: {node: '>=16'} @@ -3785,6 +3959,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3794,11 +3972,18 @@ packages: search-insights@2.17.3: resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + serialize-error@12.0.0: + resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} + engines: {node: '>=18'} + sha.js@2.4.11: resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} hasBin: true @@ -3825,6 +4010,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slonik@48.13.2: + resolution: {integrity: sha512-NU+pTfVJAIPTMcWlMYhFobuAQHM0orXlEWcDxrUVvCLEf+NvzB4nrlih7w6XHIf5RLRDq2zghzWuGSGagBe7HQ==} + engines: {node: '>=24'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3856,6 +4045,9 @@ packages: std-env@3.8.1: resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} + strict-event-emitter-types@2.0.0: + resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4014,6 +4206,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typeorm@0.3.21: resolution: {integrity: sha512-lh4rUWl1liZGjyPTWpwcK8RNI5x4ekln+/JJOox1wCd7xbucYDOXWD+1cSzTN3L0wbTGxxOtloM5JlxbOxEufA==} engines: {node: '>=16.13.0'} @@ -4340,8 +4536,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.1.12: - resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5361,6 +5561,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@opentelemetry/api@1.9.0': {} + '@pgsql/types@17.6.1': {} '@pkgjs/parseargs@0.11.0': @@ -5548,6 +5750,55 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@slonik/driver@48.13.2(zod@4.3.6)': + dependencies: + '@slonik/types': 48.13.2(zod@4.3.6) + '@slonik/utilities': 48.13.2(zod@4.3.6) + roarr: 7.21.4 + serialize-error: 12.0.0 + strict-event-emitter-types: 2.0.0 + zod: 4.3.6 + + '@slonik/errors@48.13.2(zod@4.3.6)': + dependencies: + '@slonik/types': 48.13.2(zod@4.3.6) + transitivePeerDependencies: + - zod + + '@slonik/pg-driver@48.13.2(zod@4.3.6)': + dependencies: + '@slonik/driver': 48.13.2(zod@4.3.6) + '@slonik/errors': 48.13.2(zod@4.3.6) + '@slonik/sql-tag': 48.13.2 + '@slonik/types': 48.13.2(zod@4.3.6) + '@slonik/utilities': 48.13.2(zod@4.3.6) + pg: 8.20.0 + pg-query-stream: 4.14.0(pg@8.20.0) + pg-types: 4.1.0 + postgres-array: 3.0.4 + zod: 4.3.6 + transitivePeerDependencies: + - pg-native + + '@slonik/sql-tag@48.13.2': + dependencies: + '@slonik/errors': 48.13.2(zod@4.3.6) + '@slonik/types': 48.13.2(zod@4.3.6) + roarr: 7.21.4 + safe-stable-stringify: 2.5.0 + serialize-error: 12.0.0 + zod: 4.3.6 + + '@slonik/types@48.13.2(zod@4.3.6)': + dependencies: + zod: 4.3.6 + + '@slonik/utilities@48.13.2(zod@4.3.6)': + dependencies: + '@slonik/errors': 48.13.2(zod@4.3.6) + '@slonik/types': 48.13.2(zod@4.3.6) + zod: 4.3.6 + '@smithy/abort-controller@4.2.12': dependencies: '@smithy/types': 4.13.1 @@ -5825,6 +6076,8 @@ snapshots: '@sqltools/formatter@1.2.5': {} + '@standard-schema/spec@1.1.0': {} + '@trysound/sax@0.2.0': {} '@tsconfig/node10@1.0.11': {} @@ -6681,6 +6934,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-printf@1.6.10: {} + fast-xml-builder@1.1.4: dependencies: path-expression-matcher: 1.1.3 @@ -6865,6 +7120,8 @@ snapshots: isexe@2.0.0: {} + iso8601-duration@2.1.3: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -7075,6 +7332,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -7119,18 +7380,49 @@ snapshots: pg-cloudflare@1.1.1: optional: true + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + pg-connection-string@2.7.0: {} + pg-cursor@2.19.0(pg@8.14.1): + dependencies: + pg: 8.14.1 + optional: true + + pg-cursor@2.19.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + pg-int8@1.0.1: {} pg-numeric@1.0.2: {} + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + pg-pool@3.8.0(pg@8.14.1): dependencies: pg: 8.14.1 + pg-protocol@1.13.0: {} + pg-protocol@1.8.0: {} + pg-query-stream@4.14.0(pg@8.14.1): + dependencies: + pg: 8.14.1 + pg-cursor: 2.19.0(pg@8.14.1) + optional: true + + pg-query-stream@4.14.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + pg-cursor: 2.19.0(pg@8.20.0) + pg-types@2.2.0: dependencies: pg-int8: 1.0.1 @@ -7149,6 +7441,16 @@ snapshots: postgres-interval: 3.0.0 postgres-range: 1.1.4 + pg-types@4.1.0: + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.4 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + pg@8.14.1: dependencies: pg-connection-string: 2.7.0 @@ -7159,6 +7461,16 @@ snapshots: optionalDependencies: pg-cloudflare: 1.1.1 + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + pgpass@1.0.5: dependencies: split2: 4.2.0 @@ -7375,6 +7687,8 @@ snapshots: postgres-interval@3.0.0: {} + postgres-interval@4.0.2: {} + postgres-range@1.1.4: {} postgres@3.4.7: {} @@ -7446,6 +7760,12 @@ snapshots: rfdc@1.4.1: {} + roarr@7.21.4: + dependencies: + fast-printf: 1.6.10 + safe-stable-stringify: 2.5.0 + semver-compare: 1.0.0 + rollup-plugin-dts@6.2.1(rollup@4.37.0)(typescript@5.8.2): dependencies: magic-string: 0.30.17 @@ -7486,14 +7806,22 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} scule@1.3.0: {} search-insights@2.17.3: {} + semver-compare@1.0.0: {} + semver@7.7.4: {} + serialize-error@12.0.0: + dependencies: + type-fest: 4.41.0 + sha.js@2.4.11: dependencies: inherits: 2.0.4 @@ -7522,6 +7850,25 @@ snapshots: slash@3.0.0: {} + slonik@48.13.2: + dependencies: + '@opentelemetry/api': 1.9.0 + '@slonik/driver': 48.13.2(zod@4.3.6) + '@slonik/errors': 48.13.2(zod@4.3.6) + '@slonik/pg-driver': 48.13.2(zod@4.3.6) + '@slonik/sql-tag': 48.13.2 + '@slonik/utilities': 48.13.2(zod@4.3.6) + '@standard-schema/spec': 1.1.0 + iso8601-duration: 2.1.3 + p-limit: 6.2.0 + postgres-interval: 4.0.2 + roarr: 7.21.4 + serialize-error: 12.0.0 + strict-event-emitter-types: 2.0.0 + zod: 4.3.6 + transitivePeerDependencies: + - pg-native + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -7543,6 +7890,8 @@ snapshots: std-env@3.8.1: {} + strict-event-emitter-types@2.0.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -7692,7 +8041,9 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typeorm@0.3.21(pg@8.14.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)): + type-fest@4.41.0: {} + + typeorm@0.3.21(pg-query-stream@4.14.0(pg@8.14.1))(pg@8.14.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 3.17.0 @@ -7710,6 +8061,7 @@ snapshots: yargs: 17.7.2 optionalDependencies: pg: 8.14.1 + pg-query-stream: 4.14.0(pg@8.14.1) ts-node: 10.9.2(@types/node@24.12.0)(typescript@5.8.2) transitivePeerDependencies: - supports-color @@ -8019,7 +8371,9 @@ snapshots: yocto-queue@0.1.0: {} - zod@4.1.12: {} + yocto-queue@1.2.2: {} + + zod@4.3.6: {} zwitch@2.0.4: {}