TMA CloudTMA Cloud
Reference

Database Schema

PostgreSQL database schema for TMA Cloud.

PostgreSQL database schema for TMA Cloud.

Tables

users

User accounts and sub-users.

ColumnTypeDescription
idTEXTPrimary key
emailTEXTUnique, not null
passwordTEXTHashed (nullable for OAuth-only accounts)
nameTEXTDisplay name
google_idTEXTUnique (optional)
mfa_enabledBOOLEANDefault false
mfa_secretTEXTTOTP secret (nullable)
mfa_last_time_stepBIGINTLast TOTP time step spent
last_backup_code_regenerationTIMESTAMPTZStamped when backup codes are regenerated
token_versionINTEGERToken version for revocation, default 1
last_token_invalidationTIMESTAMPLast token invalidation time
storage_limitBIGINTStorage limit (nullable, bytes)
storage_usedBIGINTAccount usage counter in bytes, default 0
storage_reservedBIGINTBytes reserved by in-progress object operations, default 0
parent_user_idTEXTAccount owner for sub-users (null for owners), FK → users.id
permissionsTEXT[]Granted permissions for sub-users, default {}
created_atTIMESTAMPTZDefault now()

Sub-users: A row with parent_user_id set is a sub-user of that account. The account any row belongs to is COALESCE(parent_user_id, id), which is the value stored in files.user_id.

Constraints:

  • users_permissions_valid — every entry in permissions must be a known key (files.download, files.upload, files.edit, files.share, files.delete, files.trash)
  • users_owner_has_no_permissions — owners keep an empty permissions array; they are never checked against it
  • users_parent_not_self — a row cannot be its own parent

Triggers:

  • trg_users_reject_nested_sub_user — refuses an insert or update whose parent is itself a sub-user, so sub-users cannot own sub-users
  • trg_users_reject_demoting_parent — refuses turning an owner that already has sub-users into a sub-user

Indexes: Partial index on parent_user_id where parent_user_id IS NOT NULL

storage_reservations

Short-lived quota reservations for object-store work that cannot hold a database transaction open.

ColumnTypeDescription
idTEXTPrimary key
user_idTEXTFK → users.id
bytesBIGINTReserved plaintext bytes
purposeTEXTOperation label
expires_atTIMESTAMPTZWorker cleanup deadline
created_atTIMESTAMPTZDefault now()

Statement-level insert and delete triggers maintain users.storage_reserved. The worker removes expired rows hourly in bounded batches.

files

Files and folders.

ColumnTypeDescription
idTEXTPrimary key
nameTEXTNot null
typeTEXT'file' or 'folder'
sizeBIGINTFile size in bytes (null for folders)
mime_typeTEXTMIME type
user_idTEXTFK → users.id, the account the row belongs to
parent_idTEXTFK → files.id (null for root)
pathTEXTS3 object key. Null for folders
starredBOOLEANDefault false
sharedBOOLEANDefault false; true while the item belongs to a share
shared_atTIMESTAMPTZWhen the item joined a share; null when not shared
deleted_atTIMESTAMPTZSoft delete timestamp
modifiedTIMESTAMPTZLast modification time, not null, default now()
created_atTIMESTAMPTZRow creation time, not null, default now()
accessed_atTIMESTAMPTZLast read time, not null, default now()
dek_wrappedBYTEAWrapped per-file data key (DEK); null for folders
dek_kek_versionINTEGERVersion of the KEK the DEK is wrapped under
aggregate_sizeBIGINTTotal bytes below a folder; zero for files
aggregate_file_countINTEGERDescendant file count maintained for folders
aggregate_folder_countINTEGERDescendant folder count maintained for folders

Indexes: user_id, parent_id, deleted_at, created_at, partial index and unique index on path where path IS NOT NULL, (user_id, accessed_at DESC) where deleted_at IS NULL, dek_kek_version for encrypted files, (deleted_at, id) for bounded trash cleanup, covering indexes for active-file statistics and storage-usage reconciliation, and folder-list page indexes for name, modified time, last access, and effective size.

Hierarchy constraint: files_parent_not_self rejects a row whose parent_id equals its own id. Move operations also reject indirect descendant cycles, and recursive reads keep a visited-ID path so legacy bad data cannot loop forever.

Triggers: files_storage_insert, files_storage_update, and files_storage_delete update the account owner's storage_used counter once per statement from transition tables. files_aggregate_insert, files_aggregate_update, and files_aggregate_delete adjust ancestor folder totals when a row is inserted, moved, resized, changes type, or is removed.

Name search is backed by a trigram GIN index from pg_trgm on lower(name) and a text_pattern_ops btree on lower(name) for prefix matching — not a PostgreSQL full-text index.

