Skip to content

feat(admin-tools): add check-app-migrations to detect schema drift - #183

Draft
printminion-co wants to merge 18 commits into
mainfrom
feat/admin-tools-check-app-migrations
Draft

printminion-co wants to merge 18 commits into
mainfrom
feat/admin-tools-check-app-migrations

Conversation

@printminion-co

Copy link
Copy Markdown
Contributor

Adds a read-only admin tool that reports app migrations which were never applied and ones recorded in oc_migrations whose schema changes are missing from the database, e.g. Talk failing with an unknown column after the 31 to 32 to 33 upgrade.

It connects through the Nextcloud config, extracts addColumn, createTable, addIndex, dropColumn and similar from changeSchema() and compares them with the live schema; code it cannot parse is reported as not verifiable. Output has only versions and table, column and index names. Tested on sqlite only, the MySQL path is untested here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Incorrect drift results and unsafe migration replay guidance can lead administrators to apply harmful repairs.

9 open findings
What changed in this PR

Adds a read-only admin tool to compare deployed Nextcloud app migrations with migration records and database schema.

Changes:

  • Extracts schema effects and compares them with live databases or offline dumps.
  • Adds multi-app reporting, upgrade-date filtering, and suggested repair commands.
  • Documents usage, options, and exit codes.
File Description
admin-tools/​README.md Documents the tool and diagnostic workflows.
admin-tools/​check-app-migrations.php Implements migration discovery, schema comparison, and reporting.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php Outdated
Comment thread admin-tools/check-app-migrations.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Extraction and replay-safety gaps can produce misleading drift reports and unsafe recovery suggestions.

1 open finding
8 resolved since last review
Previously missed (6)

In code that hasn't changed since last review

Medium severity Null returns can discard previously recorded effects

admin-tools/​check-app-migrations.php:715

A return null inside an unsupported loop or try block can discard effects recorded before it, but this only marks later statements conditional. For example, addColumn(...), followed by foreach ([true] as $discard) { if ($discard) { return null; } } and a final return $schema;, produces a missing-column finding and a replay suggestion with no unverifiable reason. Replaying still discards the schema. Inspect all enclosing-method return paths in skipped constructs and invalidate earlier effects when a null or unknown return can discard them.

Medium severity Do not treat conditional index effects as definitely applied

admin-tools/​check-app-migrations.php:898

An index inside if (!hasColumn('c')) is not guaranteed to exist after the migration. If c already existed without the index, the block correctly skipped, yet this exception makes the checker report the index as applied but missing. Remove the exception so this additional index effect is reported as not verifiable rather than definite schema drift.

Medium severity Track schema-mutating helpers in recognized call arguments

admin-tools/​check-app-migrations.php:1038

Arguments of recognized calls are skipped without checking for schema-mutating helpers. For example, $table->addColumn('c', 'integer', $this->options($schema)) records only the column, with no unverifiable reason or touched-table marker, although the helper receives the whole schema. Earlier effects can then be misreported and a replay suggested for unparsed code. Mark argument expressions receiving tracked schema, table, or column objects as uncertain before accepting the call's effects.

Medium severity Invalidate dependent indexes after column changes

admin-tools/​check-app-migrations.php:1228

Dropping or renaming a column also invalidates earlier index and primary-key definitions containing it. For example, addIndex(['old'], 'idx') followed by an applied renameColumn('old', 'new') can leave a valid idx(new), but this code still checks for idx(old) and reports false drift. Treat dependent index definitions as superseded by a later column drop; renames already produce a drop effect.

Medium severity Verify ordering before replaying opaque data migrations

admin-tools/​check-app-migrations.php:1360

Running a data migration for the first time is not necessarily safe when newer migrations have already run. This exemption accepts an older never-applied postSchemaChange() even if a later data migration is recorded; for a data-only migration, the subsequent effects loop is empty, so no ordering check occurs. The suggested command can transform data in the wrong order. Require manual review for opaque data transformations unless their ordering is known to be safe, using the complete applied-version list.

Medium severity Validate prerequisites before suggesting replay commands

admin-tools/​check-app-migrations.php:1367

A missing effect is not necessarily executable. If a migration only calls $schema->getTable('t')->addColumn(...) and t is absent, checkEffect() returns table missing, but replayRisk() accepts it and suggests a command that fails at getTable(). Index additions likewise require their columns to exist. Check prerequisites against the ordered repair operations, accounting for earlier table and column creation, and require manual review when prerequisites are unmet.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread admin-tools/check-app-migrations.php

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Schema analysis and replay-safety checks can produce incorrect reports and unsafe repair recommendations.

