Skip to content

test(schema): dbDelta re-ALTERs forever when a CREATE TABLE lies, and nothing measured it (#1087 passo 7) - #1095

Merged
rpgmem merged 3 commits into
developfrom
claude/dbdelta-idempotence-gate-4vvuer
Sep 7, 2026
Merged

test(schema): dbDelta re-ALTERs forever when a CREATE TABLE lies, and nothing measured it (#1087 passo 7)#1095
rpgmem merged 3 commits into
developfrom
claude/dbdelta-idempotence-gate-4vvuer

Conversation

@rpgmem

@rpgmem rpgmem commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Uma classe de defeito que nenhum gate enxerga. dbDelta() compara um CREATE TABLE com o que o MySQL efetivamente gravou e emite ALTER para a diferença. Quando os dois discordam de um jeito que a declaração nunca satisfaz, ele re-ALTERa em toda ativação, para sempre, em silêncio. ActivatorSqlTest lê a declaração como texto, sem banco; o job fresh-install só vê o CREATE da primeira vez; o smoke pós-deploy confere que a tabela existe, não que o schema ainda bate. O Test audit by mocked boundary (stages 1–3) #997 mediu essa classe uma vez, à mão (41 erros de banco nas três tabelas de self-scheduling, 1 depois da limpeza) e nunca mais — e duas declarações mudaram desde então, no fix(schema): a fresh install of ffc_submissions was born with 7 of its 25 columns (#1087) #1091 e no fix(schema): a fresh install of ffc_reregistration_submissions lacked auth_code and magic_token (#1087 passo 5) #1093.
  • O passo 7 mede isso sempre. .github/scripts/dbdelta-idempotence-check.php roda no job fresh-install (o único com MariaDB real e schema recém-criado) e repassa cada CREATE TABLE pelo dbDelta() contra a tabela que ele mesmo acabou de criar. dbDelta devolve as mudanças que fez; contra a tabela criada por aquela mesma declaração, a lista tem de vir vazia. O que sobrar é um ALTER em toda execução futura.
  • Uma extração, dois consumidores. .github/scripts/ffc-create-statements.php passa a ser a fonte única das 37 declarações: o ActivatorSqlTest (regras de texto do dbDelta) e o gate novo leem o mesmo conjunto. É a regra que o repositório já aplica ao uninstall.php — um manifesto, vários consumidores — e a lição direta dos passos 3 e 6 desta issue: duas varreduras privadas da mesma coisa é como um denominador fica errado sem ninguém notar (a régua de linhas via 45 de 53 classes).
  • O gate falha ao ficar cego, em vez de passar. Ele aborta se a varredura não achar nada, se o número extraído divergir de uma contagem por rede mais larga (aspas simples, heredoc), ou se o nome de alguma tabela não resolver — nunca conta como limpo o que não olhou.

Achado registrado no caminho

ffc_device_signals é a única tabela cujo dbDelta deliberadamente não é protegido por table_exists() (documentado em class-ffc-rate-limit-activator.php, para o mesmo caminho servir instalação nova e a atualização 6.3.1→6.3.2). Ou seja: ela já roda dbDelta contra tabela existente em toda ativação, em toda instalação — exatamente a classe do #997, viva em produção e nunca medida. É a primeira coisa que este gate deveria ser confiado a dizer.

Type of change

  • Refactor / chore (no functional change)
  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would alter existing behavior)
  • Documentation only

Nenhuma mudança de runtime: o diff é um gate de CI, uma extração compartilhada e um teste.

Test plan

  • vendor/bin/phpunit — suíte completa verde (7666 testes, 21971 asserções)
  • vendor/bin/phpunit --filter 'ActivatorSql|SchemaAgreement|CreateStatementsParser' — 10 testes, 18 asserções
  • Extração verificada: 37 extraídas / 37 presentes / 0 sem nome de tabela resolvido
  • Substituição de interpolação verificada nas 37 declarações — nenhuma $ sobrando no SQL final
  • YAML do workflow validado; o passo entra no fresh-install entre a checagem de ativação e a desinstalação
  • O script de idempotência em si não roda aqui: precisa de ABSPATH, dbDelta() e um MySQL vivo, e o contêiner de desenvolvimento não tem banco. Quem o executa pela primeira vez é o job fresh-install desta PR (MariaDB 11.8) — e é ele que dirá se ffc_device_signals está limpa.

Testes novos

tests/Unit/CreateStatementsParserTest.php cobre o parser nas duas coisas que podem dar errado — enxergar menos do que existe, e não resolver um nome — em vez do caminho feliz. Os quatro idiomas de nomeação são verificados por nome, não por contagem: cada um derrotou uma versão anterior do resolvedor (a janela de 25 linhas, depois a de 40), e uma regressão apareceria só como um total um pouco menor que ninguém lê.

Checklist

  • Sem mudança de CSS/JS
  • CHANGELOG.md atualizado ([Unreleased]Added)
  • Sem entradas novas de baseline do PHPStan
  • Sem segredos, tokens ou PII no diff

Referencia #1087 (passo 7). A issue segue aberta.

🤖 Generated with Claude Code

https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU


Generated by Claude Code

… nothing measured it (#1087 passo 7)

`dbDelta()` ALTERs away the difference between a `CREATE TABLE` and what
MySQL actually stored. When the two disagree in a way the statement can
never satisfy, it re-ALTERs on every single run, forever, in silence.

No gate saw that class. `ActivatorSqlTest` reads the statement as text,
with no database. The `fresh-install` job starts from an empty schema, so
it only ever watches the CREATE succeed — never the second pass. The
post-deploy smoke asserts the tables exist, not that the schema still
matches. #997 measured it exactly once, by hand, against a real MariaDB
(41 database errors on the self-scheduling tables, 1 after the cleanup)
and never again — while two CREATE statements changed in #1091 and #1093.

The new step runs in `fresh-install`, the only job with a real MariaDB
AND a freshly created schema, and replays every CREATE through
`dbDelta( $sql, false )` against the table it just built. The change list
must be empty; anything in it is an ALTER on every future activation.

The extraction moves to `.github/scripts/ffc-create-statements.php` so
this gate and `ActivatorSqlTest` measure the same 37 statements — the
rule `uninstall.php` already follows as one manifest, and the direct
lesson of steps 3 and 6: two private scans of one thing is how a
denominator goes wrong unnoticed (the row ruler saw 45 of 53 classes).

The gate fails on blindness rather than passing: an empty scan, a count
that diverges from a wider net (single quotes, heredoc), or a table name
it cannot resolve all abort instead of counting as clean.

Recorded along the way: `ffc_device_signals` is the only table whose
dbDelta is deliberately not guarded by `table_exists()`, so it already
runs against an existing table on every activation in every install —
the #997 class, live and never measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
@rpgmem
rpgmem marked this pull request as ready for review September 7, 2026 02:38
@rpgmem
rpgmem enabled auto-merge (squash) September 7, 2026 02:38
…s=1) is fatal

The gate failed on its first run for a reason that has nothing to do with
the schema: `wp eval-file` reads the file and `eval()`s it, and a
`declare(strict_types=1)` is only legal as the first statement of a
script — inside an eval it is a fatal error, so the step died before
reaching a single CREATE TABLE.

Load WordPress the way `fresh-install-check.php` does in this same job
instead: take the wp root as an argument and `require` its `wp-load.php`.
Same invocation shape as its sibling, the declaration stays, and the
command that eval()s files is out of the loop.

Verified: the original failure reproduces exactly (`eval()` of this file
before the change → "strict_types declaration must be the very first
statement in the script"), and after it the file parses whole and stops
at its own usage guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
…Delta re-ALTERs (#1087 passo 7)

The gate ran for real and did not come back empty. Against tables it had
just created from those very statements, `dbDelta()` wanted 33 changes
across 14 `CREATE TABLE` literals — an ALTER on every future activation,
in every install.

Three families, and only one is a schema question:

1. Display width the server supplies. The statement writes `int unsigned`
   and MariaDB stores `int(10) unsigned`; dbDelta compares text and
   ALTERs the difference forever. 9 statements.
2. `json`, which MariaDB implements as `longtext` plus a CHECK, so the
   statement asks for `json` and `SHOW CREATE TABLE` answers `longtext`.
   6 statements.
3. A column or index the live table does not have —
   `ffc_reregistrations.audience_id` and a `KEY auth_code` on two tables.
   Not cosmetic: statement and table genuinely disagree, and three files
   declare `ffc_custom_fields` while three declare
   `ffc_reregistration_submissions`.

So the gate blocks against a frozen baseline rather than against zero, in
the shape this repository already uses four times over (module boundary,
vacuous tests, superglobal casts, the row ruler): a change not listed
fails, and a listed change that stops happening also fails, so a fix is
locked in the moment it lands. Fixing the 14 is follow-up work on #1087,
not something to fold into the pull request that adds the measurement.

Two things the run settled. `ffc_device_signals` — the only table whose
dbDelta is not guarded by `table_exists()`, so it re-runs on every
activation in every install — is NOT among the 14. It was the obvious
suspect and it is clean; the drift is in tables where the repeated ALTER
is latent instead. And there is no local way to regenerate the baseline,
since it needs a live MariaDB, so the gate prints the exact block to
paste and dumps `SHOW CREATE TABLE` for the statements whose
disagreement is not a type spelling — the data family 3 needs.

`CreateStatementsParserTest` gains a check that every baseline key still
names a file that declares that table: an entry whose statement moved
would otherwise match nothing and fail nothing, which is the same silent
exemption this issue has spent three steps on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
@rpgmem
rpgmem merged commit bf43297 into develop Sep 7, 2026
20 checks passed
@rpgmem
rpgmem deleted the claude/dbdelta-idempotence-gate-4vvuer branch September 7, 2026 03:00
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34077374481

Warning

No base build found for commit f465498 on develop.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 89.969%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 53944
Covered Lines: 48533
Line Coverage: 89.97%
Coverage Strength: 4.87 hits per line

💛 - Coveralls

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.

3 participants