Skip to content

Commit 6990b0d

Browse files
fix(driver-sql,cli): os migrate plan previews a _id-keyed table as apply treats it; one refused table no longer stops apply (#30750)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DQK6nb1EwEVz5iwboqt6xrS)_ Fixes #30636 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:`os migrate plan` 遇到用 `_id` 当主键、没有 `id` 列的已有表,不再预报「补列」,而是照 apply 的真实做法预报:有数据就列为「拒绝」(带表名、`_id`、行数和拒绝原文),空表就列为「重建」。`os migrate apply` 遇到这种被拒的表,不再整批停下:其余表照常建表补列,被拒的表单独点名,原样不动,最后 PARTIAL、退出码 1。 - 为什么改:实测 main 上 plan 对这两种表都说「补列」(无害),apply 却在第一张有数据的 `_id` 表上抛错退出,另外 80 张该建的表一张没建、该补的列一列没补,已完成清单也随异常丢了。 - 风险与回滚:只动 driver-sql 的预览与延迟 DDL 冲刷、CLI 的 plan/apply 渲染;正常表(有 `id`)的预览与执行一字未变。另加一条保护:被拒表上的 drift(其 `_id` 列看起来是「孤儿列」)不再下发,即使带 `--allow-destructive` 也不会删它。回滚即撤本 PR。 - 先看:`sql-driver-deferred-ddl.ts`(按预览把被拒表排除出冲刷);`sql-driver-schema-sync.ts` 的 `previewDeferredSchemaWork` 新分支;`apply.ts` 冲刷之后那段(拆分被拒、扣下被拒表的 drift、PARTIAL)。 - 两分钟验证:`pnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-schema.test.ts`,预期 sqlite 格 7 个 `schema sync never drops a table holding rows` 用例全绿(其中 4 个是本 PR 新增)。 - 需要你做的:无,席位落地。 ## What changes An existing table keyed by `_id` with no `id` column is the one shape the schema sync does not adopt by adding columns: `initObjects` rebuilds it while it is empty and, since PR #30632, refuses it (`ExistingTableAdoptionRefusal`) while it holds rows. - **Plan** (`previewDeferredSchemaWork`, `sql-driver-schema-sync.ts`): that shape is now planned as the sync treats it, with the same two probes and the same row count. Holding rows: a `refuse_table` entry, `columns: ['_id']`, `rows: N`, and `refusal`, the text rendered by `renderExistingTableAdoptionRefusal`, the renderer the sync throws through. Empty: a `rebuild_table` entry (`rows: 0`, the declared columns). Never `add_columns`. A rotation-declared object never reaches that branch of the sync, so it does not reach this one. - **Kinds** (`schema-drift.ts`): `PendingSchemaWorkKind` gains `rebuild_table` and `refuse_table`; `PendingSchemaWork` gains the optional `refusal`; `isRefusedSchemaWork(kind)` is exported beside `isInPlaceSchemaWork`, which now answers `true` for `rebuild_table` (it is not additive) and `false` for `refuse_table` (it is no work at all). - **Apply, driver side** (`flushDeferredSchemaDdl`, `sql-driver-deferred-ddl.ts`): the flush already captures the preview before running the DDL; a table the preview refused is now left out of the one `initObjects(pending)` re-entry, so it stops nothing. Every other deferred object gets its DDL and the refusal comes back as its entry in the returned list instead of a throw that took that list with it. The sync's own refusal still guards the rows if the table fills between preview and DDL. - **Apply, CLI side** (`apply.ts`, `schema-migrate.ts`): `splitRefusedSchemaWork` splits what the flush returned; `created` lists performed work only, each refused table is printed with its refusal text, and the run ends `PARTIAL` with exit 1 (`--json`: `refused` beside `created`, `message: "partial"`). Drift entries on a refused table are withheld from the driver and reported in `skipped`: the table's `_id` column reads as an orphaned column, and with `--allow-destructive` the driver would otherwise drop it from the table the sync just left alone (on `main` the throw stopped the run before that call, so this path is new with "carry on"). - **Rendering**: `renderPendingSchemaWork` lists a refused table under its own heading `Refused (left exactly as it is when you apply)` with the refusal text, and a rebuild under the in-place heading; `summarizePendingSchemaWork` adds `N empty table(s) to rebuild` / `N table(s) REFUSED` when there are some and no longer counts them as temporal columns. `addRefusedTableLimits` gives each drift entry on a refused table that refusal as its apply limit, so neither `plan` nor the listing `apply` prints before its prompt offers `--allow-destructive` for it. Changed lines (`git diff origin/main --stat`): 12 files, +553 / −18 (driver-sql src +65/−4, CLI src +131/−12, tests +344/−2, changeset +13). ## Tests - `packages/drivers/driver-sql/src/sql-driver-schema.test.ts`, the `schema sync never drops a table holding rows` matrix (every dialect cell): the plan pin on both arms (rows: exactly one `refuse_table` entry whose `refusal` equals the renderer's output AND the message `syncSchema` throws for the same table; empty: `rebuild_table`, then the flush performs it), the CONTROL (`id`-keyed table: `add_columns ['qty']`), and the scope addition (a refused table between two good ones in deferred order: both get their DDL, the returned list carries both `create_table` entries and the `refuse_table` entry, the refused table is byte-for-byte as before, the deferral is disarmed). - `packages/cli/src/utils/schema-migrate.pending-render.test.ts`: the Refused heading and text, the rebuild line, the summary, the split. - `packages/cli/test/migrate-plan-refused-table.e2e.test.ts` (nightly e2e tier): the CLI measurement below as a pin, `plan --json` then `apply --yes --allow-destructive --json` on a seeded SQLite file. <details><summary>Measured through the CLI (fixture: `legacy_full` _id + 3 rows, `legacy_empty` _id empty, `plain_ok` id-keyed missing `note`, `zz_fresh` absent)</summary> `origin/main` (driver-sql dist and CLI src from 89dbc74): - `os migrate plan --json`: `legacy_full` and `legacy_empty` both `kind: "add_columns"` (8 columns each), exit 0. - `os migrate apply --yes`: lists `legacy_full [add_columns: …]`, then `✗ Schema sync REFUSED for the existing table 'legacy_full' …`, exit 1. Afterwards: `legacy_full` 3 rows untouched, but `legacy_empty` not rebuilt, `plain_ok` without `note`, `zz_fresh` absent: none of the 80 creates / 25 columns listed was performed. This branch: - `os migrate plan`: `+ plain_ok [add_columns: …, note]`, `~ legacy_empty [rebuild_table: empty table dropped and recreated, 9 column(s)]`, `✗ legacy_full [refuse_table: _id — 3 row(s)]` + the refusal text; summary `80 table(s) to create, 9 column(s) to add, 1 empty table(s) to rebuild, 1 table(s) REFUSED`; `legacy_full._id [drop_column]` says `"os migrate apply" cannot apply it on this database: the schema sync refuses the table 'legacy_full' …`. - `os migrate apply --yes`: `✓ Created/extended 82 table(s)`, `✗ REFUSED legacy_full [refuse_table: _id — 3 row(s)]` + text, `✗ PARTIAL apply: schema sync REFUSED 1 existing table(s) …`, exit 1. Afterwards `legacy_full` `["_id","title"]` 3 rows; `legacy_empty` rebuilt with `id`; `plain_ok` has `note`; `zz_fresh` created. - `os migrate apply --yes --json`: `created` 82 entries, `refused` [legacy_full, 3 rows, refusal text], `notApplied: []`, `message: "partial"`, exit 1. - `os migrate apply --yes --allow-destructive`: same, and `legacy_full` still `["_id","title"]` with 3 rows. </details> <details><summary>Ablation (commit aa54f87 on the fixed tree, driver tests read src/)</summary> - Flush leg: `initObjects(refused.size === 0 ? pending : pending.filter(…))` → `initObjects(pending)` via `scripts/ablation-replace.mjs` (anchor 1 → 0, blob aa5c540 → 5d90f111): `Tests 1 failed | 26 passed` (the refused-between-two test). Restored, `git diff HEAD` empty. - Plan leg: the preview branch condition prefixed with `false &&` (blob 2de56b0 → c89e4d34): `Tests 3 failed | 24 passed` (both plan arms and the flush test). Restored: blob == HEAD, `git diff HEAD` empty. </details> ## Acceptance notes - **Published type widened.** `PendingSchemaWorkKind` is exported by `@objectstack/driver-sql` (and re-exported as `PendingSchemaWork` from the CLI's `schema-migrate.ts`); it gains two members, `PendingSchemaWork` gains the optional `refusal`, and `isRefusedSchemaWork` is a new export. The `os migrate plan --json` `pending` array and the `os migrate apply --json` document (new `refused` key) carry them. The seat re-derived Clause-② for this as `yes (widening)` (claim amendment 6673377065); the changeset carries the same two lines, both packages stay `patch` (`Metadata-protocol: none`), and its body names the widening for a driver-sql consumer: a caller of `flushDeferredSchemaDdl()` must treat a returned `refuse_table` entry as refused, not performed. - **Behaviour change on apply:** a run that met a refused `_id` table used to stop with the refusal and do nothing else; it now performs every other table's DDL and the drift, and still ends exit 1. Nothing in the tree depended on the stop (the only caller of `flushDeferredSchemaDdl` is `SchemaStack.flushSchemaDdl`). - Not changed: the drift listing still shows `legacy_empty._id [drop_column]` for an empty `_id` table the flush rebuilds; once rebuilt the column is gone, and `--allow-destructive` reports the entry as applied. Pre-existing on `main` for an empty-only database; not this card's surface. ## Verification Head `77b3053a` adds one changeset-only commit over `87ec2b98` (the Clause-② line and one sentence); the changeset gates were re-run on it (see the end of this section). Everything else below is on head `87ec2b98` (this branch merged with `origin/main` 4564d6f), after `pnpm install --frozen-lockfile` and a whole-tree `turbo run build` (lock VERDICT command-exit 0). - `pnpm --filter @objectstack/driver-sql typecheck` exit 0; `pnpm --filter @objectstack/cli typecheck` exit 0 (on aa54f87, before the merge). - `pnpm --filter @objectstack/driver-sql test`: `Test Files 238 passed | 12 skipped (250)`, `Tests 3866 passed | 218 skipped`, exit 0 (aa54f87). - `pnpm --filter @objectstack/cli exec vitest run --project unit`: `Test Files 389 passed (389)`, `Tests 5435 passed (5435)`, exit 0 (aa54f87). The integration tier is declared to CI; the new e2e file ran by hand: `OS_TEST_TIERS=nightly … --project integration test/migrate-plan-refused-table.e2e.test.ts` → `Tests 5 passed (5)`. - `sql-driver-schema.test.ts` against a local PostgreSQL 16 (`OS_TEST_POSTGRES_URL`): `Tests 34 passed | 1 skipped` (the live postgres cell of the matrix, all 7 cases green). MySQL not provisioned here; the `Temporal Conformance (live PG + MySQL)` job runs the cell. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 66 families, each run and exit 0; `--ran` → `66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED`. <details><summary>The 66 gate families (each `:: exit 0`)</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-conformance :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:object-def-param-keys :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:tenant-chokepoint :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> On head `77b3053a` (the changeset-only commit), with this body as the `pull_request` event payload: each `:: exit 0`. ``` node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:issue-citations :: exit 0 ``` `check-changeset-no-major` LEVEL AXIS line: "this PR declares clause-② yes (widening) and metadata-protocol none, and it introduces no changeset entry graded minor or above" (pass). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cy3CHSvvtQP754FaLB <!-- signed: domain:server#4 · os-elon-musk · session_016WV4cy3CHSvvtQP754FaLB --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e4e041f commit 6990b0d

12 files changed

Lines changed: 553 additions & 18 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(driver-sql,cli): `os migrate plan` previews a `_id`-keyed table as apply treats it, and one refused table no longer stops `os migrate apply`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
An existing table keyed by `_id` with no `id` column is the one shape schema sync does not adopt by adding columns: it rebuilds it while it is empty and, since #30562, refuses it while it holds rows. `os migrate plan` listed it as a harmless `add_columns` step. The plan now lists it as `apply` treats it: a `rebuild_table` entry (in place) when the table is empty, and a `refuse_table` entry when it holds rows, naming the table, the `_id` column and the row count, with the refusal's own text (`refusal`, rendered by the same renderer the sync throws through). `isRefusedSchemaWork` is exported beside `isInPlaceSchemaWork`. For a driver-sql consumer this widens the published `PendingSchemaWorkKind` / `PendingSchemaWork`: a caller of `flushDeferredSchemaDdl()` must now treat a returned `refuse_table` entry (`isRefusedSchemaWork`) as a table that was refused and left as it is, not as performed work — where that call used to throw.
12+
13+
`os migrate apply` used to hand every held-back object to one sync call, so one refused table stopped the batch: the tables after it got no DDL and the list of performed work was lost with the error. The refused table is now left out of that call and reported on its own: every other table gets its DDL, the run names the refused table with the refusal text, and ends PARTIAL with exit 1 (`--json`: `refused` beside `created`, `message: "partial"`).

‎packages/cli/src/commands/migrate/apply.ts‎

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import {
2626
summarizePendingSchemaWork,
2727
groupByCategory,
2828
SKIPPED_SCHEMA_SYNC_NOTE,
29+
describeRefusedSchemaWork,
30+
splitRefusedSchemaWork,
31+
addRefusedTableLimits,
2932
} from '../../utils/schema-migrate.js';
3033
import type { ManagedDriftEntry } from '@objectstack/driver-sql';
3134
import { exitOneShotCommand } from '../../utils/one-shot-exit.js';
@@ -347,6 +350,8 @@ export default class MigrateApply extends Command {
347350
// the --json document, `notApplied`, PARTIAL and the exit code never read
348351
// it. Empty for a driver that declares nothing, which reads as it always has.
349352
const limits = flags.json ? new Map<ManagedDriftEntry, string>() : collectDriftApplyLimits(stack, drift);
353+
// [#30636] …and every entry on a table the sync refuses (withheld below).
354+
if (!flags.json) await addRefusedTableLimits(limits, drift, pending);
350355
// [#24958] Each drop gate below reads at most once per run: the listing
351356
// and the gate share that read, so they cannot answer differently.
352357
const gateReads = new DropGateReads();
@@ -680,8 +685,22 @@ export default class MigrateApply extends Command {
680685
// marker when it created the database from empty — the half this
681686
// deferred boot's birth plugin could not run — so the first `os serve`
682687
// admits it. A database that was not empty gets none.
683-
const created = await stack.flushSchemaDdl();
684-
const { applied, skipped } = await stack.applyMigrationEntries(drift, { allowDestructive });
688+
// [#30636] A table the sync refused (`refuse_table`: keyed by `_id`, no
689+
// `id`, holding rows) comes back beside the work performed, never in a
690+
// throw: every other table still got its DDL. It is not performed work,
691+
// so it is never counted in `created`; it is named, and the run is PARTIAL.
692+
const { performed: created, refused } = await splitRefusedSchemaWork(await stack.flushSchemaDdl());
693+
// …and no drift entry on a refused table reaches the driver either: its
694+
// `_id` column reads as an orphaned column, and with --allow-destructive
695+
// the driver would drop it from the table the sync just left alone.
696+
// Those entries are reported as skipped, never as attempted.
697+
const refusedTables = new Set(refused.map((p) => p.table));
698+
const onRefused = drift.filter((d) => refusedTables.has(d.table));
699+
const { applied, skipped: driverSkipped } = await stack.applyMigrationEntries(
700+
onRefused.length === 0 ? drift : drift.filter((d) => !refusedTables.has(d.table)),
701+
{ allowDestructive },
702+
);
703+
const skipped = [...driverSkipped, ...onRefused];
685704

686705
// ── [#23848] NAME WHAT THE DRIVER DID NOT APPLY ──────────────────
687706
//
@@ -701,9 +720,9 @@ export default class MigrateApply extends Command {
701720
// applies (`manual_column_type_change` and its siblings) have always
702721
// been skipped on every dialect, and still are.
703722
const held = new Set<ManagedDriftEntry>(deferred);
704-
const notApplied = skipped.filter((d) => !held.has(d));
723+
const notApplied = driverSkipped.filter((d) => !held.has(d));
705724
const unappliedDestructive = notApplied.filter((d) => d.category === 'destructive');
706-
const partial = unappliedDestructive.length > 0;
725+
const partial = unappliedDestructive.length > 0 || refused.length > 0;
707726

708727
if (flags.json) {
709728
await emitJson({
@@ -712,6 +731,7 @@ export default class MigrateApply extends Command {
712731
applied,
713732
skipped,
714733
notApplied,
734+
...(refused.length > 0 ? { refused } : {}),
715735
...(partial ? { message: 'partial' } : {}),
716736
...compositionPayload,
717737
duration: timer.elapsed(),
@@ -729,7 +749,7 @@ export default class MigrateApply extends Command {
729749
// rest are named as drift it could not apply either. [#24958] While a
730750
// gate holds a drop, no re-run is offered: the lines before the prompt
731751
// name each gate's command.
732-
const heldBackEntries = skipped.filter((d) => held.has(d));
752+
const heldBackEntries = driverSkipped.filter((d) => held.has(d));
733753
const heldBack = heldBackEntries.filter((d) => !neverReRunnable(d)).length;
734754
const heldBackNotReRunnable = heldBackEntries.length - heldBack;
735755
if (heldBack > 0) {
@@ -752,11 +772,30 @@ export default class MigrateApply extends Command {
752772
}
753773
console.log(chalk.dim(` ${timer.display()}`));
754774
console.log('');
755-
if (partial) {
756-
printError(
757-
`PARTIAL apply: ${unappliedDestructive.length} destructive change(s) confirmed with --allow-destructive were `
758-
+ 'NOT applied (named above), so this database still differs from its metadata.',
775+
// [#30636] Each refused table, with the refusal's own text, and the drift
776+
// on it this run withheld.
777+
for (const p of refused) {
778+
printError(`REFUSED ${describeRefusedSchemaWork(p)}`);
779+
if (p.refusal) console.log(chalk.dim(` ${p.refusal}`));
780+
}
781+
if (onRefused.length > 0) {
782+
printWarning(
783+
`${onRefused.length} change(s) on the refused table(s) were NOT applied: a refused table is left exactly as it is.`,
759784
);
785+
}
786+
if (partial) {
787+
if (unappliedDestructive.length > 0) {
788+
printError(
789+
`PARTIAL apply: ${unappliedDestructive.length} destructive change(s) confirmed with --allow-destructive were `
790+
+ 'NOT applied (named above), so this database still differs from its metadata.',
791+
);
792+
}
793+
if (refused.length > 0) {
794+
printError(
795+
`PARTIAL apply: schema sync REFUSED ${refused.length} existing table(s) (named above) and left them exactly `
796+
+ 'as they were; every other table got its DDL.',
797+
);
798+
}
760799
this.exit(1);
761800
}
762801
} catch (error: any) {

‎packages/cli/src/commands/migrate/plan.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../../utils/format.js';
1616
import {
1717
bootSchemaStack,
18+
addRefusedTableLimits,
1819
collectDriftApplyLimits,
1920
describeDatabaseSource,
2021
describeTelemetryDatabase,
@@ -469,6 +470,8 @@ export default class MigratePlan extends Command {
469470
// but a drop (it runs no SQLite table rebuild). Empty for a driver that
470471
// declares nothing, and then everything below reads as it always has.
471472
const limits = collectDriftApplyLimits(stack, drift);
473+
// [#30636] …and every entry on a table the sync refuses: apply leaves it as it is.
474+
await addRefusedTableLimits(limits, drift, pending);
472475
// [#24925, #24958] The drops `os migrate apply` refuses here, each with
473476
// the gate that refuses it: an `organization_id` drop until the
474477
// organization-ownership ceremony has verified, and the

‎packages/cli/src/utils/schema-migrate.pending-render.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
1515
import {
1616
renderPendingSchemaWork,
17+
splitRefusedSchemaWork,
1718
summarizePendingSchemaWork,
1819
type PendingSchemaWork,
1920
} from './schema-migrate.js';
@@ -113,3 +114,46 @@ describe('summarizePendingSchemaWork (#3954)', () => {
113114
expect(summary).toContain('~1,234,625 rows');
114115
});
115116
});
117+
118+
/**
119+
* [#30636] A table keyed by `_id` with no `id` column: the plan says what apply
120+
* does with it — rebuilt while empty, refused while it holds rows — and a
121+
* refusal is never listed as work, additive or in place.
122+
*/
123+
describe('the `_id`-keyed table: rebuild and refusal (#30636)', () => {
124+
const REFUSAL_TEXT = "Schema sync REFUSED for the existing table 'legacy_items': the driver's own text";
125+
const REFUSED: PendingSchemaWork = {
126+
table: 'legacy_items', kind: 'refuse_table', columns: ['_id'], rows: 3, refusal: REFUSAL_TEXT,
127+
};
128+
const REBUILD: PendingSchemaWork = { table: 'empty_items', kind: 'rebuild_table', columns: ['title', 'qty'], rows: 0 };
129+
130+
it('lists a refused table under its own heading with the driver-rendered refusal, never as work', async () => {
131+
await renderPendingSchemaWork([...ADDITIVE, REFUSED]);
132+
expect(out()).toContain('Refused (left exactly as it is when you apply)');
133+
expect(out()).toContain('legacy_items [refuse_table: _id — 3 row(s)]');
134+
expect(out()).toContain(REFUSAL_TEXT);
135+
// Not under either work heading: the additive section lists only the two
136+
// additive entries, and no in-place heading appears at all.
137+
expect(out()).not.toContain('In place');
138+
expect(out()).not.toContain('add_columns: _id');
139+
});
140+
141+
it('lists an empty `_id`-keyed table as a rebuild under the in-place heading', async () => {
142+
await renderPendingSchemaWork([REBUILD]);
143+
expect(out()).toContain('In place (existing rows converged when you apply)');
144+
expect(out()).toContain('empty_items [rebuild_table: empty table dropped and recreated, 2 column(s)]');
145+
expect(out()).not.toContain('New (additive');
146+
expect(out()).not.toContain('Refused');
147+
});
148+
149+
it('counts rebuilds and refusals in the summary, never as temporal columns', async () => {
150+
const summary = await summarizePendingSchemaWork([...ADDITIVE, REBUILD, REFUSED]);
151+
expect(summary).toBe('1 table(s) to create, 1 column(s) to add, 1 empty table(s) to rebuild, 1 table(s) REFUSED');
152+
});
153+
154+
it('splits what a flush returned into performed work and refused tables', async () => {
155+
const { performed, refused } = await splitRefusedSchemaWork([...ADDITIVE, REBUILD, REFUSED]);
156+
expect(performed).toEqual([...ADDITIVE, REBUILD]);
157+
expect(refused).toEqual([REFUSED]);
158+
});
159+
});

‎packages/cli/src/utils/schema-migrate.ts‎

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,6 +1698,33 @@ async function loadIsInPlaceSchemaWork(): Promise<(kind: PendingSchemaWork['kind
16981698
return isInPlaceSchemaWork;
16991699
}
17001700

1701+
/** [#30636] The driver's refusal classifier, loaded at the point of use for the reason above. */
1702+
async function loadIsRefusedSchemaWork(): Promise<(kind: PendingSchemaWork['kind']) => boolean> {
1703+
const { isRefusedSchemaWork } = await import('@objectstack/driver-sql');
1704+
return isRefusedSchemaWork;
1705+
}
1706+
1707+
/**
1708+
* [#30636] Split what {@link SchemaStack.flushSchemaDdl} returned into the
1709+
* work it performed and the tables the sync refused and left as they were
1710+
* (`refuse_table`). A refusal is never performed work: `os migrate apply`
1711+
* names each one and ends PARTIAL.
1712+
*/
1713+
export async function splitRefusedSchemaWork(
1714+
work: PendingSchemaWork[],
1715+
): Promise<{ performed: PendingSchemaWork[]; refused: PendingSchemaWork[] }> {
1716+
const isRefusedSchemaWork = await loadIsRefusedSchemaWork();
1717+
return {
1718+
performed: work.filter((w) => !isRefusedSchemaWork(w.kind)),
1719+
refused: work.filter((w) => isRefusedSchemaWork(w.kind)),
1720+
};
1721+
}
1722+
1723+
/** [#30636] One refused table, as the plan and `os migrate apply` name it. */
1724+
export function describeRefusedSchemaWork(p: PendingSchemaWork): string {
1725+
return `${p.table} [${p.kind}: ${p.columns.join(', ')} — ${formatRows(p.rows)} row(s)]`;
1726+
}
1727+
17011728
const CATEGORY_ORDER: DriftCategory[] = ['safe', 'needs_confirm', 'destructive'];
17021729

17031730
const CATEGORY_META: Record<DriftCategory, { label: string; color: (s: string) => string; icon: string }> = {
@@ -1764,6 +1791,30 @@ export function collectDriftApplyLimits(
17641791
return limits;
17651792
}
17661793

1794+
/**
1795+
* [#30636] Mark every drift entry on a table the schema sync refuses
1796+
* (`refuse_table` in `work`) with that refusal as its apply limit, in place.
1797+
* `os migrate apply` applies no drift on a refused table — it is left exactly
1798+
* as it is — so the listing must not offer `--allow-destructive` for it (the
1799+
* `_id` column such a table carries reads as an orphaned column to drop).
1800+
* Read by `os migrate plan` and by the listing `os migrate apply` prints
1801+
* before its prompt; `apply` itself withholds those entries from the driver.
1802+
*/
1803+
export async function addRefusedTableLimits(
1804+
limits: Map<ManagedDriftEntry, string>,
1805+
drift: readonly ManagedDriftEntry[],
1806+
work: PendingSchemaWork[],
1807+
): Promise<void> {
1808+
const { refused } = await splitRefusedSchemaWork(work);
1809+
if (refused.length === 0) return;
1810+
const tables = new Set(refused.map((p) => p.table));
1811+
for (const d of drift) {
1812+
if (tables.has(d.table)) {
1813+
limits.set(d, `the schema sync refuses the table '${d.table}' (listed under Refused) and leaves it exactly as it is`);
1814+
}
1815+
}
1816+
}
1817+
17671818
/**
17681819
* [#23925] The {@link renderPlan} `describe` for those entries: the finding
17691820
* without its `--allow-destructive` sentence, then the driver's limit, so the
@@ -1808,8 +1859,9 @@ export async function renderPendingSchemaWork(pending: PendingSchemaWork[]): Pro
18081859
if (pending.length === 0) return;
18091860

18101861
const isInPlaceSchemaWork = await loadIsInPlaceSchemaWork();
1811-
const additive = pending.filter((p) => !isInPlaceSchemaWork(p.kind));
1812-
const inPlace = pending.filter((p) => isInPlaceSchemaWork(p.kind));
1862+
const { performed: work, refused } = await splitRefusedSchemaWork(pending);
1863+
const additive = work.filter((p) => !isInPlaceSchemaWork(p.kind));
1864+
const inPlace = work.filter((p) => isInPlaceSchemaWork(p.kind));
18131865

18141866
if (additive.length > 0) {
18151867
console.log(` ${chalk.bold('New (additive — created when you apply)')}`);
@@ -1827,6 +1879,15 @@ export async function renderPendingSchemaWork(pending: PendingSchemaWork[]): Pro
18271879
for (const p of inPlace) {
18281880
// A MySQL widen is `ALTER … MODIFY`, i.e. a full table rebuild holding a
18291881
// metadata lock — worth saying outright, not just implying via the count.
1882+
// [#30636] An empty `_id`-keyed table is dropped and recreated: say so,
1883+
// rather than listing every declared column as if each were converged.
1884+
if (p.kind === 'rebuild_table') {
1885+
console.log(
1886+
` ${chalk.yellow('~')} ${chalk.yellow(p.table)} ` +
1887+
`${chalk.dim(`[${p.kind}: empty table dropped and recreated, ${p.columns.length} column(s)]`)}`,
1888+
);
1889+
continue;
1890+
}
18301891
const cost = p.kind === 'widen_datetime_columns' || p.kind === 'widen_time_columns'
18311892
? `${formatRows(p.rows)} row table rebuild`
18321893
: `${formatRows(p.rows)} row update(s)`;
@@ -1837,6 +1898,17 @@ export async function renderPendingSchemaWork(pending: PendingSchemaWork[]): Pro
18371898
}
18381899
console.log('');
18391900
}
1901+
1902+
// [#30636] Work the sync will NOT do: each table it refuses, with the
1903+
// refusal's own text (rendered by the driver, the text `apply` reports).
1904+
if (refused.length > 0) {
1905+
console.log(` ${chalk.bold('Refused (left exactly as it is when you apply)')}`);
1906+
for (const p of refused) {
1907+
console.log(` ${chalk.red('✗')} ${chalk.red(describeRefusedSchemaWork(p))}`);
1908+
if (p.refusal) console.log(` ${chalk.dim(p.refusal)}`);
1909+
}
1910+
console.log('');
1911+
}
18401912
}
18411913

18421914
/** `rows` is optional on the type; an unmeasured count reads as unknown, not zero. */
@@ -1854,11 +1926,16 @@ export async function summarizePendingSchemaWork(pending: PendingSchemaWork[]):
18541926
// Only mentioned when there is some, so the common in-sync summary is
18551927
// unchanged — but never omitted when there is, which is the #3954 point.
18561928
const isInPlaceSchemaWork = await loadIsInPlaceSchemaWork();
1857-
const inPlace = pending.filter((p) => isInPlaceSchemaWork(p.kind));
1929+
const inPlace = pending.filter((p) => isInPlaceSchemaWork(p.kind) && p.kind !== 'rebuild_table');
18581930
if (inPlace.length > 0) {
18591931
const cols = inPlace.reduce((n, p) => n + p.columns.length, 0);
18601932
const rows = inPlace.reduce((n, p) => n + (p.rows ?? 0), 0);
18611933
parts.push(`${cols} temporal column(s) to converge in place (~${formatRows(rows)} rows)`);
18621934
}
1935+
// [#30636] Named only when there are some, like the in-place count above.
1936+
const rebuilds = pending.filter((p) => p.kind === 'rebuild_table').length;
1937+
if (rebuilds > 0) parts.push(`${rebuilds} empty table(s) to rebuild`);
1938+
const { refused } = await splitRefusedSchemaWork(pending);
1939+
if (refused.length > 0) parts.push(`${refused.length} table(s) REFUSED`);
18631940
return parts.join(', ');
18641941
}

0 commit comments

Comments
 (0)