From f8a437db3f3ba9e8055cfafe75b9cccb04686b92 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 12 Oct 2026 13:23:23 +0100 Subject: [PATCH 1/6] feat(appkit): default defineSchema tables to the app schema defineSchema() without a schemaName now places its tables in the Postgres schema "app" instead of "public". The deployed app's service principal can create and own a schema of its own, but has no CREATE on public, so tables in public could not be migrated on deploy without a manual grant. Signed-off-by: ditadi --- docs/docs/plugins/database.md | 5 ++ .../runtime/tests/drizzle-data-path.test.ts | 36 ++++++++------- .../database/runtime/tests/translate.test.ts | 46 ++++++++++--------- .../database/schema-builder/define-schema.ts | 9 +++- .../tests/define-schema.test.ts | 26 ++++++++++- .../src/database/schema-builder/types.ts | 1 + .../plugins/database/tests/lifecycle.test.ts | 4 +- .../database/tests/migrate-on-start.test.ts | 8 ++-- .../database/tests/schema-check.test.ts | 24 ++++++---- 9 files changed, 103 insertions(+), 56 deletions(-) diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 7f8aa77e1..0317bcd5a 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -84,6 +84,11 @@ public column, list with an explicit non-empty `order`. A keyless table with no sortable columns has no list route. `upsert` is available to server code but has no generated HTTP route. +Tables live in the Postgres schema `app` unless the second argument of +`defineSchema()` names another one, for example `{ schemaName: "public" }`. The +default is not `public` because the deployed app's service principal creates and +owns `app`, but has no `CREATE` privilege on `public`. + ## Schema discovery and overrides `database()` and `database({})` use the same defaults. During setup, the plugin diff --git a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts index b9d35409b..7c9bc7241 100644 --- a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts +++ b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts @@ -23,22 +23,26 @@ import { } from "../engine/drizzle-data-path"; import { returningColumns } from "../engine/translate"; -const schema = defineSchema((builder) => { - const users = builder.table("users", { - id: id(), - email: text().unique(), - name: text(), - active: boolean(), - secret: text().private(), - }); - const posts = builder.table("posts", { - id: id(), - authorId: fk(() => users.id), - title: text(), - secret: text().private(), - }); - return { users, posts }; -}); +// Explicitly public: the order-by assertion expects unqualified SQL ("users"."secret"). +const schema = defineSchema( + (builder) => { + const users = builder.table("users", { + id: id(), + email: text().unique(), + name: text(), + active: boolean(), + secret: text().private(), + }); + const posts = builder.table("posts", { + id: id(), + authorId: fk(() => users.id), + title: text(), + secret: text().private(), + }); + return { users, posts }; + }, + { schemaName: "public" }, +); const users = schema.$tables.users; const naturalKeySchema = defineSchema((builder) => ({ diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts index cf2c11fa9..cd90de104 100644 --- a/packages/appkit/src/database/runtime/tests/translate.test.ts +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -35,27 +35,31 @@ import { translateWhere, } from "../engine/translate"; -const schema = defineSchema((builder) => { - const users = builder.table("users", { - id: id(), - name: text(), - age: integer(), - active: boolean().notNull(), - large: bigint().notNull(), - createdAt: timestamp().notNull(), - externalId: uuid().notNull(), - status: enumColumn("user_status", ["active", "disabled"]).notNull(), - metadata: jsonb(), - secret: text().private(), - }); - const posts = builder.table("posts", { - id: id(), - authorId: fk(() => users.id), - title: text(), - secret: text().private(), - }); - return { users, posts }; -}); +// Explicitly public: the assertions expect unqualified column SQL ("users"."name"). +const schema = defineSchema( + (builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + age: integer(), + active: boolean().notNull(), + large: bigint().notNull(), + createdAt: timestamp().notNull(), + externalId: uuid().notNull(), + status: enumColumn("user_status", ["active", "disabled"]).notNull(), + metadata: jsonb(), + secret: text().private(), + }); + const posts = builder.table("posts", { + id: id(), + authorId: fk(() => users.id), + title: text(), + secret: text().private(), + }); + return { users, posts }; + }, + { schemaName: "public" }, +); const users = schema.$tables.users; const dialect = new PgDialect(); diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index 64ed5ff2d..5d8cf3d91 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -339,6 +339,13 @@ export function assertFinalizedSchema(value: unknown): asserts value is Schema { } } +/** + * Postgres schema of every table when `schemaName` is omitted. Not `public`: + * the deployed app's service principal can create and own a schema of its + * own, but has no CREATE on `public`. + */ +const DEFAULT_SCHEMA_NAME = "app"; + /** * Compile one declared schema. The returned type keeps the table names the * builder returned, so `api.tables` and `hooks` can name only real tables. @@ -347,7 +354,7 @@ export function defineSchema>( builder: (context: SchemaBuilderContext) => TTables, options?: DefineSchemaOptions, ): Schema> { - const schemaName = options?.schemaName ?? "public"; + const schemaName = options?.schemaName ?? DEFAULT_SCHEMA_NAME; if (!schemaName) throw new SchemaBuildError("Schema name cannot be empty"); const state: DeclarationState = { diff --git a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts index 6afa07afe..a8fe2ac0b 100644 --- a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts @@ -79,8 +79,10 @@ describe("defineSchema finalization", () => { expect(Object.getPrototypeOf(schema.$tables.users.$columns)).toBeNull(); }); - it("defaults to public and supports a safe custom schema", () => { - expect(schema.$schemaName).toBe("public"); + it("defaults to app and supports a safe custom schema", () => { + // Not public: the deployed app's service principal has no CREATE there. + expect(schema.$schemaName).toBe("app"); + expect(schema.$tables.users.$schemaName).toBe("app"); const options: DefineSchemaOptions = { schemaName: "application" }; const custom = defineSchema( (builder) => ({ widgets: builder.table("widgets", { id: id() }) }), @@ -90,6 +92,26 @@ describe("defineSchema finalization", () => { expect(custom.$tables.widgets.$schemaName).toBe("application"); }); + it.each([ + ["by default, in app", undefined, "app"], + ['with schemaName "public", unqualified', "public", undefined], + ] as const)("creates tables and enums %s", (_label, schemaName, expected) => { + const schema = defineSchema( + (builder) => ({ + tickets: builder.table("tickets", { + status: builder.enum("ticket_status", ["open", "closed"]), + }), + }), + schemaName ? { schemaName } : undefined, + ); + const config = getTableConfig(pgOf(schema.$tables.tickets.$engine)); + expect(config.schema).toBe(expected); + expect( + (config.columns[0] as unknown as { enum?: { schema?: string } }).enum + ?.schema, + ).toBe(expected); + }); + it("accepts the explicit builder context type", () => { const build = (builder: SchemaBuilderContext) => { const users: TableHandle<{ id: ColumnBuilder; email: ColumnBuilder }> = diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index d49a14b02..133cbb77a 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -157,6 +157,7 @@ export type TableHandle> = AppKitTable & { }; export interface DefineSchemaOptions { + /** Postgres schema of every table. Default: `"app"`. */ readonly schemaName?: string; } diff --git a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts index 0401912f6..fe7695dc7 100644 --- a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts +++ b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts @@ -209,7 +209,7 @@ describe("createDatabaseState", () => { const drift = new DatabasePluginError( "SETUP_FAILED", "setup", - "Database setup failed: table public.tags does not exist", + "Database setup failed: table app.tags does not exist", ); mocks.assertSchemaMatchesDatabase.mockRejectedValueOnce(drift); @@ -223,7 +223,7 @@ describe("createDatabaseState", () => { schema, ); expect(error).toBe(drift); - expect(error.message).toContain("table public.tags does not exist"); + expect(error.message).toContain("table app.tags does not exist"); expect(pool.end).toHaveBeenCalledTimes(1); }); diff --git a/packages/appkit/src/plugins/database/tests/migrate-on-start.test.ts b/packages/appkit/src/plugins/database/tests/migrate-on-start.test.ts index cf453a32d..788313830 100644 --- a/packages/appkit/src/plugins/database/tests/migrate-on-start.test.ts +++ b/packages/appkit/src/plugins/database/tests/migrate-on-start.test.ts @@ -20,10 +20,10 @@ import { MigrationFilesError } from "../../../database/migrations/paths"; import { defineSchema, id } from "../../../database/schema-builder"; import { migrateOnStart, shouldMigrateOnStart } from "../migrate-on-start"; -const schema = defineSchema( - ({ table }) => ({ notes: table("notes", { id: id() }) }), - { schemaName: "app" }, -); +// The default schema, app, holds the journal too. +const schema = defineSchema(({ table }) => ({ + notes: table("notes", { id: id() }), +})); const roots: string[] = []; const pool = { connect: vi.fn() }; diff --git a/packages/appkit/src/plugins/database/tests/schema-check.test.ts b/packages/appkit/src/plugins/database/tests/schema-check.test.ts index 6f10836a8..1ece4fb92 100644 --- a/packages/appkit/src/plugins/database/tests/schema-check.test.ts +++ b/packages/appkit/src/plugins/database/tests/schema-check.test.ts @@ -4,16 +4,20 @@ import type { DataPath } from "../../../database/runtime"; import { defineSchema, fk, id, text } from "../../../database/schema-builder"; import { assertSchemaMatchesDatabase } from "../schema-check"; -const schema = defineSchema(({ table }) => { - const boards = table("boards", { id: id(), title: text().notNull() }); - const notes = table("notes", { - id: id(), - board_id: fk(() => boards.id).notNull(), - author_email: text().private(), - body: text().notNull(), - }); - return { boards, notes }; -}); +// Explicitly public: the assertions name the tables public.boards and public.notes. +const schema = defineSchema( + ({ table }) => { + const boards = table("boards", { id: id(), title: text().notNull() }); + const notes = table("notes", { + id: id(), + board_id: fk(() => boards.id).notNull(), + author_email: text().private(), + body: text().notNull(), + }); + return { boards, notes }; + }, + { schemaName: "public" }, +); type CatalogRow = { table_name: string; From 9f3a34f132799e9eec79456d0fc3eabb7f6c0256 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 12 Oct 2026 13:33:40 +0100 Subject: [PATCH 2/6] feat(appkit): add appkit db migrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appkit db migrate` generates a migration from config/database/schema.ts with drizzle-kit, regenerates shared/appkit-types/database.d.ts, and applies pending migrations to the branch .env points at. It applies only to a branch created for this developer (dev-…) or this checkout (session-…); on any other branch, including the project default and any branch databricks.yml deploys to, it generates and says why nothing was applied. It connects only after the Lakebase API confirms that PGHOST is a host of LAKEBASE_ENDPOINT. The logic lives in a new @databricks/appkit/database-tooling entry point that the shared CLI loads lazily, so the runtime never imports drizzle-kit. drizzle-kit is an optional peer dependency: apps that generate migrations install it as a dev dependency, and the command says how when it is missing. `appkit db` stays hidden from --help until the database plugin is offered by `databricks apps init`. Signed-off-by: ditadi --- knip.json | 3 +- packages/appkit/package.json | 11 + .../src/connectors/lakebase/endpoint-host.ts | 2 +- .../appkit/src/database/migrations/paths.ts | 2 +- .../appkit/src/database/tooling/context.ts | 122 ++++ .../src/database/tooling/drizzle-exports.ts | 24 + packages/appkit/src/database/tooling/guard.ts | 84 +++ .../appkit/src/database/tooling/identity.ts | 29 + packages/appkit/src/database/tooling/index.ts | 7 + .../src/database/tooling/lakebase-api.ts | 104 +++ .../appkit/src/database/tooling/migrate.ts | 127 ++++ .../appkit/src/database/tooling/migrations.ts | 262 ++++++++ .../appkit/src/database/tooling/naming.ts | 48 ++ .../appkit/src/database/tooling/targets.ts | 115 ++++ .../database/tooling/tests/context.test.ts | 140 +++++ .../tooling/tests/drizzle-exports.test.ts | 47 ++ .../tests/drizzle-kit-dependency.test.ts | 104 +++ .../src/database/tooling/tests/fakes.ts | 122 ++++ .../src/database/tooling/tests/guard.test.ts | 167 +++++ .../database/tooling/tests/identity.test.ts | 32 + .../tooling/tests/lakebase-api.test.ts | 87 +++ .../database/tooling/tests/migrate.test.ts | 253 ++++++++ .../database/tooling/tests/migrations.test.ts | 343 ++++++++++ .../src/database/tooling/tests/naming.test.ts | 39 ++ .../database/tooling/tests/targets.test.ts | 111 ++++ .../src/plugins/database/load-schema.ts | 14 +- .../src/plugins/database/schema-check.ts | 2 +- packages/appkit/tsdown.config.ts | 1 + .../src/cli/commands/db/database-tooling.d.ts | 39 ++ packages/shared/src/cli/commands/db/index.ts | 11 + .../src/cli/commands/db/migrate.test.ts | 152 +++++ .../shared/src/cli/commands/db/migrate.ts | 31 + packages/shared/src/cli/commands/db/output.ts | 68 ++ packages/shared/src/cli/index.ts | 3 + pnpm-lock.yaml | 590 +++++++++++++++++- 35 files changed, 3268 insertions(+), 28 deletions(-) create mode 100644 packages/appkit/src/database/tooling/context.ts create mode 100644 packages/appkit/src/database/tooling/drizzle-exports.ts create mode 100644 packages/appkit/src/database/tooling/guard.ts create mode 100644 packages/appkit/src/database/tooling/identity.ts create mode 100644 packages/appkit/src/database/tooling/index.ts create mode 100644 packages/appkit/src/database/tooling/lakebase-api.ts create mode 100644 packages/appkit/src/database/tooling/migrate.ts create mode 100644 packages/appkit/src/database/tooling/migrations.ts create mode 100644 packages/appkit/src/database/tooling/naming.ts create mode 100644 packages/appkit/src/database/tooling/targets.ts create mode 100644 packages/appkit/src/database/tooling/tests/context.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/fakes.ts create mode 100644 packages/appkit/src/database/tooling/tests/guard.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/identity.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/lakebase-api.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/migrate.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/migrations.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/naming.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/targets.test.ts create mode 100644 packages/shared/src/cli/commands/db/database-tooling.d.ts create mode 100644 packages/shared/src/cli/commands/db/index.ts create mode 100644 packages/shared/src/cli/commands/db/migrate.test.ts create mode 100644 packages/shared/src/cli/commands/db/migrate.ts create mode 100644 packages/shared/src/cli/commands/db/output.ts diff --git a/knip.json b/knip.json index 02320c52b..722d1b550 100644 --- a/knip.json +++ b/knip.json @@ -12,7 +12,8 @@ "vitest", "@databricks/sdk-experimental", "@mlflow/core", - "autoevals" + "autoevals", + "drizzle-kit" ] }, "packages/appkit-ui": { diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 0c81d8824..99d9abdba 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -42,6 +42,11 @@ "development": "./src/type-generator/index.ts", "default": "./dist/type-generator/index.js" }, + "./database-tooling": { + "types": "./dist/database/tooling/index.d.ts", + "development": "./src/database/tooling/index.ts", + "default": "./dist/database/tooling/index.js" + }, "./testing": { "types": "./dist/testing/index.d.ts", "development": "./src/testing/index.ts", @@ -108,6 +113,7 @@ "peerDependencies": { "@mlflow/core": "0.4.0", "autoevals": "0.3.0", + "drizzle-kit": "0.31.10", "vitest": ">=3" }, "peerDependenciesMeta": { @@ -117,6 +123,9 @@ "autoevals": { "optional": true }, + "drizzle-kit": { + "optional": true + }, "vitest": { "optional": true } @@ -131,6 +140,7 @@ "@types/ws": "8.18.1", "@vitejs/plugin-react": "5.1.1", "autoevals": "0.3.0", + "drizzle-kit": "0.31.10", "vitest": "3.2.4" }, "overrides": { @@ -143,6 +153,7 @@ "./beta": "./dist/beta.js", "./dist/shared/src/plugin": "./dist/shared/src/plugin.d.ts", "./type-generator": "./dist/type-generator/index.js", + "./database-tooling": "./dist/database/tooling/index.js", "./testing": { "types": "./dist/testing/index.d.ts", "default": "./dist/testing/index.js" diff --git a/packages/appkit/src/connectors/lakebase/endpoint-host.ts b/packages/appkit/src/connectors/lakebase/endpoint-host.ts index a3020901d..515d20c48 100644 --- a/packages/appkit/src/connectors/lakebase/endpoint-host.ts +++ b/packages/appkit/src/connectors/lakebase/endpoint-host.ts @@ -134,7 +134,7 @@ async function compareHosts( } /** Read every host the endpoint serves, read-write and read-only. */ -function endpointHosts(response: unknown): string[] { +export function endpointHosts(response: unknown): string[] { if (!response || typeof response !== "object") return []; const status = Reflect.get(response, "status"); if (!status || typeof status !== "object") return []; diff --git a/packages/appkit/src/database/migrations/paths.ts b/packages/appkit/src/database/migrations/paths.ts index e331080b0..8d5126076 100644 --- a/packages/appkit/src/database/migrations/paths.ts +++ b/packages/appkit/src/database/migrations/paths.ts @@ -34,7 +34,7 @@ export function migrationsFolder(root: string): string { } /** Path of the journal inside a migrations folder. */ -function journalFile(folder: string): string { +export function journalFile(folder: string): string { return path.join(folder, "meta", "_journal.json"); } diff --git a/packages/appkit/src/database/tooling/context.ts b/packages/appkit/src/database/tooling/context.ts new file mode 100644 index 000000000..c8850dea0 --- /dev/null +++ b/packages/appkit/src/database/tooling/context.ts @@ -0,0 +1,122 @@ +import type { LakebasePoolConfig } from "@databricks/lakebase"; +import type { Pool } from "pg"; + +import { initializeLakebasePool } from "../../connectors/lakebase"; +import { endpointHosts } from "../../connectors/lakebase/endpoint-host"; +import { getClientOptions } from "../../context/client-options"; +import { createWorkspaceClient } from "../../workspace-client"; +import { LakebaseApi, type LakebaseEndpoint } from "./lakebase-api"; + +/** One REST call through the SDK's unified-auth client. */ +export interface ToolingRequest { + path: string; + method: "GET" | "POST" | "PATCH" | "DELETE"; + query?: Record; + headers: Headers; + raw: boolean; + payload?: unknown; +} + +/** + * The slice of the SDK client the tooling uses. It is also everything the + * Lakebase pool needs: token refresh calls `apiClient.request` and the user + * lookup calls `currentUser.me`. + */ +export interface ToolingWorkspaceClient { + readonly apiClient: { request(options: ToolingRequest): Promise }; + readonly currentUser: { me(): Promise<{ userName?: string }> }; +} + +/** Where a command runs: the app root, its environment and its Databricks identity. */ +export interface DbContext { + readonly root: string; + readonly env: NodeJS.ProcessEnv; + /** Lazy; the profile comes from DATABRICKS_CONFIG_PROFILE. */ + workspace(): ToolingWorkspaceClient; + /** Lazy Lakebase REST client over workspace(). */ + api(): LakebaseApi; +} + +export function createDbContext( + options: { root?: string; env?: NodeJS.ProcessEnv } = {}, +): DbContext { + const root = options.root ?? process.cwd(); + const env = options.env ?? process.env; + let client: ToolingWorkspaceClient | undefined; + let api: LakebaseApi | undefined; + const ctx: DbContext = { + root, + env, + workspace() { + if (!client) { + const profile = env.DATABRICKS_CONFIG_PROFILE; + client = createWorkspaceClient({ + ...(profile ? { profile } : {}), + clientOptions: getClientOptions(), + }).toLegacyWorkspaceClient(); + } + return client; + }, + api() { + api ??= new LakebaseApi(ctx.workspace()); + return api; + }, + }; + return ctx; +} + +/** + * The guard checks the branch named by LAKEBASE_ENDPOINT, but the pool connects + * to PGHOST. The runtime only warns when it cannot verify that PGHOST serves that + * endpoint; a command that writes refuses instead. + */ +async function assertHostServesEndpoint( + ctx: DbContext, + endpoint: string, + host: string, +): Promise { + let found: LakebaseEndpoint | null; + try { + found = await ctx.api().getEndpoint(endpoint); + } catch (error) { + throw new Error( + `Could not verify that PGHOST ${host} serves LAKEBASE_ENDPOINT ${endpoint}, so it did not connect.`, + { cause: error }, + ); + } + if (!found) { + throw new Error( + `LAKEBASE_ENDPOINT ${endpoint} does not exist, so it did not connect. Run \`appkit db init\` to point .env at your branch.`, + ); + } + const hosts = endpointHosts(found); + if (!hosts.includes(host.toLowerCase())) { + throw new Error( + `PGHOST ${host} is not a host of LAKEBASE_ENDPOINT ${endpoint} (${hosts.length > 0 ? `expected ${hosts.join(" or ")}` : "it has no hosts"}), so it did not connect. Run \`appkit db init\` to rewrite .env.`, + ); + } +} + +/** null when PGHOST / PGDATABASE / LAKEBASE_ENDPOINT are not configured. */ +export async function openBranchPool(ctx: DbContext): Promise { + const { PGHOST, PGDATABASE, LAKEBASE_ENDPOINT } = ctx.env; + if (!PGHOST || !PGDATABASE || !LAKEBASE_ENDPOINT) return null; + await assertHostServesEndpoint(ctx, LAKEBASE_ENDPOINT, PGHOST); + const port = ctx.env.PGPORT ? Number(ctx.env.PGPORT) : undefined; + const sslMode = ctx.env.PGSSLMODE as LakebasePoolConfig["sslMode"]; + return initializeLakebasePool({ + host: PGHOST, + database: PGDATABASE, + endpoint: LAKEBASE_ENDPOINT, + user: ctx.env.PGUSER || undefined, + ...(port ? { port } : {}), + ...(sslMode ? { sslMode } : {}), + // Only apiClient.request and currentUser.me are used (see ToolingWorkspaceClient). + workspaceClient: ctx.workspace() as unknown as NonNullable< + LakebasePoolConfig["workspaceClient"] + >, + // One-shot CLI pools: a small cap, and no lingering idle connections. + max: 2, + idleTimeoutMillis: 1_000, + }); +} diff --git a/packages/appkit/src/database/tooling/drizzle-exports.ts b/packages/appkit/src/database/tooling/drizzle-exports.ts new file mode 100644 index 000000000..038d0fa84 --- /dev/null +++ b/packages/appkit/src/database/tooling/drizzle-exports.ts @@ -0,0 +1,24 @@ +import { getTableColumns, is, type Table } from "drizzle-orm"; +import { PgEnumColumn } from "drizzle-orm/pg-core"; + +import type { Schema } from "../schema-builder"; + +/** + * The "module exports" drizzle-kit snapshots: every engine table plus every + * enum a column references. The pgSchema object is never included, so + * generated SQL contains no CREATE SCHEMA; the migrator creates the schema as + * the identity that applies the migrations, which then owns it. + */ +export function drizzleKitImports(schema: Schema): Record { + const imports: Record = Object.create(null); + for (const [name, table] of Object.entries(schema.$engine)) { + imports[`table:${name}`] = table; + const columns = getTableColumns(table as unknown as Table); + for (const column of Object.values(columns)) { + if (is(column, PgEnumColumn)) { + imports[`enum:${column.enum.enumName}`] = column.enum; + } + } + } + return imports; +} diff --git a/packages/appkit/src/database/tooling/guard.ts b/packages/appkit/src/database/tooling/guard.ts new file mode 100644 index 000000000..f135e8258 --- /dev/null +++ b/packages/appkit/src/database/tooling/guard.ts @@ -0,0 +1,84 @@ +import type { DbContext } from "./context"; +import { currentIdentity } from "./identity"; +import type { LakebaseBranch } from "./lakebase-api"; +import { devBranchId, sessionBranchId } from "./naming"; +import { + parseResourceName, + readDeployBranches, + readLocalTarget, + resourceId, +} from "./targets"; + +// Commands that write to a branch run only on branches `appkit db init` +// created for this developer (dev-…) or this checkout (session-…). + +interface BranchVerdict { + readonly appkitOwned: boolean; + /** Why the branch is not AppKit-owned. */ + readonly reason?: string; +} + +export function classifyBranch(input: { + branch: LakebaseBranch; + userName: string; + sessionId: string | null; + /** Every branch databricks.yml deploys to; the deployed app migrates those itself. */ + deployBranches: readonly string[]; +}): BranchVerdict { + const { branch, userName, sessionId, deployBranches } = input; + if (branch.status?.default) { + return { appkitOwned: false, reason: "it is the project's default branch" }; + } + if (branch.status?.is_protected) { + return { appkitOwned: false, reason: "it is a protected branch" }; + } + if (deployBranches.includes(branch.name)) { + return { + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }; + } + const id = resourceId(branch.name); + if (id === devBranchId(userName)) return { appkitOwned: true }; + if (sessionId && id === sessionBranchId(sessionId)) { + return { appkitOwned: true }; + } + return { + appkitOwned: false, + reason: `it is not your dev branch (${devBranchId(userName)})${ + sessionId + ? ` or this checkout's session branch (${sessionBranchId(sessionId)})` + : "" + }`, + }; +} + +interface BranchCheck { + readonly branch: LakebaseBranch; + readonly verdict: BranchVerdict; +} + +/** Classify the `.env` branch through the API (no database connection); null when none is configured. */ +export async function checkAppkitBranch( + ctx: DbContext, +): Promise { + const local = readLocalTarget(ctx.env); + const name = parseResourceName(local.endpoint ?? undefined)?.branch; + if (!name) return null; + const branch = await ctx.api().getBranch(name); + if (!branch) { + throw new Error( + `The .env branch ${name} does not exist (deleted or expired). Run \`appkit db init\` to create or pick one.`, + ); + } + const { userName } = await currentIdentity(ctx); + return { + branch, + verdict: classifyBranch({ + branch, + userName, + sessionId: local.session, + deployBranches: readDeployBranches(ctx.root), + }), + }; +} diff --git a/packages/appkit/src/database/tooling/identity.ts b/packages/appkit/src/database/tooling/identity.ts new file mode 100644 index 000000000..3b539abc6 --- /dev/null +++ b/packages/appkit/src/database/tooling/identity.ts @@ -0,0 +1,29 @@ +import type { DbContext } from "./context"; + +/** Who runs the command: the Postgres role name is the user name (email or SP client ID). */ +interface Identity { + readonly userName: string; +} + +const identities = new WeakMap>(); + +/** currentUser.me(), once per context. */ +export function currentIdentity(ctx: DbContext): Promise { + let identity = identities.get(ctx); + if (!identity) { + identity = ctx + .workspace() + .currentUser.me() + .then(({ userName }) => { + if (!userName) { + throw new Error( + "Could not determine the current Databricks user (currentUser.me returned no userName). Check DATABRICKS_CONFIG_PROFILE.", + ); + } + return { userName }; + }); + identity.catch(() => identities.delete(ctx)); + identities.set(ctx, identity); + } + return identity; +} diff --git a/packages/appkit/src/database/tooling/index.ts b/packages/appkit/src/database/tooling/index.ts new file mode 100644 index 000000000..7167ac662 --- /dev/null +++ b/packages/appkit/src/database/tooling/index.ts @@ -0,0 +1,7 @@ +/** + * `@databricks/appkit/database-tooling` — CLI-only logic behind `appkit db`. + * Loaded lazily by the shared CLI; it may import drizzle-kit, so the runtime + * plugin never imports it. + */ +export { createDbContext, type DbContext } from "./context"; +export { type MigrateOptions, type MigrateReport, runMigrate } from "./migrate"; diff --git a/packages/appkit/src/database/tooling/lakebase-api.ts b/packages/appkit/src/database/tooling/lakebase-api.ts new file mode 100644 index 000000000..1bde6df9d --- /dev/null +++ b/packages/appkit/src/database/tooling/lakebase-api.ts @@ -0,0 +1,104 @@ +import type { ToolingRequest, ToolingWorkspaceClient } from "./context"; + +// Lakebase Postgres REST API through the SDK's unified-auth client. Paths, +// query parameters and bodies mirror the Go SDK (service/postgres/impl.go). + +export interface LakebaseBranch { + name: string; + status?: { + default?: boolean; + is_protected?: boolean; + current_state?: string; + expire_time?: string; + source_branch?: string; + }; +} + +export interface LakebaseEndpoint { + name: string; + status?: { hosts?: { host?: string } }; +} + +export class LakebaseApiError extends Error { + /** HTTP status of the failing call. */ + readonly statusCode?: number; + /** What was being done, e.g. "create branch projects/p/branches/b". */ + readonly operation: string; + + constructor( + operation: string, + message: string, + options: { statusCode?: number; cause?: unknown } = {}, + ) { + super(`Lakebase: ${operation} failed: ${message}`, { + cause: options.cause, + }); + this.name = "LakebaseApiError"; + this.operation = operation; + this.statusCode = options.statusCode; + } +} + +const API = "/api/2.0/postgres"; + +function statusCodeOf(error: unknown): number | undefined { + const code = (error as { statusCode?: unknown } | undefined)?.statusCode; + return typeof code === "number" ? code : undefined; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export class LakebaseApi { + constructor( + private readonly client: Pick, + ) {} + + getBranch(name: string): Promise { + return this.getOrNull(`get branch ${name}`, name); + } + + getEndpoint(name: string): Promise { + return this.getOrNull(`get endpoint ${name}`, name); + } + + private async call( + operation: string, + request: Omit, + ): Promise { + const headers = new Headers({ Accept: "application/json" }); + if (request.payload !== undefined) { + headers.set("Content-Type", "application/json"); + } + try { + return (await this.client.apiClient.request({ + ...request, + headers, + raw: false, + })) as T; + } catch (error) { + throw new LakebaseApiError(operation, messageOf(error), { + statusCode: statusCodeOf(error), + cause: error, + }); + } + } + + private async getOrNull( + operation: string, + name: string, + ): Promise { + try { + return await this.call(operation, { + path: `${API}/${name}`, + method: "GET", + }); + } catch (error) { + if (error instanceof LakebaseApiError && error.statusCode === 404) { + return null; + } + throw error; + } + } +} diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts new file mode 100644 index 000000000..d0be9ec6d --- /dev/null +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { + DATABASE_TYPES_FILE, + generateDatabaseTypes, +} from "../../type-generator/database"; +import { applyPendingMigrations } from "../migrations/apply"; +import { migrationsFolder, readJournal } from "../migrations/paths"; +import { type DbContext, openBranchPool } from "./context"; +import { checkAppkitBranch } from "./guard"; +import { type GeneratedMigration, generateMigrationFiles } from "./migrations"; +import { resourceId } from "./targets"; + +/** Same application-relative conventions as the plugin and `appkit generate-types`. */ +const SCHEMA_FILE = "config/database/schema.ts"; + +/** Whether the checkout has a config/database/schema.ts yet. */ +function hasSchemaFile(root: string): boolean { + return fs.existsSync(path.join(root, SCHEMA_FILE)); +} + +/** Without schema.ts, point to introspect instead of a load error. */ +function requireSchemaFile(root: string): void { + if (hasSchemaFile(root)) return; + throw new Error( + `No ${SCHEMA_FILE} yet, so there is nothing to migrate. Run \`appkit db introspect\` to write it from the branch's tables, or create it.`, + ); +} +const DATABASE_TYPES_PATH = path.join( + "shared/appkit-types", + DATABASE_TYPES_FILE, +); + +export interface MigrateReport { + /** The migration written by this run; null when schema.ts matches the latest snapshot. */ + readonly generated: GeneratedMigration | null; + /** Tags applied to the `.env` branch by this run. */ + readonly applied: readonly string[]; + /** Tags recorded without running because their tables already existed (0000_baseline). */ + readonly adopted?: readonly string[]; + /** Why nothing was applied, when applying was skipped. */ + readonly notApplied?: string; + readonly typesFile: string; +} + +export interface MigrateOptions { + /** false: an ambiguous rename throws instead of prompting. */ + readonly interactive: boolean; +} + +type ApplyOutcome = Pick; + +const NO_BRANCH = + "no branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run appkit db init"; + +/** Why migrations must not be applied to the `.env` branch; undefined when they may. */ +async function applyBlocker(ctx: DbContext): Promise { + const check = await checkAppkitBranch(ctx); + if (!check) return NO_BRANCH; + if (check.verdict.appkitOwned) return undefined; + return `${resourceId(check.branch.name)} is not an AppKit dev or session branch (${check.verdict.reason}). Migrations are applied only to branches \`appkit db init\` created for you; the deployed app applies committed migrations to its own branch`; +} + +/** Apply pending migrations to the `.env` branch, or say why they were not. */ +async function applyToEnvBranch( + ctx: DbContext, + folder: string, + schemaName: string, +): Promise { + const blocker = readJournal(folder) + ? await applyBlocker(ctx) + : "no migrations to apply — schema.ts declares no tables yet"; + if (blocker) return { applied: [], notApplied: blocker }; + const pool = await openBranchPool(ctx); + if (!pool) return { applied: [], notApplied: NO_BRANCH }; + try { + const { applied, adopted } = await applyPendingMigrations(pool, { + migrationsFolder: folder, + schemaName, + }); + return { applied, ...(adopted.length > 0 ? { adopted } : {}) }; + } finally { + await pool.end().catch(() => undefined); + } +} + +/** + * `appkit db migrate`: generate a migration when schema.ts changed, regenerate + * database.d.ts, and apply pending migrations to the `.env` branch (only AppKit + * dev/session branches). + */ +export async function runMigrate( + ctx: DbContext, + options: MigrateOptions, +): Promise { + requireSchemaFile(ctx.root); + const schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + const folder = migrationsFolder(ctx.root); + const generated = await generateMigrationFiles({ + folder, + schema, + interactive: options.interactive, + }); + + // The types follow schema.ts alone, so they are current even if applying fails. + const typesFile = path.join(ctx.root, DATABASE_TYPES_PATH); + await generateDatabaseTypes({ + schemaFile: path.join(ctx.root, SCHEMA_FILE), + outFile: typesFile, + }); + + let outcome: ApplyOutcome; + try { + outcome = await applyToEnvBranch(ctx, folder, schema.$schemaName); + } catch (error) { + if (!generated) throw error; + throw new Error( + `Wrote ${path.relative(ctx.root, generated.file)}, but did not apply it: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + return { generated, ...outcome, typesFile }; +} diff --git a/packages/appkit/src/database/tooling/migrations.ts b/packages/appkit/src/database/tooling/migrations.ts new file mode 100644 index 000000000..135d4401d --- /dev/null +++ b/packages/appkit/src/database/tooling/migrations.ts @@ -0,0 +1,262 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + type Journal, + type JournalEntry, + journalFile, + readJournal, + readJsonFile, +} from "../migrations/paths"; +import type { Schema } from "../schema-builder"; +import { drizzleKitImports } from "./drizzle-exports"; + +/** + * The drizzle-kit release the snapshots and SQL are written for; the optional + * peer dependency in package.json pins the same version. + */ +export const DRIZZLE_KIT_VERSION = "0.31.10"; + +interface GenerateOptions { + /** Absolute migrations folder. */ + readonly folder: string; + readonly schema: Schema; + /** false: an ambiguous rename throws instead of prompting. */ + readonly interactive: boolean; +} + +export interface GeneratedMigration { + /** e.g. "0001_migration". */ + readonly tag: string; + /** Absolute path of the written .sql file. */ + readonly file: string; + readonly statements: readonly string[]; +} + +/** + * drizzle-kit can't tell a rename from a drop plus a create without asking. + * Raised in non-interactive runs instead of prompting. + */ +export class MigrationRenameAmbiguityError extends Error { + readonly kind: "table" | "column" | "enum"; + /** The table whose columns changed, or the schema for table and enum changes. */ + readonly table: string; + readonly created: readonly string[]; + readonly deleted: readonly string[]; + + constructor( + kind: MigrationRenameAmbiguityError["kind"], + table: string, + created: readonly string[], + deleted: readonly string[], + ) { + const scope = + kind === "column" ? `columns of table "${table}"` : `${kind}s`; + super( + `Cannot tell whether ${scope} were renamed: removed ${deleted.join(", ")}; added ${created.join(", ")}. ` + + "Run `appkit db migrate` in an interactive terminal to choose between a rename and a drop plus create.", + ); + this.name = "MigrationRenameAmbiguityError"; + this.kind = kind; + this.table = table; + this.created = Object.freeze([...created]); + this.deleted = Object.freeze([...deleted]); + } +} + +/** drizzle-kit's statement separator in v7 migration files. */ +const BREAKPOINT = "--> statement-breakpoint\n"; +const ORIGIN_ID = "00000000-0000-0000-0000-000000000000"; + +/** The parts of a drizzle-kit PostgreSQL snapshot this module reads. */ +interface Snapshot { + id: string; + tables: Record }>; + enums: Record; + [key: string]: unknown; +} + +/** drizzle-kit's empty starting snapshot (its `dryPg`). */ +function originSnapshot(): Snapshot { + return { + id: ORIGIN_ID, + prevId: "", + version: "7", + dialect: "postgresql", + tables: {}, + enums: {}, + schemas: {}, + sequences: {}, + roles: {}, + policies: {}, + views: {}, + _meta: { schemas: {}, tables: {}, columns: {} }, + }; +} + +function prefixOf(idx: number): string { + return String(idx).padStart(4, "0"); +} + +function snapshotFile(folder: string, idx: number): string { + return path.join(folder, "meta", `${prefixOf(idx)}_snapshot.json`); +} + +function latestSnapshot(folder: string, journal: Journal | null): Snapshot { + const last = journal?.entries.at(-1); + if (!last) return originSnapshot(); + const file = snapshotFile(folder, last.idx); + if (!fs.existsSync(file)) { + throw new Error( + `Missing ${file} for migration ${last.tag}. Restore it from version control; it is the baseline for the next migration.`, + ); + } + return readJsonFile(file); +} + +function keyDiff( + before: Record, + after: Record, +): { created: string[]; deleted: string[] } { + return { + created: Object.keys(after).filter((key) => !Object.hasOwn(before, key)), + deleted: Object.keys(before).filter((key) => !Object.hasOwn(after, key)), + }; +} + +/** The same "created and deleted together" conditions that make drizzle-kit prompt. */ +function findRenameAmbiguity( + prev: Snapshot, + cur: Snapshot, + schemaName: string, +): MigrationRenameAmbiguityError | null { + const tables = keyDiff(prev.tables, cur.tables); + if (tables.created.length > 0 && tables.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "table", + schemaName, + tables.created, + tables.deleted, + ); + } + const enums = keyDiff(prev.enums, cur.enums); + if (enums.created.length > 0 && enums.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "enum", + schemaName, + enums.created, + enums.deleted, + ); + } + for (const [key, table] of Object.entries(cur.tables)) { + const before = prev.tables[key]; + if (!before) continue; + const columns = keyDiff(before.columns, table.columns); + if (columns.created.length > 0 && columns.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "column", + key, + columns.created, + columns.deleted, + ); + } + } + return null; +} + +/** Whether drizzle-kit itself is not installed; module loaders may wrap that error. */ +function isDrizzleKitMissing(error: unknown): boolean { + let current = error; + for (let depth = 0; depth < 3 && current instanceof Error; depth++) { + if ( + (current as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND" && + /['"]drizzle-kit['"]/.test(current.message) + ) { + return true; + } + current = current.cause; + } + return false; +} + +/** + * drizzle-kit is large, so apps install it as a dev dependency and it is loaded + * only when a migration is generated. + */ +async function loadDrizzleKit(): Promise { + try { + return await import("drizzle-kit/api"); + } catch (error) { + if (!isDrizzleKitMissing(error)) throw error; + throw new Error( + `Generating migrations needs drizzle-kit, which is not installed. Add it as a dev dependency: npm install --save-dev drizzle-kit@${DRIZZLE_KIT_VERSION}`, + { cause: error }, + ); + } +} + +/** + * Diff the schema against the latest committed snapshot with drizzle-kit and + * write the next v7 migration: `NNNN_migration.sql`, `meta/NNNN_snapshot.json`, + * and the appended `meta/_journal.json`. null when nothing changed. + */ +export async function generateMigrationFiles( + options: GenerateOptions, +): Promise { + const { generateDrizzleJson, generateMigration } = await loadDrizzleKit(); + + const journal = readJournal(options.folder); + const prev = latestSnapshot(options.folder, journal); + const cur = generateDrizzleJson(drizzleKitImports(options.schema), prev.id, [ + options.schema.$schemaName, + ]) as unknown as Snapshot; + + if (!options.interactive) { + const ambiguity = findRenameAmbiguity( + prev, + cur, + options.schema.$schemaName, + ); + if (ambiguity) throw ambiguity; + } + + type KitSnapshot = Parameters[0]; + const statements = await generateMigration( + prev as unknown as KitSnapshot, + cur as unknown as KitSnapshot, + ); + if (statements.length === 0) return null; + + const entries = journal?.entries ?? []; + const last = entries.at(-1); + const idx = last ? last.idx + 1 : 0; + const tag = `${prefixOf(idx)}_migration`; + const entry: JournalEntry = { + idx, + version: "7", + // The migrator applies entries newer than the last recorded one, so `when` must increase. + when: Math.max(Date.now(), (last?.when ?? 0) + 1), + tag, + breakpoints: true, + }; + const next: Journal = { + version: "7", + dialect: "postgresql", + entries: [...entries, entry], + }; + + const file = path.join(options.folder, `${tag}.sql`); + fs.mkdirSync(path.join(options.folder, "meta"), { recursive: true }); + fs.writeFileSync( + snapshotFile(options.folder, idx), + JSON.stringify( + { ...cur, _meta: { columns: {}, schemas: {}, tables: {} } }, + null, + 2, + ), + ); + fs.writeFileSync(file, statements.join(BREAKPOINT)); + // The journal is written last: it is what makes the migration visible. + fs.writeFileSync(journalFile(options.folder), JSON.stringify(next, null, 2)); + return { tag, file, statements }; +} diff --git a/packages/appkit/src/database/tooling/naming.ts b/packages/appkit/src/database/tooling/naming.ts new file mode 100644 index 000000000..aadaf374b --- /dev/null +++ b/packages/appkit/src/database/tooling/naming.ts @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; + +// Branch names AppKit creates. Branch IDs: 1-63 chars, start with a +// lowercase letter, lowercase letters, digits and hyphens only. + +const MAX_BRANCH_ID = 63; +const DEV_PREFIX = "dev-"; +const SESSION_PREFIX = "session-"; +const HASH_LENGTH = 6; + +const SESSION_ID = /^[a-z0-9]{1,55}$/; + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * dev--. The hash keeps users who share + * a local part apart; the name is the same on every machine. + */ +export function devBranchId(userName: string): string { + const normalized = userName.trim().toLowerCase(); + const hash = createHash("sha256") + .update(normalized) + .digest("hex") + .slice(0, HASH_LENGTH); + const room = MAX_BRANCH_ID - DEV_PREFIX.length - 1 - HASH_LENGTH; + const local = slug(normalized.split("@")[0] ?? "") + .slice(0, room) + .replace(/-+$/, ""); + return `${DEV_PREFIX}${local || "user"}-${hash}`; +} + +export function isValidSessionId(sessionId: string): boolean { + return SESSION_ID.test(sessionId); +} + +export function sessionBranchId(sessionId: string): string { + if (!isValidSessionId(sessionId)) { + throw new Error( + `Invalid APPKIT_DB_SESSION ${JSON.stringify(sessionId)}: use 1-55 lowercase letters or digits.`, + ); + } + return `${SESSION_PREFIX}${sessionId}`; +} diff --git a/packages/appkit/src/database/tooling/targets.ts b/packages/appkit/src/database/tooling/targets.ts new file mode 100644 index 000000000..ff1cef3e8 --- /dev/null +++ b/packages/appkit/src/database/tooling/targets.ts @@ -0,0 +1,115 @@ +import fs from "node:fs"; +import path from "node:path"; + +import yaml from "js-yaml"; + +import { isValidSessionId } from "./naming"; + +/** Full resource names, e.g. branch = "projects/p/branches/b". */ +interface ResourceName { + readonly project: string; + readonly branch?: string; + readonly endpoint?: string; + readonly database?: string; +} + +const RESOURCE = + /^(projects\/[^/\s]+)(?:\/branches\/([^/\s]+)(?:\/(endpoints|databases)\/([^/\s]+))?)?$/; + +/** null for anything that is not a Lakebase project/branch/endpoint/database name (e.g. a placeholder). */ +export function parseResourceName( + name: string | undefined, +): ResourceName | null { + const match = name ? RESOURCE.exec(name.trim()) : null; + if (!match) return null; + const [, project, branchId, kind, leafId] = match; + if (!project) return null; + const branch = branchId ? `${project}/branches/${branchId}` : undefined; + const leaf = branch && leafId ? `${branch}/${kind}/${leafId}` : undefined; + return { + project, + ...(branch ? { branch } : {}), + ...(kind === "endpoints" && leaf ? { endpoint: leaf } : {}), + ...(kind === "databases" && leaf ? { database: leaf } : {}), + }; +} + +/** The last path segment of a resource name. */ +export function resourceId(name: string): string { + return name.slice(name.lastIndexOf("/") + 1); +} + +/** What `.env` says about the local branch. Unparseable values (template placeholders) read as null. */ +interface LocalTarget { + readonly endpoint: string | null; + readonly session: string | null; +} + +export function readLocalTarget(env: NodeJS.ProcessEnv): LocalTarget { + const endpoint = parseResourceName(env.LAKEBASE_ENDPOINT)?.endpoint ?? null; + const session = + env.APPKIT_DB_SESSION && isValidSessionId(env.APPKIT_DB_SESSION) + ? env.APPKIT_DB_SESSION + : null; + return { endpoint, session }; +} + +type Yaml = Record | undefined; + +function record(value: unknown): Yaml { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** databricks.yml, parsed; null without one. Invalid YAML throws, so no check that reads it passes silently. */ +function readBundle(root: string): Record | null { + const file = ["databricks.yml", "databricks.yaml"] + .map((name) => path.join(root, name)) + .find((candidate) => fs.existsSync(candidate)); + if (!file) return null; + try { + return record(yaml.load(fs.readFileSync(file, "utf-8"))) ?? {}; + } catch (error) { + const reason = error instanceof Error ? error.message.split("\n")[0] : ""; + throw new Error( + `Cannot read ${path.basename(file)}, so the branches it deploys to are unknown: ${reason}`, + { cause: error }, + ); + } +} + +/** A target's value for a bundle variable, else the variable's default. */ +function variableOf( + bundle: Record, + target: Yaml, + name: string, +): string | null { + const value = + record(target?.variables)?.[name] ?? + record(record(bundle.variables)?.[name])?.default ?? + null; + return typeof value === "string" && value ? value : null; +} + +/** + * Every branch databricks.yml can deploy to: each target's postgres_branch and + * the variable's default. Empty without databricks.yml or a postgres binding. + */ +export function readDeployBranches(root: string): string[] { + const bundle = readBundle(root); + if (!bundle) return []; + const targets = [ + undefined, + ...Object.values(record(bundle.targets) ?? {}).map(record), + ]; + const branches = targets + .map( + (target) => + parseResourceName( + variableOf(bundle, target, "postgres_branch") ?? undefined, + )?.branch, + ) + .filter((branch): branch is string => !!branch); + return [...new Set(branches)]; +} diff --git a/packages/appkit/src/database/tooling/tests/context.test.ts b/packages/appkit/src/database/tooling/tests/context.test.ts new file mode 100644 index 000000000..e7ac24dd5 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/context.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { createWorkspaceClient, initializeLakebasePool } = vi.hoisted(() => ({ + createWorkspaceClient: vi.fn(), + initializeLakebasePool: vi.fn(), +})); + +vi.mock("../../../connectors/lakebase", () => ({ initializeLakebasePool })); +vi.mock("../../../workspace-client", () => ({ createWorkspaceClient })); + +import { getClientOptions } from "../../../context/client-options"; +import { createDbContext, openBranchPool } from "../context"; +import type { LakebaseApi } from "../lakebase-api"; +import { cleanupTempApps, FakeLakebase, fakeContext, tempApp } from "./fakes"; + +const BRANCH = "projects/p/branches/dev-alice-3f9a2c"; +const ENDPOINT = `${BRANCH}/endpoints/primary`; +const HOST = "primary.dev-alice-3f9a2c.example.com"; +const pool = { end: vi.fn() }; + +beforeEach(() => { + initializeLakebasePool.mockReset().mockResolvedValue(pool); + createWorkspaceClient.mockReset(); +}); +afterEach(cleanupTempApps); + +function contextFor(env: NodeJS.ProcessEnv, api: LakebaseApi) { + return fakeContext({ root: tempApp(), env, api }); +} + +describe("openBranchPool", () => { + test("null without PGHOST, PGDATABASE and LAKEBASE_ENDPOINT, before any API call", async () => { + const lakebase = new FakeLakebase(); + + await expect( + openBranchPool( + contextFor({ PGHOST: HOST, PGDATABASE: "db" }, lakebase.api()), + ), + ).resolves.toBeNull(); + expect(lakebase.calls).toEqual([]); + expect(initializeLakebasePool).not.toHaveBeenCalled(); + }); + + test("connects to PGHOST once it is a host of LAKEBASE_ENDPOINT", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(BRANCH); + const ctx = contextFor( + { + PGHOST: HOST.toUpperCase(), + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + PGUSER: "alice@example.com", + }, + lakebase.api(), + ); + + await expect(openBranchPool(ctx)).resolves.toBe(pool); + expect(lakebase.calls).toEqual([`getEndpoint ${ENDPOINT}`]); + expect(initializeLakebasePool).toHaveBeenCalledWith( + expect.objectContaining({ + host: HOST.toUpperCase(), + database: "databricks_postgres", + endpoint: ENDPOINT, + user: "alice@example.com", + max: 2, + }), + ); + }); + + test("refuses another branch's host, a missing endpoint, or a failed lookup, and never connects", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(BRANCH); + const env = { + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + }; + + await expect( + openBranchPool( + contextFor( + { ...env, PGHOST: "primary.production.example.com" }, + lakebase.api(), + ), + ), + ).rejects.toThrow( + `PGHOST primary.production.example.com is not a host of LAKEBASE_ENDPOINT ${ENDPOINT} (expected ${HOST}), so it did not connect. Run \`appkit db init\` to rewrite .env.`, + ); + await expect( + openBranchPool( + contextFor( + { + ...env, + PGHOST: HOST, + LAKEBASE_ENDPOINT: `${BRANCH}/endpoints/gone`, + }, + lakebase.api(), + ), + ), + ).rejects.toThrow( + `LAKEBASE_ENDPOINT ${BRANCH}/endpoints/gone does not exist, so it did not connect.`, + ); + + const lookupFailure = new Error("Lakebase: get endpoint failed: timeout"); + const failing = { + getEndpoint: async () => { + throw lookupFailure; + }, + } as unknown as LakebaseApi; + const error = (await openBranchPool( + contextFor({ ...env, PGHOST: HOST }, failing), + ).catch((caught: unknown) => caught)) as Error; + expect(error.message).toBe( + `Could not verify that PGHOST ${HOST} serves LAKEBASE_ENDPOINT ${ENDPOINT}, so it did not connect.`, + ); + expect(error.cause).toBe(lookupFailure); + + expect(initializeLakebasePool).not.toHaveBeenCalled(); + }); +}); + +describe("createDbContext", () => { + test("the workspace client uses the .env profile and AppKit's client options, once", () => { + const legacy = { apiClient: {}, currentUser: {} }; + createWorkspaceClient.mockReturnValue({ + toLegacyWorkspaceClient: () => legacy, + }); + const ctx = createDbContext({ + root: tempApp(), + env: { DATABRICKS_CONFIG_PROFILE: "dev" }, + }); + + expect(ctx.workspace()).toBe(legacy); + expect(ctx.workspace()).toBe(legacy); + expect(createWorkspaceClient).toHaveBeenCalledOnce(); + expect(createWorkspaceClient).toHaveBeenCalledWith({ + profile: "dev", + clientOptions: getClientOptions(), + }); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts b/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts new file mode 100644 index 000000000..cda0e8a9a --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts @@ -0,0 +1,47 @@ +import { is } from "drizzle-orm"; +import { isPgEnum, PgSchema, PgTable } from "drizzle-orm/pg-core"; +import { describe, expect, test } from "vitest"; + +import { defineSchema, enumColumn, fk, id, text } from "../../schema-builder"; +import { drizzleKitImports } from "../drizzle-exports"; + +describe("drizzleKitImports", () => { + const schema = defineSchema(({ table }) => { + const users = table("users", { + id: id(), + role: enumColumn("user_role", ["admin", "member"]), + backup: enumColumn("user_role", ["admin", "member"]), + }); + const posts = table("posts", { + id: id(), + author: fk(() => users.id), + title: text(), + }); + return { users, posts }; + }); + + test("exports every engine table and each referenced enum once", () => { + const values = Object.values(drizzleKitImports(schema)); + expect(values.filter((value) => is(value, PgTable))).toEqual([ + schema.$engine.users, + schema.$engine.posts, + ]); + const enums = values.filter((value) => isPgEnum(value)); + expect(enums).toHaveLength(1); + const [userRole] = enums; + expect(userRole.enumName).toBe("user_role"); + expect(userRole.schema).toBe("app"); + expect(userRole.enumValues).toEqual(["admin", "member"]); + }); + + test("never exports the pgSchema object, so drizzle-kit emits no CREATE SCHEMA", () => { + const values = Object.values(drizzleKitImports(schema)); + expect(values.some((value) => is(value, PgSchema))).toBe(false); + }); + + test("an empty schema has nothing to export", () => { + expect(Object.keys(drizzleKitImports(defineSchema(() => ({}))))).toEqual( + [], + ); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts new file mode 100644 index 000000000..ec9bcb2d1 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { defineSchema, id } from "../../schema-builder"; + +const SRC = path.resolve(import.meta.dirname, "../../.."); +const folders: string[] = []; + +afterEach(() => { + vi.doUnmock("drizzle-kit/api"); + vi.resetModules(); + for (const folder of folders.splice(0)) { + fs.rmSync(folder, { recursive: true, force: true }); + } +}); + +/** Load ../migrations with `import("drizzle-kit/api")` failing like Node's resolver does. */ +async function withoutPackage(name: string) { + vi.resetModules(); + vi.doMock("drizzle-kit/api", () => { + throw Object.assign( + new Error( + `Cannot find package '${name}' imported from /app/node_modules/@databricks/appkit/dist/database/tooling/migrations.js`, + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + }); + return import("../migrations"); +} + +function generate(migrations: typeof import("../migrations")) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-drizzle-kit-")); + folders.push(root); + return migrations.generateMigrationFiles({ + folder: path.join(root, "migrations"), + schema: defineSchema(({ table }) => ({ + notes: table("notes", { id: id() }), + })), + interactive: false, + }); +} + +/** Source files outside tests, relative to src/. */ +function sourceFiles(): string[] { + return (fs.readdirSync(SRC, { recursive: true }) as string[]).filter( + (file) => + /\.tsx?$/.test(file) && + !/\.test\.tsx?$/.test(file) && + !file.split(path.sep).includes("tests"), + ); +} + +describe("drizzle-kit dependency", () => { + test("without drizzle-kit, generating says how to install it", async () => { + const migrations = await withoutPackage("drizzle-kit"); + + await expect(generate(migrations)).rejects.toThrow( + `Generating migrations needs drizzle-kit, which is not installed. Add it as a dev dependency: npm install --save-dev drizzle-kit@${migrations.DRIZZLE_KIT_VERSION}`, + ); + }); + + test("another missing package is not reported as a missing drizzle-kit", async () => { + const migrations = await withoutPackage("esbuild"); + + const error = (await generate(migrations).catch( + (caught: unknown) => caught, + )) as Error; + expect(error.message).not.toContain("npm install"); + }); + + test("it is an optional peer dependency pinned to DRIZZLE_KIT_VERSION, never a dependency", async () => { + const { DRIZZLE_KIT_VERSION } = await import("../migrations"); + const pkg = JSON.parse( + fs.readFileSync(path.resolve(SRC, "../package.json"), "utf-8"), + ); + + expect(pkg.dependencies["drizzle-kit"]).toBeUndefined(); + expect(pkg.peerDependencies["drizzle-kit"]).toBe(DRIZZLE_KIT_VERSION); + expect(pkg.peerDependenciesMeta["drizzle-kit"]).toEqual({ optional: true }); + expect(pkg.devDependencies["drizzle-kit"]).toBe(DRIZZLE_KIT_VERSION); + }); + + test("only the tooling imports drizzle-kit, and no runtime module imports the tooling", () => { + const imports = (file: string, pattern: RegExp) => + pattern.test(fs.readFileSync(path.join(SRC, file), "utf-8")); + const tooling = path.join("database", "tooling"); + + expect( + sourceFiles().filter((file) => + imports(file, /(?:from\s+|import\s*\(\s*)["']drizzle-kit/), + ), + ).toEqual([path.join(tooling, "migrations.ts")]); + expect( + sourceFiles().filter( + (file) => + !file.startsWith(`${tooling}${path.sep}`) && + imports(file, /from\s+["'][^"']*\/tooling(?:\/[^"']*)?["']/), + ), + ).toEqual([]); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/fakes.ts b/packages/appkit/src/database/tooling/tests/fakes.ts new file mode 100644 index 000000000..c10e139af --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/fakes.ts @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { DbContext } from "../context"; +import type { + LakebaseApi, + LakebaseBranch, + LakebaseEndpoint, +} from "../lakebase-api"; + +/** In-memory Lakebase: the branches the guard looks up, and their endpoints. */ +export class FakeLakebase { + readonly branches = new Map(); + readonly endpoints = new Map(); + readonly calls: string[] = []; + + addBranch( + name: string, + status: LakebaseBranch["status"] = {}, + ): LakebaseBranch { + const branch = { name, status: { current_state: "READY", ...status } }; + this.branches.set(name, branch); + this.endpoints.set(name, [this.endpoint(name, "primary")]); + return branch; + } + + endpoint(branch: string, id: string): LakebaseEndpoint { + return { + name: `${branch}/endpoints/${id}`, + status: { + hosts: { host: `${id}.${branch.split("/").pop()}.example.com` }, + }, + }; + } + + api(): LakebaseApi { + const fake = this; + const api = { + async getBranch(name: string) { + fake.calls.push(`getBranch ${name}`); + return fake.branches.get(name) ?? null; + }, + async getEndpoint(name: string) { + fake.calls.push(`getEndpoint ${name}`); + const branch = name.slice(0, name.indexOf("/endpoints/")); + return ( + fake.endpoints + .get(branch) + ?.find((endpoint) => endpoint.name === name) ?? null + ); + }, + }; + return api as unknown as LakebaseApi; + } +} + +const roots: string[] = []; + +/** A temp app root with optional files (relative path → contents). */ +export function tempApp(files: Record = {}): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-tooling-")); + roots.push(root); + for (const [file, contents] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), contents); + } + return root; +} + +export function cleanupTempApps(): void { + for (const root of roots.splice(0)) + fs.rmSync(root, { recursive: true, force: true }); +} + +export function fakeContext(options: { + root: string; + env?: NodeJS.ProcessEnv; + userName?: string; + api?: LakebaseApi; +}): DbContext { + const userName = options.userName ?? "alice@example.com"; + return { + root: options.root, + env: { ...options.env }, + workspace: () => ({ + apiClient: { + request: async () => { + throw new Error("no raw API calls expected"); + }, + }, + currentUser: { me: async () => ({ userName }) }, + }), + api: () => { + if (!options.api) throw new Error("no Lakebase API calls expected"); + return options.api; + }, + }; +} + +/** The bundle `databricks apps init` writes for a database app. */ +export function databricksYml(branch: string, database: string): string { + return `bundle: + name: my-app +variables: + postgres_branch: + description: Lakebase branch resource name + postgres_database: + description: Lakebase database resource name +resources: + apps: + app: + name: "my-app" + source_code_path: ./ +targets: + default: + default: true + variables: + postgres_branch: ${branch} + postgres_database: ${database} +`; +} diff --git a/packages/appkit/src/database/tooling/tests/guard.test.ts b/packages/appkit/src/database/tooling/tests/guard.test.ts new file mode 100644 index 000000000..175f9823c --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/guard.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { checkAppkitBranch, classifyBranch } from "../guard"; +import { devBranchId } from "../naming"; +import { + cleanupTempApps, + databricksYml, + FakeLakebase, + fakeContext, + tempApp, +} from "./fakes"; + +afterEach(cleanupTempApps); + +const USER = "alice@example.com"; +const P = "projects/p"; +const dev = `${P}/branches/${devBranchId(USER)}`; + +function verdict( + name: string, + status = {}, + sessionId: string | null = "k2x9q0ab", + deployBranches: string[] = [], +) { + return classifyBranch({ + branch: { name, status }, + userName: USER, + sessionId, + deployBranches, + }); +} + +describe("classifyBranch", () => { + test("only this developer's dev branch and this checkout's session branch are AppKit-owned", () => { + expect(verdict(dev)).toEqual({ appkitOwned: true }); + expect(verdict(`${P}/branches/session-k2x9q0ab`)).toEqual({ + appkitOwned: true, + }); + }); + + test("everything else is refused, with the reason", () => { + expect( + verdict(`${P}/branches/production`, { default: true }), + ).toMatchObject({ + appkitOwned: false, + reason: "it is the project's default branch", + }); + expect(verdict(dev, { is_protected: true })).toMatchObject({ + appkitOwned: false, + reason: "it is a protected branch", + }); + expect( + verdict(dev, {}, null, [`${P}/branches/staging`, dev]), + ).toMatchObject({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + expect( + verdict(`${P}/branches/${devBranchId("bob@example.com")}`).appkitOwned, + ).toBe(false); + expect(verdict(`${P}/branches/session-other`).appkitOwned).toBe(false); + expect( + verdict(`${P}/branches/session-k2x9q0ab`, {}, null).appkitOwned, + ).toBe(false); + expect(verdict(`${P}/branches/staging`).reason).toContain( + devBranchId(USER), + ); + }); +}); + +describe("checkAppkitBranch", () => { + const envFor = (branch: string) => ({ + LAKEBASE_ENDPOINT: `${branch}/endpoints/primary`, + }); + + test("classifies the .env branch through the API, without connecting", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + lakebase.addBranch(`${P}/branches/production`, { default: true }); + + const onDev = await checkAppkitBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ); + expect(onDev).toMatchObject({ + branch: { name: dev }, + verdict: { appkitOwned: true }, + }); + + const onDefault = await checkAppkitBranch( + fakeContext({ + root: tempApp(), + env: envFor(`${P}/branches/production`), + api: lakebase.api(), + }), + ); + expect(onDefault?.verdict).toEqual({ + appkitOwned: false, + reason: "it is the project's default branch", + }); + expect(lakebase.calls.every((call) => call.startsWith("getBranch"))).toBe( + true, + ); + }); + + test("a branch databricks.yml deploys to is never AppKit-owned", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const root = tempApp({ + "databricks.yml": databricksYml( + dev, + `${dev}/databases/databricks-postgres`, + ), + }); + + const check = await checkAppkitBranch( + fakeContext({ root, env: envFor(dev), api: lakebase.api() }), + ); + + expect(check?.verdict).toEqual({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + }); + + test("a non-default target counts too, and an unreadable databricks.yml refuses", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const personalTarget = tempApp({ + "databricks.yml": `${databricksYml(`${P}/branches/production`, `${P}/branches/production/databases/db`)} personal: + variables: + postgres_branch: ${dev} +`, + }); + const broken = tempApp({ "databricks.yml": "targets:\n default: [\n" }); + + const check = await checkAppkitBranch( + fakeContext({ + root: personalTarget, + env: envFor(dev), + api: lakebase.api(), + }), + ); + expect(check?.verdict).toEqual({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + await expect( + checkAppkitBranch( + fakeContext({ root: broken, env: envFor(dev), api: lakebase.api() }), + ), + ).rejects.toThrow( + /^Cannot read databricks\.yml, so the branches it deploys to are unknown: /, + ); + }); + + test("null without a configured branch; a deleted branch points at init", async () => { + const lakebase = new FakeLakebase(); + await expect( + checkAppkitBranch(fakeContext({ root: tempApp(), api: lakebase.api() })), + ).resolves.toBeNull(); + await expect( + checkAppkitBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ), + ).rejects.toThrow(/does not exist.*appkit db init/); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/identity.test.ts b/packages/appkit/src/database/tooling/tests/identity.test.ts new file mode 100644 index 000000000..d600a844b --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/identity.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { currentIdentity } from "../identity"; +import { cleanupTempApps, fakeContext, tempApp } from "./fakes"; + +afterEach(cleanupTempApps); + +describe("identity", () => { + test("currentUser.me() is called once per context", async () => { + const ctx = fakeContext({ root: tempApp() }); + const me = vi.fn(async () => ({ userName: "alice@example.com" })); + const client = { ...ctx.workspace(), currentUser: { me } }; + const counted = { ...ctx, workspace: () => client }; + + await expect(currentIdentity(counted)).resolves.toEqual({ + userName: "alice@example.com", + }); + await currentIdentity(counted); + expect(me).toHaveBeenCalledOnce(); + }); + + test("no userName is an error that points at the profile", async () => { + const ctx = fakeContext({ root: tempApp() }); + const client = { + ...ctx.workspace(), + currentUser: { me: async () => ({}) }, + }; + await expect( + currentIdentity({ ...ctx, workspace: () => client }), + ).rejects.toThrow(/DATABRICKS_CONFIG_PROFILE/); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts new file mode 100644 index 000000000..22adaa0f3 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "vitest"; + +import type { ToolingRequest } from "../context"; +import { LakebaseApi, LakebaseApiError } from "../lakebase-api"; + +type Reply = unknown | ((request: ToolingRequest) => unknown); + +/** apiClient fake: replies in order, records each request. */ +function client(replies: Reply[]) { + const requests: ToolingRequest[] = []; + return { + requests, + apiClient: { + async request(request: ToolingRequest) { + requests.push(request); + if (replies.length === 0) + throw new Error( + `unexpected request ${request.method} ${request.path}`, + ); + const reply = replies.shift(); + const value = + typeof reply === "function" + ? (reply as (r: ToolingRequest) => unknown)(request) + : reply; + if (value instanceof Error) throw value; + return value; + }, + }, + }; +} + +const apiError = (statusCode: number, message: string) => + Object.assign(new Error(message), { statusCode, errorCode: "X" }); + +describe("LakebaseApi", () => { + test("getBranch: GET /api/2.0/postgres/{name}; 404 → null; other errors keep their status", async () => { + const fake = client([ + { name: "projects/p/branches/b" }, + apiError(404, "NOT_FOUND"), + apiError(403, "PERMISSION_DENIED"), + ]); + const api = new LakebaseApi(fake); + + await expect(api.getBranch("projects/p/branches/b")).resolves.toEqual({ + name: "projects/p/branches/b", + }); + await expect( + api.getBranch("projects/p/branches/missing"), + ).resolves.toBeNull(); + const error = await api + .getBranch("projects/p/branches/x") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(LakebaseApiError); + expect(error).toMatchObject({ + statusCode: 403, + operation: "get branch projects/p/branches/x", + }); + + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b", + method: "GET", + raw: false, + }); + expect(fake.requests[0]?.headers.get("Accept")).toBe("application/json"); + expect(fake.requests[0]?.headers.get("Content-Type")).toBeNull(); + }); + + test("getEndpoint: GET /api/2.0/postgres/{name}; 404 → null", async () => { + const endpoint = { + name: "projects/p/branches/b/endpoints/primary", + status: { hosts: { host: "ep-1.example.com" } }, + }; + const fake = client([endpoint, apiError(404, "NOT_FOUND")]); + const api = new LakebaseApi(fake); + + await expect(api.getEndpoint(endpoint.name)).resolves.toEqual(endpoint); + await expect( + api.getEndpoint("projects/p/branches/b/endpoints/gone"), + ).resolves.toBeNull(); + expect( + fake.requests.map((request) => [request.method, request.path]), + ).toEqual([ + ["GET", "/api/2.0/postgres/projects/p/branches/b/endpoints/primary"], + ["GET", "/api/2.0/postgres/projects/p/branches/b/endpoints/gone"], + ]); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/migrate.test.ts b/packages/appkit/src/database/tooling/tests/migrate.test.ts new file mode 100644 index 000000000..93f0eb500 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrate.test.ts @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { applyPendingMigrations, checkAppkitBranch, openBranchPool } = + vi.hoisted(() => ({ + applyPendingMigrations: vi.fn(), + checkAppkitBranch: vi.fn(), + openBranchPool: vi.fn(), + })); + +vi.mock("../../migrations/apply", () => ({ applyPendingMigrations })); +vi.mock("../guard", () => ({ checkAppkitBranch })); +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); + +import type { DbContext } from "../context"; +import { runMigrate } from "../migrate"; + +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); +const roots: string[] = []; + +function app(tables: string): DbContext { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-migrate-")); + roots.push(root); + const file = path.join(root, "config/database/schema.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `import { defineSchema, id, text } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ ${tables} }));`, + ); + return { + root, + env: {}, + workspace: () => { + throw new Error("no workspace calls expected"); + }, + api: () => { + throw new Error("no Lakebase API calls expected"); + }, + }; +} + +const devBranch = { + branch: { name: "projects/p/branches/dev-alice-3f9a2c" }, + verdict: { appkitOwned: true }, +}; + +const pool = { end: vi.fn(async () => undefined) }; + +beforeEach(() => { + applyPendingMigrations.mockReset(); + openBranchPool.mockReset(); + checkAppkitBranch.mockReset(); + checkAppkitBranch.mockResolvedValue(devBranch); + pool.end.mockClear(); +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("runMigrate", () => { + test("without schema.ts it points to introspect before touching anything", async () => { + const ctx = app(""); + fs.rmSync(path.join(ctx.root, "config/database/schema.ts")); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( + "No config/database/schema.ts yet, so there is nothing to migrate. Run `appkit db introspect` to write it from the branch's tables, or create it.", + ); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect( + fs.existsSync(path.join(ctx.root, "config/database/migrations")), + ).toBe(false); + }); + + test("an empty schema generates nothing, applies nothing, and still writes database.d.ts", async () => { + const ctx = app(""); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated).toBeNull(); + expect(report.applied).toEqual([]); + expect(report.notApplied).toMatch(/no migrations/); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect(report.typesFile).toBe( + path.join(ctx.root, "shared/appkit-types/database.d.ts"), + ); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("generates, regenerates types, applies to the .env branch with the app schema, and ends the pool", async () => { + const ctx = app(`notes: table("notes", { id: id(), body: text() })`); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(openBranchPool).toHaveBeenCalledWith(ctx); + expect(applyPendingMigrations).toHaveBeenCalledWith(pool, { + migrationsFolder: path.join(ctx.root, "config/database/migrations"), + schemaName: "app", + }); + expect(report.applied).toEqual(["0000_migration"]); + expect(report.notApplied).toBeUndefined(); + expect(pool.end).toHaveBeenCalledOnce(); + expect(fs.readFileSync(report.typesFile, "utf-8")).toContain("notes"); + }); + + test("applies committed migrations even when schema.ts did not change", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + openBranchPool.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated).toBeNull(); + expect(report.applied).toEqual(["0000_migration"]); + }); + + test("without a configured branch it still generates, and says why nothing was applied", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(applyPendingMigrations).not.toHaveBeenCalled(); + expect(report.notApplied).toMatch(/appkit db init/); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("on a branch AppKit did not create for you it generates, never connects, and says why", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue({ + branch: { name: "projects/p/branches/production" }, + verdict: { + appkitOwned: false, + reason: "it is the project's default branch", + }, + }); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(applyPendingMigrations).not.toHaveBeenCalled(); + expect(report.notApplied).toBe( + "production is not an AppKit dev or session branch (it is the project's default branch). Migrations are applied only to branches `appkit db init` created for you; the deployed app applies committed migrations to its own branch", + ); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("reports a baseline adopted without running it", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: [], + adopted: ["0000_baseline"], + }); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report).toMatchObject({ applied: [], adopted: ["0000_baseline"] }); + }); + + test("an apply failure ends the pool and says the generated migration was written but not applied", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + openBranchPool.mockResolvedValue(pool); + const failure = new Error("apply failed"); + applyPendingMigrations.mockRejectedValue(failure); + + const error = (await runMigrate(ctx, { interactive: false }).catch( + (caught: unknown) => caught, + )) as Error; + + expect(error.message).toBe( + `Wrote ${path.join("config/database/migrations", "0000_migration.sql")}, but did not apply it: apply failed`, + ); + expect(error.cause).toBe(failure); + expect(pool.end).toHaveBeenCalledOnce(); + expect( + fs.existsSync( + path.join(ctx.root, "config/database/migrations/0000_migration.sql"), + ), + ).toBe(true); + expect( + fs.readFileSync( + path.join(ctx.root, "shared/appkit-types/database.d.ts"), + "utf-8", + ), + ).toContain("notes"); + }); + + test("a guard failure after generating says so too, and never connects", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockRejectedValue( + new Error("The .env branch projects/p/branches/dev-a does not exist"), + ); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( + /^Wrote .*0000_migration\.sql, but did not apply it: The \.env branch .* does not exist$/, + ); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("a failure with nothing generated propagates unchanged", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + const failure = new Error("apply failed"); + applyPendingMigrations.mockRejectedValue(failure); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toBe(failure); + }); + + test("a schema.ts that fails to import names the import error, not the runtime hint", async () => { + const ctx = app(`notes: table("notes", { id: id( })`); + + const error = (await runMigrate(ctx, { interactive: false }).catch( + (caught: unknown) => caught, + )) as Error; + + expect(error.message).toBe("Could not load config/database/schema.ts."); + expect((error.cause as Error).message).toMatch(/schema\.ts:\d+:\d+/); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/migrations.test.ts b/packages/appkit/src/database/tooling/tests/migrations.test.ts new file mode 100644 index 000000000..39c8bf193 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrations.test.ts @@ -0,0 +1,343 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { readMigrationFiles } from "drizzle-orm/migrator"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { readJournal } from "../../migrations/paths"; +import { + bigid, + boolean, + defineSchema, + enumColumn, + fk, + id, + integer, + text, + timestamp, + varchar, +} from "../../schema-builder"; +import { + generateMigrationFiles, + MigrationRenameAmbiguityError, +} from "../migrations"; + +const folders: string[] = []; + +function tempFolder(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-migrations-")); + folders.push(root); + return path.join(root, "config/database/migrations"); +} + +/** Every file under a folder, relative, sorted — to prove nothing was written. */ +function listFiles(folder: string): string[] { + if (!fs.existsSync(folder)) return []; + return (fs.readdirSync(folder, { recursive: true }) as string[]) + .filter((entry) => fs.statSync(path.join(folder, entry)).isFile()) + .sort(); +} + +afterEach(() => { + vi.useRealTimers(); + for (const folder of folders.splice(0)) { + fs.rmSync(folder, { recursive: true, force: true }); + } +}); + +const blog = (extraPostColumns = {}) => + defineSchema(({ table }) => { + const authors = table("authors", { + id: id(), + email: text().notNull().unique(), + role: enumColumn("author_role", ["admin", "writer"]) + .notNull() + .default("writer"), + createdAt: timestamp({ withTimezone: true }).defaultNow(), + }); + const posts = table("posts", { + id: bigid(), + author: fk(() => authors.id).onDelete("cascade"), + title: varchar(120).notNull(), + published: boolean().default(false), + ...extraPostColumns, + }); + return { authors, posts }; + }); + +describe("generateMigrationFiles", () => { + test("writes a v7 migration from real drizzle-kit output for the builder's tables", async () => { + const folder = tempFolder(); + const generated = await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + + expect(generated?.tag).toBe("0000_migration"); + expect(generated?.file).toBe(path.join(folder, "0000_migration.sql")); + const sql = fs.readFileSync( + path.join(folder, "0000_migration.sql"), + "utf-8", + ); + + expect(sql).toContain( + `CREATE TYPE "app"."author_role" AS ENUM('admin', 'writer');`, + ); + expect(sql).toContain(`CREATE TABLE "app"."authors"`); + expect(sql).toContain(`CREATE TABLE "app"."posts"`); + expect(sql).toMatch( + /"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY/, + ); + expect(sql).toMatch( + /"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY/, + ); + expect(sql).toContain( + `"role" "app"."author_role" DEFAULT 'writer' NOT NULL`, + ); + expect(sql).toMatch( + /FOREIGN KEY \("author"\) REFERENCES "app"\."authors"\("id"\) ON DELETE cascade/, + ); + // The migrator creates the schema as the identity that applies it; generated SQL never does. + expect(sql).not.toMatch(/CREATE SCHEMA/i); + expect(sql).toContain("--> statement-breakpoint\n"); + + expect(listFiles(folder)).toEqual([ + "0000_migration.sql", + path.join("meta", "0000_snapshot.json"), + path.join("meta", "_journal.json"), + ]); + const journal = readJournal(folder); + expect(journal).toMatchObject({ + version: "7", + dialect: "postgresql", + entries: [ + { idx: 0, version: "7", tag: "0000_migration", breakpoints: true }, + ], + }); + const snapshot = JSON.parse( + fs.readFileSync(path.join(folder, "meta/0000_snapshot.json"), "utf-8"), + ); + expect(Object.keys(snapshot.tables)).toEqual(["app.authors", "app.posts"]); + expect(snapshot.prevId).toBe("00000000-0000-0000-0000-000000000000"); + }); + + test("returns null and writes nothing when the schema matches the latest snapshot", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + const before = listFiles(folder).map((file) => [ + file, + fs.readFileSync(path.join(folder, file), "utf-8"), + ]); + + await expect( + generateMigrationFiles({ folder, schema: blog(), interactive: false }), + ).resolves.toBeNull(); + expect( + listFiles(folder).map((file) => [ + file, + fs.readFileSync(path.join(folder, file), "utf-8"), + ]), + ).toEqual(before); + }); + + test("an added column produces the next migration with an ALTER TABLE", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + + // A clock that did not move must still yield a newer journal entry. + const next = await generateMigrationFiles({ + folder, + schema: blog({ views: integer().notNull().default(0) }), + interactive: false, + }); + + expect(next?.tag).toBe("0001_migration"); + expect(next?.statements).toEqual([ + `ALTER TABLE "app"."posts" ADD COLUMN "views" integer DEFAULT 0 NOT NULL;`, + ]); + const journal = readJournal(folder); + expect(journal?.entries.map((entry) => entry.tag)).toEqual([ + "0000_migration", + "0001_migration", + ]); + const [first, second] = journal?.entries ?? []; + expect(second.when).toBeGreaterThan(first.when); + const prev = JSON.parse( + fs.readFileSync(path.join(folder, "meta/0000_snapshot.json"), "utf-8"), + ); + const cur = JSON.parse( + fs.readFileSync(path.join(folder, "meta/0001_snapshot.json"), "utf-8"), + ); + expect(cur.prevId).toBe(prev.id); + }); + + test("a non-interactive column rename throws instead of prompting, and writes nothing", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog({ summary: text() }), + interactive: false, + }); + const before = listFiles(folder); + + const error = await generateMigrationFiles({ + folder, + schema: blog({ abstract: text() }), + interactive: false, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MigrationRenameAmbiguityError); + expect(error).toMatchObject({ + kind: "column", + table: "app.posts", + created: ["abstract"], + deleted: ["summary"], + }); + expect((error as Error).message).toMatch(/interactive terminal/); + expect(listFiles(folder)).toEqual(before); + }); + + test("a non-interactive table rename throws too", async () => { + const folder = tempFolder(); + const named = (name: string) => + defineSchema(({ table }) => ({ [name]: table(name, { id: id() }) })); + await generateMigrationFiles({ + folder, + schema: named("notes"), + interactive: false, + }); + + await expect( + generateMigrationFiles({ + folder, + schema: named("memos"), + interactive: false, + }), + ).rejects.toMatchObject({ + name: "MigrationRenameAmbiguityError", + kind: "table", + created: ["app.memos"], + deleted: ["app.notes"], + }); + }); + + test("an empty schema with no history writes nothing", async () => { + const folder = tempFolder(); + await expect( + generateMigrationFiles({ + folder, + schema: defineSchema(() => ({})), + interactive: false, + }), + ).resolves.toBeNull(); + expect(listFiles(folder)).toEqual([]); + }); + + test("drizzle-orm's runtime migrator reads the written files", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + await generateMigrationFiles({ + folder, + schema: blog({ views: integer() }), + interactive: false, + }); + + const migrations = readMigrationFiles({ migrationsFolder: folder }); + const journal = readJournal(folder); + expect(migrations.map((migration) => migration.folderMillis)).toEqual( + journal?.entries.map((entry) => entry.when), + ); + // Four statements: the enum, two tables, the FK. + expect(migrations[0].sql).toHaveLength(4); + expect(migrations[1].sql).toEqual([ + `ALTER TABLE "app"."posts" ADD COLUMN "views" integer;`, + ]); + }); + + test("a non-interactive enum rename throws too, and writes nothing", async () => { + const folder = tempFolder(); + const roles = (enumName: string) => + defineSchema(({ table }) => ({ + users: table("users", { + id: id(), + role: enumColumn(enumName, ["admin", "member"]), + }), + })); + await generateMigrationFiles({ + folder, + schema: roles("user_role"), + interactive: false, + }); + const before = listFiles(folder); + + const error = await generateMigrationFiles({ + folder, + schema: roles("member_role"), + interactive: false, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MigrationRenameAmbiguityError); + expect(error).toMatchObject({ + kind: "enum", + created: ["app.member_role"], + deleted: ["app.user_role"], + }); + expect(listFiles(folder)).toEqual(before); + }); + + test("refuses to diff when the latest snapshot is missing", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + fs.rmSync(path.join(folder, "meta/0000_snapshot.json")); + await expect( + generateMigrationFiles({ + folder, + schema: blog({ views: integer() }), + interactive: false, + }), + ).rejects.toThrow(/Restore it from version control/); + }); + + test("a corrupt snapshot names the file and how to recover", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + const snapshot = path.join(folder, "meta/0000_snapshot.json"); + fs.writeFileSync(snapshot, "{ not json"); + + await expect( + generateMigrationFiles({ + folder, + schema: blog({ views: integer() }), + interactive: false, + }), + ).rejects.toThrow( + new RegExp( + `^Cannot read ${snapshot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: .+\\. Restore it from version control\\.$`, + ), + ); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/naming.test.ts b/packages/appkit/src/database/tooling/tests/naming.test.ts new file mode 100644 index 000000000..423f33ad0 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/naming.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; + +import { devBranchId, isValidSessionId, sessionBranchId } from "../naming"; + +const BRANCH_ID = /^[a-z][a-z0-9-]{0,62}$/; + +describe("branch naming", () => { + test("dev branches are dev--<6 hex>, stable and case-insensitive", () => { + const id = devBranchId("Alice.Smith@example.com"); + expect(id).toMatch(/^dev-alice-smith-[0-9a-f]{6}$/); + expect(devBranchId("alice.smith@example.com")).toBe(id); + expect(id).toMatch(BRANCH_ID); + }); + + test("users sharing a local part get different branches", () => { + expect(devBranchId("alice@example.com")).not.toBe( + devBranchId("alice@example.org"), + ); + }); + + test("long or odd local parts stay within the 63-character limit", () => { + const id = devBranchId(`${"x".repeat(80)}@example.com`); + expect(id.length).toBeLessThanOrEqual(63); + expect(id).toMatch(BRANCH_ID); + expect(devBranchId("__@example.com")).toMatch(/^dev-user-[0-9a-f]{6}$/); + }); + + test("a service principal's client ID makes a valid branch ID too", () => { + expect(devBranchId("11111111-2222-3333-4444-555555555555")).toMatch( + BRANCH_ID, + ); + }); + + test("session branches are session-; ids are lowercase base36", () => { + expect(sessionBranchId("k2x9q0ab")).toBe("session-k2x9q0ab"); + expect(isValidSessionId("Bad-Id")).toBe(false); + expect(() => sessionBranchId("Bad-Id")).toThrow(/APPKIT_DB_SESSION/); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/targets.test.ts b/packages/appkit/src/database/tooling/tests/targets.test.ts new file mode 100644 index 000000000..ac159a7df --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/targets.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { + parseResourceName, + readDeployBranches, + readLocalTarget, +} from "../targets"; +import { cleanupTempApps, databricksYml, tempApp } from "./fakes"; + +afterEach(cleanupTempApps); + +describe("parseResourceName", () => { + test("splits project, branch, endpoint and database names", () => { + expect( + parseResourceName("projects/p/branches/b/endpoints/primary"), + ).toEqual({ + project: "projects/p", + branch: "projects/p/branches/b", + endpoint: "projects/p/branches/b/endpoints/primary", + }); + expect( + parseResourceName("projects/p/branches/b/databases/databricks-postgres"), + ).toEqual({ + project: "projects/p", + branch: "projects/p/branches/b", + database: "projects/p/branches/b/databases/databricks-postgres", + }); + expect(parseResourceName("projects/p")).toEqual({ project: "projects/p" }); + }); + + test("template placeholders and junk are null", () => { + expect(parseResourceName("your_postgres_endpointPath")).toBeNull(); + expect(parseResourceName("")).toBeNull(); + expect(parseResourceName(undefined)).toBeNull(); + }); +}); + +describe("readLocalTarget", () => { + test("reads the .env endpoint and this checkout's session", () => { + expect( + readLocalTarget({ + LAKEBASE_ENDPOINT: "projects/p/branches/b/endpoints/primary", + PGHOST: "ep.example.com", + APPKIT_DB_SESSION: "k2x9q0ab", + }), + ).toEqual({ + endpoint: "projects/p/branches/b/endpoints/primary", + session: "k2x9q0ab", + }); + }); + + test("placeholders from .env.example read as not configured", () => { + expect( + readLocalTarget({ + LAKEBASE_ENDPOINT: "your_postgres_endpointPath", + APPKIT_DB_SESSION: "NOT VALID", + }), + ).toEqual({ endpoint: null, session: null }); + }); +}); + +describe("readDeployBranches", () => { + test("reads the default target's postgres_branch", () => { + const root = tempApp({ + "databricks.yml": databricksYml( + "projects/p/branches/production", + "projects/p/branches/production/databases/databricks-postgres", + ), + }); + expect(readDeployBranches(root)).toEqual([ + "projects/p/branches/production", + ]); + }); + + test("every target's branch counts, and the variable's default", () => { + const root = tempApp({ + "databricks.yml": `variables: + postgres_branch: + default: projects/p/branches/main +targets: + default: + default: true + personal: + variables: + postgres_branch: projects/p/branches/dev-alice-1a2b3c + staging: + variables: + postgres_branch: projects/p/branches/staging +`, + }); + expect(readDeployBranches(root)).toEqual([ + "projects/p/branches/main", + "projects/p/branches/dev-alice-1a2b3c", + "projects/p/branches/staging", + ]); + }); + + test("empty without databricks.yml or without a postgres binding", () => { + expect(readDeployBranches(tempApp())).toEqual([]); + expect( + readDeployBranches(tempApp({ "databricks.yml": "bundle:\n name: x\n" })), + ).toEqual([]); + }); + + test("an invalid databricks.yml throws instead of reading as no binding", () => { + const root = tempApp({ "databricks.yml": "targets:\n default: [\n" }); + expect(() => readDeployBranches(root)).toThrow( + /^Cannot read databricks\.yml, so the branches it deploys to are unknown: .+\(\d+:\d+\)$/, + ); + }); +}); diff --git a/packages/appkit/src/plugins/database/load-schema.ts b/packages/appkit/src/plugins/database/load-schema.ts index 9378dfd6f..fa9f3ef0f 100644 --- a/packages/appkit/src/plugins/database/load-schema.ts +++ b/packages/appkit/src/plugins/database/load-schema.ts @@ -8,9 +8,15 @@ import { assertFinalizedSchema } from "../../database/schema-builder/define-sche /** Same application-relative convention used by appkit generate-types. */ const DEFAULT_SCHEMA_FILE = "config/database/schema.ts"; +interface LoadSchemaOptions { + /** Report why the module failed to import. Only for local CLI runs: see the catch below. */ + readonly showImportError?: boolean; +} + /** Load application-owned declarations during setup, never during registration. */ export async function loadDefaultDatabaseSchema( root = process.cwd(), + options: LoadSchemaOptions = {}, ): Promise { const file = path.resolve(root, DEFAULT_SCHEMA_FILE); const label = JSON.stringify(file); @@ -34,8 +40,14 @@ export async function loadDefaultDatabaseSchema( module = await createJiti(import.meta.url, { moduleCache: false }).import( file, ); - } catch { + } catch (error) { // Import errors can contain credentials, row values, or application internals. + // A CLI run shows them to the developer who wrote the file; the runtime never does. + if (options.showImportError) { + throw new Error(`Could not load ${DEFAULT_SCHEMA_FILE}.`, { + cause: error, + }); + } throw databaseSetupFailed( `Could not load the default schema at ${label}. Check the module and its imports, or pass database({ schema }).`, ); diff --git a/packages/appkit/src/plugins/database/schema-check.ts b/packages/appkit/src/plugins/database/schema-check.ts index 9b6f7176a..841dcc19c 100644 --- a/packages/appkit/src/plugins/database/schema-check.ts +++ b/packages/appkit/src/plugins/database/schema-check.ts @@ -76,7 +76,7 @@ export async function assertSchemaMatchesDatabase( } if (problems.length > 0) { throw databaseSetupFailed( - `the declared schema does not match the database: ${problems.join("; ")}. Create them, or commit migrations under config/database/migrations so the deployed app applies them on start.`, + `the declared schema does not match the database: ${problems.join("; ")}. Run \`npx appkit db migrate\` locally, or commit migrations under config/database/migrations so the deployed app applies them on start.`, ); } } diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index bb17c29ba..d11a7b42d 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -14,6 +14,7 @@ export default defineConfig([ "src/testing/index.ts", "src/tsdown/index.ts", "src/type-generator/index.ts", + "src/database/tooling/index.ts", ], outDir: "dist", hash: false, diff --git a/packages/shared/src/cli/commands/db/database-tooling.d.ts b/packages/shared/src/cli/commands/db/database-tooling.d.ts new file mode 100644 index 000000000..b1c18beee --- /dev/null +++ b/packages/shared/src/cli/commands/db/database-tooling.d.ts @@ -0,0 +1,39 @@ +/** + * Intentionally narrowed mirror of `@databricks/appkit/database-tooling`. + * + * `shared` is a leaf package and must not statically depend on `appkit`, so the + * CLI loads the tooling via a dynamic `import(...)` and this declares its shape + * without a build-time dependency (same pattern as ../type-generator.d.ts). + * There is NO compile-time link to the real exports — if they change, re-sync + * this by hand against `packages/appkit/src/database/tooling/index.ts`. + */ +declare module "@databricks/appkit/database-tooling" { + export interface DbContext { + readonly root: string; + readonly env: NodeJS.ProcessEnv; + } + + export function createDbContext(options?: { + root?: string; + env?: NodeJS.ProcessEnv; + }): DbContext; + + export interface GeneratedMigration { + readonly tag: string; + readonly file: string; + readonly statements: readonly string[]; + } + + export interface MigrateReport { + readonly generated: GeneratedMigration | null; + readonly applied: readonly string[]; + readonly adopted?: readonly string[]; + readonly notApplied?: string; + readonly typesFile: string; + } + + export function runMigrate( + ctx: DbContext, + options: { interactive: boolean }, + ): Promise; +} diff --git a/packages/shared/src/cli/commands/db/index.ts b/packages/shared/src/cli/commands/db/index.ts new file mode 100644 index 000000000..aca41b888 --- /dev/null +++ b/packages/shared/src/cli/commands/db/index.ts @@ -0,0 +1,11 @@ +import { Command } from "commander"; + +import { dbMigrateCommand } from "./migrate"; + +/** + * Lakebase database lifecycle for the DatabasePlugin. The logic lives in + * `@databricks/appkit/database-tooling`; these commands only parse, prompt and print. + */ +export const dbCommand = new Command("db") + .description("Lakebase database lifecycle") + .addCommand(dbMigrateCommand); diff --git a/packages/shared/src/cli/commands/db/migrate.test.ts b/packages/shared/src/cli/commands/db/migrate.test.ts new file mode 100644 index 000000000..1640b5223 --- /dev/null +++ b/packages/shared/src/cli/commands/db/migrate.test.ts @@ -0,0 +1,152 @@ +import path from "node:path"; + +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const { createDbContext, runMigrate } = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + runMigrate: vi.fn(), +})); + +// The tooling is an ambient module loaded with a dynamic import; mock it so the +// command never touches drizzle-kit or a database. +vi.mock("@databricks/appkit/database-tooling", () => ({ + createDbContext, + runMigrate, +})); + +import { dbCommand } from "./index"; + +let log: MockInstance; +let error: MockInstance; + +function output(spy: MockInstance): string { + return spy.mock.calls.map((call) => call.join(" ")).join("\n"); +} + +beforeEach(() => { + runMigrate.mockReset(); + createDbContext.mockClear(); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +const typesFile = path.join(process.cwd(), "shared/appkit-types/database.d.ts"); + +describe("appkit db migrate", () => { + test("is registered under the db group", () => { + expect(dbCommand.commands.map((command) => command.name())).toContain( + "migrate", + ); + }); + + test("prints the generated file, the applied tags and the types file", async () => { + runMigrate.mockResolvedValue({ + generated: { + tag: "0001_migration", + file: path.join( + process.cwd(), + "config/database/migrations/0001_migration.sql", + ), + statements: ["a", "b"], + }, + applied: ["0001_migration"], + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + expect(createDbContext).toHaveBeenCalledWith(); + expect(runMigrate).toHaveBeenCalledWith( + { root: "/app", env: {} }, + // Vitest workers have no TTY, so the run is non-interactive. + { interactive: false }, + ); + const printed = output(log); + expect(printed).toContain( + `Generated ${path.join("config/database/migrations/0001_migration.sql")} (2 statements)`, + ); + expect(printed).toContain("Applied 0001_migration to the .env branch"); + expect(printed).toContain( + `Generated ${path.join("shared/appkit-types/database.d.ts")}`, + ); + expect(process.exitCode).toBeUndefined(); + }); + + test("reports an unchanged schema and an up-to-date branch", async () => { + runMigrate.mockResolvedValue({ generated: null, applied: [], typesFile }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain("No schema changes since the last migration"); + expect(printed).toContain("The .env branch is up to date"); + }); + + test("reports a baseline adopted without running it", async () => { + runMigrate.mockResolvedValue({ + generated: null, + applied: ["0001_migration"], + adopted: ["0000_baseline"], + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain( + "Adopted 0000_baseline: its tables already existed, so it was recorded without running", + ); + expect(printed).toContain("Applied 0001_migration to the .env branch"); + }); + + test("says why nothing was applied", async () => { + runMigrate.mockResolvedValue({ + generated: null, + applied: [], + notApplied: "no branch configured — run appkit db init", + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + expect(output(log)).toContain( + "Not applied: no branch configured — run appkit db init", + ); + expect(process.exitCode).toBeUndefined(); + }); + + test("prints the error and its root cause, and exits 1", async () => { + const driverError = new Error('relation "notes" already exists'); + const wrapped = new Error("Failed query: CREATE TABLE ...", { + cause: driverError, + }); + runMigrate.mockRejectedValue( + new Error( + "Applying migration 0000_migration failed (SQLSTATE 42P07); none were applied.", + { cause: wrapped }, + ), + ); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(error); + expect(printed).toContain( + "Applying migration 0000_migration failed (SQLSTATE 42P07); none were applied.", + ); + expect(printed).toContain('relation "notes" already exists'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/migrate.ts b/packages/shared/src/cli/commands/db/migrate.ts new file mode 100644 index 000000000..8d061f6b0 --- /dev/null +++ b/packages/shared/src/cli/commands/db/migrate.ts @@ -0,0 +1,31 @@ +import { Command } from "commander"; + +import { fail, isInteractive, printMigrateReport } from "./output"; + +async function runDbMigrate(): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runMigrate(tooling.createDbContext(), { + interactive: isInteractive(), + }); + printMigrateReport(report); + } catch (error) { + fail(error); + } +} + +export const dbMigrateCommand = new Command("migrate") + .description( + "Generate a migration from config/database/schema.ts, apply pending migrations to the .env branch, and regenerate database.d.ts", + ) + .addHelpText( + "after", + ` +Migrations are written to config/database/migrations/. Commit them with schema.ts; +the deployed app applies pending committed migrations when it starts. They are +applied locally only on your dev or session branch (see \`appkit db init\`). + +Examples: + $ appkit db migrate`, + ) + .action(runDbMigrate); diff --git a/packages/shared/src/cli/commands/db/output.ts b/packages/shared/src/cli/commands/db/output.ts new file mode 100644 index 000000000..2cfba1973 --- /dev/null +++ b/packages/shared/src/cli/commands/db/output.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import process from "node:process"; + +import type { MigrateReport } from "@databricks/appkit/database-tooling"; +import pc from "picocolors"; + +/** Prompts only when a human can answer them. */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +export function relative(file: string): string { + return path.relative(process.cwd(), file) || file; +} + +export const ok = (message: string) => + console.log(`${pc.green("✔")} ${message}`); +export const info = (message: string) => + console.log(`${pc.dim("•")} ${message}`); +export const warn = (message: string) => + console.log(`${pc.yellow("!")} ${message}`); + +/** Innermost message: drizzle wraps the driver error, whose text names the failing object. */ +function rootCause(error: unknown): string | undefined { + let current = (error as { cause?: unknown } | undefined)?.cause; + let message: string | undefined; + for (let depth = 0; depth < 5 && current instanceof Error; depth++) { + message = current.message; + current = (current as { cause?: unknown }).cause; + } + return message; +} + +/** Print the error and its root cause, and fail the process. */ +export function fail(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`${pc.red("✖")} ${message}`); + const cause = rootCause(error); + if (cause && cause !== message && !message.includes(cause)) + console.error(` ${cause}`); + process.exitCode = 1; +} + +export function printMigrateReport(report: MigrateReport): void { + if (report.generated) { + const count = report.generated.statements.length; + ok( + `Generated ${relative(report.generated.file)} (${count} statement${count === 1 ? "" : "s"})`, + ); + } else { + info("No schema changes since the last migration"); + } + + if (report.adopted?.length) { + ok( + `Adopted ${report.adopted.join(", ")}: its tables already existed, so it was recorded without running`, + ); + } + if (report.applied.length > 0) { + ok(`Applied ${report.applied.join(", ")} to the .env branch`); + } else if (report.notApplied) { + warn(`Not applied: ${report.notApplied}`); + } else if (!report.adopted?.length) { + info("The .env branch is up to date"); + } + + ok(`Generated ${relative(report.typesFile)}`); +} diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index af8c22e6e..2fce08005 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -8,6 +8,7 @@ import { Command } from "commander"; import { agentCommand } from "./commands/agent/index.js"; import { codemodCommand } from "./commands/codemod/index.js"; +import { dbCommand } from "./commands/db/index.js"; import { docsCommand } from "./commands/docs.js"; import { doctorCommand } from "./commands/doctor/index.js"; import { generateTypesCommand } from "./commands/generate-types.js"; @@ -35,6 +36,8 @@ cmd.addCommand(docsCommand); cmd.addCommand(pluginCommand); cmd.addCommand(codemodCommand); cmd.addCommand(doctorCommand); +// Hidden from --help until the database plugin is offered by `databricks apps init`. +cmd.addCommand(dbCommand, { hidden: true }); // Registry commands are executable but hidden from --help while the feature // is still in development (registry + add work end-to-end but aren't announced). cmd.addCommand(registryCommand, { hidden: true }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ced623e4..8f51d4855 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,7 +132,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: 5.1.1 - version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2)) eslint: specifier: 9.39.1 version: 9.39.1(jiti@2.6.1) @@ -153,7 +153,7 @@ importers: version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) vite: specifier: 7.2.4 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) apps/dev-playground: dependencies: @@ -368,7 +368,7 @@ importers: version: link:../shared vite: specifier: npm:rolldown-vite@7.1.14 - version: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + version: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) ws: specifier: 8.21.0 version: 8.21.0 @@ -399,13 +399,16 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: 5.1.1 - version: 5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2)) autoevals: specifier: 0.3.0 version: 0.3.0(ws@8.21.0)(zod@4.3.6) + drizzle-kit: + specifier: 0.31.10 + version: 0.31.10 vitest: specifier: 3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) packages/appkit-ui: dependencies: @@ -2189,6 +2192,9 @@ packages: resolution: {integrity: sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==} engines: {node: '>=20.0'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -2198,162 +2204,458 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + '@esbuild/aix-ppc64@0.25.10': resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.10': resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.10': resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.10': resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.10': resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.10': resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.10': resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.10': resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.10': resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.10': resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.10': resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.10': resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.10': resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.10': resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.10': resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.10': resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.10': resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.10': resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.10': resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.10': resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.10': resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.10': resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.10': resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.10': resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.10': resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.10': resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -7174,6 +7476,10 @@ packages: resolution: {integrity: sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + drizzle-orm@0.45.2: resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: @@ -7415,11 +7721,21 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.10: resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -11736,6 +12052,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -15057,6 +15378,8 @@ snapshots: - uglify-js - webpack-cli + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -15073,84 +15396,238 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.6 + '@esbuild/aix-ppc64@0.25.10': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.25.10': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.25.10': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.25.10': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.25.10': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.25.10': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.25.10': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.25.10': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.25.10': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.25.10': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.25.10': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.25.10': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.25.10': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.25.10': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.25.10': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.25.10': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.25.10': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.10': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.25.10': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.10': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.25.10': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.10': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.25.10': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.25.10': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.25.10': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.25.10': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -18601,7 +19078,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -18609,11 +19086,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.47 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -18621,7 +19098,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.47 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -18657,13 +19134,13 @@ snapshots: optionalDependencies: vite: 7.2.4(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -20373,6 +20850,13 @@ snapshots: dottie@2.0.6: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.10 + tsx: 4.23.15 + drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.18.0): optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -20510,6 +20994,31 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.25.10: optionalDependencies: '@esbuild/aix-ppc64': 0.25.10 @@ -20539,6 +21048,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.10 '@esbuild/win32-x64': 0.25.10 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -24809,7 +25347,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.2 - rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: '@oxc-project/runtime': 0.92.0 fdir: 6.5.0(picomatch@4.0.3) @@ -24824,7 +25362,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 terser: 5.44.1 - tsx: 4.20.6 + tsx: 4.23.15 yaml: 2.8.2 rolldown@1.0.0-beta.41: @@ -25711,6 +26249,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -26121,13 +26665,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -26153,7 +26697,7 @@ snapshots: - supports-color - typescript - vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: esbuild: 0.25.10 fdir: 6.5.0(picomatch@4.0.3) @@ -26167,7 +26711,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.44.1 - tsx: 4.20.6 + tsx: 4.23.15 yaml: 2.8.2 vite@7.2.4(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): @@ -26187,7 +26731,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.2 - vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: esbuild: 0.25.10 fdir: 6.5.0(picomatch@4.0.3) @@ -26201,7 +26745,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.44.1 - tsx: 4.20.6 + tsx: 4.23.15 yaml: 2.8.2 vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): @@ -26247,11 +26791,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -26269,8 +26813,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 From a2522e52a0b9e4fc404d685085c91ba77d3e6fa5 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 12 Oct 2026 13:36:54 +0100 Subject: [PATCH 3/6] feat(appkit): add appkit db introspect `appkit db introspect` reads an existing Postgres schema (default: app) from pg_catalog and writes config/database/schema.ts from it, keeping the previous file as schema.ts.bak. It also writes the migration that brings history up to the database: a first 0000_baseline, which the deployed app adopts on start when its tables already exist, or the next migration on a branch created for this developer or checkout, where it is recorded as applied. What the schema builder cannot express is skipped and reported, and what it declares differently from the database (a timestamp precision, an index) is listed. With history, it runs only on a branch that has applied every committed migration, and it refuses a migration that would drop something it skipped, since that migration runs on deploy. If anything fails after schema.ts is written, schema.ts, its backup and the migrations are put back. Signed-off-by: ditadi --- .../appkit/src/database/migrations/apply.ts | 32 +- .../database/migrations/tests/apply.test.ts | 113 +++ .../database/schema-builder/define-schema.ts | 2 +- packages/appkit/src/database/tooling/index.ts | 5 + .../database/tooling/introspect/catalog.ts | 252 ++++++ .../src/database/tooling/introspect/index.ts | 260 ++++++ .../src/database/tooling/introspect/render.ts | 775 ++++++++++++++++++ .../tooling/introspect/tests/catalog.test.ts | 142 ++++ .../tooling/introspect/tests/fixture.ts | 283 +++++++ .../tests/golden/rich.schema.ts.txt | 51 ++ .../introspect/tests/introspect.test.ts | 562 +++++++++++++ .../tooling/introspect/tests/render.test.ts | 538 ++++++++++++ .../introspect/tests/roundtrip.test.ts | 260 ++++++ .../appkit/src/database/tooling/migrate.ts | 4 +- .../appkit/src/database/tooling/migrations.ts | 42 +- .../database/tooling/tests/migrations.test.ts | 71 ++ .../src/cli/commands/db/database-tooling.d.ts | 26 + packages/shared/src/cli/commands/db/index.ts | 4 +- .../src/cli/commands/db/introspect.test.ts | 173 ++++ .../shared/src/cli/commands/db/introspect.ts | 41 + packages/shared/src/cli/commands/db/output.ts | 48 +- 21 files changed, 3675 insertions(+), 9 deletions(-) create mode 100644 packages/appkit/src/database/tooling/introspect/catalog.ts create mode 100644 packages/appkit/src/database/tooling/introspect/index.ts create mode 100644 packages/appkit/src/database/tooling/introspect/render.ts create mode 100644 packages/appkit/src/database/tooling/introspect/tests/catalog.test.ts create mode 100644 packages/appkit/src/database/tooling/introspect/tests/fixture.ts create mode 100644 packages/appkit/src/database/tooling/introspect/tests/golden/rich.schema.ts.txt create mode 100644 packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts create mode 100644 packages/appkit/src/database/tooling/introspect/tests/render.test.ts create mode 100644 packages/appkit/src/database/tooling/introspect/tests/roundtrip.test.ts create mode 100644 packages/shared/src/cli/commands/db/introspect.test.ts create mode 100644 packages/shared/src/cli/commands/db/introspect.ts diff --git a/packages/appkit/src/database/migrations/apply.ts b/packages/appkit/src/database/migrations/apply.ts index a4e6dd704..e2f0bdd50 100644 --- a/packages/appkit/src/database/migrations/apply.ts +++ b/packages/appkit/src/database/migrations/apply.ts @@ -99,7 +99,7 @@ export class MigrationBaselineMismatchError extends Error { constructor(schemaName: string, problems: readonly string[]) { super( `Cannot adopt ${BASELINE_TAG}: schema "${schemaName}" has no recorded migrations and only part of the baseline: ${problems.join("; ")}. ` + - "Create the missing tables and columns (or drop the partial ones), or regenerate the baseline against this database.", + "Create the missing tables and columns (or drop the partial ones), or regenerate the baseline with `appkit db introspect` against this database.", ); this.name = "MigrationBaselineMismatchError"; this.problems = Object.freeze([...problems]); @@ -391,6 +391,36 @@ async function recordMigrationsApplied( } } +/** Tags of the committed migrations the database has not recorded, oldest first. */ +export async function pendingMigrationTags( + client: QueryClient, + options: ApplyMigrationsOptions, +): Promise { + const journal = readJournal(options.migrationsFolder); + if (!journal) return []; + const last = await lastRecordedMigration(client, options.schemaName); + return pendingEntries(journal, last).map((entry) => entry.tag); +} + +/** recordMigrationsApplied in one transaction under the migration lock (introspect on an AppKit branch). */ +export async function markMigrationsApplied( + pool: Pick, + options: ApplyMigrationsOptions, + tags: readonly string[], +): Promise { + const journal = readJournal(options.migrationsFolder); + if (!journal) { + throw new MigrationFilesError( + `Cannot record ${tags.join(", ")} as applied: ${options.migrationsFolder} has no meta/_journal.json.`, + ); + } + await withMigrationLock(pool, options.schemaName, (client) => + inTransaction(client, () => + recordMigrationsApplied(client, options, journal, tags), + ), + ); +} + /** * Apply every committed migration the database has not recorded yet. * diff --git a/packages/appkit/src/database/migrations/tests/apply.test.ts b/packages/appkit/src/database/migrations/tests/apply.test.ts index 4fc178d14..d0d0fd185 100644 --- a/packages/appkit/src/database/migrations/tests/apply.test.ts +++ b/packages/appkit/src/database/migrations/tests/apply.test.ts @@ -7,9 +7,11 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { applyPendingMigrations, + markMigrationsApplied, MigrationApplyError, MigrationBaselineMismatchError, migrationLockKey, + pendingMigrationTags, } from "../apply"; import { type Journal, MigrationFilesError } from "../paths"; @@ -830,3 +832,114 @@ describe("applyPendingMigrations — failures name their cause", () => { ); }); }); + +describe("pendingMigrationTags", () => { + const options = (migrationsFolder: string) => ({ + migrationsFolder, + schemaName: "app", + }); + + test.each([ + [ + "without a journal table, every committed migration", + false, + null, + ["0000_migration", "0001_migration"], + ], + [ + "with nothing recorded, every committed migration", + true, + null, + ["0000_migration", "0001_migration"], + ], + [ + "only the migrations newer than the last recorded one", + true, + 1000, + ["0001_migration"], + ], + ["nothing once the last one is recorded", true, 2000, []], + ] as const)( + "lists %s", + async (_label, journalTableExists, lastCreatedAt, expected) => { + const { client } = fakePool({ journalTableExists, lastCreatedAt }); + + await expect( + pendingMigrationTags(client as never, options(twoMigrations())), + ).resolves.toEqual(expected); + }, + ); + + test("without a journal file there is nothing to apply, and it does not query", async () => { + const folder = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-apply-")); + folders.push(folder); + const { client } = fakePool({ + journalTableExists: true, + lastCreatedAt: null, + }); + + await expect( + pendingMigrationTags(client as never, options(folder)), + ).resolves.toEqual([]); + expect(client.statements).toEqual([]); + }); +}); + +describe("markMigrationsApplied", () => { + test("records the tags with the migrator's hash and when, in one transaction under the lock", async () => { + const folder = baselineThenMigration(); + const { pool, client } = fakePool({ + journalTableExists: false, + lastCreatedAt: null, + }); + + await markMigrationsApplied( + pool, + { migrationsFolder: folder, schemaName: "app" }, + ["0001_migration"], + ); + + const file = fs.readFileSync( + path.join(folder, "0001_migration.sql"), + "utf-8", + ); + expect(client.recorded).toEqual([ + [createHash("sha256").update(file).digest("hex"), 2000], + ]); + expect(steps(client.statements)).toEqual([ + "SET statement_timeout = 0", + "try lock", + "SET lock_timeout = '30s'", + "begin", + `CREATE SCHEMA IF NOT EXISTS "app"`, + `CREATE TABLE IF NOT EXISTS "app"."__drizzle_migrations" ( id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint )`, + `insert into "app"."__drizzle_migrations" ("hash", "created_at") values($1, $2)`, + "commit", + "unlock", + "RESET lock_timeout", + "RESET statement_timeout", + ]); + }); + + test("an unknown tag rolls back", async () => { + const folder = baselineThenMigration(); + const { pool, client } = fakePool({ + journalTableExists: false, + lastCreatedAt: null, + }); + + await expect( + markMigrationsApplied( + pool, + { migrationsFolder: folder, schemaName: "app" }, + ["0009_missing"], + ), + ).rejects.toThrow("Migration 0009_missing is not in the journal."); + expect(steps(client.statements).slice(-4)).toEqual([ + "rollback", + "unlock", + "RESET lock_timeout", + "RESET statement_timeout", + ]); + }); +}); diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index 5d8cf3d91..1bab4ca25 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -344,7 +344,7 @@ export function assertFinalizedSchema(value: unknown): asserts value is Schema { * the deployed app's service principal can create and own a schema of its * own, but has no CREATE on `public`. */ -const DEFAULT_SCHEMA_NAME = "app"; +export const DEFAULT_SCHEMA_NAME = "app"; /** * Compile one declared schema. The returned type keeps the table names the diff --git a/packages/appkit/src/database/tooling/index.ts b/packages/appkit/src/database/tooling/index.ts index 7167ac662..11e9a8614 100644 --- a/packages/appkit/src/database/tooling/index.ts +++ b/packages/appkit/src/database/tooling/index.ts @@ -4,4 +4,9 @@ * plugin never imports it. */ export { createDbContext, type DbContext } from "./context"; +export { + type IntrospectOptions, + type IntrospectReport, + runIntrospect, +} from "./introspect"; export { type MigrateOptions, type MigrateReport, runMigrate } from "./migrate"; diff --git a/packages/appkit/src/database/tooling/introspect/catalog.ts b/packages/appkit/src/database/tooling/introspect/catalog.ts new file mode 100644 index 000000000..49429401b --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/catalog.ts @@ -0,0 +1,252 @@ +import { MIGRATIONS_TABLE } from "../../migrations/paths"; +import type { ReferentialAction } from "../../schema-builder/types"; + +/** Run one parameterized query and return its rows (a pg Pool fits: `(t, v) => pool.query(t, v).then(r => r.rows)`). */ +export type CatalogQuery = ( + text: string, + values: readonly unknown[], +) => Promise; + +export interface IntrospectedEnum { + readonly name: string; + readonly schema: string; + readonly values: readonly string[]; +} + +export interface IntrospectedColumn { + readonly name: string; + /** format_type(), e.g. "character varying(120)". */ + readonly type: string; + readonly notNull: boolean; + /** attidentity: "" none, "a" always, "d" by default. */ + readonly identity: "" | "a" | "d"; + /** Stored generated column (attgenerated = 's'). */ + readonly generated: boolean; + /** pg_get_expr() of the default (or generation expression). */ + readonly defaultExpr: string | null; + readonly enum?: IntrospectedEnum; +} + +export interface IntrospectedForeignKey { + readonly name: string; + readonly columns: readonly string[]; + readonly schema: string; + readonly table: string; + readonly refColumns: readonly string[]; + readonly onDelete: ReferentialAction; + readonly onUpdate: ReferentialAction; + /** DEFERRABLE or MATCH FULL, neither of which the builder declares. */ + readonly deferrable: boolean; + readonly matchFull: boolean; +} + +export interface IntrospectedUnique { + readonly name: string; + readonly columns: readonly string[]; +} + +export interface IntrospectedTable { + readonly name: string; + /** relkind 'p': partitioned in the database. */ + readonly partitioned: boolean; + readonly columns: readonly IntrospectedColumn[]; + readonly primaryKey: readonly string[]; + readonly unique: readonly IntrospectedUnique[]; + readonly foreignKeys: readonly IntrospectedForeignKey[]; + /** CHECK and EXCLUDE constraint names. */ + readonly checks: readonly string[]; + /** Index names, except the indexes behind primary-key, unique and exclusion constraints. */ + readonly indexes: readonly string[]; +} + +/** pg_constraint.confdeltype / confupdtype. */ +const ACTIONS: Record = { + a: "no action", + r: "restrict", + c: "cascade", + n: "set null", + d: "set default", +}; + +export interface ColumnRow { + table_name: string; + name: string; + type: string; + not_null: boolean; + identity: string; + generated: string; + default_expr: string | null; + type_kind: string; + type_schema: string; + type_name: string; + enum_values: string[] | null; +} + +export interface TableRow { + name: string; + partitioned: boolean; +} + +export interface ConstraintRow { + table_name: string; + name: string; + kind: "p" | "u" | "f" | "c" | "x"; + columns: string[]; + ref_schema: string | null; + ref_table: string | null; + ref_columns: string[] | null; + on_delete: string; + on_update: string; + deferrable?: boolean; + /** confmatchtype: "f" full, "s" simple. */ + match_type?: string; +} + +export interface IndexRow { + table_name: string; + name: string; +} + +// pg_catalog, never information_schema: it hides objects the role cannot access. +// Ordinary and partitioned tables only; partitions belong to their parent. +const TABLES_SQL = ` +select c.relname::text as name, c.relkind = 'p' as partitioned + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = $1 and c.relkind in ('r', 'p') and not c.relispartition + and c.relname <> $2 + order by c.relname`; + +const COLUMNS_SQL = ` +select c.relname::text as table_name, a.attname::text as name, + pg_catalog.format_type(a.atttypid, a.atttypmod) as type, + a.attnotnull as not_null, a.attidentity::text as identity, + a.attgenerated::text as generated, + pg_catalog.pg_get_expr(d.adbin, d.adrelid) as default_expr, + t.typtype::text as type_kind, tn.nspname::text as type_schema, + t.typname::text as type_name, + case when t.typtype = 'e' then array( + select e.enumlabel::text from pg_catalog.pg_enum e + where e.enumtypid = t.oid order by e.enumsortorder) + end as enum_values + from pg_catalog.pg_attribute a + join pg_catalog.pg_class c on c.oid = a.attrelid + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + join pg_catalog.pg_type t on t.oid = a.atttypid + join pg_catalog.pg_namespace tn on tn.oid = t.typnamespace + left join pg_catalog.pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum + where n.nspname = $1 and c.relkind in ('r', 'p') and not c.relispartition + and c.relname <> $2 and a.attnum > 0 and not a.attisdropped + order by c.relname, a.attnum`; + +const CONSTRAINTS_SQL = ` +select c.relname::text as table_name, k.conname::text as name, k.contype::text as kind, + array(select a.attname::text + from unnest(k.conkey) with ordinality as u(attnum, ord) + join pg_catalog.pg_attribute a on a.attrelid = k.conrelid and a.attnum = u.attnum + order by u.ord) as columns, + rn.nspname::text as ref_schema, rc.relname::text as ref_table, + array(select a.attname::text + from unnest(k.confkey) with ordinality as u(attnum, ord) + join pg_catalog.pg_attribute a on a.attrelid = k.confrelid and a.attnum = u.attnum + order by u.ord) as ref_columns, + k.confdeltype::text as on_delete, k.confupdtype::text as on_update, + k.condeferrable as deferrable, k.confmatchtype::text as match_type + from pg_catalog.pg_constraint k + join pg_catalog.pg_class c on c.oid = k.conrelid + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + left join pg_catalog.pg_class rc on rc.oid = k.confrelid + left join pg_catalog.pg_namespace rn on rn.oid = rc.relnamespace + where n.nspname = $1 and k.contype in ('p', 'u', 'f', 'c', 'x') and c.relname <> $2 + -- A foreign key to a partitioned table has one copy per partition; keep the original. + and k.conparentid = 0 + order by c.relname, k.conname`; + +// Indexes no constraint stands for: schema.ts cannot declare them. +const INDEXES_SQL = ` +select c.relname::text as table_name, i.relname::text as name + from pg_catalog.pg_index x + join pg_catalog.pg_class c on c.oid = x.indrelid + join pg_catalog.pg_class i on i.oid = x.indexrelid + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = $1 and c.relname <> $2 + and not exists ( + select 1 from pg_catalog.pg_constraint k + where k.conrelid = x.indrelid and k.conindid = x.indexrelid + and k.contype in ('p', 'u', 'x')) + order by c.relname, i.relname`; + +function toColumn(row: ColumnRow): IntrospectedColumn { + const identity = + row.identity === "a" || row.identity === "d" ? row.identity : ""; + return { + name: row.name, + type: row.type, + notNull: row.not_null, + identity, + generated: row.generated === "s", + defaultExpr: row.default_expr, + ...(row.type_kind === "e" + ? { + enum: { + name: row.type_name, + schema: row.type_schema, + values: row.enum_values ?? [], + }, + } + : {}), + }; +} + +/** Assemble the model from the catalog result sets (exported for fixture tests). */ +export function buildTables( + tables: readonly TableRow[], + columns: readonly ColumnRow[], + constraints: readonly ConstraintRow[], + indexes: readonly IndexRow[] = [], +): IntrospectedTable[] { + return tables.map(({ name, partitioned }) => { + const own = constraints.filter((row) => row.table_name === name); + return { + name, + partitioned, + columns: columns.filter((row) => row.table_name === name).map(toColumn), + primaryKey: own.find((row) => row.kind === "p")?.columns ?? [], + unique: own + .filter((row) => row.kind === "u") + .map((row) => ({ name: row.name, columns: row.columns })), + foreignKeys: own + .filter((row) => row.kind === "f" && row.ref_table) + .map((row) => ({ + name: row.name, + columns: row.columns, + schema: row.ref_schema ?? "", + table: row.ref_table ?? "", + refColumns: row.ref_columns ?? [], + onDelete: ACTIONS[row.on_delete] ?? "no action", + onUpdate: ACTIONS[row.on_update] ?? "no action", + deferrable: row.deferrable === true, + matchFull: row.match_type === "f", + })), + checks: own + .filter((row) => row.kind === "c" || row.kind === "x") + .map((row) => row.name), + indexes: indexes + .filter((row) => row.table_name === name) + .map((row) => row.name), + }; + }); +} + +/** Tables of one schema from pg_catalog, excluding the migration journal. */ +export async function readCatalog( + query: CatalogQuery, + schemaName: string, +): Promise { + const params = [schemaName, MIGRATIONS_TABLE]; + const tables = await query(TABLES_SQL, params); + const columns = await query(COLUMNS_SQL, params); + const constraints = await query(CONSTRAINTS_SQL, params); + const indexes = await query(INDEXES_SQL, params); + return buildTables(tables, columns, constraints, indexes); +} diff --git a/packages/appkit/src/database/tooling/introspect/index.ts b/packages/appkit/src/database/tooling/introspect/index.ts new file mode 100644 index 000000000..e2f19a978 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/index.ts @@ -0,0 +1,260 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadDefaultDatabaseSchema } from "../../../plugins/database/load-schema"; +import { generateDatabaseTypes } from "../../../type-generator/database"; +import { + markMigrationsApplied, + pendingMigrationTags, +} from "../../migrations/apply"; +import { + journalFile, + migrationsFolder, + readJournal, +} from "../../migrations/paths"; +import { DEFAULT_SCHEMA_NAME } from "../../schema-builder/define-schema"; +import { type DbContext, openBranchPool } from "../context"; +import { checkAppkitBranch } from "../guard"; +import { DATABASE_TYPES_PATH, SCHEMA_FILE } from "../migrate"; +import { + discardMigrationFiles, + type GeneratedMigration, + generateMigrationFiles, +} from "../migrations"; +import { resourceId } from "../targets"; +import { readCatalog } from "./catalog"; +import { renderSchemaSource, type Skip } from "./render"; + +export interface IntrospectOptions { + /** false: an ambiguous rename against existing history throws instead of prompting. */ + readonly interactive: boolean; + /** Schema to read; defaults to DEFAULT_SCHEMA_NAME. The current schema.ts is never read. */ + readonly schemaName?: string; + /** Module the written schema.ts imports the builder from. Default "@databricks/appkit/beta". */ + readonly importSource?: string; +} + +export interface IntrospectReport { + readonly branch: string; + readonly schemaName: string; + /** Tables written to schema.ts. */ + readonly tables: readonly string[]; + readonly schemaFile: string; + /** Where the previous schema.ts went, when this run replaced a different one. */ + readonly backupFile: string | null; + readonly skipped: readonly Skip[]; + readonly warnings: readonly string[]; + /** What schema.ts declares differently from the database, which keeps it as it is. */ + readonly approximated: readonly string[]; + /** 0000_baseline without history, NNNN_introspect with it; null when nothing changed. */ + readonly migration: GeneratedMigration | null; + /** The migration was recorded as applied on the branch (AppKit branches only). */ + readonly recordedApplied: boolean; + /** Why the migration was not recorded. */ + readonly notRecorded?: string; + readonly typesFile: string; +} + +/** Introspect would leave history the deployed migrator cannot adopt. */ +export class IntrospectRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = "IntrospectRefusedError"; + } +} + +/** File contents, or null when it does not exist. */ +function readIfExists(file: string): string | null { + return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : null; +} + +function writeOrRemove(file: string, contents: string | null): void { + if (contents === null) fs.rmSync(file, { force: true }); + else fs.writeFileSync(file, contents); +} + +/** + * With history, a DROP in the diff runs on deploy. Without skips it is something + * the branch no longer has; with skips it may be something the branch still has + * but schema.ts cannot express, so the migration is refused. + */ +function refuseDropsOfSkipped( + statements: readonly string[], + skipped: readonly Skip[], +): void { + const drops = statements.filter((statement) => /\bDROP\b/i.test(statement)); + if (drops.length === 0 || skipped.length === 0) return; + const what = skipped.map((skip) => + skip.column ? `${skip.table}.${skip.column}` : skip.table, + ); + throw new IntrospectRefusedError( + `Refusing to write a migration that drops what schema.ts cannot express (${[...new Set(what)].join(", ")}): the deployed app would run ${drops.join(" ")}`, + ); +} + +/** + * `appkit db introspect`: read the branch's app schema from pg_catalog, write + * schema.ts (the previous file goes to schema.ts.bak), write the migration that + * brings history up to the database, and record it as applied on AppKit + * branches. On any other branch only a first `0000_baseline` is allowed, which + * the deployed migrator adopts; with existing history the command refuses + * before writing anything. A failure after schema.ts is written puts back + * schema.ts, the backup and the migrations folder. + */ +export async function runIntrospect( + ctx: DbContext, + options: IntrospectOptions, +): Promise { + const schemaFile = path.join(ctx.root, SCHEMA_FILE); + // Introspect replaces schema.ts, so the current file (missing, empty or broken) is never read. + const schemaName = options.schemaName || DEFAULT_SCHEMA_NAME; + const folder = migrationsFolder(ctx.root); + const hasHistory = (readJournal(folder)?.entries.length ?? 0) > 0; + + const check = await checkAppkitBranch(ctx); + if (!check) { + throw new Error( + "No branch configured in .env (LAKEBASE_ENDPOINT) — run `appkit db init`.", + ); + } + const branchId = resourceId(check.branch.name); + const owned = check.verdict.appkitOwned; + if (hasHistory && !owned) { + throw new IntrospectRefusedError( + `Refusing to introspect ${branchId}: config/database/migrations already has history, and ${branchId} is not your dev or session branch (${check.verdict.reason}). ` + + "Only a first 0000_baseline can be adopted by the deployed app. Point .env at your dev or session branch with `appkit db init` and introspect there.", + ); + } + + const pool = await openBranchPool(ctx); + if (!pool) { + throw new Error( + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`.", + ); + } + try { + if (hasHistory) { + // The diff starts from the latest committed snapshot: a migration the + // branch lacks would come out reversed, and run reversed on deploy. + const pending = await pendingMigrationTags(pool, { + migrationsFolder: folder, + schemaName, + }); + if (pending.length > 0) { + throw new IntrospectRefusedError( + `Refusing to introspect ${branchId}: it has not applied ${pending.join(", ")} in schema "${schemaName}", so the migration would undo ${pending.length === 1 ? "it" : "them"}. Run \`appkit db migrate\` first.`, + ); + } + } + + const tables = await readCatalog( + async (text, values) => (await pool.query(text, [...values])).rows, + schemaName, + ); + const rendered = renderSchemaSource(tables, schemaName, { + importSource: options.importSource, + }); + // An app on another schema that forgot --schema-name must not lose its + // schema.ts to an empty one; with history, an empty schema drops every table. + if ( + rendered.tables.length === 0 && + (hasHistory || fs.existsSync(schemaFile)) + ) { + const reasons = rendered.skipped + .filter((skip) => !skip.column) + .map((skip) => `${skip.table}: ${skip.reason}`); + throw new IntrospectRefusedError( + `Schema "${schemaName}" on ${branchId} has no tables${tables.length > 0 ? ` introspect can write (${reasons.join("; ")})` : ""}, so introspect would ${hasHistory ? "write a migration that drops every table" : `replace ${SCHEMA_FILE} with an empty one`}. Pass --schema-name to read another schema.`, + ); + } + + // Write schema.ts, keeping the previous file when it differs. + const backupFile = `${schemaFile}.bak`; + const previous = readIfExists(schemaFile); + const previousBackup = readIfExists(backupFile); + const backsUp = previous !== null && previous !== rendered.source; + const journalBefore = readIfExists(journalFile(folder)); + let migration: GeneratedMigration | null = null; + let recordedApplied = false; + if (backsUp) fs.writeFileSync(backupFile, previous); + fs.mkdirSync(path.dirname(schemaFile), { recursive: true }); + fs.writeFileSync(schemaFile, rendered.source); + try { + let schema: Awaited>; + try { + schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + } catch (error) { + throw new Error( + `The schema.ts introspect generated does not load. This is a bug in \`appkit db introspect\`; please report it with the catalog of schema "${schemaName}".`, + { cause: error }, + ); + } + + migration = await generateMigrationFiles({ + folder, + schema, + tag: hasHistory ? "introspect" : "baseline", + interactive: options.interactive, + ...(hasHistory + ? { + beforeWrite: (statements: readonly string[]) => + refuseDropsOfSkipped(statements, rendered.skipped), + } + : {}), + }); + if (migration && owned) { + // The database already holds what the migration describes. + await markMigrationsApplied( + pool, + { migrationsFolder: folder, schemaName }, + [migration.tag], + ); + recordedApplied = true; + } + } catch (error) { + writeOrRemove(schemaFile, previous); + if (backsUp) writeOrRemove(backupFile, previousBackup); + if (migration) discardMigrationFiles(folder, migration, journalBefore); + if (error instanceof IntrospectRefusedError) throw error; + throw new Error( + `Introspect changed nothing: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + + const warnings = [...rendered.warnings]; + const drops = (migration?.statements ?? []).filter((statement) => + /\bDROP\b/i.test(statement), + ); + if (migration && drops.length > 0) { + warnings.push( + `${migration.tag} drops what the branch no longer has, and the deployed app runs it on start: ${drops.join(" ")}`, + ); + } + const notRecorded = + migration && !owned + ? `${branchId} is not your dev or session branch (${check.verdict.reason}); the deployed app adopts ${migration.tag} on start when its tables already exist` + : undefined; + + const typesFile = path.join(ctx.root, DATABASE_TYPES_PATH); + await generateDatabaseTypes({ schemaFile, outFile: typesFile }); + return { + branch: check.branch.name, + schemaName, + tables: rendered.tables, + schemaFile, + backupFile: backsUp ? backupFile : null, + skipped: rendered.skipped, + warnings, + approximated: rendered.approximated, + migration, + recordedApplied, + ...(notRecorded ? { notRecorded } : {}), + typesFile, + }; + } finally { + await pool.end().catch(() => undefined); + } +} diff --git a/packages/appkit/src/database/tooling/introspect/render.ts b/packages/appkit/src/database/tooling/introspect/render.ts new file mode 100644 index 000000000..1b5ef0fdc --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/render.ts @@ -0,0 +1,775 @@ +import { DEFAULT_SCHEMA_NAME } from "../../schema-builder/define-schema"; +import type { + IntrospectedColumn, + IntrospectedForeignKey, + IntrospectedTable, +} from "./catalog"; + +/** + * Something introspect left out of schema.ts, with an `// appkit: skipped …` comment. + * Display text: catalog names come with control characters escaped (see shown()). + */ +export interface Skip { + readonly table: string; + readonly column?: string; + readonly reason: string; +} + +interface SchemaSource { + readonly source: string; + /** Tables written to schema.ts. */ + readonly tables: readonly string[]; + readonly skipped: readonly Skip[]; + /** Omissions that change behavior, e.g. a NOT NULL column the API can no longer fill. */ + readonly warnings: readonly string[]; + /** What schema.ts declares differently from the database, which keeps it as it is. */ + readonly approximated: readonly string[]; +} + +interface RenderOptions { + /** Module the builder is imported from. Default "@databricks/appkit/beta". */ + readonly importSource?: string; +} + +/** Storage as the builder sees it; id/bigid are server-generated integer/bigint keys. */ +type Storage = + | "id" + | "bigid" + | "integer" + | "bigint" + | "text" + | "varchar" + | "boolean" + | "uuid" + | "timestamp" + | "jsonb" + | "enum"; + +interface BaseType { + /** Builder call, e.g. `varchar(120)`. */ + readonly call: string; + /** Import it needs ("" for the context's enum). */ + readonly importName: string; + readonly storage: Storage; + readonly varcharLength?: number; + /** How the call differs from the database type. */ + readonly approximation?: string; +} + +interface ColumnPlan { + readonly column: IntrospectedColumn; + base: BaseType; + /** Set when `fk(() => .)` replaces the base call. */ + fk?: { + readonly table: string; + readonly column: string; + readonly actions: string[]; + }; + readonly isPrimaryKey: boolean; + readonly isUnique: boolean; + defaultCall?: string; + readonly comments: string[]; +} + +interface TablePlan { + readonly table: IntrospectedTable; + readonly columns: ColumnPlan[]; + /** Table-level comments, rendered first. */ + readonly comments: string[]; + /** Columns and skipped-column comments, in catalog order. */ + readonly body: Array; + variable: string; +} + +const RESERVED_TABLE_NAMES = new Set([ + "sql", + "transaction", + "__proto__", + "prototype", + "constructor", +]); +const RESERVED_COLUMN_NAMES = new Set([ + "and", + "or", + "__proto__", + "prototype", + "constructor", +]); +const JS_RESERVED = new Set( + ( + "break case catch class const continue debugger default delete do else enum export extends false finally for " + + "function if import in instanceof new null return super switch this throw true try typeof var void while with " + + "yield let static implements interface package private protected public await arguments eval undefined" + ).split(" "), +); +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const INT32 = { min: -2147483648, max: 2147483647 }; + +const q = (value: string) => JSON.stringify(value); +/** + * Catalog text for a `//` comment or a terminal line. A quoted Postgres name can + * hold a newline (or U+2028/U+2029, which JSON.stringify keeps), which would end + * the comment and turn the rest of the name into code. + */ +const shown = (text: string) => + Array.from(text, (char) => { + const code = char.charCodeAt(0); + const breaks = + code < 0x20 || code === 0x7f || code === 0x2028 || code === 0x2029; + return breaks ? `\\u${code.toString(16).padStart(4, "0")}` : char; + }).join(""); +/** Postgres keeps the first 63 bytes of a name. */ +const pgName = (name: string) => name.slice(0, 63); +const key = (name: string) => + IDENTIFIER.test(name) && name !== "__proto__" ? name : q(name); +const access = (variable: string, name: string) => + IDENTIFIER.test(name) ? `${variable}.${name}` : `${variable}[${q(name)}]`; +const list = (names: readonly string[]) => names.join(", "); + +function baseType( + column: IntrospectedColumn, + schemaName: string, +): BaseType | string { + const type = column.type; + if (column.enum) { + if (column.enum.schema !== schemaName) { + return `enum type "${column.enum.schema}.${column.enum.name}" lives outside schema "${schemaName}"`; + } + if (column.enum.values.length === 0) { + return `enum type "${column.enum.name}" has no values`; + } + return { + call: `enumType(${q(column.enum.name)}, [${column.enum.values.map(q).join(", ")}])`, + importName: "", + storage: "enum", + }; + } + switch (type) { + case "integer": + return { call: "integer()", importName: "integer", storage: "integer" }; + case "bigint": + return { call: "bigint()", importName: "bigint", storage: "bigint" }; + case "text": + return { call: "text()", importName: "text", storage: "text" }; + case "character varying": + return { + call: "text()", + importName: "text", + storage: "text", + approximation: + "character varying without a length is declared as text()", + }; + case "boolean": + return { call: "boolean()", importName: "boolean", storage: "boolean" }; + case "uuid": + return { call: "uuid()", importName: "uuid", storage: "uuid" }; + case "jsonb": + return { call: "jsonb()", importName: "jsonb", storage: "jsonb" }; + } + const varchar = /^character varying\((\d+)\)$/.exec(type); + if (varchar) { + const length = Number(varchar[1]); + return { + call: `varchar(${length})`, + importName: "varchar", + storage: "varchar", + varcharLength: length, + }; + } + const timestamp = /^timestamp(\(\d\))? (with|without) time zone$/.exec(type); + if (timestamp) { + return { + call: + timestamp[2] === "with" + ? "timestamp({ withTimezone: true })" + : "timestamp()", + importName: "timestamp", + storage: "timestamp", + ...(timestamp[1] + ? { approximation: `${type} is declared without its precision` } + : {}), + }; + } + return `type "${type}" is not supported`; +} + +const isSequenceDefault = (expr: string | null) => + !!expr && /^nextval\(/.test(expr); + +/** A builder default call, or why the database default cannot be expressed. */ +function defaultCall( + expr: string, + base: BaseType, + enumValues: readonly string[] | undefined, +): { call: string } | { skip: string } { + const unsupported = { skip: `default ${expr} is not supported` }; + switch (base.storage) { + case "timestamp": + return /^(now\(\)|CURRENT_TIMESTAMP)$/i.test(expr) + ? { call: ".defaultNow()" } + : unsupported; + case "uuid": + return expr === "gen_random_uuid()" + ? { call: ".defaultRandom()" } + : unsupported; + case "boolean": + return expr === "true" || expr === "false" + ? { call: `.default(${expr})` } + : unsupported; + case "integer": { + const match = /^\(?'?(-?\d+)'?(?:::integer)?\)?$/.exec(expr); + const value = match ? Number(match[1]) : Number.NaN; + return Number.isInteger(value) && value >= INT32.min && value <= INT32.max + ? { call: `.default(${value})` } + : unsupported; + } + case "text": + case "varchar": + case "enum": { + const match = /^'((?:[^']|'')*)'(?:::[\w." ]+)?$/.exec(expr); + if (!match) return unsupported; + const value = (match[1] ?? "").replace(/''/g, "'"); + if ( + base.storage === "varchar" && + value.length > (base.varcharLength ?? 0) + ) { + return unsupported; + } + if (base.storage === "enum" && !enumValues?.includes(value)) { + return unsupported; + } + return { call: `.default(${q(value)})` }; + } + default: + // The builder accepts no literal defaults for bigint and jsonb. + return unsupported; + } +} + +function importLine(names: readonly string[], source: string): string { + const line = `import { ${names.join(", ")} } from ${q(source)};`; + if (line.length <= 80) return line; + return `import {\n${names.map((name) => ` ${name},`).join("\n")}\n} from ${q(source)};`; +} + +function makeSkip( + table: string, + column: string | undefined, + reason: string, +): Skip { + return { + table: shown(table), + ...(column === undefined ? {} : { column: shown(column) }), + reason: shown(reason), + }; +} + +function skipComment(skip: Skip): string { + return `// appkit: skipped ${skip.column ? `column "${skip.column}"` : `table "${skip.table}"`}: ${skip.reason}`; +} + +/** Defaults the builder allows only on uuid / timestamp columns, never on fk(). */ +const TYPED_DEFAULTS = new Set([".defaultRandom()", ".defaultNow()"]); + +/** The storage an fk() column inherits from its target. */ +function mirrored(target: BaseType): string { + if (target.storage === "id") return "integer()"; + if (target.storage === "bigid") return "bigint()"; + return target.call; +} + +/** + * Render pg_catalog tables as a `defineSchema` source the builder accepts. + * What the builder cannot express is left out with an `// appkit: skipped …` + * comment; what it declares differently (a timestamp without its precision, + * say) is listed in `approximated`. + */ +export function renderSchemaSource( + tables: readonly IntrospectedTable[], + schemaName: string, + options: RenderOptions = {}, +): SchemaSource { + const skipped: Skip[] = []; + const warnings: string[] = []; + const approximated: string[] = []; + /** Constraints whose database name is not the one migrations use: "db (migrations)". */ + const renamed: string[] = []; + const tableComments: string[] = []; + const qualified = (table: string, column?: string) => + `${schemaName}.${table}${column ? `.${column}` : ""}`; + const skipTable = (table: string, reason: string) => { + const skip = makeSkip(table, undefined, reason); + skipped.push(skip); + tableComments.push(skipComment(skip)); + }; + + // 1. Tables the builder can key: one primary-key column, a usable name. + const names = new Set(tables.map((table) => table.name)); + const plans = new Map(); + for (const table of tables) { + if (RESERVED_TABLE_NAMES.has(table.name)) { + skipTable(table.name, `the name "${table.name}" is reserved by AppKit`); + } else if ( + table.name.endsWith("Relations") && + names.has(table.name.slice(0, -"Relations".length)) + ) { + skipTable( + table.name, + `the name collides with the relation metadata AppKit generates for "${table.name.slice(0, -"Relations".length)}"`, + ); + } else if (table.primaryKey.length === 0) { + skipTable(table.name, "it has no primary key"); + } else if (table.primaryKey.length > 1) { + skipTable( + table.name, + `composite primary key (${list(table.primaryKey)}) is not supported`, + ); + } else { + plans.set(table.name, { + table, + columns: [], + comments: [], + body: [], + variable: "", + }); + } + } + + // 2. Columns. A table whose primary-key column cannot be expressed is skipped whole. + for (const plan of [...plans.values()]) { + const { table } = plan; + const pk = table.primaryKey[0]; + const tableSkips: Skip[] = []; + const tableWarnings: string[] = []; + const tableApproximated: string[] = []; + const tableRenamed: string[] = []; + const columnReasons = new Map(); + /** Single-column unique constraints: column → constraint name. */ + const singleUnique = new Map( + table.unique + .filter((unique) => unique.columns.length === 1) + .map((unique) => [unique.columns[0] as string, unique.name]), + ); + for (const unique of table.unique.filter((u) => u.columns.length > 1)) { + const skip = makeSkip( + table.name, + undefined, + `unique constraint (${list(unique.columns)}) spans several columns, which is not supported`, + ); + tableSkips.push(skip); + plan.comments.push(`// appkit: skipped ${skip.reason}`); + } + + for (const column of table.columns) { + const skipColumn = (reason: string) => { + const skip = makeSkip(table.name, column.name, reason); + tableSkips.push(skip); + columnReasons.set(column.name, reason); + plan.body.push(skipComment(skip)); + const generatesValue = + column.identity !== "" || column.generated || !!column.defaultExpr; + if (column.notNull && !generatesValue) { + tableWarnings.push( + `${qualified(table.name, column.name)} is NOT NULL with no default but is not in schema.ts (${reason}). Inserts through the API will fail until it gets a default, becomes nullable, or is dropped.`, + ); + } + }; + + if ( + column.name.startsWith("$") || + RESERVED_COLUMN_NAMES.has(column.name) + ) { + skipColumn(`the name "${column.name}" is reserved by AppKit`); + continue; + } + if (column.generated) { + skipColumn("generated columns are not supported"); + continue; + } + const base = baseType(column, schemaName); + if (typeof base === "string") { + skipColumn(base); + continue; + } + const isPrimaryKey = column.name === pk; + if (column.identity !== "" && !isPrimaryKey) { + skipColumn( + "identity columns other than the primary key are not supported", + ); + continue; + } + + const columnPlan: ColumnPlan = { + column, + base, + isPrimaryKey, + isUnique: singleUnique.has(column.name) && !isPrimaryKey, + comments: [], + }; + if (base.approximation) { + tableApproximated.push( + `${qualified(table.name, column.name)}: ${base.approximation}`, + ); + } + const uniqueName = singleUnique.get(column.name); + const migrationsUnique = pgName(`${table.name}_${column.name}_unique`); + if (columnPlan.isUnique && uniqueName !== migrationsUnique) { + tableRenamed.push(`${uniqueName} (${migrationsUnique})`); + } + const generatesKey = + column.identity !== "" || isSequenceDefault(column.defaultExpr); + if ( + isPrimaryKey && + generatesKey && + (base.storage === "integer" || base.storage === "bigint") + ) { + // A serial or identity key is the builder's server-generated id()/bigid(). + columnPlan.base = + base.storage === "integer" + ? { call: "id()", importName: "id", storage: "id" } + : { call: "bigid()", importName: "bigid", storage: "bigid" }; + if (column.identity !== "d") { + tableApproximated.push( + `${qualified(table.name, column.name)}: ${column.identity === "a" ? "GENERATED ALWAYS AS IDENTITY" : "a serial key"} is declared as ${columnPlan.base.call}, an identity generated BY DEFAULT`, + ); + } + } else if (column.defaultExpr) { + const parsed = defaultCall( + column.defaultExpr, + base, + column.enum?.values, + ); + if ("call" in parsed) { + columnPlan.defaultCall = parsed.call; + } else { + const skip = makeSkip(table.name, column.name, parsed.skip); + tableSkips.push(skip); + columnPlan.comments.push(`// appkit: skipped ${skip.reason}`); + if (column.notNull) { + tableWarnings.push( + `${qualified(table.name, column.name)}: its ${parsed.skip.replace(/ is not supported$/, "")} is not in schema.ts, so the API requires a value for it on insert.`, + ); + } + } + } + plan.columns.push(columnPlan); + plan.body.push(columnPlan); + } + + if (!plan.columns.some((c) => c.isPrimaryKey)) { + plans.delete(table.name); + skipTable( + table.name, + `its primary key column "${pk}" cannot be expressed (${columnReasons.get(pk as string) ?? "unsupported"})`, + ); + continue; + } + skipped.push(...tableSkips); + warnings.push(...tableWarnings); + if (table.partitioned) { + tableApproximated.push( + `${qualified(table.name)}: a partitioned table is declared as a plain table`, + ); + } + for (const [one, many, names] of [ + [ + "CHECK or EXCLUDE constraint", + "CHECK or EXCLUDE constraints", + table.checks, + ], + ["index", "indexes", table.indexes], + ] as const) { + if (names.length > 0) { + tableApproximated.push( + `${qualified(table.name)}: ${names.length === 1 ? `${one} ${names[0]} is` : `${many} ${list(names)} are`} not declared`, + ); + } + } + approximated.push(...tableApproximated); + renamed.push(...tableRenamed); + } + + // 3. Foreign keys the builder can express; the rest stay plain columns. + const edges = new Map>(); + const reaches = (from: string, to: string): boolean => { + const seen = new Set(); + const stack = [from]; + while (stack.length > 0) { + const node = stack.pop() as string; + if (node === to) return true; + if (seen.has(node)) continue; + seen.add(node); + stack.push(...(edges.get(node) ?? [])); + } + return false; + }; + /** Why the builder cannot express this FK; null when it can. */ + const fkProblem = ( + plan: TablePlan, + foreignKey: IntrospectedForeignKey, + source: ColumnPlan | undefined, + seenTargets: ReadonlySet, + ): string | null => { + if (foreignKey.columns.length !== 1) { + return "foreign keys over several columns are not supported"; + } + if (!source) return "its column is skipped"; + if (foreignKey.schema !== schemaName) { + return `it references schema "${foreignKey.schema}"`; + } + if (foreignKey.table === plan.table.name) { + // The builder takes one with a TableHandle annotation, which introspect does not write. + return "introspect does not write self-references"; + } + const target = plans.get(foreignKey.table); + if (!target) return `table "${foreignKey.table}" is skipped`; + const targetColumn = target.columns.find( + (c) => c.column.name === foreignKey.refColumns[0], + ); + if (!targetColumn) { + return `column ${foreignKey.table}.${foreignKey.refColumns[0]} is skipped`; + } + if (!targetColumn.isPrimaryKey && !targetColumn.isUnique) { + return `${foreignKey.table}.${foreignKey.refColumns[0]} is not a primary key or single-column unique key`; + } + if (seenTargets.has(foreignKey.table)) { + return `a second foreign key to "${foreignKey.table}" would make the relation ambiguous`; + } + if (plan.table.columns.some((c) => c.name === foreignKey.table)) { + return `the relation "${foreignKey.table}" would clash with the column of the same name`; + } + if (target.table.columns.some((c) => c.name === plan.table.name)) { + return `the reverse relation "${plan.table.name}" would clash with column ${foreignKey.table}.${plan.table.name}`; + } + if (reaches(foreignKey.table, plan.table.name)) { + return "it would close a cycle of foreign keys"; + } + if (mirrored(targetColumn.base) !== source.base.call) { + return `its type ${source.base.call} differs from ${foreignKey.table}.${foreignKey.refColumns[0]}`; + } + const actions = [foreignKey.onDelete, foreignKey.onUpdate]; + if (actions.includes("set null") && source.column.notNull) { + return "SET NULL on a NOT NULL column"; + } + const fkDefault = + source.defaultCall && !TYPED_DEFAULTS.has(source.defaultCall); + if (actions.includes("set default") && !fkDefault) { + return "SET DEFAULT without a default the builder can express"; + } + return null; + }; + + for (const plan of plans.values()) { + const targets = new Set(); + for (const foreignKey of plan.table.foreignKeys) { + const source = plan.columns.find( + (c) => c.column.name === foreignKey.columns[0], + ); + const reason = fkProblem(plan, foreignKey, source, targets); + if (reason) { + // A skipped column already carries its own comment. + if (source || foreignKey.columns.length > 1) { + const skip = makeSkip( + plan.table.name, + foreignKey.columns.join(", "), + `foreign key to ${foreignKey.table}(${list(foreignKey.refColumns)}) is not kept: ${reason}`, + ); + skipped.push(skip); + (source?.comments ?? plan.comments).push( + `// appkit: skipped ${skip.reason}`, + ); + } + continue; + } + const sourceColumn = source as ColumnPlan; + const target = plans.get(foreignKey.table) as TablePlan; + const targetColumn = target.columns.find( + (c) => c.column.name === foreignKey.refColumns[0], + ) as ColumnPlan; + sourceColumn.base = { ...targetColumn.base }; + if ( + sourceColumn.defaultCall && + TYPED_DEFAULTS.has(sourceColumn.defaultCall) + ) { + // fk() takes no defaultRandom()/defaultNow(); the key matters more than the default. + const skip = makeSkip( + plan.table.name, + sourceColumn.column.name, + `default ${sourceColumn.column.defaultExpr} is not supported on a foreign key column`, + ); + skipped.push(skip); + sourceColumn.comments.push(`// appkit: skipped ${skip.reason}`); + delete sourceColumn.defaultCall; + if (sourceColumn.column.notNull) { + warnings.push( + `${qualified(plan.table.name, sourceColumn.column.name)}: its default ${sourceColumn.column.defaultExpr} is not in schema.ts, so the API requires a value for it on insert.`, + ); + } + } + for (const [flag, what] of [ + [foreignKey.deferrable, "DEFERRABLE"], + [foreignKey.matchFull, "MATCH FULL"], + ] as const) { + if (flag) { + approximated.push( + `${qualified(plan.table.name, sourceColumn.column.name)}: the foreign key is declared without ${what}`, + ); + } + } + const migrationsFk = pgName( + `${plan.table.name}_${sourceColumn.column.name}_${foreignKey.table}_${foreignKey.refColumns[0]}_fk`, + ); + if (foreignKey.name !== migrationsFk) { + renamed.push(`${foreignKey.name} (${migrationsFk})`); + } + sourceColumn.fk = { + table: foreignKey.table, + column: foreignKey.refColumns[0] as string, + actions: [ + ...(foreignKey.onDelete !== "no action" + ? [`.onDelete(${q(foreignKey.onDelete)})`] + : []), + ...(foreignKey.onUpdate !== "no action" + ? [`.onUpdate(${q(foreignKey.onUpdate)})`] + : []), + ], + }; + targets.add(foreignKey.table); + const out = edges.get(plan.table.name) ?? new Set(); + out.add(foreignKey.table); + edges.set(plan.table.name, out); + } + } + + // 4. Declaration order: targets before the tables that reference them. + const ordered: TablePlan[] = []; + const placed = new Set(); + const place = (plan: TablePlan) => { + if (placed.has(plan.table.name)) return; + placed.add(plan.table.name); + for (const target of [...(edges.get(plan.table.name) ?? [])].sort()) { + const targetPlan = plans.get(target); + if (targetPlan) place(targetPlan); + } + ordered.push(plan); + }; + for (const plan of plans.values()) place(plan); + + // 5. Identifiers and imports. + const imports = new Set(["defineSchema"]); + for (const plan of ordered) { + for (const column of plan.columns) { + if (column.fk) imports.add("fk"); + else if (column.base.importName) imports.add(column.base.importName); + } + } + const usesEnum = ordered.some((plan) => + plan.columns.some((c) => !c.fk && c.base.storage === "enum"), + ); + const used = new Set([...imports, "table", "enumType", "schema"]); + for (const plan of ordered) { + let candidate = plan.table.name.replace(/[^A-Za-z0-9_$]/g, "_"); + if (!/^[A-Za-z_$]/.test(candidate)) candidate = `_${candidate}`; + if (used.has(candidate) || JS_RESERVED.has(candidate)) { + candidate = `${candidate}Table`; + } + let variable = candidate; + for (let n = 2; used.has(variable); n++) variable = `${candidate}${n}`; + used.add(variable); + plan.variable = variable; + } + + // 6. Source. + const lines: string[] = [ + `// Generated by \`appkit db introspect\` from schema "${shown(schemaName)}". Edit tables here,`, + "// then run `npx appkit db migrate` on your dev branch.", + importLine( + [...imports].sort(), + options.importSource ?? "@databricks/appkit/beta", + ), + "", + ]; + // Only a schema other than the default is passed to defineSchema(). + const schemaOption = + schemaName === DEFAULT_SCHEMA_NAME + ? undefined + : `{ schemaName: ${q(schemaName)} }`; + if (renamed.length > 0) { + warnings.push( + `Constraint names differ from the ones migrations use: ${list(renamed)}. A later migration that drops or changes one of them fails until it is renamed in the database (ALTER TABLE … RENAME CONSTRAINT).`, + ); + } + const result = (source: string): SchemaSource => ({ + source, + tables: ordered.map((plan) => plan.table.name), + skipped, + warnings: warnings.map(shown), + approximated: approximated.map(shown), + }); + if (ordered.length === 0) { + lines.push(...tableComments); + lines.push( + `export const schema = defineSchema(() => ({})${schemaOption ? `, ${schemaOption}` : ""});`, + "", + ); + return result(lines.join("\n")); + } + + // The builder's body, indented one level. + const body: string[] = []; + for (const plan of ordered) { + body.push(` const ${plan.variable} = table(${q(plan.table.name)}, {`); + for (const comment of plan.comments) body.push(` ${comment}`); + for (const entry of plan.body) { + if (typeof entry === "string") { + body.push(` ${entry}`); + continue; + } + const column = entry; + for (const comment of column.comments) body.push(` ${comment}`); + const chain: string[] = []; + const head = column.fk + ? `fk(() => ${access((plans.get(column.fk.table) as TablePlan).variable, column.fk.column)})` + : column.base.call; + const serverKey = + !column.fk && + (column.base.storage === "id" || column.base.storage === "bigid"); + if (column.isPrimaryKey && !serverKey) chain.push(".primaryKey()"); + else if (column.column.notNull && !serverKey) chain.push(".notNull()"); + if (column.isUnique) chain.push(".unique()"); + if (column.defaultCall) chain.push(column.defaultCall); + chain.push(...(column.fk?.actions ?? [])); + body.push(` ${key(column.column.name)}: ${head}${chain.join("")},`); + } + body.push(" });", ""); + } + for (const comment of tableComments) body.push(` ${comment}`); + if (tableComments.length > 0) body.push(""); + const entries = ordered.map((plan) => + plan.variable === plan.table.name + ? plan.variable + : `${key(plan.table.name)}: ${plan.variable}`, + ); + body.push(` return { ${entries.join(", ")} };`); + + const builder = `({ table${usesEnum ? ", enum: enumType" : ""} }) =>`; + if (schemaOption) { + lines.push( + "export const schema = defineSchema(", + ` ${builder} {`, + ...body.map((line) => (line ? ` ${line}` : line)), + " },", + ` ${schemaOption},`, + ");", + "", + ); + } else { + lines.push( + `export const schema = defineSchema(${builder} {`, + ...body, + "});", + "", + ); + } + return result(lines.join("\n")); +} diff --git a/packages/appkit/src/database/tooling/introspect/tests/catalog.test.ts b/packages/appkit/src/database/tooling/introspect/tests/catalog.test.ts new file mode 100644 index 000000000..48831febc --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/catalog.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "vitest"; + +import { buildTables, readCatalog } from "../catalog"; +import { catalogResponder, catalogRows } from "./fixture"; + +describe("readCatalog", () => { + test("runs four pg_catalog queries for the schema, excluding the migration journal", async () => { + const { calls, respond } = catalogResponder(); + + await readCatalog(respond, "app"); + + expect(calls).toHaveLength(4); + for (const call of calls) { + expect(call.values).toEqual(["app", "__drizzle_migrations"]); + expect(call.text).toContain("pg_catalog."); + expect(call.text).not.toContain("information_schema"); + } + const [, , constraints, indexes] = calls.map((call) => call.text); + // Copies of a foreign key to a partitioned table are left out. + expect(constraints).toContain("k.conparentid = 0"); + expect(constraints).toContain("k.contype in ('p', 'u', 'f', 'c', 'x')"); + // Indexes behind a key or exclusion constraint are not reported twice. + expect(indexes).toContain("from pg_catalog.pg_index x"); + expect(indexes).toContain("k.contype in ('p', 'u', 'x')"); + }); + + test("maps catalog rows to tables, columns, keys, foreign keys and enums", async () => { + const { respond } = catalogResponder(); + const [authors, posts] = await readCatalog(respond, "app"); + + expect(authors).toEqual({ + name: "authors", + partitioned: false, + primaryKey: ["id"], + unique: [{ name: "authors_email_key", columns: ["email"] }], + foreignKeys: [], + checks: [], + indexes: [], + columns: [ + { + name: "id", + type: "integer", + notNull: true, + identity: "", + generated: false, + defaultExpr: "nextval('app.authors_id_seq'::regclass)", + }, + { + name: "email", + type: "character varying(255)", + notNull: true, + identity: "", + generated: false, + defaultExpr: null, + }, + { + name: "created_at", + type: "timestamp with time zone", + notNull: true, + identity: "", + generated: false, + defaultExpr: "now()", + }, + ], + }); + expect(posts?.primaryKey).toEqual(["id"]); + expect(posts?.foreignKeys).toEqual([ + { + name: "posts_author_id_fkey", + columns: ["author_id"], + schema: "app", + table: "authors", + refColumns: ["id"], + onDelete: "cascade", + onUpdate: "no action", + deferrable: false, + matchFull: false, + }, + ]); + expect(posts?.indexes).toEqual(["posts_status_idx"]); + expect(posts?.columns.find((c) => c.name === "id")?.identity).toBe("d"); + expect(posts?.columns.find((c) => c.name === "total")?.generated).toBe( + true, + ); + expect(posts?.columns.find((c) => c.name === "status")?.enum).toEqual({ + name: "ticket_status", + schema: "app", + values: ["open", "closed"], + }); + }); + + test("partitioned tables, CHECK and EXCLUDE constraints, indexes, DEFERRABLE and MATCH FULL are kept for the report", () => { + const row = (kind: "c" | "x" | "f", name: string, extra = {}) => ({ + ...catalogRows.constraints[2], + kind, + name, + ...extra, + }); + const [table] = buildTables( + [{ name: "posts", partitioned: true }], + [], + [ + row("c", "posts_score_check"), + row("x", "posts_no_overlap"), + row("f", "posts_author_id_fkey", { + deferrable: true, + match_type: "f", + }), + ] as never, + [ + { table_name: "posts", name: "posts_status_idx" }, + { table_name: "authors", name: "authors_name_idx" }, + ], + ); + expect(table).toMatchObject({ + partitioned: true, + checks: ["posts_score_check", "posts_no_overlap"], + indexes: ["posts_status_idx"], + foreignKeys: [{ deferrable: true, matchFull: true }], + }); + }); + + test("referential action letters map to builder actions", () => { + const fkRow = (on_delete: string, on_update: string) => ({ + ...catalogRows.constraints[2], + on_delete, + on_update, + }); + const [table] = buildTables( + [{ name: "posts", partitioned: false }], + [], + ["r", "c", "n", "d", "a"].map((letter) => fkRow(letter, letter)) as never, + ); + expect(table?.foreignKeys.map((fk) => [fk.onDelete, fk.onUpdate])).toEqual([ + ["restrict", "restrict"], + ["cascade", "cascade"], + ["set null", "set null"], + ["set default", "set default"], + ["no action", "no action"], + ]); + }); +}); diff --git a/packages/appkit/src/database/tooling/introspect/tests/fixture.ts b/packages/appkit/src/database/tooling/introspect/tests/fixture.ts new file mode 100644 index 000000000..9c87d4c32 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/fixture.ts @@ -0,0 +1,283 @@ +import path from "node:path"; + +import type { + CatalogQuery, + ColumnRow, + ConstraintRow, + IndexRow, + IntrospectedColumn, + IntrospectedForeignKey, + IntrospectedTable, + IntrospectedUnique, + TableRow, +} from "../catalog"; + +/** The builder by absolute path, so rendered sources load from a temp root. */ +export const BUILDER = path.resolve( + import.meta.dirname, + "../../../schema-builder/index.ts", +); + +export const col = ( + name: string, + type: string, + extra: Partial = {}, +): IntrospectedColumn => ({ + name, + type, + notNull: false, + identity: "", + generated: false, + defaultExpr: null, + ...extra, +}); + +/** A catalog table; what a test does not set is empty. */ +export const tableOf = ( + table: Pick & + Partial, +): IntrospectedTable => ({ + partitioned: false, + primaryKey: [], + unique: [], + foreignKeys: [], + checks: [], + indexes: [], + ...table, +}); + +export const foreignKey = ( + key: Omit & + Partial, +): IntrospectedForeignKey => ({ deferrable: false, matchFull: false, ...key }); + +/** A unique constraint with the name Postgres gives it. */ +export const uniqueKey = ( + table: string, + ...columns: string[] +): IntrospectedUnique => ({ + name: `${table}_${columns.join("_")}_key`, + columns, +}); + +const status = { + name: "ticket_status", + schema: "app", + values: ["open", "closed"], +}; + +/** Supported types, defaults, FKs and enums, plus one of each skip. Listed out of dependency order. */ +export const richTables: IntrospectedTable[] = [ + tableOf({ + name: "posts", + primaryKey: ["id"], + unique: [], + foreignKeys: [ + foreignKey({ + name: "posts_author_id_fkey", + columns: ["author_id"], + schema: "app", + table: "authors", + refColumns: ["id"], + onDelete: "cascade", + onUpdate: "no action", + }), + foreignKey({ + name: "posts_editor_id_fkey", + columns: ["editor_id"], + schema: "app", + table: "authors", + refColumns: ["id"], + onDelete: "set null", + onUpdate: "no action", + }), + ], + columns: [ + col("id", "uuid", { notNull: true, defaultExpr: "gen_random_uuid()" }), + col("author_id", "integer", { notNull: true }), + col("editor_id", "integer"), + col("status", "app.ticket_status", { + notNull: true, + defaultExpr: "'open'::app.ticket_status", + enum: status, + }), + col("body", "jsonb", { defaultExpr: "'{}'::jsonb" }), + col("views", "bigint", { notNull: true, defaultExpr: "0" }), + col("my col", "character varying"), + ], + }), + tableOf({ + name: "authors", + primaryKey: ["id"], + unique: [uniqueKey("authors", "email")], + foreignKeys: [], + columns: [ + col("id", "integer", { + notNull: true, + defaultExpr: "nextval('app.authors_id_seq'::regclass)", + }), + col("email", "character varying(255)", { notNull: true }), + col("bio", "text", { defaultExpr: "'it''s me'::text" }), + col("active", "boolean", { notNull: true, defaultExpr: "true" }), + col("score", "numeric(10,2)", { notNull: true }), + col("rank", "integer", { defaultExpr: "'-1'::integer" }), + col("created_at", "timestamp with time zone", { + notNull: true, + defaultExpr: "now()", + }), + col("seen_at", "timestamp(3) without time zone"), + ], + }), + tableOf({ + name: "events", + primaryKey: ["a", "b"], + unique: [], + foreignKeys: [], + columns: [ + col("a", "integer", { notNull: true }), + col("b", "integer", { notNull: true }), + ], + }), + tableOf({ + name: "logs", + primaryKey: [], + unique: [], + foreignKeys: [], + columns: [col("line", "text")], + }), + tableOf({ + name: "default", + primaryKey: ["id"], + unique: [], + foreignKeys: [], + columns: [col("id", "bigint", { notNull: true, identity: "d" })], + }), +]; + +export const columnRow = ( + table_name: string, + name: string, + type: string, + extra: Partial = {}, +): ColumnRow => ({ + table_name, + name, + type, + not_null: false, + identity: "", + generated: "", + default_expr: null, + type_kind: "b", + type_schema: "pg_catalog", + type_name: type, + enum_values: null, + ...extra, +}); + +/** What the three pg_catalog queries return for a small hand-made schema. */ +export const catalogRows = { + tables: [ + { name: "authors", partitioned: false }, + { name: "posts", partitioned: false }, + ] satisfies TableRow[], + columns: [ + columnRow("authors", "id", "integer", { + not_null: true, + default_expr: "nextval('app.authors_id_seq'::regclass)", + }), + columnRow("authors", "email", "character varying(255)", { + not_null: true, + }), + columnRow("authors", "created_at", "timestamp with time zone", { + not_null: true, + default_expr: "now()", + }), + columnRow("posts", "id", "integer", { not_null: true, identity: "d" }), + columnRow("posts", "author_id", "integer", { not_null: true }), + columnRow("posts", "status", "app.ticket_status", { + not_null: true, + default_expr: "'open'::app.ticket_status", + type_kind: "e", + type_schema: "app", + type_name: "ticket_status", + enum_values: ["open", "closed"], + }), + columnRow("posts", "score", "numeric(10,2)", { not_null: true }), + columnRow("posts", "total", "integer", { + generated: "s", + default_expr: "1", + }), + ] satisfies ColumnRow[], + constraints: [ + { + table_name: "authors", + name: "authors_email_key", + kind: "u", + columns: ["email"], + ref_schema: null, + ref_table: null, + ref_columns: [], + on_delete: " ", + on_update: " ", + }, + { + table_name: "authors", + name: "authors_pkey", + kind: "p", + columns: ["id"], + ref_schema: null, + ref_table: null, + ref_columns: [], + on_delete: " ", + on_update: " ", + }, + { + table_name: "posts", + name: "posts_author_id_fkey", + kind: "f", + columns: ["author_id"], + ref_schema: "app", + ref_table: "authors", + ref_columns: ["id"], + on_delete: "c", + on_update: "a", + }, + { + table_name: "posts", + name: "posts_pkey", + kind: "p", + columns: ["id"], + ref_schema: null, + ref_table: null, + ref_columns: [], + on_delete: " ", + on_update: " ", + }, + ] satisfies ConstraintRow[], + indexes: [ + { table_name: "posts", name: "posts_status_idx" }, + ] satisfies IndexRow[], +}; + +/** A CatalogQuery (and pg-like pool) answering the four catalog queries from rows. */ +export function catalogResponder(rows = catalogRows) { + const calls: Array<{ text: string; values: readonly unknown[] }> = []; + const respond: CatalogQuery = async ( + text: string, + values: readonly unknown[], + ) => { + calls.push({ text, values }); + // The index query also mentions pg_constraint, so it is matched first. + const result = text.includes("pg_catalog.pg_index") + ? rows.indexes + : text.includes("pg_catalog.pg_constraint") + ? rows.constraints + : text.includes("format_type") + ? rows.columns + : text.includes("select c.relname::text as name") + ? rows.tables + : []; + return result as unknown as T[]; + }; + return { calls, respond }; +} diff --git a/packages/appkit/src/database/tooling/introspect/tests/golden/rich.schema.ts.txt b/packages/appkit/src/database/tooling/introspect/tests/golden/rich.schema.ts.txt new file mode 100644 index 000000000..2fe3e54d5 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/golden/rich.schema.ts.txt @@ -0,0 +1,51 @@ +// Generated by `appkit db introspect` from schema "app". Edit tables here, +// then run `npx appkit db migrate` on your dev branch. +import { + bigid, + bigint, + boolean, + defineSchema, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "@databricks/appkit/beta"; + +export const schema = defineSchema(({ table, enum: enumType }) => { + const authors = table("authors", { + id: id(), + email: varchar(255).notNull().unique(), + bio: text().default("it's me"), + active: boolean().notNull().default(true), + // appkit: skipped column "score": type "numeric(10,2)" is not supported + rank: integer().default(-1), + created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), + seen_at: timestamp(), + }); + + const posts = table("posts", { + id: uuid().primaryKey().defaultRandom(), + author_id: fk(() => authors.id).notNull().onDelete("cascade"), + // appkit: skipped foreign key to authors(id) is not kept: a second foreign key to "authors" would make the relation ambiguous + editor_id: integer(), + status: enumType("ticket_status", ["open", "closed"]).notNull().default("open"), + // appkit: skipped default '{}'::jsonb is not supported + body: jsonb(), + // appkit: skipped default 0 is not supported + views: bigint().notNull(), + "my col": text(), + }); + + const defaultTable = table("default", { + id: bigid(), + }); + + // appkit: skipped table "events": composite primary key (a, b) is not supported + // appkit: skipped table "logs": it has no primary key + + return { authors, posts, default: defaultTable }; +}); diff --git a/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts b/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts new file mode 100644 index 000000000..d9c7fb3ef --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts @@ -0,0 +1,562 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { checkAppkitBranch, markMigrationsApplied, openBranchPool } = vi.hoisted( + () => ({ + checkAppkitBranch: vi.fn(), + markMigrationsApplied: vi.fn(), + openBranchPool: vi.fn(), + }), +); + +vi.mock("../../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); +vi.mock("../../guard", async (importOriginal) => ({ + ...(await importOriginal()), + checkAppkitBranch, +})); +vi.mock("../../../migrations/apply", async (importOriginal) => ({ + ...(await importOriginal()), + markMigrationsApplied, +})); + +import { loadDefaultDatabaseSchema } from "../../../../plugins/database/load-schema"; +import { readJournal } from "../../../migrations/paths"; +import { generateMigrationFiles } from "../../migrations"; +import { cleanupTempApps, fakeContext, tempApp } from "../../tests/fakes"; +import type { ColumnRow } from "../catalog"; +import { IntrospectRefusedError, runIntrospect } from "../index"; +import { BUILDER, catalogResponder, columnRow } from "./fixture"; + +const SCHEMA = "config/database/schema.ts"; +const MIGRATIONS = "config/database/migrations"; +const DEV = "projects/p/branches/dev-alice-3f9a2c"; +const PRODUCTION = "projects/p/branches/production"; + +const previousSchema = `import { defineSchema, id, text } from ${JSON.stringify(BUILDER)}; +export const schema = defineSchema(({ table }) => ({ + authors: table("authors", { id: id(), email: text() }), +})); +`; + +const onDevBranch = { + branch: { name: DEV }, + verdict: { appkitOwned: true }, +}; +const onProduction = { + branch: { name: PRODUCTION, status: { default: true } }, + verdict: { appkitOwned: false, reason: "it is the project's default branch" }, +}; + +let catalog: ReturnType; +/** `when` of the last migration the branch recorded; null: no journal table. */ +let recorded: number | null; +const lastWhen = (root: string) => + readJournal(path.join(root, MIGRATIONS))?.entries.at(-1)?.when ?? null; +const pool = { + query: vi.fn(async (text: string, values: unknown[]) => { + // The branch's migration journal (pendingMigrationTags). + if (text.includes("max(created_at)")) return { rows: [{ last: recorded }] }; + if (text.includes("c.relname = $2")) { + return { rows: recorded === null ? [] : [{ "?column?": 1 }] }; + } + return { rows: await catalog.respond(text, values) }; + }), + end: vi.fn(async () => undefined), +}; + +beforeEach(() => { + catalog = catalogResponder(); + recorded = null; + checkAppkitBranch.mockReset().mockResolvedValue(onDevBranch); + markMigrationsApplied + .mockReset() + .mockImplementation( + async (_pool: unknown, options: { migrationsFolder: string }) => { + recorded = + readJournal(options.migrationsFolder)?.entries.at(-1)?.when ?? null; + }, + ); + openBranchPool.mockReset().mockResolvedValue(pool); + pool.end.mockClear(); +}); +afterEach(cleanupTempApps); + +/** A catalog with only authors(id), plus the given authors columns. */ +const authorsOnly = (...columns: ColumnRow[]) => + catalogResponder({ + tables: [{ name: "authors", partitioned: false }], + columns: [ + columnRow("authors", "id", "integer", { not_null: true, identity: "d" }), + ...columns, + ], + constraints: [ + { + table_name: "authors", + name: "authors_pkey", + kind: "p", + columns: ["id"], + ref_schema: null, + ref_table: null, + ref_columns: [], + on_delete: " ", + on_update: " ", + }, + ], + indexes: [], + }); + +/** Commit a migration of the current schema.ts; `applied`: the branch has recorded it. */ +async function commitMigration(root: string, applied = true): Promise { + await generateMigrationFiles({ + folder: path.join(root, MIGRATIONS), + schema: await loadDefaultDatabaseSchema(root), + interactive: false, + }); + if (applied) recorded = lastWhen(root); +} + +/** Every file under config/, with its contents. */ +function tree(root: string): Record { + const files: Record = {}; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else files[path.relative(root, full)] = fs.readFileSync(full, "utf-8"); + } + }; + if (fs.existsSync(path.join(root, "config"))) walk(path.join(root, "config")); + return files; +} + +const ctxFor = (root: string) => fakeContext({ root }); +const introspect = (root: string) => + runIntrospect(ctxFor(root), { interactive: false, importSource: BUILDER }); + +describe("runIntrospect", () => { + test("on an AppKit branch without history: schema.ts (+ .bak), 0000_baseline recorded as applied, database.d.ts", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + + const report = await introspect(root); + + expect(catalog.calls.every((call) => call.values[0] === "app")).toBe(true); + expect(fs.readFileSync(path.join(root, `${SCHEMA}.bak`), "utf-8")).toBe( + previousSchema, + ); + const written = fs.readFileSync(path.join(root, SCHEMA), "utf-8"); + expect(written).toContain('const authors = table("authors", {'); + expect(written).toContain( + 'author_id: fk(() => authors.id).notNull().onDelete("cascade"),', + ); + expect(report).toMatchObject({ + branch: DEV, + schemaName: "app", + tables: ["authors", "posts"], + schemaFile: path.join(root, SCHEMA), + backupFile: path.join(root, `${SCHEMA}.bak`), + recordedApplied: true, + migration: { tag: "0000_baseline" }, + }); + expect(report.skipped.map((skip) => skip.column)).toEqual([ + "score", + "total", + ]); + expect(report.warnings).toEqual([ + expect.stringMatching(/^app\.posts\.score is NOT NULL with no default/), + expect.stringMatching( + /^Constraint names differ from the ones migrations use: authors_email_key \(authors_email_unique\), posts_author_id_fkey \(posts_author_id_authors_id_fk\)\./, + ), + ]); + expect(report.approximated).toEqual([ + "app.authors.id: a serial key is declared as id(), an identity generated BY DEFAULT", + "app.posts: index posts_status_idx is not declared", + ]); + expect( + fs.existsSync(path.join(root, MIGRATIONS, "0000_baseline.sql")), + ).toBe(true); + expect(markMigrationsApplied).toHaveBeenCalledWith( + pool, + { migrationsFolder: path.join(root, MIGRATIONS), schemaName: "app" }, + ["0000_baseline"], + ); + expect(fs.readFileSync(report.typesFile, "utf-8")).toContain("authors"); + expect(pool.end).toHaveBeenCalledOnce(); + // The written schema is what the plugin loads. + await expect(loadDefaultDatabaseSchema(root)).resolves.toMatchObject({ + $schemaName: "app", + }); + }); + + test("on another branch without history: writes 0000_baseline but never records it (the deployed app adopts it)", async () => { + checkAppkitBranch.mockResolvedValue(onProduction); + const root = tempApp({ [SCHEMA]: previousSchema }); + + const report = await introspect(root); + + expect(report.migration?.tag).toBe("0000_baseline"); + expect(report.recordedApplied).toBe(false); + expect(report.notRecorded).toBe( + "production is not your dev or session branch (it is the project's default branch); the deployed app adopts 0000_baseline on start when its tables already exist", + ); + expect(markMigrationsApplied).not.toHaveBeenCalled(); + }); + + test("on another branch with history: refuses before reading or writing anything", async () => { + checkAppkitBranch.mockResolvedValue(onProduction); + const root = tempApp({ [SCHEMA]: previousSchema }); + const schema = await loadDefaultDatabaseSchema(root); + await generateMigrationFiles({ + folder: path.join(root, MIGRATIONS), + schema, + interactive: false, + }); + const before = fs.readdirSync(path.join(root, MIGRATIONS)); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toMatch( + /^Refusing to introspect production: config\/database\/migrations already has history/, + ); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toBe( + previousSchema, + ); + expect(fs.existsSync(path.join(root, `${SCHEMA}.bak`))).toBe(false); + expect(fs.readdirSync(path.join(root, MIGRATIONS))).toEqual(before); + }); + + test("on an AppKit branch with history: writes and records the next NNNN_introspect", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + const schema = await loadDefaultDatabaseSchema(root); + await generateMigrationFiles({ + folder: path.join(root, MIGRATIONS), + schema, + interactive: false, + }); + recorded = lastWhen(root); + + const report = await introspect(root); + + expect(report.migration?.tag).toBe("0001_introspect"); + expect(report.migration?.statements.join("\n")).toContain( + 'CREATE TABLE "app"."posts"', + ); + expect(markMigrationsApplied).toHaveBeenCalledWith( + pool, + expect.anything(), + ["0001_introspect"], + ); + }); + + test("a second run with no catalog change writes no migration and records nothing", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await introspect(root); + markMigrationsApplied.mockClear(); + + const report = await introspect(root); + + expect(report.migration).toBeNull(); + expect(report.recordedApplied).toBe(false); + expect(markMigrationsApplied).not.toHaveBeenCalled(); + }); + + test("without schema.ts it reads the app schema and creates the file", async () => { + const root = tempApp(); + + const report = await introspect(root); + + expect(catalog.calls.every((call) => call.values[0] === "app")).toBe(true); + expect(report).toMatchObject({ + schemaName: "app", + backupFile: null, + tables: ["authors", "posts"], + migration: { tag: "0000_baseline" }, + recordedApplied: true, + }); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toMatch( + /from schema "app"\.[^]*export const schema = defineSchema\(\(\{ table[^}]*\}\) => \{/, + ); + expect(fs.existsSync(path.join(root, `${SCHEMA}.bak`))).toBe(false); + }); + + test("--schema-name picks another schema", async () => { + const root = tempApp(); + + const report = await runIntrospect(ctxFor(root), { + interactive: false, + importSource: BUILDER, + schemaName: "sales", + }); + + expect(catalog.calls.every((call) => call.values[0] === "sales")).toBe( + true, + ); + expect(report.schemaName).toBe("sales"); + }); + + test.each([ + ["a syntax error", "export const schema = ;\n"], + ["a single line", "// todo\n"], + [ + "a missing import", + `import { defineSchema } from ${JSON.stringify(BUILDER)};\nexport const schema = defineSchema(({ table }) => ({ x: table("x", { id: id() }) }));\n`, + ], + ])( + "a schema.ts with %s is never read: it goes to .bak and is replaced from the app schema", + async (_label, broken) => { + const root = tempApp({ [SCHEMA]: broken }); + + const report = await introspect(root); + + expect(catalog.calls.every((call) => call.values[0] === "app")).toBe( + true, + ); + expect(report).toMatchObject({ + schemaName: "app", + tables: ["authors", "posts"], + backupFile: path.join(root, `${SCHEMA}.bak`), + migration: { tag: "0000_baseline" }, + }); + expect(fs.readFileSync(path.join(root, `${SCHEMA}.bak`), "utf-8")).toBe( + broken, + ); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toMatch( + /from schema "app"\.[^]*export const schema = defineSchema\(\(\{ table[^}]*\}\) => \{/, + ); + }, + ); + + test("--schema-name public is written explicitly, so the app never looks for the tables in the default app schema", async () => { + const root = tempApp(); + + const report = await runIntrospect(ctxFor(root), { + interactive: false, + importSource: BUILDER, + schemaName: "public", + }); + + expect(report.schemaName).toBe("public"); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toContain( + '{ schemaName: "public" }', + ); + expect((await loadDefaultDatabaseSchema(root)).$schemaName).toBe("public"); + }); + + test("the schema schema.ts declares does not change which schema is read", async () => { + const root = tempApp({ + [SCHEMA]: previousSchema.replace("}));", '}), { schemaName: "sales" });'), + }); + + const report = await introspect(root); + + expect(catalog.calls.every((call) => call.values[0] === "app")).toBe(true); + expect(report.schemaName).toBe("app"); + }); + + test("an empty schema next to an existing schema.ts refuses before writing anything", async () => { + catalog = catalogResponder({ + tables: [], + columns: [], + constraints: [], + indexes: [], + }); + const root = tempApp({ [SCHEMA]: previousSchema }); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Schema "app" on dev-alice-3f9a2c has no tables, so introspect would replace config/database/schema.ts with an empty one. Pass --schema-name to read another schema.', + ); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toBe( + previousSchema, + ); + expect(fs.existsSync(path.join(root, `${SCHEMA}.bak`))).toBe(false); + expect(fs.existsSync(path.join(root, MIGRATIONS))).toBe(false); + }); + + test("an empty schema without schema.ts still writes an empty one", async () => { + catalog = catalogResponder({ + tables: [], + columns: [], + constraints: [], + indexes: [], + }); + const root = tempApp(); + + const report = await introspect(root); + + expect(report.tables).toEqual([]); + expect(report.migration).toBeNull(); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toContain( + "export const schema = defineSchema(() => ({}));", + ); + }); + + test("without a configured branch it fails before writing", async () => { + checkAppkitBranch.mockResolvedValue(null); + const root = tempApp({ [SCHEMA]: previousSchema }); + + await expect(introspect(root)).rejects.toThrow( + /No branch configured.*appkit db init/, + ); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toBe( + previousSchema, + ); + }); + + test("with history, a branch that has not applied every committed migration refuses before reading the catalog", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await commitMigration(root); + // A teammate's migration, pulled but not applied on this branch yet. + fs.writeFileSync( + path.join(root, SCHEMA), + previousSchema.replace( + "email: text() }),", + 'email: text() }),\n tags: table("tags", { id: id() }),', + ), + ); + await commitMigration(root, false); + const before = tree(root); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Refusing to introspect dev-alice-3f9a2c: it has not applied 0001_migration in schema "app", so the migration would undo it. Run `appkit db migrate` first.', + ); + expect(catalog.calls).toEqual([]); + expect(tree(root)).toEqual(before); + }); + + test("with history and no schema.ts, an empty schema refuses instead of dropping every table", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await commitMigration(root); + fs.rmSync(path.join(root, SCHEMA)); + catalog = catalogResponder({ + tables: [], + columns: [], + constraints: [], + indexes: [], + }); + const before = tree(root); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Schema "app" on dev-alice-3f9a2c has no tables, so introspect would write a migration that drops every table. Pass --schema-name to read another schema.', + ); + expect(tree(root)).toEqual(before); + }); + + test("with history, a migration that drops what introspect skipped is refused and nothing changes", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await commitMigration(root); + // email still exists, with a type schema.ts cannot declare. + catalog = authorsOnly(columnRow("authors", "email", "numeric")); + const before = tree(root); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Refusing to write a migration that drops what schema.ts cannot express (authors.email): the deployed app would run ALTER TABLE "app"."authors" DROP COLUMN "email";', + ); + expect(tree(root)).toEqual(before); + expect(markMigrationsApplied).not.toHaveBeenCalled(); + }); + + test("with history, dropping what the branch no longer has is written, recorded and warned about", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await commitMigration(root); + catalog = authorsOnly(); + + const report = await introspect(root); + + expect(report.migration?.tag).toBe("0001_introspect"); + expect(report.migration?.statements).toEqual([ + 'ALTER TABLE "app"."authors" DROP COLUMN "email";', + ]); + expect(report.recordedApplied).toBe(true); + expect(report.warnings).toEqual([ + '0001_introspect drops what the branch no longer has, and the deployed app runs it on start: ALTER TABLE "app"."authors" DROP COLUMN "email";', + ]); + }); + + test("when recording fails, schema.ts, its backup and the migrations are put back, so a rerun records", async () => { + const root = tempApp({ + [SCHEMA]: previousSchema, + [`${SCHEMA}.bak`]: "// an older backup\n", + }); + const before = tree(root); + markMigrationsApplied.mockRejectedValueOnce(new Error("lock timeout")); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe( + "Introspect changed nothing: lock timeout", + ); + expect(tree(root)).toEqual(before); + expect(fs.existsSync(path.join(root, MIGRATIONS))).toBe(false); + + const report = await introspect(root); + expect(report).toMatchObject({ + migration: { tag: "0000_baseline" }, + recordedApplied: true, + }); + }); + + test("a second run with the same catalog keeps the original .bak", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await introspect(root); + + const second = await introspect(root); + + expect(second.backupFile).toBeNull(); + expect(fs.readFileSync(path.join(root, `${SCHEMA}.bak`), "utf-8")).toBe( + previousSchema, + ); + }); + + test("when every table is skipped, an existing schema.ts is kept", async () => { + catalog = catalogResponder({ + tables: [{ name: "logs", partitioned: false }], + columns: [columnRow("logs", "line", "text")], + constraints: [], + indexes: [], + }); + const root = tempApp({ [SCHEMA]: previousSchema }); + const before = tree(root); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Schema "app" on dev-alice-3f9a2c has no tables introspect can write (logs: it has no primary key), so introspect would replace config/database/schema.ts with an empty one. Pass --schema-name to read another schema.', + ); + expect(tree(root)).toEqual(before); + }); + + test("a generated schema.ts that does not load is reported as a bug, and schema.ts is put back", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + const before = tree(root); + + const error = await runIntrospect(ctxFor(root), { + interactive: false, + importSource: path.join(root, "missing-builder.ts"), + }).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe( + 'Introspect changed nothing: The schema.ts introspect generated does not load. This is a bug in `appkit db introspect`; please report it with the catalog of schema "app".', + ); + expect(tree(root)).toEqual(before); + expect(markMigrationsApplied).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/appkit/src/database/tooling/introspect/tests/render.test.ts b/packages/appkit/src/database/tooling/introspect/tests/render.test.ts new file mode 100644 index 000000000..5ed7e6fb5 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/render.test.ts @@ -0,0 +1,538 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import type { IntrospectedForeignKey, IntrospectedTable } from "../catalog"; +import { renderSchemaSource } from "../render"; +import { col, richTables, tableOf, uniqueKey } from "./fixture"; + +const golden = (name: string) => + fs.readFileSync(path.join(import.meta.dirname, "golden", name), "utf-8"); + +const fk = ( + column: string, + table: string, + extra: Partial = {}, +): IntrospectedForeignKey => ({ + name: `${column}_fkey`, + columns: [column], + schema: "app", + table, + refColumns: ["id"], + onDelete: "no action", + onUpdate: "no action", + deferrable: false, + matchFull: false, + ...extra, +}); + +/** A table with an integer identity key plus the given columns and FKs. */ +const keyed = ( + name: string, + columns: IntrospectedTable["columns"] = [], + foreignKeys: IntrospectedForeignKey[] = [], + unique: string[][] = [], +): IntrospectedTable => + tableOf({ + name, + primaryKey: ["id"], + unique: unique.map((columns) => uniqueKey(name, ...columns)), + foreignKeys, + columns: [ + col("id", "integer", { notNull: true, identity: "d" }), + ...columns, + ], + }); + +const body = (source: string) => + source.slice(source.indexOf("export const schema")); + +describe("renderSchemaSource", () => { + test("golden: supported types, defaults, FKs and enums, with skip comments", () => { + const out = renderSchemaSource(richTables, "app"); + + expect(out.source).toBe(golden("rich.schema.ts.txt")); + expect(out.skipped).toEqual( + expect.arrayContaining([ + { + table: "events", + reason: "composite primary key (a, b) is not supported", + }, + { table: "logs", reason: "it has no primary key" }, + { + table: "authors", + column: "score", + reason: 'type "numeric(10,2)" is not supported', + }, + { + table: "posts", + column: "body", + reason: "default '{}'::jsonb is not supported", + }, + { + table: "posts", + column: "views", + reason: "default 0 is not supported", + }, + { + table: "posts", + column: "editor_id", + reason: + 'foreign key to authors(id) is not kept: a second foreign key to "authors" would make the relation ambiguous', + }, + ]), + ); + expect(out.skipped).toHaveLength(6); + }); + + test("a skipped NOT NULL column without a default is a loud warning", () => { + const { warnings } = renderSchemaSource(richTables, "app"); + expect(warnings).toContain( + 'app.authors.score is NOT NULL with no default but is not in schema.ts (type "numeric(10,2)" is not supported). Inserts through the API will fail until it gets a default, becomes nullable, or is dropped.', + ); + expect(warnings).toContain( + "app.posts.views: its default 0 is not in schema.ts, so the API requires a value for it on insert.", + ); + expect( + warnings.filter((warning) => !warning.startsWith("Constraint names")), + ).toHaveLength(2); + }); + + test.each([ + ["app", "export const schema = defineSchema(() => ({}));\n"], + [ + "public", + 'export const schema = defineSchema(() => ({}), { schemaName: "public" });\n', + ], + ])( + "no tables in %s renders an empty schema, naming the schema only when it is not app", + (schemaName, expected) => { + expect(body(renderSchemaSource([], schemaName).source)).toBe(expected); + }, + ); + + test("tables in a schema other than app pass it to defineSchema", () => { + expect(body(renderSchemaSource([keyed("notes")], "public").source)).toBe( + [ + "export const schema = defineSchema(", + " ({ table }) => {", + ' const notes = table("notes", {', + " id: id(),", + " });", + "", + " return { notes };", + " },", + ' { schemaName: "public" },', + ");", + "", + ].join("\n"), + ); + }); + + test("declares FK targets first, whatever the catalog order", () => { + const { source } = renderSchemaSource( + [ + keyed("c", [col("b_id", "integer")], [fk("b_id", "b")]), + keyed("b", [col("a_id", "integer")], [fk("a_id", "a")]), + keyed("a"), + ], + "app", + ); + const order = ["a", "b", "c"].map((name) => + source.indexOf(`const ${name} = table`), + ); + expect(order).toEqual([...order].sort((x, y) => x - y)); + expect(source).toContain("return { a, b, c };"); + }); + + test("FKs the builder cannot express stay plain columns, each with its reason", () => { + const cases: Array<[IntrospectedTable[], string]> = [ + [ + [keyed("t", [col("parent_id", "integer")], [fk("parent_id", "t")])], + "introspect does not write self-references", + ], + [ + [ + keyed("a", [col("b_id", "integer")], [fk("b_id", "b")]), + keyed("b", [col("a_id", "integer")], [fk("a_id", "a")]), + ], + "it would close a cycle of foreign keys", + ], + [ + [ + keyed( + "t", + [col("x", "integer"), col("y", "integer")], + [{ ...fk("x", "u"), columns: ["x", "y"], refColumns: ["id", "k"] }], + ), + keyed("u"), + ], + "foreign keys over several columns are not supported", + ], + [ + [ + keyed( + "t", + [col("u_id", "integer")], + [fk("u_id", "u", { schema: "public" })], + ), + keyed("u"), + ], + 'it references schema "public"', + ], + [ + [ + keyed( + "t", + [col("u_code", "integer")], + [fk("u_code", "u", { refColumns: ["code"] })], + ), + keyed("u", [col("code", "integer")]), + ], + "u.code is not a primary key or single-column unique key", + ], + [ + [keyed("t", [col("u_id", "bigint")], [fk("u_id", "u")]), keyed("u")], + "its type bigint() differs from u.id", + ], + [ + [ + keyed( + "t", + [col("u_id", "integer", { notNull: true })], + [fk("u_id", "u", { onDelete: "set null" })], + ), + keyed("u"), + ], + "SET NULL on a NOT NULL column", + ], + [ + [ + keyed( + "t", + [col("u_id", "integer")], + [fk("u_id", "u", { onDelete: "set default" })], + ), + keyed("u"), + ], + "SET DEFAULT without a default", + ], + [ + [ + keyed( + "t", + [col("u_id", "integer"), col("u", "text")], + [fk("u_id", "u")], + ), + keyed("u"), + ], + 'the relation "u" would clash with the column of the same name', + ], + [ + [ + keyed("t", [col("u_id", "integer")], [fk("u_id", "u")]), + keyed("u", [col("t", "text")]), + ], + 'the reverse relation "t" would clash with column u.t', + ], + [ + [ + keyed("t", [col("u_id", "integer")], [fk("u_id", "u")]), + { ...keyed("u"), primaryKey: ["id", "x"] }, + ], + 'table "u" is skipped', + ], + ]; + for (const [tables, reason] of cases) { + const out = renderSchemaSource(tables, "app"); + expect(out.source, reason).toContain(reason); + const fkSkips = out.skipped.filter((skip) => + skip.reason.startsWith("foreign key"), + ); + expect(fkSkips, reason).toHaveLength(1); + // Exactly one fk() survives only in the cycle case (the first edge). + expect(out.source.includes("fk(() =>"), reason).toBe( + reason.includes("cycle"), + ); + } + }); + + test("an FK keeps NOT NULL, unique, a default and its actions", () => { + const { source } = renderSchemaSource( + [ + keyed( + "t", + [col("u_id", "integer", { notNull: true, defaultExpr: "1" })], + [fk("u_id", "u", { onDelete: "set default", onUpdate: "cascade" })], + [["u_id"]], + ), + keyed("u"), + ], + "app", + ); + expect(source).toContain( + 'u_id: fk(() => u.id).notNull().unique().default(1).onDelete("set default").onUpdate("cascade"),', + ); + }); + + test("a table whose primary key cannot be expressed is skipped whole", () => { + const out = renderSchemaSource( + [ + tableOf({ + name: "t", + primaryKey: ["code"], + unique: [], + foreignKeys: [], + columns: [ + col("code", "smallint", { notNull: true }), + col("x", "numeric", { notNull: true }), + ], + }), + ], + "app", + ); + expect(out.skipped).toEqual([ + { + table: "t", + reason: + 'its primary key column "code" cannot be expressed (type "smallint" is not supported)', + }, + ]); + // The table is gone, so its columns produce no warnings. + expect(out.warnings).toEqual([]); + }); + + test("names AppKit reserves are skipped; unusual names are quoted or renamed", () => { + const out = renderSchemaSource( + [ + keyed("sql"), + keyed("my-table", [ + col("$meta", "text"), + col("and", "text"), + col("select", "text"), + ]), + keyed("users"), + keyed("usersRelations"), + ], + "app", + ); + expect( + out.skipped.map((skip) => [skip.table, skip.column ?? null, skip.reason]), + ).toEqual([ + ["sql", null, 'the name "sql" is reserved by AppKit'], + [ + "usersRelations", + null, + 'the name collides with the relation metadata AppKit generates for "users"', + ], + ["my-table", "$meta", 'the name "$meta" is reserved by AppKit'], + ["my-table", "and", 'the name "and" is reserved by AppKit'], + ]); + expect(out.source).toContain('const my_table = table("my-table", {'); + expect(out.source).toContain(" select: text(),"); + expect(out.source).toContain('return { "my-table": my_table, users };'); + }); + + test("unsupported defaults are dropped with a comment; nextval on a key becomes id()", () => { + const { source, skipped } = renderSchemaSource( + [ + tableOf({ + name: "t", + primaryKey: ["id"], + unique: [], + foreignKeys: [], + columns: [ + col("id", "bigint", { + notNull: true, + defaultExpr: "nextval('t_id_seq'::regclass)", + }), + col("code", "character varying(2)", { + defaultExpr: "'toolong'::character varying", + }), + col("at", "timestamp without time zone", { + defaultExpr: "'2020-01-01 00:00:00'::timestamp without time zone", + }), + col("n", "integer", { defaultExpr: "nextval('n_seq'::regclass)" }), + ], + }), + ], + "app", + ); + expect(source).toContain(" id: bigid(),"); + expect(skipped.map((skip) => skip.column)).toEqual(["code", "at", "n"]); + expect(source).toContain(" code: varchar(2),"); + }); + + test("a catalog name with a line break stays inside its comment", () => { + const evil = "x\nrequire('node:child_process').execSync('touch /tmp/p')//"; + const { source, skipped, warnings } = renderSchemaSource( + [ + keyed("t", [ + col(evil, "numeric", { notNull: true }), + col("y\u2028z", "numeric"), + ]), + ], + "app", + ); + for (const line of source.split("\n")) { + expect(line.trimStart().startsWith("require(")).toBe(false); + } + expect(source).not.toMatch(/[\u2028\u2029]/); + expect(source).toContain( + `// appkit: skipped column "x\\u000arequire('node:child_process').execSync('touch /tmp/p')//": type "numeric" is not supported`, + ); + expect(source).toContain( + '// appkit: skipped column "y\\u2028z": type "numeric" is not supported', + ); + expect(skipped.map((skip) => skip.column)).toEqual([ + "x\\u000arequire('node:child_process').execSync('touch /tmp/p')//", + "y\\u2028z", + ]); + expect(warnings[0]).toMatch(/^app\.t\.x\\u000arequire/); + }); + + test("the schema name in the header comment is escaped too", () => { + const { source } = renderSchemaSource([keyed("t")], "a\nb"); + expect(source.split("\n")[0]).toBe( + '// Generated by `appkit db introspect` from schema "a\\u000ab". Edit tables here,', + ); + expect(source).toContain('{ schemaName: "a\\nb" }'); + }); + + test("returns the tables it wrote", () => { + expect(renderSchemaSource(richTables, "app").tables).toEqual([ + "authors", + "posts", + "default", + ]); + }); + + test("what schema.ts declares differently from the database is listed as approximated", () => { + const { approximated } = renderSchemaSource( + [ + tableOf({ + name: "a", + partitioned: true, + primaryKey: ["id"], + checks: ["a_n_check"], + indexes: ["a_n_idx", "a_m_idx"], + columns: [ + col("id", "integer", { notNull: true, identity: "a" }), + col("n", "character varying"), + col("at", "timestamp(3) with time zone"), + ], + }), + keyed( + "b", + [col("a_id", "integer")], + [ + fk("a_id", "a", { + name: "b_a_id_a_id_fk", + deferrable: true, + matchFull: true, + }), + ], + ), + ], + "app", + ); + expect(approximated).toEqual([ + "app.a.id: GENERATED ALWAYS AS IDENTITY is declared as id(), an identity generated BY DEFAULT", + "app.a.n: character varying without a length is declared as text()", + "app.a.at: timestamp(3) with time zone is declared without its precision", + "app.a: a partitioned table is declared as a plain table", + "app.a: CHECK or EXCLUDE constraint a_n_check is not declared", + "app.a: indexes a_n_idx, a_m_idx are not declared", + "app.b.a_id: the foreign key is declared without DEFERRABLE", + "app.b.a_id: the foreign key is declared without MATCH FULL", + ]); + }); + + test("constraint names that differ from the ones migrations use are one warning", () => { + const { warnings } = renderSchemaSource(richTables, "app"); + expect(warnings).toContain( + "Constraint names differ from the ones migrations use: authors_email_key (authors_email_unique), posts_author_id_fkey (posts_author_id_authors_id_fk). A later migration that drops or changes one of them fails until it is renamed in the database (ALTER TABLE … RENAME CONSTRAINT).", + ); + }); + + test("constraint names migrations would use raise no warning", () => { + const { warnings } = renderSchemaSource( + [ + tableOf({ + ...keyed("u", [col("email", "text")]), + unique: [{ name: "u_email_unique", columns: ["email"] }], + }), + keyed( + "t", + [col("u_id", "integer")], + [fk("u_id", "u", { name: "t_u_id_u_id_fk" })], + ), + ], + "app", + ); + expect(warnings).toEqual([]); + }); + + test("a uuid FK keeps the key and skips gen_random_uuid(), which fk() does not take", () => { + const { source, skipped, warnings } = renderSchemaSource( + [ + tableOf({ + name: "u", + primaryKey: ["id"], + columns: [ + col("id", "uuid", { + notNull: true, + defaultExpr: "gen_random_uuid()", + }), + ], + }), + keyed( + "t", + [ + col("u_id", "uuid", { + notNull: true, + defaultExpr: "gen_random_uuid()", + }), + ], + [fk("u_id", "u", { name: "t_u_id_u_id_fk" })], + ), + ], + "app", + ); + expect(source).toContain(" u_id: fk(() => u.id).notNull(),"); + expect(source).not.toContain("fk(() => u.id).notNull().defaultRandom()"); + expect(skipped).toEqual([ + { + table: "t", + column: "u_id", + reason: + "default gen_random_uuid() is not supported on a foreign key column", + }, + ]); + expect(warnings).toEqual([ + "app.t.u_id: its default gen_random_uuid() is not in schema.ts, so the API requires a value for it on insert.", + ]); + }); + + test("SET DEFAULT needs a default fk() can take", () => { + const { skipped } = renderSchemaSource( + [ + tableOf({ + name: "u", + primaryKey: ["id"], + columns: [col("id", "uuid", { notNull: true })], + }), + keyed( + "t", + [col("u_id", "uuid", { defaultExpr: "gen_random_uuid()" })], + [fk("u_id", "u", { onDelete: "set default" })], + ), + ], + "app", + ); + expect(skipped.map((skip) => skip.reason)).toEqual([ + "foreign key to u(id) is not kept: SET DEFAULT without a default the builder can express", + ]); + }); +}); diff --git a/packages/appkit/src/database/tooling/introspect/tests/roundtrip.test.ts b/packages/appkit/src/database/tooling/introspect/tests/roundtrip.test.ts new file mode 100644 index 000000000..a4909c814 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/roundtrip.test.ts @@ -0,0 +1,260 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { loadDefaultDatabaseSchema } from "../../../../plugins/database/load-schema"; +import { drizzleKitImports } from "../../drizzle-exports"; +import { generateMigrationFiles } from "../../migrations"; +import type { IntrospectedTable } from "../catalog"; +import { renderSchemaSource } from "../render"; +import { + BUILDER, + col, + foreignKey, + richTables, + tableOf, + uniqueKey, +} from "./fixture"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) + fs.rmSync(root, { recursive: true, force: true }); +}); + +/** Write the rendered source as an app's schema.ts and load it the way the plugin does. */ +async function load(tables: IntrospectedTable[]) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-introspect-")); + roots.push(root); + const file = path.join(root, "config/database/schema.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + renderSchemaSource(tables, "app", { importSource: BUILDER }).source, + ); + return { root, schema: await loadDefaultDatabaseSchema(root) }; +} + +interface KitColumn { + name: string; + type: string; + notNull: boolean; + primaryKey: boolean; + default?: unknown; +} +interface KitTable { + name: string; + schema: string; + columns: Record; + foreignKeys: Record< + string, + { + tableTo: string; + columnsFrom: string[]; + columnsTo: string[]; + onDelete?: string; + } + >; +} + +describe("introspect round trip", () => { + test("the rendered source loads through loadDefaultDatabaseSchema, and drizzle-kit sees the same tables", async () => { + const { schema } = await load(richTables); + expect(schema.$schemaName).toBe("app"); + expect(Object.keys(schema.$tables).sort()).toEqual([ + "authors", + "default", + "posts", + ]); + + const { generateDrizzleJson } = await import("drizzle-kit/api"); + const snapshot = generateDrizzleJson(drizzleKitImports(schema), undefined, [ + "app", + ]) as unknown as { + tables: Record; + enums: Record; + }; + const columns = (table: string) => + Object.fromEntries( + Object.values(snapshot.tables[`app.${table}`]?.columns ?? {}).map( + (c) => [ + c.name, + `${c.type}${c.notNull ? " not null" : ""}${c.primaryKey ? " pk" : ""}`, + ], + ), + ); + + expect(columns("authors")).toEqual({ + id: "integer not null pk", + email: "varchar(255) not null", + bio: "text", + active: "boolean not null", + rank: "integer", + created_at: "timestamp with time zone not null", + seen_at: "timestamp", + }); + expect(columns("posts")).toEqual({ + id: "uuid not null pk", + author_id: "integer not null", + editor_id: "integer", + status: "ticket_status not null", + body: "jsonb", + views: "bigint not null", + "my col": "text", + }); + expect(columns("default")).toEqual({ id: "bigint not null pk" }); + expect( + Object.values(snapshot.tables["app.posts"]?.foreignKeys ?? {}), + ).toEqual([ + expect.objectContaining({ + tableTo: "authors", + columnsFrom: ["author_id"], + columnsTo: ["id"], + onDelete: "cascade", + }), + ]); + expect(snapshot.enums["app.ticket_status"]).toMatchObject({ + values: ["open", "closed"], + }); + }); + + test("the baseline is generated from the empty snapshot: it creates every rendered table", async () => { + const { root, schema } = await load(richTables); + const folder = path.join(root, "config/database/migrations"); + + const migration = await generateMigrationFiles({ + folder, + schema, + tag: "baseline", + interactive: false, + }); + + expect(migration?.tag).toBe("0000_baseline"); + const sql = migration?.statements.join("\n") ?? ""; + for (const table of ["authors", "posts", "default"]) { + expect(sql).toContain(`CREATE TABLE "app"."${table}"`); + } + expect(sql).toContain( + `CREATE TYPE "app"."ticket_status" AS ENUM('open', 'closed')`, + ); + expect(sql).not.toContain("CREATE SCHEMA"); + }); + + test("every skip path still yields a loadable schema", async () => { + const tables: IntrospectedTable[] = [ + tableOf({ + name: "a", + primaryKey: ["id"], + unique: [uniqueKey("a", "x", "y")], + foreignKeys: [ + foreignKey({ + name: "self", + columns: ["parent_id"], + schema: "app", + table: "a", + refColumns: ["id"], + onDelete: "no action", + onUpdate: "no action", + }), + foreignKey({ + name: "to_b", + columns: ["b_id"], + schema: "app", + table: "b", + refColumns: ["id"], + onDelete: "set null", + onUpdate: "no action", + }), + ], + columns: [ + col("id", "integer", { notNull: true, identity: "a" }), + col("parent_id", "integer"), + col("b_id", "integer", { notNull: true }), + col("x", "integer"), + col("y", "integer"), + col("g", "integer", { generated: true, defaultExpr: "(x + y)" }), + col("seq", "integer", { identity: "d" }), + col("tags", "text[]"), + ], + }), + tableOf({ + name: "b", + primaryKey: ["id"], + unique: [], + foreignKeys: [ + foreignKey({ + name: "to_a", + columns: ["a_id"], + schema: "app", + table: "a", + refColumns: ["id"], + onDelete: "no action", + onUpdate: "no action", + }), + ], + columns: [ + col("id", "integer", { notNull: true, identity: "d" }), + col("a_id", "integer"), + ], + }), + ]; + const { schema } = await load(tables); + expect(Object.keys(schema.$tables).sort()).toEqual(["a", "b"]); + expect(Object.keys(schema.$tables.a?.$columns ?? {})).toEqual([ + "id", + "parent_id", + "b_id", + "x", + "y", + ]); + }); + + test("a uuid FK with gen_random_uuid() and names with line breaks still load, with no extra code", async () => { + const marker = "__introspect_injected__"; + const { schema } = await load([ + tableOf({ + name: "u", + primaryKey: ["id"], + columns: [ + col("id", "uuid", { + notNull: true, + defaultExpr: "gen_random_uuid()", + }), + ], + }), + tableOf({ + name: "t", + primaryKey: ["id"], + foreignKeys: [ + foreignKey({ + name: "t_u_id_fkey", + columns: ["u_id"], + schema: "app", + table: "u", + refColumns: ["id"], + onDelete: "no action", + onUpdate: "no action", + }), + ], + columns: [ + col("id", "integer", { notNull: true, identity: "d" }), + col("u_id", "uuid", { + notNull: true, + defaultExpr: "gen_random_uuid()", + }), + col(`x\nglobalThis.${marker} = true; //`, "numeric"), + col(`y\u2028globalThis.${marker} = true; //`, "numeric"), + ], + }), + ]); + + expect((globalThis as Record)[marker]).toBeUndefined(); + expect(Object.keys(schema.$tables).sort()).toEqual(["t", "u"]); + expect(Object.keys(schema.$tables.t?.$columns ?? {})).toEqual([ + "id", + "u_id", + ]); + }); +}); diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts index d0be9ec6d..4b3bddec1 100644 --- a/packages/appkit/src/database/tooling/migrate.ts +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -14,7 +14,7 @@ import { type GeneratedMigration, generateMigrationFiles } from "./migrations"; import { resourceId } from "./targets"; /** Same application-relative conventions as the plugin and `appkit generate-types`. */ -const SCHEMA_FILE = "config/database/schema.ts"; +export const SCHEMA_FILE = "config/database/schema.ts"; /** Whether the checkout has a config/database/schema.ts yet. */ function hasSchemaFile(root: string): boolean { @@ -28,7 +28,7 @@ function requireSchemaFile(root: string): void { `No ${SCHEMA_FILE} yet, so there is nothing to migrate. Run \`appkit db introspect\` to write it from the branch's tables, or create it.`, ); } -const DATABASE_TYPES_PATH = path.join( +export const DATABASE_TYPES_PATH = path.join( "shared/appkit-types", DATABASE_TYPES_FILE, ); diff --git a/packages/appkit/src/database/tooling/migrations.ts b/packages/appkit/src/database/tooling/migrations.ts index 135d4401d..c5af81372 100644 --- a/packages/appkit/src/database/tooling/migrations.ts +++ b/packages/appkit/src/database/tooling/migrations.ts @@ -21,8 +21,12 @@ interface GenerateOptions { /** Absolute migrations folder. */ readonly folder: string; readonly schema: Schema; + /** Default "migration" → "NNNN_migration". */ + readonly tag?: string; /** false: an ambiguous rename throws instead of prompting. */ readonly interactive: boolean; + /** Sees the SQL before anything is written; throwing writes nothing. */ + readonly beforeWrite?: (statements: readonly string[]) => void; } export interface GeneratedMigration { @@ -54,7 +58,7 @@ export class MigrationRenameAmbiguityError extends Error { kind === "column" ? `columns of table "${table}"` : `${kind}s`; super( `Cannot tell whether ${scope} were renamed: removed ${deleted.join(", ")}; added ${created.join(", ")}. ` + - "Run `appkit db migrate` in an interactive terminal to choose between a rename and a drop plus create.", + "Run the command in an interactive terminal to choose between a rename and a drop plus create.", ); this.name = "MigrationRenameAmbiguityError"; this.kind = kind; @@ -67,6 +71,7 @@ export class MigrationRenameAmbiguityError extends Error { /** drizzle-kit's statement separator in v7 migration files. */ const BREAKPOINT = "--> statement-breakpoint\n"; const ORIGIN_ID = "00000000-0000-0000-0000-000000000000"; +const TAG_NAME = /^[a-z0-9_]+$/; /** The parts of a drizzle-kit PostgreSQL snapshot this module reads. */ interface Snapshot { @@ -197,12 +202,18 @@ async function loadDrizzleKit(): Promise { /** * Diff the schema against the latest committed snapshot with drizzle-kit and - * write the next v7 migration: `NNNN_migration.sql`, `meta/NNNN_snapshot.json`, + * write the next v7 migration: `NNNN_.sql`, `meta/NNNN_snapshot.json`, * and the appended `meta/_journal.json`. null when nothing changed. */ export async function generateMigrationFiles( options: GenerateOptions, ): Promise { + const name = options.tag ?? "migration"; + if (!TAG_NAME.test(name)) { + throw new Error( + `Migration tag "${name}" must use lowercase letters, digits and underscores`, + ); + } const { generateDrizzleJson, generateMigration } = await loadDrizzleKit(); const journal = readJournal(options.folder); @@ -226,11 +237,12 @@ export async function generateMigrationFiles( cur as unknown as KitSnapshot, ); if (statements.length === 0) return null; + options.beforeWrite?.(statements); const entries = journal?.entries ?? []; const last = entries.at(-1); const idx = last ? last.idx + 1 : 0; - const tag = `${prefixOf(idx)}_migration`; + const tag = `${prefixOf(idx)}_${name}`; const entry: JournalEntry = { idx, version: "7", @@ -260,3 +272,27 @@ export async function generateMigrationFiles( fs.writeFileSync(journalFile(options.folder), JSON.stringify(next, null, 2)); return { tag, file, statements }; } + +/** + * Remove a migration generateMigrationFiles wrote and put back the journal it + * replaced (null: there was none, so the folders it created go too). + */ +export function discardMigrationFiles( + folder: string, + migration: GeneratedMigration, + journalBefore: string | null, +): void { + fs.rmSync(migration.file, { force: true }); + fs.rmSync(snapshotFile(folder, Number.parseInt(migration.tag, 10)), { + force: true, + }); + if (journalBefore !== null) { + fs.writeFileSync(journalFile(folder), journalBefore); + return; + } + fs.rmSync(journalFile(folder), { force: true }); + for (const dir of [path.join(folder, "meta"), folder]) { + if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) + fs.rmdirSync(dir); + } +} diff --git a/packages/appkit/src/database/tooling/tests/migrations.test.ts b/packages/appkit/src/database/tooling/tests/migrations.test.ts index 39c8bf193..575f8fe5a 100644 --- a/packages/appkit/src/database/tooling/tests/migrations.test.ts +++ b/packages/appkit/src/database/tooling/tests/migrations.test.ts @@ -19,6 +19,7 @@ import { varchar, } from "../../schema-builder"; import { + discardMigrationFiles, generateMigrationFiles, MigrationRenameAmbiguityError, } from "../migrations"; @@ -301,6 +302,17 @@ describe("generateMigrationFiles", () => { expect(listFiles(folder)).toEqual(before); }); + test("rejects a tag that is not a safe file name", async () => { + await expect( + generateMigrationFiles({ + folder: tempFolder(), + schema: blog(), + tag: "../escape", + interactive: false, + }), + ).rejects.toThrow(/lowercase letters/); + }); + test("refuses to diff when the latest snapshot is missing", async () => { const folder = tempFolder(); await generateMigrationFiles({ @@ -340,4 +352,63 @@ describe("generateMigrationFiles", () => { ), ); }); + + test("beforeWrite sees the SQL first, and throwing from it writes nothing", async () => { + const folder = tempFolder(); + const seen: string[][] = []; + + const error = await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + beforeWrite: (statements) => { + seen.push([...statements]); + throw new Error("refused"); + }, + }).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe("refused"); + expect(seen).toHaveLength(1); + expect(seen[0]?.join("\n")).toContain('CREATE TABLE "app"."authors"'); + expect(listFiles(folder)).toEqual([]); + }); +}); + +describe("discardMigrationFiles", () => { + test("a first migration goes with the folders it created", async () => { + const folder = tempFolder(); + const migration = await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + + discardMigrationFiles(folder, migration as never, null); + + expect(fs.existsSync(folder)).toBe(false); + }); + + test("a later migration goes, and the previous journal comes back byte for byte", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + const journalFile = path.join(folder, "meta/_journal.json"); + const journalBefore = `${fs.readFileSync(journalFile, "utf-8")}\n`; + fs.writeFileSync(journalFile, journalBefore); + const filesBefore = listFiles(folder); + const migration = await generateMigrationFiles({ + folder, + schema: blog({ summary: text() }), + interactive: false, + }); + expect(listFiles(folder)).not.toEqual(filesBefore); + + discardMigrationFiles(folder, migration as never, journalBefore); + + expect(listFiles(folder)).toEqual(filesBefore); + expect(fs.readFileSync(journalFile, "utf-8")).toBe(journalBefore); + }); }); diff --git a/packages/shared/src/cli/commands/db/database-tooling.d.ts b/packages/shared/src/cli/commands/db/database-tooling.d.ts index b1c18beee..7e1a1ff7b 100644 --- a/packages/shared/src/cli/commands/db/database-tooling.d.ts +++ b/packages/shared/src/cli/commands/db/database-tooling.d.ts @@ -36,4 +36,30 @@ declare module "@databricks/appkit/database-tooling" { ctx: DbContext, options: { interactive: boolean }, ): Promise; + + export interface Skip { + readonly table: string; + readonly column?: string; + readonly reason: string; + } + + export interface IntrospectReport { + readonly branch: string; + readonly schemaName: string; + readonly tables: readonly string[]; + readonly schemaFile: string; + readonly backupFile: string | null; + readonly skipped: readonly Skip[]; + readonly warnings: readonly string[]; + readonly approximated: readonly string[]; + readonly migration: GeneratedMigration | null; + readonly recordedApplied: boolean; + readonly notRecorded?: string; + readonly typesFile: string; + } + + export function runIntrospect( + ctx: DbContext, + options: { interactive: boolean; schemaName?: string }, + ): Promise; } diff --git a/packages/shared/src/cli/commands/db/index.ts b/packages/shared/src/cli/commands/db/index.ts index aca41b888..35ff2f721 100644 --- a/packages/shared/src/cli/commands/db/index.ts +++ b/packages/shared/src/cli/commands/db/index.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; +import { dbIntrospectCommand } from "./introspect"; import { dbMigrateCommand } from "./migrate"; /** @@ -8,4 +9,5 @@ import { dbMigrateCommand } from "./migrate"; */ export const dbCommand = new Command("db") .description("Lakebase database lifecycle") - .addCommand(dbMigrateCommand); + .addCommand(dbMigrateCommand) + .addCommand(dbIntrospectCommand); diff --git a/packages/shared/src/cli/commands/db/introspect.test.ts b/packages/shared/src/cli/commands/db/introspect.test.ts new file mode 100644 index 000000000..143c2b54e --- /dev/null +++ b/packages/shared/src/cli/commands/db/introspect.test.ts @@ -0,0 +1,173 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const tooling = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + runIntrospect: vi.fn(), +})); + +vi.mock("@databricks/appkit/database-tooling", () => tooling); + +import { dbCommand } from "./index"; + +let log: MockInstance; +let error: MockInstance; +const output = (spy: MockInstance) => + spy.mock.calls.map((call) => call.join(" ")).join("\n"); + +const report = { + branch: "projects/p/branches/dev-alice-3f9a2c", + schemaName: "app", + tables: ["authors", "posts"], + schemaFile: `${process.cwd()}/config/database/schema.ts`, + backupFile: `${process.cwd()}/config/database/schema.ts.bak`, + skipped: [ + { + table: "events", + reason: "composite primary key (a, b) is not supported", + }, + { + table: "posts", + column: "score", + reason: 'type "numeric(10,2)" is not supported', + }, + ], + warnings: [ + "app.posts.score is NOT NULL with no default but is not in schema.ts.", + ], + approximated: [ + "app.authors.id: a serial key is declared as id(), an identity generated BY DEFAULT", + ], + migration: { + tag: "0000_baseline", + file: `${process.cwd()}/config/database/migrations/0000_baseline.sql`, + statements: ["CREATE TABLE a", "CREATE TABLE b"], + }, + recordedApplied: true, + typesFile: `${process.cwd()}/shared/appkit-types/database.d.ts`, +}; + +beforeEach(() => { + for (const option of dbCommand.commands.flatMap((c) => + c.options.map((o) => [c, o] as const), + )) { + option[0].setOptionValueWithSource( + option[1].attributeName(), + undefined, + "default", + ); + } + tooling.runIntrospect.mockReset().mockResolvedValue(report); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +describe("appkit db introspect", () => { + test("prints the written schema, each skip, the loud warnings, and the recorded baseline", async () => { + await dbCommand.parseAsync(["introspect"], { from: "user" }); + + expect(tooling.runIntrospect).toHaveBeenCalledWith( + { root: "/app", env: {} }, + { interactive: false }, + ); + const printed = output(log); + expect(printed).toContain( + "Wrote config/database/schema.ts from schema app on dev-alice-3f9a2c (2 tables); the previous file is config/database/schema.ts.bak", + ); + expect(printed).toContain( + "Skipped table events: composite primary key (a, b) is not supported", + ); + expect(printed).toContain( + 'Skipped posts.score: type "numeric(10,2)" is not supported', + ); + expect(printed).toContain("app.posts.score is NOT NULL with no default"); + expect(printed).toContain( + "Approximated app.authors.id: a serial key is declared as id(), an identity generated BY DEFAULT", + ); + expect(printed).toContain( + "Generated config/database/migrations/0000_baseline.sql (2 statements)", + ); + expect(printed).toContain( + "Recorded 0000_baseline as applied on dev-alice-3f9a2c (its tables already exist)", + ); + expect(printed).toContain("Generated shared/appkit-types/database.d.ts"); + expect(process.exitCode).toBeUndefined(); + }); + + test("--schema-name is passed through; an unrecorded baseline says why", async () => { + tooling.runIntrospect.mockResolvedValue({ + ...report, + recordedApplied: false, + notRecorded: "production is not your dev or session branch", + }); + + await dbCommand.parseAsync(["introspect", "--schema-name", "app"], { + from: "user", + }); + + expect(tooling.runIntrospect).toHaveBeenCalledWith(expect.anything(), { + interactive: false, + schemaName: "app", + }); + expect(output(log)).toContain( + "Not recorded: production is not your dev or session branch", + ); + }); + + test("a recorded NNNN_introspect says the branch already has the changes", async () => { + tooling.runIntrospect.mockResolvedValue({ + ...report, + migration: { ...report.migration, tag: "0002_introspect" }, + }); + + await dbCommand.parseAsync(["introspect"], { from: "user" }); + + expect(output(log)).toContain( + "Recorded 0002_introspect as applied on dev-alice-3f9a2c (the branch already has these changes)", + ); + }); + + test.each([ + [[], "No migration needed: schema.ts has no tables"], + [ + ["authors"], + "No migration needed: the migrations already describe this schema", + ], + ])("without a migration, %j tables print %j", async (tables, line) => { + tooling.runIntrospect.mockResolvedValue({ + ...report, + tables, + migration: null, + recordedApplied: false, + }); + + await dbCommand.parseAsync(["introspect"], { from: "user" }); + + expect(output(log)).toContain(line); + }); + + test("a refusal is printed and exits 1", async () => { + tooling.runIntrospect.mockRejectedValue( + new Error( + "Refusing to introspect production: config/database/migrations already has history", + ), + ); + + await dbCommand.parseAsync(["introspect"], { from: "user" }); + + expect(output(error)).toContain("Refusing to introspect production"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/introspect.ts b/packages/shared/src/cli/commands/db/introspect.ts new file mode 100644 index 000000000..7c4c81f54 --- /dev/null +++ b/packages/shared/src/cli/commands/db/introspect.ts @@ -0,0 +1,41 @@ +import { Command } from "commander"; + +import { fail, isInteractive, printIntrospectReport } from "./output"; + +async function runDbIntrospect(flags: { schemaName?: string }): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runIntrospect(tooling.createDbContext(), { + interactive: isInteractive(), + ...(flags.schemaName ? { schemaName: flags.schemaName } : {}), + }); + printIntrospectReport(report); + } catch (error) { + fail(error); + } +} + +export const dbIntrospectCommand = new Command("introspect") + .description( + "Write config/database/schema.ts from the .env branch's tables, plus the migration that brings history up to them", + ) + .option("--schema-name ", "Postgres schema to read (default: app)") + .addHelpText( + "after", + ` +The previous schema.ts is kept as schema.ts.bak. What the schema builder cannot +express (composite keys, unsupported types or defaults, some foreign keys) is left +out with an "// appkit: skipped …" comment. + +Without migration history this writes 0000_baseline; with history, the next +NNNN_introspect, once the branch has applied every committed migration. On your +dev or session branch the migration is recorded as applied. On any other branch +only 0000_baseline is written, and the deployed app adopts it on start when its +tables already exist. If anything fails, schema.ts and the migrations are left +as they were. + +Examples: + $ appkit db introspect + $ appkit db introspect --schema-name sales`, + ) + .action(runDbIntrospect); diff --git a/packages/shared/src/cli/commands/db/output.ts b/packages/shared/src/cli/commands/db/output.ts index 2cfba1973..031a5f424 100644 --- a/packages/shared/src/cli/commands/db/output.ts +++ b/packages/shared/src/cli/commands/db/output.ts @@ -1,7 +1,10 @@ import path from "node:path"; import process from "node:process"; -import type { MigrateReport } from "@databricks/appkit/database-tooling"; +import type { + IntrospectReport, + MigrateReport, +} from "@databricks/appkit/database-tooling"; import pc from "picocolors"; /** Prompts only when a human can answer them. */ @@ -13,6 +16,11 @@ export function relative(file: string): string { return path.relative(process.cwd(), file) || file; } +/** The last segment of a Lakebase resource name: projects/p/branches/dev-a → dev-a. */ +function shortName(name: string): string { + return name.slice(name.lastIndexOf("/") + 1); +} + export const ok = (message: string) => console.log(`${pc.green("✔")} ${message}`); export const info = (message: string) => @@ -66,3 +74,41 @@ export function printMigrateReport(report: MigrateReport): void { ok(`Generated ${relative(report.typesFile)}`); } + +export function printIntrospectReport(report: IntrospectReport): void { + const count = report.tables.length; + ok( + `Wrote ${relative(report.schemaFile)} from schema ${report.schemaName} on ${shortName(report.branch)} (${count} table${count === 1 ? "" : "s"})${report.backupFile ? `; the previous file is ${relative(report.backupFile)}` : ""}`, + ); + for (const skip of report.skipped) { + warn( + `Skipped ${skip.column ? `${skip.table}.${skip.column}` : `table ${skip.table}`}: ${skip.reason}`, + ); + } + for (const warning of report.warnings) { + console.log(`${pc.bold(pc.red("!"))} ${pc.bold(warning)}`); + } + for (const difference of report.approximated) { + info(`Approximated ${difference}`); + } + if (report.migration) { + const statements = report.migration.statements.length; + ok( + `Generated ${relative(report.migration.file)} (${statements} statement${statements === 1 ? "" : "s"})`, + ); + if (report.recordedApplied) { + ok( + `Recorded ${report.migration.tag} as applied on ${shortName(report.branch)} (${report.migration.tag.endsWith("_baseline") ? "its tables already exist" : "the branch already has these changes"})`, + ); + } else if (report.notRecorded) { + info(`Not recorded: ${report.notRecorded}`); + } + } else { + info( + report.tables.length === 0 + ? "No migration needed: schema.ts has no tables" + : "No migration needed: the migrations already describe this schema", + ); + } + ok(`Generated ${relative(report.typesFile)}`); +} From 960b2111db4ad2cbf8652b6edd9e8fdcb824481d Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 13 Oct 2026 00:05:20 +0100 Subject: [PATCH 4/6] feat(appkit): add appkit db init, clean and seed `appkit db init` gives a checkout its own Lakebase branch: a per-developer dev branch or a per-checkout session branch (7-day TTL), forked from the project's default branch. It makes sure the branch has a read-write endpoint and a Postgres role for the developer, hands the objects the fork copied from its parent to the developer (role delete with reassign_owned_to, only on that branch), points .env at it, and can start the schema by migrating, introspecting or cleaning. It refuses the project's default branch, protected branches and branches databricks.yml deploys to before changing anything: those belong to the deployed app. `appkit db clean` drops the app schema and `appkit db seed` runs config/database/seed.sql with the app schema on the search_path, both only on a branch created for this developer or checkout. Signed-off-by: ditadi --- .../appkit/src/database/migrations/apply.ts | 6 +- .../src/database/migrations/ownership.ts | 106 ++- .../database/migrations/tests/apply.test.ts | 1 + .../migrations/tests/ownership.test.ts | 46 +- .../appkit/src/database/tooling/branch-sql.ts | 32 + packages/appkit/src/database/tooling/clean.ts | 26 + .../appkit/src/database/tooling/env-file.ts | 64 ++ packages/appkit/src/database/tooling/guard.ts | 36 + .../appkit/src/database/tooling/identity.ts | 13 +- packages/appkit/src/database/tooling/index.ts | 13 + packages/appkit/src/database/tooling/init.ts | 504 ++++++++++++++ .../src/database/tooling/introspect/index.ts | 2 +- .../introspect/tests/introspect.test.ts | 6 +- .../src/database/tooling/lakebase-api.ts | 208 +++++- .../appkit/src/database/tooling/migrate.ts | 34 +- .../appkit/src/database/tooling/naming.ts | 14 +- packages/appkit/src/database/tooling/seed.ts | 47 ++ .../appkit/src/database/tooling/targets.ts | 57 +- .../database/tooling/tests/clean-seed.test.ts | 187 +++++ .../database/tooling/tests/env-file.test.ts | 116 ++++ .../src/database/tooling/tests/fakes.ts | 105 ++- .../src/database/tooling/tests/guard.test.ts | 79 ++- .../database/tooling/tests/identity.test.ts | 10 +- .../src/database/tooling/tests/init.test.ts | 656 ++++++++++++++++++ .../tooling/tests/lakebase-api.test.ts | 190 ++++- .../database/tooling/tests/migrate.test.ts | 29 + .../src/database/tooling/tests/naming.test.ts | 13 +- .../database/tooling/tests/targets.test.ts | 71 +- packages/shared/src/cli/commands/db/clean.ts | 51 ++ .../src/cli/commands/db/database-tooling.d.ts | 42 ++ packages/shared/src/cli/commands/db/index.ts | 8 +- .../cli/commands/db/init-clean-seed.test.ts | 409 +++++++++++ packages/shared/src/cli/commands/db/init.ts | 214 ++++++ packages/shared/src/cli/commands/db/output.ts | 2 +- packages/shared/src/cli/commands/db/seed.ts | 26 + 35 files changed, 3382 insertions(+), 41 deletions(-) create mode 100644 packages/appkit/src/database/tooling/branch-sql.ts create mode 100644 packages/appkit/src/database/tooling/clean.ts create mode 100644 packages/appkit/src/database/tooling/env-file.ts create mode 100644 packages/appkit/src/database/tooling/init.ts create mode 100644 packages/appkit/src/database/tooling/seed.ts create mode 100644 packages/appkit/src/database/tooling/tests/clean-seed.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/env-file.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/init.test.ts create mode 100644 packages/shared/src/cli/commands/db/clean.ts create mode 100644 packages/shared/src/cli/commands/db/init-clean-seed.test.ts create mode 100644 packages/shared/src/cli/commands/db/init.ts create mode 100644 packages/shared/src/cli/commands/db/seed.ts diff --git a/packages/appkit/src/database/migrations/apply.ts b/packages/appkit/src/database/migrations/apply.ts index e2f0bdd50..a871e858e 100644 --- a/packages/appkit/src/database/migrations/apply.ts +++ b/packages/appkit/src/database/migrations/apply.ts @@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import type { Pool, PoolClient } from "pg"; import { createLogger } from "../../logging/logger"; -import { ownershipHint } from "./ownership"; +import { ownershipHint, quoteIdentifier } from "./ownership"; import { BASELINE_TAG, type Journal, @@ -122,10 +122,6 @@ function sqlStateOf(error: unknown): string | undefined { return undefined; } -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - /** One lock per app schema, so concurrent instances apply in turn. */ export function migrationLockKey(schemaName: string): string { return createHash("sha256") diff --git a/packages/appkit/src/database/migrations/ownership.ts b/packages/appkit/src/database/migrations/ownership.ts index 606efb1d1..76039f342 100644 --- a/packages/appkit/src/database/migrations/ownership.ts +++ b/packages/appkit/src/database/migrations/ownership.ts @@ -1,6 +1,9 @@ // Ownership model: each branch has one owner, the identity its migrations run -// as, and that identity owns the app's objects there. On the deploy branch it -// is the app's service principal, which creates the app schema on first start. +// as, and that identity owns the app's objects there: +// - deploy branch: the app's service principal, which creates the app schema +// on first start; +// - dev / session fork: the developer, once `appkit db init` hands over the +// objects the fork copied from its parent (Lakebase role API, reassign_owned_to). // There is no shared owner role and no SET ROLE. A shared owner role is not // possible: Lakebase rejects `GRANT … TO databricks_superuser`. @@ -13,6 +16,103 @@ export function ownershipHint(code: string | undefined): string | undefined { return ( "The connecting role lacks a privilege this migration needs: CREATE on the database, or ownership of the app schema and its tables. " + "On the deploy branch that role is the app's service principal: bind the database to the app with CAN_CONNECT_AND_CREATE, " + - "and if another role created the schema, use another schemaName in schema.ts or have that role transfer the schema and its objects." + "and if another role created the schema, use another schemaName in schema.ts or have that role transfer the schema and its objects. " + + "On your dev or session branch it is you: `appkit db init` hands you the objects the branch copied from its parent." ); } + +/** A Postgres identifier in double quotes, with embedded quotes doubled. */ +export function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +/** User schemas: everything except pg_* (pg_catalog, pg_toast, …) and information_schema. */ +const USER_SCHEMAS = `n.nspname !~ '^pg_' and n.nspname <> 'information_schema'`; + +/** + * Postgres roles, other than current_user, that own a schema, relation, type or + * function in this database. A fork copies its parent's objects with their + * owners; `appkit db init` hands each of these roles' objects to the developer + * (role API delete with reassign_owned_to). Roles the role API does not manage + * (pg_database_owner, databricks_control_plane, …) are filtered out by the caller. + */ +export const FOREIGN_OWNERS_SQL = `select distinct pg_catalog.pg_get_userbyid(o.owner)::text as owner + from ( + select n.nspowner as owner from pg_catalog.pg_namespace n where ${USER_SCHEMAS} + union all + select c.relowner from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace where ${USER_SCHEMAS} + union all + select t.typowner from pg_catalog.pg_type t + join pg_catalog.pg_namespace n on n.oid = t.typnamespace where ${USER_SCHEMAS} + union all + select p.proowner from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace where ${USER_SCHEMAS} + ) o + where o.owner <> (select r.oid from pg_catalog.pg_roles r where r.rolname = current_user) + order by 1`; + +/** + * Remove every object in the app schema, including the migration journal. Runs + * as the branch's owner, in one transaction. + * - A non-public schema is dropped as a unit; the migrator recreates it. + * - `public` belongs to pg_database_owner and cannot be dropped, so each + * function, procedure, relation and standalone type in it that current_user + * owns is dropped, routines first. Sequences owned by a column (identity, + * serial) go with their table, and a table's row type goes with the table. + * Objects that belong to an extension are left to it. + */ +export function cleanStatements(schemaName: string): readonly string[] { + if (schemaName !== "public") { + return [`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`]; + } + return [ + `DO $appkit$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT p.proname, pg_catalog.pg_get_function_identity_arguments(p.oid) AS args + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prokind IN ('f', 'p') + AND p.proowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e') + LOOP + -- args is null when an earlier CASCADE already dropped the routine. + CONTINUE WHEN r.args IS NULL; + EXECUTE pg_catalog.format('DROP ROUTINE IF EXISTS public.%I(%s) CASCADE', r.proname, r.args); + END LOOP; + FOR r IN + SELECT c.relname, c.relkind FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S') + AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_class'::regclass AND d.objid = c.oid AND d.deptype IN ('a', 'i')) + LOOP + EXECUTE pg_catalog.format('DROP %s IF EXISTS public.%I CASCADE', + CASE r.relkind WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED VIEW' + WHEN 'f' THEN 'FOREIGN TABLE' WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END, + r.relname); + END LOOP; + FOR r IN + SELECT t.typname FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' + AND t.typowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) + AND (t.typtype IN ('e', 'd') + OR (t.typtype = 'c' AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))) + LOOP + EXECUTE pg_catalog.format('DROP TYPE IF EXISTS public.%I CASCADE', r.typname); + END LOOP; +END +$appkit$`, + ]; +} diff --git a/packages/appkit/src/database/migrations/tests/apply.test.ts b/packages/appkit/src/database/migrations/tests/apply.test.ts index d0d0fd185..48cce854d 100644 --- a/packages/appkit/src/database/migrations/tests/apply.test.ts +++ b/packages/appkit/src/database/migrations/tests/apply.test.ts @@ -357,6 +357,7 @@ describe("applyPendingMigrations", () => { expect((error as Error).message).toMatch( /^Applying migrations 0000_migration, 0001_migration failed \(SQLSTATE 42501\); none were applied\. The connecting role lacks a privilege this migration needs: /, ); + expect((error as Error).message).toContain("appkit db init"); }); test("a failed unlock destroys the connection instead of returning it to the pool", async () => { diff --git a/packages/appkit/src/database/migrations/tests/ownership.test.ts b/packages/appkit/src/database/migrations/tests/ownership.test.ts index 56ea47022..c0fddfdd2 100644 --- a/packages/appkit/src/database/migrations/tests/ownership.test.ts +++ b/packages/appkit/src/database/migrations/tests/ownership.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { ownershipHint } from "../ownership"; +import { + cleanStatements, + FOREIGN_OWNERS_SQL, + ownershipHint, +} from "../ownership"; describe("ownership: one owner per branch", () => { test("insufficient_privilege gets the branch-owner rule and the fix", () => { @@ -12,6 +16,9 @@ describe("ownership: one owner per branch", () => { "On the deploy branch that role is the app's service principal: bind the database to the app with CAN_CONNECT_AND_CREATE", ); expect(hint).toContain("use another schemaName in schema.ts"); + expect(hint).toContain( + "On your dev or session branch it is you: `appkit db init` hands you the objects", + ); }); test("any other failure gets no ownership hint", () => { @@ -19,3 +26,40 @@ describe("ownership: one owner per branch", () => { expect(ownershipHint(undefined)).toBeUndefined(); }); }); + +describe("cleanStatements", () => { + test("a non-public app schema is dropped as a unit, journal included", () => { + expect(cleanStatements("app")).toEqual([ + `DROP SCHEMA IF EXISTS "app" CASCADE`, + ]); + expect(cleanStatements('we"ird')).toEqual([ + `DROP SCHEMA IF EXISTS "we""ird" CASCADE`, + ]); + }); + + test("public is kept; only what current_user owns in it is dropped", () => { + const [statement, ...rest] = cleanStatements("public"); + expect(rest).toEqual([]); + expect(statement).toMatch(/^DO \$appkit\$/); + expect(statement).not.toContain("DROP SCHEMA"); + expect(statement).toContain("n.nspname = 'public'"); + expect(statement).toContain( + "c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)", + ); + // Column-owned sequences go with their table; table row types are not dropped separately. + expect(statement).toContain("d.deptype IN ('a', 'i')"); + expect(statement).toContain("DROP TYPE IF EXISTS public.%I CASCADE"); + }); +}); + +describe("FOREIGN_OWNERS_SQL", () => { + test("lists owners of user schemas, relations, types and functions other than current_user", () => { + for (const catalog of ["pg_namespace", "pg_class", "pg_type", "pg_proc"]) { + expect(FOREIGN_OWNERS_SQL).toContain(`pg_catalog.${catalog}`); + } + expect(FOREIGN_OWNERS_SQL).toContain( + "n.nspname !~ '^pg_' and n.nspname <> 'information_schema'", + ); + expect(FOREIGN_OWNERS_SQL).toContain("where r.rolname = current_user"); + }); +}); diff --git a/packages/appkit/src/database/tooling/branch-sql.ts b/packages/appkit/src/database/tooling/branch-sql.ts new file mode 100644 index 000000000..b77244dda --- /dev/null +++ b/packages/appkit/src/database/tooling/branch-sql.ts @@ -0,0 +1,32 @@ +import { type DbContext, openBranchPool } from "./context"; + +/** Run statements on the `.env` branch in one transaction, then end the pool. */ +export async function runInTransaction( + ctx: DbContext, + statements: readonly string[], +): Promise { + const pool = await openBranchPool(ctx); + if (!pool) { + throw new Error( + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`.", + ); + } + try { + const client = await pool.connect(); + let failed: unknown; + try { + await client.query("BEGIN"); + for (const statement of statements) await client.query(statement); + await client.query("COMMIT"); + } catch (error) { + failed = error; + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + // A connection whose transaction state is unknown is not reused. + client.release(failed instanceof Error ? failed : undefined); + } + } finally { + await pool.end().catch(() => undefined); + } +} diff --git a/packages/appkit/src/database/tooling/clean.ts b/packages/appkit/src/database/tooling/clean.ts new file mode 100644 index 000000000..b18707c2a --- /dev/null +++ b/packages/appkit/src/database/tooling/clean.ts @@ -0,0 +1,26 @@ +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { cleanStatements } from "../migrations/ownership"; +import { runInTransaction } from "./branch-sql"; +import type { DbContext } from "./context"; +import { assertAppkitBranch } from "./guard"; +import { requireSchemaFile } from "./migrate"; + +interface CleanReport { + readonly branch: string; + readonly schemaName: string; +} + +/** + * `appkit db clean`: remove every app-schema object, including the migration + * journal, on an AppKit dev or session branch. Refuses any other branch + * before connecting. + */ +export async function runClean(ctx: DbContext): Promise { + requireSchemaFile(ctx.root, "clean"); + const branch = await assertAppkitBranch(ctx, "clean"); + const schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + await runInTransaction(ctx, cleanStatements(schema.$schemaName)); + return { branch: branch.name, schemaName: schema.$schemaName }; +} diff --git a/packages/appkit/src/database/tooling/env-file.ts b/packages/appkit/src/database/tooling/env-file.ts new file mode 100644 index 000000000..de97d66fe --- /dev/null +++ b/packages/appkit/src/database/tooling/env-file.ts @@ -0,0 +1,64 @@ +import fs from "node:fs"; + +/** Captures any indentation and `export ` before the key, so a rewrite keeps them. */ +const KEY_LINE = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=/; +const PLAIN_VALUE = /^[A-Za-z0-9_./:@+-]*$/; + +/** + * A value dotenv reads back unchanged, always on one line: plain; else + * double-quoted, where dotenv turns `\n` and `\r` back into line breaks; else + * single-quoted, which dotenv takes literally. + */ +function formatValue(key: string, value: string): string { + if (PLAIN_VALUE.test(value)) return value; + if (!/["\\]/.test(value)) { + return `"${value.replace(/\r/g, "\\r").replace(/\n/g, "\\n")}"`; + } + if (!/['\r\n]/.test(value)) return `'${value}'`; + throw new Error( + `Cannot write ${key} to .env: its value mixes quotes, backslashes and line breaks.`, + ); +} + +/** + * Replace `KEY=…` lines in place and append missing keys. Comments, blank + * lines, other keys, their order, `export ` prefixes and CRLF line endings are + * kept. A missing file starts from `seedFrom` (e.g. .env.example) when that + * exists. Written 0600. + */ +export function updateEnvFile( + file: string, + values: Readonly>, + seedFrom?: string, +): void { + const source = fs.existsSync(file) + ? file + : seedFrom && fs.existsSync(seedFrom) + ? seedFrom + : null; + const text = source ? fs.readFileSync(source, "utf-8") : ""; + const eol = text.includes("\r\n") ? "\r\n" : "\n"; + const lines = + text.length > 0 ? text.replace(/\r?\n$/, "").split(/\r?\n/) : []; + + const written = new Set(); + const out: string[] = []; + for (const line of lines) { + const match = KEY_LINE.exec(line); + const key = match?.[2]; + if (key === undefined || !Object.hasOwn(values, key)) { + out.push(line); + continue; + } + // Later duplicates of a managed key are dropped, so the value is unambiguous. + if (written.has(key)) continue; + written.add(key); + out.push(`${match?.[1] ?? ""}${key}=${formatValue(key, values[key])}`); + } + for (const [key, value] of Object.entries(values)) { + if (!written.has(key)) out.push(`${key}=${formatValue(key, value)}`); + } + + fs.writeFileSync(file, `${out.join(eol)}${eol}`, { mode: 0o600 }); + fs.chmodSync(file, 0o600); +} diff --git a/packages/appkit/src/database/tooling/guard.ts b/packages/appkit/src/database/tooling/guard.ts index f135e8258..830d28e25 100644 --- a/packages/appkit/src/database/tooling/guard.ts +++ b/packages/appkit/src/database/tooling/guard.ts @@ -53,6 +53,19 @@ export function classifyBranch(input: { }; } +type GuardedAction = "clean" | "seed"; + +export class BranchGuardError extends Error { + constructor(action: GuardedAction, branch: string | null, reason: string) { + super( + branch + ? `Refusing to ${action} ${resourceId(branch)}: ${reason}. \`appkit db ${action}\` only writes to branches \`appkit db init\` created for you (dev-… or session-…).` + : `Cannot ${action}: ${reason}.`, + ); + this.name = "BranchGuardError"; + } +} + interface BranchCheck { readonly branch: LakebaseBranch; readonly verdict: BranchVerdict; @@ -82,3 +95,26 @@ export async function checkAppkitBranch( }), }; } + +/** Throws BranchGuardError unless the `.env` branch is AppKit-owned. Never connects to the database. */ +export async function assertAppkitBranch( + ctx: DbContext, + action: GuardedAction, +): Promise { + const check = await checkAppkitBranch(ctx); + if (!check) { + throw new BranchGuardError( + action, + null, + "no branch configured in .env (LAKEBASE_ENDPOINT) — run `appkit db init`", + ); + } + if (!check.verdict.appkitOwned) { + throw new BranchGuardError( + action, + check.branch.name, + check.verdict.reason ?? "it is not an AppKit branch", + ); + } + return check.branch; +} diff --git a/packages/appkit/src/database/tooling/identity.ts b/packages/appkit/src/database/tooling/identity.ts index 3b539abc6..6a7308405 100644 --- a/packages/appkit/src/database/tooling/identity.ts +++ b/packages/appkit/src/database/tooling/identity.ts @@ -1,8 +1,17 @@ import type { DbContext } from "./context"; +import type { IdentityType } from "./lakebase-api"; /** Who runs the command: the Postgres role name is the user name (email or SP client ID). */ -interface Identity { +export interface Identity { readonly userName: string; + readonly identityType: IdentityType; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** A service principal's user name is its application (client) ID. */ +export function identityTypeOf(userName: string): IdentityType { + return UUID.test(userName) ? "SERVICE_PRINCIPAL" : "USER"; } const identities = new WeakMap>(); @@ -20,7 +29,7 @@ export function currentIdentity(ctx: DbContext): Promise { "Could not determine the current Databricks user (currentUser.me returned no userName). Check DATABRICKS_CONFIG_PROFILE.", ); } - return { userName }; + return { userName, identityType: identityTypeOf(userName) }; }); identity.catch(() => identities.delete(ctx)); identities.set(ctx, identity); diff --git a/packages/appkit/src/database/tooling/index.ts b/packages/appkit/src/database/tooling/index.ts index 11e9a8614..10909be0a 100644 --- a/packages/appkit/src/database/tooling/index.ts +++ b/packages/appkit/src/database/tooling/index.ts @@ -3,10 +3,23 @@ * Loaded lazily by the shared CLI; it may import drizzle-kit, so the runtime * plugin never imports it. */ +export { runClean } from "./clean"; export { createDbContext, type DbContext } from "./context"; +export { + defaultSchemaStart, + type InitChoices, + type InitOptions, + type InitReport, + resolveInitDefaults, + runInit, + type SchemaStart, + schemaStartsFor, +} from "./init"; export { type IntrospectOptions, type IntrospectReport, runIntrospect, } from "./introspect"; export { type MigrateOptions, type MigrateReport, runMigrate } from "./migrate"; +export { runSeed } from "./seed"; +export type { BranchMode } from "./targets"; diff --git a/packages/appkit/src/database/tooling/init.ts b/packages/appkit/src/database/tooling/init.ts new file mode 100644 index 000000000..9fc23e28b --- /dev/null +++ b/packages/appkit/src/database/tooling/init.ts @@ -0,0 +1,504 @@ +import fs from "node:fs"; +import path from "node:path"; + +import dotenv from "dotenv"; + +import { FOREIGN_OWNERS_SQL } from "../migrations/ownership"; +import { runClean } from "./clean"; +import { type DbContext, openBranchPool } from "./context"; +import { updateEnvFile } from "./env-file"; +import { classifyBranch } from "./guard"; +import { currentIdentity, type Identity } from "./identity"; +import { type IntrospectReport, runIntrospect } from "./introspect"; +import { + type LakebaseBranch, + LakebaseApiError, + type LakebaseEndpoint, + type LakebaseRole, + READ_WRITE_ENDPOINT, + SUPERUSER_MEMBERSHIP, +} from "./lakebase-api"; +import { + hasSchemaFile, + type MigrateReport, + requireSchemaFile, + runMigrate, +} from "./migrate"; +import { + devBranchId, + newSessionId, + SESSION_BRANCH_TTL, + sessionBranchId, +} from "./naming"; +import { + type BranchMode, + type DeployTarget, + type LocalTarget, + parseResourceName, + readDeployBranches, + readDeployTarget, + readLocalTarget, + resourceId, +} from "./targets"; + +/** How init leaves the schema. */ +export type SchemaStart = "migrate" | "introspect" | "clean"; +/** + * The starts worth offering, the default first. Without schema.ts there is + * nothing to migrate and no schema name to clean, so introspect is the only + * start that can run. + */ +export function schemaStartsFor(hasSchema: boolean): readonly SchemaStart[] { + return hasSchema ? ["migrate", "introspect", "clean"] : ["introspect"]; +} + +export function defaultSchemaStart(hasSchema: boolean): SchemaStart { + return schemaStartsFor(hasSchema)[0] as SchemaStart; +} + +export interface InitChoices { + readonly mode: BranchMode; + /** null leaves the schema as it is. */ + readonly schemaStart: SchemaStart | null; +} + +export interface InitOptions { + /** Passed to migrate: false makes an ambiguous rename fail instead of prompting. */ + readonly interactive: boolean; +} + +type RoleAction = "kept" | "created" | "updated"; + +export interface InitReport { + readonly mode: BranchMode; + readonly branch: string; + readonly created: boolean; + /** The branch a new fork was created from. */ + readonly sourceBranch?: string; + readonly endpoint: string; + readonly endpointCreated: boolean; + readonly host: string; + readonly database: string; + readonly user: string; + readonly role: { readonly name: string; readonly action: RoleAction }; + /** Postgres roles whose objects on this branch were handed to the developer. */ + readonly handedOff: readonly string[]; + readonly envFile: string; + readonly cleaned: boolean; + readonly schema: MigrateReport | null; + readonly introspect: IntrospectReport | null; +} + +const ENV_FILE = ".env"; +const ENV_EXAMPLE = ".env.example"; +const DEFAULT_ENDPOINT_ID = "primary"; +/** Every Lakebase branch has it; used when nothing names another database. */ +const DEFAULT_DATABASE = "databricks_postgres"; + +/** + * The mode `.env`'s branch implies: your dev branch is "dev", this checkout's + * session branch (APPKIT_DB_SESSION) is "session". Null for any other branch, + * without an endpoint, or when it belongs to another project than the + * configured one: init then asks, or uses "dev" without prompts. + */ +export async function currentBranchMode( + ctx: DbContext, +): Promise { + const local = readLocalTarget(ctx.env); + const current = parseResourceName(local.endpoint ?? undefined); + if (!current?.branch) return null; + if (local.project && current.project !== local.project) return null; + const branchId = resourceId(current.branch); + if (local.session && branchId === sessionBranchId(local.session)) { + return "session"; + } + const identity = await currentIdentity(ctx); + return branchId === devBranchId(identity.userName) ? "dev" : null; +} + +/** The mode `.env`'s branch implies (null without one), and whether schema.ts exists. */ +export async function resolveInitDefaults(ctx: DbContext): Promise<{ + mode: BranchMode | null; + hasSchema: boolean; +}> { + return { + mode: await currentBranchMode(ctx), + hasSchema: hasSchemaFile(ctx.root), + }; +} + +/** LAKEBASE_PROJECT, else the project of LAKEBASE_ENDPOINT, else of the bundle's postgres_branch. */ +function resolveProject( + local: LocalTarget, + deploy: DeployTarget | null, +): string { + const project = + local.project ?? + parseResourceName(local.endpoint ?? undefined)?.project ?? + parseResourceName(deploy?.branch)?.project; + if (!project) { + throw new Error( + "No Lakebase project configured. Set LAKEBASE_PROJECT=projects/ in .env, or bind postgres_branch in databricks.yml (`databricks apps init` does).", + ); + } + return project; +} + +/** Drop .env connection values and a deploy binding that belong to another project than LAKEBASE_PROJECT. */ +function scopeToProject( + project: string, + local: LocalTarget, + deploy: DeployTarget | null, +): { local: LocalTarget; deploy: DeployTarget | null } { + const inProject = (name: string | null | undefined) => + parseResourceName(name ?? undefined)?.project === project; + return { + local: inProject(local.endpoint) + ? local + : { ...local, endpoint: null, database: null }, + deploy: deploy && inProject(deploy.branch) ? deploy : null, + }; +} + +function isExpired(branch: LakebaseBranch, now = Date.now()): boolean { + if (branch.status?.current_state === "DELETED") return true; + const expires = branch.status?.expire_time; + return !!expires && Date.parse(expires) <= now; +} + +/** Find dev-…/session-… with one GET, or fork the project's default branch. */ +async function findOrCreateBranch( + ctx: DbContext, + project: string, + mode: BranchMode, + identity: Identity, + session: string | null, +): Promise<{ + branch: LakebaseBranch; + created: boolean; + sourceBranch?: string; + session: string | null; +}> { + const api = ctx.api(); + let sessionId = mode === "session" ? (session ?? newSessionId()) : null; + const idFor = () => + mode === "dev" + ? devBranchId(identity.userName) + : sessionBranchId(sessionId as string); + + let existing = await api.getBranch(`${project}/branches/${idFor()}`); + if (existing && isExpired(existing)) { + if (mode === "dev") { + throw new Error( + `Your dev branch ${existing.name} was deleted. Restore it, or delete it for good, then rerun \`appkit db init\`.`, + ); + } + // An expired session branch is replaced by a fresh session. + sessionId = newSessionId(); + existing = await api.getBranch(`${project}/branches/${idFor()}`); + } + if (existing) return { branch: existing, created: false, session: sessionId }; + + const parent = (await api.listBranches(project)).find( + (b) => b.status?.default, + ); + if (!parent) { + throw new Error(`Project ${project} has no default branch to fork from.`); + } + const id = idFor(); + if (sessionId && sessionId !== session) { + // Written first, so a failed or interrupted run reuses this id instead of forking again. + updateEnvFile( + path.join(ctx.root, ENV_FILE), + { APPKIT_DB_SESSION: sessionId }, + path.join(ctx.root, ENV_EXAMPLE), + ); + } + try { + const branch = await api.createBranch(project, id, { + source_branch: parent.name, + ...(mode === "dev" + ? { no_expiry: true as const } + : { ttl: SESSION_BRANCH_TTL }), + }); + return { + branch: branch?.name ? branch : { name: `${project}/branches/${id}` }, + created: true, + sourceBranch: parent.name, + session: sessionId, + }; + } catch (error) { + if (error instanceof LakebaseApiError && error.statusCode === 403) { + throw new LakebaseApiError( + error.operation, + `creating ${id} needs CAN MANAGE on ${project}. Ask a project owner for it.`, + { statusCode: 403, cause: error }, + ); + } + if (error instanceof LakebaseApiError && error.statusCode === 409) { + // Another run created it first. + const raced = await api.getBranch(`${project}/branches/${id}`); + if (raced) return { branch: raced, created: false, session: sessionId }; + } + throw error; + } +} + +async function ensureReadWriteEndpoint( + ctx: DbContext, + branch: string, +): Promise<{ endpoint: LakebaseEndpoint; created: boolean; host: string }> { + const api = ctx.api(); + const readWrite = (endpoints: LakebaseEndpoint[]) => + endpoints.find((e) => e.status?.endpoint_type === READ_WRITE_ENDPOINT); + + let endpoint = readWrite(await api.listEndpoints(branch)); + let created = false; + if (!endpoint) { + endpoint = await api.createEndpoint(branch, DEFAULT_ENDPOINT_ID); + created = true; + } + let host = endpoint?.status?.hosts?.host; + if (!host) { + endpoint = readWrite(await api.listEndpoints(branch)) ?? endpoint; + host = endpoint?.status?.hosts?.host; + } + if (!endpoint?.name || !host) { + throw new Error( + `The read-write endpoint of ${branch} has no host yet. Rerun \`appkit db init\` in a minute.`, + ); + } + return { endpoint, created, host }; +} + +async function ensureDeveloperRole( + ctx: DbContext, + branch: string, + identity: Identity, + roles: readonly LakebaseRole[], +): Promise<{ role: LakebaseRole; action: RoleAction }> { + const api = ctx.api(); + const existing = roles.find( + (r) => r.status?.postgres_role === identity.userName, + ); + if (!existing) { + const role = await api.createRole(branch, { + identity_type: identity.identityType, + postgres_role: identity.userName, + auth_method: "LAKEBASE_OAUTH_V1", + membership_roles: [SUPERUSER_MEMBERSHIP], + }); + if (!role?.name) { + // The operation response normally carries the role; fall back to a lookup. + const found = (await api.listRoles(branch)).find( + (r) => r.status?.postgres_role === identity.userName, + ); + if (!found) + throw new Error(`Creating your role on ${branch} did not return it.`); + return { role: found, action: "created" }; + } + return { role, action: "created" }; + } + if (existing.status?.membership_roles?.includes(SUPERUSER_MEMBERSHIP)) { + return { role: existing, action: "kept" }; + } + const role = await api.setRoleMemberships(existing.name, [ + SUPERUSER_MEMBERSHIP, + ]); + return { role: role?.name ? role : existing, action: "updated" }; +} + +/** + * A fork copies its parent's objects with their owners. Hand + * every object owned by another role-API role to the developer, by deleting that + * role's copy on this branch with reassign_owned_to. Idempotent: nothing owned + * by others means no API call. + */ +async function handOffOwnership( + ctx: DbContext, + developerRole: LakebaseRole, + roles: readonly LakebaseRole[], +): Promise { + const pool = await openBranchPool(ctx); + if (!pool) + throw new Error("The branch connection settings were not written to .env."); + let owners: Set; + try { + const { rows } = await pool.query<{ owner: string }>(FOREIGN_OWNERS_SQL); + owners = new Set(rows.map((row) => row.owner)); + } finally { + await pool.end().catch(() => undefined); + } + + const handedOff: string[] = []; + for (const role of roles) { + const postgresRole = role.status?.postgres_role; + if ( + !postgresRole || + role.name === developerRole.name || + !owners.has(postgresRole) + ) { + continue; + } + await ctx + .api() + .deleteRole(role.name, { reassignOwnedTo: developerRole.name }); + handedOff.push(postgresRole); + } + return handedOff; +} + +async function resolveDatabase( + ctx: DbContext, + branch: string, + local: LocalTarget, + deploy: DeployTarget | null, +): Promise { + // A real .env endpoint came with its database; forks inherit the parent's databases. + if (local.database) return local.database; + if (deploy?.database) { + const database = await ctx + .api() + .getDatabase(`${branch}/databases/${resourceId(deploy.database)}`); + if (database?.status?.postgres_database) + return database.status.postgres_database; + } + return DEFAULT_DATABASE; +} + +function readEnvValues(file: string): Record { + return fs.existsSync(file) + ? dotenv.parse(fs.readFileSync(file, "utf-8")) + : {}; +} + +/** + * `appkit db init`: find or create the developer's dev branch or this + * checkout's session branch, ensure a read-write endpoint and the developer's + * DATABRICKS_SUPERUSER role, hand copied objects to the developer, point `.env` + * at the branch, then apply the schema start. It refuses a branch that is the + * project's default, protected or one databricks.yml deploys to before + * changing anything on it, and never touches the deploy bundle. + */ +export async function runInit( + ctx: DbContext, + choices: InitChoices, + options: InitOptions = { interactive: false }, +): Promise { + // Before any change: migrate and clean cannot run without schema.ts. + if (choices.schemaStart === "migrate" || choices.schemaStart === "clean") { + requireSchemaFile(ctx.root, choices.schemaStart); + } + const api = ctx.api(); + const configured = { + local: readLocalTarget(ctx.env), + deploy: readDeployTarget(ctx.root), + }; + const project = resolveProject(configured.local, configured.deploy); + const { local, deploy } = scopeToProject( + project, + configured.local, + configured.deploy, + ); + const identity = await currentIdentity(ctx); + + const deployBranches = readDeployBranches(ctx.root); + + // 1. Branch; init never changes a branch the app deploys to. + const found = await findOrCreateBranch( + ctx, + project, + choices.mode, + identity, + local.session, + ); + const { branch, created, sourceBranch } = found; + const session = found.session ?? local.session; + const verdict = classifyBranch({ + branch, + userName: identity.userName, + sessionId: session, + deployBranches, + }); + if (!verdict.appkitOwned) { + throw new Error( + `Refusing to set up ${resourceId(branch.name)}: ${verdict.reason ?? "it is not an AppKit branch"}. \`appkit db init\` only sets up your dev branch or this checkout's session branch, never a branch the app deploys to.`, + ); + } + + // 2. Endpoint and role. + const { + endpoint, + created: endpointCreated, + host, + } = await ensureReadWriteEndpoint(ctx, branch.name); + const roles = await api.listRoles(branch.name); + const { role, action } = await ensureDeveloperRole( + ctx, + branch.name, + identity, + roles, + ); + const database = await resolveDatabase(ctx, branch.name, local, deploy); + + // 3. .env, replaced in place; the running context sees the new branch. + const envFile = path.join(ctx.root, ENV_FILE); + const current = readEnvValues( + fs.existsSync(envFile) ? envFile : path.join(ctx.root, ENV_EXAMPLE), + ); + const values: Record = { + LAKEBASE_PROJECT: project, + PGHOST: host, + LAKEBASE_ENDPOINT: endpoint.name, + PGDATABASE: database, + PGUSER: identity.userName, + ...(choices.mode === "session" && session + ? { APPKIT_DB_SESSION: session } + : {}), + ...(current.PGPORT && /^\d+$/.test(current.PGPORT) + ? {} + : { PGPORT: "5432" }), + ...(current.PGSSLMODE ? {} : { PGSSLMODE: "require" }), + }; + updateEnvFile(envFile, values, path.join(ctx.root, ENV_EXAMPLE)); + Object.assign(ctx.env, values); + + // 4. Ownership hand-off: the fork's copies become the developer's. + const handedOff = await handOffOwnership(ctx, role, roles); + + // 5. Schema start. + let schema: MigrateReport | null = null; + let introspect: IntrospectReport | null = null; + let cleaned = false; + if (choices.schemaStart === "introspect") { + introspect = await runIntrospect(ctx, { interactive: options.interactive }); + } else if (choices.schemaStart === "migrate") { + schema = await runMigrate(ctx, { interactive: options.interactive }); + } else if (choices.schemaStart === "clean") { + await runClean(ctx); + cleaned = true; + // Recreate from the committed migrations, so the startup check passes. + schema = await runMigrate(ctx, { + interactive: options.interactive, + generate: false, + }); + } + + return { + mode: choices.mode, + branch: branch.name, + created, + ...(sourceBranch ? { sourceBranch } : {}), + endpoint: endpoint.name, + endpointCreated, + host, + database, + user: identity.userName, + role: { name: role.name, action }, + handedOff, + envFile, + cleaned, + schema, + introspect, + }; +} diff --git a/packages/appkit/src/database/tooling/introspect/index.ts b/packages/appkit/src/database/tooling/introspect/index.ts index e2f19a978..5f315f77c 100644 --- a/packages/appkit/src/database/tooling/introspect/index.ts +++ b/packages/appkit/src/database/tooling/introspect/index.ts @@ -164,7 +164,7 @@ export async function runIntrospect( .filter((skip) => !skip.column) .map((skip) => `${skip.table}: ${skip.reason}`); throw new IntrospectRefusedError( - `Schema "${schemaName}" on ${branchId} has no tables${tables.length > 0 ? ` introspect can write (${reasons.join("; ")})` : ""}, so introspect would ${hasHistory ? "write a migration that drops every table" : `replace ${SCHEMA_FILE} with an empty one`}. Pass --schema-name to read another schema.`, + `Schema "${schemaName}" on ${branchId} has no tables${tables.length > 0 ? ` introspect can write (${reasons.join("; ")})` : ""}, so introspect would ${hasHistory ? "write a migration that drops every table" : `replace ${SCHEMA_FILE} with an empty one`}. Run \`appkit db introspect --schema-name \` to read another schema.`, ); } diff --git a/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts b/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts index d9c7fb3ef..2e655d269 100644 --- a/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts +++ b/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts @@ -372,7 +372,7 @@ describe("runIntrospect", () => { expect(error).toBeInstanceOf(IntrospectRefusedError); expect((error as Error).message).toBe( - 'Schema "app" on dev-alice-3f9a2c has no tables, so introspect would replace config/database/schema.ts with an empty one. Pass --schema-name to read another schema.', + 'Schema "app" on dev-alice-3f9a2c has no tables, so introspect would replace config/database/schema.ts with an empty one. Run `appkit db introspect --schema-name ` to read another schema.', ); expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toBe( previousSchema, @@ -451,7 +451,7 @@ describe("runIntrospect", () => { expect(error).toBeInstanceOf(IntrospectRefusedError); expect((error as Error).message).toBe( - 'Schema "app" on dev-alice-3f9a2c has no tables, so introspect would write a migration that drops every table. Pass --schema-name to read another schema.', + 'Schema "app" on dev-alice-3f9a2c has no tables, so introspect would write a migration that drops every table. Run `appkit db introspect --schema-name ` to read another schema.', ); expect(tree(root)).toEqual(before); }); @@ -539,7 +539,7 @@ describe("runIntrospect", () => { expect(error).toBeInstanceOf(IntrospectRefusedError); expect((error as Error).message).toBe( - 'Schema "app" on dev-alice-3f9a2c has no tables introspect can write (logs: it has no primary key), so introspect would replace config/database/schema.ts with an empty one. Pass --schema-name to read another schema.', + 'Schema "app" on dev-alice-3f9a2c has no tables introspect can write (logs: it has no primary key), so introspect would replace config/database/schema.ts with an empty one. Run `appkit db introspect --schema-name ` to read another schema.', ); expect(tree(root)).toEqual(before); }); diff --git a/packages/appkit/src/database/tooling/lakebase-api.ts b/packages/appkit/src/database/tooling/lakebase-api.ts index 1bde6df9d..b350110ff 100644 --- a/packages/appkit/src/database/tooling/lakebase-api.ts +++ b/packages/appkit/src/database/tooling/lakebase-api.ts @@ -16,11 +16,51 @@ export interface LakebaseBranch { export interface LakebaseEndpoint { name: string; - status?: { hosts?: { host?: string } }; + status?: { + endpoint_type?: string; + hosts?: { host?: string }; + }; +} + +export interface LakebaseRole { + name: string; + status?: { + postgres_role?: string; + membership_roles?: string[]; + }; +} + +export interface LakebaseDatabase { + name: string; + status?: { postgres_database?: string }; +} + +export type IdentityType = "USER" | "SERVICE_PRINCIPAL"; + +export const SUPERUSER_MEMBERSHIP = "DATABRICKS_SUPERUSER"; +export const READ_WRITE_ENDPOINT = "ENDPOINT_TYPE_READ_WRITE"; + +export interface RoleSpec { + identity_type: IdentityType; + postgres_role: string; + auth_method: "LAKEBASE_OAUTH_V1"; + membership_roles: [typeof SUPERUSER_MEMBERSHIP]; +} + +export type BranchSpec = { source_branch: string } & ( + | { no_expiry: true } + | { ttl: string } +); + +interface Operation { + name?: string; + done?: boolean; + error?: { message?: string; error_code?: string }; + response?: unknown; } export class LakebaseApiError extends Error { - /** HTTP status of the failing call. */ + /** HTTP status of the failing call; absent for a failed long-running operation. */ readonly statusCode?: number; /** What was being done, e.g. "create branch projects/p/branches/b". */ readonly operation: string; @@ -50,10 +90,27 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +interface LakebaseApiOptions { + /** First poll delay for long-running operations; doubles up to 5 s. */ + readonly pollIntervalMs?: number; + /** Give up waiting for one long-running operation after this long. */ + readonly timeoutMs?: number; +} + export class LakebaseApi { + private readonly pollIntervalMs: number; + private readonly timeoutMs: number; + constructor( private readonly client: Pick, - ) {} + options: LakebaseApiOptions = {}, + ) { + this.pollIntervalMs = options.pollIntervalMs ?? 500; + this.timeoutMs = options.timeoutMs ?? 10 * 60_000; + } getBranch(name: string): Promise { return this.getOrNull(`get branch ${name}`, name); @@ -63,6 +120,98 @@ export class LakebaseApi { return this.getOrNull(`get endpoint ${name}`, name); } + listBranches(project: string): Promise { + return this.list( + `list branches of ${project}`, + `${project}/branches`, + "branches", + ); + } + + async createBranch( + project: string, + branchId: string, + spec: BranchSpec, + ): Promise { + const operation = `create branch ${project}/branches/${branchId}`; + const op = await this.call(operation, { + path: `${API}/${project}/branches`, + method: "POST", + query: { branch_id: branchId }, + payload: { spec }, + }); + return this.wait(operation, op); + } + + listEndpoints(branch: string): Promise { + return this.list( + `list endpoints of ${branch}`, + `${branch}/endpoints`, + "endpoints", + ); + } + + async createEndpoint( + branch: string, + endpointId: string, + ): Promise { + const operation = `create endpoint ${branch}/endpoints/${endpointId}`; + const op = await this.call(operation, { + path: `${API}/${branch}/endpoints`, + method: "POST", + query: { endpoint_id: endpointId }, + payload: { spec: { endpoint_type: READ_WRITE_ENDPOINT } }, + }); + return this.wait(operation, op); + } + + getDatabase(name: string): Promise { + return this.getOrNull(`get database ${name}`, name); + } + + listRoles(branch: string): Promise { + return this.list(`list roles of ${branch}`, `${branch}/roles`, "roles"); + } + + /** Without a role_id; the API derives one (sidesteps the USER role_id format). */ + async createRole(branch: string, spec: RoleSpec): Promise { + const operation = `create role ${spec.postgres_role} on ${branch}`; + const op = await this.call(operation, { + path: `${API}/${branch}/roles`, + method: "POST", + payload: { spec }, + }); + return this.wait(operation, op); + } + + async setRoleMemberships( + role: string, + memberships: RoleSpec["membership_roles"], + ): Promise { + const operation = `update role ${role}`; + const op = await this.call(operation, { + path: `${API}/${role}`, + method: "PATCH", + query: { update_mask: "spec.membership_roles" }, + payload: { spec: { membership_roles: memberships } }, + }); + return this.wait(operation, op); + } + + /** Every object the role owns is reassigned first (runs SQL on the branch's compute). */ + async deleteRole( + role: string, + options: { reassignOwnedTo: string }, + ): Promise { + const operation = `delete role ${role}`; + const op = await this.call(operation, { + path: `${API}/${role}`, + method: "DELETE", + query: { reassign_owned_to: options.reassignOwnedTo }, + }); + await this.wait(operation, op); + } + private async call( operation: string, request: Omit, @@ -101,4 +250,57 @@ export class LakebaseApi { throw error; } } + + private async list( + operation: string, + collection: string, + key: string, + ): Promise { + const items: T[] = []; + let pageToken: string | undefined; + do { + const page = await this.call>(operation, { + path: `${API}/${collection}`, + method: "GET", + ...(pageToken ? { query: { page_token: pageToken } } : {}), + }); + items.push(...((page?.[key] as T[] | undefined) ?? [])); + pageToken = (page?.next_page_token as string | undefined) || undefined; + } while (pageToken); + return items; + } + + /** Poll GET /api/2.0/postgres/{operation.name} until done. */ + private async wait(operation: string, first: Operation): Promise { + const deadline = Date.now() + this.timeoutMs; + let delay = this.pollIntervalMs; + let current = first; + while (!current?.done) { + if (!current?.name) { + throw new LakebaseApiError( + operation, + "the API returned an unfinished operation without a name", + ); + } + if (Date.now() >= deadline) { + throw new LakebaseApiError( + operation, + `timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${current.name}`, + ); + } + await sleep(delay); + delay = Math.min(delay * 2, 5_000); + current = await this.call(operation, { + path: `${API}/${current.name}`, + method: "GET", + }); + } + if (current.error) { + throw new LakebaseApiError( + operation, + current.error.message ?? current.error.error_code ?? "unknown error", + ); + } + return current.response as T; + } } diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts index 4b3bddec1..e6425fa58 100644 --- a/packages/appkit/src/database/tooling/migrate.ts +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -17,15 +17,24 @@ import { resourceId } from "./targets"; export const SCHEMA_FILE = "config/database/schema.ts"; /** Whether the checkout has a config/database/schema.ts yet. */ -function hasSchemaFile(root: string): boolean { +export function hasSchemaFile(root: string): boolean { return fs.existsSync(path.join(root, SCHEMA_FILE)); } -/** Without schema.ts, point to introspect instead of a load error. */ -function requireSchemaFile(root: string): void { +const WITHOUT_SCHEMA = { + migrate: "there is nothing to migrate", + clean: "clean cannot tell which schema to drop", +} as const; + +/** For a command that reads schema.ts: without one, point to introspect instead of a load error. */ +export function requireSchemaFile( + root: string, + command: keyof typeof WITHOUT_SCHEMA, +): void { if (hasSchemaFile(root)) return; + const what = WITHOUT_SCHEMA[command]; throw new Error( - `No ${SCHEMA_FILE} yet, so there is nothing to migrate. Run \`appkit db introspect\` to write it from the branch's tables, or create it.`, + `No ${SCHEMA_FILE} yet, so ${what}. Run \`appkit db introspect\` to write it from the branch's tables, or create it.`, ); } export const DATABASE_TYPES_PATH = path.join( @@ -48,6 +57,8 @@ export interface MigrateReport { export interface MigrateOptions { /** false: an ambiguous rename throws instead of prompting. */ readonly interactive: boolean; + /** false: only apply committed migrations (init's `clean` start). Default true. */ + readonly generate?: boolean; } type ApplyOutcome = Pick; @@ -95,16 +106,19 @@ export async function runMigrate( ctx: DbContext, options: MigrateOptions, ): Promise { - requireSchemaFile(ctx.root); + requireSchemaFile(ctx.root, "migrate"); const schema = await loadDefaultDatabaseSchema(ctx.root, { showImportError: true, }); const folder = migrationsFolder(ctx.root); - const generated = await generateMigrationFiles({ - folder, - schema, - interactive: options.interactive, - }); + const generated = + options.generate === false + ? null + : await generateMigrationFiles({ + folder, + schema, + interactive: options.interactive, + }); // The types follow schema.ts alone, so they are current even if applying fails. const typesFile = path.join(ctx.root, DATABASE_TYPES_PATH); diff --git a/packages/appkit/src/database/tooling/naming.ts b/packages/appkit/src/database/tooling/naming.ts index aadaf374b..0c50388f9 100644 --- a/packages/appkit/src/database/tooling/naming.ts +++ b/packages/appkit/src/database/tooling/naming.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, randomInt } from "node:crypto"; // Branch names AppKit creates. Branch IDs: 1-63 chars, start with a // lowercase letter, lowercase letters, digits and hyphens only. @@ -8,6 +8,9 @@ const DEV_PREFIX = "dev-"; const SESSION_PREFIX = "session-"; const HASH_LENGTH = 6; +/** Seven days: a session branch outlives a working week, then expires on its own. */ +export const SESSION_BRANCH_TTL = "604800s"; + const SESSION_ID = /^[a-z0-9]{1,55}$/; function slug(value: string): string { @@ -46,3 +49,12 @@ export function sessionBranchId(sessionId: string): string { } return `${SESSION_PREFIX}${sessionId}`; } + +const BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz"; + +/** 8 random base36 characters, generated once per checkout and kept in .env. */ +export function newSessionId(): string { + let id = ""; + for (let i = 0; i < 8; i++) id += BASE36[randomInt(BASE36.length)]; + return id; +} diff --git a/packages/appkit/src/database/tooling/seed.ts b/packages/appkit/src/database/tooling/seed.ts new file mode 100644 index 000000000..78226a347 --- /dev/null +++ b/packages/appkit/src/database/tooling/seed.ts @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { quoteIdentifier } from "../migrations/ownership"; +import { DEFAULT_SCHEMA_NAME } from "../schema-builder/define-schema"; +import { runInTransaction } from "./branch-sql"; +import type { DbContext } from "./context"; +import { assertAppkitBranch } from "./guard"; +import { hasSchemaFile } from "./migrate"; + +export const SEED_FILE = "config/database/seed.sql"; + +interface SeedReport { + readonly branch: string; + readonly file: string; +} + +/** + * `appkit db seed`: run config/database/seed.sql in one transaction on an + * AppKit dev or session branch, with the app schema first on the search_path + * so unqualified names resolve to it. Refuses any other branch before + * connecting. + */ +export async function runSeed(ctx: DbContext): Promise { + const file = path.join(ctx.root, SEED_FILE); + if (!fs.existsSync(file)) { + throw new Error( + `No seed file at ${SEED_FILE}. Create it with the SQL to run.`, + ); + } + const branch = await assertAppkitBranch(ctx, "seed"); + const schemaName = hasSchemaFile(ctx.root) + ? (await loadDefaultDatabaseSchema(ctx.root, { showImportError: true })) + .$schemaName + : DEFAULT_SCHEMA_NAME; + const searchPath = + schemaName === "public" + ? "public" + : `${quoteIdentifier(schemaName)}, public`; + await runInTransaction(ctx, [ + `SET LOCAL search_path TO ${searchPath}`, + // One simple-protocol query: the file may hold several statements. + fs.readFileSync(file, "utf-8"), + ]); + return { branch: branch.name, file }; +} diff --git a/packages/appkit/src/database/tooling/targets.ts b/packages/appkit/src/database/tooling/targets.ts index ff1cef3e8..b359e9364 100644 --- a/packages/appkit/src/database/tooling/targets.ts +++ b/packages/appkit/src/database/tooling/targets.ts @@ -5,6 +5,8 @@ import yaml from "js-yaml"; import { isValidSessionId } from "./naming"; +export type BranchMode = "dev" | "session"; + /** Full resource names, e.g. branch = "projects/p/branches/b". */ interface ResourceName { readonly project: string; @@ -39,9 +41,26 @@ export function resourceId(name: string): string { return name.slice(name.lastIndexOf("/") + 1); } +const PROJECT_ID = /^[a-z0-9][a-z0-9-]{0,62}$/; + +/** + * LAKEBASE_PROJECT as a project resource name: accepts "projects/" or a bare + * ""; null for anything else (e.g. the .env.example placeholder). + */ +export function parseProject(value: string | undefined): string | null { + const trimmed = value?.trim() ?? ""; + const id = trimmed.startsWith("projects/") + ? trimmed.slice("projects/".length) + : trimmed; + return PROJECT_ID.test(id) ? `projects/${id}` : null; +} + /** What `.env` says about the local branch. Unparseable values (template placeholders) read as null. */ -interface LocalTarget { +export interface LocalTarget { + /** LAKEBASE_PROJECT, normalized to "projects/". */ + readonly project: string | null; readonly endpoint: string | null; + readonly database: string | null; readonly session: string | null; } @@ -51,7 +70,19 @@ export function readLocalTarget(env: NodeJS.ProcessEnv): LocalTarget { env.APPKIT_DB_SESSION && isValidSessionId(env.APPKIT_DB_SESSION) ? env.APPKIT_DB_SESSION : null; - return { endpoint, session }; + return { + project: parseProject(env.LAKEBASE_PROJECT), + endpoint, + // Without a real endpoint PGDATABASE is a template placeholder too. + database: endpoint ? env.PGDATABASE || null : null, + session, + }; +} + +/** The branch the bundle's default target deploys to. */ +export interface DeployTarget { + readonly branch: string; + readonly database: string | null; } type Yaml = Record | undefined; @@ -92,6 +123,28 @@ function variableOf( return typeof value === "string" && value ? value : null; } +/** Default target's postgres_branch / postgres_database variables; null without databricks.yml or binding. */ +export function readDeployTarget(root: string): DeployTarget | null { + const bundle = readBundle(root); + if (!bundle) return null; + const targets = record(bundle.targets) ?? {}; + const target = + Object.values(targets) + .map(record) + .find((candidate) => candidate?.default === true) ?? + record(targets.default); + + const branch = parseResourceName( + variableOf(bundle, target, "postgres_branch") ?? undefined, + )?.branch; + if (!branch) return null; + const database = + parseResourceName( + variableOf(bundle, target, "postgres_database") ?? undefined, + )?.database ?? null; + return { branch, database }; +} + /** * Every branch databricks.yml can deploy to: each target's postgres_branch and * the variable's default. Empty without databricks.yml or a postgres binding. diff --git a/packages/appkit/src/database/tooling/tests/clean-seed.test.ts b/packages/appkit/src/database/tooling/tests/clean-seed.test.ts new file mode 100644 index 000000000..0a92134ba --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/clean-seed.test.ts @@ -0,0 +1,187 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { assertAppkitBranch, openBranchPool } = vi.hoisted(() => ({ + assertAppkitBranch: vi.fn(), + openBranchPool: vi.fn(), +})); + +vi.mock("../guard", async (importOriginal) => ({ + ...(await importOriginal()), + assertAppkitBranch, +})); +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); + +import { runClean } from "../clean"; +import { BranchGuardError } from "../guard"; +import { runSeed, SEED_FILE } from "../seed"; +import { cleanupTempApps, fakeContext, tempApp } from "./fakes"; + +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); +const schemaFile = (schemaName: string) => + `import { defineSchema, id } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ notes: table("notes", { id: id() }) }), { schemaName: ${JSON.stringify(schemaName)} });`; + +/** A pool whose one client records statements and can fail on one of them. */ +function recordingPool(failOn?: RegExp) { + const statements: string[] = []; + const client = { + released: undefined as { error: unknown } | undefined, + async query(text: string) { + statements.push(text); + if (failOn?.test(text)) throw new Error("boom"); + return { rows: [] }; + }, + release(error?: unknown) { + client.released = { error }; + }, + }; + const pool = { + connect: vi.fn(async () => client), + end: vi.fn(async () => undefined), + }; + return { pool, client, statements }; +} + +const devBranch = { name: "projects/p/branches/dev-alice-3f9a2c" }; + +beforeEach(() => { + assertAppkitBranch.mockReset(); + openBranchPool.mockReset(); +}); +afterEach(cleanupTempApps); + +describe("runClean", () => { + test("drops the app schema in one transaction on an AppKit branch", async () => { + const ctx = fakeContext({ + root: tempApp({ "config/database/schema.ts": schemaFile("app") }), + }); + assertAppkitBranch.mockResolvedValue(devBranch); + const { pool, client, statements } = recordingPool(); + openBranchPool.mockResolvedValue(pool); + + await expect(runClean(ctx)).resolves.toEqual({ + branch: devBranch.name, + schemaName: "app", + }); + expect(assertAppkitBranch).toHaveBeenCalledWith(ctx, "clean"); + expect(statements).toEqual([ + "BEGIN", + `DROP SCHEMA IF EXISTS "app" CASCADE`, + "COMMIT", + ]); + expect(client.released).toEqual({ error: undefined }); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + test("without schema.ts it points to introspect before checking the branch", async () => { + const ctx = fakeContext({ root: tempApp() }); + + await expect(runClean(ctx)).rejects.toThrow( + "No config/database/schema.ts yet, so clean cannot tell which schema to drop. Run `appkit db introspect` to write it from the branch's tables, or create it.", + ); + expect(assertAppkitBranch).not.toHaveBeenCalled(); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("refuses a non-AppKit branch before connecting", async () => { + const ctx = fakeContext({ + root: tempApp({ "config/database/schema.ts": schemaFile("app") }), + }); + assertAppkitBranch.mockRejectedValue( + new BranchGuardError( + "clean", + "projects/p/branches/production", + "it is the project's default branch", + ), + ); + + await expect(runClean(ctx)).rejects.toThrow(/Refusing to clean production/); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("a failing statement rolls back and discards the connection", async () => { + const ctx = fakeContext({ + root: tempApp({ "config/database/schema.ts": schemaFile("app") }), + }); + assertAppkitBranch.mockResolvedValue(devBranch); + const { pool, client, statements } = recordingPool(/DROP SCHEMA/); + openBranchPool.mockResolvedValue(pool); + + await expect(runClean(ctx)).rejects.toThrow("boom"); + expect(statements.slice(-1)).toEqual(["ROLLBACK"]); + expect(client.released?.error).toBeInstanceOf(Error); + expect(pool.end).toHaveBeenCalledOnce(); + }); +}); + +describe("runSeed", () => { + test("runs config/database/seed.sql in one transaction on an AppKit branch, app schema first", async () => { + const sql = + "insert into app.notes default values;\ninsert into app.notes default values;\n"; + const root = tempApp({ [SEED_FILE]: sql }); + assertAppkitBranch.mockResolvedValue(devBranch); + const { pool, statements } = recordingPool(); + openBranchPool.mockResolvedValue(pool); + + await expect(runSeed(fakeContext({ root }))).resolves.toEqual({ + branch: devBranch.name, + file: path.join(root, SEED_FILE), + }); + expect(assertAppkitBranch).toHaveBeenCalledWith(expect.anything(), "seed"); + expect(statements).toEqual([ + "BEGIN", + 'SET LOCAL search_path TO "app", public', + sql, + "COMMIT", + ]); + }); + + test.each([ + ["sales", 'SET LOCAL search_path TO "sales", public'], + ["public", "SET LOCAL search_path TO public"], + ])( + "with schema.ts on %s, unqualified names resolve there", + async (schemaName, searchPath) => { + const root = tempApp({ + [SEED_FILE]: "insert into notes default values;", + "config/database/schema.ts": schemaFile(schemaName), + }); + assertAppkitBranch.mockResolvedValue(devBranch); + const { pool, statements } = recordingPool(); + openBranchPool.mockResolvedValue(pool); + + await runSeed(fakeContext({ root })); + + expect(statements.slice(0, 2)).toEqual(["BEGIN", searchPath]); + }, + ); + + test("a missing seed file is an error, before any API call", async () => { + await expect(runSeed(fakeContext({ root: tempApp() }))).rejects.toThrow( + "No seed file at config/database/seed.sql", + ); + expect(assertAppkitBranch).not.toHaveBeenCalled(); + }); + + test("refuses a non-AppKit branch before connecting", async () => { + const root = tempApp({ [SEED_FILE]: "select 1;" }); + assertAppkitBranch.mockRejectedValue( + new BranchGuardError("seed", "projects/p/branches/staging", "nope"), + ); + + await expect(runSeed(fakeContext({ root }))).rejects.toThrow( + /Refusing to seed staging/, + ); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(root, SEED_FILE))).toBe(true); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/env-file.test.ts b/packages/appkit/src/database/tooling/tests/env-file.test.ts new file mode 100644 index 000000000..2429c37a1 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/env-file.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import path from "node:path"; + +import dotenv from "dotenv"; +import { afterEach, describe, expect, test } from "vitest"; + +import { updateEnvFile } from "../env-file"; +import { cleanupTempApps, tempApp } from "./fakes"; + +afterEach(cleanupTempApps); + +describe("updateEnvFile", () => { + test("replaces values in place, keeps comments and order, appends new keys", () => { + const root = tempApp({ + ".env": [ + "# Databricks", + "DATABRICKS_CONFIG_PROFILE=DEFAULT", + "PGHOST='old-host'", + "", + "# keep me", + "LAKEBASE_ENDPOINT=projects/p/branches/old/endpoints/primary", + "APPKIT_DB_SESSION=abc", + "PGHOST=duplicate", + ].join("\n"), + }); + const file = path.join(root, ".env"); + + updateEnvFile(file, { + PGHOST: "new-host.example.com", + LAKEBASE_ENDPOINT: "projects/p/branches/dev-a/endpoints/primary", + OTHER_KEY: "value", + }); + + expect(fs.readFileSync(file, "utf-8")).toBe( + [ + "# Databricks", + "DATABRICKS_CONFIG_PROFILE=DEFAULT", + "PGHOST=new-host.example.com", + "", + "# keep me", + "LAKEBASE_ENDPOINT=projects/p/branches/dev-a/endpoints/primary", + "APPKIT_DB_SESSION=abc", + "OTHER_KEY=value", + "", + ].join("\n"), + ); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + + test("a missing .env starts from .env.example", () => { + const root = tempApp({ + ".env.example": "PGHOST=your_postgres_host\nPGPORT=5432\n", + }); + const file = path.join(root, ".env"); + + updateEnvFile( + file, + { PGHOST: "h.example.com" }, + path.join(root, ".env.example"), + ); + + expect(fs.readFileSync(file, "utf-8")).toBe( + "PGHOST=h.example.com\nPGPORT=5432\n", + ); + expect(fs.readFileSync(path.join(root, ".env.example"), "utf-8")).toContain( + "your_postgres_host", + ); + }); + + test("quotes values that need it", () => { + const root = tempApp(); + const file = path.join(root, ".env"); + updateEnvFile(file, { A: "has space", B: "plain@example.com" }); + expect(fs.readFileSync(file, "utf-8")).toBe( + 'A="has space"\nB=plain@example.com\n', + ); + }); + + test("keeps export prefixes and CRLF line endings", () => { + const root = tempApp({ + ".env": "# keep\r\nexport PGHOST=old\r\nPGPORT=5432\r\n", + }); + const file = path.join(root, ".env"); + + updateEnvFile(file, { PGHOST: "new.example.com", PGUSER: "a@example.com" }); + + expect(fs.readFileSync(file, "utf-8")).toBe( + "# keep\r\nexport PGHOST=new.example.com\r\nPGPORT=5432\r\nPGUSER=a@example.com\r\n", + ); + }); + + test("every value stays on one line and reads back unchanged", () => { + const root = tempApp({ ".env": "KEEP=1\n" }); + const file = path.join(root, ".env"); + const values = { + NEWLINE: "line1\nline2\r\nINJECTED=1", + QUOTES: 'say "hi"', + BACKSLASH: "back\\slash", + }; + + updateEnvFile(file, values); + + const text = fs.readFileSync(file, "utf-8"); + expect(text.split("\n").filter(Boolean)).toHaveLength(4); + expect(dotenv.parse(text)).toEqual({ KEEP: "1", ...values }); + }); + + test("a value dotenv cannot read back unchanged is refused", () => { + const root = tempApp(); + const file = path.join(root, ".env"); + expect(() => updateEnvFile(file, { BAD: `it's "x"\ny` })).toThrow( + "Cannot write BAD to .env: its value mixes quotes, backslashes and line breaks.", + ); + expect(fs.existsSync(file)).toBe(false); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/fakes.ts b/packages/appkit/src/database/tooling/tests/fakes.ts index c10e139af..5df053965 100644 --- a/packages/appkit/src/database/tooling/tests/fakes.ts +++ b/packages/appkit/src/database/tooling/tests/fakes.ts @@ -4,16 +4,26 @@ import path from "node:path"; import type { DbContext } from "../context"; import type { + BranchSpec, LakebaseApi, LakebaseBranch, + LakebaseDatabase, LakebaseEndpoint, + LakebaseRole, + RoleSpec, } from "../lakebase-api"; +import { LakebaseApiError } from "../lakebase-api"; -/** In-memory Lakebase: the branches the guard looks up, and their endpoints. */ +/** In-memory Lakebase with the semantics init relies on: forks copy roles, endpoints come with new forks. */ export class FakeLakebase { readonly branches = new Map(); readonly endpoints = new Map(); + readonly roles = new Map(); + readonly databases = new Map(); readonly calls: string[] = []; + /** New forks get a read-write `primary` endpoint, like Lakebase forks do. */ + autoEndpoint = true; + createBranchError?: Error; addBranch( name: string, @@ -22,13 +32,29 @@ export class FakeLakebase { const branch = { name, status: { current_state: "READY", ...status } }; this.branches.set(name, branch); this.endpoints.set(name, [this.endpoint(name, "primary")]); + this.roles.set(name, []); return branch; } + addRole( + branch: string, + roleId: string, + postgresRole: string, + memberships: string[] = [], + ) { + const role: LakebaseRole = { + name: `${branch}/roles/${roleId}`, + status: { postgres_role: postgresRole, membership_roles: memberships }, + }; + this.roles.get(branch)?.push(role); + return role; + } + endpoint(branch: string, id: string): LakebaseEndpoint { return { name: `${branch}/endpoints/${id}`, status: { + endpoint_type: "ENDPOINT_TYPE_READ_WRITE", hosts: { host: `${id}.${branch.split("/").pop()}.example.com` }, }, }; @@ -50,11 +76,88 @@ export class FakeLakebase { ?.find((endpoint) => endpoint.name === name) ?? null ); }, + async listBranches(project: string) { + fake.calls.push(`listBranches ${project}`); + return [...fake.branches.values()].filter((b) => + b.name.startsWith(`${project}/`), + ); + }, + async createBranch(project: string, branchId: string, spec: BranchSpec) { + fake.calls.push(`createBranch ${branchId} ${JSON.stringify(spec)}`); + if (fake.createBranchError) throw fake.createBranchError; + const name = `${project}/branches/${branchId}`; + const branch = { + name, + status: { current_state: "READY", source_branch: spec.source_branch }, + }; + fake.branches.set(name, branch); + fake.endpoints.set( + name, + fake.autoEndpoint ? [fake.endpoint(name, "primary")] : [], + ); + // A fork copies the parent's roles. + fake.roles.set( + name, + (fake.roles.get(spec.source_branch) ?? []).map((role) => ({ + ...role, + name: `${name}/roles/${role.name.split("/").pop()}`, + })), + ); + return branch; + }, + async listEndpoints(branch: string) { + fake.calls.push(`listEndpoints ${branch}`); + return fake.endpoints.get(branch) ?? []; + }, + async createEndpoint(branch: string, endpointId: string) { + fake.calls.push(`createEndpoint ${branch} ${endpointId}`); + const endpoint = fake.endpoint(branch, endpointId); + fake.endpoints.set(branch, [ + ...(fake.endpoints.get(branch) ?? []), + endpoint, + ]); + return endpoint; + }, + async getDatabase(name: string) { + fake.calls.push(`getDatabase ${name}`); + return fake.databases.get(name) ?? null; + }, + async listRoles(branch: string) { + fake.calls.push(`listRoles ${branch}`); + return [...(fake.roles.get(branch) ?? [])]; + }, + async createRole(branch: string, spec: RoleSpec) { + fake.calls.push(`createRole ${branch} ${JSON.stringify(spec)}`); + return fake.addRole(branch, "rol-generated", spec.postgres_role, [ + ...spec.membership_roles, + ]); + }, + async setRoleMemberships(role: string, memberships: string[]) { + fake.calls.push(`setRoleMemberships ${role} ${memberships.join(",")}`); + const branch = role.slice(0, role.indexOf("/roles/")); + const found = fake.roles.get(branch)?.find((r) => r.name === role); + if (!found?.status) throw new Error(`no role ${role}`); + found.status.membership_roles = [...memberships]; + return found; + }, + async deleteRole(role: string, options: { reassignOwnedTo: string }) { + fake.calls.push(`deleteRole ${role} → ${options.reassignOwnedTo}`); + const branch = role.slice(0, role.indexOf("/roles/")); + fake.roles.set( + branch, + (fake.roles.get(branch) ?? []).filter((r) => r.name !== role), + ); + }, }; return api as unknown as LakebaseApi; } } +export const forbidden = () => + new LakebaseApiError("create branch", "PERMISSION_DENIED", { + statusCode: 403, + }); + const roots: string[] = []; /** A temp app root with optional files (relative path → contents). */ diff --git a/packages/appkit/src/database/tooling/tests/guard.test.ts b/packages/appkit/src/database/tooling/tests/guard.test.ts index 175f9823c..23899dc10 100644 --- a/packages/appkit/src/database/tooling/tests/guard.test.ts +++ b/packages/appkit/src/database/tooling/tests/guard.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, test } from "vitest"; -import { checkAppkitBranch, classifyBranch } from "../guard"; +import { + assertAppkitBranch, + BranchGuardError, + checkAppkitBranch, + classifyBranch, +} from "../guard"; import { devBranchId } from "../naming"; import { cleanupTempApps, @@ -165,3 +170,75 @@ describe("checkAppkitBranch", () => { ).rejects.toThrow(/does not exist.*appkit db init/); }); }); + +describe("assertAppkitBranch", () => { + const envFor = (branch: string) => ({ + LAKEBASE_ENDPOINT: `${branch}/endpoints/primary`, + }); + + test("passes the .env branch when it is the developer's dev branch", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const ctx = fakeContext({ + root: tempApp(), + env: envFor(dev), + api: lakebase.api(), + }); + + await expect(assertAppkitBranch(ctx, "clean")).resolves.toMatchObject({ + name: dev, + }); + }); + + test("refuses the default branch and the deploy branch, using only the API", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(`${P}/branches/production`, { default: true }); + lakebase.addBranch(dev); + + const onDefault = fakeContext({ + root: tempApp(), + env: envFor(`${P}/branches/production`), + api: lakebase.api(), + }); + const error = await assertAppkitBranch(onDefault, "clean").catch( + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(BranchGuardError); + expect((error as Error).message).toBe( + "Refusing to clean production: it is the project's default branch. `appkit db clean` only writes to branches `appkit db init` created for you (dev-… or session-…).", + ); + + const deployRoot = tempApp({ + "databricks.yml": databricksYml( + dev, + `${dev}/databases/databricks-postgres`, + ), + }); + const onDeploy = fakeContext({ + root: deployRoot, + env: envFor(dev), + api: lakebase.api(), + }); + await expect(assertAppkitBranch(onDeploy, "seed")).rejects.toThrow( + /deploys to/, + ); + expect(lakebase.calls.every((call) => call.startsWith("getBranch"))).toBe( + true, + ); + }); + + test("without a configured branch it refuses, and a deleted branch points at init", async () => { + const lakebase = new FakeLakebase(); + await expect( + assertAppkitBranch( + fakeContext({ root: tempApp(), api: lakebase.api() }), + "seed", + ), + ).rejects.toThrow(/no branch configured/); + await expect( + checkAppkitBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ), + ).rejects.toThrow(/does not exist.*appkit db init/); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/identity.test.ts b/packages/appkit/src/database/tooling/tests/identity.test.ts index d600a844b..49bf76fd6 100644 --- a/packages/appkit/src/database/tooling/tests/identity.test.ts +++ b/packages/appkit/src/database/tooling/tests/identity.test.ts @@ -1,11 +1,18 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { currentIdentity } from "../identity"; +import { currentIdentity, identityTypeOf } from "../identity"; import { cleanupTempApps, fakeContext, tempApp } from "./fakes"; afterEach(cleanupTempApps); describe("identity", () => { + test("an email is a USER, a client ID is a SERVICE_PRINCIPAL", () => { + expect(identityTypeOf("alice@example.com")).toBe("USER"); + expect(identityTypeOf("11111111-2222-3333-4444-555555555555")).toBe( + "SERVICE_PRINCIPAL", + ); + }); + test("currentUser.me() is called once per context", async () => { const ctx = fakeContext({ root: tempApp() }); const me = vi.fn(async () => ({ userName: "alice@example.com" })); @@ -14,6 +21,7 @@ describe("identity", () => { await expect(currentIdentity(counted)).resolves.toEqual({ userName: "alice@example.com", + identityType: "USER", }); await currentIdentity(counted); expect(me).toHaveBeenCalledOnce(); diff --git a/packages/appkit/src/database/tooling/tests/init.test.ts b/packages/appkit/src/database/tooling/tests/init.test.ts new file mode 100644 index 000000000..710a636ca --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/init.test.ts @@ -0,0 +1,656 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { openBranchPool, runClean, runIntrospect, runMigrate } = vi.hoisted( + () => ({ + openBranchPool: vi.fn(), + runClean: vi.fn(), + runIntrospect: vi.fn(), + runMigrate: vi.fn(), + }), +); + +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); +vi.mock("../migrate", async (importOriginal) => ({ + ...(await importOriginal()), + runMigrate, +})); +vi.mock("../clean", () => ({ runClean })); +vi.mock("../introspect", () => ({ runIntrospect })); + +import { + currentBranchMode, + defaultSchemaStart, + resolveInitDefaults, + runInit, + schemaStartsFor, +} from "../init"; +import { LakebaseApiError } from "../lakebase-api"; +import { devBranchId } from "../naming"; +import { + cleanupTempApps, + databricksYml, + FakeLakebase, + fakeContext, + forbidden, + tempApp, +} from "./fakes"; + +const USER = "alice@example.com"; +const SP = "11111111-2222-3333-4444-555555555555"; +const P = "projects/p"; +const PRODUCTION = `${P}/branches/production`; +const DEV = `${P}/branches/${devBranchId(USER)}`; +const migrateReport = { + generated: null, + applied: [], + typesFile: "/app/shared/appkit-types/database.d.ts", +}; + +/** pg_catalog owners the hand-off sees on the new branch. */ +let owners: string[]; + +function project(): FakeLakebase { + const lakebase = new FakeLakebase(); + lakebase.addBranch(PRODUCTION, { default: true }); + lakebase.addRole(PRODUCTION, "alice", USER, ["DATABRICKS_SUPERUSER"]); + lakebase.addRole(PRODUCTION, `dbrx-apps-${SP}`, SP); + lakebase.addRole(PRODUCTION, "dbrx-apps-user-bob", "bob@example.com"); + return lakebase; +} + +/** An app that deploys to `deploy` (the default branch unless set), with .env pointing at the default branch. */ +function appOn( + lakebase: FakeLakebase, + env: Record = {}, + { + schema = true, + deploy = PRODUCTION, + }: { schema?: boolean; deploy?: string } = {}, +) { + const dotenv = { + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + PGHOST: "primary.production.example.com", + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: `${PRODUCTION}/endpoints/primary`, + ...env, + }; + const root = tempApp({ + ".env": `# Lakebase\n${Object.entries(dotenv) + .map(([key, value]) => `${key}=${value}`) + .join("\n")}\n`, + "databricks.yml": databricksYml( + deploy, + `${deploy}/databases/databricks-postgres`, + ), + // migrate and clean (mocked here) need the file to exist. + ...(schema + ? { "config/database/schema.ts": "export const schema = {};\n" } + : {}), + }); + return fakeContext({ + root, + env: dotenv, + userName: USER, + api: lakebase.api(), + }); +} + +function envFile(root: string): Record { + return Object.fromEntries( + fs + .readFileSync(path.join(root, ".env"), "utf-8") + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => [ + line.slice(0, line.indexOf("=")), + line.slice(line.indexOf("=") + 1), + ]), + ); +} + +const pool = { + query: vi.fn(async () => ({ rows: owners.map((owner) => ({ owner })) })), + end: vi.fn(async () => undefined), +}; + +beforeEach(() => { + owners = [SP, "pg_database_owner", "databricks_control_plane"]; + openBranchPool.mockReset().mockResolvedValue(pool); + runMigrate.mockReset().mockResolvedValue(migrateReport); + runClean.mockReset().mockResolvedValue({ branch: DEV, schemaName: "app" }); + runIntrospect.mockReset().mockResolvedValue({ branch: DEV, tables: [] }); + pool.query.mockClear(); + pool.end.mockClear(); +}); +afterEach(cleanupTempApps); + +describe("runInit — dev branch", () => { + test("forks the default branch with no expiry, hands the SP's objects to the developer, rewrites .env, migrates", async () => { + const lakebase = project(); + const ctx = appOn(lakebase); + + const report = await runInit(ctx, { mode: "dev", schemaStart: "migrate" }); + + expect(lakebase.calls).toContain( + `createBranch ${devBranchId(USER)} ${JSON.stringify({ source_branch: PRODUCTION, no_expiry: true })}`, + ); + // Only the role-API role that owns objects is reassigned; system owners are ignored. + expect( + lakebase.calls.filter((call) => call.startsWith("deleteRole")), + ).toEqual([`deleteRole ${DEV}/roles/dbrx-apps-${SP} → ${DEV}/roles/alice`]); + expect(report).toMatchObject({ + mode: "dev", + branch: DEV, + created: true, + sourceBranch: PRODUCTION, + endpoint: `${DEV}/endpoints/primary`, + endpointCreated: false, + host: `primary.${devBranchId(USER)}.example.com`, + database: "databricks_postgres", + user: USER, + role: { name: `${DEV}/roles/alice`, action: "kept" }, + handedOff: [SP], + cleaned: false, + schema: migrateReport, + }); + + const written = envFile(ctx.root); + expect(written).toMatchObject({ + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + LAKEBASE_PROJECT: P, + PGHOST: `primary.${devBranchId(USER)}.example.com`, + LAKEBASE_ENDPOINT: `${DEV}/endpoints/primary`, + PGDATABASE: "databricks_postgres", + PGUSER: USER, + PGPORT: "5432", + PGSSLMODE: "require", + }); + expect(written.APPKIT_DB_SESSION).toBeUndefined(); + expect(fs.readFileSync(path.join(ctx.root, ".env"), "utf-8")).toMatch( + /^# Lakebase\n/, + ); + // The running context now targets the new branch (the hand-off pool and migrate read it). + expect(ctx.env.LAKEBASE_ENDPOINT).toBe(`${DEV}/endpoints/primary`); + expect(runMigrate).toHaveBeenCalledWith(ctx, { interactive: false }); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + test("a re-run reuses the dev branch; with nothing owned by others there is no hand-off", async () => { + const lakebase = project(); + lakebase.addBranch(DEV); + lakebase.addRole(DEV, "alice", USER, ["DATABRICKS_SUPERUSER"]); + owners = ["pg_database_owner"]; + + const report = await runInit(appOn(lakebase), { + mode: "dev", + schemaStart: null, + }); + + expect(report.created).toBe(false); + expect(report.handedOff).toEqual([]); + expect( + lakebase.calls.some((call) => + /^(createBranch|deleteRole|createRole)/.test(call), + ), + ).toBe(false); + expect(runMigrate).not.toHaveBeenCalled(); + }); + + test("creates a read-write endpoint when the branch has none", async () => { + const lakebase = project(); + lakebase.autoEndpoint = false; + + const report = await runInit(appOn(lakebase), { + mode: "dev", + schemaStart: null, + }); + + expect(lakebase.calls).toContain(`createEndpoint ${DEV} primary`); + expect(report).toMatchObject({ + endpoint: `${DEV}/endpoints/primary`, + endpointCreated: true, + }); + }); + + test("creates the developer role without a role_id when missing, and patches a role without the superuser membership", async () => { + const lakebase = project(); + lakebase.addBranch(DEV); + owners = []; + const created = await runInit(appOn(lakebase), { + mode: "dev", + schemaStart: null, + }); + expect(lakebase.calls).toContain( + `createRole ${DEV} ${JSON.stringify({ + identity_type: "USER", + postgres_role: USER, + auth_method: "LAKEBASE_OAUTH_V1", + membership_roles: ["DATABRICKS_SUPERUSER"], + })}`, + ); + expect(created.role.action).toBe("created"); + + const patched = project(); + patched.addBranch(DEV); + patched.addRole(DEV, "alice", USER, []); + const report = await runInit(appOn(patched), { + mode: "dev", + schemaStart: null, + }); + expect(patched.calls).toContain( + `setRoleMemberships ${DEV}/roles/alice DATABRICKS_SUPERUSER`, + ); + expect(report.role.action).toBe("updated"); + }); + + test("a 403 on branch creation names the CAN MANAGE requirement", async () => { + const lakebase = project(); + lakebase.createBranchError = forbidden(); + + await expect( + runInit(appOn(lakebase), { mode: "dev", schemaStart: null }), + ).rejects.toThrow( + /needs CAN MANAGE on projects\/p\. Ask a project owner for it\.$/, + ); + }); + + test("when another run creates the dev branch first, the 409 reuses it", async () => { + const lakebase = project(); + const ctx = fakeContext({ + root: appOn(lakebase).root, + env: { + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + LAKEBASE_ENDPOINT: `${PRODUCTION}/endpoints/primary`, + }, + userName: USER, + api: Object.assign(lakebase.api(), { + async createBranch() { + lakebase.addBranch(DEV); + lakebase.addRole(DEV, "alice", USER, ["DATABRICKS_SUPERUSER"]); + throw new LakebaseApiError(`create branch ${DEV}`, "ALREADY_EXISTS", { + statusCode: 409, + }); + }, + }), + }); + + const report = await runInit(ctx, { mode: "dev", schemaStart: null }); + + expect(report).toMatchObject({ branch: DEV, created: false }); + expect(envFile(ctx.root).LAKEBASE_ENDPOINT).toBe( + `${DEV}/endpoints/primary`, + ); + }); +}); + +describe("runInit — session branch", () => { + test("generates a session id, forks with a 7-day TTL, and stores APPKIT_DB_SESSION", async () => { + const lakebase = project(); + const ctx = appOn(lakebase); + + const report = await runInit(ctx, { mode: "session", schemaStart: null }); + + const sessionId = envFile(ctx.root).APPKIT_DB_SESSION; + expect(sessionId).toMatch(/^[0-9a-z]{8}$/); + expect(report.branch).toBe(`${P}/branches/session-${sessionId}`); + expect(lakebase.calls).toContain( + `createBranch session-${sessionId} ${JSON.stringify({ source_branch: PRODUCTION, ttl: "604800s" })}`, + ); + expect(report.handedOff).toEqual([SP]); + }); + + test("the session id is in .env before the branch is created, so a failed run's rerun reuses it", async () => { + const lakebase = project(); + lakebase.createBranchError = new Error("operation timed out"); + const ctx = appOn(lakebase); + + await expect( + runInit(ctx, { mode: "session", schemaStart: null }), + ).rejects.toThrow("operation timed out"); + const sessionId = envFile(ctx.root).APPKIT_DB_SESSION; + expect(sessionId).toMatch(/^[0-9a-z]{8}$/); + + lakebase.createBranchError = undefined; + const rerun = await runInit( + fakeContext({ + root: ctx.root, + env: envFile(ctx.root), + userName: USER, + api: lakebase.api(), + }), + { mode: "session", schemaStart: null }, + ); + expect(rerun.branch).toBe(`${P}/branches/session-${sessionId}`); + expect( + lakebase.calls.filter((call) => call.startsWith("createBranch")), + ).toEqual([ + `createBranch session-${sessionId} ${JSON.stringify({ source_branch: PRODUCTION, ttl: "604800s" })}`, + `createBranch session-${sessionId} ${JSON.stringify({ source_branch: PRODUCTION, ttl: "604800s" })}`, + ]); + }); + + test("reuses this checkout's session branch, and replaces an expired one with a fresh session", async () => { + const lakebase = project(); + lakebase.addBranch(`${P}/branches/session-k2x9q0ab`); + lakebase.addRole(`${P}/branches/session-k2x9q0ab`, "alice", USER, [ + "DATABRICKS_SUPERUSER", + ]); + const reused = await runInit( + appOn(lakebase, { APPKIT_DB_SESSION: "k2x9q0ab" }), + { + mode: "session", + schemaStart: null, + }, + ); + expect(reused).toMatchObject({ + branch: `${P}/branches/session-k2x9q0ab`, + created: false, + }); + + const expired = project(); + expired.addBranch(`${P}/branches/session-k2x9q0ab`, { + expire_time: "2020-01-01T00:00:00Z", + }); + const ctx = appOn(expired, { APPKIT_DB_SESSION: "k2x9q0ab" }); + const fresh = await runInit(ctx, { mode: "session", schemaStart: null }); + expect(fresh.created).toBe(true); + expect(fresh.branch).not.toBe(`${P}/branches/session-k2x9q0ab`); + expect(envFile(ctx.root).APPKIT_DB_SESSION).not.toBe("k2x9q0ab"); + }); +}); + +describe("runInit — branches it never sets up", () => { + test.each([ + [ + "a dev branch databricks.yml deploys to", + {}, + DEV, + /it is a branch databricks\.yml deploys to/, + ], + [ + "a protected dev branch", + { is_protected: true }, + PRODUCTION, + /it is a protected branch/, + ], + ] as const)( + "%s: refused before any change", + async (_label, status, deploy, reason) => { + const lakebase = project(); + lakebase.addBranch(DEV, status); + // A role without the superuser membership and objects owned by the SP: nothing may change either. + lakebase.addRole(DEV, "alice", USER, []); + lakebase.addRole(DEV, `dbrx-apps-${SP}`, SP); + lakebase.endpoints.set(DEV, []); + const ctx = appOn(lakebase, {}, { deploy }); + const envBefore = fs.readFileSync(path.join(ctx.root, ".env"), "utf-8"); + + const failure = runInit(ctx, { mode: "dev", schemaStart: "clean" }); + + await expect(failure).rejects.toThrow(reason); + await expect(failure).rejects.toThrow( + new RegExp( + `^Refusing to set up ${devBranchId(USER)}: .*\\. \`appkit db init\` only sets up your dev branch or this checkout's session branch, never a branch the app deploys to\\.$`, + ), + ); + expect( + lakebase.calls.filter((call) => + /^(createBranch|createEndpoint|createRole|setRoleMemberships|deleteRole)/.test( + call, + ), + ), + ).toEqual([]); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(runClean).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(ctx.root, ".env"), "utf-8")).toBe( + envBefore, + ); + }, + ); +}); + +describe("runInit — introspect start", () => { + test("runs introspect on the branch init pointed .env at", async () => { + const lakebase = project(); + const ctx = appOn(lakebase); + + const report = await runInit( + ctx, + { mode: "dev", schemaStart: "introspect" }, + { interactive: true }, + ); + + expect(runIntrospect).toHaveBeenCalledWith(ctx, { interactive: true }); + expect(ctx.env.LAKEBASE_ENDPOINT).toBe(`${DEV}/endpoints/primary`); + expect(runMigrate).not.toHaveBeenCalled(); + expect(report).toMatchObject({ + introspect: { branch: DEV }, + schema: null, + }); + }); + + test("with schema.ts every start is offered and migrate is the default", () => { + expect(defaultSchemaStart(true)).toBe("migrate"); + expect(schemaStartsFor(true)).toEqual(["migrate", "introspect", "clean"]); + }); + + test("without schema.ts only introspect can run", () => { + expect(schemaStartsFor(false)).toEqual(["introspect"]); + expect(defaultSchemaStart(false)).toBe("introspect"); + }); + + test("resolveInitDefaults reports whether schema.ts exists", async () => { + const lakebase = project(); + expect((await resolveInitDefaults(appOn(lakebase))).hasSchema).toBe(true); + expect( + (await resolveInitDefaults(appOn(lakebase, {}, { schema: false }))) + .hasSchema, + ).toBe(false); + }); + + test("migrate or clean without schema.ts fails before any change", async () => { + for (const schemaStart of ["migrate", "clean"] as const) { + const lakebase = project(); + const ctx = appOn(lakebase, {}, { schema: false }); + const envBefore = fs.readFileSync(path.join(ctx.root, ".env"), "utf-8"); + + await expect( + runInit(ctx, { mode: "dev", schemaStart }, { interactive: false }), + ).rejects.toThrow( + /^No config\/database\/schema\.ts yet, so .*Run `appkit db introspect` to write it from the branch's tables, or create it\.$/, + ); + expect(lakebase.calls).toEqual([]); + expect(fs.readFileSync(path.join(ctx.root, ".env"), "utf-8")).toBe( + envBefore, + ); + } + }); +}); + +describe("currentBranchMode — the mode follows from the .env branch", () => { + const endpointOf = (branch: string) => `${branch}/endpoints/primary`; + + test.each([ + ["your dev branch", { LAKEBASE_ENDPOINT: endpointOf(DEV) }, "dev"], + [ + "this checkout's session branch", + { + LAKEBASE_ENDPOINT: endpointOf(`${P}/branches/session-k2x9q0ab`), + APPKIT_DB_SESSION: "k2x9q0ab", + }, + "session", + ], + [ + "someone else's session branch", + { + LAKEBASE_ENDPOINT: endpointOf(`${P}/branches/session-zzzz0000`), + APPKIT_DB_SESSION: "k2x9q0ab", + }, + null, + ], + ["the default branch", { LAKEBASE_ENDPOINT: endpointOf(PRODUCTION) }, null], + [ + "any other branch", + { LAKEBASE_ENDPOINT: endpointOf(`${P}/branches/staging`) }, + null, + ], + ] as const)("%s → %s", async (_label, env, mode) => { + const lakebase = project(); + expect(await currentBranchMode(appOn(lakebase, env))).toBe(mode); + expect((await resolveInitDefaults(appOn(lakebase, env))).mode).toBe(mode); + }); + + test("null without an endpoint, or with one from another project than LAKEBASE_PROJECT", async () => { + const lakebase = project(); + expect( + await currentBranchMode( + appOn(lakebase, { LAKEBASE_ENDPOINT: "your_postgres_endpointPath" }), + ), + ).toBeNull(); + expect( + await currentBranchMode( + appOn(lakebase, { + LAKEBASE_PROJECT: "projects/q", + LAKEBASE_ENDPOINT: endpointOf(DEV), + }), + ), + ).toBeNull(); + }); +}); + +describe("runInit — clean start", () => { + test("on an AppKit branch: clean, then apply the committed migrations without generating", async () => { + const lakebase = project(); + const ctx = appOn(lakebase); + + const report = await runInit(ctx, { mode: "dev", schemaStart: "clean" }); + + expect(runClean).toHaveBeenCalledWith(ctx); + expect(runMigrate).toHaveBeenCalledWith(ctx, { + interactive: false, + generate: false, + }); + expect(runClean.mock.invocationCallOrder[0]).toBeLessThan( + runMigrate.mock.invocationCallOrder[0] ?? 0, + ); + expect(report).toMatchObject({ cleaned: true, schema: migrateReport }); + }); +}); + +describe("runInit — project and database from databricks.yml", () => { + test("a fresh app (.env from .env.example placeholders) resolves both from the bundle", async () => { + const lakebase = project(); + lakebase.databases.set(`${DEV}/databases/databricks-postgres`, { + name: `${DEV}/databases/databricks-postgres`, + status: { postgres_database: "databricks_postgres" }, + }); + const root = tempApp({ + ".env.example": + "PGHOST=your_postgres_host\nLAKEBASE_ENDPOINT=your_postgres_endpointPath\nPGPORT=5432\nPGSSLMODE=require\n", + "databricks.yml": databricksYml( + PRODUCTION, + `${PRODUCTION}/databases/databricks-postgres`, + ), + }); + const ctx = fakeContext({ root, userName: USER, api: lakebase.api() }); + + const report = await runInit(ctx, { mode: "dev", schemaStart: null }); + + expect(report.branch).toBe(DEV); + expect(lakebase.calls).toContain( + `getDatabase ${DEV}/databases/databricks-postgres`, + ); + expect(envFile(root)).toMatchObject({ + PGHOST: `primary.${devBranchId(USER)}.example.com`, + LAKEBASE_ENDPOINT: `${DEV}/endpoints/primary`, + PGDATABASE: "databricks_postgres", + PGPORT: "5432", + PGSSLMODE: "require", + }); + }); + + test("no project anywhere is a clear error", async () => { + const ctx = fakeContext({ + root: tempApp(), + userName: USER, + api: project().api(), + }); + await expect( + runInit(ctx, { mode: "dev", schemaStart: null }), + ).rejects.toThrow( + /No Lakebase project configured\. Set LAKEBASE_PROJECT=projects\/ in \.env/, + ); + }); +}); + +describe("runInit — LAKEBASE_PROJECT", () => { + test.each([P, "p"])( + "a .env with only the profile and LAKEBASE_PROJECT=%s is enough", + async (value) => { + const lakebase = project(); + const root = tempApp({ + ".env": `DATABRICKS_CONFIG_PROFILE=DEFAULT\nLAKEBASE_PROJECT=${value}\n`, + }); + const ctx = fakeContext({ + root, + env: { DATABRICKS_CONFIG_PROFILE: "DEFAULT", LAKEBASE_PROJECT: value }, + userName: USER, + api: lakebase.api(), + }); + + const report = await runInit(ctx, { mode: "dev", schemaStart: null }); + + expect(report).toMatchObject({ + branch: DEV, + created: true, + sourceBranch: PRODUCTION, + database: "databricks_postgres", + }); + expect(envFile(root)).toEqual({ + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + LAKEBASE_PROJECT: P, + PGHOST: `primary.${devBranchId(USER)}.example.com`, + LAKEBASE_ENDPOINT: `${DEV}/endpoints/primary`, + PGDATABASE: "databricks_postgres", + PGUSER: USER, + PGPORT: "5432", + PGSSLMODE: "require", + }); + }, + ); + + test("it wins over LAKEBASE_ENDPOINT and databricks.yml; their values from another project are ignored", async () => { + const lakebase = project(); + const otherProduction = "projects/q/branches/production"; + const otherDev = `projects/q/branches/${devBranchId(USER)}`; + lakebase.addBranch(otherProduction, { default: true }); + lakebase.addRole(otherProduction, "alice", USER, ["DATABRICKS_SUPERUSER"]); + // .env and the bundle still name project p, with a database q does not have. + const ctx = appOn(lakebase, { + LAKEBASE_PROJECT: "q", + PGDATABASE: "p_only_database", + }); + + const report = await runInit(ctx, { mode: "dev", schemaStart: null }); + + expect(report).toMatchObject({ + branch: otherDev, + sourceBranch: otherProduction, + database: "databricks_postgres", + }); + expect(lakebase.calls.some((call) => call.includes("projects/p/"))).toBe( + false, + ); + expect(envFile(ctx.root)).toMatchObject({ + LAKEBASE_PROJECT: "projects/q", + LAKEBASE_ENDPOINT: `${otherDev}/endpoints/primary`, + PGDATABASE: "databricks_postgres", + }); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts index 22adaa0f3..002d7fb6e 100644 --- a/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts +++ b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts @@ -32,6 +32,8 @@ function client(replies: Reply[]) { const apiError = (statusCode: number, message: string) => Object.assign(new Error(message), { statusCode, errorCode: "X" }); +const fast = { pollIntervalMs: 1 }; + describe("LakebaseApi", () => { test("getBranch: GET /api/2.0/postgres/{name}; 404 → null; other errors keep their status", async () => { const fake = client([ @@ -39,7 +41,7 @@ describe("LakebaseApi", () => { apiError(404, "NOT_FOUND"), apiError(403, "PERMISSION_DENIED"), ]); - const api = new LakebaseApi(fake); + const api = new LakebaseApi(fake, fast); await expect(api.getBranch("projects/p/branches/b")).resolves.toEqual({ name: "projects/p/branches/b", @@ -84,4 +86,190 @@ describe("LakebaseApi", () => { ["GET", "/api/2.0/postgres/projects/p/branches/b/endpoints/gone"], ]); }); + + test("lists follow next_page_token", async () => { + const fake = client([ + { branches: [{ name: "a" }], next_page_token: "t2" }, + { branches: [{ name: "b" }] }, + ]); + const branches = await new LakebaseApi(fake, fast).listBranches( + "projects/p", + ); + + expect(branches.map((b) => b.name)).toEqual(["a", "b"]); + expect(fake.requests.map((r) => [r.path, r.query])).toEqual([ + ["/api/2.0/postgres/projects/p/branches", undefined], + ["/api/2.0/postgres/projects/p/branches", { page_token: "t2" }], + ]); + }); + + test("createBranch: POST with branch_id and {spec}; a finished operation returns its response", async () => { + const fake = client([ + { + name: "projects/p/branches/dev-a/operations/1", + done: true, + response: { name: "projects/p/branches/dev-a" }, + }, + ]); + const branch = await new LakebaseApi(fake, fast).createBranch( + "projects/p", + "dev-a", + { + source_branch: "projects/p/branches/production", + no_expiry: true, + }, + ); + + expect(branch).toEqual({ name: "projects/p/branches/dev-a" }); + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches", + method: "POST", + query: { branch_id: "dev-a" }, + payload: { + spec: { + source_branch: "projects/p/branches/production", + no_expiry: true, + }, + }, + }); + expect(fake.requests[0]?.headers.get("Content-Type")).toBe( + "application/json", + ); + }); + + test("polls GET /api/2.0/postgres/{operation.name} until the operation is done", async () => { + const op = "projects/p/branches/b/operations/7"; + const fake = client([ + { name: op, done: false }, + { name: op, done: false }, + { + name: op, + done: true, + response: { name: "projects/p/branches/b/endpoints/primary" }, + }, + ]); + const endpoint = await new LakebaseApi(fake, fast).createEndpoint( + "projects/p/branches/b", + "primary", + ); + + expect(endpoint.name).toBe("projects/p/branches/b/endpoints/primary"); + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b/endpoints", + method: "POST", + query: { endpoint_id: "primary" }, + payload: { spec: { endpoint_type: "ENDPOINT_TYPE_READ_WRITE" } }, + }); + expect(fake.requests.slice(1).map((r) => [r.method, r.path])).toEqual([ + ["GET", `/api/2.0/postgres/${op}`], + ["GET", `/api/2.0/postgres/${op}`], + ]); + }); + + test("a failed operation becomes a LakebaseApiError with the operation's message", async () => { + const fake = client([ + { + name: "op", + done: true, + error: { + error_code: "RESOURCE_EXHAUSTED", + message: "branch limit reached", + }, + }, + ]); + await expect( + new LakebaseApi(fake, fast).createBranch("projects/p", "dev-a", { + source_branch: "projects/p/branches/production", + no_expiry: true, + }), + ).rejects.toThrow( + "Lakebase: create branch projects/p/branches/dev-a failed: branch limit reached", + ); + }); + + test("an operation that never finishes times out", async () => { + const fake = client( + Array.from({ length: 50 }, () => ({ name: "op", done: false })), + ); + await expect( + new LakebaseApi(fake, { pollIntervalMs: 1, timeoutMs: 5 }).createEndpoint( + "projects/p/branches/b", + "primary", + ), + ).rejects.toThrow(/timed out/); + }); + + test("createRole posts {spec} without a role_id", async () => { + const fake = client([ + { + name: "op", + done: true, + response: { name: "projects/p/branches/b/roles/r" }, + }, + ]); + await new LakebaseApi(fake, fast).createRole("projects/p/branches/b", { + identity_type: "USER", + postgres_role: "alice@example.com", + auth_method: "LAKEBASE_OAUTH_V1", + membership_roles: ["DATABRICKS_SUPERUSER"], + }); + + expect(fake.requests[0]).toEqual( + expect.objectContaining({ + path: "/api/2.0/postgres/projects/p/branches/b/roles", + method: "POST", + payload: { + spec: { + identity_type: "USER", + postgres_role: "alice@example.com", + auth_method: "LAKEBASE_OAUTH_V1", + membership_roles: ["DATABRICKS_SUPERUSER"], + }, + }, + }), + ); + expect(fake.requests[0]?.query).toBeUndefined(); + }); + + test("setRoleMemberships: PATCH with update_mask=spec.membership_roles", async () => { + const fake = client([ + { + name: "op", + done: true, + response: { name: "projects/p/branches/b/roles/r" }, + }, + ]); + await new LakebaseApi(fake, fast).setRoleMemberships( + "projects/p/branches/b/roles/r", + ["DATABRICKS_SUPERUSER"], + ); + + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b/roles/r", + method: "PATCH", + query: { update_mask: "spec.membership_roles" }, + payload: { spec: { membership_roles: ["DATABRICKS_SUPERUSER"] } }, + }); + }); + + test("deleteRole: DELETE with reassign_owned_to = the target role's resource name", async () => { + const fake = client([ + { name: "op", done: false }, + { name: "op", done: true }, + ]); + await new LakebaseApi(fake, fast).deleteRole( + "projects/p/branches/b/roles/sp", + { + reassignOwnedTo: "projects/p/branches/b/roles/alice", + }, + ); + + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b/roles/sp", + method: "DELETE", + query: { reassign_owned_to: "projects/p/branches/b/roles/alice" }, + }); + expect(fake.requests[0]?.payload).toBeUndefined(); + expect(fake.requests).toHaveLength(2); + }); }); diff --git a/packages/appkit/src/database/tooling/tests/migrate.test.ts b/packages/appkit/src/database/tooling/tests/migrate.test.ts index 93f0eb500..b8db45875 100644 --- a/packages/appkit/src/database/tooling/tests/migrate.test.ts +++ b/packages/appkit/src/database/tooling/tests/migrate.test.ts @@ -170,6 +170,35 @@ describe("runMigrate", () => { expect(fs.existsSync(report.typesFile)).toBe(true); }); + test("generate: false applies the committed migrations without writing new ones", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + fs.writeFileSync( + path.join(ctx.root, "config/database/schema.ts"), + fs + .readFileSync(path.join(ctx.root, "config/database/schema.ts"), "utf-8") + .replace("id: id()", "id: id(), body: text()"), + ); + + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + const report = await runMigrate(ctx, { + interactive: false, + generate: false, + }); + + expect(report.generated).toBeNull(); + expect(report.applied).toEqual(["0000_migration"]); + expect( + fs.readdirSync(path.join(ctx.root, "config/database/migrations")), + ).toEqual(["0000_migration.sql", "meta"]); + }); + test("reports a baseline adopted without running it", async () => { const ctx = app(`notes: table("notes", { id: id() })`); checkAppkitBranch.mockResolvedValue(null); diff --git a/packages/appkit/src/database/tooling/tests/naming.test.ts b/packages/appkit/src/database/tooling/tests/naming.test.ts index 423f33ad0..3b7c974e1 100644 --- a/packages/appkit/src/database/tooling/tests/naming.test.ts +++ b/packages/appkit/src/database/tooling/tests/naming.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; -import { devBranchId, isValidSessionId, sessionBranchId } from "../naming"; +import { + devBranchId, + isValidSessionId, + newSessionId, + SESSION_BRANCH_TTL, + sessionBranchId, +} from "../naming"; const BRANCH_ID = /^[a-z][a-z0-9-]{0,62}$/; @@ -32,8 +38,11 @@ describe("branch naming", () => { }); test("session branches are session-; ids are lowercase base36", () => { - expect(sessionBranchId("k2x9q0ab")).toBe("session-k2x9q0ab"); + const sessionId = newSessionId(); + expect(sessionId).toMatch(/^[0-9a-z]{8}$/); + expect(sessionBranchId(sessionId)).toBe(`session-${sessionId}`); expect(isValidSessionId("Bad-Id")).toBe(false); expect(() => sessionBranchId("Bad-Id")).toThrow(/APPKIT_DB_SESSION/); + expect(SESSION_BRANCH_TTL).toBe("604800s"); }); }); diff --git a/packages/appkit/src/database/tooling/tests/targets.test.ts b/packages/appkit/src/database/tooling/tests/targets.test.ts index ac159a7df..6be36190e 100644 --- a/packages/appkit/src/database/tooling/tests/targets.test.ts +++ b/packages/appkit/src/database/tooling/tests/targets.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, test } from "vitest"; import { + parseProject, parseResourceName, readDeployBranches, + readDeployTarget, readLocalTarget, } from "../targets"; import { cleanupTempApps, databricksYml, tempApp } from "./fakes"; @@ -35,16 +37,35 @@ describe("parseResourceName", () => { }); }); +describe("parseProject", () => { + test("accepts projects/ or a bare , normalized to the resource name", () => { + expect(parseProject("projects/my-app")).toBe("projects/my-app"); + expect(parseProject(" my-app ")).toBe("projects/my-app"); + }); + + test("placeholders, branch paths and junk are null", () => { + expect(parseProject("your_postgres_project")).toBeNull(); + expect(parseProject("projects/p/branches/b")).toBeNull(); + expect(parseProject("")).toBeNull(); + expect(parseProject(undefined)).toBeNull(); + }); +}); + describe("readLocalTarget", () => { - test("reads the .env endpoint and this checkout's session", () => { + test("reads a real .env target", () => { expect( readLocalTarget({ + LAKEBASE_PROJECT: "p", LAKEBASE_ENDPOINT: "projects/p/branches/b/endpoints/primary", PGHOST: "ep.example.com", + PGDATABASE: "databricks_postgres", + PGUSER: "alice@example.com", APPKIT_DB_SESSION: "k2x9q0ab", }), ).toEqual({ + project: "projects/p", endpoint: "projects/p/branches/b/endpoints/primary", + database: "databricks_postgres", session: "k2x9q0ab", }); }); @@ -52,10 +73,18 @@ describe("readLocalTarget", () => { test("placeholders from .env.example read as not configured", () => { expect( readLocalTarget({ + LAKEBASE_PROJECT: "your_postgres_project", LAKEBASE_ENDPOINT: "your_postgres_endpointPath", + PGHOST: "your_postgres_host", + PGDATABASE: "your_postgres_databaseName", APPKIT_DB_SESSION: "NOT VALID", }), - ).toEqual({ endpoint: null, session: null }); + ).toEqual({ + project: null, + endpoint: null, + database: null, + session: null, + }); }); }); @@ -109,3 +138,41 @@ targets: ); }); }); + +describe("readDeployTarget", () => { + test("reads the default target's postgres variables", () => { + const root = tempApp({ + "databricks.yml": databricksYml( + "projects/p/branches/production", + "projects/p/branches/production/databases/databricks-postgres", + ), + }); + expect(readDeployTarget(root)).toEqual({ + branch: "projects/p/branches/production", + database: "projects/p/branches/production/databases/databricks-postgres", + }); + }); + + test("falls back to top-level variable defaults", () => { + const root = tempApp({ + "databricks.yml": `variables: + postgres_branch: + default: projects/p/branches/main +targets: + dev: + default: true +`, + }); + expect(readDeployTarget(root)).toEqual({ + branch: "projects/p/branches/main", + database: null, + }); + }); + + test("null without databricks.yml or without a postgres binding", () => { + expect(readDeployTarget(tempApp())).toBeNull(); + expect( + readDeployTarget(tempApp({ "databricks.yml": "bundle:\n name: x\n" })), + ).toBeNull(); + }); +}); diff --git a/packages/shared/src/cli/commands/db/clean.ts b/packages/shared/src/cli/commands/db/clean.ts new file mode 100644 index 000000000..cee18b0f7 --- /dev/null +++ b/packages/shared/src/cli/commands/db/clean.ts @@ -0,0 +1,51 @@ +import { Command } from "commander"; + +import { fail, info, isInteractive, ok, shortName } from "./output"; + +function envBranchLabel(): string { + const endpoint = process.env.LAKEBASE_ENDPOINT ?? ""; + const match = /\/branches\/([^/]+)/.exec(endpoint); + return match?.[1] ? `branch ${match[1]}` : "the .env branch"; +} + +async function runDbClean(flags: { yes?: boolean }): Promise { + try { + if (!flags.yes) { + if (!isInteractive()) { + throw new Error( + "Refusing to clean without confirmation in a non-interactive shell. Pass --yes.", + ); + } + const { confirm, isCancel } = await import("@clack/prompts"); + const answer = await confirm({ + message: `Remove every object in the app schema of ${envBranchLabel()}, including the migration journal?`, + initialValue: false, + }); + if (isCancel(answer) || !answer) { + info("Cancelled"); + return; + } + } + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runClean(tooling.createDbContext()); + ok( + `Removed every object in schema ${report.schemaName} on ${shortName(report.branch)}`, + ); + info("`appkit db migrate` recreates it from the committed migrations"); + } catch (error) { + fail(error); + } +} + +export const dbCleanCommand = new Command("clean") + .description( + "Remove every object in the app schema, including the migration journal, on your dev or session branch", + ) + .option("-y, --yes", "skip the confirmation") + .addHelpText( + "after", + ` +Only branches \`appkit db init\` created for you (dev-… or session-…) can be cleaned; +the default, protected and deploy branches are always refused.`, + ) + .action(runDbClean); diff --git a/packages/shared/src/cli/commands/db/database-tooling.d.ts b/packages/shared/src/cli/commands/db/database-tooling.d.ts index 7e1a1ff7b..5ca77cf08 100644 --- a/packages/shared/src/cli/commands/db/database-tooling.d.ts +++ b/packages/shared/src/cli/commands/db/database-tooling.d.ts @@ -37,6 +37,32 @@ declare module "@databricks/appkit/database-tooling" { options: { interactive: boolean }, ): Promise; + export type BranchMode = "dev" | "session"; + export type SchemaStart = "migrate" | "introspect" | "clean"; + + export interface InitChoices { + readonly mode: BranchMode; + readonly schemaStart: SchemaStart | null; + } + + export interface InitReport { + readonly mode: BranchMode; + readonly branch: string; + readonly created: boolean; + readonly sourceBranch?: string; + readonly endpoint: string; + readonly endpointCreated: boolean; + readonly host: string; + readonly database: string; + readonly user: string; + readonly role: { readonly name: string; readonly action: "kept" | "created" | "updated" }; + readonly handedOff: readonly string[]; + readonly envFile: string; + readonly cleaned: boolean; + readonly schema: MigrateReport | null; + readonly introspect: IntrospectReport | null; + } + export interface Skip { readonly table: string; readonly column?: string; @@ -62,4 +88,20 @@ declare module "@databricks/appkit/database-tooling" { ctx: DbContext, options: { interactive: boolean; schemaName?: string }, ): Promise; + + export function resolveInitDefaults(ctx: DbContext): Promise<{ + mode: BranchMode | null; + hasSchema: boolean; + }>; + export function schemaStartsFor(hasSchema: boolean): readonly SchemaStart[]; + export function defaultSchemaStart(hasSchema: boolean): SchemaStart; + export function runInit( + ctx: DbContext, + choices: InitChoices, + options?: { interactive: boolean }, + ): Promise; + + export function runClean(ctx: DbContext): Promise<{ branch: string; schemaName: string }>; + + export function runSeed(ctx: DbContext): Promise<{ branch: string; file: string }>; } diff --git a/packages/shared/src/cli/commands/db/index.ts b/packages/shared/src/cli/commands/db/index.ts index 35ff2f721..ed811c91c 100644 --- a/packages/shared/src/cli/commands/db/index.ts +++ b/packages/shared/src/cli/commands/db/index.ts @@ -1,7 +1,10 @@ import { Command } from "commander"; +import { dbCleanCommand } from "./clean"; +import { dbInitCommand } from "./init"; import { dbIntrospectCommand } from "./introspect"; import { dbMigrateCommand } from "./migrate"; +import { dbSeedCommand } from "./seed"; /** * Lakebase database lifecycle for the DatabasePlugin. The logic lives in @@ -9,5 +12,8 @@ import { dbMigrateCommand } from "./migrate"; */ export const dbCommand = new Command("db") .description("Lakebase database lifecycle") + .addCommand(dbInitCommand) .addCommand(dbMigrateCommand) - .addCommand(dbIntrospectCommand); + .addCommand(dbIntrospectCommand) + .addCommand(dbCleanCommand) + .addCommand(dbSeedCommand); diff --git a/packages/shared/src/cli/commands/db/init-clean-seed.test.ts b/packages/shared/src/cli/commands/db/init-clean-seed.test.ts new file mode 100644 index 000000000..b2be58282 --- /dev/null +++ b/packages/shared/src/cli/commands/db/init-clean-seed.test.ts @@ -0,0 +1,409 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const tooling = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + resolveInitDefaults: vi.fn(), + schemaStartsFor: vi.fn((hasSchema: boolean) => + hasSchema ? ["migrate", "introspect", "clean"] : ["introspect"], + ), + defaultSchemaStart: vi.fn((hasSchema: boolean) => + hasSchema ? "migrate" : "introspect", + ), + runInit: vi.fn(), + runClean: vi.fn(), + runSeed: vi.fn(), +})); +const prompts = vi.hoisted(() => ({ + select: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn((value: unknown) => typeof value === "symbol"), +})); + +vi.mock("@databricks/appkit/database-tooling", () => tooling); +vi.mock("@clack/prompts", () => prompts); + +import { dbCommand } from "./index"; + +let log: MockInstance; +let error: MockInstance; +const output = (spy: MockInstance) => + spy.mock.calls.map((call) => call.join(" ")).join("\n"); + +function setTTY(value: boolean | undefined) { + Object.defineProperty(process.stdin, "isTTY", { value, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value, configurable: true }); +} +const ttyBefore = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }; + +const report = { + mode: "dev", + branch: "projects/p/branches/dev-alice-3f9a2c", + created: true, + sourceBranch: "projects/p/branches/production", + endpoint: "projects/p/branches/dev-alice-3f9a2c/endpoints/primary", + endpointCreated: false, + host: "ep-1.example.com", + database: "databricks_postgres", + user: "alice@example.com", + role: { + name: "projects/p/branches/dev-alice-3f9a2c/roles/alice", + action: "kept", + }, + handedOff: ["11111111-2222-3333-4444-555555555555"], + envFile: "/app/.env", + cleaned: false, + schema: { + generated: null, + applied: ["0000_migration"], + typesFile: `${process.cwd()}/shared/appkit-types/database.d.ts`, + }, +}; + +/** Commander keeps parsed option values on the command; each test parses afresh. */ +function resetOptionValues() { + for (const command of dbCommand.commands) { + for (const option of command.options) { + command.setOptionValueWithSource( + option.attributeName(), + undefined, + "default", + ); + } + } +} + +beforeEach(() => { + resetOptionValues(); + for (const mock of [ + tooling.createDbContext, + tooling.resolveInitDefaults, + tooling.runInit, + tooling.runClean, + tooling.runSeed, + prompts.select, + prompts.confirm, + ]) + mock.mockReset(); + tooling.createDbContext.mockReturnValue({ root: "/app", env: {} }); + tooling.resolveInitDefaults.mockResolvedValue({ + mode: null, + hasSchema: true, + }); + tooling.runInit.mockResolvedValue(report); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setTTY(undefined); + Object.defineProperty(process.stdin, "isTTY", { + value: ttyBefore.stdin, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: ttyBefore.stdout, + configurable: true, + }); + process.exitCode = undefined; +}); + +describe("appkit db init", () => { + test("registers init, migrate, introspect, clean and seed", () => { + expect(dbCommand.commands.map((c) => c.name())).toEqual([ + "init", + "migrate", + "introspect", + "clean", + "seed", + ]); + }); + + test("--yes skips every prompt, even on a TTY; flags map to the choices", async () => { + setTTY(true); + await dbCommand.parseAsync(["init", "--new", "session", "--yes"], { + from: "user", + }); + + expect(prompts.select).not.toHaveBeenCalled(); + expect(tooling.runInit).toHaveBeenCalledWith( + { root: "/app", env: {} }, + { mode: "session", schemaStart: "migrate" }, + { interactive: false }, + ); + }); + + test("init only sets up a dev or session branch: there is no option to pick another one", () => { + const init = dbCommand.commands.find((c) => c.name() === "init"); + expect(init?.options.map((o) => o.long)).toEqual([ + "--new", + "--schema", + "--yes", + ]); + }); + + test("--schema none leaves the schema alone", async () => { + await dbCommand.parseAsync( + ["init", "--new", "dev", "--schema", "none", "--yes"], + { from: "user" }, + ); + + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "dev", schemaStart: null }, + { interactive: false }, + ); + }); + + test("without a TTY: the mode of the .env branch, else dev; schema migrate", async () => { + await dbCommand.parseAsync(["init"], { from: "user" }); + expect(tooling.runInit).toHaveBeenLastCalledWith( + expect.anything(), + { mode: "dev", schemaStart: "migrate" }, + { interactive: false }, + ); + + tooling.resolveInitDefaults.mockResolvedValue({ + mode: "session", + hasSchema: true, + }); + await dbCommand.parseAsync(["init"], { from: "user" }); + expect(tooling.runInit).toHaveBeenLastCalledWith( + expect.anything(), + { mode: "session", schemaStart: "migrate" }, + { interactive: false }, + ); + expect(prompts.select).not.toHaveBeenCalled(); + }); + + test("on a TTY it asks for dev or session, then the schema start", async () => { + setTTY(true); + prompts.select + .mockResolvedValueOnce("session") + .mockResolvedValueOnce("none"); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + expect(prompts.select).toHaveBeenCalledTimes(2); + expect( + prompts.select.mock.calls[0]?.[0].options.map( + (o: { value: string }) => o.value, + ), + ).toEqual(["dev", "session"]); + expect(prompts.select.mock.calls[0]?.[0].initialValue).toBe("dev"); + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "session", schemaStart: null }, + { interactive: true }, + ); + }); + + test("a new branch offers migrate, introspect and clean, defaulting to migrate", async () => { + setTTY(true); + prompts.select + .mockResolvedValueOnce("dev") + .mockResolvedValueOnce("introspect"); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + expect( + prompts.select.mock.calls[1]?.[0].options.map( + (o: { value: string }) => o.value, + ), + ).toEqual(["migrate", "introspect", "clean", "none"]); + expect(prompts.select.mock.calls[1]?.[0].initialValue).toBe("migrate"); + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "dev", schemaStart: "introspect" }, + { interactive: true }, + ); + }); + + test("without schema.ts only introspect is offered, and it is the default", async () => { + setTTY(true); + tooling.resolveInitDefaults.mockResolvedValue({ + mode: null, + hasSchema: false, + }); + prompts.select + .mockResolvedValueOnce("session") + .mockResolvedValueOnce("introspect"); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + const schemaPrompt = prompts.select.mock.calls[1]?.[0]; + expect(schemaPrompt.options).toEqual([ + { + value: "introspect", + label: "Introspect", + hint: "there is no schema.ts yet: write it from the branch's tables", + }, + { value: "none", label: "Leave the schema as it is" }, + ]); + expect(schemaPrompt.initialValue).toBe("introspect"); + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "session", schemaStart: "introspect" }, + { interactive: true }, + ); + }); + + test("without schema.ts and without prompts the start is introspect", async () => { + tooling.resolveInitDefaults.mockResolvedValue({ + mode: null, + hasSchema: false, + }); + + await dbCommand.parseAsync(["init", "--new", "dev", "--yes"], { + from: "user", + }); + + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "dev", schemaStart: "introspect" }, + { interactive: false }, + ); + }); + + test("on a TTY, a mode implied by the .env branch is not asked again", async () => { + setTTY(true); + tooling.resolveInitDefaults.mockResolvedValue({ + mode: "session", + hasSchema: true, + }); + prompts.select.mockResolvedValueOnce("migrate"); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + expect(prompts.select).toHaveBeenCalledOnce(); + expect(prompts.select.mock.calls[0]?.[0].message).toBe( + "How should the branch's schema start?", + ); + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "session", schemaStart: "migrate" }, + { interactive: true }, + ); + }); + + test("a cancelled prompt stops before any change", async () => { + setTTY(true); + prompts.select.mockResolvedValueOnce(Symbol("cancel")); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + expect(tooling.runInit).not.toHaveBeenCalled(); + expect(output(log)).toContain("Cancelled"); + expect(process.exitCode).toBeUndefined(); + }); + + test("prints what it did", async () => { + await dbCommand.parseAsync(["init", "--new", "dev", "--yes"], { + from: "user", + }); + + const printed = output(log); + expect(printed).toContain( + "Branch dev-alice-3f9a2c (created from production)", + ); + expect(printed).toContain("Endpoint primary — ep-1.example.com"); + expect(printed).toContain("Role alice@example.com (DATABRICKS_SUPERUSER)"); + expect(printed).toContain( + "Handed the objects owned by 11111111-2222-3333-4444-555555555555 to you", + ); + expect(printed).toContain( + "Pointed .env at dev-alice-3f9a2c (database databricks_postgres)", + ); + expect(printed).toContain("Applied 0000_migration to the .env branch"); + }); + + test("an error is printed and exits 1", async () => { + tooling.runInit.mockRejectedValue( + new Error( + "Lakebase: create branch failed: creating dev-a needs CAN MANAGE on projects/p.", + ), + ); + await dbCommand.parseAsync(["init", "--yes"], { from: "user" }); + expect(output(error)).toContain("needs CAN MANAGE on projects/p"); + expect(process.exitCode).toBe(1); + }); +}); + +describe("appkit db clean", () => { + test("refuses without --yes when there is no TTY, before any API call", async () => { + await dbCommand.parseAsync(["clean"], { from: "user" }); + + expect(output(error)).toContain( + "Refusing to clean without confirmation in a non-interactive shell. Pass --yes.", + ); + expect(tooling.runClean).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + test("asks for confirmation on a TTY; no means nothing is removed", async () => { + setTTY(true); + prompts.confirm.mockResolvedValueOnce(false); + await dbCommand.parseAsync(["clean"], { from: "user" }); + expect(prompts.confirm).toHaveBeenCalledOnce(); + expect(tooling.runClean).not.toHaveBeenCalled(); + + prompts.confirm.mockResolvedValueOnce(true); + tooling.runClean.mockResolvedValue({ + branch: "projects/p/branches/dev-a", + schemaName: "app", + }); + await dbCommand.parseAsync(["clean"], { from: "user" }); + expect(tooling.runClean).toHaveBeenCalledOnce(); + expect(output(log)).toContain( + "Removed every object in schema app on dev-a", + ); + }); + + test("--yes cleans without asking", async () => { + tooling.runClean.mockResolvedValue({ + branch: "projects/p/branches/dev-a", + schemaName: "app", + }); + await dbCommand.parseAsync(["clean", "--yes"], { from: "user" }); + expect(prompts.confirm).not.toHaveBeenCalled(); + expect(tooling.runClean).toHaveBeenCalledOnce(); + }); + + test("the guard's refusal is printed and exits 1", async () => { + tooling.runClean.mockRejectedValue( + new Error( + "Refusing to clean production: it is the project's default branch.", + ), + ); + await dbCommand.parseAsync(["clean", "--yes"], { from: "user" }); + expect(output(error)).toContain("Refusing to clean production"); + expect(process.exitCode).toBe(1); + }); +}); + +describe("appkit db seed", () => { + test("prints the seed file and branch", async () => { + tooling.runSeed.mockResolvedValue({ + branch: "projects/p/branches/dev-a", + file: `${process.cwd()}/config/database/seed.sql`, + }); + await dbCommand.parseAsync(["seed"], { from: "user" }); + expect(output(log)).toContain("Ran config/database/seed.sql on dev-a"); + }); + + test("a missing seed file exits 1", async () => { + tooling.runSeed.mockRejectedValue( + new Error("No seed file at config/database/seed.sql."), + ); + await dbCommand.parseAsync(["seed"], { from: "user" }); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/init.ts b/packages/shared/src/cli/commands/db/init.ts new file mode 100644 index 000000000..9a925a32f --- /dev/null +++ b/packages/shared/src/cli/commands/db/init.ts @@ -0,0 +1,214 @@ +import type { + BranchMode, + DbContext, + InitChoices, + InitReport, + SchemaStart, +} from "@databricks/appkit/database-tooling"; +import { Command, Option } from "commander"; + +import { + fail, + info, + isInteractive, + ok, + printIntrospectReport, + printMigrateReport, + shortName, +} from "./output"; + +type Tooling = typeof import("@databricks/appkit/database-tooling"); + +export interface InitFlags { + readonly new?: "dev" | "session"; + readonly schema?: SchemaStart | "none"; + readonly yes?: boolean; +} + +/** null: the user cancelled a prompt. */ +type Choice = T | null; + +async function promptMode(): Promise> { + const { isCancel, select } = await import("@clack/prompts"); + const mode = await select({ + message: "Which Lakebase branch should local development use?", + options: [ + { + value: "dev", + label: "New per-developer branch", + hint: "dev--…, forked from the default branch, reused on re-runs", + }, + { + value: "session", + label: "New per-session branch", + hint: "session-…, for this checkout, expires after 7 days", + }, + ], + initialValue: "dev", + }); + return isCancel(mode) ? null : mode; +} + +const START_LABELS: Record = { + migrate: "Apply migrations", + introspect: "Introspect", + clean: "Start clean", +}; + +function startHint(start: SchemaStart, hasSchema: boolean): string { + if (start === "migrate") return "schema.ts → migration → branch"; + if (start === "clean") { + return "drop the app schema, then apply committed migrations"; + } + return hasSchema + ? "write schema.ts from the tables the branch copied" + : "there is no schema.ts yet: write it from the branch's tables"; +} + +/** Only the starts that can run here (schemaStartsFor), the default first, then "leave". */ +async function promptSchemaStart( + tooling: Tooling, + hasSchema: boolean, +): Promise> { + const { isCancel, select } = await import("@clack/prompts"); + const starts = tooling.schemaStartsFor(hasSchema); + const options: { + value: SchemaStart | "none"; + label: string; + hint?: string; + }[] = [ + ...starts.map((value) => ({ + value, + label: START_LABELS[value], + hint: startHint(value, hasSchema), + })), + { value: "none", label: "Leave the schema as it is" }, + ]; + const start = await select({ + message: "How should the branch's schema start?", + options, + initialValue: tooling.defaultSchemaStart(hasSchema), + }); + return isCancel(start) ? null : start; +} + +/** + * Flags first, then the mode .env's branch implies (your dev or this + * checkout's session branch), then prompts, then defaults. With `--yes` or + * without a TTY nothing is prompted: mode falls back to "dev" and the schema + * start to defaultSchemaStart (migrate with schema.ts, introspect without). + * Returns null when a prompt is cancelled. + */ +export async function resolveInitChoices( + tooling: Tooling, + ctx: DbContext, + flags: InitFlags, + interactive: boolean, +): Promise { + const defaults = await tooling.resolveInitDefaults(ctx); + let mode: BranchMode | undefined = flags.new ?? defaults.mode ?? undefined; + if (!mode) { + if (interactive) { + const picked = await promptMode(); + if (!picked) return null; + mode = picked; + } else { + mode = "dev"; + } + } + + let start: SchemaStart | "none" | undefined = flags.schema; + if (!start) { + if (interactive) { + const picked = await promptSchemaStart(tooling, defaults.hasSchema); + if (!picked) return null; + start = picked; + } else { + start = tooling.defaultSchemaStart(defaults.hasSchema); + } + } + return { mode, schemaStart: start === "none" ? null : start }; +} + +function printInitReport(report: InitReport): void { + const branch = shortName(report.branch); + ok( + report.created + ? `Branch ${branch} (created from ${shortName(report.sourceBranch ?? "the default branch")})` + : `Branch ${branch} (reused)`, + ); + ok( + `Endpoint ${shortName(report.endpoint)}${report.endpointCreated ? " (created)" : ""} — ${report.host}`, + ); + const role = { + created: "created", + updated: "added to DATABRICKS_SUPERUSER", + kept: "DATABRICKS_SUPERUSER", + }[report.role.action]; + ok(`Role ${report.user} (${role})`); + if (report.handedOff.length > 0) { + ok(`Handed the objects owned by ${report.handedOff.join(", ")} to you`); + } + ok(`Pointed .env at ${branch} (database ${report.database})`); + if (report.cleaned) ok("Removed every object in the app schema"); + if (report.introspect) printIntrospectReport(report.introspect); + else if (report.schema) printMigrateReport(report.schema); + else info("Left the schema as it is"); +} + +async function runDbInit(flags: InitFlags): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const ctx = tooling.createDbContext(); + const interactive = !flags.yes && isInteractive(); + const choices = await resolveInitChoices(tooling, ctx, flags, interactive); + if (!choices) { + info("Cancelled"); + return; + } + printInitReport(await tooling.runInit(ctx, choices, { interactive })); + } catch (error) { + fail(error); + } +} + +export const dbInitCommand = new Command("init") + .description( + "Set up your Lakebase dev or session branch for local development: create or reuse it, ensure an endpoint and your role, point .env at it, then set up the schema", + ) + .addOption( + new Option( + "--new ", + "create or reuse a per-developer or per-session branch", + ).choices(["dev", "session"]), + ) + .addOption( + new Option("--schema ", "how the branch's schema starts").choices([ + "migrate", + "introspect", + "clean", + "none", + ]), + ) + .option( + "-y, --yes", + "no prompts: mode from --new, then from the branch .env points at, then dev; schema from --schema, then migrate with schema.ts, introspect without", + ) + .addHelpText( + "after", + ` +Without --new, init keeps your dev branch or this checkout's session branch when +.env already points at one, and otherwise asks (dev without prompts). New +branches fork the project's default branch. init never changes the default +branch, a protected branch or a branch databricks.yml deploys to, and never edits +databricks.yml. The project comes from LAKEBASE_PROJECT in .env (projects/ +or ), else from LAKEBASE_ENDPOINT, else from the bundle's postgres_branch +variable. A fresh checkout needs only DATABRICKS_CONFIG_PROFILE and +LAKEBASE_PROJECT. + +Examples: + $ appkit db init + $ appkit db init --new dev --yes + $ appkit db init --new session --schema clean --yes`, + ) + .action(runDbInit); diff --git a/packages/shared/src/cli/commands/db/output.ts b/packages/shared/src/cli/commands/db/output.ts index 031a5f424..fb5903594 100644 --- a/packages/shared/src/cli/commands/db/output.ts +++ b/packages/shared/src/cli/commands/db/output.ts @@ -17,7 +17,7 @@ export function relative(file: string): string { } /** The last segment of a Lakebase resource name: projects/p/branches/dev-a → dev-a. */ -function shortName(name: string): string { +export function shortName(name: string): string { return name.slice(name.lastIndexOf("/") + 1); } diff --git a/packages/shared/src/cli/commands/db/seed.ts b/packages/shared/src/cli/commands/db/seed.ts new file mode 100644 index 000000000..d69df16e9 --- /dev/null +++ b/packages/shared/src/cli/commands/db/seed.ts @@ -0,0 +1,26 @@ +import { Command } from "commander"; + +import { fail, ok, relative, shortName } from "./output"; + +async function runDbSeed(): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runSeed(tooling.createDbContext()); + ok(`Ran ${relative(report.file)} on ${shortName(report.branch)}`); + } catch (error) { + fail(error); + } +} + +export const dbSeedCommand = new Command("seed") + .description( + "Run config/database/seed.sql in one transaction on your dev or session branch", + ) + .addHelpText( + "after", + ` +Unqualified names resolve to the app schema first (app, or the schemaName in +config/database/schema.ts), then public. The file already runs in one +transaction, so it must not contain BEGIN or COMMIT.`, + ) + .action(runDbSeed); From 6dd2fd4d25295ab096db1742d7fcd000b8876a55 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 12 Oct 2026 13:41:32 +0100 Subject: [PATCH 5/6] feat(appkit): add appkit db verify `appkit db verify` checks, read-only, that the branch .env points at matches config/database/schema.ts and has every committed migration, including the ones the migrator would never run there: merged out of order, or edited after they ran. With --prod it checks, through the Databricks API only, that the deployed app is set up to use the branch databricks.yml deploys to: the app exists, its postgres resource names that branch and database with CAN_CONNECT_AND_CREATE, its service principal has a role there, and it is running. It reads no database, so it needs no role on the deploy branch. Signed-off-by: ditadi --- .../appkit/src/database/migrations/apply.ts | 56 +- .../database/migrations/tests/apply.test.ts | 91 +++ packages/appkit/src/database/tooling/index.ts | 1 + .../appkit/src/database/tooling/migrate.ts | 1 + .../appkit/src/database/tooling/targets.ts | 12 +- .../database/tooling/tests/targets.test.ts | 15 +- .../src/database/tooling/tests/verify.test.ts | 628 ++++++++++++++++++ .../appkit/src/database/tooling/verify.ts | 322 +++++++++ .../src/plugins/database/schema-check.ts | 26 +- .../database/tests/schema-check.test.ts | 41 +- .../src/cli/commands/db/database-tooling.d.ts | 24 + packages/shared/src/cli/commands/db/index.ts | 4 +- .../cli/commands/db/init-clean-seed.test.ts | 3 +- packages/shared/src/cli/commands/db/output.ts | 62 ++ .../shared/src/cli/commands/db/verify.test.ts | 236 +++++++ packages/shared/src/cli/commands/db/verify.ts | 45 ++ 16 files changed, 1552 insertions(+), 15 deletions(-) create mode 100644 packages/appkit/src/database/tooling/tests/verify.test.ts create mode 100644 packages/appkit/src/database/tooling/verify.ts create mode 100644 packages/shared/src/cli/commands/db/verify.test.ts create mode 100644 packages/shared/src/cli/commands/db/verify.ts diff --git a/packages/appkit/src/database/migrations/apply.ts b/packages/appkit/src/database/migrations/apply.ts index a871e858e..b247402e4 100644 --- a/packages/appkit/src/database/migrations/apply.ts +++ b/packages/appkit/src/database/migrations/apply.ts @@ -163,8 +163,7 @@ async function lastRecordedMigration( * * Known gap, shared with drizzle-orm: an entry older than the last recorded one * (e.g. merged from a parallel branch) is never applied, and an applied - * migration edited later goes unnoticed. Comparing the recorded hashes with the - * journal would catch both. + * migration edited later goes unnoticed. strandedMigrations reports both. */ function pendingEntries( journal: Journal, @@ -398,6 +397,59 @@ export async function pendingMigrationTags( return pendingEntries(journal, last).map((entry) => entry.tag); } +/** A committed migration drizzle-orm's migrator will never run on this database. */ +export interface StrandedMigration { + readonly tag: string; + /** + * "out of order": older than the last recorded migration but not recorded + * (merged from a parallel branch). "edited": recorded, but the file changed + * since. + */ + readonly reason: "out of order" | "edited"; +} + +/** + * Committed migrations the migrator skips for good: it runs only entries newer + * than the last recorded one, and never compares hashes. Recorded rows carry + * the journal's `when` and the file's sha256, so each entry is matched by its + * `when`. Read-only. + */ +export async function strandedMigrations( + client: QueryClient, + options: ApplyMigrationsOptions, +): Promise { + const journal = readJournal(options.migrationsFolder); + if (!journal) return []; + const last = await lastRecordedMigration(client, options.schemaName); + if (last === null) return []; + assertMigrationFiles(options.migrationsFolder, journal); + // One file per journal entry, in journal order. + const files = readMigrationFiles({ + migrationsFolder: options.migrationsFolder, + }); + const recorded = await client.query<{ + hash: string; + created_at: string | number; + }>( + `select hash, created_at from ${quoteIdentifier(options.schemaName)}.${quoteIdentifier(MIGRATIONS_TABLE)}`, + ); + const hashAt = new Map( + recorded.rows.map((row) => [Number(row.created_at), row.hash]), + ); + const stranded: StrandedMigration[] = []; + journal.entries.forEach((entry, index) => { + // Newer entries are pending: the migrator runs them. + if (entry.when > last) return; + const hash = hashAt.get(entry.when); + if (hash === undefined) { + stranded.push({ tag: entry.tag, reason: "out of order" }); + } else if (hash !== files[index]?.hash) { + stranded.push({ tag: entry.tag, reason: "edited" }); + } + }); + return stranded; +} + /** recordMigrationsApplied in one transaction under the migration lock (introspect on an AppKit branch). */ export async function markMigrationsApplied( pool: Pick, diff --git a/packages/appkit/src/database/migrations/tests/apply.test.ts b/packages/appkit/src/database/migrations/tests/apply.test.ts index 48cce854d..f8d4524b4 100644 --- a/packages/appkit/src/database/migrations/tests/apply.test.ts +++ b/packages/appkit/src/database/migrations/tests/apply.test.ts @@ -12,6 +12,7 @@ import { MigrationBaselineMismatchError, migrationLockKey, pendingMigrationTags, + strandedMigrations, } from "../apply"; import { type Journal, MigrationFilesError } from "../paths"; @@ -89,6 +90,8 @@ afterEach(() => { interface FakeState { journalTableExists: boolean; lastCreatedAt: number | null; + /** Rows of `select hash, created_at` from the journal table. */ + journalRows?: Array<{ hash: string; created_at: string }>; failOn?: RegExp; /** SQLSTATE of the failOn error; defaults to 42P07 (duplicate_table). */ failCode?: string; @@ -148,6 +151,9 @@ class RecordingClient { if (text.startsWith("select max(created_at)")) { return { rows: [{ last: this.state.lastCreatedAt }] }; } + if (text.startsWith("select hash, created_at from")) { + return { rows: this.state.journalRows ?? [] }; + } if (text.includes("order by created_at desc limit 1")) { return { rows: @@ -886,6 +892,91 @@ describe("pendingMigrationTags", () => { }); }); +describe("strandedMigrations", () => { + const options = (migrationsFolder: string) => ({ + migrationsFolder, + schemaName: "app", + }); + /** A journal row as the migrator writes it for that file. */ + const row = (folder: string, tag: string, when: number) => ({ + hash: createHash("sha256") + .update(fs.readFileSync(path.join(folder, `${tag}.sql`), "utf-8")) + .digest("hex"), + created_at: String(when), + }); + const threeMigrations = () => + migrations([ + { tag: "0000_migration", when: 1000, sql: ["select 0;"] }, + { tag: "0001_parallel", when: 1500, sql: ["select 1;"] }, + { tag: "0002_migration", when: 2000, sql: ["select 2;"] }, + ]); + + test("none when every entry up to the last recorded one is recorded unchanged", async () => { + const folder = threeMigrations(); + const { client } = fakePool({ + journalTableExists: true, + lastCreatedAt: 1500, + journalRows: [ + row(folder, "0000_migration", 1000), + row(folder, "0001_parallel", 1500), + ], + }); + + await expect( + strandedMigrations(client as never, options(folder)), + ).resolves.toEqual([]); + }); + + test("an entry older than the last recorded one but never recorded is out of order", async () => { + const folder = threeMigrations(); + const { client } = fakePool({ + journalTableExists: true, + lastCreatedAt: 2000, + journalRows: [ + row(folder, "0000_migration", 1000), + row(folder, "0002_migration", 2000), + ], + }); + + await expect( + strandedMigrations(client as never, options(folder)), + ).resolves.toEqual([{ tag: "0001_parallel", reason: "out of order" }]); + }); + + test("a recorded entry whose file changed since is edited", async () => { + const folder = threeMigrations(); + const rows = [ + row(folder, "0000_migration", 1000), + row(folder, "0001_parallel", 1500), + ]; + fs.appendFileSync(path.join(folder, "0000_migration.sql"), "select 9;"); + const { client } = fakePool({ + journalTableExists: true, + lastCreatedAt: 1500, + journalRows: rows, + }); + + await expect( + strandedMigrations(client as never, options(folder)), + ).resolves.toEqual([{ tag: "0000_migration", reason: "edited" }]); + }); + + test("with nothing recorded nothing is stranded, and the rows are not read", async () => { + const folder = threeMigrations(); + const { client } = fakePool({ + journalTableExists: false, + lastCreatedAt: null, + }); + + await expect( + strandedMigrations(client as never, options(folder)), + ).resolves.toEqual([]); + expect( + client.statements.some((s) => s.startsWith("select hash, created_at")), + ).toBe(false); + }); +}); + describe("markMigrationsApplied", () => { test("records the tags with the migrator's hash and when, in one transaction under the lock", async () => { const folder = baselineThenMigration(); diff --git a/packages/appkit/src/database/tooling/index.ts b/packages/appkit/src/database/tooling/index.ts index 10909be0a..88b5867fa 100644 --- a/packages/appkit/src/database/tooling/index.ts +++ b/packages/appkit/src/database/tooling/index.ts @@ -23,3 +23,4 @@ export { export { type MigrateOptions, type MigrateReport, runMigrate } from "./migrate"; export { runSeed } from "./seed"; export type { BranchMode } from "./targets"; +export { runVerify, type VerifyOptions, type VerifyReport } from "./verify"; diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts index e6425fa58..49b7a86e5 100644 --- a/packages/appkit/src/database/tooling/migrate.ts +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -24,6 +24,7 @@ export function hasSchemaFile(root: string): boolean { const WITHOUT_SCHEMA = { migrate: "there is nothing to migrate", clean: "clean cannot tell which schema to drop", + verify: "there is nothing to verify", } as const; /** For a command that reads schema.ts: without one, point to introspect instead of a load error. */ diff --git a/packages/appkit/src/database/tooling/targets.ts b/packages/appkit/src/database/tooling/targets.ts index b359e9364..08af0ba07 100644 --- a/packages/appkit/src/database/tooling/targets.ts +++ b/packages/appkit/src/database/tooling/targets.ts @@ -83,6 +83,7 @@ export function readLocalTarget(env: NodeJS.ProcessEnv): LocalTarget { export interface DeployTarget { readonly branch: string; readonly database: string | null; + readonly appName: string | null; } type Yaml = Record | undefined; @@ -123,7 +124,7 @@ function variableOf( return typeof value === "string" && value ? value : null; } -/** Default target's postgres_branch / postgres_database variables; null without databricks.yml or binding. */ +/** Default target's postgres_branch / postgres_database variables and app name; null without databricks.yml or binding. */ export function readDeployTarget(root: string): DeployTarget | null { const bundle = readBundle(root); if (!bundle) return null; @@ -142,7 +143,14 @@ export function readDeployTarget(root: string): DeployTarget | null { parseResourceName( variableOf(bundle, target, "postgres_database") ?? undefined, )?.database ?? null; - return { branch, database }; + // The default target's override wins over the top-level app definition. + const appName = [target, bundle] + .flatMap((scope) => + Object.values(record(record(scope?.resources)?.apps) ?? {}), + ) + .map((app) => record(app)?.name) + .find((name): name is string => typeof name === "string" && !!name); + return { branch, database, appName: appName ?? null }; } /** diff --git a/packages/appkit/src/database/tooling/tests/targets.test.ts b/packages/appkit/src/database/tooling/tests/targets.test.ts index 6be36190e..c7e2243b4 100644 --- a/packages/appkit/src/database/tooling/tests/targets.test.ts +++ b/packages/appkit/src/database/tooling/tests/targets.test.ts @@ -140,7 +140,7 @@ targets: }); describe("readDeployTarget", () => { - test("reads the default target's postgres variables", () => { + test("reads the default target's postgres variables and the app name", () => { const root = tempApp({ "databricks.yml": databricksYml( "projects/p/branches/production", @@ -150,6 +150,7 @@ describe("readDeployTarget", () => { expect(readDeployTarget(root)).toEqual({ branch: "projects/p/branches/production", database: "projects/p/branches/production/databases/databricks-postgres", + appName: "my-app", }); }); @@ -166,9 +167,21 @@ targets: expect(readDeployTarget(root)).toEqual({ branch: "projects/p/branches/main", database: null, + appName: null, }); }); + test("the default target's app name overrides the top-level one", () => { + const root = tempApp({ + // Appended under targets.default. + "databricks.yml": `${databricksYml( + "projects/p/branches/production", + "projects/p/branches/production/databases/databricks-postgres", + )} resources:\n apps:\n app:\n name: my-app-prod\n`, + }); + expect(readDeployTarget(root)?.appName).toBe("my-app-prod"); + }); + test("null without databricks.yml or without a postgres binding", () => { expect(readDeployTarget(tempApp())).toBeNull(); expect( diff --git a/packages/appkit/src/database/tooling/tests/verify.test.ts b/packages/appkit/src/database/tooling/tests/verify.test.ts new file mode 100644 index 000000000..75ace710c --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/verify.test.ts @@ -0,0 +1,628 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { openBranchPool } = vi.hoisted(() => ({ openBranchPool: vi.fn() })); + +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); + +import type { DbContext, ToolingRequest } from "../context"; +import { runVerify } from "../verify"; +import { databricksYml, FakeLakebase } from "./fakes"; + +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); +const BRANCH = "projects/p/branches/dev-alice-3f9a2c"; +const MIGRATIONS = "config/database/migrations"; +const roots: string[] = []; + +type Migration = { tag: string; when: number }; + +/** An app whose schema.ts declares app.notes(id, body), with the given committed migrations. */ +function app( + migrations: Migration[] = [], + env: NodeJS.ProcessEnv = { LAKEBASE_ENDPOINT: `${BRANCH}/endpoints/primary` }, +): DbContext { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-verify-")); + roots.push(root); + const file = path.join(root, "config/database/schema.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `import { defineSchema, id, text } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ + notes: table("notes", { id: id(), body: text() }), + }));`, + ); + if (migrations.length > 0) { + const meta = path.join(root, MIGRATIONS, "meta"); + fs.mkdirSync(meta, { recursive: true }); + fs.writeFileSync( + path.join(meta, "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: migrations.map((m, idx) => ({ + idx, + version: "7", + when: m.when, + tag: m.tag, + breakpoints: true, + })), + }), + ); + for (const m of migrations) { + fs.writeFileSync( + path.join(root, MIGRATIONS, `${m.tag}.sql`), + `-- ${m.tag}\nselect 1;\n`, + ); + } + } + return { + root, + env, + workspace: () => { + throw new Error("no workspace calls expected"); + }, + api: () => { + throw new Error("no Lakebase API calls expected"); + }, + }; +} + +/** Journal rows as the migrator writes them: the file's sha256 and the journal's `when`. */ +function recordedAs(ctx: DbContext, migrations: Migration[]) { + return migrations.map((m) => ({ + hash: createHash("sha256") + .update( + fs.readFileSync( + path.join(ctx.root, MIGRATIONS, `${m.tag}.sql`), + "utf-8", + ), + ) + .digest("hex"), + created_at: String(m.when), + })); +} + +/** + * A connection answering the catalog and journal queries; it records every + * statement. + */ +function branch(state: { + columns: Record; + /** max(created_at) of the journal table, or "absent" without one. */ + journal: number | null | "absent"; + /** The journal table's rows. */ + recorded?: Array<{ hash: string; created_at: string }>; + failOn?: RegExp; +}) { + const statements: string[] = []; + const client = { + query: vi.fn(async (text: string, values?: unknown[]) => { + statements.push(text); + if (state.failOn?.test(text)) throw new Error("connection reset"); + if (/pg_catalog\.pg_attribute/.test(text)) { + expect(values?.[0]).toBe("app"); + return { + rows: Object.entries(state.columns).flatMap(([table, columns]) => + columns.map((column) => ({ + table_name: table, + column_name: column, + relation_kind: "r", + })), + ), + }; + } + if (/from pg_catalog\.pg_class c\s+join/.test(text)) { + return { rows: state.journal === "absent" ? [] : [{ "?column?": 1 }] }; + } + if (/max\(created_at\)/.test(text)) { + return { + rows: [{ last: state.journal === "absent" ? null : state.journal }], + }; + } + if (/select hash, created_at from/.test(text)) { + return { rows: state.recorded ?? [] }; + } + return { rows: [] }; + }), + release: vi.fn(), + }; + const pool = { + connect: vi.fn(async () => client), + end: vi.fn(async () => undefined), + }; + openBranchPool.mockResolvedValue(pool); + return { statements, client, pool }; +} + +const WRITES = /\b(insert|update|delete|create|drop|alter|grant|truncate)\b/i; + +beforeEach(() => { + openBranchPool.mockReset(); +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("runVerify", () => { + const two = [ + { tag: "0000_migration", when: 1000 }, + { tag: "0001_migration", when: 2000 }, + ]; + + test("a branch that matches schema.ts and has every migration is ok", async () => { + const ctx = app(two); + const { pool } = branch({ + columns: { notes: ["id", "body"] }, + journal: 2000, + recorded: recordedAs(ctx, two), + }); + + const report = await runVerify(ctx); + + expect(report).toEqual({ + kind: "local", + branch: BRANCH, + schemaProblems: [], + pendingMigrations: [], + strandedMigrations: [], + ok: true, + }); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + test("reports schema problems and pending migrations separately", async () => { + const ctx = app(two); + branch({ + columns: { notes: ["id"] }, + journal: 1000, + recorded: recordedAs(ctx, two.slice(0, 1)), + }); + + const report = await runVerify(ctx); + + expect(report).toMatchObject({ + schemaProblems: ["table app.notes is missing column body"], + pendingMigrations: ["0001_migration"], + strandedMigrations: [], + ok: false, + }); + }); + + test("without a journal table every committed migration is pending", async () => { + const ctx = app([{ tag: "0000_baseline", when: 1000 }]); + branch({ columns: {}, journal: "absent" }); + + const report = await runVerify(ctx); + + expect(report).toMatchObject({ + schemaProblems: ["table app.notes does not exist"], + pendingMigrations: ["0000_baseline"], + strandedMigrations: [], + ok: false, + }); + }); + + test("a migration merged out of order, older than the last one the branch ran, never runs", async () => { + const migrations = [ + { tag: "0000_migration", when: 1000 }, + { tag: "0001_parallel", when: 1500 }, + { tag: "0002_migration", when: 2000 }, + ]; + const ctx = app(migrations); + branch({ + columns: { notes: ["id", "body"] }, + journal: 2000, + recorded: recordedAs(ctx, [migrations[0], migrations[2]] as Migration[]), + }); + + const report = await runVerify(ctx); + + expect(report).toMatchObject({ + pendingMigrations: [], + strandedMigrations: [{ tag: "0001_parallel", reason: "out of order" }], + ok: false, + }); + }); + + test("a migration edited after the branch ran it never runs again", async () => { + const ctx = app(two); + const recorded = recordedAs(ctx, two); + fs.appendFileSync( + path.join(ctx.root, MIGRATIONS, "0000_migration.sql"), + "select 2;\n", + ); + branch({ columns: { notes: ["id", "body"] }, journal: 2000, recorded }); + + expect(await runVerify(ctx)).toMatchObject({ + strandedMigrations: [{ tag: "0000_migration", reason: "edited" }], + ok: false, + }); + }); + + test("no committed migrations: only the schema is checked", async () => { + const ctx = app(); + const { statements } = branch({ + columns: { notes: ["id", "body"] }, + journal: "absent", + }); + + expect((await runVerify(ctx)).ok).toBe(true); + expect(statements.some((s) => /created_at/.test(s))).toBe(false); + }); + + test("never writes: everything runs in a READ ONLY transaction that is rolled back", async () => { + const ctx = app([{ tag: "0000_migration", when: 1000 }]); + const { statements, client } = branch({ + columns: { notes: ["id"] }, + journal: 500, + }); + + await runVerify(ctx); + + expect(statements[0]).toBe("BEGIN TRANSACTION READ ONLY"); + expect(statements.at(-1)).toBe("ROLLBACK"); + expect(statements.filter((s) => WRITES.test(s))).toEqual([]); + expect(client.release).toHaveBeenCalledWith(undefined); + }); + + test("a failing query rolls back, discards the connection and ends the pool", async () => { + const ctx = app([{ tag: "0000_migration", when: 1000 }]); + const { statements, client, pool } = branch({ + columns: {}, + journal: 1000, + failOn: /max\(created_at\)/, + }); + + await expect(runVerify(ctx)).rejects.toThrow("connection reset"); + expect(statements.at(-1)).toBe("ROLLBACK"); + expect(client.release).toHaveBeenCalledWith(expect.any(Error)); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + test("without a configured branch it points to init before connecting", async () => { + await expect(runVerify(app([], {}))).rejects.toThrow( + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`.", + ); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("without schema.ts it points to introspect", async () => { + const ctx = app(); + fs.rmSync(path.join(ctx.root, "config/database/schema.ts")); + + await expect(runVerify(ctx)).rejects.toThrow( + "No config/database/schema.ts yet, so there is nothing to verify. Run `appkit db introspect` to write it from the branch's tables, or create it.", + ); + expect(openBranchPool).not.toHaveBeenCalled(); + }); +}); + +const PRODUCTION = "projects/p/branches/production"; +const DATABASE = `${PRODUCTION}/databases/db-1`; +const SP = "11111111-2222-3333-4444-555555555555"; + +/** GET /api/2.0/apps/my-app for a deployed, running app bound to the deploy branch. */ +function runningApp(overrides: Record = {}) { + return { + name: "my-app", + service_principal_client_id: SP, + resources: [ + { + name: "postgres", + postgres: { + branch: PRODUCTION, + database: DATABASE, + permission: "CAN_CONNECT_AND_CREATE", + }, + }, + ], + app_status: { state: "RUNNING", message: "App is running." }, + compute_status: { state: "ACTIVE", message: "App compute is running." }, + active_deployment: { + status: { state: "SUCCEEDED", message: "App started successfully" }, + }, + ...overrides, + }; +} + +/** + * An app that databricks.yml deploys to the deploy branch. The developer has + * no Postgres role there: --prod must not need one. + */ +function prodApp( + options: { + app?: Record | "missing" | Error; + yml?: string; + spRole?: boolean; + } = {}, +) { + const base = app([], {}); + fs.writeFileSync( + path.join(base.root, "databricks.yml"), + options.yml ?? databricksYml(PRODUCTION, DATABASE), + ); + const lakebase = new FakeLakebase(); + lakebase.addBranch(PRODUCTION, { default: true }); + if (options.spRole !== false) { + lakebase.addRole(PRODUCTION, `dbrx-apps-${SP}`, SP); + } + lakebase.databases.set(DATABASE, { + name: DATABASE, + status: { postgres_database: "appdb" }, + }); + const requests: string[] = []; + const ctx: DbContext = { + ...base, + workspace: () => ({ + apiClient: { + request: async (request: ToolingRequest) => { + requests.push(`${request.method} ${request.path}`); + const response = options.app ?? runningApp(); + if (response === "missing") { + throw Object.assign(new Error("App my-app does not exist."), { + statusCode: 404, + }); + } + if (response instanceof Error) throw response; + return response; + }, + }, + currentUser: { + me: async () => { + throw new Error("--prod does not need the developer's identity"); + }, + }, + }), + api: () => lakebase.api(), + }; + return { ctx, lakebase, requests }; +} + +/** --prod's problems; fails when the report is not a deploy report. */ +async function deployProblems(ctx: DbContext): Promise { + const report = await runVerify(ctx, { prod: true }); + if (report.kind !== "deploy") throw new Error("expected a deploy report"); + return report.problems; +} + +/** databricks.yml with the given resources block in place of the default app. */ +function ymlWithApps(apps: string): string { + return databricksYml(PRODUCTION, DATABASE).replace( + /resources:\n {2}apps:\n(?: {4}.*\n)+/, + apps, + ); +} + +describe("runVerify --prod", () => { + test("a running app bound to the deploy branch is ok, with no database connection and no role of yours there", async () => { + const { ctx, lakebase, requests } = prodApp(); + + const report = await runVerify(ctx, { prod: true }); + + expect(report).toEqual({ + kind: "deploy", + branch: PRODUCTION, + app: "my-app", + problems: [], + ok: true, + }); + expect(requests).toEqual(["GET /api/2.0/apps/my-app"]); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(lakebase.calls.sort()).toEqual([ + `getBranch ${PRODUCTION}`, + `getDatabase ${DATABASE}`, + `listRoles ${PRODUCTION}`, + ]); + }); + + test("does not need schema.ts", async () => { + const { ctx } = prodApp(); + fs.rmSync(path.join(ctx.root, "config/database/schema.ts")); + + expect((await runVerify(ctx, { prod: true })).ok).toBe(true); + }); + + test("an app that does not exist yet: deploy it", async () => { + const { ctx, lakebase } = prodApp({ app: "missing" }); + + expect(await runVerify(ctx, { prod: true })).toMatchObject({ + problems: [ + "app my-app is not deployed yet: `databricks apps deploy` creates it and gives it access to production", + ], + ok: false, + }); + expect(lakebase.calls).not.toContain(`listRoles ${PRODUCTION}`); + }); + + test.each([ + [ + "another branch", + [ + { + postgres: { + branch: "projects/p/branches/staging", + database: DATABASE, + permission: "CAN_CONNECT_AND_CREATE", + }, + }, + ], + `app my-app uses branch projects/p/branches/staging, not ${PRODUCTION} from databricks.yml: \`databricks apps deploy\` applies databricks.yml`, + ], + [ + "no postgres resource", + [{ sql_warehouse: { id: "w", permission: "CAN_USE" } }], + "app my-app has no postgres resource: `databricks apps deploy` adds the one databricks.yml declares", + ], + [ + "another database", + [ + { + postgres: { + branch: PRODUCTION, + database: `${PRODUCTION}/databases/other`, + permission: "CAN_CONNECT_AND_CREATE", + }, + }, + ], + `app my-app uses database ${PRODUCTION}/databases/other, not ${DATABASE} from databricks.yml: \`databricks apps deploy\` applies databricks.yml`, + ], + [ + "a permission that cannot create the schema", + [ + { + postgres: { + branch: PRODUCTION, + database: DATABASE, + permission: "CAN_CONNECT", + }, + }, + ], + "app my-app's postgres resource has permission CAN_CONNECT, but the app needs CAN_CONNECT_AND_CREATE to create its schema: set it in databricks.yml, then run `databricks apps deploy`", + ], + ])("a postgres resource with %s", async (_label, resources, problem) => { + const { ctx, lakebase } = prodApp({ app: runningApp({ resources }) }); + + expect(await deployProblems(ctx)).toEqual([problem]); + expect(lakebase.calls).not.toContain(`listRoles ${PRODUCTION}`); + }); + + test("a service principal without a role on the branch", async () => { + const { ctx } = prodApp({ spRole: false }); + + expect(await deployProblems(ctx)).toEqual([ + `the app's service principal ${SP} has no Postgres role on production: \`databricks apps deploy\` creates it from the app's postgres resource`, + ]); + }); + + test.each([ + [ + "crashed", + { + app_status: { state: "CRASHED", message: "App crashed unexpectedly." }, + }, + "app my-app is crashed: App crashed unexpectedly. `databricks apps logs` shows why; a migration that fails stops the app on start", + ], + [ + // As the API returns it: a stopped app has no active_deployment. + "stopped", + { + compute_status: { + state: "STOPPED", + message: "Start the app compute to deploy the app.", + }, + app_status: { + state: "UNAVAILABLE", + message: "App status is unavailable.", + }, + active_deployment: undefined, + }, + "app my-app is stopped: `databricks apps start` starts its last deployment, or `databricks apps deploy` deploys the current code; it migrates on start", + ], + [ + "starting", + { compute_status: { state: "STARTING" } }, + "app my-app's compute is starting", + ], + [ + "a failed deployment", + { + pending_deployment: { + status: { state: "FAILED", message: "Build failed." }, + }, + }, + "the last deployment of app my-app failed: Build failed. `databricks apps logs` shows why", + ], + [ + "a deployment in progress", + { pending_deployment: { status: { state: "IN_PROGRESS" } } }, + "a deployment of app my-app is in progress: run verify again when it finishes", + ], + [ + "no deployment", + { active_deployment: undefined, app_status: { state: "UNAVAILABLE" } }, + "app my-app has no deployment yet: `databricks apps deploy` deploys it", + ], + ])("an app that is %s", async (_label, overrides, problem) => { + const { ctx } = prodApp({ app: runningApp(overrides) }); + + expect(await deployProblems(ctx)).toEqual([problem]); + }); + + test("a deploy branch or database that does not exist", async () => { + const { ctx, lakebase } = prodApp(); + lakebase.databases.clear(); + expect(await deployProblems(ctx)).toEqual([ + `databricks.yml binds database ${DATABASE}, which does not exist on production`, + ]); + + lakebase.branches.clear(); + expect(await deployProblems(ctx)).toEqual([ + `databricks.yml deploys to ${PRODUCTION}, which does not exist`, + ]); + }); + + test("databricks.yml without an app, or with an app name verify cannot resolve, before any API call", async () => { + const none = prodApp({ yml: ymlWithApps("") }); + expect(await runVerify(none.ctx, { prod: true })).toMatchObject({ + app: null, + problems: ["databricks.yml declares no app"], + ok: false, + }); + expect(none.requests).toEqual([]); + + const interpolated = prodApp({ + yml: ymlWithApps( + 'resources:\n apps:\n app:\n name: "my-app-${bundle.target}"\n', + ), + }); + expect(await deployProblems(interpolated.ctx)).toEqual([ + "databricks.yml names the app my-app-${bundle.target}, with a variable verify cannot resolve", + ]); + expect(interpolated.requests).toEqual([]); + expect(interpolated.lakebase.calls).toEqual([]); + }); + + test("an error other than 404 from the apps API names the app", async () => { + const { ctx } = prodApp({ + app: Object.assign(new Error("Forbidden"), { statusCode: 403 }), + }); + + await expect(runVerify(ctx, { prod: true })).rejects.toThrow( + "Could not read app my-app: Forbidden", + ); + }); + + test("never writes and never connects to the database", async () => { + const { ctx, lakebase } = prodApp({ spRole: false }); + + await runVerify(ctx, { prod: true }); + + expect(openBranchPool).not.toHaveBeenCalled(); + expect(lakebase.calls.filter((c) => !/^(get|list)[A-Z]/.test(c))).toEqual( + [], + ); + }); + + test("without a deploy binding in databricks.yml it says so", async () => { + const { ctx } = prodApp(); + fs.rmSync(path.join(ctx.root, "databricks.yml")); + + await expect(runVerify(ctx, { prod: true })).rejects.toThrow( + "No deploy branch: the default target in databricks.yml binds no postgres_branch.", + ); + }); +}); diff --git a/packages/appkit/src/database/tooling/verify.ts b/packages/appkit/src/database/tooling/verify.ts new file mode 100644 index 000000000..5a56b8142 --- /dev/null +++ b/packages/appkit/src/database/tooling/verify.ts @@ -0,0 +1,322 @@ +import type { PoolClient } from "pg"; + +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { findSchemaProblems } from "../../plugins/database/schema-check"; +import { + pendingMigrationTags, + type StrandedMigration, + strandedMigrations, +} from "../migrations/apply"; +import { migrationsFolder } from "../migrations/paths"; +import type { DataPath } from "../runtime"; +import { type DbContext, openBranchPool } from "./context"; +import { requireSchemaFile } from "./migrate"; +import { + type DeployTarget, + parseResourceName, + readDeployTarget, + resourceId, +} from "./targets"; + +export interface VerifyOptions { + /** Check that the deployed app is set up to use the deploy branch, instead of the `.env` branch. */ + readonly prod?: boolean; +} + +/** `appkit db verify`: the `.env` branch against schema.ts and the committed migrations. */ +interface LocalVerifyReport { + readonly kind: "local"; + readonly branch: string; + /** Declared tables or columns the branch lacks (the plugin's startup check). */ + readonly schemaProblems: readonly string[]; + /** Committed migrations the branch has not recorded yet. */ + readonly pendingMigrations: readonly string[]; + /** Committed migrations the migrator will never run on the branch. */ + readonly strandedMigrations: readonly StrandedMigration[]; + readonly ok: boolean; +} + +/** `appkit db verify --prod`: whether the deployed app is set up to use the deploy branch. */ +interface DeployVerifyReport { + readonly kind: "deploy"; + readonly branch: string; + /** The app databricks.yml deploys; null when it declares none. */ + readonly app: string | null; + /** What keeps the deployed app from using the branch, each with its fix. */ + readonly problems: readonly string[]; + readonly ok: boolean; +} + +export type VerifyReport = LocalVerifyReport | DeployVerifyReport; + +const NO_BRANCH = + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`."; +const DEPLOY = "`databricks apps deploy`"; +const LOGS = "`databricks apps logs`"; +/** What the app's postgres resource needs to create and own its schema. */ +const POSTGRES_PERMISSION = "CAN_CONNECT_AND_CREATE"; + +/** DataPath.raw over one connection: each interpolation becomes a $n parameter. */ +function rawOn(client: Pick): Pick { + return { + async raw(strings: TemplateStringsArray, ...values: unknown[]) { + const text = strings.reduce( + (sql, part, index) => `${sql}$${index}${part}`, + ); + const result = await client.query(text, values); + return result.rows as T[]; + }, + }; +} + +/** Read-only, as the developer, on the `.env` branch: one READ ONLY transaction, rolled back. */ +async function verifyLocal(ctx: DbContext): Promise { + requireSchemaFile(ctx.root, "verify"); + const schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + const branch = parseResourceName(ctx.env.LAKEBASE_ENDPOINT)?.branch; + if (!branch) throw new Error(NO_BRANCH); + const pool = await openBranchPool(ctx); + if (!pool) throw new Error(NO_BRANCH); + + try { + const client = await pool.connect(); + let failed: unknown; + try { + await client.query("BEGIN TRANSACTION READ ONLY"); + const options = { + migrationsFolder: migrationsFolder(ctx.root), + schemaName: schema.$schemaName, + }; + const schemaProblems = await findSchemaProblems(rawOn(client), schema); + const pendingMigrations = await pendingMigrationTags(client, options); + const stranded = await strandedMigrations(client, options); + await client.query("ROLLBACK"); + return { + kind: "local", + branch, + schemaProblems, + pendingMigrations, + strandedMigrations: stranded, + ok: + schemaProblems.length === 0 && + pendingMigrations.length === 0 && + stranded.length === 0, + }; + } catch (error) { + failed = error; + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + // A connection whose transaction state is unknown is not reused. + client.release(failed instanceof Error ? failed : undefined); + } + } finally { + await pool.end().catch(() => undefined); + } +} + +interface DeploymentStatus { + readonly state?: string; + readonly message?: string; +} + +/** The fields of GET /api/2.0/apps/{name} that verify reads. */ +interface DeployedApp { + readonly service_principal_client_id?: string; + readonly resources?: ReadonlyArray<{ + readonly postgres?: { + readonly branch?: string; + readonly database?: string; + readonly permission?: string; + }; + }>; + readonly app_status?: DeploymentStatus; + readonly compute_status?: DeploymentStatus; + readonly active_deployment?: { readonly status?: DeploymentStatus }; + readonly pending_deployment?: { readonly status?: DeploymentStatus }; +} + +/** GET /api/2.0/apps/{name}; null when the app does not exist yet. */ +async function readApp( + ctx: DbContext, + name: string, +): Promise { + try { + return (await ctx.workspace().apiClient.request({ + path: `/api/2.0/apps/${encodeURIComponent(name)}`, + method: "GET", + headers: new Headers({ Accept: "application/json" }), + raw: false, + })) as DeployedApp; + } catch (error) { + if ((error as { statusCode?: unknown } | null)?.statusCode === 404) { + return null; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not read app ${name}: ${message}`, { + cause: error, + }); + } +} + +/** The app's postgres resource against databricks.yml: same branch and database, and the permission to create its schema. */ +function bindingProblems( + app: DeployedApp, + name: string, + deploy: DeployTarget, +): string[] { + const bound = (app.resources ?? []).flatMap((resource) => + resource.postgres ? [resource.postgres] : [], + ); + const binding = bound.find((postgres) => postgres.branch === deploy.branch); + if (!binding) { + return [ + bound.length > 0 + ? `app ${name} uses branch ${bound.map((postgres) => postgres.branch).join(", ")}, not ${deploy.branch} from databricks.yml: ${DEPLOY} applies databricks.yml` + : `app ${name} has no postgres resource: ${DEPLOY} adds the one databricks.yml declares`, + ]; + } + const problems: string[] = []; + if (deploy.database && binding.database !== deploy.database) { + problems.push( + `app ${name} uses database ${binding.database ?? "(none)"}, not ${deploy.database} from databricks.yml: ${DEPLOY} applies databricks.yml`, + ); + } + if (binding.permission !== POSTGRES_PERMISSION) { + problems.push( + `app ${name}'s postgres resource has permission ${binding.permission ?? "(none)"}, but the app needs ${POSTGRES_PERMISSION} to create its schema: set it in databricks.yml, then run ${DEPLOY}`, + ); + } + return problems; +} + +/** ": " for an API status message, without its final period; "" without one. */ +function detail(message: string | undefined): string { + const text = message?.trim().replace(/\.+$/, ""); + return text ? `: ${text}` : ""; +} + +/** Whether the app is up: a running app has applied its migrations and checked its schema on start. */ +function runningProblems(app: DeployedApp, name: string): string[] { + const pending = app.pending_deployment?.status; + if (pending?.state === "IN_PROGRESS") { + return [ + `a deployment of app ${name} is in progress: run verify again when it finishes`, + ]; + } + const deployment = app.active_deployment?.status; + if (pending?.state === "FAILED" || deployment?.state === "FAILED") { + const failed = pending?.state === "FAILED" ? pending : deployment; + return [ + `the last deployment of app ${name} failed${detail(failed?.message)}. ${LOGS} shows why`, + ]; + } + const compute = app.compute_status; + // A stopped app's API response has no active_deployment, deployed before or not. + if (compute?.state === "STOPPED") { + return [ + `app ${name} is stopped: \`databricks apps start\` starts its last deployment, or ${DEPLOY} deploys the current code; it migrates on start`, + ]; + } + if (!app.active_deployment) { + return [`app ${name} has no deployment yet: ${DEPLOY} deploys it`]; + } + if (compute?.state && compute.state !== "ACTIVE") { + return [ + `app ${name}'s compute is ${compute.state.toLowerCase()}${detail(compute.message)}`, + ]; + } + const status = app.app_status; + if (status?.state === "RUNNING") return []; + return [ + `app ${name} is ${status?.state?.toLowerCase() ?? "not running"}${detail(status?.message)}. ${LOGS} shows why; a migration that fails stops the app on start`, + ]; +} + +/** + * --prod, through the Databricks API only: no database connection, so no + * Postgres role on the deploy branch is needed. The app must exist, be bound + * to the branch and database databricks.yml names with CAN_CONNECT_AND_CREATE, + * have a role on the branch for its service principal, and be running. + */ +async function verifyDeploy(ctx: DbContext): Promise { + const deploy = readDeployTarget(ctx.root); + if (!deploy) { + throw new Error( + "No deploy branch: the default target in databricks.yml binds no postgres_branch.", + ); + } + const report = ( + app: string | null, + problems: string[], + ): DeployVerifyReport => ({ + kind: "deploy", + branch: deploy.branch, + app, + problems, + ok: problems.length === 0, + }); + const name = deploy.appName; + if (!name) return report(null, ["databricks.yml declares no app"]); + if (name.includes("${")) { + return report(name, [ + `databricks.yml names the app ${name}, with a variable verify cannot resolve`, + ]); + } + + const api = ctx.api(); + const [branch, database, app] = await Promise.all([ + api.getBranch(deploy.branch), + deploy.database ? api.getDatabase(deploy.database) : null, + readApp(ctx, name), + ]); + const branchId = resourceId(deploy.branch); + if (!branch) { + return report(name, [ + `databricks.yml deploys to ${deploy.branch}, which does not exist`, + ]); + } + const problems: string[] = []; + if (deploy.database && !database) { + problems.push( + `databricks.yml binds database ${deploy.database}, which does not exist on ${branchId}`, + ); + } + if (!app) { + problems.push( + `app ${name} is not deployed yet: ${DEPLOY} creates it and gives it access to ${branchId}`, + ); + return report(name, problems); + } + + const binding = bindingProblems(app, name, deploy); + const running = runningProblems(app, name); + problems.push(...binding, ...running); + const sp = app.service_principal_client_id; + // Only meaningful once the app is bound to this branch. + if (sp && binding.length === 0) { + const roles = await api.listRoles(deploy.branch); + if (!roles.some((role) => role.status?.postgres_role === sp)) { + problems.push( + `the app's service principal ${sp} has no Postgres role on ${branchId}: ${DEPLOY} creates it from the app's postgres resource`, + ); + } + } + return report(name, problems); +} + +/** + * `appkit db verify`: check the `.env` branch against schema.ts (the plugin's + * startup check) and the committed migrations, read-only. With `prod`, check + * through the Databricks API that the deployed app is set up to use the deploy + * branch; that reads no database, so it needs no role on the deploy branch. + * Nothing is written or created either way. + */ +export async function runVerify( + ctx: DbContext, + options: VerifyOptions = {}, +): Promise { + return options.prod ? verifyDeploy(ctx) : verifyLocal(ctx); +} diff --git a/packages/appkit/src/plugins/database/schema-check.ts b/packages/appkit/src/plugins/database/schema-check.ts index 841dcc19c..2cd71820e 100644 --- a/packages/appkit/src/plugins/database/schema-check.ts +++ b/packages/appkit/src/plugins/database/schema-check.ts @@ -9,18 +9,17 @@ interface CatalogColumn { } /** - * Confirm every declared table and column exists before routes are published. - * Even after a deployed app applies its migrations, drift would surface on each - * request as an undefined-table or undefined-column failure, so check up front. + * Every declared table or column the database lacks, and every declared table + * that is a view or another non-table relation, as human-readable problems. * pg_catalog is read rather than information_schema, which hides objects the * role cannot access and would report a privilege gap as a missing column. */ -export async function assertSchemaMatchesDatabase( - dataPath: DataPath, +export async function findSchemaProblems( + dataPath: Pick, schema: Schema, -): Promise { +): Promise { const tables = Object.values(schema.$tables); - if (tables.length === 0) return; + if (tables.length === 0) return []; const schemaName = schema.$schemaName; const tableNames = tables.map((table) => table.$name); // Include non-tables so setup can report their kind instead of claiming they are absent. @@ -74,6 +73,19 @@ export async function assertSchemaMatchesDatabase( ); } } + return problems; +} + +/** + * Confirm every declared table and column exists before routes are published. + * Even after a deployed app applies its migrations, drift would surface on each + * request as an undefined-table or undefined-column failure, so check up front. + */ +export async function assertSchemaMatchesDatabase( + dataPath: DataPath, + schema: Schema, +): Promise { + const problems = await findSchemaProblems(dataPath, schema); if (problems.length > 0) { throw databaseSetupFailed( `the declared schema does not match the database: ${problems.join("; ")}. Run \`npx appkit db migrate\` locally, or commit migrations under config/database/migrations so the deployed app applies them on start.`, diff --git a/packages/appkit/src/plugins/database/tests/schema-check.test.ts b/packages/appkit/src/plugins/database/tests/schema-check.test.ts index 1ece4fb92..67d3aa8ef 100644 --- a/packages/appkit/src/plugins/database/tests/schema-check.test.ts +++ b/packages/appkit/src/plugins/database/tests/schema-check.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test, vi } from "vitest"; import type { DataPath } from "../../../database/runtime"; import { defineSchema, fk, id, text } from "../../../database/schema-builder"; -import { assertSchemaMatchesDatabase } from "../schema-check"; +import { + assertSchemaMatchesDatabase, + findSchemaProblems, +} from "../schema-check"; // Explicitly public: the assertions name the tables public.boards and public.notes. const schema = defineSchema( @@ -147,3 +150,39 @@ describe("assertSchemaMatchesDatabase", () => { expect(raw).not.toHaveBeenCalled(); }); }); + +describe("findSchemaProblems", () => { + test("returns every problem without throwing; the assert reports the same ones", async () => { + const { path } = catalog( + { boards: ["id", "title"], notes: ["id", "body"] }, + { boards: "v" }, + ); + + const problems = await findSchemaProblems(path, schema); + + expect(problems).toEqual([ + "table public.boards is a view, not a table", + "table public.notes is missing columns board_id, author_email", + ]); + await expect(assertSchemaMatchesDatabase(path, schema)).rejects.toThrow( + `the declared schema does not match the database: ${problems.join("; ")}.`, + ); + }); + + test("returns no problems for a matching database or an empty schema", async () => { + const { path } = catalog({ + boards: ["id", "title"], + notes: ["id", "board_id", "author_email", "body"], + }); + expect(await findSchemaProblems(path, schema)).toEqual([]); + + const empty = catalog({}); + expect( + await findSchemaProblems( + empty.path, + defineSchema(() => ({})), + ), + ).toEqual([]); + expect(empty.raw).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/cli/commands/db/database-tooling.d.ts b/packages/shared/src/cli/commands/db/database-tooling.d.ts index 5ca77cf08..df14de9d1 100644 --- a/packages/shared/src/cli/commands/db/database-tooling.d.ts +++ b/packages/shared/src/cli/commands/db/database-tooling.d.ts @@ -104,4 +104,28 @@ declare module "@databricks/appkit/database-tooling" { export function runClean(ctx: DbContext): Promise<{ branch: string; schemaName: string }>; export function runSeed(ctx: DbContext): Promise<{ branch: string; file: string }>; + + export type VerifyReport = + | { + readonly kind: "local"; + readonly branch: string; + readonly schemaProblems: readonly string[]; + readonly pendingMigrations: readonly string[]; + readonly strandedMigrations: readonly { + readonly tag: string; + readonly reason: "out of order" | "edited"; + }[]; + readonly ok: boolean; + } + | { + readonly kind: "deploy"; + readonly branch: string; + readonly app: string | null; + readonly problems: readonly string[]; + readonly ok: boolean; + }; + export function runVerify( + ctx: DbContext, + options?: { prod?: boolean }, + ): Promise; } diff --git a/packages/shared/src/cli/commands/db/index.ts b/packages/shared/src/cli/commands/db/index.ts index ed811c91c..3c5bbcfdf 100644 --- a/packages/shared/src/cli/commands/db/index.ts +++ b/packages/shared/src/cli/commands/db/index.ts @@ -5,6 +5,7 @@ import { dbInitCommand } from "./init"; import { dbIntrospectCommand } from "./introspect"; import { dbMigrateCommand } from "./migrate"; import { dbSeedCommand } from "./seed"; +import { dbVerifyCommand } from "./verify"; /** * Lakebase database lifecycle for the DatabasePlugin. The logic lives in @@ -16,4 +17,5 @@ export const dbCommand = new Command("db") .addCommand(dbMigrateCommand) .addCommand(dbIntrospectCommand) .addCommand(dbCleanCommand) - .addCommand(dbSeedCommand); + .addCommand(dbSeedCommand) + .addCommand(dbVerifyCommand); diff --git a/packages/shared/src/cli/commands/db/init-clean-seed.test.ts b/packages/shared/src/cli/commands/db/init-clean-seed.test.ts index b2be58282..4de50eaa5 100644 --- a/packages/shared/src/cli/commands/db/init-clean-seed.test.ts +++ b/packages/shared/src/cli/commands/db/init-clean-seed.test.ts @@ -117,13 +117,14 @@ afterEach(() => { }); describe("appkit db init", () => { - test("registers init, migrate, introspect, clean and seed", () => { + test("registers init, migrate, introspect, clean, seed and verify", () => { expect(dbCommand.commands.map((c) => c.name())).toEqual([ "init", "migrate", "introspect", "clean", "seed", + "verify", ]); }); diff --git a/packages/shared/src/cli/commands/db/output.ts b/packages/shared/src/cli/commands/db/output.ts index fb5903594..d04d510a3 100644 --- a/packages/shared/src/cli/commands/db/output.ts +++ b/packages/shared/src/cli/commands/db/output.ts @@ -4,6 +4,7 @@ import process from "node:process"; import type { IntrospectReport, MigrateReport, + VerifyReport, } from "@databricks/appkit/database-tooling"; import pc from "picocolors"; @@ -75,6 +76,67 @@ export function printMigrateReport(report: MigrateReport): void { ok(`Generated ${relative(report.typesFile)}`); } +type LocalVerifyReport = Extract; +type DeployVerifyReport = Extract; + +const STRANDED: Record<"out of order" | "edited", string> = { + "out of order": + "it is older than a migration the branch already ran (merged out of order); the deployed app skips it too once it ran a newer one", + edited: + "it changed after the branch ran it; restore it from version control and put the change in a new migration", +}; + +/** The one next step for a failed local verify, most fundamental gap first. */ +function localVerifyHint(report: LocalVerifyReport): string { + if (report.strandedMigrations.length > 0) { + return "`appkit db clean` and then `appkit db migrate` rebuild the branch from the migrations, in order."; + } + // Pending migrations explain the gap; without them, schema.ts changed or the branch was edited. + return report.pendingMigrations.length > 0 + ? "Run `appkit db migrate` to apply the pending migrations." + : "If you changed schema.ts, run `appkit db migrate`; if the branch was changed by hand, `appkit db clean` and then `appkit db migrate` rebuild it from the migrations."; +} + +function printLocalVerifyReport(report: LocalVerifyReport): void { + const branch = shortName(report.branch); + if (report.ok) { + ok( + `${branch} matches config/database/schema.ts and has every committed migration`, + ); + return; + } + for (const problem of report.schemaProblems) warn(`Schema: ${problem}`); + if (report.pendingMigrations.length > 0) { + warn(`Pending migrations: ${report.pendingMigrations.join(", ")}`); + } + for (const { tag, reason } of report.strandedMigrations) { + warn(`Never runs: ${tag}: ${STRANDED[reason]}`); + } + console.error( + `${pc.red("✖")} ${branch} does not match config/database/schema.ts and the committed migrations. ${localVerifyHint(report)}`, + ); +} + +function printDeployVerifyReport(report: DeployVerifyReport): void { + const branch = `${shortName(report.branch)} (deploy branch)`; + const app = report.app ? `app ${report.app}` : "The deployed app"; + if (report.ok) { + ok( + `${app} is running on ${branch}: on start it applied its migrations and checked its schema`, + ); + return; + } + for (const problem of report.problems) warn(problem); + console.error( + `${pc.red("✖")} ${app} cannot use ${branch} yet. Fix the above, then run \`appkit db verify --prod\` again.`, + ); +} + +export function printVerifyReport(report: VerifyReport): void { + if (report.kind === "local") printLocalVerifyReport(report); + else printDeployVerifyReport(report); +} + export function printIntrospectReport(report: IntrospectReport): void { const count = report.tables.length; ok( diff --git a/packages/shared/src/cli/commands/db/verify.test.ts b/packages/shared/src/cli/commands/db/verify.test.ts new file mode 100644 index 000000000..57f6ffdc3 --- /dev/null +++ b/packages/shared/src/cli/commands/db/verify.test.ts @@ -0,0 +1,236 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const { createDbContext, runVerify } = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + runVerify: vi.fn(), +})); + +// The tooling is an ambient module loaded with a dynamic import; mock it so the +// command never touches a database. +vi.mock("@databricks/appkit/database-tooling", () => ({ + createDbContext, + runVerify, +})); + +import { dbCommand } from "./index"; + +let log: MockInstance; +let error: MockInstance; + +function output(spy: MockInstance): string { + return spy.mock.calls.map((call) => call.join(" ")).join("\n"); +} + +const local = { + kind: "local" as const, + branch: "projects/p/branches/dev-alice-3f9a2c", + schemaProblems: [], + pendingMigrations: [], + strandedMigrations: [], + ok: true, +}; + +beforeEach(() => { + runVerify.mockReset(); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +describe("appkit db verify", () => { + test("is registered under the db group", () => { + expect(dbCommand.commands.map((command) => command.name())).toContain( + "verify", + ); + }); + + test("a clean branch prints one line and exits 0", async () => { + runVerify.mockResolvedValue({ + ...local, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(output(log)).toContain( + "dev-alice-3f9a2c matches config/database/schema.ts and has every committed migration", + ); + expect(error).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + test("prints every schema problem and pending migration, and exits 1", async () => { + runVerify.mockResolvedValue({ + ...local, + schemaProblems: [ + "table app.notes is missing column body", + "table app.tags does not exist", + ], + pendingMigrations: ["0001_migration", "0002_migration"], + ok: false, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain("Schema: table app.notes is missing column body"); + expect(printed).toContain("Schema: table app.tags does not exist"); + expect(printed).toContain( + "Pending migrations: 0001_migration, 0002_migration", + ); + expect(output(error)).toContain( + "dev-alice-3f9a2c does not match config/database/schema.ts and the committed migrations. Run `appkit db migrate` to apply the pending migrations.", + ); + expect(process.exitCode).toBe(1); + }); + + test("schema problems without pending migrations point to migrate or clean", async () => { + runVerify.mockResolvedValue({ + ...local, + schemaProblems: ["table app.notes is missing column body"], + ok: false, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(output(error)).toContain( + "If you changed schema.ts, run `appkit db migrate`; if the branch was changed by hand, `appkit db clean` and then `appkit db migrate` rebuild it from the migrations.", + ); + expect(process.exitCode).toBe(1); + }); + + test("only pending migrations still exit 1", async () => { + runVerify.mockResolvedValue({ + ...local, + pendingMigrations: ["0001_migration"], + ok: false, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(output(log)).not.toContain("Schema:"); + expect(process.exitCode).toBe(1); + }); + + test("names each migration that never runs and why, and how to rebuild the branch", async () => { + runVerify.mockResolvedValue({ + ...local, + strandedMigrations: [ + { tag: "0001_parallel", reason: "out of order" }, + { tag: "0000_migration", reason: "edited" }, + ], + ok: false, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain( + "Never runs: 0001_parallel: it is older than a migration the branch already ran (merged out of order); the deployed app skips it too once it ran a newer one", + ); + expect(printed).toContain( + "Never runs: 0000_migration: it changed after the branch ran it; restore it from version control and put the change in a new migration", + ); + expect(output(error)).toContain( + "`appkit db clean` and then `appkit db migrate` rebuild the branch from the migrations, in order.", + ); + expect(process.exitCode).toBe(1); + }); + + test("without --prod it checks the .env branch", async () => { + runVerify.mockResolvedValue({ + ...local, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(runVerify).toHaveBeenCalledWith(expect.anything(), { prod: false }); + }); + + describe("--prod", () => { + const deploy = { + kind: "deploy" as const, + branch: "projects/p/branches/production", + app: "my-app", + problems: [], + ok: true, + }; + + test("a running app on the deploy branch prints one line and exits 0", async () => { + runVerify.mockResolvedValue(deploy); + + await dbCommand.parseAsync(["verify", "--prod"], { from: "user" }); + + expect(runVerify).toHaveBeenCalledWith(expect.anything(), { prod: true }); + expect(output(log)).toContain( + "app my-app is running on production (deploy branch): on start it applied its migrations and checked its schema", + ); + expect(error).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + test("lists each problem with its fix, and exits 1", async () => { + runVerify.mockResolvedValue({ + ...deploy, + problems: [ + "app my-app has no postgres resource: `databricks apps deploy` adds the one databricks.yml declares", + "app my-app is crashed: App crashed unexpectedly. `databricks apps logs` shows why; a migration that fails stops the app on start", + ], + ok: false, + }); + + await dbCommand.parseAsync(["verify", "--prod"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain( + "app my-app has no postgres resource: `databricks apps deploy` adds the one databricks.yml declares", + ); + expect(printed).toContain( + "app my-app is crashed: App crashed unexpectedly.", + ); + expect(output(error)).toContain( + "app my-app cannot use production (deploy branch) yet. Fix the above, then run `appkit db verify --prod` again.", + ); + expect(process.exitCode).toBe(1); + }); + + test("without an app in databricks.yml it names the deployed app generically", async () => { + runVerify.mockResolvedValue({ + ...deploy, + app: null, + problems: ["databricks.yml declares no app"], + ok: false, + }); + + await dbCommand.parseAsync(["verify", "--prod"], { from: "user" }); + + expect(output(error)).toContain( + "The deployed app cannot use production (deploy branch) yet.", + ); + }); + }); + + test("an error is printed and exits 1", async () => { + runVerify.mockRejectedValue( + new Error( + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`.", + ), + ); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(output(error)).toContain("No branch configured"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/verify.ts b/packages/shared/src/cli/commands/db/verify.ts new file mode 100644 index 000000000..7fbb83aa3 --- /dev/null +++ b/packages/shared/src/cli/commands/db/verify.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; + +import { fail, printVerifyReport } from "./output"; + +async function runDbVerify(options: { prod?: boolean }): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runVerify(tooling.createDbContext(), { + prod: options.prod === true, + }); + printVerifyReport(report); + if (!report.ok) process.exitCode = 1; + } catch (error) { + fail(error); + } +} + +export const dbVerifyCommand = new Command("verify") + .description( + "Check the .env branch against config/database/schema.ts and the committed migrations (read-only)", + ) + .option( + "--prod", + "check instead that the deployed app is set up to use the deploy branch from databricks.yml", + ) + .addHelpText( + "after", + ` +Reports declared tables or columns the branch lacks (what the app checks on +start), committed migrations the branch has not applied, and migrations it will +never run (merged out of order, or edited after they ran). + +--prod reads only the Databricks API, so it needs no access to the deploy +branch's database: the app exists, its postgres resource names the branch and +database in databricks.yml with CAN_CONNECT_AND_CREATE, its service principal +has a role on the branch, and the app is running. A running app has applied +its migrations and checked its schema on start. + +Exits 1 on any finding. Nothing is written or created. + +Examples: + $ appkit db verify + $ appkit db verify --prod`, + ) + .action(runDbVerify); From 07b1748fc360618736bedfa02f5b9a28aa15d9f7 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 12 Oct 2026 13:45:02 +0100 Subject: [PATCH 6/6] feat(appkit): offer the database plugin in databricks apps init The DatabasePlugin is no longer hidden from `databricks apps init`. The template ships config/database/schema.ts in the app schema and a README section on the development workflow, and the plugin docs describe the flow end to end: `appkit db init` for a branch of your own, `appkit db migrate` while you develop, migrations applied by the deployed app on start, `appkit db verify` to check both sides, and one owner per branch. The template lists drizzle-kit as a dev dependency, since `appkit db init` and `appkit db migrate` generate migrations with it; check-template-deps keeps it at the version AppKit pins. Every scaffolded app gets it, with or without the plugin: `apps init` installs from the template's one committed lockfile with npm ci / pnpm --frozen-lockfile, so the dependency cannot be dropped per app. It is dev-only and adds two deprecation notices on install (drizzle-kit's own @esbuild-kit dependencies). The template's .gitignore skips config/database/*.bak, the copy of schema.ts that `appkit db introspect` keeps. Signed-off-by: ditadi --- docs/docs/plugins/database.md | 87 +- packages/appkit/src/beta.ts | 1 - .../src/plugins/beta-exports.generated.ts | 1 + .../appkit/src/plugins/database/manifest.json | 2 +- packages/shared/src/cli/index.ts | 3 +- template/README.md.tmpl | 39 +- template/_gitignore | 3 + template/appkit.plugins.json | 109 + template/config/database/schema.ts | 5 +- template/package-lock.json | 4647 ++++++++++------- template/package.json | 1 + template/pnpm-lock.yaml | 563 ++ tools/check-template-deps.ts | 17 +- 13 files changed, 3617 insertions(+), 1861 deletions(-) diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 0317bcd5a..fac09510f 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -21,10 +21,9 @@ not support OBO and does not apply per-user or per-row authorization. Every call who can reach the generated API can perform every enabled operation on every exposed row, including delete. -Restrict access to the app and grant its service principal only the database -permissions it needs. If users need different permissions or row ownership checks, -disable the relevant generated routes and implement authorization in custom -server routes. App admission alone does not provide row-level isolation. +Restrict access to the app. If users need different permissions or row ownership +checks, disable the relevant generated routes and implement authorization in +custom server routes. App admission alone does not provide row-level isolation. ::: ## Basic usage @@ -39,9 +38,10 @@ identifier-only messages for known missing-column or missing-table errors details, and hints are omitted because they may contain row values. Apps scaffolded with the Database plugin selected include an empty -`config/database/schema.ts`, so `database()` can start without requiring sample -tables. Replace the empty declaration with your models when their PostgreSQL -tables are ready. +`config/database/schema.ts` in the Postgres schema `app`, and `databricks apps init` +points `.env` at a development branch of your own. Declare your tables, then run +`npx appkit db migrate` to create them there (see +[Development workflow](#development-workflow)). For local development, the PostgreSQL username is resolved from your Databricks credentials when `PGUSER` and `DATABRICKS_CLIENT_ID` are absent. An explicitly @@ -89,6 +89,79 @@ Tables live in the Postgres schema `app` unless the second argument of default is not `public` because the deployed app's service principal creates and owns `app`, but has no `CREATE` privilege on `public`. +## Development workflow + +You develop against a Lakebase branch of your own, never the deployed one. Tables +are created and changed through migrations: generated locally from `schema.ts`, +committed with your code, and applied by the deployed app when it starts. + +| Command | What it does | +| --- | --- | +| `npx appkit db init` | Points `.env` at your branch: a dev branch of yours (`--new dev`) or a 7-day branch for this checkout (`--new session`), forked from the project's default branch. | +| `npx appkit db migrate` | Writes `config/database/migrations/NNNN_*.sql` when `schema.ts` changed, and applies pending migrations to your branch. | +| `npx appkit db introspect` | Writes `schema.ts` from tables that already exist, and a `0000_baseline` migration (or `NNNN_introspect` when migrations already exist). | +| `npx appkit db clean` | Drops the app schema on your branch; `migrate` then rebuilds it. | +| `npx appkit db seed` | Runs `config/database/seed.sql` on your branch. | +| `npx appkit db verify` | Read-only check of your branch: missing tables or columns, pending migrations, and migrations that will never run there. `--prod` checks that the deployed app is set up to use the deploy branch. | + +`databricks apps init` runs `db init` for you. Run it again after cloning, or with +`--new` to switch between your dev and session branches. Without `--new`, it keeps +your dev or session branch when `.env` points at one, and otherwise asks (with +`--yes`, or without a terminal, it uses your dev branch). + +`migrate`, `clean` and `seed` change only branches `db init` created for you (your +dev branch, or this checkout's session branch). On any other branch, including the +project's default branch, a protected branch, or the branch `databricks.yml` +deploys to, `clean` and `seed` refuse and `migrate` only writes the migration files. + +A typical change: + +```bash +# edit config/database/schema.ts +npx appkit db migrate # writes and applies 0001_migration.sql on your branch +git add config/database # commit schema.ts with its migrations +databricks apps deploy # the deployed app applies 0001 on start +``` + +### Migrations on deploy + +When the deployed app starts, `database()` applies the committed migrations the +deploy branch has not recorded yet, before it checks the tables and serves +requests. They run as the app's service principal, one instance at a time, in one +transaction. If one fails, none are applied and the app does not start; the server +log names the migration and the SQLSTATE, or the missing migration file. A +migration that waits more than 30 seconds for a table lock, for example behind a +long transaction of the running app, fails instead of blocking that table. Local +runs (`npm run dev`) never apply migrations: use `npx appkit db migrate`. + +A `0000_baseline` from `introspect` is recorded without running when its tables +already exist on the deploy branch and no migration was ever applied there. If +only some of them exist, the app does not start, and nothing is changed. + +### One owner per branch + +Each branch has one owner of the app schema, and only the owner can change its +tables: + +- On the deploy branch, the app's service principal creates schema `app` on its + first start and owns everything in it. The `postgres` resource's + `CAN_CONNECT_AND_CREATE` permission is all it needs; no grants are required. +- On your dev or session branch, you own it. A fork copies its parent's objects + with their owners, so `db init` hands them over to you. + +Do not apply migrations to the deploy branch yourself: you would own those objects, +and the app could no longer change them. If schema `app` on the deploy branch +already belongs to someone else, the app cannot migrate it. Use another +`schemaName` in `defineSchema()`, or have the owner transfer the schema. + +`npx appkit db verify --prod` checks, through the Databricks API, that the deployed +app is set up to use the deploy branch: the app exists, its `postgres` resource +names the branch and database in `databricks.yml` with `CAN_CONNECT_AND_CREATE`, its +service principal has a role on the branch, and the app is running. A running app +has applied its migrations and checked its schema on start; otherwise the report +names `databricks apps logs`, `start` or `deploy`. It needs no access to the deploy +branch's database and changes nothing. + ## Schema discovery and overrides `database()` and `database({})` use the same defaults. During setup, the plugin diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 03d574653..ca75d8272 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -121,7 +121,6 @@ export type { SearchResult, } from "./plugins/ai-search/types"; export * from "./plugins/beta-exports.generated"; -export { database } from "./plugins/database"; export type { DatabaseApiConfig, DatabaseApiWriteOperation, diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 7e556ebd9..10982785b 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -7,3 +7,4 @@ export { agents } from "./agents"; export { aiSearch } from "./ai-search"; +export { database } from "./database"; diff --git a/packages/appkit/src/plugins/database/manifest.json b/packages/appkit/src/plugins/database/manifest.json index 111a7b6be..fc665fe97 100644 --- a/packages/appkit/src/plugins/database/manifest.json +++ b/packages/appkit/src/plugins/database/manifest.json @@ -4,7 +4,6 @@ "displayName": "Database (Beta)", "description": "Schema-driven typed access to Databricks Lakebase PostgreSQL", "stability": "beta", - "hidden": true, "resources": { "required": [ { @@ -17,6 +16,7 @@ "project": { "description": "Lakebase project resource name", "examples": ["projects/{project-id}"], + "bundleIgnore": true, "discovery": { "type": "kind", "resourceKind": "postgres_project", diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index 2fce08005..55bd50f9e 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -36,8 +36,7 @@ cmd.addCommand(docsCommand); cmd.addCommand(pluginCommand); cmd.addCommand(codemodCommand); cmd.addCommand(doctorCommand); -// Hidden from --help until the database plugin is offered by `databricks apps init`. -cmd.addCommand(dbCommand, { hidden: true }); +cmd.addCommand(dbCommand); // Registry commands are executable but hidden from --help while the feature // is still in development (registry + add work end-to-end but aren't announced). cmd.addCommand(registryCommand, { hidden: true }); diff --git a/template/README.md.tmpl b/template/README.md.tmpl index c21dc1cc0..af8b67b34 100644 --- a/template/README.md.tmpl +++ b/template/README.md.tmpl @@ -49,13 +49,33 @@ The Lakebase plugin requires additional environment variables for PostgreSQL con {{- if .plugins.database}} -#### Database schema +#### Database -`database()` loads `config/database/schema.ts`. The generated schema starts empty, -so initialization does not require sample tables or expose existing data. -Add your table declarations after creating the corresponding PostgreSQL tables; -AppKit does not create or migrate them. CRUD routes are enabled for declared tables -by default, and can be restricted with `api` in the plugin configuration. +`database()` loads `config/database/schema.ts`, whose tables live in the Postgres +schema `app`. CRUD routes are enabled for declared tables by default, and can be +restricted with `api` in the plugin configuration. + +Local development runs against a Lakebase branch of your own, never the deployed one. +`databricks apps init` set it up by running `npx appkit db init`; run it again at any +time (for example after cloning) to point `.env` at your branch: + +```bash +npx appkit db init # keep or pick your branch, then set up its schema +npx appkit db init --new dev # your own branch, forked from the default branch +npx appkit db init --new session # a branch for this checkout that expires after 7 days +``` + +To change the schema, edit `config/database/schema.ts`, then: + +```bash +npx appkit db migrate # writes config/database/migrations/NNNN_*.sql and applies it to your branch +``` + +Commit the migrations. To adopt tables that already exist, run `npx appkit db introspect`: +it writes `schema.ts` from the branch's tables, and a `0000_baseline` migration (or +`NNNN_introspect` when migrations already exist). `npx appkit db clean` drops the app +schema on your branch, and `npx appkit db seed` runs `config/database/seed.sql` on it; +both refuse any branch `db init` did not create for you, such as the deployed one. {{- end}} ### CLI Authentication @@ -196,10 +216,15 @@ databricks apps deploy -t prod * server.ts # Server entry point * routes/ # Routes * shared/ # Shared types -{{- if .plugins.analytics}} +{{- if or .plugins.analytics .plugins.database}} * config/ # Configuration +{{- end}} +{{- if .plugins.analytics}} * queries/ # SQL query files {{- end}} +{{- if .plugins.database}} + * database/ # schema.ts and its migrations +{{- end}} * databricks.yml # Bundle configuration * app.yaml # App configuration * .env.example # Environment variables example diff --git a/template/_gitignore b/template/_gitignore index 29895e7c0..870cd2b9e 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -9,6 +9,9 @@ build/ test-results/ playwright-report/ +# `appkit db introspect` keeps the previous schema.ts here +config/database/*.bak + # Auto-generated types (endpoint-specific, varies per developer) shared/appkit-types/serving.d.ts diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index db53b96b7..a122753a5 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -196,6 +196,115 @@ } } }, + "database": { + "name": "database", + "displayName": "Database (Beta)", + "description": "Schema-driven typed access to Databricks Lakebase PostgreSQL", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Postgres", + "resourceKey": "postgres", + "description": "Lakebase Postgres database for persistent storage", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "project": { + "description": "Lakebase project resource name", + "bundleIgnore": true, + "examples": [ + "projects/{project-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_project", + "select": "name" + }, + "origin": "user" + }, + "branch": { + "description": "Lakebase branch resource name", + "examples": [ + "projects/{project-id}/branches/{branch-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_branch", + "select": "name", + "dependsOn": "project" + }, + "origin": "user" + }, + "database": { + "description": "Lakebase database resource name", + "examples": [ + "projects/{project-id}/branches/{branch-id}/databases/{database-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_database", + "select": "name", + "dependsOn": "branch" + }, + "origin": "user" + }, + "host": { + "env": "PGHOST", + "description": "Postgres host", + "localOnly": true, + "resolve": "postgres:host", + "origin": "platform" + }, + "databaseName": { + "env": "PGDATABASE", + "description": "Postgres database name", + "localOnly": true, + "resolve": "postgres:databaseName", + "origin": "platform" + }, + "endpointPath": { + "env": "LAKEBASE_ENDPOINT", + "description": "Lakebase endpoint resource name", + "bundleIgnore": true, + "resolve": "postgres:endpointPath", + "origin": "cli" + }, + "port": { + "env": "PGPORT", + "description": "Postgres port", + "localOnly": true, + "value": "5432", + "origin": "platform" + }, + "sslmode": { + "env": "PGSSLMODE", + "description": "Postgres SSL mode", + "localOnly": true, + "value": "require", + "origin": "platform" + } + }, + "appOnly": true, + "binding": { + "yamlKey": "postgres", + "varFields": [ + [ + "branch", + "branch" + ], + [ + "database", + "database" + ] + ] + } + } + ], + "optional": [] + }, + "stability": "beta" + }, "files": { "name": "files", "displayName": "Files Plugin", diff --git a/template/config/database/schema.ts b/template/config/database/schema.ts index c0f6dc1e3..19163eae6 100644 --- a/template/config/database/schema.ts +++ b/template/config/database/schema.ts @@ -1,7 +1,8 @@ {{if .plugins.database -}} import { defineSchema } from '@databricks/appkit/beta'; -// database() discovers this file automatically. Start with no exposed tables. -// Add declarations here after creating the corresponding PostgreSQL tables. +// database() discovers this file automatically. Edit tables here, then run +// `npx appkit db migrate` on your dev branch. The deployed app's service principal +// owns schema "app" and applies committed migrations on start. export const schema = defineSchema(() => ({})); {{- end}} diff --git a/template/package-lock.json b/template/package-lock.json index d26af5237..c94498a74 100644 --- a/template/package-lock.json +++ b/template/package-lock.json @@ -39,6 +39,7 @@ "@typescript-eslint/parser": "8.48.0", "@vitejs/plugin-react": "5.0.4", "autoprefixer": "10.4.21", + "drizzle-kit": "0.31.10", "eslint": "9.39.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-react": "7.37.5", @@ -855,6 +856,13 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "license": "MIT" }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -886,425 +894,861 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", "cpu": [ - "arm64" + "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "netbsd" + "openbsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openharmony-arm64": { + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ - "arm64" + "ppc64" ], "license": "MIT", "optional": true, "os": [ - "openharmony" + "aix" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { + "node_modules/@esbuild/android-arm": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ - "x64" + "arm" ], "license": "MIT", "optional": true, "os": [ - "sunos" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/win32-arm64": { + "node_modules/@esbuild/android-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/win32-ia32": { + "node_modules/@esbuild/android-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ - "ia32" + "x64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/win32-x64": { + "node_modules/@esbuild/darwin-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ], "engines": { "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", @@ -5240,7 +5684,175 @@ "license": "MPL-2.0", "optional": true, "os": [ - "android" + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" ], "engines": { "node": ">= 12.0.0" @@ -5250,10 +5862,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", "cpu": [ "arm64" ], @@ -5261,7 +5873,7 @@ "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">= 12.0.0" @@ -5271,10 +5883,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", "cpu": [ "x64" ], @@ -5282,7 +5894,7 @@ "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">= 12.0.0" @@ -5292,1393 +5904,1685 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">= 10" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", "cpu": [ "arm" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">= 10" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">= 10" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.6.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.6.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", "dev": true, - "license": "MPL-2.0", + "inBundle": true, + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", "dev": true, - "license": "MPL-2.0", + "inBundle": true, + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">= 10" } }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">= 10" } }, - "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "node_modules/@tailwindcss/postcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", + "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "postcss": "^8.4.41", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "dev": true, "license": "MIT" }, - "node_modules/@tailwindcss/oxide": { + "node_modules/@tailwindcss/vite": { "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 10" + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", - "cpu": [ - "arm64" - ], + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, "engines": { - "node": ">= 10" + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 10" + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", - "cpu": [ - "x64" - ], + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.162", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.162.tgz", + "integrity": "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@babel/types": "^7.28.2" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", - "cpu": [ - "x64" - ], + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@types/connect": "*", + "@types/node": "*" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", - "cpu": [ - "arm" - ], - "dev": true, + "node_modules/@types/bunyan": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.11.tgz", + "integrity": "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@types/node": "*" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "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==", + "devOptional": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@types/node": "*" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", - "cpu": [ - "x64" - ], + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", - "cpu": [ - "x64" - ], + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/memcached": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz", + "integrity": "sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==", "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" + "@types/node": "*" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.6.0", + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true, - "inBundle": true, + "license": "MIT" + }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" + "@types/node": "*" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.6.0", - "dev": true, - "inBundle": true, + "node_modules/@types/node": { + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", + "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "undici-types": "~7.13.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, + "node_modules/@types/oracledb": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.5.2.tgz", + "integrity": "sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==", "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@types/node": "*" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "dev": true, - "inBundle": true, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@types/pg": "*" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true + "license": "MIT" }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", - "cpu": [ - "arm64" - ], + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } + "license": "MIT" }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "devOptional": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "csstype": "^3.0.2" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", - "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", - "dev": true, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "devOptional": true, "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "postcss": "^8.4.41", - "tailwindcss": "4.1.17" + "peerDependencies": { + "@types/react": "^19.2.0" } }, - "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "dev": true, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "license": "MIT" }, - "node_modules/@tailwindcss/vite": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", - "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "tailwindcss": "4.1.17" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "@types/node": "*" } }, - "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "dev": true, - "license": "MIT" - }, - "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", "license": "MIT", "dependencies": { - "@tanstack/table-core": "8.21.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" } }, - "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@types/node": "*" } }, - "node_modules/@types/aws-lambda": { - "version": "8.10.162", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.162.tgz", - "integrity": "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==", - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } + "optional": true }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.0.0" + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@typescript-eslint/parser": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/connect": "*", - "@types/node": "*" + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/bunyan": { - "version": "1.8.11", - "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.11.tgz", - "integrity": "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.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==", - "devOptional": true, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/command-line-args": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", - "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", - "license": "MIT" - }, - "node_modules/@types/command-line-usage": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", - "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", - "license": "MIT" - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^1" + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/memcached": { - "version": "2.2.10", - "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz", - "integrity": "sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==", "license": "MIT", "dependencies": { - "@types/node": "*" + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "node_modules/@vitejs/plugin-react": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", + "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.38", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@types/node": { - "version": "24.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", - "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.13.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/oracledb": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.5.2.tgz", - "integrity": "sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==", + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "devOptional": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "devOptional": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "devOptional": true, "license": "MIT", "dependencies": { - "@types/pg": "*" + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", - "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "devOptional": true, "license": "MIT", "dependencies": { - "csstype": "^3.0.2" + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/react-dom": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", - "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "devOptional": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "devOptional": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" } }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" } }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "license": "MIT", - "dependencies": { - "@types/node": "*" + "peerDependencies": { + "acorn": "^8" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", - "optional": true + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", - "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", - "dev": true, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/type-utils": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">= 14" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", - "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", - "dev": true, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">=8" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", - "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", - "dev": true, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.0", - "@typescript-eslint/types": "^8.48.0", - "debug": "^4.3.4" + "color-convert": "^2.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=8" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/apache-arrow": { + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", + "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^24.0.3", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^25.1.24", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "bin": { + "arrow2csv": "bin/arrow2csv.js" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", - "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", - "dev": true, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" + "tslib": "^2.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=10" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", - "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", - "dev": true, + "node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node": ">=12.17" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", - "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", - "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", - "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.0", - "@typescript-eslint/tsconfig-utils": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", - "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", - "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "eslint-visitor-keys": "^4.2.1" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", - "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.4", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.38", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 0.4" }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "devOptional": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=12" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "devOptional": true, + "node_modules/ast-kit": { + "version": "3.0.0-beta.1", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz", + "integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==", + "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "pathe": "^2.0.3" }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "engines": { + "node": ">=20.19.0" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "devOptional": true, + "node_modules/ast-kit/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.2.tgz", + "integrity": "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ==", + "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "devOptional": true, + "node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.2.tgz", + "integrity": "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/parser": { + "version": "8.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.2.tgz", + "integrity": "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ==", + "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" + "@babel/types": "^8.0.0-rc.2" }, - "funding": { - "url": "https://opencollective.com/vitest" + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "devOptional": true, + "node_modules/ast-kit/node_modules/@babel/types": { + "version": "8.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.2.tgz", + "integrity": "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==", + "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "@babel/helper-string-parser": "^8.0.0-rc.2", + "@babel/helper-validator-identifier": "^8.0.0-rc.2" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "devOptional": true, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 0.4" } }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "devOptional": true, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "possible-typed-array-names": "^1.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", "bin": { - "acorn": "bin/acorn" + "baseline-browser-mapping": "dist/cli.cjs" }, "engines": { - "node": ">=0.4.0" + "node": ">=6.0.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", - "peerDependencies": { - "acorn": "^8" + "engines": { + "node": "*" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/birpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", + "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, "engines": { - "node": ">= 14" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "ms": "2.0.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ansis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", - "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", - "license": "ISC", - "engines": { - "node": ">=14" + "balanced-match": "^1.0.0" } }, - "node_modules/apache-arrow": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", - "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", - "license": "Apache-2.0", + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/command-line-args": "^5.2.3", - "@types/command-line-usage": "^5.0.4", - "@types/node": "^24.0.3", - "command-line-args": "^6.0.1", - "command-line-usage": "^7.0.1", - "flatbuffers": "^25.1.24", - "json-bignum": "^0.0.3", - "tslib": "^2.6.2" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { - "arrow2csv": "bin/arrow2csv.js" + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, "engines": { - "node": ">=10" + "node": ">= 0.8" } }, - "node_modules/array-back": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", - "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12.17" + "node": ">=8" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -6687,27 +7591,27 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -6716,422 +7620,384 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "devOptional": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" + "chalk": "^4.1.2" }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" + "clsx": "^2.1.1" }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://polar.sh/cva" } }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "devOptional": true, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=6" } }, - "node_modules/ast-kit": { - "version": "3.0.0-beta.1", - "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz", - "integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==", - "dev": true, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", "license": "MIT", "dependencies": { - "@babel/parser": "^8.0.0-beta.4", - "estree-walker": "^3.0.3", - "pathe": "^2.0.3" - }, - "engines": { - "node": ">=20.19.0" + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" }, - "funding": { - "url": "https://github.com/sponsors/sxzz" + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/ast-kit/node_modules/@babel/helper-string-parser": { - "version": "8.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.2.tgz", - "integrity": "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ==", - "dev": true, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=7.0.0" } }, - "node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.2.tgz", - "integrity": "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, - "node_modules/ast-kit/node_modules/@babel/parser": { - "version": "8.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.2.tgz", - "integrity": "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ==", - "dev": true, + "node_modules/command-line-args": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.1.tgz", + "integrity": "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==", "license": "MIT", "dependencies": { - "@babel/types": "^8.0.0-rc.2" - }, - "bin": { - "parser": "bin/babel-parser.js" + "array-back": "^6.2.2", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } } }, - "node_modules/ast-kit/node_modules/@babel/types": { - "version": "8.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.2.tgz", - "integrity": "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==", - "dev": true, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^8.0.0-rc.2", - "@babel/helper-validator-identifier": "^8.0.0-rc.2" + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=12.20.0" } }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" + "safe-buffer": "5.2.1" }, "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">= 0.6" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "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==", + "devOptional": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=6.0.0" + "node": ">= 8" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", "engines": { - "node": "*" + "node": ">= 12" } }, - "node_modules/birpc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", - "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { - "url": "https://github.com/sponsors/antfu" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "2.0.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" + "ms": "^2.1.3" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -7140,27 +8006,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -7169,483 +8024,598 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.8" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "devOptional": true, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "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": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=8" } }, - "node_modules/chalk-template": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", - "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", - "license": "MIT", + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "chalk": "^4.1.2" + "esutils": "^2.0.2" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/chalk-template?sponsor=1" + "node": ">=0.10.0" } }, - "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "license": "MIT" + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" }, "funding": { - "url": "https://polar.sh/cva" + "url": "https://dotenvx.com" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/cmdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", - "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-id": "^1.1.0", - "@radix-ui/react-primitive": "^2.0.2" - }, - "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "react-dom": "^18 || ^19 || ^19.0.0-rc" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=7.0.0" + "node": ">=18" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/command-line-args": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.1.tgz", - "integrity": "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==", + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "find-replace": "^5.0.2", - "lodash.camelcase": "^4.3.0", - "typical": "^7.2.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12.20" - }, - "peerDependencies": { - "@75lb/nature": "latest" - }, - "peerDependenciesMeta": { - "@75lb/nature": { - "optional": true - } + "node": ">=18" } }, - "node_modules/command-line-usage": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", - "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "chalk-template": "^0.4.0", - "table-layout": "^4.1.0", - "typical": "^7.1.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12.20.0" + "node": ">=18" } }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.6" + "node": ">=18" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.6" + "node": ">=18" } }, - "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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.6" + "node": ">=18" } }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 12" + "node": ">=18" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" + "node": ">=18" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/date-fns-jalali": { - "version": "4.1.0-0", - "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", - "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" } }, - "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", + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "node_modules/drizzle-kit/node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "esutils": "^2.0.2" + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", - "license": "(MPL-2.0 OR Apache-2.0)", + "node": ">=18.0.0" + }, "optionalDependencies": { - "@types/trusted-types": "^2.0.7" + "fsevents": "~2.3.3" } }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", + "node_modules/drizzle-kit/node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=12" + "node": ">=18" }, - "funding": { - "url": "https://dotenvx.com" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/drizzle-orm": { @@ -8893,6 +9863,20 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -11973,6 +12957,16 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -11982,6 +12976,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -12842,20 +13847,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -13561,20 +14552,6 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitest": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", diff --git a/template/package.json b/template/package.json index e126d8649..625f607c8 100644 --- a/template/package.json +++ b/template/package.json @@ -59,6 +59,7 @@ "@typescript-eslint/parser": "8.48.0", "@vitejs/plugin-react": "5.0.4", "autoprefixer": "10.4.21", + "drizzle-kit": "0.31.10", "eslint": "9.39.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-react": "7.37.5", diff --git a/template/pnpm-lock.yaml b/template/pnpm-lock.yaml index d169a07cc..87f86f8d7 100644 --- a/template/pnpm-lock.yaml +++ b/template/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: autoprefixer: specifier: 10.4.21 version: 10.4.21(postcss@8.5.28) + drizzle-kit: + specifier: 0.31.10 + version: 0.31.10 eslint: specifier: 9.39.1 version: 9.39.1(jiti@2.6.1) @@ -369,6 +372,9 @@ packages: '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -378,162 +384,458 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2607,6 +2909,9 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2801,6 +3106,10 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + drizzle-orm@0.45.2: resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: @@ -3000,11 +3309,21 @@ packages: resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -4318,6 +4637,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -4479,6 +4805,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -5106,6 +5437,8 @@ snapshots: '@date-fns/tz@1.5.0': {} + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -5122,84 +5455,238 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.3 + '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.1(jiti@2.6.1))': dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -7479,6 +7966,8 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} + bytes@3.1.2: {} cac@6.7.14: {} @@ -7653,6 +8142,13 @@ snapshots: dotenv@16.6.1: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.15 + drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(pg@8.18.0): optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -7829,6 +8325,31 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -7858,6 +8379,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -9232,6 +9782,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + split2@4.2.0: {} stackback@0.0.2: {} @@ -9398,6 +9955,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tw-animate-css@1.4.0: {} type-check@0.4.0: diff --git a/tools/check-template-deps.ts b/tools/check-template-deps.ts index f6f1811ec..f659aed09 100644 --- a/tools/check-template-deps.ts +++ b/tools/check-template-deps.ts @@ -2,7 +2,8 @@ /** * Validates that all dependencies in template/package.json use exact versions * (no ^, ~, >=, * prefixes) AND that a small allowlist of version-sensitive - * runtime deps matches the version AppKit was built against. + * deps matches the version AppKit was built against: zod (below) and + * drizzle-kit, whose migration snapshot and SQL format `appkit db` reads. * * The pin check is the supply-chain guard: template sync writes through to * scaffolded apps verbatim, so a `^4.x` here would let npm install resolve to @@ -68,17 +69,21 @@ if (unpinned.length) { // Cross-version check against AppKit. Limited to deps whose types leak // through AppKit's public API surface — bumping any of these in AppKit // without bumping the template here produces a confusing tsc error in -// scaffolded apps (e.g. "ZodObject is missing 'toJSONSchema' from ZodType"). -const SYNCED_DEPS = ["zod"]; -for (const dep of SYNCED_DEPS) { +// scaffolded apps (e.g. "ZodObject is missing 'toJSONSchema' from ZodType") — +// and to drizzle-kit, whose snapshot and SQL format `appkit db` is pinned to. +const SYNCED_DEPS: Record = { + zod: "types resolved through @databricks/appkit match the version installed in scaffolded apps", + "drizzle-kit": + "scaffolded apps generate migrations in the format `appkit db` reads", +}; +for (const [dep, reason] of Object.entries(SYNCED_DEPS)) { const templateVer = templateDeps[dep]; const appkitVer = appkitDeps[dep]; if (templateVer && appkitVer && templateVer !== appkitVer) { errors.push( `Version skew on ${dep}: template pins ${templateVer}, ` + `packages/appkit pins ${appkitVer}. Update template/package.json ` + - `so types resolved through @databricks/appkit match the version ` + - `installed in scaffolded apps.`, + `so ${reason}.`, ); } }