e336e31812
Collections declare a `fields:` schema in `.collection.yaml`; entries carry typed metadata (enum/tags/text). New central `metadata_schema` module parses the schema leniently (INV-3) and validates entry values — advisory at read (corpus mirror flags violations as `metadata_malformed` without hard-failing), the enforcement point for the write boundary (edit endpoints land SLICE-4/5). - app/metadata_schema.py: parse_fields (lenient/normalizing) + validate - registry.parse_collection_manifest reads `fields:` into collection config - collections.get_collection unpacks `fields`; served by the collection API - cache._refresh_collection_corpus validates each entry advisory-only Non-breaking, opt-in: no `fields:` → unchanged (INV-5); default `document` collection declares none (N=1 unchanged). No DB migration — schema rides in collections.config_json. SLICE-2 of docs/design/2026-06-06-configurable-collection-metadata.md §7.2. Backend suite green (601 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
5.7 KiB
Python
147 lines
5.7 KiB
Python
"""§22 collection grain — resolution helpers beneath the project tier.
|
|
|
|
In S1 each project has exactly one collection (the default). These helpers
|
|
recover the collection for a project and read the per-corpus fields (`type`,
|
|
`initial_state`) that moved down from `projects` in migration 029. Project-grain
|
|
authz (auth.py) recovers a row's project by joining `collections` on
|
|
`collection_id`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from . import db
|
|
|
|
DEFAULT_COLLECTION_ID = "default"
|
|
|
|
# §22.4a item (2): the displayed noun for an entry is a type-driven label, a
|
|
# framework concept (like role names), not deployment content. The chrome reads
|
|
# this from the API rather than hardcoding "RFC", so a `specification` collection
|
|
# says "Spec" and a `bdd` collection says "Feature" with no per-deployment config.
|
|
ENTRY_NOUN = {
|
|
"document": "RFC",
|
|
"specification": "Spec",
|
|
"bdd": "Feature",
|
|
}
|
|
_DEFAULT_ENTRY_NOUN = "RFC"
|
|
|
|
|
|
def entry_noun(collection_type: str) -> str:
|
|
"""The §22.4a entry noun for a collection type. Unknown types fall back to
|
|
the generic 'RFC' so a future type is never label-less."""
|
|
return ENTRY_NOUN.get(collection_type, _DEFAULT_ENTRY_NOUN)
|
|
|
|
|
|
def _enabled_models_from_config(config_json: str | None) -> list[str] | None:
|
|
"""§22.12 per-collection enabled_models from a `config_json` blob, or None
|
|
when unset (the collection inherits its project's universe)."""
|
|
if not config_json:
|
|
return None
|
|
try:
|
|
cfg = json.loads(config_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
|
|
return [str(m) for m in em] if isinstance(em, list) else None
|
|
|
|
|
|
def _fields_from_config(config_json: str | None) -> dict | None:
|
|
"""§22.4a SLICE-2 per-collection metadata field schema from a `config_json`
|
|
blob, or None when the collection declares no `fields:`. The stored value is
|
|
already normalized by `metadata_schema.parse_fields` at ingest, so it's
|
|
served verbatim."""
|
|
if not config_json:
|
|
return None
|
|
try:
|
|
cfg = json.loads(config_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
fields = cfg.get("fields") if isinstance(cfg, dict) else None
|
|
return fields if isinstance(fields, dict) and fields else None
|
|
|
|
|
|
def default_collection_id(project_id: str) -> str:
|
|
"""The id of a project's default (S1: sole) collection. Falls back to the
|
|
literal 'default' when the project has no collection row yet."""
|
|
row = db.conn().execute(
|
|
"SELECT id FROM collections WHERE project_id = ? ORDER BY created_at, id LIMIT 1",
|
|
(project_id,),
|
|
).fetchone()
|
|
return row["id"] if row else DEFAULT_COLLECTION_ID
|
|
|
|
|
|
def project_of_collection(collection_id: str) -> str | None:
|
|
"""The project a collection belongs to, or None if unknown."""
|
|
row = db.conn().execute(
|
|
"SELECT project_id FROM collections WHERE id = ?", (collection_id,)
|
|
).fetchone()
|
|
return row["project_id"] if row else None
|
|
|
|
|
|
def collection_initial_state(collection_id: str) -> str:
|
|
"""§22.4b landing state for new entries in a collection. 'super-draft'
|
|
default for an unknown/unset row (today's safe flow)."""
|
|
row = db.conn().execute(
|
|
"SELECT initial_state FROM collections WHERE id = ?", (collection_id,)
|
|
).fetchone()
|
|
if row is None or not row["initial_state"]:
|
|
return "super-draft"
|
|
return row["initial_state"]
|
|
|
|
|
|
def collection_type(collection_id: str) -> str:
|
|
"""The collection's immutable §22.4a type. 'document' default for unknown."""
|
|
row = db.conn().execute(
|
|
"SELECT type FROM collections WHERE id = ?", (collection_id,)
|
|
).fetchone()
|
|
return row["type"] if row and row["type"] else "document"
|
|
|
|
|
|
def subfolder_of(collection_id: str) -> str:
|
|
"""The content-repo subfolder a collection lives under (§22.3). Empty string
|
|
for the default collection (entries at the repo root `rfcs/`)."""
|
|
row = db.conn().execute(
|
|
"SELECT subfolder FROM collections WHERE id = ?", (collection_id,)
|
|
).fetchone()
|
|
return (row["subfolder"] if row else "") or ""
|
|
|
|
|
|
def get_collection(collection_id: str) -> dict | None:
|
|
"""The full collection row as a dict, or None if unknown. `enabled_models`
|
|
(§22.12) is unpacked from `config_json` as a list, or None when unset."""
|
|
row = db.conn().execute(
|
|
"SELECT id, project_id, type, subfolder, initial_state, visibility, name, "
|
|
"config_json FROM collections WHERE id = ?",
|
|
(collection_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
out = dict(row)
|
|
config_json = out.pop("config_json", None)
|
|
out["enabled_models"] = _enabled_models_from_config(config_json)
|
|
# §22.4a SLICE-2: serve the collection's metadata field schema (None when
|
|
# the collection declares no `fields:` — INV-5, the default `document`
|
|
# collection is unaffected).
|
|
out["fields"] = _fields_from_config(config_json)
|
|
out["entry_noun"] = entry_noun(out["type"])
|
|
return out
|
|
|
|
|
|
def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]:
|
|
"""Collections in a project, the default first then by name (§22.5). `unlisted`
|
|
is omitted from enumeration unless include_unlisted (a direct-id read or the
|
|
corpus mirror, which serves every collection)."""
|
|
rows = db.conn().execute(
|
|
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
|
|
"FROM collections WHERE project_id = ? ORDER BY (id != 'default'), name, id",
|
|
(project_id,),
|
|
).fetchall()
|
|
out: list[dict] = []
|
|
for r in rows:
|
|
if not include_unlisted and r["visibility"] == "unlisted":
|
|
continue
|
|
item = dict(r)
|
|
item["entry_noun"] = entry_noun(item["type"])
|
|
out.append(item)
|
|
return out
|