1 open finding
1 resolved since last review
Previously missed (7)

In code that hasn't changed since last review

Medium severity Withhold replay for fall-through or false schema returns

admin-tools/​check-app-migrations.php:567

When changeSchema() has no explicit return, this returns false and leaves its extracted schema effects eligible for verification and replay. An untyped method falls through to null, so Nextcloud discards its changes; replay cannot repair them. Mark this case as not verifiable and withhold replay. Also account conservatively for fall-through paths rather than assuming that the presence of a return elsewhere proves a schema wrapper is returned.

Medium severity Treat nonliteral null returns as unverifiable schema effects

admin-tools/​check-app-migrations.php:692

$result = null; return $result; reaches this branch without recording uncertainty, and onlyReturnsNull() does not recognize it either. Schema effects remain asserted and replay can be recommended, although Nextcloud will discard the changes again. Treat returns other than literal null or a known schema variable as not verifiable, and discard their asserted effects.

Medium severity Inspect schema method argument expressions for hidden effects

admin-tools/​check-app-migrations.php:1073

Recognized schema methods skip argument expressions. For example, addColumn('c', 'integer', ['default' => $this->calculateDefault($schema)]) records no uncertainty, even though the helper can change other tables or write data. The migration can then be incorrectly verified or recommended for replay. Inspect these arguments for opaque calls and tracked schema references, and record their possible effects before accepting the method's effect.

Medium severity Invalidate dependent indexes and keys after column renames

admin-tools/​check-app-migrations.php:1262

renameColumn is represented as a column drop and add, but neither supersedes earlier index or primary-key expectations using the old column. If one applied migration indexes items.old and a later one renames it to new, the live index uses new, yet this check reports drift against the old definition. Invalidate dependent index and primary-key expectations when their column is removed or renamed.

Medium severity Validate table and column prerequisites before recommending replay

admin-tools/​check-app-migrations.php:1401

A missing effect does not establish that the operation can run. A migration that calls getTable('items')->addColumn(...) is recommended for replay even when items is absent. An index-only migration is also recommended when its indexed columns are missing. Both commands fail instead of repairing the schema. Validate table and column prerequisites against the state produced by preceding operations and guards; send unmet prerequisites to "Review by hand".

Medium severity Withhold replay when newer applied migration source is unavailable

admin-tools/​check-app-migrations.php:1586

Applied versions missing from the app directory are absent from both $extracted and $flat, so replay safety ignores them. If a newer applied migration dropped a table but its source is no longer deployed, an earlier create-table migration can be recommended for replay, recreating deliberately removed schema. Withhold replay whenever a newer applied migration has unavailable source, since its effects cannot be checked.

Medium severity Reject all unparsed positional arguments

admin-tools/​check-app-migrations.php:1756

getopt() sets $rest to the first unparsed argument's index. With one trailing positional argument, $rest === count($argv) - 1, so this condition silently accepts it and continues the check. Compare against count($argv) to reject any unparsed argument as malformed usage.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread admin-tools/check-app-migrations.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Schema extraction and replay validation can recommend unsafe or ineffective migration executions.

0 open findings

1 resolved since last review
Previously missed (6)

In code that hasn't changed since last review

Medium severity Recognize qualified function calls as opaque side effects

admin-tools/​check-app-migrations.php:643

This check only recognizes T_STRING and T_VARIABLE, so it misses qualified function calls such as \Vendor\updateData(). PHP uses T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, or T_NAME_RELATIVE for these names. An applied migration can therefore receive a replay suggestion that repeats an unreported side effect. Include qualified-name tokens in the opaque-call check.

Medium severity Reject unknown return values and discard their effects

admin-tools/​check-app-migrations.php:692

A return expression that does not mention a tracked schema variable is accepted without proving that it returns the modified wrapper. For example, adding a column and then doing $result = null; return $result; produces no unverifiable reason and can receive a replay suggestion, although Nextcloud discards the changes again. Mark unknown return values as not verifiable and withdraw the effects they may discard.

Medium severity Discard effects before skipped loops with null returns

admin-tools/​check-app-migrations.php:740

A return null; inside a skipped loop only makes subsequent effects conditional; earlier effects remain asserted. For example, addColumn(...), followed by foreach ([1] as $item) { return null; } and a final return $schema;, retains the column effect without an unverifiable reason. Nextcloud discards that change, so a missing column produces a false drift finding and an ineffective replay suggestion. Discard earlier effects when skipped constructs contain null returns, as handleIf() already does.

Medium severity Inspect schema call arguments for opaque side effects

admin-tools/​check-app-migrations.php:1073