File timestamps: modified is the file's own timestamp — uploads and copies preserve the client's original mtime, so it can be years old on a row written seconds ago. created_at is when the row was written and is what orphan detection uses to tell an in-flight write from an orphan. accessed_at is when the item was last read. shared_at is set when an item joins a share and cleared when it is unshared. Re-sharing an active item does not change it. Renames and moves change neither path nor created_at, and they do not count as reads, so accessed_at is left alone as well. Reading an item never changes modified.

accessed_at precision: The value is written at most once per hour per item, so it can lag a read by up to that long. This follows NTFS, which guarantees its last-access time only to within an hour, and Linux's relatime. Writes are buffered in memory and flushed in batches, so a read never waits on the update. See File System.

Share link metadata.

ColumnTypeDescription
idTEXTPrimary key (used as token)
file_idTEXTFK → files.id
user_idTEXTFK → users.id
expires_atTIMESTAMPTZExpiration (null = no expiration)
created_atTIMESTAMPTZDefault now()

Indexes: file_id; partial index on expires_at where expires_at IS NOT NULL

Junction table linking share links to files.

ColumnTypeDescription
share_idTEXTFK → share_links.id
file_idTEXTFK → files.id

Primary Key: (share_id, file_id)

Indexes: file_id

Note the column is share_id, not share_link_id. Both foreign keys cascade on delete.

app_settings

Application-wide settings.

ColumnTypeDescription
idTEXTPrimary key (always 'app_settings')
signup_enabledBOOLEANDefault true
first_user_idTEXTFK → users.id (immutable)
share_base_urlTEXTCustom share link base URL (null = use request origin)
max_upload_size_bytesBIGINTMax single-file upload size in bytes (default 10737418240 = 10 GB)
hide_file_extensionsBOOLEANWhen true, file names are shown without extensions (default false)
require_electron_clientBOOLEANWhen true, only desktop app is allowed to use (default false)
allow_password_changeBOOLEANWhen true, users may change their own password (default false)
known_proxiesTEXT[]Proxy IPs, CIDR ranges, or hostnames trusted after server restart
onlyoffice_urlTEXTOnlyOffice Document Server URL (null = integration off)
onlyoffice_jwt_secretTEXTShared secret for signing OnlyOffice payloads
updated_atTIMESTAMPTZDefault now()

The table holds exactly one row, keyed 'app_settings'. first_user_id has a RESTRICT foreign key, so the first user cannot be deleted while the row references them.

sessions

Active user sessions.

ColumnTypeDescription
idTEXTPrimary key
user_idTEXTFK → users.id
token_versionINTEGERToken version when created
user_agentTEXTBrowser user agent
ip_addressINETLatest client IP observed on a request
created_atTIMESTAMPTZDefault now()
last_activityTIMESTAMPTZDefault now()

Indexes: (user_id, created_at DESC), (user_id, token_version), last_activity

A session is invalid once its token_version falls behind the user's current one, which is how "logout everywhere" and a password change end every session at once.

Retention: rows older than 30 days are removed in bounded batches by the daily 02:45 UTC worker job.

file_operation_results

Idempotency results for durable file operations.

ColumnTypeDescription
job_idUUIDPrimary key; the pg-boss job ID
user_idTEXTAccount that owns the operation
taskTEXTOperation type, currently copy
outputJSONBStable result returned by a worker retry
completed_atTIMESTAMPTZDefault now()

Indexes: completed_at

The copy transaction writes its result with the new file rows. If pg-boss retries the same job after that commit, the worker returns this result instead of inserting another copy. Rows older than 30 days are removed in bounded batches by the daily 02:50 UTC worker job.

mfa_backup_codes

One row per single-use MFA backup code.

ColumnTypeDescription
idTEXTPrimary key
user_idTEXTFK → users.id, not null, CASCADE
code_hashTEXTbcrypt hash of the code, not null
usedBOOLEANDefault false
created_atTIMESTAMPDefault CURRENT_TIMESTAMP
used_atTIMESTAMPWhen the code was spent (null while unused)

Codes are stored hashed, never in plain text, so a lost code cannot be recovered — only replaced. Ten rows are written when MFA is enabled and again on each regeneration, which first deletes the old set. Disabling MFA deletes all of a user's rows.

Indexes: user_id; partial index on (user_id, used) where used = FALSE

client_heartbeats

Browser-session presence and Electron desktop client heartbeat records.

ColumnTypeDescription
idVARCHAR(64)Primary key
user_idVARCHAR(255)FK → users.id
session_idVARCHAR(255)JWT session ID (nullable)
app_versionVARCHAR(64)Electron version or web
platformVARCHAR(64)win32, another platform, or web
user_agentTEXTClient request user agent
ip_addressVARCHAR(45)Client IP
last_seen_atTIMESTAMPTZLast heartbeat timestamp
created_atTIMESTAMPTZFirst heartbeat timestamp

Indexes: user_id, last_seen_at

The Active Sessions list treats a session as online only when a matching row was seen in the last three minutes. Browser presence rows use web for app_version and platform; the Active Desktop Clients admin list excludes them.

