Skip to content

Commit 19d929b

Browse files
authored
v0.7.50: custom sandboxes, auto model, tables currency type, perf improvements
2 parents 2a62673 + 48aeac2 commit 19d929b

430 files changed

Lines changed: 46767 additions & 3515 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
---
2+
name: add-column-type
3+
description: Add a new table column type to Sim — registry entry, icon, storage shape, coercion, and the behavioral hooks the grid and API read. Use when adding a value kind under `apps/sim/lib/table/column-types/`.
4+
argument-hint: <type-name>
5+
---
6+
7+
# Adding a Table Column Type
8+
9+
A column type is **one file** in `apps/sim/lib/table/column-types/` plus a registry entry. Everything that varies per type — label, icon, storage cast, coercion, validation, conversion compatibility, formatting, editor, filter operators — lives on that one object, so no consumer needs editing.
10+
11+
This was not always true: adding `currency` originally took ~40 edits across 32 `switch` arms and 26 UI branches, each of which failed **silently** when missed. The registry exists to make that impossible, so the rule is absolute: **if you find yourself adding a `case 'yourtype':` anywhere outside `column-types/`, the registry is missing a field. Add the field instead.**
12+
13+
## Hard Rule: the compiler tells you what to do
14+
15+
Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list:
16+
17+
```bash
18+
cd apps/sim && bunx tsc --noEmit -p tsconfig.json
19+
```
20+
21+
You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both.
22+
23+
If your type owns metadata, adding its key to `TYPE_SPECIFIC_COLUMN_KEYS` produces two more legitimate errors — `FOREIGN_METADATA_VERB` in `validation.ts` (a `Record` over those keys) and the key's absence from `ColumnDefinition`. Those are the gate working, not sites to "fix".
24+
25+
Any error beyond those four is a site reading a hardcoded type list that should read the registry — fix that site, don't work around it.
26+
27+
## Directory Structure
28+
29+
```
30+
apps/sim/lib/table/column-types/
31+
├── types.ts # ColumnTypeDefinition — the contract you implement
32+
├── types.server.ts # ColumnTypeServerDefinition — cell migrations only
33+
├── registry.ts # Record<ColumnType, …> ← client-safe, the gate
34+
├── registry.server.ts # Record<ColumnType, …> ← adds migrations (drizzle)
35+
├── index.ts # barrel + accessors (columnTypeOf, columnTypeById, …)
36+
└── {type}.ts # one file per type — what you write
37+
```
38+
39+
## Step 1: Pick the storage shape
40+
41+
Decide what a cell literally holds in `user_table_rows.data` (JSONB). This drives almost everything else:
42+
43+
| Storage | `jsonbCast` | Notes |
44+
|---------|-------------|-------|
45+
| number | `'numeric'` | Filters/sorts compare numerically. `currency` does this. |
46+
| ISO string | `'timestamptz'` | `date` does this. |
47+
| string / bool / object | `null` | Text comparison is correct. |
48+
49+
**Prefer an existing primitive over a new shape.** `currency` stores a plain number and keeps its ISO code as *display metadata* — which is why filtering, sorting, uniqueness, and CSV export all reuse the numeric paths untouched, and why re-denominating a column rewrites zero rows.
50+
51+
## Step 2: Add the icon
52+
53+
Create `packages/emcn/src/icons/type-{name}.tsx`, copying the geometry conventions of its siblings exactly:
54+
55+
```tsx
56+
import type { SVGProps } from 'react'
57+
58+
/**
59+
* Type {name} icon component - {what the glyph is} for {name} columns
60+
* @param props - SVG properties including className, fill, etc.
61+
*/
62+
export function Type{Pascal}(props: SVGProps<SVGSVGElement>) {
63+
return (
64+
<svg
65+
width='24'
66+
height='24'
67+
viewBox='-1.75 -1.5 24 24'
68+
fill='none'
69+
stroke='currentColor'
70+
strokeWidth='1.55'
71+
strokeLinecap='round'
72+
strokeLinejoin='round'
73+
xmlns='http://www.w3.org/2000/svg'
74+
aria-hidden='true'
75+
{...props}
76+
>
77+
<path d='' />
78+
</svg>
79+
)
80+
}
81+
```
82+
83+
- `viewBox='-1.75 -1.5 24 24'` is the **`type-*` family** value, not the set-wide default. Match the family.
84+
- Center the glyph on the viewBox's optical center (**y = 10.5**, **x = 10.25**) — every sibling does, and a few tenths off is visible at `size-[14px]`.
85+
- Export alphabetically **by component name** in `packages/emcn/src/icons/index.ts`.
86+
87+
## Step 3: Write the type file
88+
89+
`apps/sim/lib/table/column-types/{name}.ts`. Copy the closest existing type and change what differs. Every field is required by the interface, so the compiler enumerates them for you — read the TSDoc in `types.ts` rather than guessing.
90+
91+
The three that are easy to get wrong:
92+
93+
- **`coerce`** is the *single* write-path implementation. The server runs it before persisting **and** the grid runs it to fill the optimistic cache. Accept every shape the value legitimately arrives in (paste, CSV, tool write), because rejecting means the cell is nulled.
94+
- **`isCompatibleWith`** gates type conversion and must read the value **exactly as `coerce` will**, or a conversion will pass its check and then null the cell.
95+
- **`ownedMetadata`** lists the `ColumnDefinition` keys your type owns. Anything you add must also be added to `TYPE_SPECIFIC_COLUMN_KEYS` in `types.ts` and given a phrase in `FOREIGN_METADATA_VERB` in `validation.ts` — both are `Record`-typed, so the compiler will tell you.
96+
97+
## Step 4: Register
98+
99+
Add the entry to `COLUMN_TYPE_REGISTRY` in `registry.ts` **and** `COLUMN_TYPE_SERVER_REGISTRY` in `registry.server.ts`.
100+
101+
`COLUMN_TYPES` is declared in `types.ts` (not derived from the registry — the registry is annotated `Record<ColumnType, …>` against it, which is the gate). `constants.ts` re-exports it, so `columnTypeSchema = z.enum(COLUMN_TYPES)` picks your type up with no edit. **Type-specific metadata does not** — see the next step.
102+
103+
## Step 5: Migrations (only if the stored bytes change)
104+
105+
If converting an existing column **to** your type must rewrite cells, add `migrateCellsTo` in `registry.server.ts`; if converting **away** must rewrite them, add `migrateCellsFrom`.
106+
107+
This is load-bearing, not cosmetic: filters and sorts apply `jsonbCast` to whatever is stored, so leaving a non-castable string behind makes **every query on that column fail** — not merely render oddly.
108+
109+
Prefer set-based SQL. When the transform genuinely needs JS (`currency`'s separator disambiguation), compute the values during the compatibility scan and pass them through `resolved`, then apply them in one batched statement.
110+
111+
## Naming Convention
112+
113+
- Type id: lowercase, singular — `currency`, not `Currency` or `currencies`
114+
- File: `column-types/{id}.ts`, export `const {id}ColumnType`
115+
- Icon: `type-{kebab}.tsx`, export `Type{Pascal}`
116+
117+
## Watch out
118+
119+
- **Import cycles.** `column-types/select.ts` imports `select-values.ts`, so `select-values.ts` must **not** import the registry — that closes a cycle and fails at module init. Inside a type's own helper module the string literal is the implementation, not a config leak.
120+
- **The client-safe boundary.** `registry.ts` and everything it imports must stay free of `@sim/db`, `drizzle-orm`, and `next/server` — the tables grid imports it directly. A React icon is fine (it's a component *reference*, never called server-side). Only `registry.server.ts` may touch drizzle.
121+
- **Don't re-export the registry from `@/lib/table`.** 44 server modules import that barrel; routing this through it pulls `@sim/emcn/icons` into all of them. Deep-import `@/lib/table/column-types`.
122+
- **`import.ts`'s `coerceValue` is a SECOND write path and is not opt-in.** Importing into a column of your type always hits it, and its `default` arm silently `String(value)`s — so a missing `case` stores text in a column whose `jsonbCast` is numeric, and then every filter and sort on that column errors in Postgres. Add a `case`, even though the switch compiles without one. (It is deliberately separate from the registry's `coerce`: an import wants an unparseable value to survive as its raw string so the row error can name it.)
123+
- **CSV inference** is an ordered heuristic in `import.ts`, deliberately not registry-driven. A new type is not inferred from a CSV unless you extend `inferColumnType` — usually you should not, since inference cannot supply configuration (an option set, a currency code).
124+
125+
## If your type owns metadata, read this
126+
127+
Registering the *type* is compiler-enforced. Registering its *metadata* is not, and that is where the remaining manual work lives. A key like `precision` has to be added in each of these, none of which will fail to compile if you forget:
128+
129+
| Where | What happens if you forget |
130+
|---|---|
131+
| `lib/table/types.ts` `ColumnDefinition` | (this one DOES fail — the ownership loop indexes it) |
132+
| `column-types/types.ts` `TYPE_SPECIFIC_COLUMN_KEYS` | it is never stripped on conversion, and poisons the target type |
133+
| `lib/api/contracts/tables.ts` — the schema slot in all three column schemas, plus `refineColumnOptions` | zod strips it at the boundary; silently never saved |
134+
| `columns/service.ts` `addTableColumn` param type | callers cannot pass it |
135+
| A metadata-only update path (`updateColumnCurrency` is the model) + a branch in both column routes + the copilot tool | changing it on an existing column is a silent 200 no-op |
136+
| `column-config-sidebar.tsx` | no UI to set it |
137+
| `table-grid.tsx` delete-column undo + `use-table-undo.ts` restore | undo silently resets it to the default |
138+
139+
`normalizeColumn`, `buildConvertedColumn`, and the undo snapshot read `TYPE_SPECIFIC_COLUMN_KEYS` generically, so those three are already zero-edit.
140+
141+
**Known gap:** the metadata-only update path is ~6 near-identical copies (service + 2 routes + copilot). A `metadataUpdate` descriptor on `ColumnTypeServerDefinition` would collapse them; until that exists, copy `currency`'s.
142+
143+
## Checklist Before Finishing
144+
145+
- [ ] Added to the `ColumnType` union in `column-types/types.ts`
146+
- [ ] `column-types/{id}.ts` created, every interface field filled in
147+
- [ ] Registered in **both** `registry.ts` and `registry.server.ts`
148+
- [ ] Icon added, centered on the family's optical center, exported alphabetically
149+
- [ ] `migrateCellsTo` / `migrateCellsFrom` added if the stored bytes change
150+
- [ ] New metadata keys added to `TYPE_SPECIFIC_COLUMN_KEYS` + `FOREIGN_METADATA_VERB`
151+
- [ ] Unit tests for `coerce` / `isCompatibleWith` round-trips, verified to fail without the code
152+
- [ ] Docs row added to `apps/docs/content/docs/en/tables/index.mdx`
153+
154+
## Final Validation (Required)
155+
156+
1. **`cd apps/sim && bunx tsc --noEmit -p tsconfig.json`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry.
157+
2. **Grep for leaks**`grep -rnE "(===|!==) '{id}'|case '{id}':" apps/sim --include='*.ts' --include='*.tsx' | grep -v column-types/`. (All three forms: a plain `!==` and a `case` are how half of `currency`'s real branches are written.) Hits are expected; judge each. A hit is fine when it mounts a specific React component or encodes a genuinely one-off behavior (`json`'s mono textarea, `date`'s timezone-aware parsing). A hit is a **leak** when it restates something the registry could answer — an icon, a label, a colour, an operator set, a cast, a coercion. Leaks get a registry field, not a new branch.
158+
3. **Run the suite**`bunx vitest run lib/table 'app/workspace/[workspaceId]/tables' lib/api app/api/table app/api/v1 lib/copilot/tools/server/table`. Existing tests must pass **unchanged**; needing to edit one means you changed behavior for the other types.
159+
4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root.
160+
5. **Exercise it in the running app** on a table with one column of every type: create, edit inline / in the expanded popover / in the row modal, paste from a spreadsheet, filter, sort, convert to and from other types, export CSV, undo a column delete.

0 commit comments

Comments
 (0)