Skip to content
16 changes: 16 additions & 0 deletions .changeset/25030-preflight-issuer-physical-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-auth': minor
'@objectstack/cli': patch
---

fix(plugin-auth): `os migrate apply`'s `sys_account.issuer` pre-flight report names each colliding row's stored issuer instead of `(none)`

Clause-②: yes (widening)

`probeAccountIdentityCollisions` read `sys_account` through the engine's `find`. The engine answers declared columns only, and the shipped `sys_account` no longer declares `issuer`, so the report `os migrate apply --allow-destructive` prints when it refuses the `sys_account.issuer` drop labelled every colliding row's issuer `(none)`, in the human report and in the `--json` document's `preflight.collisions[].issuers` alike. `os migrate account-issuer` read the same database through the driver and printed the stored values. The issuers are what an operator needs to decide which colliding row survives.

- **`@objectstack/plugin-auth`.** When its source offers the storage door (`getDriverForObject`, as the `objectql` engine both CLI doors pass does), the probe reads `sys_account` through the driver the engine routes that name to, and through nothing else: a missing driver is refused, never answered from the engine's `find`. A source that offers `find` alone is still read as handed. A new option, `onTableAbsent`, reads a missing `sys_account` table as no rows and reports that it did. Without it, a missing table is refused like any other failed read.
- **Widened public surface (`@objectstack/plugin-auth`).** The package entry re-exports the pre-flight module, so it now publishes two new types, `AccountTableStorage` (`getDriverForObject`) and `AccountTableDriver`, and a new option, `AccountIdentityPreflightOptions.onTableAbsent`. `probeAccountIdentityCollisions` now accepts `AccountTableStorage | AccountIdentityReadEngine` as its source. Every earlier caller still type-checks, and nothing narrows.
- **`@objectstack/cli`.** `os migrate account-issuer` reads through the probe's reader instead of its own, so it and `os migrate apply` read one table one way. Its output is unchanged.

**Unchanged.** The collision verdict (`ok`, the colliding keys, row ids, users, counts), the `apply` refusal and its exit code. A database whose `sys_account` has no `issuer` column reports each row as `(none)`, as before.
68 changes: 21 additions & 47 deletions packages/cli/src/commands/migrate/account-issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { isMissingTableError } from '@objectstack/types';
import {
printHeader,
printSuccess,
Expand All @@ -17,14 +16,6 @@ import {
} from '../../utils/format.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';

/** The table this pre-flight inventories. Never written. */
const SYS_ACCOUNT = 'sys_account';

/** The driver read this pre-flight issues: `find` only, read-only by construction. */
interface AccountTableDriver {
find(object: string, query: Record<string, unknown>): Promise<unknown>;
}

/**
* `os migrate account-issuer` — the PLAN leg of the `sys_account.issuer`
* retirement (#17440).
Expand Down Expand Up @@ -134,51 +125,34 @@ export default class MigrateAccountIssuer extends Command {
const engine = (stack.kernel as { getService?: (n: string) => unknown }).getService?.call(
stack.kernel,
'objectql',
) as { getDriverForObject?: (object: string) => AccountTableDriver | undefined } | undefined;
) as Parameters<typeof probeAccountIdentityCollisions>[0];

if (!flags.json) printStep('Scanning sys_account…');

// The pre-flight reads the PHYSICAL `sys_account` table through the
// driver the engine routes that name to, not through the engine's
// `find`. This boot composes no `AuthPlugin`, so `sys_account` is not a
// registered object here, and the engine refuses a name its registry
// does not resolve (`OBJECT_NOT_FOUND`) before any driver is asked. That
// refusal is a fact about this boot's composition, never about the
// database: ⛔ reading it as "no rows" would report a table full of
// accounts as a clean pre-flight and authorise the drop. The table is
// read in its legacy shape, `issuer` included, which is the very column
// the registered schema no longer declares, so the driver (the path the
// engine leaves to host code) is the reader this inventory needs.
// [#25030] The pre-flight reads the PHYSICAL `sys_account` table through
// the probe's own reader, `@objectstack/plugin-auth`'s: the driver the
// engine routes that name to, never the engine's `find`. It is the same
// reader `os migrate apply`'s drop gate runs, so the two reports cannot
// disagree about a row's stored `issuer`. This boot composes no
// `AuthPlugin`, so `sys_account` is not a registered object here, and the
// engine's `find` refuses a name its registry does not resolve
// (`OBJECT_NOT_FOUND`) before any driver is asked. ⛔ Reading that
// refusal as "no rows" would report a table full of accounts as a clean
// pre-flight and authorise the drop.
//
// [#21552] A database with no `sys_account` table holds no account, so no
// two rows collide: the probe reads it as no rows. The read is not
// avoided, measured: `sys_account` is not a registered object on this
// boot, so the held-back sync never lists it, and
// `stack.tableAbsent('sys_account')` answers false on every database.
// The driver's missing-table refusal is therefore recognised, with the
// shared predicate and for this command's own table only. ⛔ No other
// refused read is softened: it still throws the probe's refusal below
// and is never read as clean.
// two rows collide: the probe reads it as no rows, and only because this
// command asks it to and says so below. The read is not avoided,
// measured: `sys_account` is not a registered object on this boot, so the
// held-back sync never lists it, and `stack.tableAbsent('sys_account')`
// answers false on every database. ⛔ No other refused read is softened:
// it still throws the probe's refusal and is never read as clean.
let noAccountTable = false;
const readView: Parameters<typeof probeAccountIdentityCollisions>[0] = {
find: async (object, query) => {
// No driver is not an empty table: the probe turns this into its
// refusal, never into a clean report.
const driver = engine?.getDriverForObject?.(object);
if (!driver || typeof driver.find !== 'function') {
throw new Error(`no driver serves ${object} on this stack`);
}
try {
return await driver.find(object, query);
} catch (error) {
if (object !== SYS_ACCOUNT || !isMissingTableError(error, object)) throw error;
noAccountTable = true;
return [];
}
},
};
const report = await probeAccountIdentityCollisions(readView, {
const report = await probeAccountIdentityCollisions(engine, {
...(flags['max-records'] != null ? { max: flags['max-records'] } : {}),
onTableAbsent: () => {
noAccountTable = true;
},
});
const noAccountTableLine = noAccountTable
? 'sys_account has no table in this database yet, so no account is stored in it and it was read as no rows.'
Expand Down
Loading
Loading