audit_log

Audit trail events.

ColumnTypeDescription
idBIGSERIALPrimary key
request_idTEXTCorrelation ID, not null
user_idTEXTFK → users.id (nullable). Who performed the action
account_owner_idTEXTFK → users.id (nullable). Account it happened under
actor_roleTEXTowner or sub_user at the time of the event
actionTEXTEvent type, e.g. file.upload
resource_typeTEXTResource type
resource_idTEXTResource ID
statusTEXT'success', 'failure' or 'error'
ip_addressINETClient IP
user_agentTEXTBrowser user agent
metadataJSONBEvent-specific data
error_messageTEXTError details when status is 'error'
processing_time_msINTEGEROperation duration
created_atTIMESTAMPTZDefault now()

user_id is always the login that acted. For a sub-user, account_owner_id is the owner whose files were touched; for an owner the two match.

Indexes: (user_id, created_at DESC) where user_id IS NOT NULL, (account_owner_id, created_at DESC), action, created_at DESC, request_id, (resource_type, resource_id) where both are set, and (created_at DESC, action, error_message) where status is failure or error. There is no general metadata GIN index; application queries use the indexed columns, avoiding the write cost of maintaining an index for administrative JSON searches.

Retention: rows older than 30 days are removed by the daily 02:15 UTC worker job. cleanup_old_audit_logs(30) deletes at most 10,000 rows per call, and one job processes at most 20 batches.

Views

audit_activity

Read-only view over audit_log that resolves user IDs to names and emails, so the trail can be read without writing joins.

ColumnDescription
actor_idaudit_log.user_id
actor_nameName of the login that acted
actor_emailEmail of the login that acted
account_owner_idAccount the action happened under
account_emailEmail of the account owner
acted_as_sub_usertrue when the actor differs from the account owner
actor_roleowner or sub_user

All other columns are passed through from audit_log.

SELECT created_at, actor_email, actor_role, action, resource_id
FROM audit_activity
WHERE account_email = 'owner@example.com'
ORDER BY created_at DESC
LIMIT 100;

pgboss.*

pg-boss job queue tables (managed automatically).

bulk_import_items

Durable rollback records used by the administrative bulk-import scripts. Each row records the import run, created file or folder ID, storage key when present, item type, and tree depth. A completed import removes its rows. A failed import consumes them in batches, deleting files before folders.

migrations

Migration tracking.

ColumnTypeDescription
versionVARCHAR(255)Primary key
applied_atTIMESTAMPTZDefault now()

Applied versions are not run again. Changes needed by an existing installation must be shipped in a new forward migration rather than only editing an older file.

Relationships

  • User → Sub-users (parent-child, self-referential via parent_user_id, CASCADE)
  • User → Files (one-to-many, CASCADE)
  • File → Files (parent-child, self-referential, CASCADE)
  • User → Share Links (one-to-many, CASCADE)
  • Share Link → Files (many-to-many via share_link_files)
  • User → Sessions (one-to-many, CASCADE)
  • User → File Operation Results (logical account ownership through user_id)
  • User → MFA Backup Codes (one-to-many, CASCADE)
  • User → Client Heartbeats (one-to-many, CASCADE)
  • User → Audit Log (one-to-many, SET NULL — on both user_id and account_owner_id)

Deleting an owner cascades to its sub-users. Deleting a sub-user removes only that login; the account's files stay because they are stored under the owner's ID.

Common Queries

List account files:

$1 is the account ID — the owner's ID, not the sub-user's.

SELECT * FROM files
WHERE user_id = $1 AND parent_id = $2 AND deleted_at IS NULL
ORDER BY type, name;

Resolve the account for a login:

SELECT COALESCE(parent_user_id, id) AS account_id, permissions
FROM users
WHERE id = $1;

List an owner's sub-users:

SELECT id, email, name, permissions, created_at
FROM users
WHERE parent_user_id = $1
ORDER BY created_at;

Search files:

This is the query used for search terms of three characters or more. Terms of one or two characters take a prefix-only branch instead, skipping the trigram similarity work.

SELECT * FROM files
WHERE user_id = $1 AND deleted_at IS NULL
  AND (
    lower(name) LIKE lower($2) || '%'
    OR lower(name) % lower($2)
  )
ORDER BY
  CASE
    WHEN lower(name) = lower($2) THEN 1
    WHEN lower(name) LIKE lower($2) || '%' THEN 2
    ELSE 3
  END ASC,
  similarity(lower(name), lower($2)) DESC NULLS LAST,
  modified DESC;

Query audit log:

SELECT action, status, metadata, created_at
FROM audit_log
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 100;

Query everything done under one account, including its sub-users:

SELECT created_at, user_id, actor_role, action, resource_id
FROM audit_log
WHERE account_owner_id = $1
ORDER BY created_at DESC
LIMIT 100;

On this page