Add durable per-file metadata (attributes, not JSONB)
Directive: when you need to persist a new, durable per-file fact — a tempo, a
detected key, a summary, an “extended region” highlight, an editable display name — write
it to the normalized storage.attributes table via ctx.set_attributes(...). Do not
add it to the storage.files.metadata JSONB bag. The team is migrating off the single
JSONB blob to a scoped entity-attribute store, and every new key you drop in the bag is one
more thing to migrate later.
Why not the JSONB bag
Section titled “Why not the JSONB bag”storage.files.metadata is a single unindexed jsonb column. storage.attributes is a
normalized EAV store keyed by (scope, scope_id, namespace, key). The difference that
matters:
- Queryable & per-key — every fact is its own row with a numeric mirror (
value_num) for future range queries, not a nested key buried in one blob. - Ownership-scoped — denormalized
user_id/workspace_id/project_idcolumns (with partial indexes) let reads narrow to an owner. - Editable & audited — a
set_bycolumn records who last wrote (user/agent/ tool name /system), so a user rename and a tool write are distinguishable. - Any scope, zero DDL — the same table holds
file,project,workspace, anduserattributes. Adding a new attribute is a data change, never a schema change.
The table shape
Section titled “The table shape”The authoritative DDL lives in
storage/v1/schema.sql:
CREATE TABLE storage.attributes ( scope VARCHAR(16) NOT NULL, -- 'file' | 'project' | 'workspace' | 'user' scope_id VARCHAR(255) NOT NULL, -- file uuid (as text) / project / workspace / user id namespace VARCHAR(40) NOT NULL, -- 'display' | 'cover' | 'summary' | 'audio' | 'extend' | ... key VARCHAR(80) NOT NULL, -- 'name','tempo_bpm','file_id','blob_hash',... value_text TEXT, -- string / serialized value value_num DOUBLE PRECISION, -- numeric mirror (NULL when non-numeric) value_json JSONB, -- structured value (NULL when scalar) user_id VARCHAR(255) NOT NULL DEFAULT '', -- denormalized owner for scoped reads workspace_id VARCHAR(255) NOT NULL DEFAULT '', project_id VARCHAR(255) NOT NULL DEFAULT '', set_by VARCHAR(60) NOT NULL DEFAULT '', -- audit: who last wrote created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (scope, scope_id, namespace, key));scope_id is VARCHAR (not uuid) on purpose — it holds file UUIDs and the string
user / workspace / project ids that storage.files already uses. There is deliberately
no FK to storage.files: the scope is polymorphic, and files are soft-deleted anyway,
so the old ON DELETE CASCADE never fired in practice.
Write it from a tool
Section titled “Write it from a tool”Inside a worker’s process() you already hold a
TaskContext. It carries the attribute helpers — no direct
RPC plumbing:
-
Persist the fact.
ctx.set_attributes(file_id, namespace, mapping)merges a batch of keys under one namespace, scopefile. It stamps the file’s owner / workspace / project onto every row so the store stays ownership-scopable.app/tools/my_tool.py (inside process) # after you've uploaded the file and have its file_idctx.set_attributes(file_id, "audio", {"tempo_bpm": 128, "musical_key": "F#m"})# sugar wrappers for the common namespaces:ctx.set_audio_analysis(file_id, tempo_bpm=128, musical_key="F#m")ctx.set_summary(file_id, "Uptempo synthwave instrumental")ctx.set_display_name(file_id, "Neon Drive") # display.name — what the library showsctx.link_cover(song_file_id, image_file_id) # cover.file_id / cover.blob_hashEvery helper is best-effort — it swallows errors and logs, because metadata polish must never fail a generation. See
context.py. -
Understand value packing.
pack_attributesshapes each value: a number →value_num(indexable), abool/str→value_text, anything structured → JSON-encoded intovalue_text.Nonevalues are dropped. Thevalue_jsoncolumn exists for structured payloads but is only populated by directUpsertAttributescallers — thectxhelpers do not emit it today. -
Beyond files. For a project / workspace / user attribute, use
ctx.set_entity_attributes(scope, scope_id, namespace, mapping)— same store, any scope.
Reserved keys & precedence
Section titled “Reserved keys & precedence”Two namespaces carry first-class, resolvable keys:
display/name— the editable display name shown in the library in place of the immutableoriginal_filename.cover/file_idandcover/blob_hash— the relinkable album-art cover on a song.
The backend resolves a file’s shown name in one place (mappers.resolve_display_name) with
this precedence: display.name → metadata.song_name (legacy JSONB) → original_filename
→ "Untitled".
Read it back
Section titled “Read it back”The attribute store now has a consumer read surface (this closed an earlier gap):
- File list —
ListFiles/GetFileById/GetFilessurfacedisplay_name,cover_file_id, andcover_blob_hashviaLEFT JOINs, so the library shows editable names and covers without a second fetch. - File detail —
GetFileInfo(inlibrary.py) fans out toListAttributes(scope="file")and returns every file-scoped row inconsumer.v1 FileInfoView.attributes(field 14). Callers filter by namespace — e.g. theextendnamespace carries the extended-region highlight.
The underlying data-service RPCs are UpsertAttributes / ListAttributes on
StorageDataService (batch-merge via json_to_recordset; list filtered by
scope + scope_id + optional user_id), defined in
storage.proto.
The codegen trap: JOINs in SELECT, subqueries only in RETURNING
Section titled “The codegen trap: JOINs in SELECT, subqueries only in RETURNING”Adding a whole new attribute-backed read
Section titled “Adding a whole new attribute-backed read”If a new key needs to reach the browser, the mechanics are the standard proto cascade:
- Write the key from your tool with
ctx.set_attributes. - If the frontend needs it structured, add a field to the relevant
consumer.v1view (e.g. another entry alongsideFileInfoView.attributes), or read the genericattributeslist and filter by namespace on the client. - Fold
ListAttributes(or aLEFT JOIN) into the gateway servicer if it isn’t already surfaced. - Republish the proto and refresh stubs — see Drive the proto → deploy cascade and Refresh proto stubs.
Related
Section titled “Related”- Add a new generation tool worker — where
process()lives - TaskContext API reference — every
ctx.*method, including the attribute helpers - Add a field or RPC to a data service — the
$Nrule and the codegen guardrails - How the DB codegen plugin works — the FROM-scan extractor in detail
- Data model overview — where the side tables sit
- Storage & media plane — the wider file/blob story