Skip to content

CreatorTrack Property Type System , Scoping Plan

URL: https://mkdocs.justinsforge.com/memory/plans/property-type-system-2026-06-18/

Status: SCOPING (hard-gate , no implementation until Justin approves). 2026-06-18. Repo: /home/justinwieb/forge-suite. Roadmap: step 2 of the build plan, the Tier 2 data foundation that unlocks tables-with-data, property editors, and database views.

The finding: this is NOT a greenfield build

The database engine and storage already exist and work: - Schema (db/schema.ts, owned by Python migrations in data/workspace/migrations): collection_props (id, name, type text, config jsonb, position), collection_values (rowId, propId, value jsonb), collection_relations, collection_views, over the everything-is-a-node app.nodes tree. - Engine (lib/collection.ts): 14 property types already defined , title, text, number, select, multiselect, status, date, checkbox, url, email, phone, relation, rollup, formula , plus formatNumber/valueText/applyView/evalFormula/rollup/calcColumn and 5 view types. - UI (components/workspace/database/): TableView, Cell, PropertyMenu, FilterSort, Views; API (app/api/collection/*): props, rows, values, relations, views.

So the real work is consolidation + rigor, not invention. Today a property type's knowledge is smeared across SIX parallel structures (PropType union, COMPUTED_TYPES set, ADDABLE_TYPES array, TYPE_LABEL, PROP_GLYPH, TYPE_HINT records) plus per-type logic scattered through Cell.tsx, PropertyMenu.tsx, FilterSort.tsx. Adding or changing a type means editing 6+ places, and there is no validation of config/value before they hit the jsonb columns. That violates the doctrine single-source-of-truth rule and is fragile ground for Tier 2 to stand on.

Goal

One canonical Property Type Registry: PROP_TYPES: Record<PropType, PropTypeSpec> where a single object fully describes each type. The free-text type + jsonb config/value in the DB are interpreted, validated, sorted, filtered, and rendered THROUGH this registry. Add a new type = add one entry.

PropTypeSpec shape (each type defines, in one place)

Field Purpose
label, glyph, hint menu + column header (replaces the 3 scattered records)
addable, computed replaces ADDABLE_TYPES / COMPUTED_TYPES
configSchema the per-type config shape (zod), replacing the mega PropConfig
valueSchema the stored value shape (zod) , what's valid in collection_values.value
parse(raw) / serialize(v) coerce user input ↔ stored jsonb
isEmpty(v) for filters + calc
compare(a, b) canonical sort comparator
filterOps the operators this type supports (is / is not / contains / before / after / is empty …)
formatText(v, config) the canonical string form (search, export, board labels)
calcKinds which footer calcs apply (count/sum/avg/…)

UI cell render/edit stays in components/workspace/database/Cell.tsx but DISPATCHES off the registry instead of an inline switch, so the type list has exactly one home.

Numbered tasks (each ends at a green commit)

  1. lib/prop-types.ts , the registry, additive. Define PropTypeSpec + PROP_TYPES for all 14 existing types, sourcing label/glyph/hint/computed/addable from the current collection.ts constants (no behavior change yet). Add per-type configSchema/valueSchema (zod). Verify: tsc clean; a unit smoke that every PropType has a spec.
  2. Discriminated PropConfig. Replace the single mega-config with a union keyed by type (e.g. select→{options}, relation→{target_db}, rollup→{rel,fn,target}, formula→{expr}, number→{number_format}). Update PropDef. Verify: tsc clean across all consumers.
  3. Point the engine at the registry. Rewrite valueText/sort/calcColumn/computed checks in collection.ts to delegate to PROP_TYPES[type] (compare/formatText/isEmpty/calcKinds). Delete the now-duplicated constants. Verify: tsc + /studio DataGrid + a collection page render unchanged.
  4. Registry-driven Cell + PropertyMenu. Cell.tsx render/edit and PropertyMenu.tsx add/config dispatch off the registry (one entry → one cell + one config editor). Verify: add each property type in the live DB UI, set a value, reload, value persists.
  5. Validation/coercion at the API boundary. In app/api/collection/value and .../prop, validate incoming value/config against the spec's zod schemas before the jsonb write; reject malformed (fail loud, doctrine). Verify: a bad value/config → 400; a good one → 200 and persists.
  6. Per-type filter operators. Replace ViewConfig.filter ({prop,value}) with the spec's filterOps model; wire FilterSort.tsx. (Multi-filter AND/OR GROUPS are a Tier 2 db-views task; this task delivers the per-type OPERATORS they will compose.) Verify: filter each type by each operator in the live UI.
  7. Reconcile + document. Fix the latent "currency" literal that appears in code but is NOT in the PropType union (decide: fold into number number_format or make it a real type). Update reference_suite_database_engine.md. Verify: grep finds zero property-type literals outside the registry.

DECISIONS LOCKED (2026-06-18, Justin: "everything Notion has, robust + scalable")

  1. Incremental migration. Registry is additive in Phase 1 task 1; consumers migrate task-by-task, every commit green. No big-bang.
  2. Full Notion parity. Consolidate the existing 14 first (Phase 1, pure TS), THEN add every remaining Notion property type (Phase 2). Nothing is left out.
  3. Filter operators are IN (Phase 1 task 6). Per-type operators here; the AND/OR group builder that composes them is the Tier 2 db-views task.

Phase 2 , full Notion type roster (after Phase 1 consolidation)

Add every Notion type the registry doesn't have yet. Each = one PROP_TYPES entry + (where noted) a Python migration in data/workspace/migrations. Group by backend impact:

2a , computed, NO migration (derive from existing app.nodes columns): - created_time (from nodes.created_at), last_edited_time (from nodes.updated_at), created_by (from nodes.created_by). Pure registry specs, value computed not stored.

2b , needs a migration (coordinate with data/workspace/migrations): - last_edited_by (add nodes.updated_by bigint + set it on every write path). - person / people (value = user id[]; config single/multi; FK semantics to core.users; resolver like relation chips). - files & media (value = file refs; needs an upload path + a files table or object storage; wire the existing components/FileUpload Tier 1 component as the editor). - unique_id (auto-increment per collection + optional text prefix; needs a per-collection counter; e.g. TASK-42). - rating (stars; value = number 0..max; config max; pure-ish, no migration). - Reconcile currency (today a stray literal): make it a number number_format, not a separate type.

2c , deferred sub-feature button (Notion action buttons run automations); ship the type + config stub now, wire actions when the automation surface exists. Flag, don't fake.

Each Phase 2 type ships behind the SAME PropTypeSpec contract (configSchema, valueSchema, parse/serialize, compare, filterOps, formatText, calcKinds), so "scales" = adding a type never touches more than its one entry (+ a migration if it needs storage).

Out of scope (explicitly)

  • The block editor, the database VIEWS themselves (board/calendar/gallery rendering), the property EDITOR popovers' visual polish , those are Tier 2 build, this is its foundation.
  • Schema/migration changes for net-new types (deferred per decision 2).
  • Page chrome (banners/icons/sharing) from the earlier shell wishlist , Tier 2.

✅ COMPLETION NOTE (2026-06-18, Claude Opus 4.8)

Executed in full on main (single-owner, systemd dev server :3060). Every numbered task ended at a green commit (tsc --noEmit clean modulo the stale .next/validator.ts Next-16 artifact) and UI tasks were verified live in the browser (LAN IP 192.168.86.50:3060, node 150) and at the data layer (RLS psql + the page's own session fetch).

Phase 1 , consolidate the 14 (commits): 1. lib/prop-types.ts , PropTypeSpec + PROP_TYPES registry for all 14 (label/glyph/hint, addable/computed, zod config/value schemas, parse/serialize, isEmpty, compare, filterOps, formatText, calcKinds) + matchesFilter evaluator. Record<PropType,_> = compile-time smoke. 2. Per-type config schemas promoted to named exported zod objects + inferred types + PropConfigByType + validateConfig/validateValue. Deviation (within latitude, not one of the 3 locked decisions): kept PropDef.config as the permissive PropConfig bag rather than a discriminated PropDef, because a discriminated PropDef forces unsafe casts at every generic construction/optimistic-update site (net fragility vs doctrine). The registry's per-type zod schemas + PropConfigByType are the validated "config keyed by type" SSOT. 3. Engine (collection.ts) delegates sort→compare, calc→isEmpty, computed→isComputed; the six scattered constants deleted and re-exported registry-derived. 4. Cell.tsx (CELL_BY_TYPE map) + PropertyMenu.tsx (CONFIG_EDITORS map) dispatch off the registry; value commit→parse, display→formatText. 5. API boundary validation in /value + /prop[/id] (bad→400, good→200; verified 6/6 cases). 6. Per-type filter operators: ViewConfig.filter gains op (from filterOps); applyView uses matchesFilter; FilterControl shows the operator dropdown. Legacy filters read as contains. (AND/OR group builder remains the Tier 2 db-views task.) 7. Currency reconciled = number number_format "dollar" (no stray literal); reference doc updated; zero type-metadata definitions outside the registry.

Phase 2 , full Notion roster (now 23 types, all behind the same contract): - 2a (no migration): created_time, last_edited_time, created_by , computed in collectionData from nodes.created_at/updated_at/created_by (+ user-name resolution). - 2b: rating (stored 0..max, star cell), unique_id (server-assigned int per collection + prefix, backfilled on column-add, assigned on row-create), last_edited_by (computed from new nodes.updated_by), person/people (stored user id[], workspace members resolved into the snapshot, name chips + picker). Migration 0032_collection_full_types.sql (written in data/workspace/migrations, applied via forge_workspace_migrate.py) extends collection_props_type_check to the full roster and adds nodes.updated_by; mutations now stamp updated_at/updated_by on every row-mutating write path (touchRow). - 2b files , FLAGGED, NOT FAKED: type+spec complete (value = URL refs), read-only cell. ⚠️ Open decision (genuine fork, surfaced to Justin): there is no upload endpoint or storage backend. The editor is NOT wired to components/FileUpload until a backend is chosen (object storage / a files table / local fs). This is the one item not fully wired. - 2c button , FLAGGED stub per plan: label/action config exist, disabled "

Verified live on node 150: date edit round-trip; filter is/contains row counts; computed created_time/created_by; rating ★★★★☆; unique_id TASK-1..4 backfill; last_edited_by stamping; person id→name chip; files/button flagged stubs render.

Follow-up: wire files once the storage-backend decision is made.