Skip to content

Schema sync is additive-only: non-additive metadata changes (required→optional, type, drop, rename) silently diverge from existing DBs; need drift detection + os migrate #2186

Description

@xuyushun441-sys

Summary

The metadata→DB schema sync is additive-only: it creates new tables and adds new columns, but never alters or drops existing columns. Any non-additive metadata change (required↔optional, type/length, drop field, rename) silently diverges from an existing database — with no warning — and the physical column wins at write time. There is also no migration command to reconcile an existing DB.

This produces confusing, hard-to-diagnose failures: the served metadata says one thing, the write fails saying another.

Concrete repro (the one that surfaced this)

After #2178 made sys_business_unit.organization_id optional (required: false):

  1. Existing dev DB created while the field was required: true → column is NOT NULL (PRAGMA table_infonotnull: 1).
  2. git pull + pnpm build + restart os dev (code fully fixed).
  3. GET /meta/object/sys_business_unitorganization_idrequired: false ✅ (reads metadata)
  4. POST /data/sys_business_unit {name, kind}HTTP 400 {"error":"organization_id is required","code":"VALIDATION_FAILED"}
DBorganization_id columncreate
existing (made under required:true)NOT NULL (notnull=1)❌ 400 "organization_id is required"
freshnullable (notnull=0)✅ 201

Rebuilding code cannot fix it — the existing column constraint is never relaxed. A DB reset (or manual table rebuild) is currently the only workaround.

Root cause

  • Additive-only sync.SqlDriver.initObjects (packages/plugins/driver-sql/src/sql-driver.ts:1446-1493): table missing → CREATE TABLE with full constraints (requiredNOT NULL, see createColumnsql-driver.ts:2282); table exists → ALTER TABLE ADD COLUMNonly for new fields. Existing columns are never touched (no nullability/type change, no drop, no rename). Runs at boot via ObjectQLPluginsyncRegisteredSchemas() (packages/objectql/src/plugin.ts:336,361) and on package install.
  • Two sources of truth for "required"./meta reports required from the metadata (now false), but create-validation effectively enforces the physical column (NOT NULL). These must converge on metadata as the single source of truth.
  • Silent. Nothing detects or reports that metadata and the physical schema have diverged.

Impact

  • Dev: any required→optional / type / drop / rename change appears to "not work" after pulling + rebuilding; only a DB reset helps. Easy to mistake for stale code (it is not).
  • Prod: non-additive metadata changes cannot be applied at all — no migration path, and auto-DDL would be unsafe anyway.

Current behavior (what syncs vs. not)

Metadata changeExisting DB on next boot
New object / new field / new index✅ applied (additive)
required: true → false (relax NOT NULL)❌ not applied
field type / length change❌ not applied
field removed❌ column kept (orphan)
field renamed❌ old kept + new added

Proposed solution

Adopt a Prisma-style dev/prod split, with the metadata as the single source of truth and the physical schema reconciled to it:

  • P1 — Drift detection + actionable warning (boot-time). After sync, diff metadata vs. actual columns (nullability, type, orphaned columns) and WARN with a concrete hint, e.g. sys_business_unit.organization_id: metadata optional but column is NOT NULL — run "os migrate". Turns silent divergence into a visible, actionable signal.
  • P2 — Dev auto-reconcile, "loosen-only". Automatically apply non-destructive, cannot-fail alters: relax NOT NULL → NULL, widen varchar, add index. required → optional falls in this safe set, so existing dev DBs self-heal on restart. (Dev only / opt-in; never auto-DDL in prod.)
  • P3 — os migrate command (required). Deliberate, reviewable migrations for the unsafe/destructive set (drop column, tighten constraint w/ backfill, narrow type, rename):
    • os migrate plan → dry-run diff categorized safe / needs-confirm / destructive;
    • os migrate apply → execute (destructive requires --allow-destructive), with backup/rollback;
    • SQLite: table-rebuild (copy → swap) since it can't alter constraints in place; Postgres: native ALTER.

Also fix the "required" source-of-truth inconsistency so validation follows metadata, not the physical column.

Acceptance criteria

  • Boot logs a clear warning for every metadata↔physical schema divergence (P1).
  • In dev, a required:true→false change is reflected in an existing DB after restart, no reset needed (P2).
  • os migrate plan/apply reconciles non-additive changes with a preview + destructive guardrail (P3).
  • Validation derives "required" from metadata, consistent with /meta.
  • Works across SQLite + Postgres.

References

  • Surfaced by fix(platform-objects): organization_id optional so BU/Team create in single-tenant (ADR-0057) #2178 (sys_business_unit/sys_teamorganization_id made optional — fix correct, but existing DBs still reject).
  • packages/plugins/driver-sql/src/sql-driver.ts:1446-1493 (initObjects), :2282 (createColumn required→NOT NULL)
  • packages/objectql/src/plugin.ts:336,361 (syncRegisteredSchemas at boot)
  • packages/objectql/src/validation/rule-validator.ts:295, record-validator.ts:127 (the two "required" enforcement paths)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions