diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9c7d2d..5e42bb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,18 @@ jobs: with: sqlc-version: '1.24.0' - uses: actions/setup-node@v6 + - uses: sqlc-dev/action-setup-postgres@master + with: + postgres-version: "16" + id: postgres - name: Build run: | make generate make validate + - name: Integration tests + run: npm run test:integration + env: + DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }}?sslmode=disable - uses: actions/upload-artifact@v7 with: name: plugin diff --git a/examples/authors/postgresql/query.sql b/examples/authors/postgresql/query.sql index 971b8f9..5476773 100644 --- a/examples/authors/postgresql/query.sql +++ b/examples/authors/postgresql/query.sql @@ -14,6 +14,30 @@ INSERT INTO authors ( ) RETURNING *; +-- name: CopyAuthors :copyfrom +INSERT INTO authors ( + name, bio +) VALUES ( + $1, $2 +); + -- name: DeleteAuthor :exec DELETE FROM authors WHERE id = $1; + +-- name: BatchCreateAuthor :batchone +INSERT INTO authors ( + name, bio +) VALUES ( + $1, $2 +) +RETURNING *; + +-- name: BatchListAuthorsByBio :batchmany +SELECT * FROM authors +WHERE bio = $1 +ORDER BY name; + +-- name: BatchDeleteAuthor :batchexec +DELETE FROM authors +WHERE id = $1; diff --git a/examples/bun-pg/src/db/query_sql.ts b/examples/bun-pg/src/db/query_sql.ts index 0b57676..0f8ab2f 100644 --- a/examples/bun-pg/src/db/query_sql.ts +++ b/examples/bun-pg/src/db/query_sql.ts @@ -6,6 +6,43 @@ interface Client { query: (config: QueryArrayConfig) => Promise; } +/** + * Accepted by pg batch stubs for source compatibility only. + * node-postgres batch annotations always throw SqlcBatchUnsupportedError. + */ +export interface SqlcBatchOptions { + batchSize?: number; +} + +/** + * node-postgres does not expose pgx-style SendBatch/pipelining semantics. + * Use the postgres driver for generated batch query support. + */ +export class SqlcBatchUnsupportedError extends Error { + readonly driver = "pg"; + readonly command: string; + + constructor(command: string) { + super(`pg driver does not support ${command}; use the postgres driver for batch annotations.`); + this.name = "SqlcBatchUnsupportedError"; + this.command = command; + } +} + +/** + * node-postgres needs external COPY stream wiring that this generator does not emit. + * Use the postgres driver for generated :copyfrom support. + */ +export class SqlcCopyFromUnsupportedError extends Error { + readonly driver = "pg"; + readonly command: string; + + constructor(command: string) { + super(`pg driver does not support ${command}; use the postgres driver for :copyfrom annotations.`); + this.name = "SqlcCopyFromUnsupportedError"; + this.command = command; + } +} export const getAuthorQuery = `-- name: GetAuthor :one SELECT id, name, bio FROM authors WHERE id = $1 LIMIT 1`; @@ -104,6 +141,22 @@ export async function createAuthor(client: Client, args: CreateAuthorArgs): Prom }; } +export const copyAuthorsQuery = `-- name: CopyAuthors :copyfrom +INSERT INTO authors ( + name, bio +) VALUES ( + $1, $2 +)`; + +export interface CopyAuthorsArgs { + name: string; + bio: string | null; +} + +export async function copyAuthors(_client: Client, _args: CopyAuthorsArgs[]): Promise { + throw new SqlcCopyFromUnsupportedError(":copyfrom"); +} + export const deleteAuthorQuery = `-- name: DeleteAuthor :exec DELETE FROM authors WHERE id = $1`; @@ -120,3 +173,71 @@ export async function deleteAuthor(client: Client, args: DeleteAuthorArgs): Prom }); } +export const batchCreateAuthorQuery = `-- name: BatchCreateAuthor :batchone +INSERT INTO authors ( + name, bio +) VALUES ( + $1, $2 +) +RETURNING id, name, bio`; + +export interface BatchCreateAuthorArgs { + name: string; + bio: string | null; +} + +export interface BatchCreateAuthorRow { + id: string; + name: string; + bio: string | null; +} + +export interface BatchCreateAuthorBatchResult { + index: number; + row: BatchCreateAuthorRow | null; +} + +export async function batchCreateAuthor(_client: Client, _args: BatchCreateAuthorArgs[], _options?: SqlcBatchOptions): Promise { + throw new SqlcBatchUnsupportedError(":batchone"); +} + +export const batchListAuthorsByBioQuery = `-- name: BatchListAuthorsByBio :batchmany +SELECT id, name, bio FROM authors +WHERE bio = $1 +ORDER BY name`; + +export interface BatchListAuthorsByBioArgs { + bio: string | null; +} + +export interface BatchListAuthorsByBioRow { + id: string; + name: string; + bio: string | null; +} + +export interface BatchListAuthorsByBioBatchResult { + index: number; + rows: BatchListAuthorsByBioRow[]; +} + +export async function batchListAuthorsByBio(_client: Client, _args: BatchListAuthorsByBioArgs[], _options?: SqlcBatchOptions): Promise { + throw new SqlcBatchUnsupportedError(":batchmany"); +} + +export const batchDeleteAuthorQuery = `-- name: BatchDeleteAuthor :batchexec +DELETE FROM authors +WHERE id = $1`; + +export interface BatchDeleteAuthorArgs { + id: string; +} + +export interface BatchDeleteAuthorBatchResult { + index: number; +} + +export async function batchDeleteAuthor(_client: Client, _args: BatchDeleteAuthorArgs[], _options?: SqlcBatchOptions): Promise { + throw new SqlcBatchUnsupportedError(":batchexec"); +} + diff --git a/examples/bun-postgres/src/db/query_sql.ts b/examples/bun-postgres/src/db/query_sql.ts index 8e15f3b..7334ec5 100644 --- a/examples/bun-postgres/src/db/query_sql.ts +++ b/examples/bun-postgres/src/db/query_sql.ts @@ -1,6 +1,360 @@ // Code generated by sqlc. DO NOT EDIT. -import { Sql } from "postgres"; +import type { Sql, TransactionSql } from "postgres"; + +type SqlcCopyFromEvent = "error" | "finish" | "close" | "drain"; +type SqlcCopyFromListener = (error?: unknown) => void; +type SqlcCopyFromEncoder = (value: unknown) => string; + +const sqlcCopyFromMaxChunkBytes = 65536; + +interface SqlcCopyFromWritable { + write(chunk: string, callback: (error?: Error | null) => void): boolean; + end(callback: (error?: Error | null) => void): void; + destroy?(error?: unknown): void; + readonly writableFinished?: boolean; + once?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; + off?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; + removeListener?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; +} + +interface SqlcCopyFromQueuedWrite { + canContinue: boolean; + complete: Promise; + failed: Promise; +} + +interface SqlcCopyFromState { + error?: Error; + pendingWrites: Promise[]; +} + +function sqlcCopyFromCreateState(): SqlcCopyFromState { + return { pendingWrites: [] }; +} + +function sqlcCopyFromError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error ?? "COPY stream failed")); +} + +function sqlcCopyFromRecordError(state: SqlcCopyFromState, error: unknown): void { + state.error ??= sqlcCopyFromError(error); +} + +function sqlcCopyFromThrowIfError(state: SqlcCopyFromState): void { + if (state.error) { + throw state.error; + } +} + +function sqlcCopyFromUnsupportedValue(value: unknown, expected = "null, string, number, boolean, bigint, Date, Uint8Array/Buffer, JSON-serializable values, and arrays for PostgreSQL array columns"): TypeError { + const tag = Object.prototype.toString.call(value); + return new TypeError(":copyfrom supports only " + expected + "; received " + tag); +} + +function sqlcCopyFromScalar(value: unknown): string { + switch (typeof value) { + case "string": + return value; + case "number": + return String(value); + case "boolean": + return value ? "true" : "false"; + case "bigint": + return value.toString(); + case "object": + if (value instanceof Date) { + return value.toISOString(); + } + throw sqlcCopyFromUnsupportedValue(value, "string, number, boolean, bigint, and Date values for scalar columns"); + default: + throw sqlcCopyFromUnsupportedValue(value, "string, number, boolean, bigint, and Date values for scalar columns"); + } +} + +function sqlcCopyFromBytea(value: unknown): string { + let bytes: Uint8Array | undefined; + if (typeof ArrayBuffer !== "undefined") { + if (ArrayBuffer.isView(value)) { + const view = value as ArrayBufferView; + bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } else if (value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]") { + bytes = new Uint8Array(value as ArrayBuffer); + } + } + if (!bytes) { + throw sqlcCopyFromUnsupportedValue(value, "Uint8Array or Buffer values for bytea columns"); + } + + let hex = ""; + for (let index = 0; index < bytes.length; index++) { + const byte = bytes[index]; + if (byte === undefined) { + continue; + } + hex += byte.toString(16).padStart(2, "0"); + } + return "\\x" + hex; +} + +function sqlcCopyFromJson(value: unknown): string { + let text: string | undefined; + try { + text = JSON.stringify(value); + } catch { + throw sqlcCopyFromUnsupportedValue(value, "JSON-serializable values for json/jsonb columns"); + } + if (text === undefined) { + throw sqlcCopyFromUnsupportedValue(value, "JSON-serializable values for json/jsonb columns"); + } + return text; +} + +function sqlcCopyFromArrayEncoder(elementEncoder: SqlcCopyFromEncoder, dimensions: number): SqlcCopyFromEncoder { + return (value: unknown) => sqlcCopyFromArray(value, elementEncoder, dimensions); +} + +function sqlcCopyFromArray(value: unknown, elementEncoder: SqlcCopyFromEncoder, dimensions: number): string { + if (!Array.isArray(value)) { + throw sqlcCopyFromUnsupportedValue(value, "arrays for PostgreSQL array columns"); + } + const nestedDimensions = dimensions - 1; + return "{" + value.map((element) => { + if (element === null || element === undefined) { + return "NULL"; + } + if (nestedDimensions > 0) { + return sqlcCopyFromArray(element, elementEncoder, nestedDimensions); + } + return sqlcCopyFromArrayElement(elementEncoder(element)); + }).join(",") + "}"; +} + +function sqlcCopyFromArrayElement(value: string): string { + return '"' + value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'; +} + +function sqlcCopyFromValue(value: unknown, encoder: SqlcCopyFromEncoder): string { + if (value === null || value === undefined) { + return "\\N"; + } + return encoder(value) + .replace(/\\/g, "\\\\") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r"); +} + +function sqlcCopyFromRow(values: unknown[], encoders: SqlcCopyFromEncoder[]): string { + if (values.length !== encoders.length) { + throw new Error(":copyfrom row has " + values.length + " values, expected " + encoders.length); + } + return values.map((value, index) => { + const encoder = encoders[index]; + if (!encoder) { + throw new Error(":copyfrom row is missing encoder for column " + index); + } + return sqlcCopyFromValue(value, encoder); + }).join("\t") + "\n"; +} + +function sqlcCopyFromQueueWrite(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, chunk: string): SqlcCopyFromQueuedWrite { + let canContinue = true; + let notifyFailed!: () => void; + const failed = new Promise((resolve) => { + notifyFailed = resolve; + }); + const complete = new Promise((resolve) => { + const finish = (error?: unknown) => { + if (error) { + sqlcCopyFromRecordError(state, error); + notifyFailed(); + } + resolve(); + }; + try { + canContinue = stream.write(chunk, (error?: Error | null) => finish(error ?? undefined)) !== false; + } catch (error) { + canContinue = false; + finish(error); + } + }); + state.pendingWrites.push(complete); + return { canContinue, complete, failed }; +} + +async function sqlcCopyFromWrite(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, chunk: string): Promise { + if (chunk.length === 0) { + return; + } + sqlcCopyFromThrowIfError(state); + const queuedWrite = sqlcCopyFromQueueWrite(stream, state, chunk); + sqlcCopyFromThrowIfError(state); + if (!queuedWrite.canContinue) { + await sqlcCopyFromDrain(stream, state, queuedWrite); + } + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromDrain(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, queuedWrite: SqlcCopyFromQueuedWrite): Promise { + if (typeof stream.once !== "function") { + await queuedWrite.complete; + sqlcCopyFromThrowIfError(state); + return; + } + await Promise.race([ + queuedWrite.failed.then(() => sqlcCopyFromThrowIfError(state)), + new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + stream.off?.("drain", onDrain) ?? stream.removeListener?.("drain", onDrain); + stream.off?.("error", onError) ?? stream.removeListener?.("error", onError); + stream.off?.("close", onClose) ?? stream.removeListener?.("close", onClose); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + complete(); + }; + const onDrain = () => settle(() => resolve()); + const onError = (error?: unknown) => settle(() => { + sqlcCopyFromRecordError(state, error); + reject(state.error); + }); + const onClose = () => { + if (stream.writableFinished === true) { + onDrain(); + return; + } + onError(new Error("COPY stream closed before drain")); + }; + stream.once?.("drain", onDrain); + stream.once?.("error", onError); + stream.once?.("close", onClose); + }), + ]); + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromFlushWrites(state: SqlcCopyFromState): Promise { + while (state.pendingWrites.length > 0) { + const pendingWrites = state.pendingWrites; + state.pendingWrites = []; + await Promise.all(pendingWrites); + } + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromEnd(stream: SqlcCopyFromWritable): Promise { + await new Promise((resolve, reject) => { + let settled = false; + const hasCompletionEvents = typeof stream.once === "function"; + const cleanup = () => { + stream.off?.("error", onError) ?? stream.removeListener?.("error", onError); + stream.off?.("finish", onFinish) ?? stream.removeListener?.("finish", onFinish); + stream.off?.("close", onClose) ?? stream.removeListener?.("close", onClose); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + complete(); + }; + const onError = (error?: unknown) => settle(() => reject(sqlcCopyFromError(error))); + const onFinish = () => settle(() => resolve()); + const onClose = () => { + if (stream.writableFinished === true) { + onFinish(); + return; + } + onError(new Error("COPY stream closed before finish")); + }; + + if (hasCompletionEvents) { + stream.once?.("error", onError); + stream.once?.("finish", onFinish); + stream.once?.("close", onClose); + } + + stream.end((error?: Error | null) => { + if (error) { + onError(error); + return; + } + if (!hasCompletionEvents || stream.writableFinished !== false) { + onFinish(); + } + }); + }); +} + + +/** + * Options for generated batch queries. + * batchSize bounds pipelined queries per reserved connection chunk; chunks run in input order. + */ +export interface SqlcBatchOptions { + batchSize?: number; +} + +export interface SqlcBatchErrorItem { + index: number; + error: unknown; +} + +/** + * Batch functions reject with this after the first failed chunk. + * errors contain input indexes; later chunks are not started. + */ +export class SqlcBatchError extends Error { + readonly errors: SqlcBatchErrorItem[]; + + constructor(errors: SqlcBatchErrorItem[]) { + super(`Batch failed for ${errors.length} item(s)`); + this.name = "SqlcBatchError"; + this.errors = errors; + } +} + +function sqlcBatchSize(options: SqlcBatchOptions | undefined): number { + const batchSize = options?.batchSize ?? 64; + if (!Number.isInteger(batchSize) || batchSize < 1) { + throw new RangeError("batchSize must be a positive integer"); + } + return batchSize; +} + +async function sqlcRunBatched( + args: TArg[], + options: SqlcBatchOptions | undefined, + run: (arg: TArg, index: number) => Promise +): Promise { + const batchSize = sqlcBatchSize(options); + const results = new Array(args.length); + for (let start = 0; start < args.length; start += batchSize) { + const chunk = args.slice(start, start + batchSize); + const settled = await Promise.allSettled(chunk.map((arg, offset) => run(arg, start + offset))); + const errors: SqlcBatchErrorItem[] = []; + for (const [offset, result] of settled.entries()) { + const index = start + offset; + if (result.status === "fulfilled") { + results[index] = result.value; + } else { + errors.push({ index, error: result.reason }); + } + } + if (errors.length > 0) { + throw new SqlcBatchError(errors); + } + } + return results; +} + export const getAuthorQuery = `-- name: GetAuthor :one SELECT id, name, bio FROM authors @@ -16,7 +370,7 @@ export interface GetAuthorRow { bio: string | null; } -export async function getAuthor(sql: Sql, args: GetAuthorArgs): Promise { +export async function getAuthor(sql: Sql | TransactionSql, args: GetAuthorArgs): Promise { const rows = await sql.unsafe(getAuthorQuery, [args.id]).values(); if (rows.length !== 1) { return null; @@ -42,7 +396,7 @@ export interface ListAuthorsRow { bio: string | null; } -export async function listAuthors(sql: Sql): Promise { +export async function listAuthors(sql: Sql | TransactionSql): Promise { return (await sql.unsafe(listAuthorsQuery, []).values()).map(row => ({ id: row[0], name: row[1], @@ -69,7 +423,7 @@ export interface CreateAuthorRow { bio: string | null; } -export async function createAuthor(sql: Sql, args: CreateAuthorArgs): Promise { +export async function createAuthor(sql: Sql | TransactionSql, args: CreateAuthorArgs): Promise { const rows = await sql.unsafe(createAuthorQuery, [args.name, args.bio]).values(); if (rows.length !== 1) { return null; @@ -85,6 +439,52 @@ export async function createAuthor(sql: Sql, args: CreateAuthorArgs): Promise { + const copyStream = await sql.unsafe("COPY \"authors\" (\"name\", \"bio\") FROM STDIN").writable() as SqlcCopyFromWritable; + const copyStreamState = sqlcCopyFromCreateState(); + const copyEncoders: SqlcCopyFromEncoder[] = [sqlcCopyFromScalar, sqlcCopyFromScalar]; + const onCopyStreamError = (error?: unknown) => { + sqlcCopyFromRecordError(copyStreamState, error); + }; + copyStream.once?.("error", onCopyStreamError); + try { + let copyRowBatch = ""; + for (const arg of args) { + sqlcCopyFromThrowIfError(copyStreamState); + const copyRow = sqlcCopyFromRow([arg.name, arg.bio], copyEncoders); + if (copyRowBatch.length > 0 && copyRowBatch.length + copyRow.length > sqlcCopyFromMaxChunkBytes) { + await sqlcCopyFromWrite(copyStream, copyStreamState, copyRowBatch); + copyRowBatch = ""; + } + copyRowBatch += copyRow; + } + await sqlcCopyFromWrite(copyStream, copyStreamState, copyRowBatch); + await sqlcCopyFromFlushWrites(copyStreamState); + await sqlcCopyFromEnd(copyStream); + sqlcCopyFromThrowIfError(copyStreamState); + return args.length; + } + catch (error) { + copyStream.destroy?.(error); + throw error; + } + finally { + copyStream.off?.("error", onCopyStreamError) ?? copyStream.removeListener?.("error", onCopyStreamError); + } +} + export const deleteAuthorQuery = `-- name: DeleteAuthor :exec DELETE FROM authors WHERE id = $1`; @@ -93,7 +493,123 @@ export interface DeleteAuthorArgs { id: string; } -export async function deleteAuthor(sql: Sql, args: DeleteAuthorArgs): Promise { +export async function deleteAuthor(sql: Sql | TransactionSql, args: DeleteAuthorArgs): Promise { await sql.unsafe(deleteAuthorQuery, [args.id]); } +export const batchCreateAuthorQuery = `-- name: BatchCreateAuthor :batchone +INSERT INTO authors ( + name, bio +) VALUES ( + $1, $2 +) +RETURNING id, name, bio`; + +export interface BatchCreateAuthorArgs { + name: string; + bio: string | null; +} + +export interface BatchCreateAuthorRow { + id: string; + name: string; + bio: string | null; +} + +export interface BatchCreateAuthorBatchResult { + index: number; + row: BatchCreateAuthorRow | null; +} + +export async function batchCreateAuthor(sql: Sql, args: BatchCreateAuthorArgs[], options?: SqlcBatchOptions): Promise { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise => { + const rows = await batchSql.unsafe(batchCreateAuthorQuery, [arg.name, arg.bio]).values(); + if (rows.length !== 1) { + return { index, row: null }; + } + const row = rows[0]; + if (!row) { + return { index, row: null }; + } + return { + index, + row: { + id: row[0], + name: row[1], + bio: row[2] + } + }; + }); + } + finally { + batchSql.release(); + } +} + +export const batchListAuthorsByBioQuery = `-- name: BatchListAuthorsByBio :batchmany +SELECT id, name, bio FROM authors +WHERE bio = $1 +ORDER BY name`; + +export interface BatchListAuthorsByBioArgs { + bio: string | null; +} + +export interface BatchListAuthorsByBioRow { + id: string; + name: string; + bio: string | null; +} + +export interface BatchListAuthorsByBioBatchResult { + index: number; + rows: BatchListAuthorsByBioRow[]; +} + +export async function batchListAuthorsByBio(sql: Sql, args: BatchListAuthorsByBioArgs[], options?: SqlcBatchOptions): Promise { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise => { + const rows = await batchSql.unsafe(batchListAuthorsByBioQuery, [arg.bio]).values(); + return { + index, + rows: rows.map(row => ({ + id: row[0], + name: row[1], + bio: row[2] + })) + }; + }); + } + finally { + batchSql.release(); + } +} + +export const batchDeleteAuthorQuery = `-- name: BatchDeleteAuthor :batchexec +DELETE FROM authors +WHERE id = $1`; + +export interface BatchDeleteAuthorArgs { + id: string; +} + +export interface BatchDeleteAuthorBatchResult { + index: number; +} + +export async function batchDeleteAuthor(sql: Sql, args: BatchDeleteAuthorArgs[], options?: SqlcBatchOptions): Promise { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise => { + await batchSql.unsafe(batchDeleteAuthorQuery, [arg.id]); + return { index }; + }); + } + finally { + batchSql.release(); + } +} + diff --git a/examples/node-pg/src/db/query_sql.ts b/examples/node-pg/src/db/query_sql.ts index 0b57676..0f8ab2f 100644 --- a/examples/node-pg/src/db/query_sql.ts +++ b/examples/node-pg/src/db/query_sql.ts @@ -6,6 +6,43 @@ interface Client { query: (config: QueryArrayConfig) => Promise; } +/** + * Accepted by pg batch stubs for source compatibility only. + * node-postgres batch annotations always throw SqlcBatchUnsupportedError. + */ +export interface SqlcBatchOptions { + batchSize?: number; +} + +/** + * node-postgres does not expose pgx-style SendBatch/pipelining semantics. + * Use the postgres driver for generated batch query support. + */ +export class SqlcBatchUnsupportedError extends Error { + readonly driver = "pg"; + readonly command: string; + + constructor(command: string) { + super(`pg driver does not support ${command}; use the postgres driver for batch annotations.`); + this.name = "SqlcBatchUnsupportedError"; + this.command = command; + } +} + +/** + * node-postgres needs external COPY stream wiring that this generator does not emit. + * Use the postgres driver for generated :copyfrom support. + */ +export class SqlcCopyFromUnsupportedError extends Error { + readonly driver = "pg"; + readonly command: string; + + constructor(command: string) { + super(`pg driver does not support ${command}; use the postgres driver for :copyfrom annotations.`); + this.name = "SqlcCopyFromUnsupportedError"; + this.command = command; + } +} export const getAuthorQuery = `-- name: GetAuthor :one SELECT id, name, bio FROM authors WHERE id = $1 LIMIT 1`; @@ -104,6 +141,22 @@ export async function createAuthor(client: Client, args: CreateAuthorArgs): Prom }; } +export const copyAuthorsQuery = `-- name: CopyAuthors :copyfrom +INSERT INTO authors ( + name, bio +) VALUES ( + $1, $2 +)`; + +export interface CopyAuthorsArgs { + name: string; + bio: string | null; +} + +export async function copyAuthors(_client: Client, _args: CopyAuthorsArgs[]): Promise { + throw new SqlcCopyFromUnsupportedError(":copyfrom"); +} + export const deleteAuthorQuery = `-- name: DeleteAuthor :exec DELETE FROM authors WHERE id = $1`; @@ -120,3 +173,71 @@ export async function deleteAuthor(client: Client, args: DeleteAuthorArgs): Prom }); } +export const batchCreateAuthorQuery = `-- name: BatchCreateAuthor :batchone +INSERT INTO authors ( + name, bio +) VALUES ( + $1, $2 +) +RETURNING id, name, bio`; + +export interface BatchCreateAuthorArgs { + name: string; + bio: string | null; +} + +export interface BatchCreateAuthorRow { + id: string; + name: string; + bio: string | null; +} + +export interface BatchCreateAuthorBatchResult { + index: number; + row: BatchCreateAuthorRow | null; +} + +export async function batchCreateAuthor(_client: Client, _args: BatchCreateAuthorArgs[], _options?: SqlcBatchOptions): Promise { + throw new SqlcBatchUnsupportedError(":batchone"); +} + +export const batchListAuthorsByBioQuery = `-- name: BatchListAuthorsByBio :batchmany +SELECT id, name, bio FROM authors +WHERE bio = $1 +ORDER BY name`; + +export interface BatchListAuthorsByBioArgs { + bio: string | null; +} + +export interface BatchListAuthorsByBioRow { + id: string; + name: string; + bio: string | null; +} + +export interface BatchListAuthorsByBioBatchResult { + index: number; + rows: BatchListAuthorsByBioRow[]; +} + +export async function batchListAuthorsByBio(_client: Client, _args: BatchListAuthorsByBioArgs[], _options?: SqlcBatchOptions): Promise { + throw new SqlcBatchUnsupportedError(":batchmany"); +} + +export const batchDeleteAuthorQuery = `-- name: BatchDeleteAuthor :batchexec +DELETE FROM authors +WHERE id = $1`; + +export interface BatchDeleteAuthorArgs { + id: string; +} + +export interface BatchDeleteAuthorBatchResult { + index: number; +} + +export async function batchDeleteAuthor(_client: Client, _args: BatchDeleteAuthorArgs[], _options?: SqlcBatchOptions): Promise { + throw new SqlcBatchUnsupportedError(":batchexec"); +} + diff --git a/examples/node-postgres/src/db/query_sql.ts b/examples/node-postgres/src/db/query_sql.ts index 8e15f3b..7334ec5 100644 --- a/examples/node-postgres/src/db/query_sql.ts +++ b/examples/node-postgres/src/db/query_sql.ts @@ -1,6 +1,360 @@ // Code generated by sqlc. DO NOT EDIT. -import { Sql } from "postgres"; +import type { Sql, TransactionSql } from "postgres"; + +type SqlcCopyFromEvent = "error" | "finish" | "close" | "drain"; +type SqlcCopyFromListener = (error?: unknown) => void; +type SqlcCopyFromEncoder = (value: unknown) => string; + +const sqlcCopyFromMaxChunkBytes = 65536; + +interface SqlcCopyFromWritable { + write(chunk: string, callback: (error?: Error | null) => void): boolean; + end(callback: (error?: Error | null) => void): void; + destroy?(error?: unknown): void; + readonly writableFinished?: boolean; + once?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; + off?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; + removeListener?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; +} + +interface SqlcCopyFromQueuedWrite { + canContinue: boolean; + complete: Promise; + failed: Promise; +} + +interface SqlcCopyFromState { + error?: Error; + pendingWrites: Promise[]; +} + +function sqlcCopyFromCreateState(): SqlcCopyFromState { + return { pendingWrites: [] }; +} + +function sqlcCopyFromError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error ?? "COPY stream failed")); +} + +function sqlcCopyFromRecordError(state: SqlcCopyFromState, error: unknown): void { + state.error ??= sqlcCopyFromError(error); +} + +function sqlcCopyFromThrowIfError(state: SqlcCopyFromState): void { + if (state.error) { + throw state.error; + } +} + +function sqlcCopyFromUnsupportedValue(value: unknown, expected = "null, string, number, boolean, bigint, Date, Uint8Array/Buffer, JSON-serializable values, and arrays for PostgreSQL array columns"): TypeError { + const tag = Object.prototype.toString.call(value); + return new TypeError(":copyfrom supports only " + expected + "; received " + tag); +} + +function sqlcCopyFromScalar(value: unknown): string { + switch (typeof value) { + case "string": + return value; + case "number": + return String(value); + case "boolean": + return value ? "true" : "false"; + case "bigint": + return value.toString(); + case "object": + if (value instanceof Date) { + return value.toISOString(); + } + throw sqlcCopyFromUnsupportedValue(value, "string, number, boolean, bigint, and Date values for scalar columns"); + default: + throw sqlcCopyFromUnsupportedValue(value, "string, number, boolean, bigint, and Date values for scalar columns"); + } +} + +function sqlcCopyFromBytea(value: unknown): string { + let bytes: Uint8Array | undefined; + if (typeof ArrayBuffer !== "undefined") { + if (ArrayBuffer.isView(value)) { + const view = value as ArrayBufferView; + bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } else if (value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]") { + bytes = new Uint8Array(value as ArrayBuffer); + } + } + if (!bytes) { + throw sqlcCopyFromUnsupportedValue(value, "Uint8Array or Buffer values for bytea columns"); + } + + let hex = ""; + for (let index = 0; index < bytes.length; index++) { + const byte = bytes[index]; + if (byte === undefined) { + continue; + } + hex += byte.toString(16).padStart(2, "0"); + } + return "\\x" + hex; +} + +function sqlcCopyFromJson(value: unknown): string { + let text: string | undefined; + try { + text = JSON.stringify(value); + } catch { + throw sqlcCopyFromUnsupportedValue(value, "JSON-serializable values for json/jsonb columns"); + } + if (text === undefined) { + throw sqlcCopyFromUnsupportedValue(value, "JSON-serializable values for json/jsonb columns"); + } + return text; +} + +function sqlcCopyFromArrayEncoder(elementEncoder: SqlcCopyFromEncoder, dimensions: number): SqlcCopyFromEncoder { + return (value: unknown) => sqlcCopyFromArray(value, elementEncoder, dimensions); +} + +function sqlcCopyFromArray(value: unknown, elementEncoder: SqlcCopyFromEncoder, dimensions: number): string { + if (!Array.isArray(value)) { + throw sqlcCopyFromUnsupportedValue(value, "arrays for PostgreSQL array columns"); + } + const nestedDimensions = dimensions - 1; + return "{" + value.map((element) => { + if (element === null || element === undefined) { + return "NULL"; + } + if (nestedDimensions > 0) { + return sqlcCopyFromArray(element, elementEncoder, nestedDimensions); + } + return sqlcCopyFromArrayElement(elementEncoder(element)); + }).join(",") + "}"; +} + +function sqlcCopyFromArrayElement(value: string): string { + return '"' + value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'; +} + +function sqlcCopyFromValue(value: unknown, encoder: SqlcCopyFromEncoder): string { + if (value === null || value === undefined) { + return "\\N"; + } + return encoder(value) + .replace(/\\/g, "\\\\") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r"); +} + +function sqlcCopyFromRow(values: unknown[], encoders: SqlcCopyFromEncoder[]): string { + if (values.length !== encoders.length) { + throw new Error(":copyfrom row has " + values.length + " values, expected " + encoders.length); + } + return values.map((value, index) => { + const encoder = encoders[index]; + if (!encoder) { + throw new Error(":copyfrom row is missing encoder for column " + index); + } + return sqlcCopyFromValue(value, encoder); + }).join("\t") + "\n"; +} + +function sqlcCopyFromQueueWrite(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, chunk: string): SqlcCopyFromQueuedWrite { + let canContinue = true; + let notifyFailed!: () => void; + const failed = new Promise((resolve) => { + notifyFailed = resolve; + }); + const complete = new Promise((resolve) => { + const finish = (error?: unknown) => { + if (error) { + sqlcCopyFromRecordError(state, error); + notifyFailed(); + } + resolve(); + }; + try { + canContinue = stream.write(chunk, (error?: Error | null) => finish(error ?? undefined)) !== false; + } catch (error) { + canContinue = false; + finish(error); + } + }); + state.pendingWrites.push(complete); + return { canContinue, complete, failed }; +} + +async function sqlcCopyFromWrite(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, chunk: string): Promise { + if (chunk.length === 0) { + return; + } + sqlcCopyFromThrowIfError(state); + const queuedWrite = sqlcCopyFromQueueWrite(stream, state, chunk); + sqlcCopyFromThrowIfError(state); + if (!queuedWrite.canContinue) { + await sqlcCopyFromDrain(stream, state, queuedWrite); + } + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromDrain(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, queuedWrite: SqlcCopyFromQueuedWrite): Promise { + if (typeof stream.once !== "function") { + await queuedWrite.complete; + sqlcCopyFromThrowIfError(state); + return; + } + await Promise.race([ + queuedWrite.failed.then(() => sqlcCopyFromThrowIfError(state)), + new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + stream.off?.("drain", onDrain) ?? stream.removeListener?.("drain", onDrain); + stream.off?.("error", onError) ?? stream.removeListener?.("error", onError); + stream.off?.("close", onClose) ?? stream.removeListener?.("close", onClose); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + complete(); + }; + const onDrain = () => settle(() => resolve()); + const onError = (error?: unknown) => settle(() => { + sqlcCopyFromRecordError(state, error); + reject(state.error); + }); + const onClose = () => { + if (stream.writableFinished === true) { + onDrain(); + return; + } + onError(new Error("COPY stream closed before drain")); + }; + stream.once?.("drain", onDrain); + stream.once?.("error", onError); + stream.once?.("close", onClose); + }), + ]); + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromFlushWrites(state: SqlcCopyFromState): Promise { + while (state.pendingWrites.length > 0) { + const pendingWrites = state.pendingWrites; + state.pendingWrites = []; + await Promise.all(pendingWrites); + } + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromEnd(stream: SqlcCopyFromWritable): Promise { + await new Promise((resolve, reject) => { + let settled = false; + const hasCompletionEvents = typeof stream.once === "function"; + const cleanup = () => { + stream.off?.("error", onError) ?? stream.removeListener?.("error", onError); + stream.off?.("finish", onFinish) ?? stream.removeListener?.("finish", onFinish); + stream.off?.("close", onClose) ?? stream.removeListener?.("close", onClose); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + complete(); + }; + const onError = (error?: unknown) => settle(() => reject(sqlcCopyFromError(error))); + const onFinish = () => settle(() => resolve()); + const onClose = () => { + if (stream.writableFinished === true) { + onFinish(); + return; + } + onError(new Error("COPY stream closed before finish")); + }; + + if (hasCompletionEvents) { + stream.once?.("error", onError); + stream.once?.("finish", onFinish); + stream.once?.("close", onClose); + } + + stream.end((error?: Error | null) => { + if (error) { + onError(error); + return; + } + if (!hasCompletionEvents || stream.writableFinished !== false) { + onFinish(); + } + }); + }); +} + + +/** + * Options for generated batch queries. + * batchSize bounds pipelined queries per reserved connection chunk; chunks run in input order. + */ +export interface SqlcBatchOptions { + batchSize?: number; +} + +export interface SqlcBatchErrorItem { + index: number; + error: unknown; +} + +/** + * Batch functions reject with this after the first failed chunk. + * errors contain input indexes; later chunks are not started. + */ +export class SqlcBatchError extends Error { + readonly errors: SqlcBatchErrorItem[]; + + constructor(errors: SqlcBatchErrorItem[]) { + super(`Batch failed for ${errors.length} item(s)`); + this.name = "SqlcBatchError"; + this.errors = errors; + } +} + +function sqlcBatchSize(options: SqlcBatchOptions | undefined): number { + const batchSize = options?.batchSize ?? 64; + if (!Number.isInteger(batchSize) || batchSize < 1) { + throw new RangeError("batchSize must be a positive integer"); + } + return batchSize; +} + +async function sqlcRunBatched( + args: TArg[], + options: SqlcBatchOptions | undefined, + run: (arg: TArg, index: number) => Promise +): Promise { + const batchSize = sqlcBatchSize(options); + const results = new Array(args.length); + for (let start = 0; start < args.length; start += batchSize) { + const chunk = args.slice(start, start + batchSize); + const settled = await Promise.allSettled(chunk.map((arg, offset) => run(arg, start + offset))); + const errors: SqlcBatchErrorItem[] = []; + for (const [offset, result] of settled.entries()) { + const index = start + offset; + if (result.status === "fulfilled") { + results[index] = result.value; + } else { + errors.push({ index, error: result.reason }); + } + } + if (errors.length > 0) { + throw new SqlcBatchError(errors); + } + } + return results; +} + export const getAuthorQuery = `-- name: GetAuthor :one SELECT id, name, bio FROM authors @@ -16,7 +370,7 @@ export interface GetAuthorRow { bio: string | null; } -export async function getAuthor(sql: Sql, args: GetAuthorArgs): Promise { +export async function getAuthor(sql: Sql | TransactionSql, args: GetAuthorArgs): Promise { const rows = await sql.unsafe(getAuthorQuery, [args.id]).values(); if (rows.length !== 1) { return null; @@ -42,7 +396,7 @@ export interface ListAuthorsRow { bio: string | null; } -export async function listAuthors(sql: Sql): Promise { +export async function listAuthors(sql: Sql | TransactionSql): Promise { return (await sql.unsafe(listAuthorsQuery, []).values()).map(row => ({ id: row[0], name: row[1], @@ -69,7 +423,7 @@ export interface CreateAuthorRow { bio: string | null; } -export async function createAuthor(sql: Sql, args: CreateAuthorArgs): Promise { +export async function createAuthor(sql: Sql | TransactionSql, args: CreateAuthorArgs): Promise { const rows = await sql.unsafe(createAuthorQuery, [args.name, args.bio]).values(); if (rows.length !== 1) { return null; @@ -85,6 +439,52 @@ export async function createAuthor(sql: Sql, args: CreateAuthorArgs): Promise { + const copyStream = await sql.unsafe("COPY \"authors\" (\"name\", \"bio\") FROM STDIN").writable() as SqlcCopyFromWritable; + const copyStreamState = sqlcCopyFromCreateState(); + const copyEncoders: SqlcCopyFromEncoder[] = [sqlcCopyFromScalar, sqlcCopyFromScalar]; + const onCopyStreamError = (error?: unknown) => { + sqlcCopyFromRecordError(copyStreamState, error); + }; + copyStream.once?.("error", onCopyStreamError); + try { + let copyRowBatch = ""; + for (const arg of args) { + sqlcCopyFromThrowIfError(copyStreamState); + const copyRow = sqlcCopyFromRow([arg.name, arg.bio], copyEncoders); + if (copyRowBatch.length > 0 && copyRowBatch.length + copyRow.length > sqlcCopyFromMaxChunkBytes) { + await sqlcCopyFromWrite(copyStream, copyStreamState, copyRowBatch); + copyRowBatch = ""; + } + copyRowBatch += copyRow; + } + await sqlcCopyFromWrite(copyStream, copyStreamState, copyRowBatch); + await sqlcCopyFromFlushWrites(copyStreamState); + await sqlcCopyFromEnd(copyStream); + sqlcCopyFromThrowIfError(copyStreamState); + return args.length; + } + catch (error) { + copyStream.destroy?.(error); + throw error; + } + finally { + copyStream.off?.("error", onCopyStreamError) ?? copyStream.removeListener?.("error", onCopyStreamError); + } +} + export const deleteAuthorQuery = `-- name: DeleteAuthor :exec DELETE FROM authors WHERE id = $1`; @@ -93,7 +493,123 @@ export interface DeleteAuthorArgs { id: string; } -export async function deleteAuthor(sql: Sql, args: DeleteAuthorArgs): Promise { +export async function deleteAuthor(sql: Sql | TransactionSql, args: DeleteAuthorArgs): Promise { await sql.unsafe(deleteAuthorQuery, [args.id]); } +export const batchCreateAuthorQuery = `-- name: BatchCreateAuthor :batchone +INSERT INTO authors ( + name, bio +) VALUES ( + $1, $2 +) +RETURNING id, name, bio`; + +export interface BatchCreateAuthorArgs { + name: string; + bio: string | null; +} + +export interface BatchCreateAuthorRow { + id: string; + name: string; + bio: string | null; +} + +export interface BatchCreateAuthorBatchResult { + index: number; + row: BatchCreateAuthorRow | null; +} + +export async function batchCreateAuthor(sql: Sql, args: BatchCreateAuthorArgs[], options?: SqlcBatchOptions): Promise { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise => { + const rows = await batchSql.unsafe(batchCreateAuthorQuery, [arg.name, arg.bio]).values(); + if (rows.length !== 1) { + return { index, row: null }; + } + const row = rows[0]; + if (!row) { + return { index, row: null }; + } + return { + index, + row: { + id: row[0], + name: row[1], + bio: row[2] + } + }; + }); + } + finally { + batchSql.release(); + } +} + +export const batchListAuthorsByBioQuery = `-- name: BatchListAuthorsByBio :batchmany +SELECT id, name, bio FROM authors +WHERE bio = $1 +ORDER BY name`; + +export interface BatchListAuthorsByBioArgs { + bio: string | null; +} + +export interface BatchListAuthorsByBioRow { + id: string; + name: string; + bio: string | null; +} + +export interface BatchListAuthorsByBioBatchResult { + index: number; + rows: BatchListAuthorsByBioRow[]; +} + +export async function batchListAuthorsByBio(sql: Sql, args: BatchListAuthorsByBioArgs[], options?: SqlcBatchOptions): Promise { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise => { + const rows = await batchSql.unsafe(batchListAuthorsByBioQuery, [arg.bio]).values(); + return { + index, + rows: rows.map(row => ({ + id: row[0], + name: row[1], + bio: row[2] + })) + }; + }); + } + finally { + batchSql.release(); + } +} + +export const batchDeleteAuthorQuery = `-- name: BatchDeleteAuthor :batchexec +DELETE FROM authors +WHERE id = $1`; + +export interface BatchDeleteAuthorArgs { + id: string; +} + +export interface BatchDeleteAuthorBatchResult { + index: number; +} + +export async function batchDeleteAuthor(sql: Sql, args: BatchDeleteAuthorArgs[], options?: SqlcBatchOptions): Promise { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise => { + await batchSql.unsafe(batchDeleteAuthorQuery, [arg.id]); + return { index }; + }); + } + finally { + batchSql.release(); + } +} + diff --git a/package-lock.json b/package-lock.json index c923afa..662d752 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,16 +7,18 @@ "": { "name": "@reloaddk/sqlc-gen-typescript", "version": "0.2.0", - "license": "ISC", "dependencies": { "@bufbuild/protobuf": "^2.12.1", "javy": "^0.1.2", "pg": "^8.22.0" }, "devDependencies": { + "@types/node": "^26.1.1", "@types/pg": "^8.10.9", "esbuild": "^0.28.1", - "typescript": "^6.0.3" + "postgres": "^3.4.9", + "typescript": "^6.0.3", + "vitest": "^4.1.10" } }, "node_modules/@bufbuild/protobuf": { @@ -25,516 +27,1308 @@ "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "aix" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "ppc64" + ] }, "node_modules/@esbuild/android-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm" + ] }, "node_modules/@esbuild/android-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm64" + ] }, "node_modules/@esbuild/android-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "x64" + ] }, "node_modules/@esbuild/darwin-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "darwin" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm64" + ] }, "node_modules/@esbuild/darwin-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "darwin" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "x64" + ] }, "node_modules/@esbuild/freebsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "freebsd" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm64" + ] }, "node_modules/@esbuild/freebsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "freebsd" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "x64" + ] }, "node_modules/@esbuild/linux-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm" + ] }, "node_modules/@esbuild/linux-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm64" + ] }, "node_modules/@esbuild/linux-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "ia32" + ] }, "node_modules/@esbuild/linux-loong64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "loong64" + ] }, "node_modules/@esbuild/linux-mips64el": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "mips64el" + ] }, "node_modules/@esbuild/linux-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "ppc64" + ] }, "node_modules/@esbuild/linux-riscv64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "riscv64" + ] }, "node_modules/@esbuild/linux-s390x": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "s390x" + ] }, "node_modules/@esbuild/linux-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "x64" + ] }, "node_modules/@esbuild/netbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "netbsd" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm64" + ] }, "node_modules/@esbuild/netbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "netbsd" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "x64" + ] }, "node_modules/@esbuild/openbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "openbsd" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm64" + ] }, "node_modules/@esbuild/openbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "openbsd" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "x64" + ] }, "node_modules/@esbuild/openharmony-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ "openharmony" ], - "engines": { - "node": ">=18" - } + "cpu": [ + "arm64" + ] }, "node_modules/@esbuild/sunos-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "sunos" + ], + "cpu": [ "x64" + ] + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" ], - "dev": true, + "cpu": [ + "arm64" + ] + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "license": "MIT", - "optional": true, + "engines": { + "node": ">=18" + }, "os": [ - "sunos" + "win32" + ], + "cpu": [ + "ia32" + ] + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ] + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT", + "dev": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@tybys/wasm-util": "^0.10.3" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + }, + "funding": { + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "android" + ], + "cpu": [ + "arm64" + ] + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ] + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ] + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "x64" + ] + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm" + ] + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ] + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ] + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ] + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ] + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ] + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ] + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "openharmony" + ], + "cpu": [ + "arm64" + ] + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "license": "MIT", + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "cpu": [ + "wasm32" + ] + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ] + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ] + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT", + "dev": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + }, + "dev": true + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "license": "MIT", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT", + "dev": true + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + }, + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "dev": true + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21", + "vite": "8.1.4" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "dev": true + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "dev": true + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "dev": true + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "dev": true + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + }, + "dev": true + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "dev": true + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "dev": true + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT", + "dev": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "license": "MIT", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "license": "MIT", + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + }, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "dev": true + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + }, + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "dependencies": { + "picomatch": "4.0.5" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "license": "MIT", + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + }, + "os": [ + "darwin" + ] + }, + "node_modules/javy": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/javy/-/javy-0.1.2.tgz", + "integrity": "sha512-z6Z+CV13SXBGxmY5UseWsYNVVR4SWPfOixKGluWi1Dpn594+M2JaGPBrFIIMnFFfi7kJzHCkcTn7CrhJ7JGKsA==", + "license": "Apache-2.0" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "android" + ], + "cpu": [ + "arm64" + ], + "funding": { + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "funding": { + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "funding": { + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "x64" + ], + "funding": { + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/parcel" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "linux" + ], "cpu": [ "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" + "libc": [ + "glibc" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/parcel" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "linux" + ], "cpu": [ - "ia32" + "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" + "libc": [ + "musl" ], + "funding": { + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "license": "MPL-2.0", "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "funding": { + "url": "https://opencollective.com/parcel" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "linux" + ], "cpu": [ "x64" ], - "dev": true, - "license": "MIT", - "optional": true, + "libc": [ + "musl" + ], + "funding": { + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, "os": [ "win32" ], - "engines": { - "node": ">=18" + "cpu": [ + "arm64" + ], + "funding": { + "url": "https://opencollective.com/parcel" } }, - "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 12.0.0" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "funding": { + "url": "https://opencollective.com/parcel" } }, - "node_modules/@types/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } + "@jridgewell/sourcemap-codec": "^1.5.5" + }, + "dev": true }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "license": "MIT", "bin": { - "esbuild": "bin/esbuild" + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "funding": { + "url": "https://github.com/sponsors/ai" } }, - "node_modules/javy": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/javy/-/javy-0.1.2.tgz", - "integrity": "sha512-z6Z+CV13SXBGxmY5UseWsYNVVR4SWPfOixKGluWi1Dpn594+M2JaGPBrFIIMnFFfi7kJzHCkcTn7CrhJ7JGKsA==" + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "dev": true + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT", + "dev": true }, "node_modules/pg": { "version": "8.22.0", @@ -548,9 +1342,6 @@ "pg-types": "2.2.0", "pgpass": "1.0.5" }, - "engines": { - "node": ">= 16.0.0" - }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, @@ -561,14 +1352,16 @@ "pg-native": { "optional": true } + }, + "engines": { + "node": ">= 16.0.0" } }, "node_modules/pg-cloudflare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/pg-connection-string": { "version": "2.14.0", @@ -590,6 +1383,9 @@ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", "license": "MIT", + "dependencies": { + "pg": "8.22.0" + }, "peerDependencies": { "pg": ">=8.0" } @@ -625,6 +1421,54 @@ "split2": "^4.1.0" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "funding": { + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/porsager" + }, + "dev": true + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -635,9 +1479,9 @@ } }, "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -664,6 +1508,55 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + }, + "bin": { + "rolldown": "./bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "license": "ISC", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -673,11 +1566,73 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "license": "MIT", + "dev": true + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "license": "MIT", + "dev": true + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "dev": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -685,15 +1640,200 @@ }, "engines": { "node": ">=14.17" - } + }, + "dev": true }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "license": "MIT", + "dependencies": { + "@types/node": "26.1.1", + "esbuild": "0.28.1", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "license": "MIT", + "dependencies": { + "@types/node": "26.1.1", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": {} + }, + "bin": { + "vitest": "./vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "dev": true + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "dev": true + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 2bd505e..c4ffd50 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,19 @@ "description": "", "main": "app.js", "scripts": { - "test": "tsc --noEmit && node --test" + "test": "tsc --noEmit && vitest run test/*.test.mjs", + "test:integration": "vitest run test/integration", + "test:perf": "SQLC_PERF=1 vitest run --reporter=verbose test/perf" }, "author": "", "license": "ISC", "devDependencies": { + "@types/node": "^26.1.1", "@types/pg": "^8.10.9", "esbuild": "^0.28.1", - "typescript": "^6.0.3" + "postgres": "^3.4.9", + "typescript": "^6.0.3", + "vitest": "^4.1.10" }, "dependencies": { "@bufbuild/protobuf": "^2.12.1", diff --git a/src/app.ts b/src/app.ts index d3ddcc5..203bbbc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -27,6 +27,7 @@ import { File, FileSchema, Query, + Identifier, } from "./gen/plugin/codegen_pb"; import { argName, colName } from "./drivers/utlis"; @@ -34,6 +35,7 @@ import { Driver as Sqlite3Driver } from "./drivers/better-sqlite3"; import { Driver as PgDriver } from "./drivers/pg"; import { Driver as PostgresDriver } from "./drivers/postgres"; import { Mysql2Options, Driver as MysqlDriver } from "./drivers/mysql2"; +import { validateCopyfromQuery } from "./copyfrom-validation"; // Read input from stdin const input = readInput(); @@ -79,6 +81,38 @@ interface Driver { params: Parameter[], columns: Column[] ) => Node; + batchexecDecl: ( + name: string, + text: string, + argIface: string | undefined, + resultIface: string, + params: Parameter[] + ) => Node[]; + batchmanyDecl: ( + name: string, + text: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ) => Node[]; + batchoneDecl: ( + name: string, + text: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ) => Node[]; + copyfromDecl: ( + name: string, + text: string, + argIface: string | undefined, + params: Parameter[], + table: Identifier | undefined + ) => Node[]; } function createNodeGenerator(options: Options): Driver { @@ -199,6 +233,59 @@ ${query.text}` ); break; } + case ":batchexec": { + nodes.push( + ...driver.batchexecDecl( + lowerName, + textName, + argIface, + `${query.name}BatchResult`, + query.params + ) + ); + break; + } + case ":batchmany": { + nodes.push( + ...driver.batchmanyDecl( + lowerName, + textName, + argIface, + returnIface ?? "void", + `${query.name}BatchResult`, + query.params, + query.columns + ) + ); + break; + } + case ":batchone": { + nodes.push( + ...driver.batchoneDecl( + lowerName, + textName, + argIface, + returnIface ?? "void", + `${query.name}BatchResult`, + query.params, + query.columns + ) + ); + break; + } + case ":copyfrom": { + validateCopyfromQuery(query); + nodes.push( + ...driver.copyfromDecl( + lowerName, + textName, + argIface, + query.params, + query.insertIntoTable + ) + ); + break; + } } if (nodes) { files.push( diff --git a/src/copyfrom-validation.ts b/src/copyfrom-validation.ts new file mode 100644 index 0000000..0100465 --- /dev/null +++ b/src/copyfrom-validation.ts @@ -0,0 +1,120 @@ +import type { Query } from "./gen/plugin/codegen_pb"; + +const simpleInsertPattern = /^\s*insert\s+into\s+((?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*))?)\s*\(([\s\S]+)\)\s*values\s*\(([\s\S]+)\)\s*;?\s*$/i; +const identifierPattern = /^(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)$/; +const positionalParameterPattern = /^\$(\d+)$/; + +function failCopyfromValidation(query: Query, reason: string): never { + throw new Error( + `:copyfrom query ${query.name} must be a simple INSERT INTO table (columns...) VALUES ($1, ...) statement: ${reason}` + ); +} + +function splitSqlDelimited(text: string, delimiter: string, query: Query, label: string): string[] { + const items: string[] = []; + let start = 0; + let inQuotedIdentifier = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '"') { + if (inQuotedIdentifier && text[i + 1] === '"') { + i++; + continue; + } + inQuotedIdentifier = !inQuotedIdentifier; + continue; + } + if (char === delimiter && !inQuotedIdentifier) { + items.push(text.slice(start, i).trim()); + start = i + 1; + } + } + + if (inQuotedIdentifier) { + failCopyfromValidation(query, `${label} contains an unterminated quoted identifier`); + } + + items.push(text.slice(start).trim()); + if (items.some((item) => item.length === 0)) { + failCopyfromValidation(query, `${label} contains an empty item`); + } + return items; +} + +function splitSqlList(text: string, query: Query, label: string): string[] { + return splitSqlDelimited(text, ",", query, label); +} + +function sqlIdentifierText(identifier: string): string { + const trimmed = identifier.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replace(/""/g, '"'); + } + return trimmed.toLowerCase(); +} + +function validateCopyfromTable(tableText: string, query: Query): void { + const tableParts = splitSqlDelimited(tableText, ".", query, "table name").map(sqlIdentifierText); + const expectedParts = [query.insertIntoTable?.schema, query.insertIntoTable?.name] + .filter((part): part is string => Boolean(part)) + .map((part) => part.toLowerCase()); + + if (tableParts.length !== expectedParts.length) { + failCopyfromValidation(query, "INSERT table must match sqlc table metadata"); + } + for (const [index, tablePart] of tableParts.entries()) { + if (tablePart !== expectedParts[index]) { + failCopyfromValidation(query, "INSERT table must match sqlc table metadata"); + } + } +} + +export function validateCopyfromQuery(query: Query): void { + if (!query.insertIntoTable?.name) { + failCopyfromValidation(query, "missing INSERT INTO table metadata"); + } + + const match = simpleInsertPattern.exec(query.text); + if (!match) { + failCopyfromValidation(query, "unsupported INSERT shape"); + } + + const tableText = match[1]; + const columnsText = match[2]; + const valuesText = match[3]; + if (tableText === undefined || columnsText === undefined || valuesText === undefined) { + failCopyfromValidation(query, "missing table, column, or VALUES list"); + } + + validateCopyfromTable(tableText, query); + const columns = splitSqlList(columnsText, query, "column list"); + const values = splitSqlList(valuesText, query, "VALUES list"); + + if (columns.length !== query.params.length) { + failCopyfromValidation(query, "column count must match parameter count"); + } + if (values.length !== query.params.length) { + failCopyfromValidation(query, "VALUES count must match parameter count"); + } + + for (const column of columns) { + if (!identifierPattern.test(column)) { + failCopyfromValidation(query, `column ${column} is not a simple identifier`); + } + } + + for (const [index, value] of values.entries()) { + const paramMatch = positionalParameterPattern.exec(value); + if (!paramMatch) { + failCopyfromValidation(query, `VALUES item ${index + 1} is not a positional parameter`); + } + const paramNumber = Number(paramMatch[1]); + if (paramNumber !== index + 1) { + failCopyfromValidation(query, "VALUES parameters must be contiguous and in order"); + } + if (query.params[index]?.number !== paramNumber) { + failCopyfromValidation(query, "sqlc parameter metadata does not match VALUES order"); + } + } +} diff --git a/src/drivers/better-sqlite3.ts b/src/drivers/better-sqlite3.ts index c6210e8..8d6e0a5 100644 --- a/src/drivers/better-sqlite3.ts +++ b/src/drivers/better-sqlite3.ts @@ -425,6 +425,49 @@ export class Driver { ); } + batchexecDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + resultIface: string, + params: Parameter[] + ): Node[] { + throw new Error("better-sqlite3 driver currently does not support :batchexec"); + } + + batchmanyDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ): Node[] { + throw new Error("better-sqlite3 driver currently does not support :batchmany"); + } + + batchoneDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ): Node[] { + throw new Error("better-sqlite3 driver currently does not support :batchone"); + } + + copyfromDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + params: Parameter[] + ): Node[] { + throw new Error("better-sqlite3 driver currently does not support :copyfrom"); + } + execlastidDecl( funcName: string, queryName: string, diff --git a/src/drivers/mysql2.ts b/src/drivers/mysql2.ts index 0ca36cc..8d50c56 100644 --- a/src/drivers/mysql2.ts +++ b/src/drivers/mysql2.ts @@ -1,4 +1,4 @@ -import { NodeFlags, SyntaxKind, TypeNode, factory } from "typescript"; +import { Node, NodeFlags, SyntaxKind, TypeNode, factory } from "typescript"; // import { writeFileSync, STDIO } from "javy/fs"; @@ -658,6 +658,49 @@ export class Driver { ); } + batchexecDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + resultIface: string, + params: Parameter[] + ): Node[] { + throw new Error("mysql2 driver currently does not support :batchexec"); + } + + batchmanyDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ): Node[] { + throw new Error("mysql2 driver currently does not support :batchmany"); + } + + batchoneDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ): Node[] { + throw new Error("mysql2 driver currently does not support :batchone"); + } + + copyfromDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + params: Parameter[] + ): Node[] { + throw new Error("mysql2 driver currently does not support :copyfrom"); + } + execlastidDecl( funcName: string, queryName: string, diff --git a/src/drivers/pg.ts b/src/drivers/pg.ts index c57b870..e822a2e 100644 --- a/src/drivers/pg.ts +++ b/src/drivers/pg.ts @@ -5,6 +5,9 @@ import { TypeNode, factory, FunctionDeclaration, + createSourceFile, + ScriptKind, + ScriptTarget, } from "typescript"; import { Column, Parameter, Query } from "../gen/plugin/codegen_pb"; @@ -44,6 +47,72 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { return funcParams; } +function sourceStatements(source: string): Node[] { + return Array.from( + createSourceFile( + "batch.ts", + source, + ScriptTarget.Latest, + false, + ScriptKind.TS + ).statements + ); +} + +function batchFuncParamsDecl(argIface: string | undefined): string { + return `_client: Client, _args: ${(argIface ?? "void")}[], _options?: SqlcBatchOptions`; +} + +function copyfromFuncParamsDecl(argIface: string | undefined): string { + return `_client: Client, _args: ${(argIface ?? "void")}[]`; +} + +function batchUnsupportedDecls(): Node[] { + return sourceStatements(` +/** + * Accepted by pg batch stubs for source compatibility only. + * node-postgres batch annotations always throw SqlcBatchUnsupportedError. + */ +export interface SqlcBatchOptions { + batchSize?: number; +} + +/** + * node-postgres does not expose pgx-style SendBatch/pipelining semantics. + * Use the postgres driver for generated batch query support. + */ +export class SqlcBatchUnsupportedError extends Error { + readonly driver = "pg"; + readonly command: string; + + constructor(command: string) { + super(\`pg driver does not support \${command}; use the postgres driver for batch annotations.\`); + this.name = "SqlcBatchUnsupportedError"; + this.command = command; + } +} +`); +} + +function copyfromUnsupportedDecls(): Node[] { + return sourceStatements(` +/** + * node-postgres needs external COPY stream wiring that this generator does not emit. + * Use the postgres driver for generated :copyfrom support. + */ +export class SqlcCopyFromUnsupportedError extends Error { + readonly driver = "pg"; + readonly command: string; + + constructor(command: string) { + super(\`pg driver does not support \${command}; use the postgres driver for :copyfrom annotations.\`); + this.name = "SqlcCopyFromUnsupportedError"; + this.command = command; + } +} +`); +} + export class Driver { columnType(column?: Column): TypeNode { if (column === undefined || column.type === undefined) { @@ -428,6 +497,14 @@ export class Driver { ) ); + if (queries.some((query) => query.cmd.startsWith(":batch"))) { + imports.push(...batchUnsupportedDecls()); + } + + if (queries.some((query) => query.cmd === ":copyfrom")) { + imports.push(...copyfromUnsupportedDecls()); + } + return imports; } @@ -797,6 +874,79 @@ export class Driver { ); } + batchexecDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + resultIface: string, + params: Parameter[] + ): Node[] { + return sourceStatements(` +export interface ${resultIface} { + index: number; +} + +export async function ${funcName}(${batchFuncParamsDecl(argIface)}): Promise<${resultIface}[]> { + throw new SqlcBatchUnsupportedError(":batchexec"); +} +`); + } + + batchmanyDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ): Node[] { + return sourceStatements(` +export interface ${resultIface} { + index: number; + rows: ${returnIface}[]; +} + +export async function ${funcName}(${batchFuncParamsDecl(argIface)}): Promise<${resultIface}[]> { + throw new SqlcBatchUnsupportedError(":batchmany"); +} +`); + } + + batchoneDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ): Node[] { + return sourceStatements(` +export interface ${resultIface} { + index: number; + row: ${returnIface} | null; +} + +export async function ${funcName}(${batchFuncParamsDecl(argIface)}): Promise<${resultIface}[]> { + throw new SqlcBatchUnsupportedError(":batchone"); +} +`); + } + + copyfromDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + params: Parameter[] + ): Node[] { + return sourceStatements(` +export async function ${funcName}(${copyfromFuncParamsDecl(argIface)}): Promise { + throw new SqlcCopyFromUnsupportedError(":copyfrom"); +} +`); + } + execlastidDecl( funcName: string, queryName: string, diff --git a/src/drivers/postgres.ts b/src/drivers/postgres.ts index 68d23f2..8640aa1 100644 --- a/src/drivers/postgres.ts +++ b/src/drivers/postgres.ts @@ -1,12 +1,16 @@ import { SyntaxKind, NodeFlags, + Node, TypeNode, factory, FunctionDeclaration, + createSourceFile, + ScriptKind, + ScriptTarget, } from "typescript"; -import { Parameter, Column } from "../gen/plugin/codegen_pb"; +import { Parameter, Column, Query, Identifier } from "../gen/plugin/codegen_pb"; import { argName, colName } from "./utlis"; import { log } from "../logger"; @@ -17,10 +21,10 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { undefined, factory.createIdentifier("sql"), undefined, - factory.createTypeReferenceNode( - factory.createIdentifier("Sql"), - undefined - ), + factory.createUnionTypeNode([ + factory.createTypeReferenceNode(factory.createIdentifier("Sql"), undefined), + factory.createTypeReferenceNode(factory.createIdentifier("TransactionSql"), undefined), + ]), undefined ), ]; @@ -44,6 +48,473 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { return funcParams; } +function sourceStatements(source: string): Node[] { + return Array.from( + createSourceFile( + "batch.ts", + source, + ScriptTarget.Latest, + false, + ScriptKind.TS + ).statements + ); +} + +function batchFuncParamsDecl(argIface: string | undefined): string { + return `sql: Sql, args: ${(argIface ?? "void")}[], options?: SqlcBatchOptions`; +} + +function batchSupportDecls(): Node[] { + return sourceStatements(` +/** + * Options for generated batch queries. + * batchSize bounds pipelined queries per reserved connection chunk; chunks run in input order. + */ +export interface SqlcBatchOptions { + batchSize?: number; +} + +export interface SqlcBatchErrorItem { + index: number; + error: unknown; +} + +/** + * Batch functions reject with this after the first failed chunk. + * errors contain input indexes; later chunks are not started. + */ +export class SqlcBatchError extends Error { + readonly errors: SqlcBatchErrorItem[]; + + constructor(errors: SqlcBatchErrorItem[]) { + super(\`Batch failed for \${errors.length} item(s)\`); + this.name = "SqlcBatchError"; + this.errors = errors; + } +} + +function sqlcBatchSize(options: SqlcBatchOptions | undefined): number { + const batchSize = options?.batchSize ?? 64; + if (!Number.isInteger(batchSize) || batchSize < 1) { + throw new RangeError("batchSize must be a positive integer"); + } + return batchSize; +} + +async function sqlcRunBatched( + args: TArg[], + options: SqlcBatchOptions | undefined, + run: (arg: TArg, index: number) => Promise +): Promise { + const batchSize = sqlcBatchSize(options); + const results = new Array(args.length); + for (let start = 0; start < args.length; start += batchSize) { + const chunk = args.slice(start, start + batchSize); + const settled = await Promise.allSettled( + chunk.map((arg, offset) => run(arg, start + offset)) + ); + const errors: SqlcBatchErrorItem[] = []; + for (const [offset, result] of settled.entries()) { + const index = start + offset; + if (result.status === "fulfilled") { + results[index] = result.value; + } else { + errors.push({ index, error: result.reason }); + } + } + if (errors.length > 0) { + throw new SqlcBatchError(errors); + } + } + return results; +} +`); +} + +function valuesExpression(params: Parameter[]): string { + if (params.length === 0) { + return "[]"; + } + return `[${params + .map((param, i) => `arg.${argName(i, param.column)}`) + .join(", ")}]`; +} + +function rowExpression(columns: Column[]): string { + if (columns.length === 0) { + return "undefined"; + } + const properties = columns + .map((column, i) => `${colName(i, column)}: row[${i}]`) + .join(",\n "); + return `({ + ${properties} + })`; +} + +function copyfromSupportDecls(): Node[] { + return sourceStatements(String.raw` +type SqlcCopyFromEvent = "error" | "finish" | "close" | "drain"; +type SqlcCopyFromListener = (error?: unknown) => void; +type SqlcCopyFromEncoder = (value: unknown) => string; + +const sqlcCopyFromMaxChunkBytes = 65536; + +interface SqlcCopyFromWritable { + write(chunk: string, callback: (error?: Error | null) => void): boolean; + end(callback: (error?: Error | null) => void): void; + destroy?(error?: unknown): void; + readonly writableFinished?: boolean; + once?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; + off?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; + removeListener?(event: SqlcCopyFromEvent, listener: SqlcCopyFromListener): SqlcCopyFromWritable; +} + +interface SqlcCopyFromQueuedWrite { + canContinue: boolean; + complete: Promise; + failed: Promise; +} + +interface SqlcCopyFromState { + error?: Error; + pendingWrites: Promise[]; +} + +function sqlcCopyFromCreateState(): SqlcCopyFromState { + return { pendingWrites: [] }; +} + +function sqlcCopyFromError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error ?? "COPY stream failed")); +} + +function sqlcCopyFromRecordError(state: SqlcCopyFromState, error: unknown): void { + state.error ??= sqlcCopyFromError(error); +} + +function sqlcCopyFromThrowIfError(state: SqlcCopyFromState): void { + if (state.error) { + throw state.error; + } +} + +function sqlcCopyFromUnsupportedValue(value: unknown, expected = "null, string, number, boolean, bigint, Date, Uint8Array/Buffer, JSON-serializable values, and arrays for PostgreSQL array columns"): TypeError { + const tag = Object.prototype.toString.call(value); + return new TypeError(":copyfrom supports only " + expected + "; received " + tag); +} + +function sqlcCopyFromScalar(value: unknown): string { + switch (typeof value) { + case "string": + return value; + case "number": + return String(value); + case "boolean": + return value ? "true" : "false"; + case "bigint": + return value.toString(); + case "object": + if (value instanceof Date) { + return value.toISOString(); + } + throw sqlcCopyFromUnsupportedValue(value, "string, number, boolean, bigint, and Date values for scalar columns"); + default: + throw sqlcCopyFromUnsupportedValue(value, "string, number, boolean, bigint, and Date values for scalar columns"); + } +} + +function sqlcCopyFromBytea(value: unknown): string { + let bytes: Uint8Array | undefined; + if (typeof ArrayBuffer !== "undefined") { + if (ArrayBuffer.isView(value)) { + const view = value as ArrayBufferView; + bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } else if (value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]") { + bytes = new Uint8Array(value as ArrayBuffer); + } + } + if (!bytes) { + throw sqlcCopyFromUnsupportedValue(value, "Uint8Array or Buffer values for bytea columns"); + } + + let hex = ""; + for (let index = 0; index < bytes.length; index++) { + const byte = bytes[index]; + if (byte === undefined) { + continue; + } + hex += byte.toString(16).padStart(2, "0"); + } + return "\\x" + hex; +} + +function sqlcCopyFromJson(value: unknown): string { + let text: string | undefined; + try { + text = JSON.stringify(value); + } catch { + throw sqlcCopyFromUnsupportedValue(value, "JSON-serializable values for json/jsonb columns"); + } + if (text === undefined) { + throw sqlcCopyFromUnsupportedValue(value, "JSON-serializable values for json/jsonb columns"); + } + return text; +} + +function sqlcCopyFromArrayEncoder(elementEncoder: SqlcCopyFromEncoder, dimensions: number): SqlcCopyFromEncoder { + return (value: unknown) => sqlcCopyFromArray(value, elementEncoder, dimensions); +} + +function sqlcCopyFromArray(value: unknown, elementEncoder: SqlcCopyFromEncoder, dimensions: number): string { + if (!Array.isArray(value)) { + throw sqlcCopyFromUnsupportedValue(value, "arrays for PostgreSQL array columns"); + } + const nestedDimensions = dimensions - 1; + return "{" + value.map((element) => { + if (element === null || element === undefined) { + return "NULL"; + } + if (nestedDimensions > 0) { + return sqlcCopyFromArray(element, elementEncoder, nestedDimensions); + } + return sqlcCopyFromArrayElement(elementEncoder(element)); + }).join(",") + "}"; +} + +function sqlcCopyFromArrayElement(value: string): string { + return '"' + value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'; +} + +function sqlcCopyFromValue(value: unknown, encoder: SqlcCopyFromEncoder): string { + if (value === null || value === undefined) { + return "\\N"; + } + return encoder(value) + .replace(/\\/g, "\\\\") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r"); +} + +function sqlcCopyFromRow(values: unknown[], encoders: SqlcCopyFromEncoder[]): string { + if (values.length !== encoders.length) { + throw new Error(":copyfrom row has " + values.length + " values, expected " + encoders.length); + } + return values.map((value, index) => { + const encoder = encoders[index]; + if (!encoder) { + throw new Error(":copyfrom row is missing encoder for column " + index); + } + return sqlcCopyFromValue(value, encoder); + }).join("\t") + "\n"; +} + +function sqlcCopyFromQueueWrite(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, chunk: string): SqlcCopyFromQueuedWrite { + let canContinue = true; + let notifyFailed!: () => void; + const failed = new Promise((resolve) => { + notifyFailed = resolve; + }); + const complete = new Promise((resolve) => { + const finish = (error?: unknown) => { + if (error) { + sqlcCopyFromRecordError(state, error); + notifyFailed(); + } + resolve(); + }; + try { + canContinue = stream.write(chunk, (error?: Error | null) => finish(error ?? undefined)) !== false; + } catch (error) { + canContinue = false; + finish(error); + } + }); + state.pendingWrites.push(complete); + return { canContinue, complete, failed }; +} + +async function sqlcCopyFromWrite(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, chunk: string): Promise { + if (chunk.length === 0) { + return; + } + sqlcCopyFromThrowIfError(state); + const queuedWrite = sqlcCopyFromQueueWrite(stream, state, chunk); + sqlcCopyFromThrowIfError(state); + if (!queuedWrite.canContinue) { + await sqlcCopyFromDrain(stream, state, queuedWrite); + } + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromDrain(stream: SqlcCopyFromWritable, state: SqlcCopyFromState, queuedWrite: SqlcCopyFromQueuedWrite): Promise { + if (typeof stream.once !== "function") { + await queuedWrite.complete; + sqlcCopyFromThrowIfError(state); + return; + } + await Promise.race([ + queuedWrite.failed.then(() => sqlcCopyFromThrowIfError(state)), + new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + stream.off?.("drain", onDrain) ?? stream.removeListener?.("drain", onDrain); + stream.off?.("error", onError) ?? stream.removeListener?.("error", onError); + stream.off?.("close", onClose) ?? stream.removeListener?.("close", onClose); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + complete(); + }; + const onDrain = () => settle(() => resolve()); + const onError = (error?: unknown) => settle(() => { + sqlcCopyFromRecordError(state, error); + reject(state.error); + }); + const onClose = () => { + if (stream.writableFinished === true) { + onDrain(); + return; + } + onError(new Error("COPY stream closed before drain")); + }; + stream.once?.("drain", onDrain); + stream.once?.("error", onError); + stream.once?.("close", onClose); + }), + ]); + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromFlushWrites(state: SqlcCopyFromState): Promise { + while (state.pendingWrites.length > 0) { + const pendingWrites = state.pendingWrites; + state.pendingWrites = []; + await Promise.all(pendingWrites); + } + sqlcCopyFromThrowIfError(state); +} + +async function sqlcCopyFromEnd(stream: SqlcCopyFromWritable): Promise { + await new Promise((resolve, reject) => { + let settled = false; + const hasCompletionEvents = typeof stream.once === "function"; + const cleanup = () => { + stream.off?.("error", onError) ?? stream.removeListener?.("error", onError); + stream.off?.("finish", onFinish) ?? stream.removeListener?.("finish", onFinish); + stream.off?.("close", onClose) ?? stream.removeListener?.("close", onClose); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + complete(); + }; + const onError = (error?: unknown) => settle(() => reject(sqlcCopyFromError(error))); + const onFinish = () => settle(() => resolve()); + const onClose = () => { + if (stream.writableFinished === true) { + onFinish(); + return; + } + onError(new Error("COPY stream closed before finish")); + }; + + if (hasCompletionEvents) { + stream.once?.("error", onError); + stream.once?.("finish", onFinish); + stream.once?.("close", onClose); + } + + stream.end((error?: Error | null) => { + if (error) { + onError(error); + return; + } + if (!hasCompletionEvents || stream.writableFinished !== false) { + onFinish(); + } + }); + }); +} +`); +} + +function copyfromIdent(name: string): string { + return `"${name.replace(/"/g, `""`)}"`; +} + +function copyfromTableName(table: Identifier | undefined): string { + if (!table?.name) { + throw new Error(":copyfrom requires an INSERT INTO table"); + } + const parts = [table.schema, table.name].filter((part) => part.length > 0); + return parts.map(copyfromIdent).join("."); +} + +function copyfromColumnName(param: Parameter, index: number): string { + return param.column?.originalName || param.column?.name || argName(index, param.column); +} + +function copyfromSql(table: Identifier | undefined, params: Parameter[]): string { + const columnNames = params.map(copyfromColumnName).map(copyfromIdent).join(", "); + return `COPY ${copyfromTableName(table)} (${columnNames}) FROM STDIN`; +} + +function copyfromColumnTypeName(column: Column | undefined): string { + let typeName = column?.type?.name ?? ""; + const pgCatalog = "pg_catalog."; + if (typeName.startsWith(pgCatalog)) { + typeName = typeName.slice(pgCatalog.length); + } + if (typeName.startsWith("_")) { + typeName = typeName.slice(1); + } + return typeName; +} + +function copyfromArrayDimensions(column: Column | undefined): number { + if (!column?.isArray && !column?.arrayDims) { + return 0; + } + return Math.max(column.arrayDims || 1, 1); +} + +function copyfromScalarEncoderExpression(column: Column | undefined): string { + switch (copyfromColumnTypeName(column)) { + case "bytea": + return "sqlcCopyFromBytea"; + case "json": + case "jsonb": + return "sqlcCopyFromJson"; + default: + return "sqlcCopyFromScalar"; + } +} + +function copyfromEncoderExpression(param: Parameter): string { + const scalarEncoder = copyfromScalarEncoderExpression(param.column); + const dimensions = copyfromArrayDimensions(param.column); + if (dimensions === 0) { + return scalarEncoder; + } + return `sqlcCopyFromArrayEncoder(${scalarEncoder}, ${dimensions})`; +} + +function copyfromEncodersExpression(params: Parameter[]): string { + if (params.length === 0) { + return "[]"; + } + return `[${params.map(copyfromEncoderExpression).join(", ")}]`; +} + export class Driver { columnType(column?: Column): TypeNode { if (column === undefined || column.type === undefined) { @@ -298,25 +769,49 @@ export class Driver { ]); } - preamble(queries: unknown) { - return [ + preamble(queries: Query[]) { + const hasBatch = queries.some((query) => query.cmd.startsWith(":batch")); + const hasTransactionCompatibleQueries = queries.some((query) => !query.cmd.startsWith(":batch")); + const importSpecifiers = [ + factory.createImportSpecifier( + false, + undefined, + factory.createIdentifier("Sql") + ), + ]; + + if (hasTransactionCompatibleQueries) { + importSpecifiers.push( + factory.createImportSpecifier( + false, + undefined, + factory.createIdentifier("TransactionSql") + ) + ); + } + + const imports: Node[] = [ factory.createImportDeclaration( undefined, factory.createImportClause( - false, + true, undefined, - factory.createNamedImports([ - factory.createImportSpecifier( - false, - undefined, - factory.createIdentifier("Sql") - ), - ]) + factory.createNamedImports(importSpecifiers) ), factory.createStringLiteral("postgres"), undefined ), ]; + + if (queries.some((query) => query.cmd === ":copyfrom")) { + imports.push(...copyfromSupportDecls()); + } + + if (hasBatch) { + imports.push(...batchSupportDecls()); + } + + return imports; } execDecl( @@ -611,6 +1106,145 @@ export class Driver { ); } + batchexecDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + resultIface: string, + params: Parameter[] + ): Node[] { + return sourceStatements(` +export interface ${resultIface} { + index: number; +} + +export async function ${funcName}(${batchFuncParamsDecl(argIface)}): Promise<${resultIface}[]> { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise<${resultIface}> => { + await batchSql.unsafe(${queryName}, ${valuesExpression(params)}); + return { index }; + }); + } finally { + batchSql.release(); + } +} +`); + } + + batchmanyDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ): Node[] { + return sourceStatements(` +export interface ${resultIface} { + index: number; + rows: ${returnIface}[]; +} + +export async function ${funcName}(${batchFuncParamsDecl(argIface)}): Promise<${resultIface}[]> { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise<${resultIface}> => { + const rows = await batchSql.unsafe(${queryName}, ${valuesExpression(params)}).values(); + return { + index, + rows: rows.map(row => ${rowExpression(columns)}) + }; + }); + } finally { + batchSql.release(); + } +} +`); + } + + batchoneDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + returnIface: string, + resultIface: string, + params: Parameter[], + columns: Column[] + ): Node[] { + return sourceStatements(` +export interface ${resultIface} { + index: number; + row: ${returnIface} | null; +} + +export async function ${funcName}(${batchFuncParamsDecl(argIface)}): Promise<${resultIface}[]> { + const batchSql = await sql.reserve(); + try { + return await sqlcRunBatched(args, options, async (arg, index): Promise<${resultIface}> => { + const rows = await batchSql.unsafe(${queryName}, ${valuesExpression(params)}).values(); + if (rows.length !== 1) { + return { index, row: null }; + } + const row = rows[0]; + if (!row) { + return { index, row: null }; + } + return { + index, + row: ${rowExpression(columns)} + }; + }); + } finally { + batchSql.release(); + } +} +`); + } + + copyfromDecl( + funcName: string, + queryName: string, + argIface: string | undefined, + params: Parameter[], + table: Identifier | undefined + ): Node[] { + return sourceStatements(` +export async function ${funcName}(sql: Sql | TransactionSql, args: ${(argIface ?? "void")}[]): Promise { + const copyStream = await sql.unsafe(${JSON.stringify(copyfromSql(table, params))}).writable() as SqlcCopyFromWritable; + const copyStreamState = sqlcCopyFromCreateState(); + const copyEncoders: SqlcCopyFromEncoder[] = ${copyfromEncodersExpression(params)}; + const onCopyStreamError = (error?: unknown) => { + sqlcCopyFromRecordError(copyStreamState, error); + }; + copyStream.once?.("error", onCopyStreamError); + try { + let copyRowBatch = ""; + for (const arg of args) { + sqlcCopyFromThrowIfError(copyStreamState); + const copyRow = sqlcCopyFromRow(${valuesExpression(params)}, copyEncoders); + if (copyRowBatch.length > 0 && copyRowBatch.length + copyRow.length > sqlcCopyFromMaxChunkBytes) { + await sqlcCopyFromWrite(copyStream, copyStreamState, copyRowBatch); + copyRowBatch = ""; + } + copyRowBatch += copyRow; + } + await sqlcCopyFromWrite(copyStream, copyStreamState, copyRowBatch); + await sqlcCopyFromFlushWrites(copyStreamState); + await sqlcCopyFromEnd(copyStream); + sqlcCopyFromThrowIfError(copyStreamState); + return args.length; + } catch (error) { + copyStream.destroy?.(error); + throw error; + } finally { + copyStream.off?.("error", onCopyStreamError) ?? copyStream.removeListener?.("error", onCopyStreamError); + } +} +`); + } + execlastidDecl( funcName: string, queryName: string, diff --git a/test-support/driver-test-helpers.mjs b/test-support/driver-test-helpers.mjs new file mode 100644 index 0000000..040f4f4 --- /dev/null +++ b/test-support/driver-test-helpers.mjs @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { Script, createContext } from "node:vm"; +import ts from "typescript"; + +export { ts }; + +export const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +export const pgGeneratedFiles = [ + "examples/node-pg/src/db/query_sql.ts", + "examples/bun-pg/src/db/query_sql.ts", +].map((path) => resolve(root, path)); +export const postgresGeneratedFiles = [ + "examples/node-postgres/src/db/query_sql.ts", + "examples/bun-postgres/src/db/query_sql.ts", +].map((path) => resolve(root, path)); +export const batchUsageFile = resolve(root, "test-support/pg-batch-usage.ts"); +export const pgUnsupportedBatchUsageFile = resolve( + root, + "test-support/pg-unsupported-batch-usage.ts" +); +export const copyfromUsageFile = resolve( + root, + "test-support/postgres-copyfrom-usage.ts" +); +export const transactionUsageFile = resolve( + root, + "test-support/postgres-transaction-usage.ts" +); +export const pgUnsupportedCopyfromUsageFile = resolve( + root, + "test-support/pg-unsupported-copyfrom-usage.ts" +); +export const pgDriverTemplateFile = resolve(root, "src/drivers/pg.ts"); +export const postgresDriverTemplateFile = resolve(root, "src/drivers/postgres.ts"); +export const copyfromValidationFile = resolve(root, "src/copyfrom-validation.ts"); +export const appFile = resolve(root, "src/app.ts"); + +const tsc = resolve(root, "node_modules/.bin/tsc"); +const nodeRequire = createRequire(import.meta.url); + +export function relativePath(path) { + return relative(root, path); +} + +export function loadGeneratedModule(generatedFile, requireMap = {}, footer = "") { + const source = readFileSync(generatedFile, "utf8") + footer; + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }); + const module = { exports: {} }; + const context = createContext({ + module, + exports: module.exports, + Date, + Error, + TypeError, + require: (specifier) => { + if (Object.prototype.hasOwnProperty.call(requireMap, specifier)) { + return requireMap[specifier]; + } + if (specifier.startsWith("node:")) { + return nodeRequire(specifier); + } + throw new Error(`Unexpected require: ${specifier}`); + }, + }); + new Script(outputText, { filename: generatedFile }).runInContext(context); + return module.exports; +} + +export function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +export async function flushAsync() { + await new Promise((resolve) => setImmediate(resolve)); +} + +export function loadCopyfromTestHelpers() { + return loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } }, + ` +export const __copyfromTest = { + sqlcCopyFromValue, + sqlcCopyFromRow, + sqlcCopyFromScalar, + sqlcCopyFromBytea, + sqlcCopyFromJson, + sqlcCopyFromArrayEncoder, +}; +` + ).__copyfromTest; +} + +export function printTsNodes(nodes) { + const resultFile = ts.createSourceFile( + "file.ts", + "", + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS + ); + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + return nodes.map((node) => printer.printNode(ts.EmitHint.Unspecified, node, resultFile)).join("\n\n"); +} + +export function assertTypeChecks(label, files) { + assert.ok( + existsSync(tsc), + "Missing TypeScript compiler. Install project dependencies before running validation." + ); + + const result = spawnSync( + tsc, + [ + "--ignoreConfig", + "--strict", + "--noUncheckedIndexedAccess", + "--module", + "commonjs", + "--target", + "es2020", + "--esModuleInterop", + "--skipLibCheck", + "--noEmit", + ...files, + ], + { cwd: root, encoding: "utf8" } + ); + + assert.ifError(result.error); + assert.equal( + result.status, + 0, + [ + `${label} failed strict noUncheckedIndexedAccess type-check.`, + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join("\n") + ); +} diff --git a/test-support/pg-batch-usage.ts b/test-support/pg-batch-usage.ts new file mode 100644 index 0000000..08442db --- /dev/null +++ b/test-support/pg-batch-usage.ts @@ -0,0 +1,48 @@ +import { + SqlcBatchError, + batchCreateAuthor, + batchDeleteAuthor, + batchListAuthorsByBio, +} from "../examples/node-postgres/src/db/query_sql"; + +async function useBatchResults(client: Parameters[0]) { + const created = await batchCreateAuthor( + client, + [{ name: "Ursula K. Le Guin", bio: null }], + { batchSize: 1 } + ); + const firstCreated = created[0]; + if (firstCreated) { + const index: number = firstCreated.index; + const id: string | undefined = firstCreated.row?.id; + void [index, id]; + } + + const listed = await batchListAuthorsByBio(client, [{ bio: null }]); + const firstList = listed[0]; + if (firstList) { + const index: number = firstList.index; + const id: string | undefined = firstList.rows[0]?.id; + void [index, id]; + } + + const deleted = await batchDeleteAuthor(client, [{ id: "1" }], { + batchSize: 1, + }); + const firstDeleted = deleted[0]; + if (firstDeleted) { + const index: number = firstDeleted.index; + void index; + } +} + +function handleBatchError(error: unknown) { + if (error instanceof SqlcBatchError) { + const failedIndex: number | undefined = error.errors[0]?.index; + const cause: unknown | undefined = error.errors[0]?.error; + void [failedIndex, cause]; + } +} + +void useBatchResults; +void handleBatchError; diff --git a/test-support/pg-unsupported-batch-usage.ts b/test-support/pg-unsupported-batch-usage.ts new file mode 100644 index 0000000..c921df9 --- /dev/null +++ b/test-support/pg-unsupported-batch-usage.ts @@ -0,0 +1,21 @@ +import { + SqlcBatchUnsupportedError, + batchDeleteAuthor, +} from "../examples/node-pg/src/db/query_sql"; + +async function useUnsupportedPgBatch( + client: Parameters[0] +) { + await batchDeleteAuthor(client, [{ id: "1" }], { batchSize: 1 }); +} + +function handleUnsupportedPgBatch(error: unknown) { + if (error instanceof SqlcBatchUnsupportedError) { + const driver: "pg" = error.driver; + const command: string = error.command; + void [driver, command]; + } +} + +void useUnsupportedPgBatch; +void handleUnsupportedPgBatch; diff --git a/test-support/pg-unsupported-copyfrom-usage.ts b/test-support/pg-unsupported-copyfrom-usage.ts new file mode 100644 index 0000000..b46709e --- /dev/null +++ b/test-support/pg-unsupported-copyfrom-usage.ts @@ -0,0 +1,21 @@ +import { + SqlcCopyFromUnsupportedError, + copyAuthors, +} from "../examples/node-pg/src/db/query_sql"; + +async function useUnsupportedPgCopyfrom( + client: Parameters[0] +) { + await copyAuthors(client, [{ name: "Ann", bio: null }]); +} + +function handleUnsupportedPgCopyfrom(error: unknown) { + if (error instanceof SqlcCopyFromUnsupportedError) { + const driver: "pg" = error.driver; + const command: string = error.command; + void [driver, command]; + } +} + +void useUnsupportedPgCopyfrom; +void handleUnsupportedPgCopyfrom; diff --git a/test-support/postgres-copyfrom-usage.ts b/test-support/postgres-copyfrom-usage.ts new file mode 100644 index 0000000..1ac82d9 --- /dev/null +++ b/test-support/postgres-copyfrom-usage.ts @@ -0,0 +1,22 @@ +import type { Sql } from "postgres"; + +import { copyAuthors } from "../examples/node-postgres/src/db/query_sql"; + +async function useCopyfrom(client: Sql) { + const copiedCount: number = await copyAuthors(client, [ + { name: "Octavia E. Butler", bio: "Earthseed" }, + ]); + void copiedCount; +} + +async function useCopyfromInTransaction(client: Sql) { + await client.begin(async (tx) => { + const copiedCount: number = await copyAuthors(tx, [ + { name: "Octavia E. Butler", bio: "Earthseed" }, + ]); + void copiedCount; + }); +} + +void useCopyfrom; +void useCopyfromInTransaction; diff --git a/test-support/postgres-transaction-usage.ts b/test-support/postgres-transaction-usage.ts new file mode 100644 index 0000000..c2e18b4 --- /dev/null +++ b/test-support/postgres-transaction-usage.ts @@ -0,0 +1,17 @@ +import type { Sql } from "postgres"; + +import { createAuthor, deleteAuthor, getAuthor, listAuthors } from "../examples/node-postgres/src/db/query_sql"; + +async function useTransaction(client: Sql) { + await client.begin(async (tx) => { + const created = await createAuthor(tx, { + name: "Octavia E. Butler", + bio: "Earthseed", + }); + const authors = await listAuthors(tx); + const author = await getAuthor(tx, { id: created?.id ?? "1" }); + await deleteAuthor(tx, { id: author?.id ?? authors[0]?.id ?? "1" }); + }); +} + +void useTransaction; diff --git a/test/integration/postgres-batch-copyfrom.test.ts b/test/integration/postgres-batch-copyfrom.test.ts new file mode 100644 index 0000000..58d3c1b --- /dev/null +++ b/test/integration/postgres-batch-copyfrom.test.ts @@ -0,0 +1,153 @@ +import { expect, test } from "vitest"; + +import { + SqlcBatchError, + batchCreateAuthor, + batchDeleteAuthor, + batchListAuthorsByBio, + copyAuthors, +} from "../../examples/node-postgres/src/db/query_sql"; +import { countAuthors, describePostgresIntegration, listAuthorRows } from "./postgres-db"; + +describePostgresIntegration(":batch postgres integration", ({ sql }) => { + test("batchCreateAuthor inserts rows and preserves input order", async () => { + const results = await batchCreateAuthor( + sql, + [ + { name: "Octavia E. Butler", bio: "Earthseed" }, + { name: "Ursula K. Le Guin", bio: "Ekumen" }, + { name: "N. K. Jemisin", bio: "Stillness" }, + ], + { batchSize: 2 } + ); + + expect(results.map((result) => result.index)).toEqual([0, 1, 2]); + expect(results.map((result) => result.row && { name: result.row.name, bio: result.row.bio })).toEqual([ + { name: "Octavia E. Butler", bio: "Earthseed" }, + { name: "Ursula K. Le Guin", bio: "Ekumen" }, + { name: "N. K. Jemisin", bio: "Stillness" }, + ]); + await expect(listAuthorRows(sql)).resolves.toEqual([ + { id: "1", name: "Octavia E. Butler", bio: "Earthseed" }, + { id: "2", name: "Ursula K. Le Guin", bio: "Ekumen" }, + { id: "3", name: "N. K. Jemisin", bio: "Stillness" }, + ]); + }); + + test("batchListAuthorsByBio returns rows scoped to each input", async () => { + await batchCreateAuthor( + sql, + [ + { name: "Ann", bio: "shared" }, + { name: "Bea", bio: "solo" }, + { name: "Ada", bio: "shared" }, + ], + { batchSize: 3 } + ); + + const results = await batchListAuthorsByBio( + sql, + [{ bio: "shared" }, { bio: "missing" }, { bio: "solo" }], + { batchSize: 2 } + ); + + expect(results.map((result) => result.index)).toEqual([0, 1, 2]); + expect(results.map((result) => result.rows.map((row) => row.name))).toEqual([ + ["Ada", "Ann"], + [], + ["Bea"], + ]); + }); + + test("batchDeleteAuthor deletes rows and preserves result indexes", async () => { + const created = await batchCreateAuthor( + sql, + [ + { name: "Delete A", bio: null }, + { name: "Keep", bio: null }, + { name: "Delete B", bio: null }, + ], + { batchSize: 3 } + ); + const firstDeleteId = created[0]?.row?.id; + const secondDeleteId = created[2]?.row?.id; + expect(firstDeleteId).toBeDefined(); + expect(secondDeleteId).toBeDefined(); + + const results = await batchDeleteAuthor( + sql, + [{ id: firstDeleteId ?? "" }, { id: secondDeleteId ?? "" }], + { batchSize: 1 } + ); + + expect(results.map((result) => result.index)).toEqual([0, 1]); + await expect(listAuthorRows(sql)).resolves.toEqual([ + { id: "2", name: "Keep", bio: null }, + ]); + }); + + test("batchCreateAuthor rejects failed chunks with indexed SqlcBatchError", async () => { + let caught: unknown; + try { + await batchCreateAuthor( + sql, + [ + { name: "ok", bio: null }, + { name: null, bio: "invalid" } as never, + { name: "not-started", bio: null }, + ], + { batchSize: 2 } + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SqlcBatchError); + expect((caught as SqlcBatchError).errors).toEqual([ + expect.objectContaining({ index: 1 }), + ]); + await expect(sql.unsafe("SELECT 1 AS ok")).resolves.toMatchObject([{ ok: 1 }]); + }); + + test("batchSize must be a positive integer", async () => { + await expect( + batchCreateAuthor(sql, [{ name: "Ann", bio: null }], { batchSize: 0 }) + ).rejects.toThrow(RangeError); + }); +}); + +describePostgresIntegration(":copyfrom postgres integration", ({ sql }) => { + test("copyAuthors inserts rows and returns the copied count", async () => { + const count = await copyAuthors(sql, [ + { name: "Octavia E. Butler", bio: "Earthseed" }, + { name: "Ursula K. Le Guin", bio: "Ekumen" }, + { name: "N. K. Jemisin", bio: "Stillness" }, + ]); + + expect(count).toBe(3); + await expect(listAuthorRows(sql)).resolves.toEqual([ + { id: "1", name: "Octavia E. Butler", bio: "Earthseed" }, + { id: "2", name: "Ursula K. Le Guin", bio: "Ekumen" }, + { id: "3", name: "N. K. Jemisin", bio: "Stillness" }, + ]); + }); + + test("copyAuthors round-trips COPY text escaping", async () => { + const rows = [ + { name: "tab\tname", bio: "line\nnext" }, + { name: "carriage\rreturn", bio: "literal\\N" }, + { name: "back\\slash", bio: null }, + { name: "emoji 🦕", bio: "snowman ☃" }, + ]; + + expect(await copyAuthors(sql, rows)).toBe(rows.length); + await expect(listAuthorRows(sql)).resolves.toEqual( + rows.map((row, index) => ({ id: String(index + 1), ...row })) + ); + }); + + test("copyAuthors accepts empty input without inserting rows", async () => { + await expect(copyAuthors(sql, [])).resolves.toBe(0); + await expect(countAuthors(sql)).resolves.toBe(0); + }); +}); diff --git a/test/integration/postgres-db.ts b/test/integration/postgres-db.ts new file mode 100644 index 0000000..6c39123 --- /dev/null +++ b/test/integration/postgres-db.ts @@ -0,0 +1,49 @@ +import postgres, { type Sql } from "postgres"; +import { afterAll, beforeAll, beforeEach, describe } from "vitest"; + +export const databaseUrl = process.env["DATABASE_URL"]; + +export function describePostgresIntegration( + name: string, + fn: (context: { sql: Sql }) => void +) { + const describeFn = databaseUrl ? describe.sequential : describe.skip; + + describeFn(name, () => { + const sql = postgres(databaseUrl ?? "", { max: 8, onnotice: () => undefined }); + + beforeAll(async () => { + await sql.unsafe(` + CREATE TABLE IF NOT EXISTS authors ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL, + bio text + ) + `); + }); + + beforeEach(async () => { + await resetAuthors(sql); + }); + + afterAll(async () => { + await sql.end({ timeout: 5 }); + }); + + fn({ sql }); + }); +} + +export async function resetAuthors(sql: Sql): Promise { + await sql.unsafe("TRUNCATE authors RESTART IDENTITY"); +} + +export async function listAuthorRows(sql: Sql): Promise> { + const rows = await sql.unsafe("SELECT id::text AS id, name, bio FROM authors ORDER BY id"); + return rows.map((row) => ({ id: row.id, name: row.name, bio: row.bio })); +} + +export async function countAuthors(sql: Sql): Promise { + const rows = await sql.unsafe("SELECT count(*)::int AS count FROM authors"); + return rows[0]?.count ?? 0; +} diff --git a/test/perf/postgres-batch-copyfrom.perf.test.ts b/test/perf/postgres-batch-copyfrom.perf.test.ts new file mode 100644 index 0000000..627166e --- /dev/null +++ b/test/perf/postgres-batch-copyfrom.perf.test.ts @@ -0,0 +1,379 @@ +import { createHash } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import postgres, { type Sql } from "postgres"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { + batchCreateAuthor, + batchDeleteAuthor, + batchListAuthorsByBio, + copyAuthors, + createAuthor, + deleteAuthor, +} from "../../examples/node-postgres/src/db/query_sql"; +import { databaseUrl, resetAuthors } from "../integration/postgres-db"; + +type AuthorInput = { name: string; bio: string | null }; +type AuthorRow = { id: string; name: string; bio: string | null }; +type AuthorState = { count: number; checksum: string }; +type Measurement = { + scenario: string; + strategy: string; + items: number; + milliseconds: number; + itemsPerSecond: number; +}; + +type QueryResult = Promise & { + values?: () => Promise; +}; + +const enabled = Boolean(process.env["SQLC_PERF"] && databaseUrl); +const describePerf = enabled ? describe.sequential : describe.skip; + +describePerf("postgres :batch and :copyfrom performance", () => { + const sql = postgres(databaseUrl ?? "", { max: 16, onnotice: () => undefined }); + + beforeAll(async () => { + await sql.unsafe(` + CREATE TABLE IF NOT EXISTS authors ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL, + bio text + ) + `); + }); + + afterAll(async () => { + await sql.end({ timeout: 5 }); + }); + + test("bulk insert strategies reach the same table state", async () => { + const rows = authorRows(2_000, "bulk-insert"); + const expectedState = expectedAuthorState(rows.map((row, index) => ({ id: String(index + 1), ...row }))); + const results: Measurement[] = []; + + results.push( + await measureInsert(sql, "bulk insert 2,000 authors", "generated copyAuthors", rows, expectedState, async () => { + const copied = await copyAuthors(sql, rows); + expect(copied).toBe(rows.length); + }) + ); + + results.push( + await measureInsert(sql, "bulk insert 2,000 authors", "raw multi-row INSERT chunk=1000", rows, expectedState, async () => { + await rawInsertAuthors(sql, rows, 1_000); + }) + ); + + results.push( + await measureInsert(sql, "bulk insert 2,000 authors", "generated createAuthor sequential", rows, expectedState, async () => { + for (const row of rows) { + const inserted = await createAuthor(sql, row); + expect(inserted).not.toBeNull(); + } + }) + ); + + console.info(JSON.stringify({ benchmark: "bulk insert equivalent final state", results }, null, 2)); + }, 60_000); + + test("batchCreateAuthor helps repeated INSERT RETURNING under per-query latency", async () => { + const latencyMs = 5; + const rows = authorRows(500, "batch-create-latency"); + const delayedSql = sqlWithPerQueryLatency(sql, latencyMs); + const expectedState = expectedAuthorState(rows.map((row, index) => ({ id: String(index + 1), ...row }))); + const results: Measurement[] = []; + + results.push( + await measureInsert(sql, `500 INSERT RETURNING calls with ${latencyMs}ms per-query latency`, "generated batchCreateAuthor batchSize=64", rows, expectedState, async () => { + const inserted = await batchCreateAuthor(delayedSql, rows, { batchSize: 64 }); + expect(inserted).toHaveLength(rows.length); + }) + ); + + results.push( + await measureInsert(sql, `500 INSERT RETURNING calls with ${latencyMs}ms per-query latency`, "generated createAuthor pipelined=64", rows, expectedState, async () => { + await runGeneratedCreateAuthorPipelined(delayedSql, rows, 64); + }) + ); + + results.push( + await measureInsert(sql, `500 INSERT RETURNING calls with ${latencyMs}ms per-query latency`, "generated createAuthor sequential", rows, expectedState, async () => { + for (const row of rows) { + const inserted = await createAuthor(delayedSql, row); + expect(inserted).not.toBeNull(); + } + }) + ); + + console.info(JSON.stringify({ benchmark: "batchone repeated insert returning under latency", latencyMs, results }, null, 2)); + }, 60_000); + + test("batchDeleteAuthor helps repeated per-row commands under per-query latency", async () => { + const latencyMs = 5; + const rows = authorRows(1_000, "batch-delete-latency"); + const deleteIds = Array.from({ length: rows.length / 2 }, (_, index) => String(index * 2 + 1)); + const deleted = new Set(deleteIds); + const delayedSql = sqlWithPerQueryLatency(sql, latencyMs); + const expectedState = expectedAuthorState( + rows.flatMap((row, index) => { + const id = String(index + 1); + return deleted.has(id) ? [] : [{ id, ...row }]; + }) + ); + const results: Measurement[] = []; + + results.push( + await measureDelete(sql, `500 DELETE calls with ${latencyMs}ms per-query latency`, "generated batchDeleteAuthor batchSize=64", rows, deleteIds, expectedState, async () => { + const deletedRows = await batchDeleteAuthor(delayedSql, deleteIds.map((id) => ({ id })), { batchSize: 64 }); + expect(deletedRows).toHaveLength(deleteIds.length); + }) + ); + + results.push( + await measureDelete(sql, `500 DELETE calls with ${latencyMs}ms per-query latency`, "generated deleteAuthor pipelined=64", rows, deleteIds, expectedState, async () => { + await runGeneratedDeleteAuthorPipelined(delayedSql, deleteIds, 64); + }) + ); + + results.push( + await measureDelete(sql, `500 DELETE calls with ${latencyMs}ms per-query latency`, "generated deleteAuthor sequential", rows, deleteIds, expectedState, async () => { + for (const id of deleteIds) { + await deleteAuthor(delayedSql, { id }); + } + }) + ); + + console.info(JSON.stringify({ benchmark: "batchexec repeated command under latency", latencyMs, results }, null, 2)); + }, 60_000); + + test("batchListAuthorsByBio helps repeated keyed lookups under per-query latency", async () => { + const latencyMs = 5; + const rows = authorRows(5_000, "batch-list-latency"); + const bios = Array.from({ length: 250 }, (_, index) => `bio-${index % 10}`); + const delayedSql = sqlWithPerQueryLatency(sql, latencyMs); + await resetAuthors(sql); + await rawInsertAuthors(sql, rows, 1_000); + + const expectedRows = (await Promise.all(bios.map((bio) => listAuthorsByBioOnce(sql, bio)))).flat(); + const expectedChecksum = authorRowsChecksum(expectedRows); + const results: Measurement[] = []; + + results.push( + await measureSelect(`250 keyed SELECT calls with ${latencyMs}ms per-query latency`, "generated batchListAuthorsByBio batchSize=64", bios.length, expectedRows.length, expectedChecksum, async () => { + const result = await batchListAuthorsByBio(delayedSql, bios.map((bio) => ({ bio })), { batchSize: 64 }); + return result.flatMap((item) => item.rows); + }) + ); + + results.push( + await measureSelect(`250 keyed SELECT calls with ${latencyMs}ms per-query latency`, "same SELECT pipelined=64 without batch annotation", bios.length, expectedRows.length, expectedChecksum, async () => { + return runListAuthorsByBioPipelined(delayedSql, bios, 64); + }) + ); + + results.push( + await measureSelect(`250 keyed SELECT calls with ${latencyMs}ms per-query latency`, "same SELECT sequential without batch annotation", bios.length, expectedRows.length, expectedChecksum, async () => { + const batches = []; + for (const bio of bios) { + batches.push(await listAuthorsByBioOnce(delayedSql, bio)); + } + return batches.flat(); + }) + ); + + console.info(JSON.stringify({ benchmark: "batchmany repeated keyed lookup under latency", latencyMs, results }, null, 2)); + }, 60_000); +}); + +async function measureInsert( + sql: Sql, + scenario: string, + strategy: string, + rows: AuthorInput[], + expectedState: AuthorState, + run: () => Promise +): Promise { + await resetAuthors(sql); + const measurement = await measureOperation(scenario, strategy, rows.length, run); + await expect(authorState(sql)).resolves.toEqual(expectedState); + return measurement; +} + +async function measureDelete( + sql: Sql, + scenario: string, + strategy: string, + seedRows: AuthorInput[], + deleteIds: string[], + expectedState: AuthorState, + run: () => Promise +): Promise { + await resetAuthors(sql); + await rawInsertAuthors(sql, seedRows, 1_000); + const measurement = await measureOperation(scenario, strategy, deleteIds.length, run); + await expect(authorState(sql)).resolves.toEqual(expectedState); + return measurement; +} + +async function measureSelect( + scenario: string, + strategy: string, + inputCount: number, + expectedRows: number, + expectedChecksum: string, + run: () => Promise +): Promise { + const started = performance.now(); + const rows = await run(); + const milliseconds = performance.now() - started; + expect(rows).toHaveLength(expectedRows); + expect(authorRowsChecksum(rows)).toBe(expectedChecksum); + return measurement(scenario, strategy, inputCount, milliseconds); +} + +async function measureOperation( + scenario: string, + strategy: string, + items: number, + run: () => Promise +): Promise { + const started = performance.now(); + await run(); + return measurement(scenario, strategy, items, performance.now() - started); +} + +function measurement(scenario: string, strategy: string, items: number, milliseconds: number): Measurement { + return { + scenario, + strategy, + items, + milliseconds: Number(milliseconds.toFixed(2)), + itemsPerSecond: Number(((items / milliseconds) * 1000).toFixed(2)), + }; +} + +function authorRows(count: number, prefix: string): AuthorInput[] { + return Array.from({ length: count }, (_, index) => ({ + name: `${prefix}-${index}`, + bio: index % 3 === 0 ? null : `bio-${index % 10}`, + })); +} + +async function rawInsertAuthors(sql: Sql, rows: AuthorInput[], chunkSize: number): Promise { + for (let start = 0; start < rows.length; start += chunkSize) { + const chunk = rows.slice(start, start + chunkSize); + const values: Array = []; + const placeholders = chunk.map((row, index) => { + values.push(row.name, row.bio); + const parameter = index * 2 + 1; + return `($${parameter}, $${parameter + 1})`; + }); + await sql.unsafe(`INSERT INTO authors (name, bio) VALUES ${placeholders.join(", ")}`, values); + } +} + +async function listAuthorsByBioOnce(sql: Sql, bio: string | null): Promise { + const rows = await sql.unsafe( + "SELECT id, name, bio FROM authors WHERE bio = $1 ORDER BY name", + [bio] + ).values(); + return rows.map((row) => ({ id: row[0] as string, name: row[1] as string, bio: row[2] as string | null })); +} + +async function runGeneratedCreateAuthorPipelined(sql: Sql, rows: AuthorInput[], pipelineSize: number): Promise { + const reserved = await sql.reserve(); + try { + await runInChunks(rows, pipelineSize, async (row) => { + const inserted = await createAuthor(reserved, row); + expect(inserted).not.toBeNull(); + }); + } finally { + reserved.release(); + } +} + +async function runGeneratedDeleteAuthorPipelined(sql: Sql, ids: string[], pipelineSize: number): Promise { + const reserved = await sql.reserve(); + try { + await runInChunks(ids, pipelineSize, async (id) => { + await deleteAuthor(reserved, { id }); + }); + } finally { + reserved.release(); + } +} + +async function runListAuthorsByBioPipelined(sql: Sql, bios: string[], pipelineSize: number): Promise { + const reserved = await sql.reserve(); + try { + const batches: AuthorRow[][] = []; + for (let start = 0; start < bios.length; start += pipelineSize) { + const chunk = bios.slice(start, start + pipelineSize); + batches.push(...await Promise.all(chunk.map((bio) => listAuthorsByBioOnce(reserved, bio)))); + } + return batches.flat(); + } finally { + reserved.release(); + } +} + +async function runInChunks(items: T[], chunkSize: number, run: (item: T) => Promise): Promise { + for (let start = 0; start < items.length; start += chunkSize) { + const chunk = items.slice(start, start + chunkSize); + await Promise.all(chunk.map((item) => run(item))); + } +} + +function sqlWithPerQueryLatency(sql: T, milliseconds: number): T { + return new Proxy(sql as unknown as Record, { + get(target, property, receiver) { + if (property === "unsafe") { + return (...args: unknown[]) => withPerQueryLatency((target.unsafe as (...args: unknown[]) => QueryResult)(...args), milliseconds); + } + if (property === "reserve") { + return async (...args: unknown[]) => { + const reserved = await (target.reserve as (...args: unknown[]) => Promise)(...args); + return sqlWithPerQueryLatency(reserved, milliseconds); + }; + } + return Reflect.get(target, property, receiver); + }, + }) as unknown as T; +} + +function withPerQueryLatency(result: QueryResult, milliseconds: number): QueryResult { + const delayed = sleep(milliseconds).then(() => result) as QueryResult; + if (typeof result.values === "function") { + delayed.values = () => sleep(milliseconds).then(() => result.values?.() ?? []); + } + return delayed; +} + +async function authorState(sql: Sql): Promise { + const rows = await sql.unsafe(` + SELECT + count(*)::int AS count, + md5(coalesce(string_agg(id::text || ':' || name || ':' || coalesce(bio, ''), ',' ORDER BY id), '')) AS checksum + FROM authors + `); + const row = rows[0]; + return { count: row.count, checksum: row.checksum }; +} + +function expectedAuthorState(rows: AuthorRow[]): AuthorState { + return { count: rows.length, checksum: authorRowsChecksum(rows) }; +} + +function authorRowsChecksum(rows: AuthorRow[]): string { + const input = [...rows] + .sort((left, right) => Number(left.id) - Number(right.id) || left.name.localeCompare(right.name)) + .map((row) => `${row.id}:${row.name}:${row.bio ?? ""}`) + .join(","); + return createHash("md5").update(input).digest("hex"); +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/test/pg-batch.test.mjs b/test/pg-batch.test.mjs new file mode 100644 index 0000000..a344d12 --- /dev/null +++ b/test/pg-batch.test.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { test } from "vitest"; + +import { + assertTypeChecks, + loadGeneratedModule, + pgDriverTemplateFile, + pgGeneratedFiles, + pgUnsupportedBatchUsageFile, + relativePath, +} from "../test-support/driver-test-helpers.mjs"; + +test("generated pg batch queries fail with an unsupported-driver error", async () => { + for (const generatedFile of pgGeneratedFiles) { + assert.ok( + existsSync(generatedFile), + `Missing generated pg output: ${generatedFile}` + ); + + const source = readFileSync(generatedFile, "utf8"); + + assert.match( + source, + /class SqlcBatchUnsupportedError extends Error/, + `${relativePath(generatedFile)} must expose an explicit unsupported batch error.` + ); + assert.match( + source, + /use the postgres driver for batch annotations/, + `${relativePath(generatedFile)} must point users to the supported driver.` + ); + assert.equal( + [...source.matchAll(/throw new SqlcBatchUnsupportedError\(":batch(?:one|many|exec)"\);/g)].length, + 3, + `${relativePath(generatedFile)} must reject every generated pg batch function.` + ); + assert.match( + source, + /interface SqlcBatchOptions \{\n\s+batchSize\?: number;\n\}/, + `${relativePath(generatedFile)} must keep batch options source-compatible for unsupported pg stubs.` + ); + assert.equal( + [...source.matchAll(/_options\?: SqlcBatchOptions/g)].length, + 3, + `${relativePath(generatedFile)} must accept old pg batch options without using them.` + ); + assert.doesNotMatch( + source, + /Promise\.allSettled/, + `${relativePath(generatedFile)} must not emulate batching with queued node-postgres queries.` + ); + assert.doesNotMatch( + source, + /client\.query\(\{\n\s+text: batch/, + `${relativePath(generatedFile)} must not issue per-item node-postgres queries for batch annotations.` + ); + } + + const { SqlcBatchUnsupportedError, batchDeleteAuthor } = loadGeneratedModule( + pgGeneratedFiles[0] + ); + const calls = []; + const client = { + query(config) { + calls.push(config); + return Promise.resolve({ rows: [] }); + }, + }; + + await assert.rejects( + batchDeleteAuthor(client, [{ id: "1" }], { batchSize: 1 }), + (error) => { + assert.ok(error instanceof SqlcBatchUnsupportedError); + assert.equal(error.driver, "pg"); + assert.equal(error.command, ":batchexec"); + assert.equal(calls.length, 0, "pg must not send queries for unsupported batch annotations."); + return true; + } + ); +}); + +test("pg batch generator template rejects unsupported batch annotations", () => { + const source = readFileSync(pgDriverTemplateFile, "utf8"); + assert.match( + source, + /function batchUnsupportedDecls\(\): Node\[\]/, + "pg driver must generate explicit unsupported batch support." + ); + assert.match( + source, + /SqlcBatchUnsupportedError/, + "pg driver must expose a generated unsupported-driver error." + ); + assert.match( + source, + /use the postgres driver for batch annotations/, + "pg driver must direct batch users to the postgres driver." + ); + assert.equal( + [...source.matchAll(/throw new SqlcBatchUnsupportedError\(":batch(?:one|many|exec)"\);/g)].length, + 3, + "pg driver must reject all batch annotation variants." + ); + assert.doesNotMatch( + source, + /Promise\.allSettled\(/, + "pg driver must not emulate batching with node-postgres query queues." + ); + assert.doesNotMatch( + source, + /client\.query\(\{\n\s+text: \$\{queryName\}/, + "pg driver must not generate per-item node-postgres batch queries." + ); +}); + +test("generated pg batch output type-checks with strict noUncheckedIndexedAccess", () => { + assertTypeChecks("Generated pg batch output", [ + ...pgGeneratedFiles, + pgUnsupportedBatchUsageFile, + ]); +}); diff --git a/test/pg-copyfrom.test.mjs b/test/pg-copyfrom.test.mjs new file mode 100644 index 0000000..797341a --- /dev/null +++ b/test/pg-copyfrom.test.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { test } from "vitest"; + +import { + assertTypeChecks, + loadGeneratedModule, + pgDriverTemplateFile, + pgGeneratedFiles, + pgUnsupportedCopyfromUsageFile, + relativePath, +} from "../test-support/driver-test-helpers.mjs"; + +test("generated pg copyfrom queries fail with an unsupported-driver error", async () => { + for (const generatedFile of pgGeneratedFiles) { + assert.ok( + existsSync(generatedFile), + `Missing generated pg output: ${generatedFile}` + ); + + const source = readFileSync(generatedFile, "utf8"); + + assert.match( + source, + /class SqlcCopyFromUnsupportedError extends Error/, + `${relativePath(generatedFile)} must expose an explicit unsupported copyfrom error.` + ); + assert.match( + source, + /use the postgres driver for :copyfrom annotations/, + `${relativePath(generatedFile)} must point copyfrom users to the supported driver.` + ); + assert.match( + source, + /throw new SqlcCopyFromUnsupportedError\(":copyfrom"\);/, + `${relativePath(generatedFile)} must reject generated pg copyfrom functions.` + ); + } + + const { SqlcCopyFromUnsupportedError, copyAuthors } = loadGeneratedModule( + pgGeneratedFiles[0] + ); + const calls = []; + const client = { + query(config) { + calls.push(config); + return Promise.resolve({ rows: [] }); + }, + }; + + await assert.rejects( + copyAuthors(client, [{ name: "Ann", bio: null }]), + (error) => { + assert.ok(error instanceof SqlcCopyFromUnsupportedError); + assert.equal(error.driver, "pg"); + assert.equal(error.command, ":copyfrom"); + assert.equal(calls.length, 0, "pg must not send queries for unsupported copyfrom annotations."); + return true; + } + ); +}); + +test("pg copyfrom generator template rejects unsupported copyfrom annotations", () => { + const source = readFileSync(pgDriverTemplateFile, "utf8"); + assert.match( + source, + /function copyfromUnsupportedDecls\(\): Node\[\]/, + "pg driver must generate explicit unsupported copyfrom support." + ); + assert.match( + source, + /SqlcCopyFromUnsupportedError/, + "pg driver must expose a generated unsupported-driver copyfrom error." + ); + assert.match( + source, + /use the postgres driver for :copyfrom annotations/, + "pg driver must direct copyfrom users to the postgres driver." + ); + assert.match( + source, + /throw new SqlcCopyFromUnsupportedError\(":copyfrom"\);/, + "pg driver must reject copyfrom annotations." + ); +}); + +test("generated pg copyfrom output type-checks with strict noUncheckedIndexedAccess", () => { + assertTypeChecks("Generated pg copyfrom output", [ + ...pgGeneratedFiles, + pgUnsupportedCopyfromUsageFile, + ]); +}); diff --git a/test/pg-one.test.mjs b/test/pg-one.test.mjs index ef41f62..78a0f84 100644 --- a/test/pg-one.test.mjs +++ b/test/pg-one.test.mjs @@ -1,27 +1,19 @@ #!/usr/bin/env node import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; -import test from "node:test"; -import { dirname, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { test } from "vitest"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const generatedFiles = [ - "examples/node-pg/src/db/query_sql.ts", - "examples/bun-pg/src/db/query_sql.ts", -].map((path) => resolve(root, path)); -const tsc = resolve(root, "node_modules/.bin/tsc"); - -function relativePath(path) { - return relative(root, path); -} +import { + assertTypeChecks, + pgGeneratedFiles, + relativePath, +} from "../test-support/driver-test-helpers.mjs"; test("generated pg :one queries guard row access", () => { let totalOneQueryCount = 0; - for (const generatedFile of generatedFiles) { + for (const generatedFile of pgGeneratedFiles) { assert.ok( existsSync(generatedFile), `Missing generated pg output: ${generatedFile}` @@ -61,39 +53,5 @@ test("generated pg :one queries guard row access", () => { }); test("generated pg output type-checks with strict noUncheckedIndexedAccess", () => { - assert.ok( - existsSync(tsc), - "Missing TypeScript compiler. Install project dependencies before running validation." - ); - - const result = spawnSync( - tsc, - [ - "--ignoreConfig", - "--strict", - "--noUncheckedIndexedAccess", - "--module", - "commonjs", - "--target", - "es2020", - "--esModuleInterop", - "--skipLibCheck", - "--noEmit", - ...generatedFiles, - ], - { cwd: root, encoding: "utf8" } - ); - - assert.ifError(result.error); - assert.equal( - result.status, - 0, - [ - "Generated pg output failed strict noUncheckedIndexedAccess type-check.", - result.stdout, - result.stderr, - ] - .filter(Boolean) - .join("\n") - ); + assertTypeChecks("Generated pg output", pgGeneratedFiles); }); diff --git a/test/postgres-batch.test.mjs b/test/postgres-batch.test.mjs new file mode 100644 index 0000000..f10442e --- /dev/null +++ b/test/postgres-batch.test.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { test } from "vitest"; + +import { + assertTypeChecks, + batchUsageFile, + deferred, + flushAsync, + loadGeneratedModule, + postgresDriverTemplateFile, + postgresGeneratedFiles, + relativePath, +} from "../test-support/driver-test-helpers.mjs"; + +test("generated postgres batch queries use bounded helpers", () => { + for (const generatedFile of postgresGeneratedFiles) { + assert.ok( + existsSync(generatedFile), + `Missing generated postgres output: ${generatedFile}` + ); + + const source = readFileSync(generatedFile, "utf8"); + + assert.match( + source, + /-- name: BatchCreateAuthor :batchone\b/, + `${relativePath(generatedFile)} must include a generated :batchone example.` + ); + assert.match( + source, + /-- name: BatchListAuthorsByBio :batchmany\b/, + `${relativePath(generatedFile)} must include a generated :batchmany example.` + ); + assert.match( + source, + /-- name: BatchDeleteAuthor :batchexec\b/, + `${relativePath(generatedFile)} must include a generated :batchexec example.` + ); + assert.match( + source, + /interface SqlcBatchOptions \{\n\s+batchSize\?: number;\n\}/, + `${relativePath(generatedFile)} must expose bounded batch options.` + ); + assert.match( + source, + /class SqlcBatchError extends Error/, + `${relativePath(generatedFile)} must expose a rejecting batch error type.` + ); + assert.match( + source, + /Promise\.allSettled\(chunk\.map/, + `${relativePath(generatedFile)} must settle bounded chunks.` + ); + assert.match( + source, + /interface BatchCreateAuthorBatchResult \{\n\s+index: number;\n\s+row: BatchCreateAuthorRow \| null;\n\}/, + `${relativePath(generatedFile)} must expose an ordered :batchone result.` + ); + assert.match( + source, + /interface BatchListAuthorsByBioBatchResult \{\n\s+index: number;\n\s+rows: BatchListAuthorsByBioRow\[\];\n\}/, + `${relativePath(generatedFile)} must expose an ordered :batchmany result.` + ); + assert.match( + source, + /interface BatchDeleteAuthorBatchResult \{\n\s+index: number;\n\}/, + `${relativePath(generatedFile)} must expose an ordered :batchexec result.` + ); + assert.equal( + [...source.matchAll(/options\?: SqlcBatchOptions/g)].length, + 3, + `${relativePath(generatedFile)} must accept bounded batch options for every batch function.` + ); + assert.equal( + [...source.matchAll(/const batchSql = await sql\.reserve\(\);/g)].length, + 3, + `${relativePath(generatedFile)} must reserve one postgres.js connection per batch.` + ); + assert.equal( + [...source.matchAll(/batchSql\.release\(\);/g)].length, + 3, + `${relativePath(generatedFile)} must release each reserved postgres.js connection.` + ); + assert.doesNotMatch( + source, + /return Promise\.all\(promises\);/, + `${relativePath(generatedFile)} must not use unbounded Promise.all.` + ); + assert.doesNotMatch( + source, + /const promises = args\.map/, + `${relativePath(generatedFile)} must not map the whole input to promises.` + ); + assert.doesNotMatch( + source, + /error: unknown \| null/, + `${relativePath(generatedFile)} must reject batch failures instead of hiding per-item errors.` + ); + } +}); + +test("postgres batch generator template uses bounded helpers", () => { + const source = readFileSync(postgresDriverTemplateFile, "utf8"); + assert.match( + source, + /function batchSupportDecls\(\): Node\[\]/, + "postgres driver must generate shared batch support." + ); + assert.match( + source, + /Promise\.allSettled\(/, + "postgres driver must generate bounded chunk settling." + ); + assert.match( + source, + /const batchSql = await sql\.reserve\(\);/, + "postgres driver must reserve one connection for pipelined batch work." + ); + assert.doesNotMatch( + source, + /return Promise\.all\(promises\);/, + "postgres driver must not generate unbounded Promise.all." + ); + assert.doesNotMatch( + source, + /const promises = args\.map/, + "postgres driver must not generate whole-input promise maps." + ); + assert.doesNotMatch( + source, + /error: unknown \| null/, + "postgres driver must generate rejecting batch errors instead of per-item error results." + ); + assert.match( + source, + /return `\(\{\n\s+\$\{properties\}\n\s+\}\)`;/, + "postgres driver must generate parenthesized batch row object literals." + ); +}); + +test("generated postgres batch functions reserve one connection and reject failures", async () => { + const { SqlcBatchError, batchListAuthorsByBio } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + const pending = [deferred(), deferred(), deferred()]; + const calls = []; + let reserveCount = 0; + let releaseCount = 0; + const reservedSql = { + unsafe(query, values) { + const request = pending[calls.length]; + calls.push({ query, values }); + return { values: () => request.promise }; + }, + release() { + releaseCount++; + }, + }; + const sql = { + reserve() { + reserveCount++; + return Promise.resolve(reservedSql); + }, + }; + + const resultPromise = batchListAuthorsByBio( + sql, + [{ bio: "a" }, { bio: "b" }, { bio: "c" }], + { batchSize: 2 } + ); + await flushAsync(); + + assert.equal(reserveCount, 1); + assert.equal(calls.length, 2, "postgres must only queue the first bounded chunk."); + assert.deepEqual( + calls.map((call) => Array.from(call.values)), + [["a"], ["b"]] + ); + + pending[1].resolve([["2", "Bea", "b"]]); + pending[0].resolve([["1", "Ann", "a"]]); + await flushAsync(); + + assert.equal(calls.length, 3, "postgres must queue the next chunk after settling."); + assert.deepEqual(Array.from(calls[2].values), ["c"]); + pending[2].resolve([["3", "Ada", "c"]]); + + const results = await resultPromise; + assert.equal(releaseCount, 1); + assert.deepEqual( + Array.from(results, (result) => result.index), + [0, 1, 2] + ); + assert.deepEqual(JSON.parse(JSON.stringify(results[0].rows)), [ + { id: "1", name: "Ann", bio: "a" }, + ]); + assert.deepEqual(JSON.parse(JSON.stringify(results[2].rows)), [ + { id: "3", name: "Ada", bio: "c" }, + ]); + + const failed = [deferred(), deferred(), deferred()]; + const failedCalls = []; + let failedReleaseCount = 0; + const failedSql = { + reserve() { + return Promise.resolve({ + unsafe(query, values) { + const request = failed[failedCalls.length]; + failedCalls.push({ query, values }); + return { values: () => request.promise }; + }, + release() { + failedReleaseCount++; + }, + }); + }, + }; + const failure = new Error("list failed"); + const failedPromise = batchListAuthorsByBio( + failedSql, + [{ bio: "a" }, { bio: "b" }, { bio: "c" }], + { batchSize: 2 } + ); + await flushAsync(); + assert.equal(failedCalls.length, 2); + failed[0].resolve([["1", "Ann", "a"]]); + failed[1].reject(failure); + + await assert.rejects(failedPromise, (error) => { + assert.ok(error instanceof SqlcBatchError); + assert.equal(error.errors.length, 1); + assert.equal(error.errors[0].index, 1); + assert.equal(error.errors[0].error, failure); + assert.equal(failedCalls.length, 2, "postgres must not start a later chunk after failure."); + assert.equal(failedReleaseCount, 1, "postgres must release on failure."); + return true; + }); +}); + +test("generated postgres batch output type-checks with strict noUncheckedIndexedAccess", () => { + assertTypeChecks("Generated postgres batch output", [ + ...postgresGeneratedFiles, + batchUsageFile, + ]); +}); diff --git a/test/postgres-copyfrom.test.mjs b/test/postgres-copyfrom.test.mjs new file mode 100644 index 0000000..63c2760 --- /dev/null +++ b/test/postgres-copyfrom.test.mjs @@ -0,0 +1,685 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { existsSync, readFileSync } from "node:fs"; +import { test } from "vitest"; + +import { + appFile, + assertTypeChecks, + copyfromUsageFile, + copyfromValidationFile, + flushAsync, + loadCopyfromTestHelpers, + loadGeneratedModule, + postgresDriverTemplateFile, + postgresGeneratedFiles, + printTsNodes, + relativePath, + ts, +} from "../test-support/driver-test-helpers.mjs"; + +test("generated postgres copyfrom queries use COPY stream helpers", () => { + for (const generatedFile of postgresGeneratedFiles) { + assert.ok( + existsSync(generatedFile), + `Missing generated postgres output: ${generatedFile}` + ); + + const source = readFileSync(generatedFile, "utf8"); + + assert.match( + source, + /-- name: CopyAuthors :copyfrom\b/, + `${relativePath(generatedFile)} must include a generated :copyfrom example.` + ); + assert.match( + source, + /export async function copyAuthors\(sql: Sql \| TransactionSql, args: CopyAuthorsArgs\[\]\)/, + `${relativePath(generatedFile)} must accept transaction-capable COPY clients.` + ); + assert.match( + source, + /interface SqlcCopyFromWritable/, + `${relativePath(generatedFile)} must emit a minimal COPY writable stream interface.` + ); + assert.match( + source, + /COPY \\\"authors\\\" \(\\\"name\\\", \\\"bio\\\"\) FROM STDIN/, + `${relativePath(generatedFile)} must generate a COPY FROM STDIN statement.` + ); + assert.match( + source, + /sqlcCopyFromValue/, + `${relativePath(generatedFile)} must emit COPY text escaping helpers.` + ); + assert.match( + source, + /sqlcCopyFromBytea/, + `${relativePath(generatedFile)} must emit bytea COPY encoding helpers.` + ); + assert.match( + source, + /sqlcCopyFromJson/, + `${relativePath(generatedFile)} must emit json COPY encoding helpers.` + ); + assert.match( + source, + /sqlcCopyFromArrayEncoder/, + `${relativePath(generatedFile)} must emit PostgreSQL array COPY encoding helpers.` + ); + assert.match( + source, + /sqlcCopyFromUnsupportedValue/, + `${relativePath(generatedFile)} must reject unsupported COPY value types instead of stringifying them.` + ); + assert.match( + source, + /sqlcCopyFromDrain/, + `${relativePath(generatedFile)} must wait for COPY write backpressure drain events.` + ); + assert.match( + source, + /stream\.once\?\.\("error"/, + `${relativePath(generatedFile)} must listen for COPY stream finalization errors.` + ); + assert.match( + source, + /\.writable\(\)/, + `${relativePath(generatedFile)} must use the postgres.js COPY writable stream.` + ); + } +}); + +test("generated postgres copyfrom output type-checks with strict noUncheckedIndexedAccess", () => { + assertTypeChecks("Generated postgres copyfrom output", [ + ...postgresGeneratedFiles, + copyfromUsageFile, + ]); +}); + +test("postgres copyfrom generator template uses COPY streams", () => { + const source = readFileSync(postgresDriverTemplateFile, "utf8"); + assert.match( + source, + /function copyfromSupportDecls\(\): Node\[\]/, + "postgres driver must generate shared copyfrom support." + ); + assert.match( + source, + /interface SqlcCopyFromWritable/, + "postgres driver must generate a minimal COPY writable interface." + ); + assert.match( + source, + /\.writable\(\)/, + "postgres driver must generate postgres.js COPY writable usage." + ); + assert.match( + source, + /export async function \$\{funcName\}\(sql: Sql \| TransactionSql/, + "postgres driver must type COPY clients as Sql or TransactionSql." + ); + assert.match( + source, + /COPY \$\{copyfromTableName\(table\)\}/, + "postgres driver must build COPY SQL from sqlc insert metadata." + ); + assert.match( + source, + /sqlcCopyFromBytea/, + "postgres driver must encode generated bytea Buffer/Uint8Array values." + ); + assert.match( + source, + /sqlcCopyFromJson/, + "postgres driver must encode generated json/jsonb values." + ); + assert.match( + source, + /sqlcCopyFromArrayEncoder/, + "postgres driver must encode generated PostgreSQL array values." + ); + assert.match( + source, + /sqlcCopyFromUnsupportedValue/, + "postgres driver must reject unsupported COPY value types instead of stringifying them." + ); + assert.match( + source, + /sqlcCopyFromDrain/, + "postgres driver must wait for COPY write backpressure drain events." + ); + assert.match( + source, + /copyRowBatch/, + "postgres driver must batch COPY rows before writing to the stream." + ); + assert.match( + source, + /stream\.once\?\.\("error"/, + "postgres driver must listen for COPY stream finalization errors." + ); +}); + +function copyfromQuery(text, params = 2) { + return { + text, + name: "CopyAuthors", + cmd: ":copyfrom", + columns: [], + params: Array.from({ length: params }, (_, index) => ({ number: index + 1 })), + comments: [], + filename: "query.sql", + insertIntoTable: { name: "authors", schema: "" }, + }; +} + +test("copyfrom validation accepts only simple insert value lists", () => { + const { validateCopyfromQuery } = loadGeneratedModule(copyfromValidationFile); + + assert.doesNotThrow(() => + validateCopyfromQuery(copyfromQuery(` + INSERT INTO authors ( + name, bio + ) VALUES ( + $1, $2 + ); + `)) + ); + assert.doesNotThrow(() => + validateCopyfromQuery(copyfromQuery(`INSERT INTO "authors" ("na)me", bio) VALUES ($1, $2);`)) + ); + + for (const sql of [ + `WITH rows AS (SELECT $1) INSERT INTO authors (name, bio) VALUES ($1, $2)`, + `INSERT INTO authors (name, bio) VALUES ($1, DEFAULT)`, + `INSERT INTO authors (name, bio) VALUES ($1, lower($2))`, + `INSERT INTO authors (name, bio) VALUES ($1, 'constant')`, + `INSERT INTO authors (name, bio) VALUES ($1, $2) RETURNING *`, + `INSERT INTO authors (name, bio) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + `INSERT INTO authors (name, bio) VALUES ($1, $2), ($3, $4)`, + ]) { + assert.throws( + () => validateCopyfromQuery(copyfromQuery(sql)), + /:copyfrom query CopyAuthors must be a simple INSERT INTO table \(columns\.\.\.\) VALUES \(\$1, \.\.\.\) statement/ + ); + } + + assert.throws( + () => + validateCopyfromQuery({ + ...copyfromQuery(`INSERT INTO books (name, bio) VALUES ($1, $2)`), + insertIntoTable: { name: "authors", schema: "" }, + }), + /INSERT table must match sqlc table metadata/ + ); +}); + +test("app validates copyfrom query shapes before generating COPY", () => { + const source = readFileSync(appFile, "utf8"); + assert.match(source, /import \{ validateCopyfromQuery \} from "\.\/copyfrom-validation";/); + assert.match(source, /case ":copyfrom": \{\n\s+validateCopyfromQuery\(query\);/); +}); + +test("postgres copyfrom codegen selects encoders from column metadata", () => { + const { Driver } = loadGeneratedModule(postgresDriverTemplateFile, { + typescript: ts, + "../gen/plugin/codegen_pb": {}, + "./utlis": { + argName(index, column) { + return column?.name ?? `arg${index + 1}`; + }, + colName(index, column) { + return column?.name ?? `col${index + 1}`; + }, + }, + "../logger": { log() {} }, + }); + const params = [ + { number: 1, column: { name: "payload", originalName: "payload", type: { name: "bytea" } } }, + { number: 2, column: { name: "doc", originalName: "doc", type: { name: "json" } } }, + { number: 3, column: { name: "docb", originalName: "docb", type: { name: "jsonb" } } }, + { number: 4, column: { name: "tags", originalName: "tags", type: { name: "text" }, isArray: true } }, + ]; + const source = printTsNodes( + new Driver().copyfromDecl( + "copyMixed", + "copyMixedQuery", + "CopyMixedArgs", + params, + { name: "mixed", schema: "" } + ) + ); + + assert.match( + source, + /const copyEncoders: SqlcCopyFromEncoder\[\] = \[sqlcCopyFromBytea, sqlcCopyFromJson, sqlcCopyFromJson, sqlcCopyFromArrayEncoder\(sqlcCopyFromScalar, 1\)\];/ + ); + assert.match( + source, + /sqlcCopyFromRow\(\[arg\.payload, arg\.doc, arg\.docb, arg\.tags\], copyEncoders\)/ + ); +}); + +test("generated postgres copyfrom function streams escaped COPY text", async () => { + const { copyAuthors } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + const chunks = []; + let copyQuery; + let ended = false; + const writable = { + write(chunk, callback) { + chunks.push(String(chunk)); + callback(); + }, + end(callback) { + ended = true; + callback(); + }, + }; + const sql = { + unsafe(query) { + copyQuery = query; + return { + writable() { + return Promise.resolve(writable); + }, + }; + }, + }; + + const date = new Date("2024-01-02T03:04:05.006Z"); + const count = await copyAuthors(sql, [ + { name: "Ann\tA", bio: null }, + { name: "Back\\slash", bio: "line\nnext" }, + { name: "carriage\rreturn", bio: "literal\\N" }, + { name: "Dated", bio: date }, + ]); + + assert.equal(count, 4); + assert.equal(ended, true); + assert.equal(copyQuery, 'COPY "authors" ("name", "bio") FROM STDIN'); + assert.equal( + chunks.join(""), + "Ann\\tA\t\\N\nBack\\\\slash\tline\\nnext\ncarriage\\rreturn\tliteral\\\\N\nDated\t2024-01-02T03:04:05.006Z\n" + ); + assert.equal(chunks.length, 1, "small COPY inputs should be batched into one stream write."); +}); + +test("postgres copyfrom helpers encode bytea json and arrays", () => { + const helpers = loadCopyfromTestHelpers(); + + assert.equal( + helpers.sqlcCopyFromValue(Buffer.from([0, 15, 255]), helpers.sqlcCopyFromBytea), + "\\\\x000fff" + ); + assert.equal( + helpers.sqlcCopyFromValue(new Uint8Array([1, 2, 16]), helpers.sqlcCopyFromBytea), + "\\\\x010210" + ); + assert.equal( + helpers.sqlcCopyFromValue({ a: 1, b: ["x", true] }, helpers.sqlcCopyFromJson), + '{"a":1,"b":["x",true]}' + ); + assert.equal( + helpers.sqlcCopyFromValue([1, "two"], helpers.sqlcCopyFromJson), + '[1,"two"]' + ); + + const textArrayEncoder = helpers.sqlcCopyFromArrayEncoder(helpers.sqlcCopyFromScalar, 1); + assert.equal( + helpers.sqlcCopyFromValue(["a", "b,c", 'quote"', "back\\slash", null], textArrayEncoder), + '{"a","b,c","quote\\\\"","back\\\\\\\\slash",NULL}' + ); + + const numberMatrixEncoder = helpers.sqlcCopyFromArrayEncoder(helpers.sqlcCopyFromScalar, 2); + assert.equal( + helpers.sqlcCopyFromValue([[1, 2], [3, null]], numberMatrixEncoder), + '{{"1","2"},{"3",NULL}}' + ); +}); + +class FailingCopyWritable { + constructor(error) { + this.error = error; + this.writableFinished = false; + this.chunks = []; + this.listeners = new Map(); + this.destroyedWith = undefined; + } + + write(chunk, callback) { + this.chunks.push(String(chunk)); + callback(); + } + + end(callback) { + callback(); + queueMicrotask(() => this.listeners.get("error")?.(this.error)); + } + + once(event, listener) { + this.listeners.set(event, listener); + return this; + } + + off(event, listener) { + if (this.listeners.get(event) === listener) { + this.listeners.delete(event); + } + return this; + } + + removeListener(event, listener) { + return this.off(event, listener); + } + + destroy(error) { + this.destroyedWith = error; + } +} + +test("generated postgres copyfrom function rejects final COPY stream failures", async () => { + const { copyAuthors } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + const failure = new Error("COPY finalization failed"); + const writable = new FailingCopyWritable(failure); + const sql = { + unsafe() { + return { + writable() { + return Promise.resolve(writable); + }, + }; + }, + }; + + await assert.rejects( + copyAuthors(sql, [{ name: "Ann", bio: null }]), + (error) => { + assert.equal(error, failure); + assert.equal(writable.destroyedWith, failure); + return true; + } + ); + assert.equal(writable.chunks.join(""), "Ann\t\\N\n"); +}); + +test("generated postgres copyfrom function resolves callback-only evented streams", async () => { + const { copyAuthors } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + const chunks = []; + const writable = { + writableFinished: false, + write(chunk, callback) { + chunks.push(String(chunk)); + callback(); + }, + end(callback) { + this.writableFinished = true; + callback(); + }, + once() { + return this; + }, + off() { + return this; + }, + removeListener() { + return this; + }, + }; + const sql = { + unsafe() { + return { + writable() { + return Promise.resolve(writable); + }, + }; + }, + }; + + assert.equal(await copyAuthors(sql, [{ name: "Ann", bio: null }]), 1); + assert.equal(chunks.join(""), "Ann\t\\N\n"); +}); + +test("generated postgres copyfrom function rejects premature stream close", async () => { + const { copyAuthors } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + const listeners = new Map(); + let destroyedWith; + const writable = { + writableFinished: false, + write(_chunk, callback) { + callback(); + }, + end(callback) { + callback(); + queueMicrotask(() => listeners.get("close")?.()); + }, + once(event, listener) { + listeners.set(event, listener); + return this; + }, + off(event, listener) { + if (listeners.get(event) === listener) { + listeners.delete(event); + } + return this; + }, + removeListener(event, listener) { + return this.off(event, listener); + }, + destroy(error) { + destroyedWith = error; + }, + }; + const sql = { + unsafe() { + return { + writable() { + return Promise.resolve(writable); + }, + }; + }, + }; + + await assert.rejects( + copyAuthors(sql, [{ name: "Ann", bio: null }]), + (error) => { + assert.match(error.message, /COPY stream closed before finish/); + assert.equal(destroyedWith, error); + return true; + } + ); +}); + +test("generated postgres copyfrom function waits for write backpressure", async () => { + const { copyAuthors } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + const listeners = new Map(); + const writeCallbacks = []; + const chunks = []; + let ended = false; + const writable = { + write(chunk, callback) { + chunks.push(String(chunk)); + writeCallbacks.push(callback); + return false; + }, + end(callback) { + ended = true; + callback(); + }, + once(event, listener) { + listeners.set(event, listener); + return this; + }, + off(event, listener) { + if (listeners.get(event) === listener) { + listeners.delete(event); + } + return this; + }, + removeListener(event, listener) { + return this.off(event, listener); + }, + }; + const sql = { + unsafe() { + return { + writable() { + return Promise.resolve(writable); + }, + }; + }, + }; + + const result = copyAuthors(sql, [{ name: "Ann", bio: null }]); + await flushAsync(); + assert.equal(chunks.length, 1); + assert.equal(ended, false, "COPY must not end until the backpressured write drains."); + + writeCallbacks[0](); + listeners.get("drain")?.(); + assert.equal(await result, 1); + assert.equal(ended, true); +}); + +test("generated postgres copyfrom function rejects write callback errors before end", async () => { + const { copyAuthors } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + const failure = new Error("write failed"); + let ended = false; + let destroyedWith; + const writable = { + write(_chunk, callback) { + queueMicrotask(() => callback(failure)); + return true; + }, + end(callback) { + ended = true; + callback(); + }, + destroy(error) { + destroyedWith = error; + }, + }; + const sql = { + unsafe() { + return { + writable() { + return Promise.resolve(writable); + }, + }; + }, + }; + + await assert.rejects(copyAuthors(sql, [{ name: "Ann", bio: null }]), (error) => { + assert.equal(error, failure); + assert.equal(destroyedWith, failure); + return true; + }); + assert.equal(ended, false, "COPY must not finalize after a write callback fails."); +}); + +test("generated postgres copyfrom function rejects backpressured callback errors without events", async () => { + const { copyAuthors } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + const failure = new Error("backpressured write failed"); + let ended = false; + let destroyedWith; + const writable = { + write(_chunk, callback) { + queueMicrotask(() => callback(failure)); + return false; + }, + end(callback) { + ended = true; + callback(); + }, + destroy(error) { + destroyedWith = error; + }, + }; + const sql = { + unsafe() { + return { + writable() { + return Promise.resolve(writable); + }, + }; + }, + }; + + await assert.rejects(copyAuthors(sql, [{ name: "Ann", bio: null }]), (error) => { + assert.equal(error, failure); + assert.equal(destroyedWith, failure); + return true; + }); + assert.equal(ended, false, "COPY must not hang or finalize after a backpressured write callback fails."); +}); + +test("generated postgres copyfrom function rejects unsupported value types", async () => { + const { copyAuthors } = loadGeneratedModule( + postgresGeneratedFiles[0], + { postgres: { Sql: class Sql {} } } + ); + + for (const unsupportedValue of [ + { first: "Ann" }, + ["Ann"], + Buffer.from("Ann"), + ]) { + const chunks = []; + let destroyedWith; + const writable = { + write(chunk, callback) { + chunks.push(String(chunk)); + callback(); + }, + end(callback) { + callback(); + }, + destroy(error) { + destroyedWith = error; + }, + }; + const sql = { + unsafe() { + return { + writable() { + return Promise.resolve(writable); + }, + }; + }, + }; + + await assert.rejects( + copyAuthors(sql, [{ name: unsupportedValue, bio: null }]), + (error) => { + assert.ok(error instanceof TypeError); + assert.match(error.message, /scalar columns/); + assert.equal(destroyedWith, error); + return true; + } + ); + assert.equal(chunks.length, 0); + } +}); diff --git a/test/postgres-transaction.test.mjs b/test/postgres-transaction.test.mjs new file mode 100644 index 0000000..e38523b --- /dev/null +++ b/test/postgres-transaction.test.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { test } from "vitest"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const postgresGeneratedFiles = [ + "examples/node-postgres/src/db/query_sql.ts", + "examples/bun-postgres/src/db/query_sql.ts", +].map((path) => resolve(root, path)); +const transactionUsageFile = resolve(root, "test-support/postgres-transaction-usage.ts"); +const postgresDriverTemplateFile = resolve(root, "src/drivers/postgres.ts"); +const tsc = resolve(root, "node_modules/.bin/tsc"); + +function relativePath(path) { + return relative(root, path); +} + +test("generated postgres queries accept transaction clients", () => { + for (const generatedFile of postgresGeneratedFiles) { + assert.ok( + existsSync(generatedFile), + `Missing generated postgres output: ${generatedFile}` + ); + + const source = readFileSync(generatedFile, "utf8"); + + assert.match( + source, + /import type \{ Sql, TransactionSql \} from "postgres";/, + `${relativePath(generatedFile)} must import postgres client types as type-only.` + ); + assert.doesNotMatch( + source, + /type QuerySql = Pick;/, + `${relativePath(generatedFile)} must not emit a local QuerySql alias.` + ); + assert.match( + source, + /export async function getAuthor\(sql: Sql \| TransactionSql, args: GetAuthorArgs\)/, + `${relativePath(generatedFile)} must accept transaction-capable query clients.` + ); + } +}); + +test("postgres driver template types regular queries as Sql or TransactionSql", () => { + const source = readFileSync(postgresDriverTemplateFile, "utf8"); + + assert.match( + source, + /factory\.createIdentifier\("TransactionSql"\)/, + "postgres regular query parameters must allow TransactionSql." + ); + assert.doesNotMatch( + source, + /factory\.createIdentifier\("QuerySql"\)|type QuerySql = Pick;/, + "postgres driver must emit explicit postgres.js client types instead of a local QuerySql alias." + ); +}); + +test("generated postgres query output type-checks with transaction clients", () => { + assert.ok( + existsSync(tsc), + "Missing TypeScript compiler. Install project dependencies before running validation." + ); + + const result = spawnSync( + tsc, + [ + "--ignoreConfig", + "--strict", + "--noUncheckedIndexedAccess", + "--module", + "commonjs", + "--target", + "es2020", + "--esModuleInterop", + "--skipLibCheck", + "--noEmit", + ...postgresGeneratedFiles, + transactionUsageFile, + ], + { cwd: root, encoding: "utf8" } + ); + + assert.ifError(result.error); + assert.equal( + result.status, + 0, + [ + "Generated postgres transaction output failed strict noUncheckedIndexedAccess type-check.", + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join("\n") + ); +});