From c2cdd7d585ab7effc6b3db35ad336be1e476e71d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:57:03 +0000 Subject: [PATCH] fix(cli): os migrate value walks name the columns a disabled package's extension adds blank-single-values, blank-multi-values, option-codes, text-scalars and value-shapes scope by the served fold, which withholds a disabled package's objectExtensions fields (their columns are kept). Each report now names every in-scope column it skipped that way (object, field, package id), in the plan and in the --json document as withheldColumns, so the operator re-runs after enabling the package. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cwPmVRHj9NxSn1E2Xz --- ...0626-migrate-withheld-extension-columns.md | 10 ++ .../commands/migrate/blank-multi-values.ts | 3 + .../commands/migrate/blank-single-values.ts | 16 +++ .../cli/src/commands/migrate/option-codes.ts | 16 +++ .../cli/src/commands/migrate/text-scalars.ts | 16 +++ .../cli/src/commands/migrate/value-shapes.ts | 8 +- ...held-extension-columns.integration.test.ts | 109 ++++++++++++++++++ .../utils/migrate-withheld-columns.test.ts | 55 +++++++++ .../cli/src/utils/migrate-withheld-columns.ts | 89 ++++++++++++++ 9 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 .changeset/30626-migrate-withheld-extension-columns.md create mode 100644 packages/cli/src/commands/migrate/withheld-extension-columns.integration.test.ts create mode 100644 packages/cli/src/utils/migrate-withheld-columns.test.ts create mode 100644 packages/cli/src/utils/migrate-withheld-columns.ts diff --git a/.changeset/30626-migrate-withheld-extension-columns.md b/.changeset/30626-migrate-withheld-extension-columns.md new file mode 100644 index 00000000000..b9154b4e84a --- /dev/null +++ b/.changeset/30626-migrate-withheld-extension-columns.md @@ -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. diff --git a/packages/cli/src/commands/migrate/blank-multi-values.ts b/packages/cli/src/commands/migrate/blank-multi-values.ts index f45c48e99f6..863d7640184 100644 --- a/packages/cli/src/commands/migrate/blank-multi-values.ts +++ b/packages/cli/src/commands/migrate/blank-multi-values.ts @@ -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, @@ -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; } @@ -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, { diff --git a/packages/cli/src/commands/migrate/blank-single-values.ts b/packages/cli/src/commands/migrate/blank-single-values.ts index 9c1e708bf9c..dcfaff93891 100644 --- a/packages/cli/src/commands/migrate/blank-single-values.ts +++ b/packages/cli/src/commands/migrate/blank-single-values.ts @@ -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 { if (!process.stdin.isTTY) return false; // non-interactive → require --yes @@ -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. */ @@ -84,6 +95,8 @@ export interface BlankSingleValuesEngine { getConfigs?(): Record; find(object: string, options: Record): Promise>>; update(object: string, data: Record, options?: any): Promise; + /** [#30626] Where the columns a disabled package's extension adds are read from. */ + registry?: WithheldColumnsRegistry; } export interface BlankSingleValuesLogger { @@ -265,6 +278,7 @@ export async function walkBlankColumns( truncated, unreadableObjects, failures, + withheldColumns: withheldColumns(engine, candidates, spec.columnsOf), }; } @@ -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; } @@ -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, { diff --git a/packages/cli/src/commands/migrate/option-codes.ts b/packages/cli/src/commands/migrate/option-codes.ts index 16ad7357daf..ec71105b3ab 100644 --- a/packages/cli/src/commands/migrate/option-codes.ts +++ b/packages/cli/src/commands/migrate/option-codes.ts @@ -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 { if (!process.stdin.isTTY) return false; // non-interactive → require --yes @@ -188,6 +194,11 @@ 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 { @@ -195,6 +206,8 @@ export interface OptionCodesEngine { getConfigs?(): Record; find(object: string, options: Record): Promise>>; update(object: string, data: Record, options?: any): Promise; + /** [#30626] Where the columns a disabled package's extension adds are read from. */ + registry?: WithheldColumnsRegistry; } export interface OptionCodesLogger { @@ -347,6 +360,7 @@ export async function migrateOptionCodes( truncated, unreadableObjects, failures, + withheldColumns: withheldColumns(engine, candidates, optionCodeFields), }; } @@ -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; } @@ -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, { diff --git a/packages/cli/src/commands/migrate/text-scalars.ts b/packages/cli/src/commands/migrate/text-scalars.ts index 3fd8623672a..038e444d685 100644 --- a/packages/cli/src/commands/migrate/text-scalars.ts +++ b/packages/cli/src/commands/migrate/text-scalars.ts @@ -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 { if (!process.stdin.isTTY) return false; // non-interactive → require --yes @@ -149,6 +155,11 @@ 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 { @@ -156,6 +167,8 @@ export interface TextScalarsEngine { getConfigs?(): Record; find(object: string, options: Record): Promise>>; update(object: string, data: Record, options?: any): Promise; + /** [#30626] Where the columns a disabled package's extension adds are read from. */ + registry?: WithheldColumnsRegistry; } export interface TextScalarsLogger { @@ -282,6 +295,7 @@ export async function migrateTextScalars( truncated, unreadableObjects, failures, + withheldColumns: withheldColumns(engine, candidates, textScalarFields), }; } @@ -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; } @@ -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, { diff --git a/packages/cli/src/commands/migrate/value-shapes.ts b/packages/cli/src/commands/migrate/value-shapes.ts index 9f241dc20a8..197de35fe4d 100644 --- a/packages/cli/src/commands/migrate/value-shapes.ts +++ b/packages/cli/src/commands/migrate/value-shapes.ts @@ -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 { if (!process.stdin.isTTY) return false; // non-interactive → require --yes @@ -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(', ')}.` @@ -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(), @@ -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); diff --git a/packages/cli/src/commands/migrate/withheld-extension-columns.integration.test.ts b/packages/cli/src/commands/migrate/withheld-extension-columns.integration.test.ts new file mode 100644 index 00000000000..7846e0c43dc --- /dev/null +++ b/packages/cli/src/commands/migrate/withheld-extension-columns.integration.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/utils/migrate-withheld-columns.test.ts b/packages/cli/src/utils/migrate-withheld-columns.test.ts new file mode 100644 index 00000000000..c52cc5ae7ed --- /dev/null +++ b/packages/cli/src/utils/migrate-withheld-columns.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { withheldColumns, formatWithheldColumns } from './migrate-withheld-columns.js'; + +/** A served fold missing what a disabled extension adds, over a storage fold that keeps it. */ +function fakeEngine(disabled: ReadonlySet) { + const ext = (pkg: string, fields: string[]) => ({ + packageId: pkg, ownership: 'extend', definition: { fields: Object.fromEntries(fields.map((f) => [f, {}])) }, + }); + const contributors = [ + { packageId: 'app', ownership: 'own', definition: { fields: { id: {}, name: {} } } }, + ext('off', ['a_off', 'b_off']), + ext('on', ['c_on']), + ]; + const all = ['id', 'name', 'a_off', 'b_off', 'c_on']; + const served = all.filter((f) => !(disabled.has('off') && f.endsWith('_off'))); + const body = (names: string[]) => ({ fields: Object.fromEntries(names.map((f) => [f, { type: 'text' }])) }); + return { + getObject: () => body(served), + getConfigs: () => ({ acct: {} }), + registry: { + getStorageObject: () => body(all), + getObjectContributors: () => contributors, + isPackageDisabled: (id?: string) => id !== undefined && disabled.has(id), + }, + }; +} + +describe('withheldColumns (#30626)', () => { + it('names each field a disabled extension adds, with its package; an enabled one is not named', () => { + expect(withheldColumns(fakeEngine(new Set(['off'])))).toEqual([ + { object: 'acct', field: 'a_off', packageId: 'off' }, + { object: 'acct', field: 'b_off', packageId: 'off' }, + ]); + expect(withheldColumns(fakeEngine(new Set()))).toEqual([]); + }); + + it('a column rule narrows the list to the columns the walk would cover', () => { + const select = (schema: any) => Object.keys(schema?.fields ?? {}).filter((f) => f.startsWith('a_')).map((name) => ({ name })); + expect(withheldColumns(fakeEngine(new Set(['off'])), undefined, select).map((c) => c.field)).toEqual(['a_off']); + }); + + it('an engine without the registry surface names nothing', () => { + expect(withheldColumns({ getObject: () => undefined, getConfigs: () => ({ acct: {} }) })).toEqual([]); + }); + + it('formats nothing for an empty list, a header and one line per column otherwise', () => { + expect(formatWithheldColumns([])).toEqual([]); + const lines = formatWithheldColumns([{ object: 'acct', field: 'a_off', packageId: 'off' }]); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain('acct.a_off'); + expect(lines[1]).toContain('off'); + }); +}); diff --git a/packages/cli/src/utils/migrate-withheld-columns.ts b/packages/cli/src/utils/migrate-withheld-columns.ts new file mode 100644 index 00000000000..98561c6e8ef --- /dev/null +++ b/packages/cli/src/utils/migrate-withheld-columns.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30626] The columns an `os migrate` value walk cannot read because the + * package that adds them is disabled. + * + * The value walks scope by `engine.getObject(object)`, the SERVED fold. While a + * package is disabled, the fields its `objectExtensions` add are withheld from + * that fold (#30304) and `engine.find` refuses them (`INVALID_FIELD`), but their + * columns and the values in them are kept: the STORAGE fold + * (`SchemaRegistry.getStorageObject`) still has them. A run therefore skips + * those columns, and without this list its report read as covering them. + * + * Named, not read: reading them needs a storage-fold read path in the engine + * (#30626, out of scope). The operator enables the package and re-runs. + */ + +/** One column a run did not read, and the disabled package that adds it. */ +export interface WithheldColumn { + object: string; + field: string; + /** The disabled package whose `objectExtensions` entry adds the field. */ + packageId: string; +} + +/** The registry surface the list is read from (`SchemaRegistry`). */ +export interface WithheldColumnsRegistry { + getStorageObject?(name: string): unknown; + getObjectContributors?(name: string): ReadonlyArray<{ + packageId?: string; + ownership: string; + definition?: { fields?: Record }; + }>; + isPackageDisabled?(packageId?: string): boolean; +} + +export interface WithheldColumnsEngine { + getObject(name: string): unknown; + getConfigs?(): Record; + registry?: WithheldColumnsRegistry; +} + +/** + * The columns of `objects` (default: every loaded object) that a walk scoped + * by `select` would cover on the storage fold but does not see on the served + * fold, each with the disabled package that adds it. `select` is the walk's + * own column rule, so a column the walk would not cover anyway is not named; + * without it, every withheld field is. + */ +export function withheldColumns( + engine: WithheldColumnsEngine, + objects?: readonly string[], + select?: (schema: unknown) => ReadonlyArray<{ name: string }>, +): WithheldColumn[] { + const registry = engine.registry; + if ( + typeof registry?.getStorageObject !== 'function' + || typeof registry.getObjectContributors !== 'function' + || typeof registry.isPackageDisabled !== 'function' + ) return []; + const names = (schema: unknown): string[] => select + ? select(schema).map((c) => c.name) + : Object.keys((schema as { fields?: Record } | undefined)?.fields ?? {}); + const candidates = objects ?? (typeof engine.getConfigs === 'function' ? Object.keys(engine.getConfigs()) : []); + + const out: WithheldColumn[] = []; + for (const object of candidates) { + const disabled = registry.getObjectContributors(object) + .filter((c) => c.ownership === 'extend' && registry.isPackageDisabled!(c.packageId)); + if (disabled.length === 0) continue; + const served = new Set(names(engine.getObject(object))); + for (const field of names(registry.getStorageObject(object))) { + if (served.has(field)) continue; + const by = disabled.find((c) => c.definition?.fields && Object.hasOwn(c.definition.fields, field)); + if (by?.packageId) out.push({ object, field, packageId: by.packageId }); + } + } + return out.sort((a, b) => a.object.localeCompare(b.object) || a.field.localeCompare(b.field)); +} + +/** The report lines naming them; none when the list is empty. */ +export function formatWithheldColumns(columns: readonly WithheldColumn[]): string[] { + if (columns.length === 0) return []; + return [ + `⚠ ${columns.length} column(s) not read: the package that adds each is disabled. ` + + 'Enable the package and re-run to cover them:', + ...columns.map((c) => ` • ${c.object}.${c.field} (package ${c.packageId})`), + ]; +}