feat(db): retention schema for account deletion (migration 00007)

Additive/expand-contract: new append-only retained_identities table (the legal
dossier of every detached credential, keyed by account_id, TTL'd by detached_at)
plus nullable accounts columns last_login_at/last_login_ip (cold-load stamp) and
deleted_at/deleted_display_name (tombstone + retained real name). Regenerates the
go-jet models for accounts + retained_identities only.
This commit is contained in:
Ilia Denisov
2026-07-03 11:50:14 +02:00
parent 61c7da271c
commit 710ab06333
6 changed files with 189 additions and 2 deletions
@@ -32,4 +32,8 @@ type Accounts struct {
MergedAt *time.Time
FlaggedHighRateAt *time.Time
VariantPreferences pq.StringArray
LastLoginAt *time.Time
LastLoginIP *string
DeletedAt *time.Time
DeletedDisplayName *string
}
@@ -0,0 +1,24 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type RetainedIdentities struct {
RetainedID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Kind string
ExternalID string
Confirmed bool
LinkedAt time.Time
DetachedAt time.Time
Reason string
}
@@ -35,6 +35,10 @@ type accountsTable struct {
MergedAt postgres.ColumnTimestampz
FlaggedHighRateAt postgres.ColumnTimestampz
VariantPreferences postgres.ColumnStringArray
LastLoginAt postgres.ColumnTimestampz
LastLoginIP postgres.ColumnString
DeletedAt postgres.ColumnTimestampz
DeletedDisplayName postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -94,8 +98,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
MergedAtColumn = postgres.TimestampzColumn("merged_at")
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
LastLoginAtColumn = postgres.TimestampzColumn("last_login_at")
LastLoginIPColumn = postgres.StringColumn("last_login_ip")
DeletedAtColumn = postgres.TimestampzColumn("deleted_at")
DeletedDisplayNameColumn = postgres.StringColumn("deleted_display_name")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
)
@@ -121,6 +129,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
MergedAt: MergedAtColumn,
FlaggedHighRateAt: FlaggedHighRateAtColumn,
VariantPreferences: VariantPreferencesColumn,
LastLoginAt: LastLoginAtColumn,
LastLoginIP: LastLoginIPColumn,
DeletedAt: DeletedAtColumn,
DeletedDisplayName: DeletedDisplayNameColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,99 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var RetainedIdentities = newRetainedIdentitiesTable("backend", "retained_identities", "")
type retainedIdentitiesTable struct {
postgres.Table
// Columns
RetainedID postgres.ColumnString
AccountID postgres.ColumnString
Kind postgres.ColumnString
ExternalID postgres.ColumnString
Confirmed postgres.ColumnBool
LinkedAt postgres.ColumnTimestampz
DetachedAt postgres.ColumnTimestampz
Reason postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type RetainedIdentitiesTable struct {
retainedIdentitiesTable
EXCLUDED retainedIdentitiesTable
}
// AS creates new RetainedIdentitiesTable with assigned alias
func (a RetainedIdentitiesTable) AS(alias string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new RetainedIdentitiesTable with assigned schema name
func (a RetainedIdentitiesTable) FromSchema(schemaName string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new RetainedIdentitiesTable with assigned table prefix
func (a RetainedIdentitiesTable) WithPrefix(prefix string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new RetainedIdentitiesTable with assigned table suffix
func (a RetainedIdentitiesTable) WithSuffix(suffix string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newRetainedIdentitiesTable(schemaName, tableName, alias string) *RetainedIdentitiesTable {
return &RetainedIdentitiesTable{
retainedIdentitiesTable: newRetainedIdentitiesTableImpl(schemaName, tableName, alias),
EXCLUDED: newRetainedIdentitiesTableImpl("", "excluded", ""),
}
}
func newRetainedIdentitiesTableImpl(schemaName, tableName, alias string) retainedIdentitiesTable {
var (
RetainedIDColumn = postgres.StringColumn("retained_id")
AccountIDColumn = postgres.StringColumn("account_id")
KindColumn = postgres.StringColumn("kind")
ExternalIDColumn = postgres.StringColumn("external_id")
ConfirmedColumn = postgres.BoolColumn("confirmed")
LinkedAtColumn = postgres.TimestampzColumn("linked_at")
DetachedAtColumn = postgres.TimestampzColumn("detached_at")
ReasonColumn = postgres.StringColumn("reason")
allColumns = postgres.ColumnList{RetainedIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn}
defaultColumns = postgres.ColumnList{ConfirmedColumn, DetachedAtColumn}
)
return retainedIdentitiesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
RetainedID: RetainedIDColumn,
AccountID: AccountIDColumn,
Kind: KindColumn,
ExternalID: ExternalIDColumn,
Confirmed: ConfirmedColumn,
LinkedAt: LinkedAtColumn,
DetachedAt: DetachedAtColumn,
Reason: ReasonColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -34,6 +34,7 @@ func UseSchema(schema string) {
GameSetupDraws = GameSetupDraws.FromSchema(schema)
Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema)
RetainedIdentities = RetainedIdentities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema)
SuspensionReasons = SuspensionReasons.FromSchema(schema)
}
@@ -0,0 +1,47 @@
-- Account deletion as legal retention (not erasure). Two additive pieces.
--
-- retained_identities is an append-only journal of every credential detached from an
-- account — on unlink, email change, or account deletion (reason). It preserves the legal
-- dossier (which email/vk/tg was linked, and when) while the live identities row is
-- removed, so the (kind, external_id) frees for a new account to reuse. No unique
-- constraint and no kind CHECK: the same credential may recur across accounts and events,
-- and the log stays robust to future identity kinds. detached_at drives the retention TTL.
--
-- accounts gains: last_login_at / last_login_ip (stamped on a cold app-load, throttled),
-- deleted_at (the tombstone marker), and deleted_display_name (the real name retained for
-- the admin dossier after the live display_name is scrubbed to the anonymised label).
--
-- Expand-contract: everything is additive (a new table plus nullable columns), so a
-- backend image rollback stays DB-safe — older code simply ignores them. The accounts
-- table shape changes, so its generated go-jet model is regenerated.
-- +goose Up
CREATE TABLE backend.retained_identities (
retained_id uuid NOT NULL,
account_id uuid NOT NULL,
kind text NOT NULL,
external_id text NOT NULL,
confirmed boolean DEFAULT false NOT NULL,
linked_at timestamp with time zone NOT NULL,
detached_at timestamp with time zone DEFAULT now() NOT NULL,
reason text NOT NULL,
CONSTRAINT retained_identities_pkey PRIMARY KEY (retained_id),
CONSTRAINT retained_identities_reason_chk
CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text])))
);
CREATE INDEX retained_identities_account_id_idx ON backend.retained_identities (account_id);
CREATE INDEX retained_identities_detached_at_idx ON backend.retained_identities (detached_at);
ALTER TABLE backend.accounts
ADD COLUMN last_login_at timestamp with time zone,
ADD COLUMN last_login_ip text,
ADD COLUMN deleted_at timestamp with time zone,
ADD COLUMN deleted_display_name text;
-- +goose Down
ALTER TABLE backend.accounts
DROP COLUMN last_login_at,
DROP COLUMN last_login_ip,
DROP COLUMN deleted_at,
DROP COLUMN deleted_display_name;
DROP TABLE backend.retained_identities;