package console import ( "context" "database/sql" "errors" "time" "github.com/carlosframework/platform/internal/release" ) const ( // temporaryTTLDefault is a temporary app's life when nobody says // otherwise, and what a restore gives back to one whose expiry is under // temporaryRestoreFloor away (spec §1 ruling 3, §6 step 2). temporaryTTLDefault = 7 * 24 * time.Hour temporaryRestoreFloor = time.Hour // temporaryCooloffSeconds is release.TemporaryCooloff as the Unix-second // offset the row stores. temporaryCooloffSeconds = int64(release.TemporaryCooloff / time.Second) ) // AppLife is one apps row's lifecycle columns, read together because every // temporary-app decision needs them together. Times are Unix seconds. type AppLife struct { AccountID int64 App string CreatedAt int64 DeletedAt int64 // 0 = live ExpiresAt int64 // 0 = permanent Restoring bool PurgeAfter int64 // 0 = no early purge PurgeFor int64 // the marker deleted_at purge_after was computed from ControlPlane bool } func (l *AppLife) Temporary() bool { return l.ExpiresAt > 0 } func (l *AppLife) Trashed() bool { return l.DeletedAt != 0 } const appLifeCols = `account_id, app, created_at, deleted_at, expires_at, restoring, purge_after, purge_for, control_plane` func scanAppLife(sc interface{ Scan(...any) error }) (AppLife, error) { var l AppLife err := sc.Scan(&l.AccountID, &l.App, &l.CreatedAt, &l.DeletedAt, &l.ExpiresAt, &l.Restoring, &l.PurgeAfter, &l.PurgeFor, &l.ControlPlane) return l, err } // AppLife reads one row's lifecycle. (nil, nil) when there is no row. func (s *Store) AppLife(accountID int64, app string) (*AppLife, error) { l, err := scanAppLife(s.db.QueryRow(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? AND app = ?`, accountID, app)) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &l, nil } func (s *Store) appLives(query string, args ...any) ([]AppLife, error) { rows, err := s.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var out []AppLife for rows.Next() { l, err := scanAppLife(rows) if err != nil { return nil, err } out = append(out, l) } return out, rows.Err() } // AppLivesIn is every row in the account, trashed included, by name: the // input to GET /api/cli/apps/{account}/list (spec §7). func (s *Store) AppLivesIn(accountID int64) ([]AppLife, error) { return s.appLives(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? ORDER BY app`, accountID) } // liveTemporaryWhere is the cap's definition of a live temporary app (spec // §6): a row mid-restore holds its slot, so a new app cannot take it. const liveTemporaryWhere = `expires_at > 0 AND (deleted_at = 0 OR restoring = 1)` // LiveTemporaryApps lists the rows the cap counts, soonest expiry first, so // the refusal can say what each one is and when it goes. func (s *Store) LiveTemporaryApps(accountID int64) ([]AppLife, error) { return s.appLives(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? AND `+liveTemporaryWhere+` ORDER BY expires_at, app`, accountID) } // CreateTemporaryAppIn is CreateTemporaryAppInRegion at the home, the way // CreateAppIn is CreateAppInRegion at the home. func (s *Store) CreateTemporaryAppIn(accountID int64, app, createdBy string, now, expiresAt time.Time) (bool, error) { return s.CreateTemporaryAppInRegion(accountID, app, createdBy, "", "", now, expiresAt) } // CreateTemporaryAppInRegion claims a name with its expiry AND its region in // the SAME insert (spec §3; nearest-region spec §5.3): there is no moment // where the row exists permanent or regionless, and a racing permanent // create of the same name loses the ON CONFLICT. func (s *Store) CreateTemporaryAppInRegion(accountID int64, app, createdBy, region, source string, now, expiresAt time.Time) (bool, error) { if expiresAt.Unix() <= 0 { return false, errors.New("console: a temporary app needs an expiry") } res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`, app, accountID, createdBy, now.Unix(), region, source, expiresAt.Unix()) if err != nil { return false, err } n, err := res.RowsAffected() return n > 0, err } // changed runs one conditional UPDATE and reports whether it won. Every // transition below is one of these: a guard in the WHERE clause, and false // when a concurrent transition got there first. func (s *Store) changed(query string, args ...any) (bool, error) { res, err := s.db.Exec(query, args...) if err != nil { return false, err } n, err := res.RowsAffected() return n > 0, err } // ExpireAppRow is the expiry pass's step 4 (spec §5): the marker's clock // becomes deleted_at and purge_for, and purge_after is 3 days later, guarded // by expires_at > 0 AND deleted_at = 0 so a keep that won the lock first // (expires_at = 0) makes this a no-op. func (s *Store) claimRawExpireAppRow(accountID int64, app string, deletedAt time.Time) (bool, error) { at := deletedAt.Unix() return s.changed(`UPDATE apps SET deleted_at = ?, purge_for = ?, purge_after = ? WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0`, at, at, at+temporaryCooloffSeconds, accountID, app) } // KeepApp makes a live temporary app permanent (`carlos apps keep`). func (s *Store) claimRawKeepApp(accountID int64, app string) (bool, error) { return s.changed(`UPDATE apps SET expires_at = 0 WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0 AND restoring = 0`, accountID, app) } // SetAppExpiry moves a live temporary app's expiry (`carlos apps ttl`). It // only ever moves a non-zero expiry: there is no door that makes an existing // app temporary (spec §3). func (s *Store) claimRawSetAppExpiry(accountID int64, app string, expiresAt time.Time) (bool, error) { return s.changed(`UPDATE apps SET expires_at = ? WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0 AND restoring = 0`, expiresAt.Unix(), accountID, app) } // BumpTrashedExpiry is restore step 2 (spec §6): while the row is still // trashed, an expiry under an hour away becomes a week away, so the expiry // pass never sees an untrashed row with a stale expiry. func (s *Store) claimRawBumpTrashedExpiry(accountID int64, app string, now time.Time) (bool, error) { return s.changed(`UPDATE apps SET expires_at = ? WHERE account_id = ? AND app = ? AND expires_at > 0 AND expires_at < ? AND deleted_at != 0`, now.Add(temporaryTTLDefault).Unix(), accountID, app, now.Add(temporaryRestoreFloor).Unix()) } // SetRestoring sets restore step 3's flag (only on a trashed row) or clears // it (settleApp, and a failed step 4). func (s *Store) claimRawSetRestoring(accountID int64, app string, on bool) (bool, error) { if on { return s.changed(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = ? AND deleted_at != 0`, accountID, app) } return s.changed(`UPDATE apps SET restoring = 0 WHERE account_id = ? AND app = ?`, accountID, app) } // FinishRestoreRow is restore step 5 in one statement, with step 2 folded in // for the paths that reach it without having done step 2 (a direct-mode // restore caught up by materialize or by the expiry pass, spec §5, §6). func (s *Store) FinishRestoreRow(accountID int64, app string, now time.Time) error { _, err := s.db.Exec(`UPDATE apps SET deleted_at = 0, restoring = 0, purge_after = 0, purge_for = 0, expires_at = CASE WHEN expires_at > 0 AND expires_at < ? THEN ? ELSE expires_at END WHERE account_id = ? AND app = ?`, now.Add(temporaryRestoreFloor).Unix(), now.Add(temporaryTTLDefault).Unix(), accountID, app) return err } // TemporaryAppRows is every temporary row of every live account: the expiry // pass's settle sweep and expire candidates. A list taken before the lock is // only a hint (spec §2). func (s *Store) TemporaryAppRows() ([]AppRow, error) { rows, err := s.db.Query(`SELECT ` + appRowCols + ` FROM apps p JOIN accounts a ON a.id = p.account_id WHERE a.deleted_at = 0 AND p.expires_at > 0 ORDER BY a.sqid, p.app`) if err != nil { return nil, err } return scanAppRows(rows) } // PurgeDueApps is the early-purge candidates: trashed rows whose window has // passed. Keyed on purge_after alone, because the decision was fixed at trash // time (spec §2). func (s *Store) PurgeDueApps(now time.Time) ([]AppRow, error) { rows, err := s.db.Query(`SELECT `+appRowCols+` FROM apps p JOIN accounts a ON a.id = p.account_id WHERE a.deleted_at = 0 AND p.deleted_at != 0 AND p.purge_after > 0 AND p.purge_after <= ? ORDER BY a.sqid, p.app`, now.Unix()) if err != nil { return nil, err } return scanAppRows(rows) } // effectivePurgeAt is the one purge deadline every display uses (spec §5, // astra 4.2): purge_after when set, else deleted_at + TrashCooloff. Nothing // that shows or returns a purge date computes DeletedAt + TrashCooloff itself. func effectivePurgeAt(deletedAt, purgeAfter int64) time.Time { if purgeAfter > 0 { return time.Unix(purgeAfter, 0).UTC() } return time.Unix(deletedAt, 0).UTC().Add(release.TrashCooloff) } func (s *Store) BumpTrashedExpiry(accountID int64, app string, now time.Time) (claimResult0 bool, claimResult1 error) { return s.BumpTrashedExpiryContext(context.Background(), accountID, app, now) } func (s *Store) BumpTrashedExpiryContext(claimContext context.Context, accountID int64, app string, now time.Time) (claimResult0 bool, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.BumpTrashedExpiry", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawBumpTrashedExpiry(accountID, app, now) return claimResult1 }) return } func (s *Store) ExpireAppRow(accountID int64, app string, deletedAt time.Time) (claimResult0 bool, claimResult1 error) { return s.ExpireAppRowContext(context.Background(), accountID, app, deletedAt) } func (s *Store) ExpireAppRowContext(claimContext context.Context, accountID int64, app string, deletedAt time.Time) (claimResult0 bool, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.ExpireAppRow", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawExpireAppRow(accountID, app, deletedAt) return claimResult1 }) return } func (s *Store) KeepApp(accountID int64, app string) (claimResult0 bool, claimResult1 error) { return s.KeepAppContext(context.Background(), accountID, app) } func (s *Store) KeepAppContext(claimContext context.Context, accountID int64, app string) (claimResult0 bool, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.KeepApp", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawKeepApp(accountID, app) return claimResult1 }) return } func (s *Store) SetAppExpiry(accountID int64, app string, expiresAt time.Time) (claimResult0 bool, claimResult1 error) { return s.SetAppExpiryContext(context.Background(), accountID, app, expiresAt) } func (s *Store) SetAppExpiryContext(claimContext context.Context, accountID int64, app string, expiresAt time.Time) (claimResult0 bool, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.SetAppExpiry", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawSetAppExpiry(accountID, app, expiresAt) return claimResult1 }) return } func (s *Store) SetRestoring(accountID int64, app string, on bool) (claimResult0 bool, claimResult1 error) { return s.SetRestoringContext(context.Background(), accountID, app, on) } func (s *Store) SetRestoringContext(claimContext context.Context, accountID int64, app string, on bool) (claimResult0 bool, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.SetRestoring", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawSetRestoring(accountID, app, on) return claimResult1 }) return }