diff --git a/.agents/skills/add-column-type/SKILL.md b/.agents/skills/add-column-type/SKILL.md new file mode 100644 index 00000000000..05f9816aa5f --- /dev/null +++ b/.agents/skills/add-column-type/SKILL.md @@ -0,0 +1,160 @@ +--- +name: add-column-type +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/`. +argument-hint: +--- + +# Adding a Table Column Type + +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. + +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.** + +## Hard Rule: the compiler tells you what to do + +Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list: + +```bash +cd apps/sim && bunx tsc --noEmit -p tsconfig.json +``` + +You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both. + +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". + +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. + +## Directory Structure + +``` +apps/sim/lib/table/column-types/ +├── types.ts # ColumnTypeDefinition — the contract you implement +├── types.server.ts # ColumnTypeServerDefinition — cell migrations only +├── registry.ts # Record ← client-safe, the gate +├── registry.server.ts # Record ← adds migrations (drizzle) +├── index.ts # barrel + accessors (columnTypeOf, columnTypeById, …) +└── {type}.ts # one file per type — what you write +``` + +## Step 1: Pick the storage shape + +Decide what a cell literally holds in `user_table_rows.data` (JSONB). This drives almost everything else: + +| Storage | `jsonbCast` | Notes | +|---------|-------------|-------| +| number | `'numeric'` | Filters/sorts compare numerically. `currency` does this. | +| ISO string | `'timestamptz'` | `date` does this. | +| string / bool / object | `null` | Text comparison is correct. | + +**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. + +## Step 2: Add the icon + +Create `packages/emcn/src/icons/type-{name}.tsx`, copying the geometry conventions of its siblings exactly: + +```tsx +import type { SVGProps } from 'react' + +/** + * Type {name} icon component - {what the glyph is} for {name} columns + * @param props - SVG properties including className, fill, etc. + */ +export function Type{Pascal}(props: SVGProps) { + return ( + + ) +} +``` + +- `viewBox='-1.75 -1.5 24 24'` is the **`type-*` family** value, not the set-wide default. Match the family. +- 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]`. +- Export alphabetically **by component name** in `packages/emcn/src/icons/index.ts`. + +## Step 3: Write the type file + +`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. + +The three that are easy to get wrong: + +- **`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. +- **`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. +- **`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. + +## Step 4: Register + +Add the entry to `COLUMN_TYPE_REGISTRY` in `registry.ts` **and** `COLUMN_TYPE_SERVER_REGISTRY` in `registry.server.ts`. + +`COLUMN_TYPES` is declared in `types.ts` (not derived from the registry — the registry is annotated `Record` 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. + +## Step 5: Migrations (only if the stored bytes change) + +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`. + +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. + +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. + +## Naming Convention + +- Type id: lowercase, singular — `currency`, not `Currency` or `currencies` +- File: `column-types/{id}.ts`, export `const {id}ColumnType` +- Icon: `type-{kebab}.tsx`, export `Type{Pascal}` + +## Watch out + +- **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. +- **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. +- **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`. +- **`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.) +- **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). + +## If your type owns metadata, read this + +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: + +| Where | What happens if you forget | +|---|---| +| `lib/table/types.ts` `ColumnDefinition` | (this one DOES fail — the ownership loop indexes it) | +| `column-types/types.ts` `TYPE_SPECIFIC_COLUMN_KEYS` | it is never stripped on conversion, and poisons the target type | +| `lib/api/contracts/tables.ts` — the schema slot in all three column schemas, plus `refineColumnOptions` | zod strips it at the boundary; silently never saved | +| `columns/service.ts` `addTableColumn` param type | callers cannot pass it | +| 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 | +| `column-config-sidebar.tsx` | no UI to set it | +| `table-grid.tsx` delete-column undo + `use-table-undo.ts` restore | undo silently resets it to the default | + +`normalizeColumn`, `buildConvertedColumn`, and the undo snapshot read `TYPE_SPECIFIC_COLUMN_KEYS` generically, so those three are already zero-edit. + +**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. + +## Checklist Before Finishing + +- [ ] Added to the `ColumnType` union in `column-types/types.ts` +- [ ] `column-types/{id}.ts` created, every interface field filled in +- [ ] Registered in **both** `registry.ts` and `registry.server.ts` +- [ ] Icon added, centered on the family's optical center, exported alphabetically +- [ ] `migrateCellsTo` / `migrateCellsFrom` added if the stored bytes change +- [ ] New metadata keys added to `TYPE_SPECIFIC_COLUMN_KEYS` + `FOREIGN_METADATA_VERB` +- [ ] Unit tests for `coerce` / `isCompatibleWith` round-trips, verified to fail without the code +- [ ] Docs row added to `apps/docs/content/docs/en/tables/index.mdx` + +## Final Validation (Required) + +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. +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. +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. +4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root. +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. diff --git a/.claude/commands/add-column-type.md b/.claude/commands/add-column-type.md new file mode 100644 index 00000000000..b390ccc0b98 --- /dev/null +++ b/.claude/commands/add-column-type.md @@ -0,0 +1,159 @@ +--- +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/`. +argument-hint: +--- + +# Adding a Table Column Type + +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. + +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.** + +## Hard Rule: the compiler tells you what to do + +Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list: + +```bash +cd apps/sim && bunx tsc --noEmit -p tsconfig.json +``` + +You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both. + +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". + +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. + +## Directory Structure + +``` +apps/sim/lib/table/column-types/ +├── types.ts # ColumnTypeDefinition — the contract you implement +├── types.server.ts # ColumnTypeServerDefinition — cell migrations only +├── registry.ts # Record ← client-safe, the gate +├── registry.server.ts # Record ← adds migrations (drizzle) +├── index.ts # barrel + accessors (columnTypeOf, columnTypeById, …) +└── {type}.ts # one file per type — what you write +``` + +## Step 1: Pick the storage shape + +Decide what a cell literally holds in `user_table_rows.data` (JSONB). This drives almost everything else: + +| Storage | `jsonbCast` | Notes | +|---------|-------------|-------| +| number | `'numeric'` | Filters/sorts compare numerically. `currency` does this. | +| ISO string | `'timestamptz'` | `date` does this. | +| string / bool / object | `null` | Text comparison is correct. | + +**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. + +## Step 2: Add the icon + +Create `packages/emcn/src/icons/type-{name}.tsx`, copying the geometry conventions of its siblings exactly: + +```tsx +import type { SVGProps } from 'react' + +/** + * Type {name} icon component - {what the glyph is} for {name} columns + * @param props - SVG properties including className, fill, etc. + */ +export function Type{Pascal}(props: SVGProps) { + return ( + + ) +} +``` + +- `viewBox='-1.75 -1.5 24 24'` is the **`type-*` family** value, not the set-wide default. Match the family. +- 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]`. +- Export alphabetically **by component name** in `packages/emcn/src/icons/index.ts`. + +## Step 3: Write the type file + +`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. + +The three that are easy to get wrong: + +- **`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. +- **`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. +- **`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. + +## Step 4: Register + +Add the entry to `COLUMN_TYPE_REGISTRY` in `registry.ts` **and** `COLUMN_TYPE_SERVER_REGISTRY` in `registry.server.ts`. + +`COLUMN_TYPES` is declared in `types.ts` (not derived from the registry — the registry is annotated `Record` 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. + +## Step 5: Migrations (only if the stored bytes change) + +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`. + +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. + +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. + +## Naming Convention + +- Type id: lowercase, singular — `currency`, not `Currency` or `currencies` +- File: `column-types/{id}.ts`, export `const {id}ColumnType` +- Icon: `type-{kebab}.tsx`, export `Type{Pascal}` + +## Watch out + +- **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. +- **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. +- **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`. +- **`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.) +- **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). + +## If your type owns metadata, read this + +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: + +| Where | What happens if you forget | +|---|---| +| `lib/table/types.ts` `ColumnDefinition` | (this one DOES fail — the ownership loop indexes it) | +| `column-types/types.ts` `TYPE_SPECIFIC_COLUMN_KEYS` | it is never stripped on conversion, and poisons the target type | +| `lib/api/contracts/tables.ts` — the schema slot in all three column schemas, plus `refineColumnOptions` | zod strips it at the boundary; silently never saved | +| `columns/service.ts` `addTableColumn` param type | callers cannot pass it | +| 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 | +| `column-config-sidebar.tsx` | no UI to set it | +| `table-grid.tsx` delete-column undo + `use-table-undo.ts` restore | undo silently resets it to the default | + +`normalizeColumn`, `buildConvertedColumn`, and the undo snapshot read `TYPE_SPECIFIC_COLUMN_KEYS` generically, so those three are already zero-edit. + +**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. + +## Checklist Before Finishing + +- [ ] Added to the `ColumnType` union in `column-types/types.ts` +- [ ] `column-types/{id}.ts` created, every interface field filled in +- [ ] Registered in **both** `registry.ts` and `registry.server.ts` +- [ ] Icon added, centered on the family's optical center, exported alphabetically +- [ ] `migrateCellsTo` / `migrateCellsFrom` added if the stored bytes change +- [ ] New metadata keys added to `TYPE_SPECIFIC_COLUMN_KEYS` + `FOREIGN_METADATA_VERB` +- [ ] Unit tests for `coerce` / `isCompatibleWith` round-trips, verified to fail without the code +- [ ] Docs row added to `apps/docs/content/docs/en/tables/index.mdx` + +## Final Validation (Required) + +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. +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. +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. +4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root. +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. diff --git a/.claude/rules/sim-sandbox.md b/.claude/rules/sim-sandbox.md index 4487af5b8c8..72768e4210e 100644 --- a/.claude/rules/sim-sandbox.md +++ b/.claude/rules/sim-sandbox.md @@ -22,7 +22,10 @@ about what must **never** live in that process. secrets, or any LLM / email / search provider API keys. If you catch yourself `require`'ing `@/lib/auth`, `@sim/db`, `@/lib/uploads/core/storage-service`, or anything that imports `env` directly inside the worker, stop and use a - host-side broker instead. + host-side broker instead. This includes the OS environment: the worker is + spawned with the explicit allowlisted env from `buildWorkerEnv()` in + `isolated-vm.ts` — never spawn it without an `env` option (Node would copy + the app's full `process.env`, secrets included, into the worker). 2. **Host-side brokers own all credentialed work**. The worker can only access resources through `ivm.Reference` / `ivm.Callback` bridges back to the host @@ -69,6 +72,10 @@ payload or `ivm.Reference` wrapper in the worker: - [ ] Did you update the broker limits (`IVM_MAX_BROKER_ARGS_JSON_CHARS`, `IVM_MAX_BROKER_RESULT_JSON_CHARS`, `IVM_MAX_BROKERS_PER_EXECUTION`) if the new broker can emit large payloads or fire frequently? +- [ ] Does the worker read a new env var? Add it to the `buildWorkerEnv()` + allowlist in `isolated-vm.ts` **and** to the allowlist regression test in + `isolated-vm.test.ts` — the worker does not inherit the app environment, + so an un-allowlisted var is simply absent in the child. ## What the worker *may* hold diff --git a/.claude/skills/add-column-type b/.claude/skills/add-column-type new file mode 120000 index 00000000000..9d7b29aa20a --- /dev/null +++ b/.claude/skills/add-column-type @@ -0,0 +1 @@ +../../.agents/skills/add-column-type \ No newline at end of file diff --git a/.cursor/commands/add-column-type.md b/.cursor/commands/add-column-type.md new file mode 100644 index 00000000000..f0be823ab6e --- /dev/null +++ b/.cursor/commands/add-column-type.md @@ -0,0 +1,154 @@ +# Adding a Table Column Type + +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. + +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.** + +## Hard Rule: the compiler tells you what to do + +Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list: + +```bash +cd apps/sim && bunx tsc --noEmit -p tsconfig.json +``` + +You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both. + +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". + +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. + +## Directory Structure + +``` +apps/sim/lib/table/column-types/ +├── types.ts # ColumnTypeDefinition — the contract you implement +├── types.server.ts # ColumnTypeServerDefinition — cell migrations only +├── registry.ts # Record ← client-safe, the gate +├── registry.server.ts # Record ← adds migrations (drizzle) +├── index.ts # barrel + accessors (columnTypeOf, columnTypeById, …) +└── {type}.ts # one file per type — what you write +``` + +## Step 1: Pick the storage shape + +Decide what a cell literally holds in `user_table_rows.data` (JSONB). This drives almost everything else: + +| Storage | `jsonbCast` | Notes | +|---------|-------------|-------| +| number | `'numeric'` | Filters/sorts compare numerically. `currency` does this. | +| ISO string | `'timestamptz'` | `date` does this. | +| string / bool / object | `null` | Text comparison is correct. | + +**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. + +## Step 2: Add the icon + +Create `packages/emcn/src/icons/type-{name}.tsx`, copying the geometry conventions of its siblings exactly: + +```tsx +import type { SVGProps } from 'react' + +/** + * Type {name} icon component - {what the glyph is} for {name} columns + * @param props - SVG properties including className, fill, etc. + */ +export function Type{Pascal}(props: SVGProps) { + return ( + + ) +} +``` + +- `viewBox='-1.75 -1.5 24 24'` is the **`type-*` family** value, not the set-wide default. Match the family. +- 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]`. +- Export alphabetically **by component name** in `packages/emcn/src/icons/index.ts`. + +## Step 3: Write the type file + +`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. + +The three that are easy to get wrong: + +- **`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. +- **`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. +- **`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. + +## Step 4: Register + +Add the entry to `COLUMN_TYPE_REGISTRY` in `registry.ts` **and** `COLUMN_TYPE_SERVER_REGISTRY` in `registry.server.ts`. + +`COLUMN_TYPES` is declared in `types.ts` (not derived from the registry — the registry is annotated `Record` 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. + +## Step 5: Migrations (only if the stored bytes change) + +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`. + +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. + +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. + +## Naming Convention + +- Type id: lowercase, singular — `currency`, not `Currency` or `currencies` +- File: `column-types/{id}.ts`, export `const {id}ColumnType` +- Icon: `type-{kebab}.tsx`, export `Type{Pascal}` + +## Watch out + +- **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. +- **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. +- **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`. +- **`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.) +- **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). + +## If your type owns metadata, read this + +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: + +| Where | What happens if you forget | +|---|---| +| `lib/table/types.ts` `ColumnDefinition` | (this one DOES fail — the ownership loop indexes it) | +| `column-types/types.ts` `TYPE_SPECIFIC_COLUMN_KEYS` | it is never stripped on conversion, and poisons the target type | +| `lib/api/contracts/tables.ts` — the schema slot in all three column schemas, plus `refineColumnOptions` | zod strips it at the boundary; silently never saved | +| `columns/service.ts` `addTableColumn` param type | callers cannot pass it | +| 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 | +| `column-config-sidebar.tsx` | no UI to set it | +| `table-grid.tsx` delete-column undo + `use-table-undo.ts` restore | undo silently resets it to the default | + +`normalizeColumn`, `buildConvertedColumn`, and the undo snapshot read `TYPE_SPECIFIC_COLUMN_KEYS` generically, so those three are already zero-edit. + +**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. + +## Checklist Before Finishing + +- [ ] Added to the `ColumnType` union in `column-types/types.ts` +- [ ] `column-types/{id}.ts` created, every interface field filled in +- [ ] Registered in **both** `registry.ts` and `registry.server.ts` +- [ ] Icon added, centered on the family's optical center, exported alphabetically +- [ ] `migrateCellsTo` / `migrateCellsFrom` added if the stored bytes change +- [ ] New metadata keys added to `TYPE_SPECIFIC_COLUMN_KEYS` + `FOREIGN_METADATA_VERB` +- [ ] Unit tests for `coerce` / `isCompatibleWith` round-trips, verified to fail without the code +- [ ] Docs row added to `apps/docs/content/docs/en/tables/index.mdx` + +## Final Validation (Required) + +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. +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. +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. +4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root. +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. diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 262386b6922..0b3704a9736 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: 22 + node-version: 24 # Cache keys are scoped by event name, and fork PRs get their own # namespace on top: untrusted fork runs must never share a cache with @@ -250,7 +250,7 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: 22 + node-version: 24 - name: Mount Bun cache uses: ./.github/actions/cache-mount diff --git a/AGENTS.md b/AGENTS.md index 9ce16b909d9..f36d633df61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -468,3 +468,9 @@ Two hard rules that the skills assume: For the full authoring instructions — SubBlock property tables, `condition`/`dependsOn`/`required`/`mode`/`canonicalParamId` syntax, required block metadata (`integrationType`, `tags`, `authMode`, `docsLink`, `{Service}BlockMeta`), file-input/`normalizeFileInput` patterns, and checklists — use the skills: `/add-integration` (end-to-end), `/add-tools`, `/add-block`, `/add-trigger`. +## Tables + +Table column types are registry entries in `apps/sim/lib/table/column-types/` — one file per type owning its label, icon, storage cast, coercion, validation, conversion compatibility, formatting, and editor. `Record` on `registry.ts` and `registry.server.ts` is a compile-time completeness gate: adding a type to the union errors until both entries exist. + +Never add a `case 'sometype':` outside `column-types/` — a missing arm fails silently (a wrong `jsonbCast` breaks every filter on the column). If a consumer needs per-type knowledge, add a registry field. Use `/add-column-type` for the full procedure. + diff --git a/CLAUDE.md b/CLAUDE.md index a0632e40ad7..d2e376dbd9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -478,3 +478,9 @@ Two hard rules that the skills assume: For the full authoring instructions — SubBlock property tables, `condition`/`dependsOn`/`required`/`mode`/`canonicalParamId` syntax, required block metadata (`integrationType`, `tags`, `authMode`, `docsLink`, `{Service}BlockMeta`), file-input/`normalizeFileInput` patterns, and checklists — use the skills: `/add-integration` (end-to-end), `/add-tools`, `/add-block`, `/add-trigger`. +## Tables + +Table column types are registry entries in `apps/sim/lib/table/column-types/` — one file per type owning its label, icon, storage cast, coercion, validation, conversion compatibility, formatting, and editor. `Record` on `registry.ts` and `registry.server.ts` is a compile-time completeness gate: adding a type to the union errors until both entries exist. + +Never add a `case 'sometype':` outside `column-types/` — a missing arm fails silently (a wrong `jsonbCast` breaks every filter on the column). If a consumer needs per-type knowledge, add a registry field. Use `/add-column-type` for the full procedure. + diff --git a/apps/desktop/build/generated-icon.icon/Assets/border.svg b/apps/desktop/build/generated-icon.icon/Assets/border.svg new file mode 100644 index 00000000000..fb9d4f8c789 --- /dev/null +++ b/apps/desktop/build/generated-icon.icon/Assets/border.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/build/generated-icon.icon/Assets/logo.png b/apps/desktop/build/generated-icon.icon/Assets/logo.png new file mode 100644 index 00000000000..6fce2046095 Binary files /dev/null and b/apps/desktop/build/generated-icon.icon/Assets/logo.png differ diff --git a/apps/desktop/build/generated-icon.icon/icon.json b/apps/desktop/build/generated-icon.icon/icon.json new file mode 100644 index 00000000000..f53f60fc7e4 --- /dev/null +++ b/apps/desktop/build/generated-icon.icon/icon.json @@ -0,0 +1,33 @@ +{ + "fill": { + "solid": "srgb:1.00000,1.00000,1.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "image-name": "logo.png", + "is-glass": false, + "name": "Sim" + }, + { + "image-name": "border.svg", + "is-glass": false, + "name": "Dev Border" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0 + }, + "specular": false, + "translucency": { + "enabled": false, + "value": 0 + } + } + ], + "supported-platforms": { + "squares": ["macOS"] + } +} diff --git a/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx index de38fe8a69a..5312576ca84 100644 --- a/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx @@ -1,6 +1,6 @@ --- title: Atlassian Service Accounts -description: Set up an Atlassian service account with a scoped API token to use Jira and Confluence in Sim workflows +description: Set up an Atlassian service account with a scoped API token to use Jira, Jira Service Management, and Confluence in Sim workflows --- import { Callout } from 'fumadocs-ui/components/callout' @@ -8,9 +8,11 @@ import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' import { FAQ } from '@/components/ui/faq' -Atlassian service accounts let your workflows authenticate to Jira and Confluence as a non-human bot user — independent of any individual employee's account. Each service account has its own email, its own permissions, and its own API tokens, all managed centrally in admin.atlassian.com. +Atlassian service accounts let your workflows authenticate to Jira, Jira Service Management, and Confluence as a non-human bot user — independent of any individual employee's account. Each service account has its own email, its own permissions, and its own API tokens, all managed centrally in admin.atlassian.com. -This is the recommended way to use Jira and Confluence in production workflows: no one person's OAuth consent expires, the bot's permissions are auditable, and access can be revoked without touching anyone's personal account. +This is the recommended way to use Atlassian products in production workflows: no one person's OAuth consent expires, the bot's permissions are auditable, and access can be revoked without touching anyone's personal account. + +One service account covers all three products. You add it once, and it appears as a connected credential on the Jira, Jira Service Management, and Confluence integration pages alike — there is no separate credential to create per product. ## Prerequisites @@ -124,12 +126,17 @@ Your Atlassian site domain is the URL you use to access Jira or Confluence in yo - Open your workspace **Settings** and go to the **Integrations** tab + Open **Integrations** in your workspace sidebar - Search for "Atlassian Service Account" and click it + Open **Jira**, **Jira Service Management**, or **Confluence** — any of the three works, since they share one service account - {/* TODO(screenshot): Integrations page with "Atlassian Service Account" in the service list */} + {/* TODO(screenshot): Integrations page with Jira in the list */} + + + Click **Add to Sim** and choose **Add service account** + + {/* TODO(screenshot): Jira integration page with the "Add to Sim" dropdown open */} Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description @@ -145,15 +152,17 @@ Your Atlassian site domain is the URL you use to access Jira or Confluence in yo - Click **Add Service Account**. Sim verifies the token by calling Atlassian's `/myself` endpoint through the gateway — if it fails, you'll see a specific error explaining what went wrong. + Click **Add service account**. Sim verifies the token by calling Atlassian's `/myself` endpoint through the gateway — if it fails, you'll see a specific error explaining what went wrong. The token, domain, and discovered cloudId are encrypted before being stored. +Once added, the credential is listed under **Connected** on all three Atlassian integration pages. It is named after the service account's own Atlassian display name, so several service accounts on the same site stay easy to tell apart. + ## Using the Service Account in Workflows -Add a Jira or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. +Add a Jira, Jira Service Management, or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
Data retention is the one feature that deletes data. Its flag controls the cleanup pass, not the settings screen — retention windows are always configurable. Nothing is ever deleted until you enable it, and even then only against windows you configured explicitly. Sim never applies the hosted plan defaults to a self-hosted deployment. diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx index 5a15538a0a5..b0dd2d1a03f 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx @@ -245,6 +245,7 @@ cat > /tmp/cors.json <<'EOF' "x-goog-meta-purpose", "x-goog-meta-userid", "x-goog-meta-workspaceid", + "x-goog-meta-folderid", "x-goog-meta-workflowid", "x-goog-meta-executionid" ], diff --git a/apps/docs/content/docs/en/tables/index.mdx b/apps/docs/content/docs/en/tables/index.mdx index ae70683af65..913711ecdfa 100644 --- a/apps/docs/content/docs/en/tables/index.mdx +++ b/apps/docs/content/docs/en/tables/index.mdx @@ -22,12 +22,16 @@ Every column has a type, which decides how its values are stored and validated. | --- | --- | --- | | **Text** | A free-form string | `"Acme Corp"` | | **Number** | A numeric value | `42` | +| **Currency** | An amount in a currency you pick per column | `$1,234.56` | | **Boolean** | `true` or `false` | `true` | | **Date** | A date | `2026-03-16` | | **JSON** | An object or array | `{ "tier": "pro" }` | +| **Select** | One of a fixed set of options, or several | `Pro` | Types are enforced as you enter values, so a Number column only takes numbers. +A Currency column stores a plain number and renders it in the currency you choose for that column, so filters, sorts, and exports all see the amount itself. Changing a column's currency relabels it — it does not convert the amounts. + ## Editing a table Open the **Tables** section in the sidebar and click **New table** to create one. Add columns from the column header, type into a cell to edit it, and paste rows from a spreadsheet to bulk-load. Filter and sort from the toolbar without changing the underlying data. The editor has full keyboard support; see [keyboard shortcuts](/keyboard-shortcuts). diff --git a/apps/docs/content/docs/en/workflows/blocks/function.mdx b/apps/docs/content/docs/en/workflows/blocks/function.mdx index e89939df2ee..2e555979bd6 100644 --- a/apps/docs/content/docs/en/workflows/blocks/function.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/function.mdx @@ -74,6 +74,82 @@ Beyond the Python standard library, the sandbox ships E2B's data-science stack p - **Math and testing:** `sympy`, `pytest` - **Sim additions:** `awscli`, `yq`, `csvkit` +## Sandboxes + +A sandbox is a named dependency set your workspace maintains — a language plus a +list of pip or npm packages. Select one on a Function block and its code can +import everything on that list. Leave it empty and the block runs on the default +image, exactly as before. + +Create and edit sandboxes in **Settings → Sandboxes**. Only workspace admins can +create or edit them. On sim.ai they need an active Max or Enterprise plan; +self-hosted deployments turn them on with `SANDBOXES_ENABLED` (see +[self-hosted enterprise](/platform/enterprise/self-hosted)). The section is +hidden when a deployment has no sandbox provider configured. + +1. **Name** the sandbox — `bigquery-etl`, `scraping`, whatever the job is. +2. Pick the **language**. A sandbox is language-scoped, so a Python block only + ever lists Python sandboxes. +3. Paste your **dependencies**, one per line. Version pins are optional. + +``` +google-cloud-bigquery==3.25.0 +pyairtable>=3.0 +pandas +``` + +Then open the block's advanced options and choose the sandbox under **Sandbox**. + +In JavaScript the sandbox applies to code that uses `import` or `require` — that is +what sends the block to a remote sandbox in the first place, so a block without them +keeps running locally and ignores the selection. Python always runs remotely, so a +selected sandbox always applies. + + +Two sandboxes with the same language and the same package list share one build, +so duplicating a set costs nothing. Editing a package list starts a new build; +runs already in flight keep using the old one. Deleting a sandbox frees its build +once nothing else uses it. + + +### Build status + +On sim.ai, each dependency set is prebuilt into a reusable image, so runs pay no +install cost. The status row in Settings shows **Queued**, **Building**, +**Ready**, or **Failed**. A failed build reports what went wrong — a package that +does not exist, a version that has no match, a resolver conflict — with the +installer log behind a disclosure. + +Running a block before its sandbox is **Ready** stops the run and shows you the +status. A failed build is retried periodically on its own; to retry immediately, +save the sandbox again in Settings. + +On a self-hosted deployment using Daytona, dependencies install inside the +sandbox at the start of every run instead, adding roughly 10–30 seconds per +execution. Prebuilt images require E2B. + +### What is allowed + +Package names and version specifiers only. URLs, `git+` references, `-e`, local +paths, `--index-url`, and npm aliases are rejected, with the offending line +number reported. A sandbox may declare up to 50 packages. + +## Scoping secrets for agent tools + +When a Function block is used as an Agent tool, its code can read every workspace +secret by default — both `{{MY_SECRET}}` and `environmentVariables['MY_SECRET']`. + +To narrow that, set **Secret access** to *Selected secrets* in the block's +tool configuration and pick the names the code may read. Two things change: + +- Only those secrets are injected. `{{OTHER_SECRET}}` no longer resolves either. +- The selected **names** are added to the tool's description, so the model knows + what it can reference. Values are never sent to the model — they are injected + server-side at execution. + +Leaving the default (*All secrets*) resolves the list at run time, so a secret +added next month is included automatically. + ## Examples ### Reshape an API response @@ -170,6 +246,6 @@ The lazy `sim.files` and `sim.values` helpers are available only in JavaScript f { question: "When does code run locally vs. in a sandbox?", answer: "JavaScript without external imports runs in a local isolated sandbox for speed. JavaScript that uses import or require runs in E2B. Python always runs in the E2B sandbox, with or without imports." }, { question: "How do I reference outputs from other blocks inside my code?", answer: "Use angle-bracket syntax directly, like or , with no quotes around the tag — Sim replaces it with the real value before execution. For environment variables, use double curly braces: {{API_KEY}}." }, { question: "What does the Function block return?", answer: "Two outputs: result (the return value of your code, read as ) and stdout (anything logged with console.log or print, read as ). Include a return statement in JavaScript, or print JSON in Python, to pass data downstream." }, - { question: "Can I make HTTP requests from a Function block?", answer: "Yes. fetch() is available in JavaScript with async/await; libraries like axios are not, only the built-in fetch. In Python, use requests or httpx in the E2B sandbox." }, + { question: "Can I make HTTP requests from a Function block?", answer: "Yes. fetch() is available in JavaScript with async/await; libraries like axios are only available when the block has a sandbox selected. In Python, use requests or httpx in the E2B sandbox." }, { question: "Is there a timeout for Function block execution?", answer: "Yes, a configurable execution timeout. If your code exceeds it, the run is terminated and the block reports an error. Keep this in mind for external calls or heavy processing." }, ]} /> diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json new file mode 100644 index 00000000000..15757b19f1e --- /dev/null +++ b/apps/docs/openapi-v2-tables.json @@ -0,0 +1,381 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim Tables API v2", + "version": "2.0.0-preview", + "description": "Read access to Sim tables with the typed predicate filter grammar and opaque cursor pagination. This surface is feature-gated (`tables-v2-api`): when the flag is off for the caller, every endpoint returns 404 as if it does not exist. Filters are predicate trees — `{\"all\": [...]}` (AND) or `{\"any\": [...]}` (OR) groups whose members are `{field, op, value}` conditions or nested groups. Built-in columns `id`, `createdAt`, and `updatedAt` (camelCase) are filterable and sortable alongside user columns." + }, + "servers": [{ "url": "https://www.sim.ai" }], + "security": [{ "apiKey": [] }], + "paths": { + "/api/v2/tables": { + "get": { + "operationId": "v2ListTables", + "summary": "List Tables", + "description": "List every table in a workspace with its column schema and row count.", + "tags": ["Tables v2"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string", "minLength": 1 } + } + ], + "responses": { + "200": { + "description": "Tables in the workspace. Served with `Cache-Control: private, no-store`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["success", "data"], + "properties": { + "success": { "const": true }, + "data": { + "type": "object", + "required": ["tables", "totalCount"], + "properties": { + "tables": { + "type": "array", + "items": { "$ref": "#/components/schemas/TableSummary" } + }, + "totalCount": { "type": "integer" } + } + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/ValidationError" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFoundOrGated" }, + "429": { "$ref": "#/components/responses/RateLimited" } + } + } + }, + "/api/v2/tables/{tableId}/query": { + "post": { + "operationId": "v2QueryTableRows", + "summary": "Query Rows", + "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`. `totalCount` is computed on the first page only (requests with a `cursor` return `totalCount: null`).", + "tags": ["Tables v2"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "schema": { "type": "string", "minLength": 1 } + } + ], + "requestBody": { + "required": true, + "description": "Bodies over 1 MB are rejected with 413.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { "type": "string", "minLength": 1 }, + "predicate": { "$ref": "#/components/schemas/Predicate" }, + "sort": { + "type": "array", + "maxItems": 16, + "description": "Ordered sort spec, highest priority first.", + "items": { + "type": "object", + "required": ["field", "direction"], + "properties": { + "field": { "type": "string" }, + "direction": { "enum": ["asc", "desc"] } + } + } + }, + "limit": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "default": 100, + "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." + }, + "cursor": { + "type": "string", + "description": "Opaque token from a previous response's `nextCursor`. Pass back verbatim. Mutually exclusive with `sort`." + } + } + }, + "examples": { + "filtered": { + "summary": "Multi-select membership + negated pattern", + "value": { + "workspaceId": "ws_123", + "predicate": { + "all": [ + { "field": "Color", "op": "contains", "value": "Purple" }, + { "field": "name", "op": "nlike", "value": "G*" } + ] + }, + "limit": 100 + } + }, + "builtinColumns": { + "summary": "Built-in column range (UTC, timezone-independent)", + "value": { + "workspaceId": "ws_123", + "predicate": { + "all": [ + { "field": "createdAt", "op": "gte", "value": "2026-07-24T03:00:00.000Z" }, + { "field": "createdAt", "op": "lte", "value": "2026-07-25T02:59:59.999Z" } + ] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A page of rows. Served with `Cache-Control: private, no-store`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["success", "data"], + "properties": { + "success": { "const": true }, + "data": { + "type": "object", + "required": ["rows", "rowCount", "nextCursor"], + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "data", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string" }, + "data": { + "type": "object", + "description": "Column-NAME-keyed cell values.", + "additionalProperties": true + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + } + }, + "rowCount": { "type": "integer", "description": "Rows in THIS page." }, + "totalCount": { + "type": ["integer", "null"], + "description": "Rows matching the predicate across all pages. First page only; null when a cursor was supplied." + }, + "limit": { "type": ["integer", "null"] }, + "nextCursor": { + "type": ["string", "null"], + "description": "Non-null ⇒ more rows exist. The ONLY termination signal is null." + } + } + } + } + } + } + } + }, + "400": { + "description": "Validation failure. Machine-readable `code` values include `INVALID_FILTER` (unknown column, operator/type mismatch, malformed tree), `INVALID_ORDER`, `INVALID_CURSOR`, `CURSOR_SORT_CONFLICT`, and `TABLE_QUERY_RESULT_TOO_LARGE` (unbounded result exceeded the 5 MB budget).", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorBody" } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFoundOrGated" }, + "413": { + "description": "Request body exceeded the 1 MB cap.", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + } + }, + "429": { "$ref": "#/components/responses/RateLimited" } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + }, + "schemas": { + "Predicate": { + "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", + "oneOf": [ + { + "type": "object", + "required": ["all"], + "additionalProperties": false, + "properties": { + "all": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { "$ref": "#/components/schemas/PredicateNode" } + } + } + }, + { + "type": "object", + "required": ["any"], + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { "$ref": "#/components/schemas/PredicateNode" } + } + } + } + ] + }, + "PredicateNode": { + "oneOf": [ + { "$ref": "#/components/schemas/Predicate" }, + { "$ref": "#/components/schemas/Condition" } + ] + }, + "Condition": { + "type": "object", + "required": ["field", "op"], + "additionalProperties": false, + "properties": { + "field": { + "type": "string", + "maxLength": 128, + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." + }, + "op": { + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." + }, + "value": { + "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." + } + } + }, + "TableSummary": { + "type": "object", + "required": ["id", "name", "schema", "rowCount", "maxRows", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "schema": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "type"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "type": { "enum": ["string", "number", "boolean", "date", "json", "select"] }, + "required": { "type": "boolean" }, + "unique": { "type": "boolean" }, + "options": { + "type": "array", + "description": "Declared choices on a `select` column.", + "items": { + "type": "object", + "properties": { "id": { "type": "string" }, "name": { "type": "string" } } + } + }, + "multiple": { "type": "boolean" } + } + } + } + } + }, + "rowCount": { "type": "integer" }, + "maxRows": { "type": "integer" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "ErrorBody": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "string", + "description": "Human-readable message naming the failing field/operator." + }, + "code": { + "type": "string", + "description": "Machine-readable code, present on domain validation failures." + } + } + } + }, + "responses": { + "ValidationError": { + "description": "Malformed request.", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + } + }, + "Unauthorized": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + } + }, + "Forbidden": { + "description": "The key's workspace scope does not cover this workspace, or the caller lacks read access.", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + } + }, + "NotFoundOrGated": { + "description": "Table not found — or the `tables-v2-api` feature flag is off for this caller, in which case the entire surface answers 404. The gate is evaluated after authorization, so a 404 never distinguishes rollout cohort from missing resource for callers without access.", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + } + }, + "RateLimited": { + "description": "Rate limit exceeded for this key.", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + } + } + } + } +} diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 1844cc7a1e2..4967b8c8186 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -3405,7 +3405,7 @@ "name": "sort", "in": "query", "required": false, - "description": "JSON-encoded sort object. Example: {\"created_at\": \"desc\"}.", + "description": "JSON-encoded sort object. Example: {\"createdAt\": \"desc\"}. Built-in columns are camelCase: id, createdAt, updatedAt.", "schema": { "type": "string" } diff --git a/apps/sim/.env.example b/apps/sim/.env.example index ef123188499..3c2dcf8f229 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -63,7 +63,11 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth # LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible) # LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth -# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing +# NEXT_PUBLIC_FORCE_HOSTED=true # Dev only: treat this instance as hosted Sim (sim-auto pool, platform keys); ignored in production builds +# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing and inference +# FIREWORKS_API_KEY_1= # Optional Fireworks API key for rotation (hosted deployments) +# FIREWORKS_API_KEY_2= # Additional Fireworks API key for load balancing +# FIREWORKS_API_KEY_3= # Additional Fireworks API key for load balancing # NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI. # AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED) # AZURE_OPENAI_API_KEY= # Azure OpenAI API key diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx deleted file mode 100644 index 57c83fa152b..00000000000 --- a/apps/sim/app/(auth)/auth-layout-client.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client' - -import { usePathname } from 'next/navigation' -import { DesktopTitleBarController } from '@/app/_shell/desktop-title-bar' -import { AuthShell } from '@/app/(auth)/components' - -export default function AuthLayoutClient({ children }: { children: React.ReactNode }) { - const isLogin = usePathname() === '/login' - - return ( - <> - {isLogin && } - {children} - - ) -} diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx index d4dd4f06521..36085dc52ad 100644 --- a/apps/sim/app/(auth)/components/auth-shell.tsx +++ b/apps/sim/app/(auth)/components/auth-shell.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' -import { cn } from '@sim/emcn' import Link from 'next/link' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' interface AuthShellProps { @@ -8,8 +8,6 @@ interface AuthShellProps { children: ReactNode /** Optional element pinned to the bottom of the shell (e.g. the support footer). */ footer?: ReactNode - /** Reserve the native macOS title-bar lane for the desktop login route. */ - reserveDesktopTitleBar?: boolean } /** @@ -21,18 +19,19 @@ interface AuthShellProps { * the canvas/`--text-primary` surface, and renders a logo-only header that reuses * the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The * single content column is centered and capped for a calm single-form layout. + * + * The shell also owns the macOS traffic-light lane, unconditionally — every surface that + * wears it (the `(auth)` routes, the CLI auth handoff, the invite pages) sits outside + * workspace chrome and draws its logo where the lights are. Gating this per route left + * whichever surface was overlooked drawing underneath them, and a route list could not + * cover a dynamic segment like `/invite/[id]` anyway. Off the desktop shell + * `--desktop-title-bar-height` is `0px`, so the reservation and the drag strip both + * collapse to nothing and `.desktop-title-bar-page` is exactly `min-h-screen`. */ -export function AuthShell({ children, footer, reserveDesktopTitleBar = false }: AuthShellProps) { +export function AuthShell({ children, footer }: AuthShellProps) { return ( -
- {reserveDesktopTitleBar && ( -
- )} +
+
}> + Loading…
} + > ) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index dfd0428f30b..de337cc5c3e 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -490,7 +490,9 @@ export default function SignupPage({ emailSignupEnabled, }: SignupFormProps) { return ( - Loading…
}> + Loading…
} + > +
+ {/* Header component */} diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx index 8f55e4c5552..a964d796cb0 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx @@ -1,8 +1,10 @@ import { Skeleton } from '@sim/emcn' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' export default function ChatLoading() { return ( -
+
+
diff --git a/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx b/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx index 178b20aac59..dc4fb3d9000 100644 --- a/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx +++ b/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx @@ -1,8 +1,10 @@ import { Skeleton } from '@sim/emcn' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' export function ChatLoadingState() { return ( -
+
+
diff --git a/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx index eaab45992a2..242ea8bdff1 100644 --- a/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx @@ -14,6 +14,7 @@ import { MAX_CHAT_SESSION_MS, SAMPLE_RATE, } from '@/lib/speech/config' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' const ParticlesVisualization = dynamic( () => @@ -524,10 +525,11 @@ export function VoiceInterface({ return (
+
+
diff --git a/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx b/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx index 545d2599da0..64c25cbc09d 100644 --- a/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx +++ b/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { cn } from '@sim/emcn' import Link from 'next/link' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' /** @@ -26,7 +27,8 @@ interface LogoShellProps { export function LogoShell({ children, center = false, footer }: LogoShellProps) { return ( -
+
+