immich/NON_DESTRUCTIVE_EDITS_MODEL_TODO.md

12 KiB

Non-Destructive Edits: Model Layer To-Do

Scope (Model Layer Only)

  • Build a durable schema for non-destructive edits that preserves originals and edit history.
  • Include only database/model work in server/src/schema/* (+ migration files).
  • Do not include controller, service, job orchestration, or UI changes in this pass.

Execution Classification (Required)

  • [TDD] means red/green automated verification is expected (migration/integration/schema tests).
  • [HUMAN] means this cannot be fully confirmed functionally via automated tests and requires manual human completion/sign-off.
  • Policy: every [HUMAN] checkbox must be completed by a human before this work is considered done.

Current Baseline (Already in Repo)

  • asset_edit stores per-asset edit operations (action, parameters, sequence).
  • asset.isEdited is maintained by DB triggers (asset_edit_insert, asset_edit_delete).
  • asset_file stores separate edited renditions using isEdited and unique (assetId, type, isEdited).

Why Model Work Is Still Needed

  • Edit history is currently destructive: replacing edits deletes prior asset_edit rows.
  • There is no first-class revision entity for versioning, rollback lineage, or concurrency control.
  • Edited files are not explicitly tied to a specific edit revision.
  • Some important invariants are only enforced in application code (not at DB level).

Target Model (End State)

  • Add asset_edit_revision table as the source of truth for edit versions per asset.
  • Keep asset_edit as operation rows, but attach each row to a revision (revisionId).
  • Add asset.currentEditRevisionId so active edits are explicit and fast to query.
  • Add asset_file.editRevisionId to link derived files to the exact revision they were generated from.
  • Keep compatibility columns (asset.isEdited, asset_file.isEdited) during transition; drive them from triggers.

Required Data Invariants

  • Original file path/checksum in asset remains immutable by edit operations.
  • At most one active revision per asset at a time.
  • asset.currentEditRevisionId must reference a revision belonging to the same asset.id.
  • Revision operation order must be deterministic and gapless from sequence = 0.
  • Edited files (asset_file) must reference a valid revision for the same asset.
  • asset.isEdited = true iff asset.currentEditRevisionId IS NOT NULL (during compatibility phase).

Implementation To-Do

1) Schema Contracts and Naming Freeze

  • [HUMAN] Finalize canonical names:
    • [HUMAN] asset_edit_revision (new table)
    • [HUMAN] asset.currentEditRevisionId (new nullable FK)
    • [HUMAN] asset_file.editRevisionId (new nullable FK)
    • [HUMAN] asset_edit.revisionId (new nullable -> then required FK)
  • [HUMAN] Decide lifecycle states for revisions (recommended: pending, active, archived, failed).
  • [HUMAN] Write a short ADR note in this file before coding migrations (1-2 paragraphs).

2) Add New Schema Objects

  • [TDD] Update server/src/schema/enums.ts:
    • [TDD] Add revision-state enum registration.
  • [TDD] Add server/src/schema/tables/asset-edit-revision.table.ts:
    • [TDD] id (uuid PK)
    • [TDD] assetId (FK -> asset.id, cascade on delete/update)
    • [TDD] createdAt (timestamp)
    • [TDD] createdById (nullable FK -> user.id, set null on delete)
    • [TDD] state (enum, default pending)
    • [TDD] activatedAt (nullable timestamp)
    • [TDD] supersededAt (nullable timestamp)
    • [TDD] sourceWidth / sourceHeight (nullable int, optional but recommended)
    • [TDD] outputWidth / outputHeight (nullable int, optional but recommended)
    • [TDD] operationCount (int default 0)
    • [TDD] hash (nullable text for dedupe/idempotency, optional)
  • [TDD] Update server/src/schema/tables/asset-edit.table.ts:
    • [TDD] Add revisionId FK -> asset_edit_revision.id (nullable during transition).
    • [TDD] Keep assetId during transition for easier backfill; remove later only after service refactor.
    • [TDD] Keep unique (assetId, sequence) for compatibility phase.
    • [TDD] Add unique (revisionId, sequence) as new canonical order key.
  • [TDD] Update server/src/schema/tables/asset.table.ts:
    • [TDD] Add nullable FK currentEditRevisionId -> asset_edit_revision.id (SET NULL on delete).
    • [TDD] Keep isEdited column for backward compatibility.
  • [TDD] Update server/src/schema/tables/asset-file.table.ts:
    • [TDD] Add nullable FK editRevisionId -> asset_edit_revision.id (CASCADE update, SET NULL delete).
    • [TDD] Keep isEdited for compatibility.
  • [TDD] Update server/src/schema/index.ts:
    • [TDD] Register new table in tables.
    • [TDD] Add asset_edit_revision to DB interface.

3) DB-Level Constraints and Indexes

  • [TDD] Add constraints to asset_edit_revision:
    • [TDD] Check operationCount >= 0.
    • [TDD] Check timestamps are consistent (activatedAt <= supersededAt when both are present).
  • [TDD] Add partial unique index for one active revision per asset:
    • [TDD] unique (assetId) where state = 'active'.
  • [TDD] Add constraints to asset_edit:
    • [TDD] Check sequence >= 0.
    • [TDD] Check jsonb_typeof(parameters) = 'object'.
    • [TDD] Add deferred constraint trigger that enforces per-revision gapless sequence (0..N-1) at commit time.
  • [HUMAN] Decide whether action-specific parameter checks are too rigid for long-term evolution.
  • [TDD] If approved, add action-specific parameter shape checks (or trigger validation) for critical actions only.
  • [TDD] Add constraints to asset_file:
    • [TDD] Check isEdited = (editRevisionId IS NOT NULL) during compatibility phase.
    • [TDD] Keep existing uniqueness on (assetId, type, isEdited) until downstream code is updated.
  • [TDD] Add helpful indexes:
    • [TDD] asset_edit_revision (assetId, state, createdAt desc)
    • [TDD] asset_edit (revisionId, sequence)
    • [TDD] asset_file (assetId, editRevisionId, type)
    • [TDD] asset (currentEditRevisionId) (if not auto-indexed by FK tooling)

4) Trigger/Function Updates (Model-Side Logic)

  • [TDD] Update server/src/schema/functions.ts with revision-aware trigger functions:
    • [TDD] On revision activation, auto-supersede prior active revision for the same asset.
    • [TDD] Keep asset.isEdited synchronized from asset.currentEditRevisionId.
    • [TDD] Maintain asset_edit_revision.operationCount on asset_edit insert/delete.
    • [TDD] Validate asset.currentEditRevisionId belongs to the same asset (asset.id).
    • [TDD] Validate asset_file.editRevisionId belongs to the same asset (asset_file.assetId).
  • [TDD] Add write-compatibility trigger for legacy inserts:
    • [TDD] Before insert on asset_edit, if revisionId is null, assign from asset.currentEditRevisionId.
    • [TDD] If asset.currentEditRevisionId is null, create and activate a revision, then assign it.
  • [TDD] Preserve existing behavior for compatibility:
    • [TDD] asset_edit_insert/asset_edit_delete must continue to produce correct asset.isEdited while service layer catches up.
  • [TDD] Add migration override entries when function SQL is intentionally managed as overrides.

5) Migration Plan (Order Matters)

  • [TDD] Create migration A: add enum + asset_edit_revision + nullable new FK columns + indexes (no hard checks yet).
  • [TDD] Create migration B: backfill revisions from existing data:
    • [TDD] One active revision per asset that currently has rows in asset_edit.
    • [TDD] Populate asset.currentEditRevisionId.
    • [TDD] Populate asset_edit.revisionId by asset mapping.
    • [TDD] Populate asset_file.editRevisionId for rows where asset_file.isEdited = true.
    • [TDD] Set operationCount.
  • [TDD] Create migration C: add triggers/functions (including legacy revisionId assignment and cross-asset guards) and add constraints as NOT VALID where possible.
  • [TDD] Create migration D: validate constraints; make asset_edit.revisionId NOT NULL only after compatibility-trigger tests are green.
  • [TDD] Keep rollback safety:
    • [TDD] Each migration down should reverse schema and trigger changes without data loss for original asset files.
  • [HUMAN] Run rollout sequence on staging/prod in change window and sign off on operational safety.

6) Data Verification Queries (Run After Backfill)

  • [TDD] Add this SQL block to migration notes and assert all counts are zero in migration/integration tests.
  • [HUMAN] Run the same queries against staging/prod after migration and record results in rollout notes.
-- 1) Edited flag consistency
select count(*) as mismatches
from asset
where ("isEdited" = true and "currentEditRevisionId" is null)
   or ("isEdited" = false and "currentEditRevisionId" is not null);

-- 2) Current revision belongs to same asset
select count(*) as bad_current_links
from asset a
join asset_edit_revision r on r.id = a."currentEditRevisionId"
where r."assetId" <> a.id;

-- 3) Edited files are linked to a revision
select count(*) as missing_revision_links
from asset_file f
where f."isEdited" = true and f."editRevisionId" is null;

-- 4) Edited files reference revision for same asset
select count(*) as cross_asset_file_links
from asset_file f
join asset_edit_revision r on r.id = f."editRevisionId"
where r."assetId" <> f."assetId";

-- 5) More than one active revision per asset
select count(*) as multi_active_assets
from (
  select "assetId"
  from asset_edit_revision
  where "state" = 'active'
  group by "assetId"
  having count(*) > 1
) t;

-- 6) Operation ordering gaps (per revision)
with ordered as (
  select "revisionId", "sequence",
         row_number() over (partition by "revisionId" order by "sequence") - 1 as expected
  from asset_edit
)
select count(*) as sequence_gaps
from ordered
where "sequence" <> expected;

-- 7) operationCount mismatch
select count(*) as operation_count_mismatch
from asset_edit_revision r
left join (
  select "revisionId", count(*)::int as cnt
  from asset_edit
  group by "revisionId"
) e on e."revisionId" = r.id
where coalesce(e.cnt, 0) <> r."operationCount";

7) Model-Focused Test Coverage

  • [TDD] Add/extend migration tests for:
    • [TDD] clean DB -> up migrations.
    • [TDD] existing edit data -> backfill correctness.
    • [TDD] down migration sanity.
  • [TDD] Add schema drift guard:
    • [TDD] run schema-check and ensure no unexpected drift.
  • [TDD] Add trigger behavior tests (integration-level):
    • [TDD] activate revision updates asset flags.
    • [TDD] deleting last edit operation does not orphan active state incorrectly.
    • [TDD] edited file links reject cross-asset revision references.
    • [TDD] legacy insert path without revisionId remains functional.
    • [TDD] sequence gap creation is rejected at transaction commit.
    • [TDD] concurrent activation attempts cannot produce two active revisions for one asset.

8) Deferred Cleanup (After Service Layer Is Updated)

  • [HUMAN] Confirm production traffic is fully revision-based before removing compatibility paths.
  • [TDD] Remove transitional compatibility constraints/triggers that only support old code paths.
  • [TDD] Remove legacy auto-assignment trigger for asset_edit.revisionId after service rollout.
  • [TDD] Consider dropping asset_edit.assetId once all reads/writes are revision-based.
  • [TDD] Consider replacing asset_file.isEdited with computed semantics from editRevisionId.
  • [HUMAN] Decide whether asset.isEdited remains as denormalized cache or is dropped.

Definition of Done (Model Layer)

  • [TDD] Schema supports multiple preserved edit revisions per asset without deleting old revisions.
  • [TDD] Active revision is explicit, constrained, and query-efficient.
  • [TDD] Edited file rows are revision-linked and integrity-checked (including same-asset validation).
  • [TDD] Backfill is complete and verification queries return zero mismatches in automated tests.
  • [TDD] Migrations are reversible and schema-check shows no drift.
  • [HUMAN] Staging/prod verification queries are run and signed off by a human.
  • [HUMAN] All [HUMAN] tasks in this file are checked off by a human owner.