Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/30626-migrate-withheld-extension-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/cli': patch
---

fix(cli): `os migrate` value walks name the columns they skip because the package that adds them is disabled

Clause-②: no
Metadata-protocol: none

`os migrate blank-single-values`, `blank-multi-values`, `option-codes`, `text-scalars` and `value-shapes` read each object as it is served. While a module package is disabled, the fields its `objectExtensions` add are withheld from that object (their columns and values are kept), so these runs could not read those columns and their reports read as if they had. Each report now lists every such column as `object.field (package ID)`, and the `--json` document carries the same list as `withheldColumns`. Enable the package and re-run to cover them. Nothing else changes: the runs still read and rewrite only what the served object declares.
3 changes: 3 additions & 0 deletions packages/cli/src/commands/migrate/blank-multi-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
import { formatWithheldColumns } from '../../utils/migrate-withheld-columns.js';
import {
walkBlankColumns,
type BlankSingleValuesEngine,
Expand Down Expand Up @@ -117,6 +118,7 @@ export function formatBlankMultiValuesReport(report: BlankSingleValuesReport): s
if (report.truncated) {
lines.push('⚠ Walk incomplete — rows past the cap or in an unreadable object were not read.');
}
lines.push(...formatWithheldColumns(report.withheldColumns));
return lines;
}

Expand Down Expand Up @@ -295,6 +297,7 @@ export default class MigrateBlankMultiValues extends Command {
find: (object, options) =>
!apply && stack.tableAbsent?.(object) ? Promise.resolve([]) : engine.find(object, options),
update: (object, data, options) => engine.update(object, data, options),
registry: engine.registry,
};

const report = await cleanBlankMultiValues(view, logger, {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/commands/migrate/blank-single-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
import {
formatWithheldColumns,
withheldColumns,
type WithheldColumn,
type WithheldColumnsRegistry,
} from '../../utils/migrate-withheld-columns.js';

async function confirm(question: string): Promise<boolean> {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
Expand Down Expand Up @@ -76,6 +82,11 @@ export interface BlankSingleValuesReport {
truncated: boolean;
unreadableObjects: string[];
failures: BlankSingleValueFailure[];
/**
* [#30626] In-scope columns this run did not read because the package that
* adds them is disabled: re-run after enabling it.
*/
withheldColumns: WithheldColumn[];
}

/** The engine surface the walk reads and writes through. */
Expand All @@ -84,6 +95,8 @@ export interface BlankSingleValuesEngine {
getConfigs?(): Record<string, unknown>;
find(object: string, options: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
update(object: string, data: Record<string, unknown>, options?: any): Promise<unknown>;
/** [#30626] Where the columns a disabled package's extension adds are read from. */
registry?: WithheldColumnsRegistry;
}

export interface BlankSingleValuesLogger {
Expand Down Expand Up @@ -265,6 +278,7 @@ export async function walkBlankColumns(
truncated,
unreadableObjects,
failures,
withheldColumns: withheldColumns(engine, candidates, spec.columnsOf),
};
}

Expand All @@ -290,6 +304,7 @@ export function formatBlankSingleValuesReport(report: BlankSingleValuesReport):
if (report.truncated) {
lines.push('⚠ Walk incomplete — rows past the cap or in an unreadable object were not read.');
}
lines.push(...formatWithheldColumns(report.withheldColumns));
return lines;
}

Expand Down Expand Up @@ -467,6 +482,7 @@ export default class MigrateBlankSingleValues extends Command {
find: (object, options) =>
!apply && stack.tableAbsent?.(object) ? Promise.resolve([]) : engine.find(object, options),
update: (object, data, options) => engine.update(object, data, options),
registry: engine.registry,
};

const report = await cleanBlankSingleValues(view, logger, {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/commands/migrate/option-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
import {
formatWithheldColumns,
withheldColumns,
type WithheldColumn,
type WithheldColumnsRegistry,
} from '../../utils/migrate-withheld-columns.js';

async function confirm(question: string): Promise<boolean> {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
Expand Down Expand Up @@ -188,13 +194,20 @@ export interface OptionCodesReport {
truncated: boolean;
unreadableObjects: string[];
failures: OptionCodeFailure[];
/**
* [#30626] In-scope columns this run did not read because the package that
* adds them is disabled: re-run after enabling it.
*/
withheldColumns: WithheldColumn[];
}

export interface OptionCodesEngine {
getObject(name: string): any | undefined;
getConfigs?(): Record<string, unknown>;
find(object: string, options: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
update(object: string, data: Record<string, unknown>, options?: any): Promise<unknown>;
/** [#30626] Where the columns a disabled package's extension adds are read from. */
registry?: WithheldColumnsRegistry;
}

export interface OptionCodesLogger {
Expand Down Expand Up @@ -347,6 +360,7 @@ export async function migrateOptionCodes(
truncated,
unreadableObjects,
failures,
withheldColumns: withheldColumns(engine, candidates, optionCodeFields),
};
}

Expand Down Expand Up @@ -389,6 +403,7 @@ export function formatOptionCodesReport(report: OptionCodesReport): string[] {
if (report.truncated) {
lines.push('⚠ Walk incomplete — rows past the cap or in an unreadable object were not read.');
}
lines.push(...formatWithheldColumns(report.withheldColumns));
return lines;
}

Expand Down Expand Up @@ -562,6 +577,7 @@ export default class MigrateOptionCodes extends Command {
find: (object, options) =>
!apply && stack.tableAbsent?.(object) ? Promise.resolve([]) : engine.find(object, options),
update: (object, data, options) => engine.update(object, data, options),
registry: engine.registry,
};

const report = await migrateOptionCodes(view, logger, {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/commands/migrate/text-scalars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import { describeOccupancy, JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
import {
formatWithheldColumns,
withheldColumns,
type WithheldColumn,
type WithheldColumnsRegistry,
} from '../../utils/migrate-withheld-columns.js';

async function confirm(question: string): Promise<boolean> {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
Expand Down Expand Up @@ -149,13 +155,20 @@ export interface TextScalarsReport {
truncated: boolean;
unreadableObjects: string[];
failures: TextScalarFailure[];
/**
* [#30626] In-scope columns this run did not read because the package that
* adds them is disabled: re-run after enabling it.
*/
withheldColumns: WithheldColumn[];
}

export interface TextScalarsEngine {
getObject(name: string): any | undefined;
getConfigs?(): Record<string, unknown>;
find(object: string, options: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
update(object: string, data: Record<string, unknown>, options?: any): Promise<unknown>;
/** [#30626] Where the columns a disabled package's extension adds are read from. */
registry?: WithheldColumnsRegistry;
}

export interface TextScalarsLogger {
Expand Down Expand Up @@ -282,6 +295,7 @@ export async function migrateTextScalars(
truncated,
unreadableObjects,
failures,
withheldColumns: withheldColumns(engine, candidates, textScalarFields),
};
}

Expand Down Expand Up @@ -320,6 +334,7 @@ export function formatTextScalarsReport(report: TextScalarsReport): string[] {
if (report.truncated) {
lines.push('⚠ Walk incomplete — rows past the cap or in an unreadable object were not read.');
}
lines.push(...formatWithheldColumns(report.withheldColumns));
return lines;
}

Expand Down Expand Up @@ -497,6 +512,7 @@ export default class MigrateTextScalars extends Command {
find: (object, options) =>
!apply && stack.tableAbsent?.(object) ? Promise.resolve([]) : engine.find(object, options),
update: (object, data, options) => engine.update(object, data, options),
registry: engine.registry,
};

const report = await migrateTextScalars(view, logger, {
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/commands/migrate/value-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
import { isNarrowedRun, narrowedFlagNote, refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
import { formatWithheldColumns, withheldColumns } from '../../utils/migrate-withheld-columns.js';

async function confirm(question: string): Promise<boolean> {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
Expand Down Expand Up @@ -226,6 +227,10 @@ export default class MigrateValueShapes extends Command {
maxRecordsPerObject: flags['max-records'],
});
const passed = valueShapeScanPassed(report);
// [#30626] The scan reads the served fold, so a field a disabled
// package's extension adds is not scanned. Every such field is named
// (the scan's own column rule is the engine's, not exported).
const withheld = withheldColumns(engine, flags.object);
const notStoredLine = notStored.size > 0
? `${notStored.size} scanned object(s) have no table in this database yet, so nothing is stored in ` +
`them and they were not read: ${[...notStored].sort().join(', ')}.`
Expand Down Expand Up @@ -276,6 +281,7 @@ export default class MigrateValueShapes extends Command {
// mistaken for a full one (the shape `os migrate duplicates` keeps).
filter: narrowed ? { objects: flags.object } : null,
scan: report,
withheldColumns: withheld,
gatePassed: passed,
flag,
duration: timer.elapsed(),
Expand All @@ -286,7 +292,7 @@ export default class MigrateValueShapes extends Command {

printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
console.log('');
console.log(formatValueShapeScanReport(report).join('\n'));
console.log([...formatValueShapeScanReport(report), ...formatWithheldColumns(withheld)].join('\n'));
console.log('');
if (notStoredLine) printInfo(notStoredLine);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#30626] The `os migrate` value walks on a real engine (`ObjectQL` + sqlite
* `SqlDriver`) while a module package that extends the walked object is
* disabled.
*
* The walks scope by the served fold, which withholds a disabled package's
* extension fields (#30304) while the storage fold keeps their columns and
* values. Pinned: each walk's report names every in-scope column it skipped
* that way (object, field, package id) and its plan prints it; with the
* package enabled the same column is read and nothing is named.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { cleanBlankSingleValues, formatBlankSingleValuesReport } from './blank-single-values.js';
import { cleanBlankMultiValues, formatBlankMultiValuesReport } from './blank-multi-values.js';
import { migrateOptionCodes, formatOptionCodesReport } from './option-codes.js';
import { migrateTextScalars, formatTextScalarsReport } from './text-scalars.js';
import { withheldColumns, formatWithheldColumns } from '../../utils/migrate-withheld-columns.js';

const APP_PKG = 'com.qa.wx.app';
const MODULE_PKG = 'com.qa.wx.loyalty';
const OBJECT = 'wx_account';
const OPTS = [{ value: 'gold', label: 'Gold' }, { value: 'silver', label: 'Silver' }];

const manifest = (id: string, type: string) =>
({ id, name: id, version: '1.0.0', type, namespace: 'wx', ...(type === 'module' ? { dependencies: { [APP_PKG]: '^1.0.0' } } : {}) }) as any;
const OWNER = {
name: OBJECT, label: 'Account', systemFields: false,
fields: {
id: { name: 'id', type: 'text', primaryKey: true },
name: { name: 'name', type: 'text' },
},
};
/** A column for each walk to rewrite, each added only by the module's extension. */
const EXTENSION = {
name: OBJECT,
fields: {
wx_tier: { name: 'wx_tier', type: 'select', options: OPTS },
wx_level: { name: 'wx_level', type: 'select', options: [{ value: '3', label: 'Three' }] },
wx_tags: { name: 'wx_tags', type: 'multiselect', options: OPTS },
wx_note: { name: 'wx_note', type: 'text' },
},
};

const silent = { info: () => {}, warn: () => {} };
const live: ObjectQL[] = [];
afterEach(async () => {
while (live.length) {
try { await live.pop()?.destroy(); } catch { /* noop */ }
}
});

async function boot() {
const engine = new ObjectQL();
live.push(engine);
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
engine.registerDriver(driver, true);
await engine.init();
engine.registry.installPackage(manifest(APP_PKG, 'app'), 'code');
engine.registry.installPackage(manifest(MODULE_PKG, 'module'), 'code');
engine.registry.registerObject(OWNER as any, APP_PKG, 'wx', 'own');
engine.registry.registerObject(EXTENSION as any, MODULE_PKG, 'wx', 'extend');
await engine.syncSchemas();
// The legacy values each walk would rewrite, written below the engine's door.
await driver.create(OBJECT, { id: 'r1', name: 'A', wx_tier: '', wx_level: 3, wx_tags: [], wx_note: 7 });
return engine;
}

/** `column`: the one this walk rewrites; `inScope`: every extension column its own rule covers. */
const WALKS = [
{ label: 'blank-single-values', column: 'wx_tier', inScope: ['wx_level', 'wx_tier'], run: cleanBlankSingleValues, format: formatBlankSingleValuesReport },
{ label: 'blank-multi-values', column: 'wx_tags', inScope: ['wx_tags'], run: cleanBlankMultiValues, format: formatBlankMultiValuesReport },
{ label: 'option-codes', column: 'wx_level', inScope: ['wx_level', 'wx_tags', 'wx_tier'], run: migrateOptionCodes, format: formatOptionCodesReport },
{ label: 'text-scalars', column: 'wx_note', inScope: ['wx_note'], run: migrateTextScalars, format: formatTextScalarsReport },
] as const;

describe('os migrate value walks: a disabled module\'s extension columns are named (#30626)', () => {
let engine: ObjectQL;
beforeEach(async () => { engine = await boot(); });

for (const walk of WALKS) {
it(`${walk.label}: the control — enabled, the column is read and nothing is named`, async () => {
const report: any = await walk.run(engine as any, silent, {});
expect(report.columns.map((c: any) => `${c.object}.${c.field}`)).toContain(`${OBJECT}.${walk.column}`);
expect(report.withheldColumns).toEqual([]);
expect(walk.format(report).join('\n')).not.toContain(`(package ${MODULE_PKG})`);
});

it(`${walk.label}: disabled, the plan names each skipped in-scope column with its package`, async () => {
engine.registry.disablePackage(MODULE_PKG);
const report: any = await walk.run(engine as any, silent, {});
expect(report.columns).toEqual([]);
expect(report.withheldColumns).toEqual(walk.inScope.map((field) => ({ object: OBJECT, field, packageId: MODULE_PKG })));
expect(walk.format(report)).toContain(` • ${OBJECT}.${walk.column} (package ${MODULE_PKG})`);
});
}

it('value-shapes (no column rule): every withheld field of the object is named', async () => {
engine.registry.disablePackage(MODULE_PKG);
const named = withheldColumns(engine as any, undefined);
expect(named.map((c) => c.field)).toEqual(['wx_level', 'wx_note', 'wx_tags', 'wx_tier']);
expect(named.every((c) => c.object === OBJECT && c.packageId === MODULE_PKG)).toBe(true);
expect(formatWithheldColumns(named)).toHaveLength(5);
});
});
Loading
Loading