Recognized schema calls skip their argument expressions. For example, addColumn('counter', 'integer', ['default' => $this->runDataChange()]) produces no unverifiable reason, so a missing column can trigger a replay suggestion that repeats an uninterpreted data change. Helpers receiving schema objects here also escape touched-table tracking. Inspect argument ranges for opaque calls and mark tables passed to unparsed code as uncertain before accepting the schema call.

Medium severity Invalidate guard proofs bypassed by early returns

admin-tools/​check-app-migrations.php:1205

An entry in $guarded remains trusted after another early return intervenes. For example, if ($table->hasColumn('x')) { return null; }, then if (true) { return null; }, followed by addColumn('x', ...) and a final return $schema;, retains the column effect without an unverifiable reason. When x is missing, the checker suggests replay even though execution always exits before adding it. Invalidate earlier guard proofs when another early return can bypass the effect, or track every condition needed to reach it.

Medium severity Require parent tables and index columns before replay

admin-tools/​check-app-migrations.php:1407

A missing parent table is accepted as a repairable column or index effect without checking prerequisites. A migration containing only getTable('room')->addColumn('x', ...) receives a replay command when room is absent, but execution fails at getTable(). Require prerequisite tables and index columns to exist or be supplied by earlier operations in the proposed execution sequence. Otherwise, put the migration under "Review by hand".

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

@printminion-co
printminion-co force-pushed the feat/admin-tools-check-app-migrations branch from bfd28a8 to 8d4fad0 Compare October 13, 2026 10:50
Apps can have migrations recorded in oc_migrations whose schema changes
are not in the database, e.g. Talk failing with an unknown column after
the 31 to 32 to 33 upgrade. Checking the applied rows alone misses that.

The read-only script lists migrations that were never applied and ones
that are applied but not in effect, by statically extracting
addColumn, createTable, addIndex, dropColumn and similar from
changeSchema() and comparing them with the live schema. Code it cannot
parse is reported as not verifiable instead of guessed.

The DB connection is taken from the Nextcloud config; output contains
only versions and table, column and index names.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
Findings from migrations that predate an upgrade are noise when the
question is whether the 31 to 32 to 33 upgrade ran correctly.

--since-root takes an older Nextcloud tree and reports only migrations
that do not exist there; --since-date filters by the date stamp in the
migration version. Never applied, not in effect and not verifiable are
all limited to that scope.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
…findings

Admins need the exact commands to fix what the check reports.

When migrations are missing or not in effect, the report now ends with
the occ migrations:execute commands in execution order and the steps
around them. The script still only prints them and changes nothing.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
An upgrade window is easier to name by Nextcloud version than by a
date or an old tree.

--since-nc N limits the report to migrations written after stable N was
cut from master, so --since-nc 31 covers the 31 to 32 to 33 path. The
cut dates come from nextcloud/server (merge-base of stableN and master)
and replace --since-root and --since-date.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
The per-app output of --all was hard to scan on a live instance: clean
apps and apps with findings were interleaved and repeated headers.

--all now prints a header with the scope, a table of the apps with
findings, the details only for those apps, one wrapped list of clean
apps, and the suggested fix at the end.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
migrations:execute is only registered with debug on. Nextcloud reads
NC_-prefixed environment variables as system config, so NC_debug=true
enables it for a single command without writing a config file or
turning debug on for the instance.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
The config directory default is already the config/ of the Nextcloud
root the script lives in, so --config is optional. Also respect
NEXTCLOUD_CONFIG_DIR like Nextcloud itself does; --config still wins.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
A migration whose changeSchema() ends with return null makes
MigrationService::executeStep() skip the schema migration, so its
changes are never applied and executing it again cannot create them.
Talk 22001Date20250927174738 is such a migration.

check-app-migrations now reports these as not verifiable with that
reason instead of listing them as not in effect and in the suggested
fix.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
… is null

A changeSchema() that returns the schema from a nested branch and ends
with return null for the nothing-to-do case was wrongly reported as
discarding its changes, e.g. core 33000Date20251013110519. Require that
all returns are return null.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
…p-migrations

The usage heredoc started lines with spaces, which editorconfig-checker
rejects in a tab-indented file; prefix them with a marker that is
stripped at runtime. Also remove an over-indented docblock continuation
line that php-cs-fixer rewrote.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
False drift findings and unsafe replay advice came from several places
where the static analysis guessed too much.

