Compare commits

...

10 Commits

Author SHA1 Message Date
Дмитрий 944a85dcc8 fix(migrations): idempotent guards so from-scratch migrate works + restore full-PG Pa11y (21 routes)
Сборка БД с нуля (php artisan migrate на пустом PostgreSQL — CI, новый сервер,
пересоздание из бэкапа) падала: 0001_load_initial_schema грузит полный текущий
db/schema.sql (v8.39), затем дельта-миграции пытаются создать уже существующие
объекты. Из 28 дельт 25 уже идемпотентны; ровно 3 не имели гарда:

  - 2026_05_24_100000 add_balance_freeze: CREATE POLICY tenant_isolation на
    balance_freeze_log (политика уже в schema.sql:3357) → +DROP POLICY IF EXISTS.
  - 2026_05_26_120000 add_paused_at: projects.paused_at + projects_paused_at_idx
    (уже в schema.sql:815/897) → guard hasColumn + CREATE INDEX IF NOT EXISTS.
  - 2026_05_27_120000 project_routing_snapshots: CREATE TABLE + 2 индекса +
    политика + 2 партиции (уже в schema.sql v8.39) → IF NOT EXISTS на таблицу/
    индексы/партиции, DROP POLICY IF EXISTS, GRANT'ы вынесены в pg_roles-guard.

db/schema.sql НЕ трогается (источник истины, Pravila §4.2). Прод не затрагивается —
эти миграции там уже отмечены выполненными, тела повторно не исполняются.

Также возвращён полный-PostgreSQL прогон Pa11y (PR #49 был сужен до 7 публичных
страниц именно из-за сломанной сборки с нуля):
  - .github/workflows/a11y.yml: postgres:16 service, 5 ролей БД (00_create_roles.sql),
    .env с DB_SUPPLIER_* + Sanctum stateful localhost:8000, mkdir storage/framework,
    migrate → partitions:create-months → db:seed (admin@demo.local). Сохранены уроки
    PR #49: Node 22, корневой npm install, app npm ci --legacy-peer-deps.
  - pa11y.config.json: +14 авторизованных маршрутов (вход под admin@demo.local) →
    7 публичных + 14 авторизованных = 21 проверяемая страница.

