Skip to content
Merged

Helo #31

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arkormx",
"version": "2.11.2",
"version": "2.11.3",
"description": "Modern TypeScript-first ORM for Node.js.",
"keywords": [
"orm",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-clear-router/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 5 additions & 0 deletions src/DB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -71,6 +72,10 @@ export class DB {
sql: string,
bindings: unknown[] = [],
): Promise<ArkormCollection<TRow>> {
// 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<TRow>([])

const adapter = this.getAdapter()
if (!adapter)
throw new ArkormException('Raw queries require a configured database adapter.', {
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/MigrateCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,11 @@ export class MigrateCommand extends Command<CliApp> {

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

Expand Down
6 changes: 6 additions & 0 deletions src/cli/commands/MigrateFreshCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CliApp> {
protected signature = `migrate:fresh
Expand All @@ -45,6 +46,11 @@ export class MigrateFreshCommand extends Command<CliApp> {

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')
Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/MigrateRollbackCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -49,6 +50,9 @@ export class MigrateRollbackCommand extends Command<CliApp> {

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')
Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/MigrationHistoryCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,6 +33,9 @@ export class MigrationHistoryCommand extends Command<CliApp> {

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(),
Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/ModelsSyncCommand.ts
Original file line number Diff line number Diff line change
@@ -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<CliApp> {
Expand All @@ -12,6 +13,9 @@ export class ModelsSyncCommand extends Command<CliApp> {

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

Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/SeedCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -36,6 +37,9 @@ export class SeedCommand extends Command<CliApp> {
*/
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)
Expand Down
4 changes: 2 additions & 2 deletions src/helpers/column-mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
}
Expand Down
41 changes: 41 additions & 0 deletions src/helpers/migration-planning.ts
Original file line number Diff line number Diff line change
@@ -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<true>
}

const migrationPlanningStorage: AsyncLocalStorage<true> =
globalScope[STORAGE_KEY] ?? (globalScope[STORAGE_KEY] = new AsyncLocalStorage<true>())

/** Runs `fn` in a side-effect-free migration-planning context. */
export const runInMigrationPlanning = async <TResult>(
fn: () => Promise<TResult> | TResult,
): Promise<TResult> => {
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
}
13 changes: 11 additions & 2 deletions src/helpers/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1046,12 +1047,20 @@ export const generateMigrationFile = (
export const getMigrationPlan = async (
migration: MigrationInstanceLike | (new () => MigrationInstanceLike),
direction: 'up' | 'down' = 'up',
options: { inert?: boolean } = {},
): Promise<SchemaOperation[]> => {
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<void> => {
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()
}
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export * from './metadata'
export * from './migrations'
export * from './model'
export * from './ModelStatic'
export * from './query-builder'
export * from './relationship'
50 changes: 50 additions & 0 deletions tests/base/migrate-database.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).__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<string, unknown>).__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)
Expand Down
63 changes: 63 additions & 0 deletions tests/base/migration-planning.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[])
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)
})
})
Loading