From 2f3c50c99c7594090e36aea1868fdafecc9848d3 Mon Sep 17 00:00:00 2001 From: Joe Thom Date: Fri, 17 Jul 2026 08:33:24 -0700 Subject: [PATCH 1/3] Add Replit Drizzle Kit compatibility --- drizzle-kit/package.json | 14 + drizzle-kit/scripts/build.ts | 14 + drizzle-kit/scripts/prepare-replit-package.ts | 20 + drizzle-kit/src/ext/api.ts | 820 ++++++++++-------- drizzle-kit/tests/other/replit-api.test.ts | 179 ++++ 5 files changed, 702 insertions(+), 345 deletions(-) create mode 100644 drizzle-kit/scripts/prepare-replit-package.ts create mode 100644 drizzle-kit/tests/other/replit-api.test.ts diff --git a/drizzle-kit/package.json b/drizzle-kit/package.json index 8a887914b3..de75985cd9 100644 --- a/drizzle-kit/package.json +++ b/drizzle-kit/package.json @@ -47,7 +47,9 @@ "build:ext": "rm -rf ./dist && vitest run bin.test && vitest run ./tests/postgres/ && vitest run ./tests/sqlite && vitest run ./tests/mysql && tsx build.ext.ts", "pack": "cp package.json README.md dist/ && (cd dist && npm pack --pack-destination ..) && rm -f package.tgz && mv *.tgz package.tgz", "pack:artifact": "pnpm run pack", + "pack:replit": "pnpm run build && tsx scripts/prepare-replit-package.ts && (cd dist && npm pack --pack-destination ..) && rm -f package.tgz && mv *.tgz package.tgz", "publish": "npm publish package.tgz", + "publish:replit": "pnpm run pack:replit && npm publish package.tgz --provenance=false", "test:postgres": "vitest run ./postgres/", "test:other": "vitest run ./mysql/ ./sqlite/ ./other", "test:cockroach": "vitest run ./cockroach", @@ -141,6 +143,18 @@ "types": "./index.d.mts", "default": "./index.mjs" }, + "./api": { + "import": { + "types": "./api.d.mts", + "default": "./api.mjs" + }, + "require": { + "types": "./api.d.ts", + "default": "./api.js" + }, + "types": "./api.d.mts", + "default": "./api.mjs" + }, "./api-postgres": { "import": { "types": "./api-postgres.d.mts", diff --git a/drizzle-kit/scripts/build.ts b/drizzle-kit/scripts/build.ts index 9f02ac80e1..34354541d5 100644 --- a/drizzle-kit/scripts/build.ts +++ b/drizzle-kit/scripts/build.ts @@ -115,6 +115,7 @@ async function buildDeclarations() { await tsdown({ entry: { + api: './src/ext/api.ts', 'api-postgres': './src/ext/api-postgres.ts', 'api-mysql': './src/ext/api-mysql.ts', 'api-sqlite': './src/ext/api-sqlite.ts', @@ -152,6 +153,7 @@ async function copyDeclarationsAndCleanTemp() { async function postProcessApiFiles() { const apiFiles = [ + 'dist/api.js', 'dist/api-postgres.js', 'dist/api-mysql.js', 'dist/api-sqlite.js', @@ -188,6 +190,18 @@ async function main() { outputName: 'index.mjs', format: 'esm', }), + buildBundle({ + name: 'api-cjs', + input: './src/ext/api.ts', + outputName: 'api.js', + format: 'cjs', + }), + buildBundle({ + name: 'api-esm', + input: './src/ext/api.ts', + outputName: 'api.mjs', + format: 'esm', + }), buildBundle({ name: 'api-postgres-cjs', input: './src/ext/api-postgres.ts', diff --git a/drizzle-kit/scripts/prepare-replit-package.ts b/drizzle-kit/scripts/prepare-replit-package.ts new file mode 100644 index 0000000000..6b74966bd8 --- /dev/null +++ b/drizzle-kit/scripts/prepare-replit-package.ts @@ -0,0 +1,20 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +async function main() { + const packagePath = 'dist/package.json'; + const parsed: unknown = JSON.parse(await readFile(packagePath, 'utf8')); + + if (typeof parsed !== 'object' || parsed === null || !('name' in parsed) || parsed.name !== 'drizzle-kit') { + throw new Error('Expected the built drizzle-kit package manifest'); + } + + await writeFile( + packagePath, + `${JSON.stringify({ ...parsed, name: '@drizzle-team/drizzle-kit' }, null, '\t')}\n`, + ); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/drizzle-kit/src/ext/api.ts b/drizzle-kit/src/ext/api.ts index 52dfb8dfb1..d4fea3ca98 100644 --- a/drizzle-kit/src/ext/api.ts +++ b/drizzle-kit/src/ext/api.ts @@ -1,345 +1,475 @@ -// import { LibSQLDatabase } from 'drizzle-orm/libsql'; -// import type { MySql2Database } from 'drizzle-orm/mysql2'; -// import { PgDatabase } from 'drizzle-orm/pg-core'; -// import { SingleStoreDriverDatabase } from 'drizzle-orm/singlestore'; -// import { introspect as postgresIntrospect } from '../cli/commands/pull-postgres'; -// import { sqliteIntrospect } from '../cli/commands/pull-sqlite'; -// import { suggestions } from '../cli/commands/push-postgres'; -// import { updateUpToV6 as upPgV6, updateUpToV7 as upPgV7 } from '../cli/commands/up-postgres'; -// import { resolver } from '../cli/prompts'; -// import type { CasingType } from '../cli/validations/common'; -// import { ProgressView, schemaError, schemaWarning } from '../cli/views'; -// import { fromDrizzleSchema, fromExports } from '../dialects/postgres/drizzle'; -// import { PostgresSnapshot, toJsonSnapshot } from '../dialects/postgres/snapshot'; -// import type { Config } from '../index'; -// import { originUUID } from '../utils'; -// import type { DB, SQLiteDB } from '../utils'; -// import { getTablesFilterByExtensions } from './extensions/getTablesFilterByExtensions'; - -// import * as postgres from './api-postgres'; - -// SQLite - -// TODO commented this because of build error -// export const generateSQLiteDrizzleJson = async ( -// imports: Record, -// prevId?: string, -// casing?: CasingType, -// ): Promise => { -// const { prepareFromExports } = await import('./dialects/sqlite/imports'); - -// const prepared = prepareFromExports(imports); - -// const id = randomUUID(); - -// const snapshot = fromDrizzleSchema(prepared.tables, prepared.views, casing); - -// return { -// ...snapshot, -// id, -// prevId: prevId ?? originUUID, -// }; -// }; - -// export const generateSQLiteMigration = async ( -// prev: DrizzleSQLiteSnapshotJSON, -// cur: DrizzleSQLiteSnapshotJSON, -// ) => { -// const { applySqliteSnapshotsDiff } = await import('./dialects/sqlite/diff'); - -// const validatedPrev = sqliteSchema.parse(prev); -// const validatedCur = sqliteSchema.parse(cur); - -// const squashedPrev = squashSqliteScheme(validatedPrev); -// const squashedCur = squashSqliteScheme(validatedCur); - -// const { sqlStatements } = await applySqliteSnapshotsDiff( -// squashedPrev, -// squashedCur, -// tablesResolver, -// columnsResolver, -// sqliteViewsResolver, -// validatedPrev, -// validatedCur, -// ); - -// return sqlStatements; -// }; - -// export const pushSQLiteSchema = async ( -// imports: Record, -// drizzleInstance: LibSQLDatabase, -// ) => { -// const { applySqliteSnapshotsDiff } = await import('./dialects/sqlite/diff'); -// const { sql } = await import('drizzle-orm'); - -// const db: SQLiteDB = { -// query: async (query: string, params?: any[]) => { -// const res = drizzleInstance.all(sql.raw(query)); -// return res; -// }, -// run: async (query: string) => { -// return Promise.resolve(drizzleInstance.run(sql.raw(query))).then( -// () => {}, -// ); -// }, -// }; - -// const cur = await generateSQLiteDrizzleJson(imports); -// const progress = new ProgressView( -// 'Pulling schema from database...', -// 'Pulling schema from database...', -// ); - -// const { schema: prev } = await sqliteIntrospect(db, [], progress); - -// const validatedPrev = sqliteSchema.parse(prev); -// const validatedCur = sqliteSchema.parse(cur); - -// const squashedPrev = squashSqliteScheme(validatedPrev, 'push'); -// const squashedCur = squashSqliteScheme(validatedCur, 'push'); - -// const { statements, _meta } = await applySqliteSnapshotsDiff( -// squashedPrev, -// squashedCur, -// tablesResolver, -// columnsResolver, -// sqliteViewsResolver, -// validatedPrev, -// validatedCur, -// 'push', -// ); - -// const { shouldAskForApprove, statementsToExecute, infoToPrint } = await logSuggestionsAndReturn( -// db, -// statements, -// squashedPrev, -// squashedCur, -// _meta!, -// ); - -// return { -// hasDataLoss: shouldAskForApprove, -// warnings: infoToPrint, -// statementsToExecute, -// apply: async () => { -// for (const dStmnt of statementsToExecute) { -// await db.query(dStmnt); -// } -// }, -// }; -// }; - -// MySQL -// TODO commented this because of build error -// export const generateMySQLDrizzleJson = async ( -// imports: Record, -// prevId?: string, -// casing?: CasingType, -// ): Promise => { -// const { prepareFromExports } = await import('./serializer/mysqlImports'); - -// const prepared = prepareFromExports(imports); - -// const id = randomUUID(); - -// const snapshot = generateMySqlSnapshot(prepared.tables, prepared.views, casing); - -// return { -// ...snapshot, -// id, -// prevId: prevId ?? originUUID, -// }; -// }; - -// export const generateMySQLMigration = async ( -// prev: DrizzleMySQLSnapshotJSON, -// cur: DrizzleMySQLSnapshotJSON, -// ) => { -// const { ddlDiff: applyMysqlSnapshotsDiff } = await import('./dialects/mysql/mysql'); - -// const validatedPrev = mysqlSchema.parse(prev); -// const validatedCur = mysqlSchema.parse(cur); - -// const squashedPrev = squashMysqlScheme(validatedPrev); -// const squashedCur = squashMysqlScheme(validatedCur); - -// const { sqlStatements } = await applyMysqlSnapshotsDiff( -// squashedPrev, -// squashedCur, -// tablesResolver, -// columnsResolver, -// mySqlViewsResolver, -// uniqueResolver, -// validatedPrev, -// validatedCur, -// ); - -// return sqlStatements; -// }; - -// export const pushMySQLSchema = async ( -// imports: Record, -// drizzleInstance: MySql2Database, -// databaseName: string, -// ) => { -// const { ddlDiff: applyMysqlSnapshotsDiff } = await import('./dialects/mysql/mysql'); -// const { logSuggestionsAndReturn } = await import( -// './cli/commands/mysqlPushUtils' -// ); -// const { mysqlPushIntrospect } = await import( -// './cli/commands/pull-mysql' -// ); -// const { sql } = await import('drizzle-orm'); - -// const db: DB = { -// query: async (query: string, params?: any[]) => { -// const res = await drizzleInstance.execute(sql.raw(query)); -// return res[0] as unknown as any[]; -// }, -// }; -// const cur = await generateMySQLDrizzleJson(imports); -// const { schema: prev } = await mysqlPushIntrospect(db, databaseName, []); - -// const validatedPrev = mysqlSchema.parse(prev); -// const validatedCur = mysqlSchema.parse(cur); - -// const squashedPrev = squashMysqlScheme(validatedPrev); -// const squashedCur = squashMysqlScheme(validatedCur); - -// const { statements } = await applyMysqlSnapshotsDiff( -// squashedPrev, -// squashedCur, -// tablesResolver, -// columnsResolver, -// mySqlViewsResolver, -// uniqueResolver, -// validatedPrev, -// validatedCur, -// 'push', -// ); - -// const { shouldAskForApprove, statementsToExecute, infoToPrint } = await logSuggestionsAndReturn( -// db, -// statements, -// validatedCur, -// ); - -// return { -// hasDataLoss: shouldAskForApprove, -// warnings: infoToPrint, -// statementsToExecute, -// apply: async () => { -// for (const dStmnt of statementsToExecute) { -// await db.query(dStmnt); -// } -// }, -// }; -// }; - -// SingleStore -// TODO commented this because of build error -// export const generateSingleStoreDrizzleJson = async ( -// imports: Record, -// prevId?: string, -// casing?: CasingType, -// ): Promise => { -// const { prepareFromExports } = await import('./serializer/singlestoreImports'); - -// const prepared = prepareFromExports(imports); - -// const id = randomUUID(); - -// const snapshot = generateSingleStoreSnapshot(prepared.tables, /* prepared.views, */ casing); - -// return { -// ...snapshot, -// id, -// prevId: prevId ?? originUUID, -// }; -// }; - -// export const generateSingleStoreMigration = async ( -// prev: DrizzleSingleStoreSnapshotJSON, -// cur: DrizzleSingleStoreSnapshotJSON, -// ) => { -// const { applySingleStoreSnapshotsDiff } = await import('./snapshot-differ/singlestore'); - -// const validatedPrev = singlestoreSchema.parse(prev); -// const validatedCur = singlestoreSchema.parse(cur); - -// const squashedPrev = squashSingleStoreScheme(validatedPrev); -// const squashedCur = squashSingleStoreScheme(validatedCur); - -// const { sqlStatements } = await applySingleStoreSnapshotsDiff( -// squashedPrev, -// squashedCur, -// tablesResolver, -// columnsResolver, -// /* singleStoreViewsResolver, */ -// validatedPrev, -// validatedCur, -// 'push', -// ); - -// return sqlStatements; -// }; - -// export const pushSingleStoreSchema = async ( -// imports: Record, -// drizzleInstance: SingleStoreDriverDatabase, -// databaseName: string, -// ) => { -// const { applySingleStoreSnapshotsDiff } = await import('./snapshot-differ/singlestore'); -// const { logSuggestionsAndReturn } = await import( -// './cli/commands/singlestorePushUtils' -// ); -// const { singlestorePushIntrospect } = await import( -// './cli/commands/pull-singlestore' -// ); -// const { sql } = await import('drizzle-orm'); - -// const db: DB = { -// query: async (query: string) => { -// const res = await drizzleInstance.execute(sql.raw(query)); -// return res[0] as unknown as any[]; -// }, -// }; -// const cur = await generateSingleStoreDrizzleJson(imports); -// const { schema: prev } = await singlestorePushIntrospect(db, databaseName, []); - -// const validatedPrev = singlestoreSchema.parse(prev); -// const validatedCur = singlestoreSchema.parse(cur); - -// const squashedPrev = squashSingleStoreScheme(validatedPrev); -// const squashedCur = squashSingleStoreScheme(validatedCur); - -// const { statements } = await applySingleStoreSnapshotsDiff( -// squashedPrev, -// squashedCur, -// tablesResolver, -// columnsResolver, -// /* singleStoreViewsResolver, */ -// validatedPrev, -// validatedCur, -// 'push', -// ); - -// const { shouldAskForApprove, statementsToExecute, infoToPrint } = await logSuggestionsAndReturn( -// db, -// statements, -// validatedCur, -// validatedPrev, -// ); - -// return { -// hasDataLoss: shouldAskForApprove, -// warnings: infoToPrint, -// statementsToExecute, -// apply: async () => { -// for (const dStmnt of statementsToExecute) { -// await db.query(dStmnt); -// } -// }, -// }; -// }; +import type { MigrationConfig } from 'drizzle-orm/migrator'; +import type { Pool, PoolClient, QueryConfig } from 'pg'; +import type { Resolver } from '../dialects/common'; +import { fromJson } from '../dialects/postgres/convertor'; +import type { + CheckConstraint, + Column, + Enum, + ForeignKey, + Index, + InterimSchema, + Policy, + PostgresDDL, + PostgresEntities, + PrimaryKey, + Privilege, + Role, + Schema, + Sequence, + UniqueConstraint, + View, +} from '../dialects/postgres/ddl'; +import { interimToDDL } from '../dialects/postgres/ddl'; +import { ddlDiff } from '../dialects/postgres/diff'; +import { fromDatabaseForDrizzle } from '../dialects/postgres/introspect'; +import type { JsonStatement } from '../dialects/postgres/statements'; +import { prepareEntityFilter } from '../dialects/pull-utils'; +import '../@types/utils'; + +type Queryable = Pool | PoolClient; +type Named = { name: string; schema?: string; table?: string }; +type RenamePromptItem = { from: T; to: T }; +type QueryDB = { + query: (sql: string, params?: any[]) => Promise; +}; +type ProxyParams = { + sql: string; + params?: any[]; + mode: 'array' | 'object'; + method: 'values' | 'get' | 'all' | 'run' | 'execute'; +}; + +export type DrizzlePgDB = QueryDB & { + proxy: (params: ProxyParams) => Promise; + migrate: (config: string | MigrationConfig) => Promise; +}; + +export type PreparePgDBOptions = { + queryConcurrency?: number; +}; + +export type DrizzlePgDBIntrospectSchema = InterimSchema; + +export type LegacyResolverInput = { + created: T[]; + deleted: T[]; + schema?: string; + tableName?: string; +}; + +export type LegacyResolverOutput = { + created: T[]; + deleted: T[]; + renamed?: RenamePromptItem[]; + renamedOrMoved?: RenamePromptItem[]; + moved?: { name: string; schemaFrom: string; schemaTo: string }[]; +}; + +export type LegacyResolver = ( + input: LegacyResolverInput, +) => Promise>; + +const defaultMigrationsConfig = { + schema: 'drizzle', + table: '__drizzle_migrations', +}; + +const passthroughResolver = async ({ created, deleted }: LegacyResolverInput) => { + return { created, deleted, renamed: [] }; +}; + +export const schemasResolver: LegacyResolver = passthroughResolver; +export const enumsResolver: LegacyResolver = passthroughResolver; +export const sequencesResolver: LegacyResolver = passthroughResolver; +export const policyResolver: LegacyResolver = passthroughResolver; +export const indPolicyResolver: LegacyResolver = passthroughResolver; +export const roleResolver: LegacyResolver = passthroughResolver; +export const tablesResolver: LegacyResolver = passthroughResolver; +export const columnsResolver: LegacyResolver = passthroughResolver; +export const viewsResolver: LegacyResolver = passthroughResolver; + +export type ResolverInput = LegacyResolverInput; +export type ColumnsResolverInput = LegacyResolverInput; +export type PolicyResolverInput = LegacyResolverInput; +export type TablePolicyResolverInput = LegacyResolverInput; +export type RolesResolverInput = LegacyResolverInput; +export type { Enum, Role, Sequence, View }; +export type Table = PostgresEntities['tables']; + +export type SelectResolverInput = { + entity: { + type: 'createUniqueConstraint'; + name: string; + count: number; + tableName: string; + }; + items: string[]; +}; + +export type SelectResolverOutput = { + data: { + index: number; + value: string; + }; +}; + +type SelectResolver = (input: SelectResolverInput) => Promise; + +function createConcurrencyLimiter(concurrency?: number) { + if (concurrency === undefined) { + return (run: () => Promise) => run(); + } + + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new RangeError('queryConcurrency must be a positive integer'); + } + + let activeCount = 0; + const queue: Array<() => void> = []; + + const runNext = () => { + if (activeCount >= concurrency) return; + + const next = queue.shift(); + if (!next) return; + + activeCount += 1; + next(); + }; + + return (run: () => Promise) => { + return new Promise((resolve, reject) => { + queue.push(() => { + Promise.resolve() + .then(run) + .then(resolve, reject) + .finally(() => { + activeCount -= 1; + runNext(); + }); + }); + + runNext(); + }); + }; +} + +export const preparePgDB = async ( + pool: Queryable, + options: PreparePgDBOptions = {}, +): Promise => { + const { default: pg } = await import('pg'); + const { drizzle } = await import('drizzle-orm/node-postgres'); + const { migrate } = await import('drizzle-orm/node-postgres/migrator'); + + const getTypeParser: typeof pg.types.getTypeParser = (typeId, format) => { + if ( + typeId === pg.types.builtins.TIMESTAMPTZ + || typeId === pg.types.builtins.TIMESTAMP + || typeId === pg.types.builtins.DATE + || typeId === pg.types.builtins.INTERVAL + ) { + return (value: string) => value; + } + + return pg.types.getTypeParser(typeId, format); + }; + + const limitQuery = createConcurrencyLimiter(options.queryConcurrency); + const types = { getTypeParser }; + + const query: QueryDB['query'] = async (sql, params) => { + const config: QueryConfig = { + text: sql, + values: params ?? [], + types, + }; + const result = await limitQuery(() => pool.query(config)); + return result.rows; + }; + + const proxy: DrizzlePgDB['proxy'] = async (params) => { + const config: QueryConfig = { + text: params.sql, + values: params.params, + ...(params.mode === 'array' && { rowMode: 'array' }), + types, + }; + const result = await limitQuery(() => pool.query(config)); + return result.rows; + }; + + const migrateFn = async (config: string | MigrationConfig) => { + const db = drizzle({ client: pool }); + await migrate(db, config as MigrationConfig); + }; + + return { query, proxy, migrate: migrateFn }; +}; + +export const createEmptyPgSchema = (): DrizzlePgDBIntrospectSchema => ({ + schemas: [], + enums: [], + tables: [], + columns: [], + indexes: [], + pks: [], + fks: [], + uniques: [], + checks: [], + sequences: [], + roles: [], + privileges: [], + policies: [], + views: [], + viewColumns: [], +}); + +export const introspectPgDB = async ( + db: DrizzlePgDB, + filters: string[], + schemaFilters: string[], +): Promise => { + const filter = prepareEntityFilter('postgresql', { + tables: filters, + schemas: schemaFilters, + entities: undefined, + extensions: [], + }, []); + + return fromDatabaseForDrizzle(db, filter, () => {}, defaultMigrationsConfig); +}; + +function isDDL(schema: DrizzlePgDBIntrospectSchema | PostgresDDL): schema is PostgresDDL { + return 'entities' in schema; +} + +export const pgSchema = { + parse: (schema: DrizzlePgDBIntrospectSchema) => schema, +}; + +export const squashPgScheme = ( + schema: DrizzlePgDBIntrospectSchema | PostgresDDL, + _mode?: 'default' | 'push', +): PostgresDDL => { + if (isDDL(schema)) return schema; + + const { ddl, errors } = interimToDDL(schema); + if (errors.length > 0) { + throw new Error(`Failed to convert Postgres schema: ${JSON.stringify(errors)}`); + } + + return ddl; +}; + +function movedToRenamed( + moved: { name: string; schemaFrom: string; schemaTo: string }, + created: T[], + deleted: T[], +): RenamePromptItem { + const from = deleted.find((item) => item.name === moved.name && (item.schema ?? 'public') === moved.schemaFrom); + const to = created.find((item) => item.name === moved.name && (item.schema ?? 'public') === moved.schemaTo); + if (!from || !to) { + throw new Error(`Invalid move for ${moved.name}: ${moved.schemaFrom} -> ${moved.schemaTo}`); + } + + return { from, to }; +} + +function adaptResolver(resolver: LegacyResolver): Resolver { + return async ({ created, deleted }) => { + const sample = created[0] ?? deleted[0]; + const result = await resolver({ + created, + deleted, + schema: sample?.schema, + tableName: sample?.table, + }); + + return { + created: result.created, + deleted: result.deleted, + renamedOrMoved: [ + ...(result.renamed ?? []), + ...(result.renamedOrMoved ?? []), + ...(result.moved ?? []).map((move) => movedToRenamed(move, created, deleted)), + ], + }; + }; +} + +const noOpV1Resolver = async ({ created, deleted }: LegacyResolverInput) => { + return { created, deleted, renamedOrMoved: [] }; +}; + +export const applyPgSnapshotsDiff = async ( + targetSchema: PostgresDDL, + sourceSchema: PostgresDDL, + schemasResolverArg: LegacyResolver, + enumsResolverArg: LegacyResolver, + sequencesResolverArg: LegacyResolver, + policyResolverArg: LegacyResolver, + _indPolicyResolverArg: LegacyResolver, + roleResolverArg: LegacyResolver, + tablesResolverArg: LegacyResolver, + columnsResolverArg: LegacyResolver, + viewsResolverArg: LegacyResolver, + _validatedTarget: DrizzlePgDBIntrospectSchema, + _validatedSource: DrizzlePgDBIntrospectSchema, + mode: 'default' | 'push', +) => { + return ddlDiff( + targetSchema, + sourceSchema, + adaptResolver(schemasResolverArg), + adaptResolver(enumsResolverArg), + adaptResolver(sequencesResolverArg), + adaptResolver(policyResolverArg), + adaptResolver(roleResolverArg), + noOpV1Resolver, + adaptResolver(tablesResolverArg), + adaptResolver(columnsResolverArg), + adaptResolver(viewsResolverArg), + noOpV1Resolver, + noOpV1Resolver, + noOpV1Resolver, + noOpV1Resolver, + noOpV1Resolver, + mode, + ); +}; + +function quoteIdentifier(value: string) { + return `"${value.replaceAll('"', '""')}"`; +} + +function quotedTable(schema: string | undefined, table: string) { + return schema && schema !== 'public' + ? `${quoteIdentifier(schema)}.${quoteIdentifier(table)}` + : quoteIdentifier(table); +} + +async function countRows(db: QueryDB, schema: string | undefined, table: string) { + const rows = await db.query<{ count: string | number }>( + `select count(*) as count from ${quotedTable(schema, table)}`, + ); + return Number(rows[0]?.count ?? 0); +} + +export const pgSuggestions = async ( + db: QueryDB, + statements: JsonStatement[], + selectResolver?: SelectResolver, +) => { + let shouldAskForApprove = false; + const statementsToExecute: string[] = []; + const infoToPrint: string[] = []; + const matViewsToRemove: string[] = []; + const columnsToRemove: string[] = []; + const schemasToRemove: string[] = []; + const tablesToTruncate: string[] = []; + const tablesToRemove: string[] = []; + + for (const statement of statements) { + if (statement.type === 'drop_table') { + const count = await countRows(db, statement.table.schema, statement.table.name); + if (count > 0) { + infoToPrint.push(`You're about to delete ${statement.table.name} table with ${count} items`); + tablesToRemove.push(statement.table.name); + shouldAskForApprove = true; + } + } else if (statement.type === 'drop_view' && statement.view.materialized) { + const count = await countRows(db, statement.view.schema, statement.view.name); + if (count > 0) { + infoToPrint.push(`You're about to delete ${statement.view.name} materialized view with ${count} items`); + matViewsToRemove.push(statement.view.name); + shouldAskForApprove = true; + } + } else if (statement.type === 'drop_column') { + const count = await countRows(db, statement.column.schema, statement.column.table); + if (count > 0) { + infoToPrint.push( + `You're about to delete ${statement.column.name} column in ${statement.column.table} table with ${count} items`, + ); + columnsToRemove.push(`${statement.column.table}_${statement.column.name}`); + shouldAskForApprove = true; + } + } else if (statement.type === 'drop_schema') { + const escapedSchema = statement.name.replaceAll("'", "''"); + const rows = await db.query<{ count: string | number }>( + `select count(*) as count from information_schema.tables where table_schema = '${escapedSchema}';`, + ); + const count = Number(rows[0]?.count ?? 0); + if (count > 0) { + infoToPrint.push(`You're about to delete ${statement.name} schema with ${count} tables`); + schemasToRemove.push(statement.name); + shouldAskForApprove = true; + } + } else if (statement.type === 'alter_column' && statement.diff.type) { + const count = await countRows(db, statement.to.schema, statement.to.table); + if (count > 0) { + infoToPrint.push(`You're about to change ${statement.to.name} column type with ${count} items`); + statementsToExecute.push(`truncate table ${quotedTable(statement.to.schema, statement.to.table)} cascade;`); + tablesToTruncate.push(statement.to.table); + shouldAskForApprove = true; + } + } else if ( + statement.type === 'add_column' + && statement.column.notNull + && (statement.column.default === null || statement.column.default === undefined) + ) { + const count = await countRows(db, statement.column.schema, statement.column.table); + if (count > 0) { + infoToPrint.push( + `You're about to add not-null ${statement.column.name} column without a default to a table with ${count} items`, + ); + statementsToExecute.push( + `truncate table ${quotedTable(statement.column.schema, statement.column.table)} cascade;`, + ); + tablesToTruncate.push(statement.column.table); + shouldAskForApprove = true; + } + } else if (statement.type === 'drop_pk' || statement.type === 'alter_pk') { + const count = await countRows(db, statement.pk.schema, statement.pk.table); + if (count > 0) { + infoToPrint.push(`You're about to change ${statement.pk.table} primary key`); + tablesToTruncate.push(statement.pk.table); + shouldAskForApprove = true; + } + } else if (statement.type === 'add_unique' && selectResolver) { + const count = await countRows(db, statement.unique.schema, statement.unique.table); + if (count > 0) { + const { data } = await selectResolver({ + entity: { + type: 'createUniqueConstraint', + name: statement.unique.name, + count, + tableName: statement.unique.table, + }, + items: ['no', 'yes'], + }); + if (data.index === 1) { + statementsToExecute.push( + `truncate table ${quotedTable(statement.unique.schema, statement.unique.table)} cascade;`, + ); + tablesToTruncate.push(statement.unique.table); + shouldAskForApprove = true; + } + } + } + + statementsToExecute.push(...fromJson([statement]).sqlStatements); + } + + return { + statementsToExecute: [...new Set(statementsToExecute)], + shouldAskForApprove, + infoToPrint, + matViewsToRemove: [...new Set(matViewsToRemove)], + columnsToRemove: [...new Set(columnsToRemove)], + schemasToRemove: [...new Set(schemasToRemove)], + tablesToTruncate: [...new Set(tablesToTruncate)], + tablesToRemove: [...new Set(tablesToRemove)], + }; +}; diff --git a/drizzle-kit/tests/other/replit-api.test.ts b/drizzle-kit/tests/other/replit-api.test.ts new file mode 100644 index 0000000000..ff8c53598f --- /dev/null +++ b/drizzle-kit/tests/other/replit-api.test.ts @@ -0,0 +1,179 @@ +import type { Pool } from 'pg'; +import { describe, expect, test, vi } from 'vitest'; +import { + applyPgSnapshotsDiff, + columnsResolver, + createEmptyPgSchema, + enumsResolver, + indPolicyResolver, + pgSuggestions, + policyResolver, + preparePgDB, + roleResolver, + schemasResolver, + sequencesResolver, + squashPgScheme, + tablesResolver, + viewsResolver, +} from '../../src/ext/api'; +import type { DB } from '../../src/utils'; + +vi.mock('pg', () => ({ + default: { + types: { + builtins: { + DATE: 1082, + INTERVAL: 1186, + TIMESTAMP: 1114, + TIMESTAMPTZ: 1184, + }, + getTypeParser: vi.fn(() => (value: unknown) => value), + }, + }, +})); + +vi.mock('drizzle-orm/node-postgres', () => ({ + drizzle: vi.fn().mockReturnValue({}), +})); + +vi.mock('drizzle-orm/node-postgres/migrator', () => ({ + migrate: vi.fn(), +})); + +function createObservedPool() { + let activeQueries = 0; + let maxActiveQueries = 0; + + const query = vi.fn(async (input: { text: string }) => { + activeQueries += 1; + maxActiveQueries = Math.max(maxActiveQueries, activeQueries); + await new Promise((resolve) => setTimeout(resolve, 10)); + activeQueries -= 1; + + if (input.text === 'fail') throw new Error('query failed'); + return { rows: [input.text] }; + }); + + return { + pool: { query } as unknown as Pool, + query, + getMaxActiveQueries: () => maxActiveQueries, + }; +} + +function usersSchema(unique = false) { + const ddl = squashPgScheme(createEmptyPgSchema()); + ddl.tables.push({ + schema: 'public', + name: 'users', + isRlsEnabled: false, + }); + ddl.columns.push({ + schema: 'public', + table: 'users', + name: 'email', + type: 'text', + typeSchema: null, + notNull: true, + dimensions: 0, + default: null, + generated: null, + identity: null, + }); + if (unique) { + ddl.uniques.push({ + schema: 'public', + table: 'users', + name: 'users_email_unique', + nameExplicit: true, + columns: ['email'], + nullsNotDistinct: false, + }); + } + + return ddl; +} + +async function diffSchemas( + target: ReturnType, + source: ReturnType, +) { + const emptySchema = createEmptyPgSchema(); + return applyPgSnapshotsDiff( + target, + source, + schemasResolver, + enumsResolver, + sequencesResolver, + policyResolver, + indPolicyResolver, + roleResolver, + tablesResolver, + columnsResolver, + viewsResolver, + emptySchema, + emptySchema, + 'push', + ); +} + +describe('preparePgDB', () => { + test('limits query and proxy concurrency and resumes after rejection', async () => { + const observed = createObservedPool(); + const db = await preparePgDB(observed.pool, { queryConcurrency: 2 }); + + const results = await Promise.allSettled([ + db.query('select 1'), + db.query('fail'), + db.query('select 2'), + db.proxy({ method: 'all', mode: 'array', params: [], sql: 'select 3' }), + ]); + + expect(results.map((result) => result.status)).toEqual(['fulfilled', 'rejected', 'fulfilled', 'fulfilled']); + expect(observed.query).toHaveBeenCalledTimes(4); + expect(observed.getMaxActiveQueries()).toBe(2); + }); + + test('rejects invalid query concurrency', async () => { + const observed = createObservedPool(); + await expect(preparePgDB(observed.pool, { queryConcurrency: 0 })).rejects.toThrow( + 'queryConcurrency must be a positive integer', + ); + }); +}); + +describe('Replit compatibility API', () => { + test('generates executable SQL through the legacy resolver contract', async () => { + const result = await diffSchemas(squashPgScheme(createEmptyPgSchema()), usersSchema()); + + expect(result.sqlStatements.join('\n')).toContain('CREATE TABLE "users"'); + expect(result.sqlStatements.join('\n')).toContain('"email" text NOT NULL'); + }); + + test('reports destructive changes only when the target contains data', async () => { + const result = await diffSchemas(usersSchema(), squashPgScheme(createEmptyPgSchema())); + const db: DB = { + query: vi.fn().mockResolvedValue([{ count: '3' }]), + }; + + const suggestions = await pgSuggestions(db, result.statements); + + expect(suggestions.shouldAskForApprove).toBe(true); + expect(suggestions.tablesToRemove).toEqual(['users']); + expect(suggestions.statementsToExecute.join('\n')).toContain('DROP TABLE "users"'); + }); + + test('places an approved truncate before a new unique constraint', async () => { + const result = await diffSchemas(usersSchema(), usersSchema(true)); + const db: DB = { + query: vi.fn().mockResolvedValue([{ count: '2' }]), + }; + const selectResolver = vi.fn().mockResolvedValue({ data: { index: 1, value: 'yes' } }); + + const suggestions = await pgSuggestions(db, result.statements, selectResolver); + + expect(suggestions.tablesToTruncate).toEqual(['users']); + expect(suggestions.statementsToExecute[0]).toBe('truncate table "users" cascade;'); + expect(suggestions.statementsToExecute.join('\n')).toContain('UNIQUE'); + }); +}); From 16044c7b0588c113f9db89faef81d2f0bcce1a47 Mon Sep 17 00:00:00 2001 From: Joe Thom Date: Fri, 17 Jul 2026 08:38:32 -0700 Subject: [PATCH 2/3] Require table context for scoped resolvers --- drizzle-kit/src/ext/api.ts | 57 ++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/drizzle-kit/src/ext/api.ts b/drizzle-kit/src/ext/api.ts index d4fea3ca98..92801f0921 100644 --- a/drizzle-kit/src/ext/api.ts +++ b/drizzle-kit/src/ext/api.ts @@ -29,6 +29,7 @@ import '../@types/utils'; type Queryable = Pool | PoolClient; type Named = { name: string; schema?: string; table?: string }; +type TableNamed = Named & { schema: string; table: string }; type RenamePromptItem = { from: T; to: T }; type QueryDB = { query: (sql: string, params?: any[]) => Promise; @@ -70,6 +71,20 @@ export type LegacyResolver = ( input: LegacyResolverInput, ) => Promise>; +export type TableScopedResolverInput = + & Omit< + LegacyResolverInput, + 'schema' | 'tableName' + > + & { + schema: string; + tableName: string; + }; + +export type TableScopedResolver = ( + input: TableScopedResolverInput, +) => Promise>; + const defaultMigrationsConfig = { schema: 'drizzle', table: '__drizzle_migrations', @@ -82,17 +97,17 @@ const passthroughResolver = async ({ created, deleted }: Legacy export const schemasResolver: LegacyResolver = passthroughResolver; export const enumsResolver: LegacyResolver = passthroughResolver; export const sequencesResolver: LegacyResolver = passthroughResolver; -export const policyResolver: LegacyResolver = passthroughResolver; +export const policyResolver: TableScopedResolver = passthroughResolver; export const indPolicyResolver: LegacyResolver = passthroughResolver; export const roleResolver: LegacyResolver = passthroughResolver; export const tablesResolver: LegacyResolver = passthroughResolver; -export const columnsResolver: LegacyResolver = passthroughResolver; +export const columnsResolver: TableScopedResolver = passthroughResolver; export const viewsResolver: LegacyResolver = passthroughResolver; export type ResolverInput = LegacyResolverInput; -export type ColumnsResolverInput = LegacyResolverInput; -export type PolicyResolverInput = LegacyResolverInput; -export type TablePolicyResolverInput = LegacyResolverInput; +export type ColumnsResolverInput = TableScopedResolverInput; +export type PolicyResolverInput = TableScopedResolverInput; +export type TablePolicyResolverInput = TableScopedResolverInput; export type RolesResolverInput = LegacyResolverInput; export type { Enum, Role, Sequence, View }; export type Table = PostgresEntities['tables']; @@ -299,6 +314,30 @@ function adaptResolver(resolver: LegacyResolver): Resolver(resolver: TableScopedResolver): Resolver { + return async ({ created, deleted }) => { + const sample = created[0] ?? deleted[0]; + if (!sample) return { created, deleted, renamedOrMoved: [] }; + + const result = await resolver({ + created, + deleted, + schema: sample.schema, + tableName: sample.table, + }); + + return { + created: result.created, + deleted: result.deleted, + renamedOrMoved: [ + ...(result.renamed ?? []), + ...(result.renamedOrMoved ?? []), + ...(result.moved ?? []).map((move) => movedToRenamed(move, created, deleted)), + ], + }; + }; +} + const noOpV1Resolver = async ({ created, deleted }: LegacyResolverInput) => { return { created, deleted, renamedOrMoved: [] }; }; @@ -309,11 +348,11 @@ export const applyPgSnapshotsDiff = async ( schemasResolverArg: LegacyResolver, enumsResolverArg: LegacyResolver, sequencesResolverArg: LegacyResolver, - policyResolverArg: LegacyResolver, + policyResolverArg: TableScopedResolver, _indPolicyResolverArg: LegacyResolver, roleResolverArg: LegacyResolver, tablesResolverArg: LegacyResolver, - columnsResolverArg: LegacyResolver, + columnsResolverArg: TableScopedResolver, viewsResolverArg: LegacyResolver, _validatedTarget: DrizzlePgDBIntrospectSchema, _validatedSource: DrizzlePgDBIntrospectSchema, @@ -325,11 +364,11 @@ export const applyPgSnapshotsDiff = async ( adaptResolver(schemasResolverArg), adaptResolver(enumsResolverArg), adaptResolver(sequencesResolverArg), - adaptResolver(policyResolverArg), + adaptTableScopedResolver(policyResolverArg), adaptResolver(roleResolverArg), noOpV1Resolver, adaptResolver(tablesResolverArg), - adaptResolver(columnsResolverArg), + adaptTableScopedResolver(columnsResolverArg), adaptResolver(viewsResolverArg), noOpV1Resolver, noOpV1Resolver, From 1a6443fb26dc34981093428912aca76446330e9c Mon Sep 17 00:00:00 2001 From: Joe Thom Date: Fri, 17 Jul 2026 08:54:22 -0700 Subject: [PATCH 3/3] Keep bundled Brocli out of internal manifest --- drizzle-kit/scripts/prepare-replit-package.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/drizzle-kit/scripts/prepare-replit-package.ts b/drizzle-kit/scripts/prepare-replit-package.ts index 6b74966bd8..f80c6cb2a3 100644 --- a/drizzle-kit/scripts/prepare-replit-package.ts +++ b/drizzle-kit/scripts/prepare-replit-package.ts @@ -4,13 +4,25 @@ async function main() { const packagePath = 'dist/package.json'; const parsed: unknown = JSON.parse(await readFile(packagePath, 'utf8')); - if (typeof parsed !== 'object' || parsed === null || !('name' in parsed) || parsed.name !== 'drizzle-kit') { + if ( + typeof parsed !== 'object' + || parsed === null + || !('name' in parsed) + || parsed.name !== 'drizzle-kit' + || !('dependencies' in parsed) + || typeof parsed.dependencies !== 'object' + || parsed.dependencies === null + || !('@drizzle-team/brocli' in parsed.dependencies) + ) { throw new Error('Expected the built drizzle-kit package manifest'); } + const dependencies = Object.fromEntries( + Object.entries(parsed.dependencies).filter(([name]) => name !== '@drizzle-team/brocli'), + ); await writeFile( packagePath, - `${JSON.stringify({ ...parsed, name: '@drizzle-team/drizzle-kit' }, null, '\t')}\n`, + `${JSON.stringify({ ...parsed, name: '@drizzle-team/drizzle-kit', dependencies }, null, '\t')}\n`, ); }