Guards are only trusted in the exact has*() forms whose body creates or
removes the tested object; other conditions, early returns under
runtime conditions, helpers that receive the schema, computed names and
a closure bound to another variable name make the affected effects
not verifiable and mark the tables as possibly changed. Index checks
compare uniqueness and columns; dumps carry the table prefix and the
unique flag. The suggested fix no longer lists replays that would drop
something a later migration re-added or repeat data changes; those go
to a review list.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
migrations:execute prints nothing on success, which makes a run hard to
tell from a no-op. Each suggested command now ends with an OK line that
only prints when the exit code is 0, and the block ends with the exact
check command to verify the result.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
Running or replaying a migration executes all of it against the current
schema, so the suggested fix now vets never applied migrations too and
sends a migration to manual review when it has code the check cannot
interpret, an unguarded operation that is already in place, or a drop
that a later migration undid or may have changed again.

Uninterpreted code is treated more conservatively: return expressions
that get the schema, conditional return null, unsupported table methods
and helpers that receive the schema mark the tables as possibly changed,
also within the same migration; string literals no longer narrow that.
Known schema methods only affect the tables they name, and a guard that
creates a column also vouches for an index on it.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
A run executes a whole migration, so the suggested fix now evaluates the
guards around the missing parts against the current schema, only calls
an operation repeatable when its own object is guarded, and checks
operation prerequisites such as taken index names and existing primary
keys. Conflicts with later migrations apply to additions too, never
applied migrations that only return null go to manual review, and
conditions and computed index names count as uninterpreted code.

The suggested commands form one && chain so nothing runs after a
failure, and the re-check keeps the original arguments.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
Code in changeSchema() that never touches a schema object, such as a DB
statement, was ignored, so a migration that also adds a missing column
could get a replay command that repeats the statement. Any call other
than on $output and a few pure functions now counts as code the check
cannot interpret and sends the migration to manual review.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
With --all the app id comes from the migrations table without the check
that --app gets. An id with shell syntax or ../ that matched a path
could reach the generated command or the app directory lookup. Ids that
are not plain app ids are now ignored and counted, and the app, version
and success message in the suggested commands are shell-quoted.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
… vetting

Qualified function calls (T_NAME_*) and calls inside schema call
arguments now count as code the check cannot interpret. A return value
that is neither the schema nor null, and return null inside a skipped
loop or try block, discard the effects recorded before them. A guard
proof no longer survives another early return that can bypass the
effect, and a run is only suggested when the table and the index
columns it needs exist or are created earlier in the same migration.

Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
@printminion-co
printminion-co force-pushed the feat/admin-tools-check-app-migrations branch from 8d4fad0 to a481cae Compare October 15, 2026 07:23
@printminion-co
printminion-co requested a balanced review from Copilot October 15, 2026 09:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Schema-effect handling can produce incorrect drift reports, miss uniqueness constraints, and recommend ineffective migration replays.

6 open findings

🧠 Review effort: Balanced

Comment on lines +547 to +567
/** True if the range has at least one return and every return is "return null;" (no schema is ever handed back). */
private function onlyReturnsNull(int $from, int $to): bool {
$returns = 0;
for ($i = $from; $i < $to; $i++) {
if ($this->T[$i]['t'] === T_FUNCTION || $this->T[$i]['t'] === T_FN) {
// closures have their own returns
$j = $i + 1;
while ($j < $to && $this->T[$j]['s'] !== '{' && $this->T[$j]['s'] !== ';') {
$j = $this->isOpen($j) ? $this->close($j) + 1 : $j + 1;
}
$i = ($j < $to && $this->T[$j]['s'] === '{') ? $this->close($j) : $j;
continue;
}
if ($this->T[$i]['t'] === T_RETURN) {
$returns++;
if (strtolower($this->T[$i + 1]['s'] ?? '') !== 'null' || ($this->T[$i + 2]['s'] ?? '') !== ';') {
return false;
}
}
}
return $returns > 0;
Comment on lines +660 to +663
private function unknownEarlyReturn(): void {
$this->conditional = true;
$this->guarded = [];
}
Comment on lines +834 to +836
$this->walk($from, $to, false);
array_pop($this->guardStack);
$this->validateGuarded($guard, $before);
if ($ok) {
$kept[] = $e;
} else {
$this->touchedLater[$e['table']] = true;
'setPrimaryKey', 'dropPrimaryKey' => "idx:{$e['table']}:PRIMARY",
'addIndex', 'dropIndex' => isset($e['index'])
? "idx:{$e['table']}:{$e['index']}"
: "idxc:{$e['table']}:" . implode(',', $e['columns'] ?? []),
if (effectKey($later) === effectKey($earlier)) {
return true;
}
return in_array($later['op'], ['createTable', 'dropTable'], true) && $later['table'] === $earlier['table'];
@printminion-co
printminion-co marked this pull request as draft October 16, 2026 06:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants