diff --git a/package.json b/package.json index 2d69d02..3c57ea9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arkormx", - "version": "2.11.2", + "version": "2.11.3", "description": "Modern TypeScript-first ORM for Node.js.", "keywords": [ "orm", diff --git a/packages/plugin-clear-router/package.json b/packages/plugin-clear-router/package.json index 0d4a243..2f5cdfe 100644 --- a/packages/plugin-clear-router/package.json +++ b/packages/plugin-clear-router/package.json @@ -1,6 +1,6 @@ { "name": "@arkormx/plugin-clear-router", - "version": "0.1.50", + "version": "0.1.52", "description": "Clear Router plugin for resolving Arkormx models from route parameters.", "publishConfig": { "access": "public" diff --git a/src/DB.ts b/src/DB.ts index 9a2598a..40488e2 100644 --- a/src/DB.ts +++ b/src/DB.ts @@ -7,6 +7,7 @@ import { import { getRuntimeAdapter, getUserConfig } from './helpers/runtime-config' import { ArkormCollection } from './Collection' +import { isMigrationPlanningActive } from './helpers/migration-planning' import { ArkormException } from './Exceptions/ArkormException' import type { DatabaseTableOptions } from './types/db' import type { ModelMetadata } from './types/metadata' @@ -71,6 +72,10 @@ export class DB { sql: string, bindings: unknown[] = [], ): Promise> { + // A migration invoked only to collect its schema plan must not re-execute its + // raw SQL side effects (see runInMigrationPlanning). + if (isMigrationPlanningActive()) return ArkormCollection.make([]) + const adapter = this.getAdapter() if (!adapter) throw new ArkormException('Raw queries require a configured database adapter.', { diff --git a/src/cli/commands/MigrateCommand.ts b/src/cli/commands/MigrateCommand.ts index e88e70e..6f03649 100644 --- a/src/cli/commands/MigrateCommand.ts +++ b/src/cli/commands/MigrateCommand.ts @@ -169,8 +169,11 @@ export class MigrateCommand extends Command { for (const [MigrationClassItem] of pending) { if (useDatabaseMigrations) { + // Planning-only: collect the migration's operations for column mappings + // without replaying its direct DB side effects (e.g. DB.raw). The actual + // side effects run once, in applyMigrationToDatabase below. const planned = await this.runWithDatabaseCreationRetry(adapter, () => - getMigrationPlan(MigrationClassItem, 'up'), + getMigrationPlan(MigrationClassItem, 'up', { inert: true }), ) if (!planned.ok) return diff --git a/src/cli/commands/MigrateFreshCommand.ts b/src/cli/commands/MigrateFreshCommand.ts index abfcbae..3f25cb7 100644 --- a/src/cli/commands/MigrateFreshCommand.ts +++ b/src/cli/commands/MigrateFreshCommand.ts @@ -31,6 +31,7 @@ import { CliApp } from '../CliApp' import { Command } from '@h3ravel/musket' import { MIGRATION_BRAND } from '../../database/Migration' import { RuntimeModuleLoader } from '../../helpers/runtime-module-loader' +import { loadArkormConfig } from '../../helpers/runtime-config' export class MigrateFreshCommand extends Command { protected signature = `migrate:fresh @@ -45,6 +46,11 @@ export class MigrateFreshCommand extends Command { async handle() { this.app.command = this + // Apply the project's arkormx.config (and its adapter) before resolving it: + // harnesses like the Arkstack CLI hand us a fresh CliApp that hasn't loaded + // the config, so without this the adapter is undefined and `migrate:fresh` + // silently falls back to the Prisma path instead of resetting the database. + await loadArkormConfig() const configuredMigrationsDir = this.app.getConfig('paths')?.migrations ?? join(process.cwd(), 'database', 'migrations') diff --git a/src/cli/commands/MigrateRollbackCommand.ts b/src/cli/commands/MigrateRollbackCommand.ts index cdc870c..7e06b2c 100644 --- a/src/cli/commands/MigrateRollbackCommand.ts +++ b/src/cli/commands/MigrateRollbackCommand.ts @@ -23,6 +23,7 @@ import { import { CliApp } from '../CliApp' import { Command } from '@h3ravel/musket' +import { loadArkormConfig } from '../../helpers/runtime-config' import { MIGRATION_BRAND } from '../../database/Migration' import { RuntimeModuleLoader } from '../../helpers/runtime-module-loader' @@ -49,6 +50,9 @@ export class MigrateRollbackCommand extends Command { async handle() { this.app.command = this + // Load the project config (and its adapter) before resolving it; harnesses + // may hand us a CliApp that hasn't applied the config yet. + await loadArkormConfig() const configuredMigrationsDir = this.app.getConfig('paths')?.migrations ?? join(process.cwd(), 'database', 'migrations') diff --git a/src/cli/commands/MigrationHistoryCommand.ts b/src/cli/commands/MigrationHistoryCommand.ts index 866771c..1a80b0f 100644 --- a/src/cli/commands/MigrationHistoryCommand.ts +++ b/src/cli/commands/MigrationHistoryCommand.ts @@ -13,6 +13,7 @@ import { import { CliApp } from '../CliApp' import { Command } from '@h3ravel/musket' +import { loadArkormConfig } from '../../helpers/runtime-config' /** * The MigrationHistoryCommand class manages tracked migration run history. @@ -32,6 +33,9 @@ export class MigrationHistoryCommand extends Command { async handle() { this.app.command = this + // Load the project config (and its adapter) before resolving it; harnesses + // may hand us a CliApp that hasn't applied the config yet. + await loadArkormConfig() const stateFilePath = resolveMigrationStateFilePath( process.cwd(), diff --git a/src/cli/commands/ModelsSyncCommand.ts b/src/cli/commands/ModelsSyncCommand.ts index f6d147b..511fba5 100644 --- a/src/cli/commands/ModelsSyncCommand.ts +++ b/src/cli/commands/ModelsSyncCommand.ts @@ -1,5 +1,6 @@ import { CliApp } from '../CliApp' import { Command } from '@h3ravel/musket' +import { loadArkormConfig } from '../../helpers/runtime-config' import { resolve } from 'node:path' export class ModelsSyncCommand extends Command { @@ -12,6 +13,9 @@ export class ModelsSyncCommand extends Command { async handle() { this.app.command = this + // Load the project config (and its adapter) before resolving it; harnesses + // may hand us a CliApp that hasn't applied the config yet. + await loadArkormConfig() let result diff --git a/src/cli/commands/SeedCommand.ts b/src/cli/commands/SeedCommand.ts index 6f87780..4fa054d 100644 --- a/src/cli/commands/SeedCommand.ts +++ b/src/cli/commands/SeedCommand.ts @@ -3,6 +3,7 @@ import { getRegisteredPaths, getRegisteredSeeders } from '../../helpers/runtime- import { CliApp } from '../CliApp' import { Command } from '@h3ravel/musket' +import { loadArkormConfig } from '../../helpers/runtime-config' import { RuntimeModuleLoader } from '../../helpers/runtime-module-loader' import { SEEDER_BRAND, Seeder } from '../../database/Seeder' import { join } from 'node:path' @@ -36,6 +37,9 @@ export class SeedCommand extends Command { */ async handle() { this.app.command = this + // Load the project config (and its adapter) before resolving paths/adapter; + // harnesses may hand us a CliApp that hasn't applied the config yet. + await loadArkormConfig() const configuredSeedersDir = this.app.getConfig('paths')?.seeders ?? join(process.cwd(), 'database', 'seeders') const seederDirs = this.resolveSeederDirectories(configuredSeedersDir) diff --git a/src/helpers/column-mappings.ts b/src/helpers/column-mappings.ts index 8967b0e..29caf8a 100644 --- a/src/helpers/column-mappings.ts +++ b/src/helpers/column-mappings.ts @@ -627,7 +627,7 @@ export const rebuildPersistedColumnMappingsState = async ( ) } - const operations = await getMigrationPlan(migrationClass, 'up') + const operations = await getMigrationPlan(migrationClass, 'up', { inert: true }) nextState = applyOperationsToPersistedColumnMappingsState(nextState, operations, features) } @@ -659,7 +659,7 @@ export const validatePersistedMetadataFeaturesForMigrations = async ( let nextState = createEmptyPersistedColumnMappingsState() for (const [migrationClass] of migrations) { - const operations = await getMigrationPlan(migrationClass, 'up') + const operations = await getMigrationPlan(migrationClass, 'up', { inert: true }) nextState = applyOperationsToPersistedColumnMappingsState(nextState, operations, features) } } diff --git a/src/helpers/migration-planning.ts b/src/helpers/migration-planning.ts new file mode 100644 index 0000000..d110e0d --- /dev/null +++ b/src/helpers/migration-planning.ts @@ -0,0 +1,41 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +/** + * Tracks whether a migration body is currently being invoked purely to collect + * its schema plan (rather than to actually apply it). + * + * A migration's `up()`/`down()` is executed both to gather its {@link SchemaOperation}s + * and, when it uses helpers like `DB.raw()`, as real database side effects. Some + * flows (column-mapping sync, feature validation) re-invoke `up()` on already + * applied migrations solely to rebuild metadata — they must NOT re-run those side + * effects, or a rollback would replay every still-applied migration's raw SQL + * against a schema it was never meant to touch again. + * + * While this context is active, direct data-affecting `DB` calls become no-ops. + * Actual apply/rollback runs outside it, so side effects happen normally. + */ +// The CLI (`dist/cli.mjs`) and the library (`dist/index.mjs`) are separate +// bundles, so a plain module-scoped store would give each its own instance and +// the flag would not cross from the CLI's getMigrationPlan() to a migration's +// DB.raw() (imported from the library). Anchor a single instance on globalThis +// via the global symbol registry so every bundle shares it. +const STORAGE_KEY = Symbol.for('arkorm.migrationPlanningStorage') + +const globalScope = globalThis as typeof globalThis & { + [STORAGE_KEY]?: AsyncLocalStorage +} + +const migrationPlanningStorage: AsyncLocalStorage = + globalScope[STORAGE_KEY] ?? (globalScope[STORAGE_KEY] = new AsyncLocalStorage()) + +/** Runs `fn` in a side-effect-free migration-planning context. */ +export const runInMigrationPlanning = async ( + fn: () => Promise | TResult, +): Promise => { + return await migrationPlanningStorage.run(true, async () => await fn()) +} + +/** True when a migration body is being invoked only to collect its schema plan. */ +export const isMigrationPlanningActive = (): boolean => { + return migrationPlanningStorage.getStore() === true +} diff --git a/src/helpers/migrations.ts b/src/helpers/migrations.ts index c17e174..498b91f 100644 --- a/src/helpers/migrations.ts +++ b/src/helpers/migrations.ts @@ -20,6 +20,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { ArkormException } from '../Exceptions/ArkormException' import type { DatabaseAdapter } from 'src/types/adapter' import { SchemaBuilder } from '../database/SchemaBuilder' +import { runInMigrationPlanning } from './migration-planning' import { join } from 'node:path' import { readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' @@ -1046,12 +1047,20 @@ export const generateMigrationFile = ( export const getMigrationPlan = async ( migration: MigrationInstanceLike | (new () => MigrationInstanceLike), direction: 'up' | 'down' = 'up', + options: { inert?: boolean } = {}, ): Promise => { const instance = typeof migration === 'function' ? new migration() : migration const schema = new SchemaBuilder() - if (direction === 'up') await instance.up(schema) - else await instance.down(schema) + const invoke = async (): Promise => { + if (direction === 'up') await instance.up(schema) + else await instance.down(schema) + } + + // Planning-only callers (metadata sync/validation) run inert so a migration's + // direct DB side effects (e.g. DB.raw) are not replayed just to read its plan. + if (options.inert) await runInMigrationPlanning(invoke) + else await invoke() return schema.getOperations() } diff --git a/src/index.ts b/src/index.ts index baa8d6c..57a50cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,7 +27,6 @@ export * from './database/SchemaBuilder' export * from './database/Seeder' export * from './database/TableBuilder' export * from './DB' -export * from './Expression' export * from './Exceptions/ArkormException' export * from './Exceptions/MissingDelegateException' export * from './Exceptions/ModelNotFoundException' @@ -37,6 +36,7 @@ export * from './Exceptions/RelationResolutionException' export * from './Exceptions/ScopeNotDefinedException' export * from './Exceptions/UniqueConstraintResolutionException' export * from './Exceptions/UnsupportedAdapterFeatureException' +export * from './Expression' export * from './helpers/column-mappings' export * from './helpers/generated-column' export * from './helpers/migration-history' @@ -47,6 +47,7 @@ export * from './helpers/runtime-compatibility' export * from './helpers/runtime-config' export * from './helpers/runtime-module-loader' export * from './helpers/runtime-registry' +export * from './JoinClause' export * from './Model' export * from './Paginator' export * from './PivotModel' diff --git a/src/types/index.ts b/src/types/index.ts index fefe96f..afae4a4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,4 +7,5 @@ export * from './metadata' export * from './migrations' export * from './model' export * from './ModelStatic' +export * from './query-builder' export * from './relationship' diff --git a/tests/base/migrate-database.spec.ts b/tests/base/migrate-database.spec.ts index 793f7ce..ec1949a 100644 --- a/tests/base/migrate-database.spec.ts +++ b/tests/base/migrate-database.spec.ts @@ -550,6 +550,56 @@ describe('database-backed migration command fallback', () => { expect(adapter.state.migrations[0]?.id).toContain('CreateUsersMigration') }) + it('migrate:fresh loads the project config to reach the adapter reset path (regression)', async () => { + const workspace = makeTempDir('arkormx-db-fresh-loadconfig-') + process.chdir(workspace) + + const migrationsDir = join(workspace, 'database', 'migrations') + mkdirSync(migrationsDir, { recursive: true }) + + const migrationBaseImport = `${originalCwd.replace(/\\/g, '/')}/src/database/Migration.ts` + writeFileSync( + join(migrationsDir, 'CreateThings.ts'), + [ + `import { Migration } from '${migrationBaseImport}'`, + 'export class CreateThings extends Migration {', + " async up (schema) { schema.createTable('things', (table) => { table.id() }) }", + " async down (schema) { schema.dropTable('things') }", + '}', + '', + ].join('\n'), + ) + + const adapter = createNoopAdapter() as DatabaseAdapter & { + resetCount: number + executed: SchemaOperation[][] + } + ;(globalThis as Record).__ARKORM_FRESH_TEST_ADAPTER__ = adapter + + // The adapter is reachable ONLY through the project config — the command must + // load it itself. Without loadArkormConfig() the adapter is undefined and + // fresh wrongly takes the Prisma path (no schema.prisma => error, no reset). + writeFileSync( + join(workspace, 'arkormx.config.cjs'), + `module.exports = { adapter: globalThis.__ARKORM_FRESH_TEST_ADAPTER__, paths: { migrations: ${JSON.stringify(migrationsDir)} } }\n`, + ) + + const app = new CliApp() + const fresh = new MigrateFreshCommand(app, new Kernel(app)) + ;(fresh as unknown as { app: CliApp }).app = app + const io = attachCommandIo(fresh as unknown as any, { + 'skip-generate': true, + 'skip-migrate': true, + }) + + await fresh.handle() + delete (globalThis as Record).__ARKORM_FRESH_TEST_ADAPTER__ + + expect(io.errorLines).toHaveLength(0) + expect(adapter.resetCount).toBe(1) + expect(adapter.executed[0]?.[0]).toMatchObject({ type: 'createTable', table: 'things' }) + }) + it('offers to create the configured database before adapter-backed fresh runs', async () => { const workspace = makeTempDir('arkormx-db-fresh-create-database-') process.chdir(workspace) diff --git a/tests/base/migration-planning.spec.ts b/tests/base/migration-planning.spec.ts new file mode 100644 index 0000000..3f02c19 --- /dev/null +++ b/tests/base/migration-planning.spec.ts @@ -0,0 +1,63 @@ +import { DB, configureArkormRuntime, getMigrationPlan, resetArkormRuntimeForTests } from '../../src' +import { afterEach, describe, expect, it, vi } from 'vitest' + +class RawSideEffectMigration { + async up(schema: any) { + schema.createTable('banking_accounts', (table: any) => { + table.id() + table.integer('userId') + table.string('currency') + }) + // A direct raw side effect — the class of statement that broke rollback. + await DB.raw( + 'alter table banking_accounts add constraint banking_accounts_user_currency_unique unique (user_id, currency)', + ) + } + + async down(schema: any) { + schema.dropTable('banking_accounts') + } +} + +const configureSpyAdapter = () => { + const rawQuery = vi.fn(async () => [] as Record[]) + configureArkormRuntime(undefined, { adapter: { rawQuery } as never }) + + return rawQuery +} + +afterEach(() => { + resetArkormRuntimeForTests() +}) + +describe('migration planning side effects', () => { + it('runs a migration DB.raw when planning normally (apply path)', async () => { + const rawQuery = configureSpyAdapter() + + const operations = await getMigrationPlan(new RawSideEffectMigration(), 'up') + + expect(rawQuery).toHaveBeenCalledTimes(1) + expect(operations[0]).toMatchObject({ type: 'createTable', table: 'banking_accounts' }) + }) + + it('suppresses DB.raw during inert planning while still collecting operations', async () => { + const rawQuery = configureSpyAdapter() + + const operations = await getMigrationPlan(new RawSideEffectMigration(), 'up', { inert: true }) + + // The raw statement must NOT be replayed just to read the plan… + expect(rawQuery).not.toHaveBeenCalled() + // …but the schema operations are still gathered. + expect(operations[0]).toMatchObject({ type: 'createTable', table: 'banking_accounts' }) + }) + + it('leaves DB.raw active again after the inert plan completes', async () => { + const rawQuery = configureSpyAdapter() + + await getMigrationPlan(new RawSideEffectMigration(), 'up', { inert: true }) + expect(rawQuery).not.toHaveBeenCalled() + + await DB.raw('select 1') + expect(rawQuery).toHaveBeenCalledTimes(1) + }) +})