1+ /*
2+ ================================================================================
3+ Grida Canvas — Node Storage (STI single-table pattern)
4+ --------------------------------------------------------------------------------
5+ Why this exists
6+ - We need a strict, evolvable, relational source of truth for very large
7+ documents (100k+ nodes) while keeping development solo-friendly.
8+ - “Relational” joins across many small tables per node-type are costly to load.
9+ - “NoSQL/JSON” makes migrations and long-term correctness hard.
10+ - This file implements a pragmatic middle ground: a SINGLE “nodes” table (STI)
11+ with strongly-typed nullable columns for capabilities we understand today,
12+ plus a few shared side tables (fills/strokes/effects/links) for arrays.
13+
14+ Core idea (STI: Single Table Inheritance)
15+ - All node kinds live in one table: grida_canvas.canvas_node
16+ - Columns are grouped by capability (state, identity, blend, geometry…).
17+ - Columns are nullable unless universally applicable.
18+ - Type-specific validity is enforced by CHECK constraints (added in later
19+ migrations) and/or by application validators.
20+ - Arrays/variable-length attributes (fills, strokes, effects, guides, etc.)
21+ live in separate “capability tables” keyed by (doc_id, node_row_id, ord).
22+
23+ Why STI over alternatives
24+ - ✅ Fast open-path: a single narrow scan (clustered by doc_id) streams rows.
25+ - ✅ Strict typing & easy migrations: ALTER TABLE ADD COLUMN (NULL default)
26+ is metadata-only in Postgres; we can evolve safely as the engine grows.
27+ - ✅ Simple ergonomics: fewer joins, clear column names, predictable code-gen.
28+ - ⚠️ Many NULLs are expected; that’s OK (NULLs are compact in Postgres).
29+ - ⚠️ The table can grow wide; mitigate by:
30+ - Keeping the row “hot” (only immediately-needed scalars live here).
31+ - Moving bulky/rare fields (e.g. huge text/html/expressions) to side tables.
32+ - Using capability tables for lists (fills/strokes/effects).
33+
34+ CRDT identity model (high level)
35+ - Runtime/CRDT uses a compact 32-bit id (actor:8 | counter:24).
36+ - Database rows use an immutable UUID primary key (row_id) for stability.
37+ - Store CRDT (actor, counter, packed_int, string) alongside row_id with a
38+ UNIQUE(doc_id, actor, counter). All FKs in capability tables point to row_id.
39+ - This lets us rewrite offline actor 0 → assigned actor without FK churn.
40+
41+ How to load big docs efficiently (100k+ rows)
42+ - SELECT only “hot” columns (avoid SELECT *) and stream with server-side cursors.
43+ - Cluster/partition by doc_id for contiguous I/O.
44+ - Fetch capability tables (fills/strokes/links) in parallel or lazily by viewport.
45+ - Optional: mirror the “first fill” on the node row for zero-join paint preview.
46+
47+ Schema evolution playbook
48+ 1) Add a new scalar prop? -> ALTER TABLE ADD COLUMN … NULL; (fast)
49+ 2) Make it type-specific? -> Add a CHECK that forbids it for other types.
50+ 3) New list-like prop? -> New capability table (doc_id, node_row_id, ord, …).
51+ 4) Unsure if it’s hot? -> Start in a side table; promote later if it becomes hot.
52+ 5) Renaming? -> Add new column, backfill once, dual-write briefly,
53+ remove old column in a later migration.
54+ 6) Deprecation? -> Keep column NULL and stop writing; drop only when safe.
55+
56+ Naming & style guidelines
57+ - snake_case, explicit, descriptive: data_state_locked, data_blend_opacity, …
58+ - Prefer consistent prefixes per capability (data_state_*, data_blend_*, …).
59+ - Keep column names < ~60 chars (PG identifier limit is 63 bytes).
60+ - Avoid reserved words; avoid quoted identifiers.
61+ - Default values should match engine defaults to reduce payload size.
62+
63+ Constraints & validation (add in later migrations)
64+ - CHECKs to guard type/capability: e.g., forbid text-only columns on non-text nodes.
65+ - Range checks for normalized values (opacity 0..1, colors 0..255).
66+ - UNIQUE(doc_id, crdt_actor, crdt_counter) when CRDT fields are present.
67+ - FKs from capability tables to canvas_node(row_id) with ON DELETE CASCADE.
68+
69+ Indexing suggestions
70+ - Primary scan path: (doc_id) → sequential/bitmap scan is ideal when clustered.
71+ - Partial indexes for hot filters (e.g., WHERE data_state_active).
72+ - (doc_id, crdt_packed) for client→DB translations.
73+ - (doc_id, type) or small partials if you frequently address subsets.
74+
75+ Hierarchy & ordering
76+ - Do NOT store “children arrays” in this table. Use a link table:
77+ link(doc_id, parent_row_id, child_row_id, ord)
78+ with a fractional ord key (e.g., “a”, “aM”, “b”) to avoid mass reindexing on
79+ reorder. Enforce acyclicity with a small trigger if needed.
80+
81+ Durable Objects (DO) / realtime server compatibility
82+ - DO holds the authoritative in-memory doc state for realtime.
83+ - DO loads from Postgres on cold start using the narrow select described above.
84+ - DO periodically snapshots back to Postgres (or on commit points).
85+ - CRDT ids are used at the edge; DB uses UUID row_id for relational stability.
86+
87+ When to split more tables
88+ - Only for repeatables (fills/strokes/effects/guides/edges).
89+ - Or when a capability family would add many rarely-used columns to this table.
90+ - Keep the number of capability tables small (3–8), shared by all node types.
91+
92+ When NOT to split
93+ - Scalar, frequently-read properties in the initial render path.
94+ - Anything you need to filter/sort by routinely.
95+
96+ SQLite/edge portability (optional)
97+ - If you also run SQLite at the edge: avoid PG-only types; store UUIDs as TEXT;
98+ keep JSON as TEXT with expression indexes; use the same logical shape.
99+
100+ FAQ
101+ - “Is a 100k-row document load OK?” → Yes, if you stream a narrow
102+ projection and cluster by doc_id (expect tens of MBs at most).
103+ - “Will hundreds of NULLs bloat storage?” → Not significantly in PG; NULLs are
104+ bitmap-tracked. Keep wide TEXT/VARCHAR out of the hot row.
105+ - “Why not JSONB?” → Harder long-term migrations; weaker
106+ guarantees. We want strict evolution + predictable queries.
107+ - “Can we enforce per-type columns strictly in DB?” → Yes, with CHECKs; complement with
108+ application validators for friendlier errors.
109+
110+ --------------------------------------------------------------------------------
111+ Everything below this header defines the initial columns for shared capabilities.
112+ Add columns incrementally with small, focused migrations. Keep the hot row narrow,
113+ arrays in capability tables, and validate aggressively.
114+
115+ */
116+
117+
118+
119+ create type grida_canvas .affine2d as (
120+ a double precision ,
121+ b double precision ,
122+ c double precision ,
123+ d double precision ,
124+ e double precision ,
125+ f double precision
126+ );
127+
128+
129+ -- [sti-pattern single table for design canvas document nodes]
130+ create table grida_canvas .canvas_node (
131+ id int not null ,
132+
133+ data_state_locked boolean not null default false,
134+ data_state_active boolean not null default true,
135+
136+ -- name of the node, optionally set by user
137+ data_name text null ,
138+
139+ -- blend
140+ blend_opacity real not null default 1 .0 ,
141+ blend_mode text not null default ' normal' ,
142+
143+ -- geometry - dimension
144+ geometry_width_real real not null default 0 .0 ,
145+ geometry_height__real real not null default 0 .0 ,
146+ geometry_width_max real not null default 0 .0 ,
147+ geometry_height_max real not null default 0 .0 ,
148+ geometry_width_min real not null default 0 .0 ,
149+ geometry_height_min real not null default 0 .0 ,
150+
151+ -- geometry - position
152+ geometry_transform_relative grida_canvas .affine2d not null default row(1 ,0 ,0 ,1 ,0 ,0 )
153+
154+ -- layout - padding
155+ layout_padding_left real null ,
156+ layout_padding_right real null ,
157+ layout_padding_top real null ,
158+ layout_padding_bottom real null ,
159+
160+
161+
162+ -- style - stroke
163+
164+ -- style - text
165+ text_font_size real null ,
166+ text_text_transform text null ,
167+ text_opentype_features jsonb null ,
168+ text_letter_spacing real null ,
169+ text_text_align text null ,
170+ text_text_align_vertical text null ,
171+
172+
173+ -- service extensions
174+ ext_guides jsonb null ,
175+ ext_export_settings jsonb null ,
176+ );
0 commit comments