Проверка — прогон CI этого workflow (чистый PostgreSQL + migrate с нуля + seed +
все 21 страница). План: docs/superpowers/plans/2026-06-03-from-scratch-migrate-idempotency-and-a11y-full.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:53:59 +03:00
CoralMinister 9ebc20ff94 Merge pull request #49 from CoralMinister/feat/a11y-ci-postgres
ci(a11y): provision full PostgreSQL so 14 authenticated Pa11y routes …
2026-06-03 17:31:22 +03:00
Дмитрий 28d2d38857 ci(a11y): mkdir storage/framework dirs so file sessions work (fixes 500) 2026-06-03 17:25:07 +03:00
Дмитрий 09f16bd83c ci(a11y): SESSION/CACHE=file so public pages render (no DB tables) + log tail 2026-06-03 17:08:29 +03:00
Дмитрий 512d8e0e24 ci(a11y): scope Pa11y to 7 public routes (defer full-PG from-scratch build) 2026-06-03 16:59:26 +03:00
CoralMinister 7aa0e4169e Update MonthlyPartitionManager.php 2026-06-03 16:40:15 +03:00
CoralMinister 7c9a8151f6 Update 0001_01_01_000000_load_initial_schema.php 2026-06-03 16:25:32 +03:00
CoralMinister be36fc64b3 Update a11y.yml 2026-06-03 15:59:05 +03:00
CoralMinister d883bf486f Update a11y.yml 2026-06-03 15:35:36 +03:00
CoralMinister 8907d16e40 Update a11y.yml 2026-06-03 15:05:13 +03:00
7 changed files with 157 additions and 41 deletions
+26 -25
View File
@@ -9,12 +9,11 @@ on:
jobs:
a11y:
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 25
# Полноценный PostgreSQL для CI: схема Лидерры — чисто PG (RLS, партиции,
# роли БД, raw schema.sql через load_initial_schema), на SQLite не грузится.
# Без живой БД 14 авторизованных Pa11y-маршрутов не могут залогиниться под
# admin@demo.local → таймаут на "wait for path /dashboard" → красный CI.
# Полноценный PostgreSQL для CI: схема Лидерры — чисто PG (RLS, партиции, роли,
# raw schema.sql через load_initial_schema), на SQLite не грузится. Без живой БД
# 14 авторизованных Pa11y-маршрутов не могут залогиниться под admin@demo.local.
services:
postgres:
image: postgres:16
@@ -41,31 +40,29 @@ jobs:
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 20
- name: Setup Node 22
# Node 22 (>=22.18): корневые tooling-пакеты @cspell/*@10 требуют node>=22.18.
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install root JS deps
run: npm ci --no-audit --no-fund
# npm install (не ci): корневой package-lock рассинхронен (gcp-metadata) — pre-existing долг.
run: npm install --no-audit --no-fund
- name: Install app composer deps
working-directory: app
run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
- name: Install app JS deps
# --legacy-peer-deps: Histoire 1.0-beta.1 заявляет peerDep vite ^7,
# установлено vite 8 (memory feedback_environment.md #74) — как в deploy.yml.
# --legacy-peer-deps: Histoire 1.0-beta.1 заявляет peerDep vite ^7, установлено vite 8.
working-directory: app
run: npm ci --no-audit --no-fund --legacy-peer-deps
- name: Create PostgreSQL roles
# Базовая schema.sql грузится без ролей (GRANT'ы обёрнуты в DO $$ EXISTS-check),
# но поздние миграции (snapshot, lead-region) делают необёрнутый
# GRANT ... TO crm_app_user/crm_supplier_worker → роли должны существовать.
# SET ROLE crm_migrator в этих миграциях с guard'ом has_schema_privilege →
# под postgres-суперюзером корректно делает RESET ROLE (грантов на public нет).
# schema.sql грузится без ролей (GRANT'ы в DO $$ EXISTS-guard), но поздние миграции
# делают необёрнутый GRANT ... TO crm_app_user/crm_supplier_worker → роли нужны.
env:
PGPASSWORD: postgres
run: |
@@ -84,12 +81,10 @@ jobs:
php artisan key:generate --force
- name: Configure .env for CI PostgreSQL + Sanctum SPA
# phpdotenv: первое вхождение ключа выигрывает → не дописываем дубли,
# а удаляем строку и добавляем заново (детерминированный override).
# APP_ENV=local нужен, чтобы DatabaseSeeder вызвал DemoSeeder (admin@demo.local)
# и чтобы session-cookie не был secure-only (вход по http в CI).
# SANCTUM_STATEFUL_DOMAINS обязан включать localhost:8000 — иначе Sanctum
# считает запрос с Pa11y-хоста (localhost:8000) stateless → сессия не залипает.
# phpdotenv: первое вхождение ключа выигрывает → удаляем строку и дописываем заново.
# APP_ENV=local → DatabaseSeeder зовёт DemoSeeder (admin@demo.local) + session-cookie не secure-only.
# SANCTUM_STATEFUL_DOMAINS обязан включать localhost:8000 — иначе сессия с Pa11y-хоста не залипает.
# DB_SUPPLIER_* нужны: часть миграций пишет через pgsql_supplier-соединение (BYPASSRLS-роль).
working-directory: app
run: |
setenv() { sed -i "/^$1=/d" .env; echo "$1=$2" >> .env; }
@@ -102,6 +97,8 @@ jobs:
setenv DB_DATABASE liderra
setenv DB_USERNAME postgres
setenv DB_PASSWORD postgres
setenv DB_SUPPLIER_USERNAME postgres
setenv DB_SUPPLIER_PASSWORD postgres
setenv DB_SSLMODE disable
setenv SESSION_DRIVER file
setenv CACHE_STORE file
@@ -109,13 +106,18 @@ jobs:
setenv MAIL_MAILER log
setenv SANCTUM_STATEFUL_DOMAINS localhost:8000,127.0.0.1:8000,localhost,127.0.0.1
- name: Prepare storage dirs (file session/cache need them)
# SESSION_DRIVER=file пишет в storage/framework/sessions — без каталога 500 (урок PR #49).
working-directory: app
run: mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/logs bootstrap/cache
- name: Run migrations (postgres superuser → guarded SET ROLE works)
working-directory: app
run: php artisan migrate --force
- name: Create current-month partitions
# schema.sql создаёт baseline-партиции; cron-команда докидывает текущий +2
# месяца (идемпотентно) — нужно для demo-сделок DemoSeeder'а за «сегодня».
# schema.sql + миграции дают baseline-партиции; cron-команда докидывает текущий +2 месяца
# (идемпотентно) — нужно для demo-сделок DemoSeeder'а за «сегодня».
working-directory: app
run: php artisan partitions:create-months --ahead=2
@@ -128,8 +130,7 @@ jobs:
run: npm run build
- name: Start Laravel dev-server
# PHP_CLI_SERVER_WORKERS>1: встроенный сервер обслуживает SPA + sub-resources
# параллельно, чтобы Pa11y-навигации не упирались в однопоточность.
# PHP_CLI_SERVER_WORKERS>1: встроенный сервер обслуживает SPA + sub-resources параллельно.
working-directory: app
env:
PHP_CLI_SERVER_WORKERS: 4
@@ -108,7 +108,16 @@ class MonthlyPartitionManager
if ($exists !== null) {
return false;
}
// Родитель-партиционированная таблица может ещё не существовать
// (создаётся более поздней миграцией) — тогда пропускаем.
$parentExists = DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
[$table],
);
if ($parentExists === null) {
return false;
}
DB::connection(self::DDL_CONNECTION)->statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partition,
@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB;
*/
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
@@ -38,6 +38,8 @@ return new class extends Migration
)
SQL);
$supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY');
// Idempotency: schema.sql (сборка с нуля) уже создаёт эту политику — снимаем перед CREATE.
$supplier->statement('DROP POLICY IF EXISTS tenant_isolation ON balance_freeze_log');
$supplier->statement(<<<'SQL'
CREATE POLICY tenant_isolation ON balance_freeze_log
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
@@ -11,10 +11,13 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->timestampTz('paused_at')->nullable()->after('is_active');
$table->index('paused_at', 'projects_paused_at_idx');
});
// Idempotency: schema.sql (сборка с нуля) уже содержит paused_at + индекс.
if (! Schema::hasColumn('projects', 'paused_at')) {
Schema::table('projects', function (Blueprint $table): void {
$table->timestampTz('paused_at')->nullable()->after('is_active');
});
}
DB::statement('CREATE INDEX IF NOT EXISTS projects_paused_at_idx ON projects (paused_at)');
// Backfill: для уже paused проектов используем updated_at как best-effort
// (для долго-paused — grace давно истёк; для свежих — близко к реальной паузе).
@@ -28,9 +31,11 @@ return new class extends Migration
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->dropIndex('projects_paused_at_idx');
$table->dropColumn('paused_at');
});
DB::statement('DROP INDEX IF EXISTS projects_paused_at_idx');
if (Schema::hasColumn('projects', 'paused_at')) {
Schema::table('projects', function (Blueprint $table): void {
$table->dropColumn('paused_at');
});
}
}
};
@@ -21,7 +21,7 @@ return new class extends Migration {
}
DB::unprepared(<<<'SQL'
CREATE TABLE project_routing_snapshots (
CREATE TABLE IF NOT EXISTS project_routing_snapshots (
snapshot_date DATE NOT NULL,
project_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
@@ -41,28 +41,43 @@ return new class extends Migration {
-- а snapshot должен пережить (хвост слепка ещё летит).
) PARTITION BY RANGE (snapshot_date);
CREATE INDEX project_routing_snapshots_tenant_date_idx
CREATE INDEX IF NOT EXISTS project_routing_snapshots_tenant_date_idx
ON project_routing_snapshots (tenant_id, snapshot_date);
CREATE INDEX project_routing_snapshots_signal_idx
CREATE INDEX IF NOT EXISTS project_routing_snapshots_signal_idx
ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));
ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS project_routing_snapshots_tenant_isolation ON project_routing_snapshots;
CREATE POLICY project_routing_snapshots_tenant_isolation
ON project_routing_snapshots
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;
-- Партиция для текущего месяца (создаётся также через partitions:create-months).
CREATE TABLE project_routing_snapshots_y2026_m05
CREATE TABLE IF NOT EXISTS project_routing_snapshots_y2026_m05
PARTITION OF project_routing_snapshots
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE project_routing_snapshots_y2026_m06
CREATE TABLE IF NOT EXISTS project_routing_snapshots_y2026_m06
PARTITION OF project_routing_snapshots
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
SQL);
// GRANT'ы вынесены из DDL-блока и обёрнуты в проверку существования роли:
// сборка с нуля на окружении без 5 ролей (dev throwaway) не должна падать (mirror balance_freeze_log).
foreach ([
'crm_app_user' => 'SELECT, INSERT, UPDATE',
'crm_supplier_worker' => 'SELECT, INSERT, UPDATE, DELETE',
] as $role => $privs) {
DB::statement(<<<SQL
DO \$\$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
GRANT {$privs} ON project_routing_snapshots TO {$role};
END IF;
END
\$\$
SQL);
}
// Регистрация в retention (system_settings).
$exists = DB::table('system_settings')
->where('key', 'partition_retention_months_project_routing_snapshots')
@@ -0,0 +1,83 @@
# From-scratch migrate idempotency + full-PostgreSQL Pa11y — Implementation Plan
> **For agentic workers:** small, surgical bugfix. Verification is the CI run of the
> full-PostgreSQL `a11y.yml` (from-scratch `migrate` on a clean PG = the reproduction).
**Goal:** Make `php artisan migrate` succeed on an empty PostgreSQL (CI / new server /
backup-rebuild), then restore the 14 authenticated Pa11y routes that need a real login.
**Architecture:** `0001_01_01_000000_load_initial_schema` loads the *full current*
`db/schema.sql` first, then 28 delta migrations run on top. 25 of them are already
idempotent (guards: `Schema::hasColumn`, `to_regclass`, `pg_class`, `IF NOT EXISTS`,
`ON CONFLICT`, `DO $$ IF NOT EXISTS pg_constraint $$`). Exactly **3** miss the guard and
fail with "already exists" because their objects are already in `schema.sql`. Fix = add the
missing guards. `db/schema.sql` is NOT touched (source-of-truth rule, Pravila §4.2). Prod is
unaffected — these migrations are already recorded as run there, so their bodies never re-execute.
**Tech Stack:** Laravel 13, PostgreSQL 16, GitHub Actions, pa11y-ci (WCAG2AA).
---
## Root cause (systematic-debugging, complete)
Confirmed by reading every post-`load_initial_schema` migration and cross-checking `db/schema.sql`:
| Migration | Non-idempotent statement | Already in schema.sql |
|---|---|---|
| `2026_05_24_100000_add_balance_freeze_to_tenants_and_projects` | `CREATE POLICY tenant_isolation ON balance_freeze_log` (table/cols/indexes already guarded) | policy @ schema.sql:3357 |
| `2026_05_26_120000_add_paused_at_to_projects` | `$table->timestampTz('paused_at')` + `$table->index('projects_paused_at_idx')` | column @ :815, index @ :897 |
| `2026_05_27_120000_create_project_routing_snapshots_table` | `CREATE TABLE` + 2 indexes + policy + 2 partitions (no `IF NOT EXISTS`); unconditional GRANTs | table/policy/indexes @ schema.sql v8.39 |
All other deltas are already idempotent (verified individually). `webhook_log`/`rejected_deals_log`
were removed from schema.sql in v8.35, so the migrations touching them are no-ops on a fresh build.
---
## Task 1 — guard `CREATE POLICY` on `balance_freeze_log`
**File:** `app/database/migrations/2026_05_24_100000_add_balance_freeze_to_tenants_and_projects.php`
- [ ] Insert `DROP POLICY IF EXISTS tenant_isolation ON balance_freeze_log` before the `CREATE POLICY`.
## Task 2 — guard `paused_at` column + index
**File:** `app/database/migrations/2026_05_26_120000_add_paused_at_to_projects.php`
- [ ] `up()`: wrap the column add in `if (! Schema::hasColumn('projects','paused_at'))`; create the
index via `CREATE INDEX IF NOT EXISTS projects_paused_at_idx`. Keep the idempotent backfill.
- [ ] `down()`: `DROP INDEX IF EXISTS` + guard `dropColumn` with `hasColumn`.
## Task 3 — make `project_routing_snapshots` DDL idempotent
**File:** `app/database/migrations/2026_05_27_120000_create_project_routing_snapshots_table.php`
- [ ] `CREATE TABLE IF NOT EXISTS` for the parent and both partitions.
- [ ] `CREATE INDEX IF NOT EXISTS` for both indexes.
- [ ] `DROP POLICY IF EXISTS ... ; CREATE POLICY ...`.
- [ ] Move GRANTs out of the DDL block into role-existence-guarded `DO $$ ... pg_roles ... $$`
blocks (so a from-scratch build on an environment without the 5 roles still succeeds).
## Task 4 — full-PostgreSQL CI workflow
**File:** `.github/workflows/a11y.yml`
- [ ] postgres:16 service container (liderra/postgres/postgres).
- [ ] Create 5 DB roles via `db/00_create_roles.sql`.
- [ ] `.env`: APP_ENV=local, DB pgsql + `DB_SUPPLIER_USERNAME/PASSWORD` (pgsql_supplier connection),
SESSION/CACHE=file, QUEUE=sync, MAIL=log, `SANCTUM_STATEFUL_DOMAINS` incl `localhost:8000`.
- [ ] `mkdir -p storage/framework/{sessions,views,cache}` (file session/cache need them — PR #49 lesson).
- [ ] `migrate --force``partitions:create-months --ahead=2``db:seed --force` (DemoSeeder admin@demo.local).
- [ ] Keep Node 22 + root `npm install` (lock drift) + app `npm ci --legacy-peer-deps` (PR #49 lessons).
## Task 5 — restore 14 authenticated Pa11y routes
**File:** `pa11y.config.json`
- [ ] Re-add the 14 authenticated URLs (dashboard/deals/kanban/projects/billing/settings/reports/
reminders + 6 admin/*) with login `actions` (admin@demo.local / password → wait for /dashboard).
## Verification
CI run of `a11y.yml` on the PR: it boots clean PostgreSQL, runs `migrate` from scratch, seeds,
and Pa11y-scans all 7 public + 14 authenticated routes. Green = both the migrate fix and the
14-page restoration are proven. (`php`/squawk/pest are gate-blocked locally → CI is the verifier.)