package console import ( "context" "database/sql" "errors" "os" "time" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/release" ) // materializeClaimApp is a claim-enabled console's projection of one app's // canonical state into its own SQL row. Flag-off materialization never calls // it. It reads canonical admission and the marker, and writes only through // Store.WithAppMutation with a grant derived from that canonical evidence: // // in-flight purge journal -> hold // last_purge retires this row's app_id -> replay that purge's SQL cleanup // (completed retirement, or a successor incarnation that replaced it) // closed by any operation -> hold (the operation owns the app) // another incarnation, no evidence -> hold // exact marker, row live -> trash with the marker's clock // exact marker, row restoring -> release the stale restoring slot // no marker, completed receipt -> replay that receipt's restore // no marker, no receipt -> hold: absence alone never restores // // Restore and trash projection acquire a short exclusive app mutation before // checking evidence and writing SQL. The receipt alone cannot exclude a new // operation on another console. Purge replay is narrower: positive retirement // evidence grants deletion of only the retired incarnation's local rows. // The admission sweep finishes interrupted restore effects. A missing marker // without a receipt never restores a row. func materializeClaimApp(ctx context.Context, bucket release.Store, st *Store, accountID int64, account, app string, now time.Time) (bool, error) { a, err := hostops.ReadCanonicalAdmission(ctx, bucket, account, app) if errors.Is(err, os.ErrNotExist) { return true, nil } if err != nil { return true, err } id, err := st.ClaimAppID(accountID, app) if errors.Is(err, sql.ErrNoRows) { return true, nil } if err != nil { return true, err } if a.Value.LastPurge != nil && purgeRetired(a.Value, id, a.Value.LastPurge.ID) { return projectClaimApp(ctx, bucket, st, accountID, account, app, now) } if a.Value.AppID != id || a.Value.State != "open" || a.Value.Op != nil { return true, nil } err = release.WithAppMutation(ctx, bucket, account, app, "projection", func(ctx context.Context) error { _, err := projectClaimApp(ctx, bucket, st, accountID, account, app, now) return err }) if errors.Is(err, hostops.ErrPending) { return true, nil } return true, err } func projectClaimApp(ctx context.Context, bucket release.Store, st *Store, accountID int64, account, app string, now time.Time) (bool, error) { if err := purgeJournalAbsent(ctx, bucket, account, app); err != nil { if errors.Is(err, hostops.ErrPending) { return true, nil } return true, err } a, err := hostops.ReadCanonicalAdmission(ctx, bucket, account, app) if errors.Is(err, os.ErrNotExist) { // No canonical identity: nothing here may be written until the signed // backfill or enrollment binds one. return true, nil } if err != nil { return true, err } id, err := st.ClaimAppID(accountID, app) if errors.Is(err, sql.ErrNoRows) { return true, nil } if err != nil { return true, err // an unbound row waits for explicit identity binding } scope := claimSQLScope{AccountID: accountID, App: app, AppID: id} if lp := a.Value.LastPurge; lp != nil && purgeRetired(a.Value, id, lp.ID) { // The retired incarnation's terminal replay, keyed on its own app_id, // so it still runs on a console that lagged past a recreation. The // revision is the canonical object's, which only grows across // recreation. op := lp.ID return true, st.WithAppMutation(ctx, []sqlGrant{purgeReceiptGrant(bucket, account, scope, op)}, func(v *Store) error { if err := v.BindClaimAppOperation(accountID, app, id, op, a.Value.Revision); err != nil { return err } if err := v.MarkClaimAppRetired(accountID, app, id, now); err != nil { return err } return v.PurgeClaimAppRow(ctx, accountID, app, id, op) }) } if id != a.Value.AppID || hostops.RequireAppMutation(ctx, bucket, account, app, id) != nil { // An exclusive operation owns the app, a retirement names some other // incarnation, or an open successor must never be projected onto an // old incarnation's row without last_purge naming it. return true, nil } life, err := st.AppLife(accountID, app) if err != nil || life == nil { return true, err } tomb, tag, err := readTombstoneVersion(ctx, bucket, account, app) if err != nil { return true, err } if tomb != nil { switch { case !life.Trashed(): return true, st.WithAppMutation(ctx, []sqlGrant{tombstoneGrant(ctx, bucket, account, scope, tag)}, func(v *Store) error { _, err := v.TrashClaimAppRow(accountID, app, id, tomb.DeletedAt) return err }) case life.Restoring: return true, st.WithAppMutation(ctx, []sqlGrant{tombstoneGrant(ctx, bucket, account, scope, tag)}, func(v *Store) error { _, err := v.SetClaimRestoring(accountID, app, id, false) return err }) } return true, nil } if !life.Trashed() { return true, nil } receipt := a.Value.LastRestore if receipt == nil || !restoreReceiptCurrent(a.Value, *receipt) || receipt.AppID != id || life.DeletedAt != receipt.DeletedAt { // No completed receipt for this incarnation, or the row's trash is // not the one the receipt removed (a later trash): hold. return true, nil } r := *receipt return true, st.WithAppMutation(ctx, []sqlGrant{restoreReceiptGrant(ctx, bucket, account, scope, r)}, func(v *Store) error { _, err := v.FinishClaimRestoreRow(accountID, app, id, r.DeletedAt, now) return err }) }