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)¶
lib/prop-types.ts, the registry, additive. DefinePropTypeSpec+PROP_TYPESfor all 14 existing types, sourcing label/glyph/hint/computed/addable from the currentcollection.tsconstants (no behavior change yet). Add per-typeconfigSchema/valueSchema(zod). Verify:tscclean; a unit smoke that everyPropTypehas a spec.- 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}). UpdatePropDef. Verify:tscclean across all consumers. - Point the engine at the registry. Rewrite
valueText/sort/calcColumn/computed checks incollection.tsto delegate toPROP_TYPES[type](compare/formatText/isEmpty/calcKinds). Delete the now-duplicated constants. Verify:tsc+/studioDataGrid + a collection page render unchanged. - Registry-driven Cell + PropertyMenu.
Cell.tsxrender/edit andPropertyMenu.tsxadd/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. - Validation/coercion at the API boundary. In
app/api/collection/valueand.../prop, validate incomingvalue/configagainst 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. - Per-type filter operators. Replace
ViewConfig.filter({prop,value}) with the spec'sfilterOpsmodel; wireFilterSort.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. - Reconcile + document. Fix the latent
"currency"literal that appears in code but is NOT in thePropTypeunion (decide: fold into numbernumber_formator make it a real type). Updatereference_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")¶
- Incremental migration. Registry is additive in Phase 1 task 1; consumers migrate task-by-task, every commit green. No big-bang.
- 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.
- 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.