Compare commits

...

12 Commits

Author SHA1 Message Date
Waleed
12534163c1 fix(tables): hide tables from sidebar and block registry (#3270)
* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint
2026-02-20 11:58:02 -08:00
Waleed
55920e9b03 fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)
Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment
2026-02-20 11:41:28 -08:00
Waleed
958dd64740 fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)
* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request
2026-02-20 11:33:52 -08:00
Vikhyath Mondreti
68f44b8df4 improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266) 2026-02-20 01:56:33 -08:00
Waleed
9920882dc5 fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)
* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.
2026-02-19 21:54:16 -08:00
Waleed
9ca5254c2b fix(audit-log): lazily resolve actor name/email when missing (#3262) 2026-02-19 16:48:43 -08:00
Waleed
d7fddb2909 feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263) 2026-02-19 16:20:20 -08:00
Waleed
61c7afc19e feat(tools): added redis, upstash, algolia, and revenuecat (#3261)
* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment
2026-02-19 16:13:06 -08:00
Waleed
3c470ab0f8 fix(workflows): disallow duplicate workflow names at the same folder level (#3260) 2026-02-19 14:12:43 -08:00
Waleed
2b5e436a2a fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)
* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing
2026-02-19 13:58:35 -08:00
Lakee Sivaraya
e24c824c9a feat(tables): added tables (#2867)
* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>
2026-02-19 13:11:35 -08:00
Waleed
dcf81372af feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)
* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 23:40:45 -08:00
310 changed files with 43437 additions and 750 deletions

View File

@@ -454,6 +454,8 @@ Enables AI-assisted field generation.
## Tools Configuration
**Important:** `tools.config.tool` runs during serialization before variable resolution. Put `Number()` and other type coercions in `tools.config.params` instead, which runs at execution time after variables are resolved.
**Preferred:** Use tool names directly as dropdown option IDs to avoid switch cases:
```typescript
// Dropdown options use tool IDs directly

View File

@@ -238,7 +238,7 @@ export const ServiceBlock: BlockConfig = {
bgColor: '#hexcolor',
icon: ServiceIcon,
subBlocks: [ /* see SubBlock Properties */ ],
tools: { access: ['service_action'], config: { tool: (p) => `service_${p.operation}` } },
tools: { access: ['service_action'], config: { tool: (p) => `service_${p.operation}`, params: (p) => ({ /* type coercions here */ }) } },
inputs: { /* ... */ },
outputs: { /* ... */ },
}
@@ -246,6 +246,8 @@ export const ServiceBlock: BlockConfig = {
Register in `blocks/registry.ts` (alphabetically).
**Important:** `tools.config.tool` runs during serialization (before variable resolution). Never do `Number()` or other type coercions there — dynamic references like `<Block.output>` will be destroyed. Use `tools.config.params` for type coercions (it runs during execution, after variables are resolved).
**SubBlock Properties:**
```typescript
{

View File

@@ -1157,6 +1157,17 @@ export function AirweaveIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function AlgoliaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50'>
<path
fill='#FFFFFF'
d='M25,0C11.3,0,0.2,11,0,24.6C-0.2,38.4,11,49.9,24.8,50c4.3,0,8.4-1,12-3c0.4-0.2,0.4-0.7,0.1-1l-2.3-2.1 c-0.5-0.4-1.2-0.5-1.7-0.3c-2.5,1.1-5.3,1.6-8.2,1.6c-11.2-0.1-20.2-9.4-20-20.6C4.9,13.6,13.9,4.7,25,4.7h20.3v36L33.7,30.5 c-0.4-0.3-0.9-0.3-1.2,0.1c-1.8,2.4-4.9,4-8.2,3.7c-4.6-0.3-8.4-4-8.7-8.7c-0.4-5.5,4-10.2,9.4-10.2c4.9,0,9,3.8,9.4,8.6 c0,0.4,0.2,0.8,0.6,1.1l3,2.7c0.3,0.3,0.9,0.1,1-0.3c0.2-1.2,0.3-2.4,0.2-3.6c-0.5-7-6.2-12.7-13.2-13.1c-8.1-0.5-14.8,5.8-15,13.7 c-0.2,7.7,6.1,14.4,13.8,14.5c3.2,0.1,6.2-0.9,8.6-2.7l15,13.3c0.6,0.6,1.7,0.1,1.7-0.7v-48C50,0.4,49.5,0,49,0L25,0 C25,0,25,0,25,0z'
/>
</svg>
)
}
export function GoogleBooksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 478.633 540.068'>
@@ -4407,6 +4418,161 @@ export function DatadogIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function MicrosoftDataverseIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const clip0 = `dataverse_clip0_${id}`
const clip1 = `dataverse_clip1_${id}`
const clip2 = `dataverse_clip2_${id}`
const paint0 = `dataverse_paint0_${id}`
const paint1 = `dataverse_paint1_${id}`
const paint2 = `dataverse_paint2_${id}`
const paint3 = `dataverse_paint3_${id}`
const paint4 = `dataverse_paint4_${id}`
const paint5 = `dataverse_paint5_${id}`
const paint6 = `dataverse_paint6_${id}`
return (
<svg
{...props}
width='96'
height='96'
viewBox='0 0 96 96'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g clipPath={`url(#${clip0})`}>
<g clipPath={`url(#${clip1})`}>
<g clipPath={`url(#${clip2})`}>
<path
d='M13.8776 21.8242C29.1033 8.13791 49.7501 8.1861 62.955 18.9134C74.9816 28.6836 77.4697 44.3159 70.851 55.7801C64.2321 67.2443 52.5277 70.1455 39.5011 62.6247L31.7286 76.087L31.7234 76.0862C27.4181 83.5324 17.8937 86.0828 10.4437 81.7817C7.45394 80.0556 5.25322 77.4879 3.96665 74.551L3.96096 74.5511C-4.07832 55.7804 0.200745 34.1184 13.8776 21.8242Z'
fill={`url(#${paint0})`}
/>
<path
d='M13.8776 21.8242C29.1033 8.13791 49.7501 8.1861 62.955 18.9134C74.9816 28.6836 77.4697 44.3159 70.851 55.7801C64.2321 67.2443 52.5277 70.1455 39.5011 62.6247L31.7286 76.087L31.7234 76.0862C27.4181 83.5324 17.8937 86.0828 10.4437 81.7817C7.45394 80.0556 5.25322 77.4879 3.96665 74.551L3.96096 74.5511C-4.07832 55.7804 0.200745 34.1184 13.8776 21.8242Z'
fill={`url(#${paint1})`}
fillOpacity='0.8'
/>
<path
d='M85.4327 14.2231C88.4528 15.9668 90.6686 18.569 91.9494 21.5433L91.9533 21.5444C99.9406 40.2943 95.6533 61.9068 81.9983 74.1814C66.7726 87.8677 46.1257 87.8196 32.9209 77.0923C20.8945 67.3221 18.4062 51.6897 25.0249 40.2256C31.6438 28.7614 43.3482 25.8601 56.3748 33.381L64.1434 19.9255L64.1482 19.9249C68.4516 12.4736 77.9805 9.92084 85.4327 14.2231Z'
fill={`url(#${paint2})`}
/>
<path
d='M85.4327 14.2231C88.4528 15.9668 90.6686 18.569 91.9494 21.5433L91.9533 21.5444C99.9406 40.2943 95.6533 61.9068 81.9983 74.1814C66.7726 87.8677 46.1257 87.8196 32.9209 77.0923C20.8945 67.3221 18.4062 51.6897 25.0249 40.2256C31.6438 28.7614 43.3482 25.8601 56.3748 33.381L64.1434 19.9255L64.1482 19.9249C68.4516 12.4736 77.9805 9.92084 85.4327 14.2231Z'
fill={`url(#${paint3})`}
fillOpacity='0.9'
/>
<path
d='M39.5041 62.6261C52.5307 70.1469 64.2352 67.2456 70.8541 55.7814C77.2488 44.7055 75.1426 29.7389 64.147 19.9271L56.3791 33.3814L39.5041 62.6261Z'
fill={`url(#${paint4})`}
/>
<path
d='M56.3794 33.3815C43.3528 25.8607 31.6482 28.762 25.0294 40.2262C18.6347 51.3021 20.7409 66.2687 31.7364 76.0806L39.5043 62.6262L56.3794 33.3815Z'
fill={`url(#${paint5})`}
/>
<path
d='M33.3215 56.4453C37.9837 64.5204 48.3094 67.2872 56.3846 62.625C64.4598 57.9628 67.2266 47.6371 62.5643 39.5619C57.9021 31.4867 47.5764 28.72 39.5013 33.3822C31.4261 38.0444 28.6593 48.3701 33.3215 56.4453Z'
fill={`url(#${paint6})`}
/>
</g>
</g>
</g>
<defs>
<radialGradient
id={paint0}
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(46.0001 49.4996) rotate(-148.717) scale(46.2195 47.5359)'
>
<stop offset='0.465088' stopColor='#09442A' />
<stop offset='0.70088' stopColor='#136C6C' />
<stop offset='1' stopColor='#22918B' />
</radialGradient>
<radialGradient
id={paint1}
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(50.0001 32.4996) rotate(123.57) scale(66.0095 46.5498)'
>
<stop offset='0.718705' stopColor='#1A7F7C' stopOpacity='0' />
<stop offset='1' stopColor='#16BBDA' />
</radialGradient>
<radialGradient
id={paint2}
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(50.4999 44.5001) rotate(30.75) scale(45.9618 44.5095)'
>
<stop offset='0.358097' stopColor='#136C6C' />
<stop offset='0.789474' stopColor='#42B870' />
<stop offset='1' stopColor='#76D45E' />
</radialGradient>
<radialGradient
id={paint3}
cx='0'
cy='0'
r='1'
gradientTransform='matrix(42.5 -36.0002 31.1824 36.8127 49.4998 55.5001)'
gradientUnits='userSpaceOnUse'
>
<stop offset='0.583166' stopColor='#76D45E' stopOpacity='0' />
<stop offset='1' stopColor='#C8F5B7' />
</radialGradient>
<radialGradient
id={paint4}
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(47.5 48) rotate(-58.9042) scale(32.6898)'
>
<stop offset='0.486266' stopColor='#22918B' />
<stop offset='0.729599' stopColor='#42B870' />
<stop offset='1' stopColor='#43E5CA' />
</radialGradient>
<radialGradient
id={paint5}
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(47.3833 49.0077) rotate(119.859) scale(31.1328 29.4032)'
>
<stop offset='0.459553' stopColor='#08494E' />
<stop offset='0.742242' stopColor='#1A7F7C' />
<stop offset='1' stopColor='#309C61' />
</radialGradient>
<radialGradient
id={paint6}
cx='0'
cy='0'
r='1'
gradientUnits='userSpaceOnUse'
gradientTransform='translate(52.5 40) rotate(120.784) scale(27.3542)'
>
<stop stopColor='#C8F5B7' />
<stop offset='0.24583' stopColor='#98F0B0' />
<stop offset='0.643961' stopColor='#52D17C' />
<stop offset='1' stopColor='#119FC5' />
</radialGradient>
<clipPath id={clip0}>
<rect width='96' height='96' fill='white' />
</clipPath>
<clipPath id={clip1}>
<rect width='96' height='96' fill='white' />
</clipPath>
<clipPath id={clip2}>
<rect width='95.9998' height='96' fill='white' />
</clipPath>
</defs>
</svg>
)
}
export function KalshiIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 78 20' fill='currentColor' xmlns='http://www.w3.org/2000/svg'>
@@ -4809,6 +4975,26 @@ export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TableIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<rect width='18' height='18' x='3' y='3' rx='2' />
<path d='M3 9h18' />
<path d='M3 15h18' />
<path d='M9 3v18' />
<path d='M15 3v18' />
</svg>
)
}
export function ReductoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -5547,3 +5733,89 @@ export function VercelIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function CloudflareIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'>
<path
fill='#f38020'
d='M331 326c11-26-4-38-19-38l-148-2c-4 0-4-6 1-7l150-2c17-1 37-15 43-33 0 0 10-21 9-24a97 97 0 0 0-187-11c-38-25-78 9-69 46-48 3-65 46-60 72 0 1 1 2 3 2h274c1 0 3-1 3-3z'
/>
<path
fill='#faae40'
d='M381 224c-4 0-6-1-7 1l-5 21c-5 16 3 30 20 31l32 2c4 0 4 6-1 7l-33 1c-36 4-46 39-46 39 0 2 0 3 2 3h113l3-2a81 81 0 0 0-78-103'
/>
</svg>
)
}
export function UpstashIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 341' width='24' height='24'>
<path
fill='#00C98D'
d='M0 298.417c56.554 56.553 148.247 56.553 204.801 0c56.554-56.554 56.554-148.247 0-204.801l-25.6 25.6c42.415 42.416 42.415 111.185 0 153.6c-42.416 42.416-111.185 42.416-153.601 0z'
/>
<path
fill='#00C98D'
d='M51.2 247.216c28.277 28.277 74.123 28.277 102.4 0c28.277-28.276 28.277-74.123 0-102.4l-25.6 25.6c14.14 14.138 14.14 37.061 0 51.2c-14.138 14.139-37.061 14.139-51.2 0zM256 42.415c-56.554-56.553-148.247-56.553-204.8 0c-56.555 56.555-56.555 148.247 0 204.801l25.599-25.6c-42.415-42.415-42.415-111.185 0-153.6c42.416-42.416 111.185-42.416 153.6 0z'
/>
<path
fill='#00C98D'
d='M204.8 93.616c-28.276-28.277-74.124-28.277-102.4 0c-28.278 28.277-28.278 74.123 0 102.4l25.6-25.6c-14.14-14.138-14.14-37.061 0-51.2c14.138-14.139 37.06-14.139 51.2 0z'
/>
<path
fill='#FFF'
fillOpacity='.4'
d='M256 42.415c-56.554-56.553-148.247-56.553-204.8 0c-56.555 56.555-56.555 148.247 0 204.801l25.599-25.6c-42.415-42.415-42.415-111.185 0-153.6c42.416-42.416 111.185-42.416 153.6 0z'
/>
<path
fill='#FFF'
fillOpacity='.4'
d='M204.8 93.616c-28.276-28.277-74.124-28.277-102.4 0c-28.278 28.277-28.278 74.123 0 102.4l25.6-25.6c-14.14-14.138-14.14-37.061 0-51.2c14.138-14.139 37.06-14.139 51.2 0z'
/>
</svg>
)
}
export function RevenueCatIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='512'
height='512'
viewBox='0 0 512 512'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M95 109.774C110.152 106.108 133.612 104 154.795 104C212.046 104 246.32 123.928 246.32 174.646C246.32 205.746 233.737 226.264 214.005 237.437L261.765 318.946C258.05 321.632 250.035 323.176 238.864 323.176C226.282 323.176 217.987 321.672 211.982 318.946L172.225 248.3H167.645C157.789 248.305 147.945 247.601 138.18 246.192V319.255C134.172 321.672 127.022 323.176 116.73 323.176C106.73 323.176 99.2874 321.659 95 319.255V109.774ZM137.643 207.848C145.772 209.263 153.997 209.968 162.235 209.956C187.12 209.956 202.285 200.556 202.285 177.057C202.285 152.886 186.268 142.949 157.668 142.949C150.956 142.918 144.255 143.515 137.643 144.735V207.848Z'
fill='#FFFFFF'
/>
<path
d='M428.529 329.244C428.529 365.526 410.145 375.494 396.306 382.195C360.972 399.32 304.368 379.4 244.206 373.338C189.732 366.214 135.706 361.522 127.309 373.738C124.152 376.832 123.481 386.798 127.309 390.862C138.604 402.85 168.061 394.493 188.919 390.714C195.391 389.694 201.933 392.099 206.079 397.021C210.226 401.944 211.349 408.637 209.024 414.58C206.699 420.522 201.28 424.811 194.809 425.831C185.379 427.264 175.85 427.989 166.306 428C145.988 428 120.442 424.495 105.943 409.072C98.7232 401.4 91.3266 387.78 97.0271 366.465C107.875 326.074 172.807 336.052 248.033 343.633C300.41 348.907 357.23 366.465 379.934 350.343C385.721 346.234 396.517 337.022 390.698 329.244C384.879 321.467 375.353 325.684 362.838 325.684C300.152 325.684 263.238 285.302 263.238 217.916C263.247 167.292 284.176 131.892 318.287 115.09C333.109 107.789 350.421 104 369.587 104C386.292 104 403.269 106.931 414.11 113.366C420.847 123.032 423.778 140.305 422.306 153.201C408.247 146.466 395.36 142.949 378.669 142.949C337.365 142.949 308.947 164.039 308.947 214.985C308.947 265.932 337.065 286.149 376.611 286.149C387.869 286.035 403.1 284.67 422.306 282.053C426.455 297.498 428.529 313.228 428.529 329.244Z'
fill='#FFFFFF'
/>
</svg>
)
}
export function RedisIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 512 512'
xmlns='http://www.w3.org/2000/svg'
fillRule='evenodd'
clipRule='evenodd'
strokeLinejoin='round'
strokeMiterlimit='2'
>
<path
d='M479.14 279.864c-34.584 43.578-71.94 93.385-146.645 93.385-66.73 0-91.59-58.858-93.337-106.672 14.62 30.915 43.203 55.949 87.804 54.792C412.737 318.6 471.53 241.127 471.53 170.57c0-84.388-62.947-145.262-172.24-145.262-78.165 0-175.004 29.743-238.646 76.782-.689 48.42 26.286 111.369 35.972 104.452 55.17-39.67 98.918-65.203 141.35-78.01C175.153 198.58 24.451 361.219 6 389.85c2.076 26.286 34.588 96.842 50.496 96.842 4.841 0 8.993-2.768 13.835-7.61 45.433-51.046 82.472-96.816 115.412-140.933 4.627 64.658 36.42 143.702 125.307 143.702 79.55 0 158.408-57.414 194.377-186.767 4.149-15.911-15.22-28.362-26.286-15.22zm-90.616-104.449c0 40.81-40.118 60.87-76.782 60.87-19.596 0-34.648-5.145-46.554-11.832 21.906-33.168 43.59-67.182 66.887-103.593 41.08 6.953 56.449 29.788 56.449 54.555z'
fill='#FFFFFF'
fillRule='nonzero'
/>
</svg>
)
}

View File

@@ -8,6 +8,7 @@ import {
AhrefsIcon,
AirtableIcon,
AirweaveIcon,
AlgoliaIcon,
ApifyIcon,
ApolloIcon,
ArxivIcon,
@@ -19,6 +20,7 @@ import {
CirclebackIcon,
ClayIcon,
ClerkIcon,
CloudflareIcon,
ConfluenceIcon,
CursorIcon,
DatadogIcon,
@@ -71,6 +73,7 @@ import {
MailgunIcon,
MailServerIcon,
Mem0Icon,
MicrosoftDataverseIcon,
MicrosoftExcelIcon,
MicrosoftOneDriveIcon,
MicrosoftPlannerIcon,
@@ -96,8 +99,10 @@ import {
QdrantIcon,
RDSIcon,
RedditIcon,
RedisIcon,
ReductoIcon,
ResendIcon,
RevenueCatIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
@@ -125,6 +130,7 @@ import {
TTSIcon,
TwilioIcon,
TypeformIcon,
UpstashIcon,
VercelIcon,
VideoIcon,
WealthboxIcon,
@@ -146,6 +152,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
airweave: AirweaveIcon,
algolia: AlgoliaIcon,
apify: ApifyIcon,
apollo: ApolloIcon,
arxiv: ArxivIcon,
@@ -156,6 +163,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
circleback: CirclebackIcon,
clay: ClayIcon,
clerk: ClerkIcon,
cloudflare: CloudflareIcon,
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
datadog: DatadogIcon,
@@ -209,6 +217,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
mailgun: MailgunIcon,
mem0: Mem0Icon,
memory: BrainIcon,
microsoft_dataverse: MicrosoftDataverseIcon,
microsoft_excel_v2: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_teams: MicrosoftTeamsIcon,
@@ -232,8 +241,10 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
qdrant: QdrantIcon,
rds: RDSIcon,
reddit: RedditIcon,
redis: RedisIcon,
reducto_v2: ReductoIcon,
resend: ResendIcon,
revenuecat: RevenueCatIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
@@ -263,6 +274,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
upstash: UpstashIcon,
vercel: VercelIcon,
video_generator_v2: VideoIcon,
vision_v2: EyeIcon,

View File

@@ -0,0 +1,404 @@
---
title: Algolia
description: Search and manage Algolia indices
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="algolia"
color="#003DFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[Algolia](https://www.algolia.com/) is a powerful hosted search platform that enables developers and teams to deliver fast, relevant search experiences in their apps and websites. Algolia provides full-text, faceted, and filtered search as well as analytics and advanced ranking capabilities.
With Algolia, you can:
- **Deliver lightning-fast search**: Provide instant search results as users type, with typo tolerance and synonyms
- **Manage and update records**: Easily add, update, or delete objects/records in your indices
- **Perform advanced filtering**: Use filters, facets, and custom ranking to refine and organize search results
- **Configure index settings**: Adjust relevance, ranking, attributes for search, and more to optimize user experience
- **Scale confidently**: Algolia handles massive traffic and data volumes with globally distributed infrastructure
- **Gain insights**: Track analytics, search patterns, and user engagement
In Sim, the Algolia integration allows your agents to search, manage, and configure Algolia indices directly within your workflows. Use Algolia to power dynamic data exploration, automate record updates, run batch operations, and more—all from a single tool in your workspace.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Algolia into your workflow. Search indices, manage records (add, update, delete, browse), configure index settings, and perform batch operations.
## Tools
### `algolia_search`
Search an Algolia index
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia API Key |
| `indexName` | string | Yes | Name of the Algolia index to search |
| `query` | string | Yes | Search query text |
| `hitsPerPage` | number | No | Number of hits per page \(default: 20\) |
| `page` | number | No | Page number to retrieve \(default: 0\) |
| `filters` | string | No | Filter string \(e.g., "category:electronics AND price &lt; 100"\) |
| `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `hits` | array | Array of matching records |
| ↳ `objectID` | string | Unique identifier of the record |
| ↳ `_highlightResult` | object | Highlighted attributes matching the query. Each attribute has value, matchLevel \(none, partial, full\), and matchedWords |
| ↳ `_snippetResult` | object | Snippeted attributes matching the query. Each attribute has value and matchLevel |
| ↳ `_rankingInfo` | object | Ranking information for the hit. Only present when getRankingInfo is enabled |
| ↳ `nbTypos` | number | Number of typos in the query match |
| ↳ `firstMatchedWord` | number | Position of the first matched word |
| ↳ `geoDistance` | number | Distance in meters for geo-search results |
| ↳ `nbExactWords` | number | Number of exactly matched words |
| ↳ `userScore` | number | Custom ranking score |
| ↳ `words` | number | Number of matched words |
| `nbHits` | number | Total number of matching hits |
| `page` | number | Current page number \(zero-based\) |
| `nbPages` | number | Total number of pages available |
| `hitsPerPage` | number | Number of hits per page \(1-1000, default 20\) |
| `processingTimeMS` | number | Server-side processing time in milliseconds |
| `query` | string | The search query that was executed |
| `parsedQuery` | string | The query string after normalization and stop word removal |
| `facets` | object | Facet counts keyed by facet name, each containing value-count pairs |
| `facets_stats` | object | Statistics \(min, max, avg, sum\) for numeric facets |
| `exhaustive` | object | Exhaustiveness flags for facetsCount, facetValues, nbHits, rulesMatch, and typo |
### `algolia_add_record`
Add or replace a record in an Algolia index
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key |
| `indexName` | string | Yes | Name of the Algolia index |
| `objectID` | string | No | Object ID for the record \(auto-generated if not provided\) |
| `record` | json | Yes | JSON object representing the record to add |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the indexing operation |
| `objectID` | string | The object ID of the added or replaced record |
| `createdAt` | string | Timestamp when the record was created \(only present when objectID is auto-generated\) |
| `updatedAt` | string | Timestamp when the record was updated \(only present when replacing an existing record\) |
### `algolia_get_record`
Get a record by objectID from an Algolia index
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia API Key |
| `indexName` | string | Yes | Name of the Algolia index |
| `objectID` | string | Yes | The objectID of the record to retrieve |
| `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `objectID` | string | The objectID of the retrieved record |
| `record` | object | The record data \(all attributes\) |
### `algolia_get_records`
Retrieve multiple records by objectID from one or more Algolia indices
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia API Key |
| `indexName` | string | Yes | Default index name for all requests |
| `requests` | json | Yes | Array of objects specifying records to retrieve. Each must have "objectID" and optionally "indexName" and "attributesToRetrieve". |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | array | Array of retrieved records \(null entries for records not found\) |
| ↳ `objectID` | string | Unique identifier of the record |
### `algolia_partial_update_record`
Partially update a record in an Algolia index without replacing it entirely
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key |
| `indexName` | string | Yes | Name of the Algolia index |
| `objectID` | string | Yes | The objectID of the record to update |
| `attributes` | json | Yes | JSON object with attributes to update. Supports built-in operations like \{"stock": \{"_operation": "Decrement", "value": 1\}\} |
| `createIfNotExists` | boolean | No | Whether to create the record if it does not exist \(default: true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the update operation |
| `objectID` | string | The objectID of the updated record |
| `updatedAt` | string | Timestamp when the record was updated |
### `algolia_delete_record`
Delete a record by objectID from an Algolia index
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key |
| `indexName` | string | Yes | Name of the Algolia index |
| `objectID` | string | Yes | The objectID of the record to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the deletion |
| `deletedAt` | string | Timestamp when the record was deleted |
### `algolia_browse_records`
Browse and iterate over all records in an Algolia index using cursor pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia API Key \(must have browse ACL\) |
| `indexName` | string | Yes | Name of the Algolia index to browse |
| `query` | string | No | Search query to filter browsed records |
| `filters` | string | No | Filter string to narrow down results |
| `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve |
| `hitsPerPage` | number | No | Number of hits per page \(default: 1000, max: 1000\) |
| `cursor` | string | No | Cursor from a previous browse response for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `hits` | array | Array of records from the index \(up to 1000 per request\) |
| ↳ `objectID` | string | Unique identifier of the record |
| `cursor` | string | Opaque cursor string for retrieving the next page of results. Absent when no more results exist. |
| `nbHits` | number | Total number of records matching the browse criteria |
| `page` | number | Current page number \(zero-based\) |
| `nbPages` | number | Total number of pages available |
| `hitsPerPage` | number | Number of hits per page \(1-1000, default 1000 for browse\) |
| `processingTimeMS` | number | Server-side processing time in milliseconds |
### `algolia_batch_operations`
Perform batch add, update, partial update, or delete operations on records in an Algolia index
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key |
| `indexName` | string | Yes | Name of the Algolia index |
| `requests` | json | Yes | Array of batch operations. Each item has "action" \(addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject\) and "body" \(the record data, must include objectID for update/delete\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the batch operation |
| `objectIDs` | array | Array of object IDs affected by the batch operation |
### `algolia_list_indices`
List all indices in an Algolia application
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia API Key |
| `page` | number | No | Page number for paginating indices \(default: not paginated\) |
| `hitsPerPage` | number | No | Number of indices per page \(default: 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `indices` | array | List of indices in the application |
| ↳ `name` | string | Name of the index |
| ↳ `entries` | number | Number of records in the index |
| ↳ `dataSize` | number | Size of the index data in bytes |
| ↳ `fileSize` | number | Size of the index files in bytes |
| ↳ `lastBuildTimeS` | number | Last build duration in seconds |
| ↳ `numberOfPendingTasks` | number | Number of pending indexing tasks |
| ↳ `pendingTask` | boolean | Whether the index has pending tasks |
| ↳ `createdAt` | string | Timestamp when the index was created |
| ↳ `updatedAt` | string | Timestamp when the index was last updated |
| ↳ `primary` | string | Name of the primary index \(if this is a replica\) |
| ↳ `replicas` | array | List of replica index names |
| ↳ `virtual` | boolean | Whether the index is a virtual replica |
| `nbPages` | number | Total number of pages of indices |
### `algolia_get_settings`
Retrieve the settings of an Algolia index
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia API Key |
| `indexName` | string | Yes | Name of the Algolia index |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `searchableAttributes` | array | List of searchable attributes |
| `attributesForFaceting` | array | Attributes used for faceting |
| `ranking` | array | Ranking criteria |
| `customRanking` | array | Custom ranking criteria |
| `replicas` | array | List of replica index names |
| `hitsPerPage` | number | Default number of hits per page |
| `maxValuesPerFacet` | number | Maximum number of facet values returned |
| `highlightPreTag` | string | HTML tag inserted before highlighted parts |
| `highlightPostTag` | string | HTML tag inserted after highlighted parts |
| `paginationLimitedTo` | number | Maximum number of hits accessible via pagination |
### `algolia_update_settings`
Update the settings of an Algolia index
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key \(must have editSettings ACL\) |
| `indexName` | string | Yes | Name of the Algolia index |
| `settings` | json | Yes | JSON object with settings to update \(e.g., \{"searchableAttributes": \["name", "description"\], "customRanking": \["desc\(popularity\)"\]\}\) |
| `forwardToReplicas` | boolean | No | Whether to apply changes to replica indices \(default: false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the settings update |
| `updatedAt` | string | Timestamp when the settings were updated |
### `algolia_delete_index`
Delete an entire Algolia index and all its records
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key \(must have deleteIndex ACL\) |
| `indexName` | string | Yes | Name of the Algolia index to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the index deletion |
| `deletedAt` | string | Timestamp when the index was deleted |
### `algolia_copy_move_index`
Copy or move an Algolia index to a new destination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key |
| `indexName` | string | Yes | Name of the source index |
| `operation` | string | Yes | Operation to perform: "copy" or "move" |
| `destination` | string | Yes | Name of the destination index |
| `scope` | json | No | Array of scopes to copy \(only for "copy" operation\): \["settings", "synonyms", "rules"\]. Omit to copy everything including records. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the copy/move operation |
| `updatedAt` | string | Timestamp when the operation was performed |
### `algolia_clear_records`
Clear all records from an Algolia index while keeping settings, synonyms, and rules
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key \(must have deleteIndex ACL\) |
| `indexName` | string | Yes | Name of the Algolia index to clear |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the clear operation |
| `updatedAt` | string | Timestamp when the records were cleared |
### `algolia_delete_by_filter`
Delete all records matching a filter from an Algolia index
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key \(must have deleteIndex ACL\) |
| `indexName` | string | Yes | Name of the Algolia index |
| `filters` | string | No | Filter expression to match records for deletion \(e.g., "category:outdated"\) |
| `facetFilters` | json | No | Array of facet filters \(e.g., \["brand:Acme"\]\) |
| `numericFilters` | json | No | Array of numeric filters \(e.g., \["price &gt; 100"\]\) |
| `tagFilters` | json | No | Array of tag filters using the _tags attribute \(e.g., \["published"\]\) |
| `aroundLatLng` | string | No | Coordinates for geo-search filter \(e.g., "40.71,-74.01"\) |
| `aroundRadius` | number | No | Maximum radius in meters for geo-search, or "all" for unlimited |
| `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search filter |
| `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search filter |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskID` | number | Algolia task ID for tracking the delete-by-filter operation |
| `updatedAt` | string | Timestamp when the operation was performed |

View File

@@ -0,0 +1,569 @@
---
title: Cloudflare
description: Manage DNS, domains, certificates, and cache
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="cloudflare"
color="#F5F6FA"
/>
{/* MANUAL-CONTENT-START:intro */}
[Cloudflare](https://cloudflare.com/) is a global cloud platform that provides content delivery, domain management, cybersecurity, and performance services for websites and applications.
In Sim, the Cloudflare integration empowers your agents to automate the management of DNS records, SSL/TLS certificates, domains (zones), cache, zone settings, and more through easy-to-use API tools. Agents can securely list and edit domains, update DNS records, monitor analytics, and manage security and performance—all as part of your automated workflows.
With Cloudflare, you can:
- **Manage DNS and Domains**: List all your domains (zones), view zone details, and fully control DNS records from your automated agent workflows.
- **Handle SSL/TLS Certificates and Settings**: Issue, renew, or list certificates and adjust security and performance settings for your sites.
- **Purge Cache and Analyze Traffic**: Instantly purge edge cache and review real-time DNS analytics directly within your Sim agent processes.
- **Automate Security and Operations**: Use agents to programmatically manage zones, update settings, and streamline repetitive Cloudflare tasks.
This integration enables streamlined, secure management of your site's infrastructure from within Sim. Your agents can integrate Cloudflare operations directly into processes—keeping DNS records up-to-date, responding to security events, improving site performance, and automating large-scale site and account administration.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Cloudflare into the workflow. Manage zones (domains), DNS records, SSL/TLS certificates, zone settings, DNS analytics, and cache purging via the Cloudflare API.
## Tools
### `cloudflare_list_zones`
Lists all zones (domains) in the Cloudflare account.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | No | Filter zones by domain name \(e.g., "example.com"\) |
| `status` | string | No | Filter by zone status: "initializing", "pending", "active", or "moved" |
| `page` | number | No | Page number for pagination \(default: 1\) |
| `per_page` | number | No | Number of zones per page \(default: 20, max: 50\) |
| `accountId` | string | No | Filter zones by account ID |
| `order` | string | No | Sort field \(name, status, account.id, account.name\) |
| `direction` | string | No | Sort direction \(asc, desc\) |
| `match` | string | No | Match logic for filters \(any, all\). Default: all |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `zones` | array | List of zones/domains |
| ↳ `id` | string | Zone ID |
| ↳ `name` | string | Domain name |
| ↳ `status` | string | Zone status \(initializing, pending, active, moved\) |
| ↳ `paused` | boolean | Whether the zone is paused |
| ↳ `type` | string | Zone type \(full, partial, or secondary\) |
| ↳ `name_servers` | array | Assigned Cloudflare name servers |
| ↳ `original_name_servers` | array | Original name servers before moving to Cloudflare |
| ↳ `created_on` | string | ISO 8601 date when the zone was created |
| ↳ `modified_on` | string | ISO 8601 date when the zone was last modified |
| ↳ `activated_on` | string | ISO 8601 date when the zone was activated |
| ↳ `development_mode` | number | Seconds remaining in development mode \(0 = off\) |
| ↳ `plan` | object | Zone plan information |
| ↳ `id` | string | Plan identifier |
| ↳ `name` | string | Plan name |
| ↳ `price` | number | Plan price |
| ↳ `is_subscribed` | boolean | Whether the zone is subscribed to the plan |
| ↳ `frequency` | string | Plan billing frequency |
| ↳ `currency` | string | Plan currency |
| ↳ `legacy_id` | string | Legacy plan identifier |
| ↳ `account` | object | Account the zone belongs to |
| ↳ `id` | string | Account identifier |
| ↳ `name` | string | Account name |
| ↳ `owner` | object | Zone owner information |
| ↳ `id` | string | Owner identifier |
| ↳ `name` | string | Owner name |
| ↳ `type` | string | Owner type |
| ↳ `meta` | object | Zone metadata |
| ↳ `cdn_only` | boolean | Whether the zone is CDN only |
| ↳ `custom_certificate_quota` | number | Custom certificate quota |
| ↳ `dns_only` | boolean | Whether the zone is DNS only |
| ↳ `foundation_dns` | boolean | Whether foundation DNS is enabled |
| ↳ `page_rule_quota` | number | Page rule quota |
| ↳ `phishing_detected` | boolean | Whether phishing was detected |
| ↳ `step` | number | Current setup step |
| ↳ `vanity_name_servers` | array | Custom vanity name servers |
| ↳ `permissions` | array | User permissions for the zone |
| `total_count` | number | Total number of zones matching the query |
### `cloudflare_get_zone`
Gets details for a specific zone (domain) by its ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to retrieve details for |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Zone ID |
| `name` | string | Domain name |
| `status` | string | Zone status \(initializing, pending, active, moved\) |
| `paused` | boolean | Whether the zone is paused |
| `type` | string | Zone type \(full, partial, or secondary\) |
| `name_servers` | array | Assigned Cloudflare name servers |
| `original_name_servers` | array | Original name servers before moving to Cloudflare |
| `created_on` | string | ISO 8601 date when the zone was created |
| `modified_on` | string | ISO 8601 date when the zone was last modified |
| `activated_on` | string | ISO 8601 date when the zone was activated |
| `development_mode` | number | Seconds remaining in development mode \(0 = off\) |
| `plan` | object | Zone plan information |
| ↳ `id` | string | Plan identifier |
| ↳ `name` | string | Plan name |
| ↳ `price` | number | Plan price |
| ↳ `is_subscribed` | boolean | Whether the zone is subscribed to the plan |
| ↳ `frequency` | string | Plan billing frequency |
| ↳ `currency` | string | Plan currency |
| ↳ `legacy_id` | string | Legacy plan identifier |
| `account` | object | Account the zone belongs to |
| ↳ `id` | string | Account identifier |
| ↳ `name` | string | Account name |
| `owner` | object | Zone owner information |
| ↳ `id` | string | Owner identifier |
| ↳ `name` | string | Owner name |
| ↳ `type` | string | Owner type |
| `meta` | object | Zone metadata |
| ↳ `cdn_only` | boolean | Whether the zone is CDN only |
| ↳ `custom_certificate_quota` | number | Custom certificate quota |
| ↳ `dns_only` | boolean | Whether the zone is DNS only |
| ↳ `foundation_dns` | boolean | Whether foundation DNS is enabled |
| ↳ `page_rule_quota` | number | Page rule quota |
| ↳ `phishing_detected` | boolean | Whether phishing was detected |
| ↳ `step` | number | Current setup step |
| `vanity_name_servers` | array | Custom vanity name servers |
| `permissions` | array | User permissions for the zone |
### `cloudflare_create_zone`
Adds a new zone (domain) to the Cloudflare account.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | Yes | The domain name to add \(e.g., "example.com"\) |
| `accountId` | string | Yes | The Cloudflare account ID |
| `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\) |
| `jump_start` | boolean | No | Automatically attempt to fetch existing DNS records when creating the zone |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Created zone ID |
| `name` | string | Domain name |
| `status` | string | Zone status \(initializing, pending, active, moved\) |
| `paused` | boolean | Whether the zone is paused |
| `type` | string | Zone type \(full, partial, or secondary\) |
| `name_servers` | array | Assigned Cloudflare name servers |
| `original_name_servers` | array | Original name servers before moving to Cloudflare |
| `created_on` | string | ISO 8601 date when the zone was created |
| `modified_on` | string | ISO 8601 date when the zone was last modified |
| `activated_on` | string | ISO 8601 date when the zone was activated |
| `development_mode` | number | Seconds remaining in development mode \(0 = off\) |
| `plan` | object | Zone plan information |
| ↳ `id` | string | Plan identifier |
| ↳ `name` | string | Plan name |
| ↳ `price` | number | Plan price |
| ↳ `is_subscribed` | boolean | Whether the zone is subscribed to the plan |
| ↳ `frequency` | string | Plan billing frequency |
| ↳ `currency` | string | Plan currency |
| ↳ `legacy_id` | string | Legacy plan identifier |
| `account` | object | Account the zone belongs to |
| ↳ `id` | string | Account identifier |
| ↳ `name` | string | Account name |
| `owner` | object | Zone owner information |
| ↳ `id` | string | Owner identifier |
| ↳ `name` | string | Owner name |
| ↳ `type` | string | Owner type |
| `meta` | object | Zone metadata |
| ↳ `cdn_only` | boolean | Whether the zone is CDN only |
| ↳ `custom_certificate_quota` | number | Custom certificate quota |
| ↳ `dns_only` | boolean | Whether the zone is DNS only |
| ↳ `foundation_dns` | boolean | Whether foundation DNS is enabled |
| ↳ `page_rule_quota` | number | Page rule quota |
| ↳ `phishing_detected` | boolean | Whether phishing was detected |
| ↳ `step` | number | Current setup step |
| `vanity_name_servers` | array | Custom vanity name servers |
| `permissions` | array | User permissions for the zone |
### `cloudflare_delete_zone`
Deletes a zone (domain) from the Cloudflare account.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to delete |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Deleted zone ID |
### `cloudflare_list_dns_records`
Lists DNS records for a specific zone.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to list DNS records for |
| `type` | string | No | Filter by record type \(e.g., "A", "AAAA", "CNAME", "MX", "TXT"\) |
| `name` | string | No | Filter by record name \(exact match\) |
| `content` | string | No | Filter by record content \(exact match\) |
| `page` | number | No | Page number for pagination \(default: 1\) |
| `per_page` | number | No | Number of records per page \(default: 100, max: 5000000\) |
| `direction` | string | No | Sort direction \(asc or desc\) |
| `match` | string | No | Match logic for filters: any or all \(default: all\) |
| `order` | string | No | Sort field \(type, name, content, ttl, proxied\) |
| `proxied` | boolean | No | Filter by proxy status |
| `search` | string | No | Free-text search across record name, content, and value |
| `tag` | string | No | Filter by tags \(comma-separated\) |
| `tag_match` | string | No | Tag filter match logic: any or all |
| `commentFilter` | string | No | Filter records by comment content \(substring match\) |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `records` | array | List of DNS records |
| ↳ `id` | string | Unique identifier for the DNS record |
| ↳ `zone_id` | string | The ID of the zone the record belongs to |
| ↳ `zone_name` | string | The name of the zone |
| ↳ `type` | string | Record type \(A, AAAA, CNAME, MX, TXT, etc.\) |
| ↳ `name` | string | Record name \(e.g., example.com\) |
| ↳ `content` | string | Record content \(e.g., IP address\) |
| ↳ `proxiable` | boolean | Whether the record can be proxied |
| ↳ `proxied` | boolean | Whether Cloudflare proxy is enabled |
| ↳ `ttl` | number | TTL in seconds \(1 = automatic\) |
| ↳ `locked` | boolean | Whether the record is locked |
| ↳ `priority` | number | MX/SRV record priority |
| ↳ `comment` | string | Comment associated with the record |
| ↳ `tags` | array | Tags associated with the record |
| ↳ `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified |
| ↳ `tags_modified_on` | string | ISO 8601 timestamp when tags were last modified |
| ↳ `meta` | object | Record metadata |
| ↳ `source` | string | Source of the DNS record |
| ↳ `created_on` | string | ISO 8601 timestamp when the record was created |
| ↳ `modified_on` | string | ISO 8601 timestamp when the record was last modified |
| `total_count` | number | Total number of DNS records matching the query |
### `cloudflare_create_dns_record`
Creates a new DNS record for a zone.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to create the DNS record in |
| `type` | string | Yes | DNS record type \(e.g., "A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"\) |
| `name` | string | Yes | DNS record name \(e.g., "example.com" or "subdomain.example.com"\) |
| `content` | string | Yes | DNS record content \(e.g., IP address for A records, target for CNAME\) |
| `ttl` | number | No | Time to live in seconds \(1 = automatic, default: 1\) |
| `proxied` | boolean | No | Whether to enable Cloudflare proxy \(default: false\) |
| `priority` | number | No | Priority for MX and SRV records |
| `comment` | string | No | Comment for the DNS record |
| `tags` | string | No | Comma-separated tags for the DNS record |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique identifier for the created DNS record |
| `zone_id` | string | The ID of the zone the record belongs to |
| `zone_name` | string | The name of the zone |
| `type` | string | DNS record type \(A, AAAA, CNAME, MX, TXT, etc.\) |
| `name` | string | DNS record hostname |
| `content` | string | DNS record value \(e.g., IP address, target hostname\) |
| `proxiable` | boolean | Whether the record can be proxied through Cloudflare |
| `proxied` | boolean | Whether Cloudflare proxy is enabled |
| `ttl` | number | Time to live in seconds \(1 = automatic\) |
| `locked` | boolean | Whether the record is locked |
| `priority` | number | Priority for MX and SRV records |
| `comment` | string | Comment associated with the record |
| `tags` | array | Tags associated with the record |
| `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified |
| `tags_modified_on` | string | ISO 8601 timestamp when tags were last modified |
| `meta` | object | Record metadata |
| ↳ `source` | string | Source of the DNS record |
| `created_on` | string | ISO 8601 timestamp when the record was created |
| `modified_on` | string | ISO 8601 timestamp when the record was last modified |
### `cloudflare_update_dns_record`
Updates an existing DNS record for a zone.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID containing the DNS record |
| `recordId` | string | Yes | The DNS record ID to update |
| `type` | string | No | DNS record type \(e.g., "A", "AAAA", "CNAME", "MX", "TXT"\) |
| `name` | string | No | DNS record name |
| `content` | string | No | DNS record content \(e.g., IP address\) |
| `ttl` | number | No | Time to live in seconds \(1 = automatic\) |
| `proxied` | boolean | No | Whether to enable Cloudflare proxy |
| `priority` | number | No | Priority for MX and SRV records |
| `comment` | string | No | Comment for the DNS record |
| `tags` | string | No | Comma-separated tags for the DNS record |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique identifier for the updated DNS record |
| `zone_id` | string | The ID of the zone the record belongs to |
| `zone_name` | string | The name of the zone |
| `type` | string | DNS record type \(A, AAAA, CNAME, MX, TXT, etc.\) |
| `name` | string | DNS record hostname |
| `content` | string | DNS record value \(e.g., IP address, target hostname\) |
| `proxiable` | boolean | Whether the record can be proxied through Cloudflare |
| `proxied` | boolean | Whether Cloudflare proxy is enabled |
| `ttl` | number | Time to live in seconds \(1 = automatic\) |
| `locked` | boolean | Whether the record is locked |
| `priority` | number | Priority for MX and SRV records |
| `comment` | string | Comment associated with the record |
| `tags` | array | Tags associated with the record |
| `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified |
| `tags_modified_on` | string | ISO 8601 timestamp when tags were last modified |
| `meta` | object | Record metadata |
| ↳ `source` | string | Source of the DNS record |
| `created_on` | string | ISO 8601 timestamp when the record was created |
| `modified_on` | string | ISO 8601 timestamp when the record was last modified |
### `cloudflare_delete_dns_record`
Deletes a DNS record from a zone.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID containing the DNS record |
| `recordId` | string | Yes | The DNS record ID to delete |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Deleted record ID |
### `cloudflare_list_certificates`
Lists SSL/TLS certificate packs for a zone.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to list certificates for |
| `status` | string | No | Filter certificate packs by status \(e.g., "all", "active", "pending"\) |
| `page` | number | No | Page number of paginated results \(default: 1\) |
| `per_page` | number | No | Number of certificate packs per page \(default: 20, min: 5, max: 50\) |
| `deploy` | string | No | Filter by deployment environment: "staging" or "production" |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `certificates` | array | List of SSL/TLS certificate packs |
| ↳ `id` | string | Certificate pack ID |
| ↳ `type` | string | Certificate type \(e.g., "universal", "advanced"\) |
| ↳ `hosts` | array | Hostnames covered by this certificate pack |
| ↳ `primary_certificate` | string | ID of the primary certificate in the pack |
| ↳ `status` | string | Certificate pack status \(e.g., "active", "pending"\) |
| ↳ `certificates` | array | Individual certificates within the pack |
| ↳ `id` | string | Certificate ID |
| ↳ `hosts` | array | Hostnames covered by this certificate |
| ↳ `issuer` | string | Certificate issuer |
| ↳ `signature` | string | Signature algorithm \(e.g., "ECDSAWithSHA256"\) |
| ↳ `status` | string | Certificate status |
| ↳ `bundle_method` | string | Bundle method \(e.g., "ubiquitous"\) |
| ↳ `zone_id` | string | Zone ID the certificate belongs to |
| ↳ `uploaded_on` | string | Upload date \(ISO 8601\) |
| ↳ `modified_on` | string | Last modified date \(ISO 8601\) |
| ↳ `expires_on` | string | Expiration date \(ISO 8601\) |
| ↳ `priority` | number | Certificate priority order |
| ↳ `geo_restrictions` | object | Geographic restrictions for the certificate |
| ↳ `label` | string | Geographic restriction label |
| ↳ `cloudflare_branding` | boolean | Whether Cloudflare branding is enabled on the certificate |
| ↳ `validation_method` | string | Validation method \(e.g., "txt", "http", "cname"\) |
| ↳ `validity_days` | number | Validity period in days |
| ↳ `certificate_authority` | string | Certificate authority \(e.g., "lets_encrypt", "google"\) |
| ↳ `validation_errors` | array | Validation issues for the certificate pack |
| ↳ `message` | string | Validation error message |
| ↳ `validation_records` | array | Validation records for the certificate pack |
| ↳ `cname` | string | CNAME record name |
| ↳ `cname_target` | string | CNAME record target |
| ↳ `emails` | array | Email addresses for validation |
| ↳ `http_body` | string | HTTP validation body content |
| ↳ `http_url` | string | HTTP validation URL |
| ↳ `status` | string | Validation record status |
| ↳ `txt_name` | string | TXT record name |
| ↳ `txt_value` | string | TXT record value |
| ↳ `dcv_delegation_records` | array | Domain control validation delegation records |
| ↳ `cname` | string | CNAME record name |
| ↳ `cname_target` | string | CNAME record target |
| ↳ `emails` | array | Email addresses for validation |
| ↳ `http_body` | string | HTTP validation body content |
| ↳ `http_url` | string | HTTP validation URL |
| ↳ `status` | string | Delegation record status |
| ↳ `txt_name` | string | TXT record name |
| ↳ `txt_value` | string | TXT record value |
| `total_count` | number | Total number of certificate packs |
### `cloudflare_get_zone_settings`
Gets all settings for a zone including SSL mode, minification, caching level, and security settings.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to get settings for |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `settings` | array | List of zone settings |
| ↳ `id` | string | Setting identifier \(e.g., ssl, minify, cache_level, security_level, always_use_https\) |
| ↳ `value` | string | Setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified \(e.g., \ |
| ↳ `editable` | boolean | Whether the setting can be modified for the current zone plan |
| ↳ `modified_on` | string | ISO 8601 timestamp when the setting was last modified |
| ↳ `time_remaining` | number | Seconds remaining until the setting can be modified again \(only present for rate-limited settings\) |
### `cloudflare_update_zone_setting`
Updates a specific zone setting such as SSL mode, security level, cache level, minification, or other configuration.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to update settings for |
| `settingId` | string | Yes | Setting to update \(e.g., "ssl", "security_level", "cache_level", "minify", "always_use_https", "browser_cache_ttl", "http3", "min_tls_version", "ciphers"\) |
| `value` | string | Yes | New value for the setting as a string or JSON string for complex values \(e.g., "full" for SSL, "medium" for security_level, "aggressive" for cache_level, \'\{"css":"on","html":"on","js":"on"\}\' for minify, \'\["ECDHE-RSA-AES128-GCM-SHA256"\]\' for ciphers\) |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Setting identifier \(e.g., ssl, minify, cache_level\) |
| `value` | string | Updated setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified. |
| `editable` | boolean | Whether the setting can be modified for the current zone plan |
| `modified_on` | string | ISO 8601 timestamp when the setting was last modified |
| `time_remaining` | number | Seconds remaining until the setting can be modified again \(only present for rate-limited settings\) |
### `cloudflare_dns_analytics`
Gets DNS analytics report for a zone including query counts and trends.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to get DNS analytics for |
| `since` | string | No | Start date for analytics \(ISO 8601, e.g., "2024-01-01T00:00:00Z"\) or relative \(e.g., "-6h"\) |
| `until` | string | No | End date for analytics \(ISO 8601, e.g., "2024-01-31T23:59:59Z"\) or relative \(e.g., "now"\) |
| `metrics` | string | Yes | Comma-separated metrics to retrieve \(e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"\) |
| `dimensions` | string | No | Comma-separated dimensions to group by \(e.g., "queryName,queryType,responseCode,responseCached,coloName,origin,dayOfWeek,tcp,ipVersion,querySizeBucket,responseSizeBucket"\) |
| `filters` | string | No | Filters to apply to the data \(e.g., "queryType==A"\) |
| `sort` | string | No | Sort order for the result set. Fields must be included in metrics or dimensions \(e.g., "+queryCount" or "-responseTimeAvg"\) |
| `limit` | number | No | Maximum number of results to return |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totals` | object | Aggregate DNS analytics totals for the entire queried period |
| ↳ `queryCount` | number | Total number of DNS queries |
| ↳ `uncachedCount` | number | Number of uncached DNS queries |
| ↳ `staleCount` | number | Number of stale DNS queries |
| ↳ `responseTimeAvg` | number | Average response time in milliseconds |
| ↳ `responseTimeMedian` | number | Median response time in milliseconds |
| ↳ `responseTime90th` | number | 90th percentile response time in milliseconds |
| ↳ `responseTime99th` | number | 99th percentile response time in milliseconds |
| `min` | object | Minimum values across the analytics period |
| ↳ `queryCount` | number | Minimum number of DNS queries |
| ↳ `uncachedCount` | number | Minimum number of uncached DNS queries |
| ↳ `staleCount` | number | Minimum number of stale DNS queries |
| ↳ `responseTimeAvg` | number | Minimum average response time in milliseconds |
| ↳ `responseTimeMedian` | number | Minimum median response time in milliseconds |
| ↳ `responseTime90th` | number | Minimum 90th percentile response time in milliseconds |
| ↳ `responseTime99th` | number | Minimum 99th percentile response time in milliseconds |
| `max` | object | Maximum values across the analytics period |
| ↳ `queryCount` | number | Maximum number of DNS queries |
| ↳ `uncachedCount` | number | Maximum number of uncached DNS queries |
| ↳ `staleCount` | number | Maximum number of stale DNS queries |
| ↳ `responseTimeAvg` | number | Maximum average response time in milliseconds |
| ↳ `responseTimeMedian` | number | Maximum median response time in milliseconds |
| ↳ `responseTime90th` | number | Maximum 90th percentile response time in milliseconds |
| ↳ `responseTime99th` | number | Maximum 99th percentile response time in milliseconds |
| `data` | array | Raw analytics data rows returned by the Cloudflare DNS analytics report |
| ↳ `dimensions` | array | Dimension values for this data row, parallel to the requested dimensions list |
| ↳ `metrics` | array | Metric values for this data row, parallel to the requested metrics list |
| `data_lag` | number | Processing lag in seconds before analytics data becomes available |
| `rows` | number | Total number of rows in the result set |
| `query` | object | Echo of the query parameters sent to the API |
| ↳ `since` | string | Start date of the analytics query |
| ↳ `until` | string | End date of the analytics query |
| ↳ `metrics` | array | Metrics requested in the query |
| ↳ `dimensions` | array | Dimensions requested in the query |
| ↳ `filters` | string | Filters applied to the query |
| ↳ `sort` | array | Sort order applied to the query |
| ↳ `limit` | number | Maximum number of results requested |
### `cloudflare_purge_cache`
Purges cached content for a zone. Can purge everything or specific files/tags/hosts/prefixes.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `zoneId` | string | Yes | The zone ID to purge cache for |
| `purge_everything` | boolean | No | Set to true to purge all cached content. Mutually exclusive with files, tags, hosts, and prefixes |
| `files` | string | No | Comma-separated list of URLs to purge from cache |
| `tags` | string | No | Comma-separated list of cache tags to purge \(Enterprise only\) |
| `hosts` | string | No | Comma-separated list of hostnames to purge \(Enterprise only\) |
| `prefixes` | string | No | Comma-separated list of URL prefixes to purge \(Enterprise only\) |
| `apiKey` | string | Yes | Cloudflare API Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Purge request identifier returned by Cloudflare |

View File

@@ -5,6 +5,7 @@
"ahrefs",
"airtable",
"airweave",
"algolia",
"apify",
"apollo",
"arxiv",
@@ -15,6 +16,7 @@
"circleback",
"clay",
"clerk",
"cloudflare",
"confluence",
"cursor",
"datadog",
@@ -68,6 +70,7 @@
"mailgun",
"mem0",
"memory",
"microsoft_dataverse",
"microsoft_excel",
"microsoft_planner",
"microsoft_teams",
@@ -91,8 +94,10 @@
"qdrant",
"rds",
"reddit",
"redis",
"reducto",
"resend",
"revenuecat",
"s3",
"salesforce",
"search",
@@ -112,6 +117,7 @@
"stripe",
"stt",
"supabase",
"table",
"tavily",
"telegram",
"textract",
@@ -122,6 +128,7 @@
"twilio_sms",
"twilio_voice",
"typeform",
"upstash",
"vercel",
"video_generator",
"vision",

View File

@@ -0,0 +1,426 @@
---
title: Microsoft Dataverse
description: Manage records in Microsoft Dataverse tables
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="microsoft_dataverse"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Microsoft Dataverse](https://learn.microsoft.com/en-us/power-apps/maker/data-platform/data-platform-intro) is a powerful cloud data platform for securely storing, managing, and interacting with structured business data. The Microsoft Dataverse integration enables you to programmatically create, read, update, delete, and link records in Dataverse tables as part of your workflow and automation needs.
With Microsoft Dataverse integration, you can:
- **List and query records:** Access lists of records or query with advanced filters to find the data you need from any Dataverse table.
- **Create and update records:** Add new records or update existing ones in any table for use across Power Platform, Dynamics 365, and custom apps.
- **Delete and manage records:** Remove records as part of data lifecycle management directly from your automation flows.
- **Associate and disassociate records:** Link related items together or remove associations using entity relationships and navigation properties—essential for reflecting complex business processes.
- **Work with any Dataverse environment:** Connect to your organizations environments, including production, sandbox, or Dynamics 365 tenants, for maximum flexibility.
- **Integrate with Power Platform and Dynamics 365:** Automate tasks ranging from sales and marketing data updates to custom app workflows—all powered by Dataverse's security and governance.
The Dataverse integration empowers solution builders and business users to automate business processes, maintain accurate and up-to-date information, create system integrations, trigger actions, and drive insights—all with robust security and governance.
Connect Microsoft Dataverse to your automations to unlock sophisticated data management, orchestration, and business logic across your apps, teams, and cloud services.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Microsoft Dataverse into your workflow. Create, read, update, delete, upsert, associate, query, search, and execute actions and functions against Dataverse tables using the Web API. Supports bulk operations, FetchXML, file uploads, and relevance search. Works with Dynamics 365, Power Platform, and custom Dataverse environments.
## Tools
### `microsoft_dataverse_associate`
Associate two records in Microsoft Dataverse via a navigation property. Creates a relationship between a source record and a target record. Supports both collection-valued (POST) and single-valued (PUT) navigation properties.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Source entity set name \(e.g., accounts\) |
| `recordId` | string | Yes | Source record GUID |
| `navigationProperty` | string | Yes | Navigation property name \(e.g., contact_customer_accounts for collection-valued, or parentcustomerid_account for single-valued\) |
| `targetEntitySetName` | string | Yes | Target entity set name \(e.g., contacts\) |
| `targetRecordId` | string | Yes | Target record GUID to associate |
| `navigationType` | string | No | Type of navigation property: "collection" \(default, uses POST\) or "single" \(uses PUT for lookup fields\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the association was created successfully |
| `entitySetName` | string | Source entity set name used in the association |
| `recordId` | string | Source record GUID that was associated |
| `navigationProperty` | string | Navigation property used for the association |
| `targetEntitySetName` | string | Target entity set name used in the association |
| `targetRecordId` | string | Target record GUID that was associated |
### `microsoft_dataverse_create_multiple`
Create multiple records of the same table type in a single request. Each record in the Targets array must include an @odata.type annotation. Recommended batch size: 100-1000 records for standard tables.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `entityLogicalName` | string | Yes | Table logical name for @odata.type annotation \(e.g., account, contact\). Used to set Microsoft.Dynamics.CRM.\{entityLogicalName\} on each record. |
| `records` | object | Yes | Array of record objects to create. Each record should contain column logical names as keys. The @odata.type annotation is added automatically. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ids` | array | Array of GUIDs for the created records |
| `count` | number | Number of records created |
| `success` | boolean | Whether all records were created successfully |
### `microsoft_dataverse_create_record`
Create a new record in a Microsoft Dataverse table. Requires the entity set name (plural table name) and record data as a JSON object.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `data` | object | Yes | Record data as a JSON object with column names as keys |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `recordId` | string | The ID of the created record |
| `record` | object | Dataverse record object. Contains dynamic columns based on the queried table, plus OData metadata fields. |
| `success` | boolean | Whether the record was created successfully |
### `microsoft_dataverse_delete_record`
Delete a record from a Microsoft Dataverse table by its ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `recordId` | string | Yes | The unique identifier \(GUID\) of the record to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `recordId` | string | The ID of the deleted record |
| `success` | boolean | Operation success status |
### `microsoft_dataverse_disassociate`
Remove an association between two records in Microsoft Dataverse. For collection-valued navigation properties, provide the target record ID. For single-valued navigation properties, only the navigation property name is needed.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Source entity set name \(e.g., accounts\) |
| `recordId` | string | Yes | Source record GUID |
| `navigationProperty` | string | Yes | Navigation property name \(e.g., contact_customer_accounts for collection-valued, or parentcustomerid_account for single-valued\) |
| `targetRecordId` | string | No | Target record GUID \(required for collection-valued navigation properties, omit for single-valued\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the disassociation was completed successfully |
| `entitySetName` | string | Source entity set name used in the disassociation |
| `recordId` | string | Source record GUID that was disassociated |
| `navigationProperty` | string | Navigation property used for the disassociation |
| `targetRecordId` | string | Target record GUID that was disassociated |
### `microsoft_dataverse_download_file`
Download a file from a file or image column on a Dataverse record. Returns the file content as a base64-encoded string along with file metadata from response headers.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `recordId` | string | Yes | Record GUID to download the file from |
| `fileColumn` | string | Yes | File or image column logical name \(e.g., entityimage, cr_document\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `fileContent` | string | Base64-encoded file content |
| `fileName` | string | Name of the downloaded file |
| `fileSize` | number | File size in bytes |
| `mimeType` | string | MIME type of the file |
| `success` | boolean | Whether the file was downloaded successfully |
### `microsoft_dataverse_execute_action`
Execute a bound or unbound Dataverse action. Actions perform operations with side effects (e.g., Merge, GrantAccess, SendEmail, QualifyLead). For bound actions, provide the entity set name and record ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `actionName` | string | Yes | Action name \(e.g., Merge, GrantAccess, SendEmail\). Do not include the Microsoft.Dynamics.CRM. namespace prefix for unbound actions. |
| `entitySetName` | string | No | Entity set name for bound actions \(e.g., accounts\). Leave empty for unbound actions. |
| `recordId` | string | No | Record GUID for bound actions. Leave empty for unbound or collection-bound actions. |
| `parameters` | object | No | Action parameters as a JSON object. For entity references, include @odata.type annotation \(e.g., \{"Target": \{"@odata.type": "Microsoft.Dynamics.CRM.account", "accountid": "..."\}\}\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `result` | object | Action response data. Structure varies by action. Null for actions that return 204 No Content. |
| `success` | boolean | Whether the action executed successfully |
### `microsoft_dataverse_execute_function`
Execute a bound or unbound Dataverse function. Functions are read-only operations (e.g., RetrievePrincipalAccess, RetrieveTotalRecordCount, InitializeFrom). For bound functions, provide the entity set name and record ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `functionName` | string | Yes | Function name \(e.g., RetrievePrincipalAccess, RetrieveTotalRecordCount\). Do not include the Microsoft.Dynamics.CRM. namespace prefix for unbound functions. |
| `entitySetName` | string | No | Entity set name for bound functions \(e.g., systemusers\). Leave empty for unbound functions. |
| `recordId` | string | No | Record GUID for bound functions. Leave empty for unbound functions. |
| `parameters` | string | No | Function parameters as a comma-separated list of name=value pairs for the URL \(e.g., "LocalizedStandardName=\'Pacific Standard Time\ |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `result` | object | Function response data. Structure varies by function. |
| `success` | boolean | Whether the function executed successfully |
### `microsoft_dataverse_fetchxml_query`
Execute a FetchXML query against a Microsoft Dataverse table. FetchXML supports aggregation, grouping, linked-entity joins, and complex filtering beyond OData capabilities.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `fetchXml` | string | Yes | FetchXML query string. Must include &lt;fetch&gt; root element and &lt;entity&gt; child element matching the table logical name. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `records` | array | Array of Dataverse records. Each record has dynamic columns based on the table schema. |
| `count` | number | Number of records returned in the current page |
| `fetchXmlPagingCookie` | string | Paging cookie for retrieving the next page of results |
| `moreRecords` | boolean | Whether more records are available beyond the current page |
| `success` | boolean | Operation success status |
### `microsoft_dataverse_get_record`
Retrieve a single record from a Microsoft Dataverse table by its ID. Supports $select and $expand OData query options.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `recordId` | string | Yes | The unique identifier \(GUID\) of the record to retrieve |
| `select` | string | No | Comma-separated list of columns to return \(OData $select\) |
| `expand` | string | No | Navigation properties to expand \(OData $expand\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `record` | object | Dataverse record object. Contains dynamic columns based on the queried table, plus OData metadata fields. |
| `recordId` | string | The record primary key ID \(auto-detected from response\) |
| `success` | boolean | Whether the record was retrieved successfully |
### `microsoft_dataverse_list_records`
Query and list records from a Microsoft Dataverse table. Supports OData query options for filtering, selecting columns, ordering, and pagination.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `select` | string | No | Comma-separated list of columns to return \(OData $select\) |
| `filter` | string | No | OData $filter expression \(e.g., statecode eq 0\) |
| `orderBy` | string | No | OData $orderby expression \(e.g., name asc, createdon desc\) |
| `top` | number | No | Maximum number of records to return \(OData $top\) |
| `expand` | string | No | Navigation properties to expand \(OData $expand\) |
| `count` | string | No | Set to "true" to include total record count in response \(OData $count\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `records` | array | Array of Dataverse records. Each record has dynamic columns based on the table schema. |
| `count` | number | Number of records returned in the current page |
| `totalCount` | number | Total number of matching records server-side \(requires $count=true\) |
| `nextLink` | string | URL for the next page of results |
| `success` | boolean | Operation success status |
### `microsoft_dataverse_search`
Perform a full-text relevance search across Microsoft Dataverse tables. Requires Dataverse Search to be enabled on the environment. Supports simple and Lucene query syntax.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `searchTerm` | string | Yes | Search text \(1-100 chars\). Supports simple syntax: + \(AND\), \| \(OR\), - \(NOT\), * \(wildcard\), "exact phrase" |
| `entities` | string | No | JSON array of search entity configs. Each object: \{"Name":"account","SelectColumns":\["name"\],"SearchColumns":\["name"\],"Filter":"statecode eq 0"\} |
| `filter` | string | No | Global OData filter applied across all entities \(e.g., "createdon gt 2024-01-01"\) |
| `facets` | string | No | JSON array of facet specifications \(e.g., \["entityname,count:100","ownerid,count:100"\]\) |
| `top` | number | No | Maximum number of results \(default: 50, max: 100\) |
| `skip` | number | No | Number of results to skip for pagination |
| `orderBy` | string | No | JSON array of sort expressions \(e.g., \["createdon desc"\]\) |
| `searchMode` | string | No | Search mode: "any" \(default, match any term\) or "all" \(match all terms\) |
| `searchType` | string | No | Query type: "simple" \(default\) or "lucene" \(enables regex, fuzzy, proximity, boosting\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | array | Array of search result objects |
| ↳ `Id` | string | Record GUID |
| ↳ `EntityName` | string | Table logical name \(e.g., account, contact\) |
| ↳ `ObjectTypeCode` | number | Entity type code |
| ↳ `Attributes` | object | Record attributes matching the search. Keys are column logical names. |
| ↳ `Highlights` | object | Highlighted search matches. Keys are column names, values are arrays of strings with \{crmhit\}/\{/crmhit\} markers. |
| ↳ `Score` | number | Relevance score for this result |
| `totalCount` | number | Total number of matching records across all tables |
| `count` | number | Number of results returned in this page |
| `facets` | object | Facet results when facets were requested. Keys are facet names, values are arrays of facet value objects with count and value properties. |
| `success` | boolean | Operation success status |
### `microsoft_dataverse_update_multiple`
Update multiple records of the same table type in a single request. Each record must include its primary key. Only include columns that need to be changed. Recommended batch size: 100-1000 records.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `entityLogicalName` | string | Yes | Table logical name for @odata.type annotation \(e.g., account, contact\). Used to set Microsoft.Dynamics.CRM.\{entityLogicalName\} on each record. |
| `records` | object | Yes | Array of record objects to update. Each record must include its primary key \(e.g., accountid\) and only the columns being changed. The @odata.type annotation is added automatically. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether all records were updated successfully |
### `microsoft_dataverse_update_record`
Update an existing record in a Microsoft Dataverse table. Only send the columns you want to change.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `recordId` | string | Yes | The unique identifier \(GUID\) of the record to update |
| `data` | object | Yes | Record data to update as a JSON object with column names as keys |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `recordId` | string | The ID of the updated record |
| `success` | boolean | Operation success status |
### `microsoft_dataverse_upload_file`
Upload a file to a file or image column on a Dataverse record. Supports single-request upload for files up to 128 MB. The file content must be provided as a base64-encoded string.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `recordId` | string | Yes | Record GUID to upload the file to |
| `fileColumn` | string | Yes | File or image column logical name \(e.g., entityimage, cr_document\) |
| `fileName` | string | Yes | Name of the file being uploaded \(e.g., document.pdf\) |
| `file` | file | No | File to upload \(UserFile object\) |
| `fileContent` | string | No | Base64-encoded file content \(legacy\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `recordId` | string | Record GUID the file was uploaded to |
| `fileColumn` | string | File column the file was uploaded to |
| `fileName` | string | Name of the uploaded file |
| `success` | boolean | Whether the file was uploaded successfully |
### `microsoft_dataverse_upsert_record`
Create or update a record in a Microsoft Dataverse table. If a record with the given ID exists, it is updated; otherwise, a new record is created.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) |
| `recordId` | string | Yes | The unique identifier \(GUID\) of the record to upsert |
| `data` | object | Yes | Record data as a JSON object with column names as keys |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `recordId` | string | The ID of the upserted record |
| `created` | boolean | True if the record was created, false if updated |
| `record` | object | Dataverse record object. Contains dynamic columns based on the queried table, plus OData metadata fields. |
| `success` | boolean | Operation success status |
### `microsoft_dataverse_whoami`
Retrieve the current authenticated user information from Microsoft Dataverse. Useful for testing connectivity and getting the user ID, business unit ID, and organization ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `userId` | string | The authenticated user ID |
| `businessUnitId` | string | The business unit ID |
| `organizationId` | string | The organization ID |
| `success` | boolean | Operation success status |

View File

@@ -0,0 +1,452 @@
---
title: Redis
description: Key-value operations with Redis
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="redis"
color="#FF4438"
/>
{/* MANUAL-CONTENT-START:intro */}
[Redis](https://redis.io/) is an open-source, in-memory data structure store, used as a distributed key-value database, cache, and message broker. Redis supports a variety of data structures including strings, hashes, lists, sets, and more, making it highly flexible for different application scenarios.
With Redis, you can:
- **Store and retrieve key-value data instantly**: Use Redis as a fast database, cache, or session store for high performance.
- **Work with multiple data structures**: Manage not just strings, but also lists, hashes, sets, sorted sets, streams, and bitmaps.
- **Perform atomic operations**: Safely manipulate data using atomic commands and transactions.
- **Support pub/sub messaging**: Use Rediss publisher/subscriber features for real-time event handling and messaging.
- **Set automatic expiration policies**: Assign TTLs to keys for caching and time-sensitive data.
- **Scale horizontally**: Use Redis Cluster for sharding, high availability, and scalable workloads.
In Sim, the Redis integration lets your agents connect to any Redis-compatible instance to perform key-value, hash, list, and utility operations. You can build workflows that involve storing, retrieving, or manipulating data in Redis, or manage your apps cache, sessions, or real-time messaging, directly within your Sim workspace.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to any Redis instance to perform key-value, hash, list, and utility operations via a direct connection.
## Tools
### `redis_get`
Get the value of a key from Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was retrieved |
| `value` | string | The value of the key, or null if the key does not exist |
### `redis_set`
Set the value of a key in Redis with an optional expiration time in seconds.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to set |
| `value` | string | Yes | The value to store |
| `ex` | number | No | Expiration time in seconds \(optional\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was set |
| `result` | string | The result of the SET operation \(typically "OK"\) |
### `redis_delete`
Delete a key from Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was deleted |
| `deletedCount` | number | Number of keys deleted \(0 if key did not exist, 1 if deleted\) |
### `redis_keys`
List all keys matching a pattern in Redis. Avoid using on large databases in production; use the Redis Command tool with SCAN for large key spaces.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `pattern` | string | No | Pattern to match keys \(default: * for all keys\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `pattern` | string | The pattern used to match keys |
| `keys` | array | List of keys matching the pattern |
| `count` | number | Number of keys found |
### `redis_command`
Execute a raw Redis command as a JSON array (e.g. [
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `command` | string | Yes | Redis command as a JSON array \(e.g. \["SET", "key", "value"\]\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `command` | string | The command that was executed |
| `result` | json | The result of the command |
### `redis_hset`
Set a field in a hash stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The hash key |
| `field` | string | Yes | The field name within the hash |
| `value` | string | Yes | The value to set for the field |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The hash key |
| `field` | string | The field that was set |
| `result` | number | Number of fields added \(1 if new, 0 if updated\) |
### `redis_hget`
Get the value of a field in a hash stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The hash key |
| `field` | string | Yes | The field name to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The hash key |
| `field` | string | The field that was retrieved |
| `value` | string | The field value, or null if the field or key does not exist |
### `redis_hgetall`
Get all fields and values of a hash stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The hash key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The hash key |
| `fields` | object | All field-value pairs in the hash as a key-value object. Empty object if the key does not exist. |
| `fieldCount` | number | Number of fields in the hash |
### `redis_hdel`
Delete a field from a hash stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The hash key |
| `field` | string | Yes | The field name to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The hash key |
| `field` | string | The field that was deleted |
| `deleted` | number | Number of fields removed \(1 if deleted, 0 if field did not exist\) |
### `redis_incr`
Increment the integer value of a key by one in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to increment |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was incremented |
| `value` | number | The new value after increment |
### `redis_incrby`
Increment the integer value of a key by a given amount in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to increment |
| `increment` | number | Yes | Amount to increment by \(negative to decrement\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was incremented |
| `value` | number | The new value after increment |
### `redis_expire`
Set an expiration time (in seconds) on a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to set expiration on |
| `seconds` | number | Yes | Timeout in seconds |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that expiration was set on |
| `result` | number | 1 if the timeout was set, 0 if the key does not exist |
### `redis_ttl`
Get the remaining time to live (in seconds) of a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to check TTL for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was checked |
| `ttl` | number | Remaining TTL in seconds. Positive integer if TTL set, -1 if no expiration, -2 if key does not exist. |
### `redis_persist`
Remove the expiration from a key in Redis, making it persist indefinitely.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to persist |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was persisted |
| `result` | number | 1 if the expiration was removed, 0 if the key does not exist or has no expiration |
### `redis_lpush`
Prepend a value to a list stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The list key |
| `value` | string | Yes | The value to prepend |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The list key |
| `length` | number | Length of the list after the push |
### `redis_rpush`
Append a value to the end of a list stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The list key |
| `value` | string | Yes | The value to append |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The list key |
| `length` | number | Length of the list after the push |
### `redis_lpop`
Remove and return the first element of a list stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The list key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The list key |
| `value` | string | The removed element, or null if the list is empty |
### `redis_rpop`
Remove and return the last element of a list stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The list key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The list key |
| `value` | string | The removed element, or null if the list is empty |
### `redis_llen`
Get the length of a list stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The list key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The list key |
| `length` | number | The length of the list, or 0 if the key does not exist |
### `redis_lrange`
Get a range of elements from a list stored at a key in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The list key |
| `start` | number | Yes | Start index \(0-based\) |
| `stop` | number | Yes | Stop index \(-1 for all elements\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The list key |
| `values` | array | List elements in the specified range |
| `count` | number | Number of elements returned |
### `redis_exists`
Check if a key exists in Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to check |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was checked |
| `exists` | boolean | Whether the key exists \(true\) or not \(false\) |
### `redis_setnx`
Set the value of a key in Redis only if the key does not already exist.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Redis connection URL \(e.g. redis://user:password@host:port\) |
| `key` | string | Yes | The key to set |
| `value` | string | Yes | The value to store |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was set |
| `wasSet` | boolean | Whether the key was set \(true\) or already existed \(false\) |

View File

@@ -0,0 +1,456 @@
---
title: RevenueCat
description: Manage in-app subscriptions and entitlements
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="revenuecat"
color="#F25A5A"
/>
{/* MANUAL-CONTENT-START:intro */}
[RevenueCat](https://www.revenuecat.com/) is a subscription management platform that enables you to easily set up, manage, and analyze in-app subscriptions for your apps. With RevenueCat, you can handle the complexities of in-app purchases across platforms like iOS, Android, and web—all through a single unified API.
With RevenueCat, you can:
- **Manage subscribers**: Track user subscriptions, entitlements, and purchases across all platforms in real time
- **Simplify implementation**: Integrate RevenueCats SDKs to abstract away App Store and Play Store purchase logic
- **Automate entitlement logic**: Define and manage what features users should receive when they purchase or renew
- **Analyze revenue**: Access dashboards and analytics to view churn, LTV, revenue, active subscriptions, and more
- **Grant or revoke entitlements**: Manually adjust user access (for example, for customer support or promotions)
- **Operate globally**: Support purchases, refunds, and promotions worldwide with ease
In Sim, the RevenueCat integration allows your agents to fetch and manage subscriber data, review and update entitlements, and automate subscription-related workflows. Use RevenueCat to centralize subscription operations for your apps directly within your Sim workspace.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate RevenueCat into the workflow. Manage subscribers, entitlements, offerings, and Google Play subscriptions. Retrieve customer subscription status, grant or revoke promotional entitlements, record purchases, update subscriber attributes, and manage Google Play subscription billing.
## Tools
### `revenuecat_get_customer`
Retrieve subscriber information by app user ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) |
| `appUserId` | string | Yes | The app user ID of the subscriber |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `subscriber` | object | The subscriber object with subscriptions and entitlements |
| ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen |
| ↳ `original_app_user_id` | string | Original app user ID |
| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase |
| ↳ `management_url` | string | URL for managing the subscriber subscriptions |
| ↳ `subscriptions` | object | Map of product identifiers to subscription objects |
| ↳ `store_transaction_id` | string | Store transaction identifier |
| ↳ `original_transaction_id` | string | Original transaction identifier |
| ↳ `purchase_date` | string | ISO 8601 purchase date |
| ↳ `original_purchase_date` | string | ISO 8601 date of the original purchase |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `is_sandbox` | boolean | Whether this is a sandbox purchase |
| ↳ `unsubscribe_detected_at` | string | ISO 8601 date when unsubscribe was detected |
| ↳ `billing_issues_detected_at` | string | ISO 8601 date when billing issues were detected |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `ownership_type` | string | Ownership type \(purchased, family_shared\) |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional, prepaid\) |
| ↳ `store` | string | Store the subscription was purchased from \(app_store, play_store, stripe, etc.\) |
| ↳ `refunded_at` | string | ISO 8601 date when subscription was refunded |
| ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume |
| ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) |
| ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects |
| ↳ `grant_date` | string | ISO 8601 grant date |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `product_identifier` | string | Product identifier |
| ↳ `is_active` | boolean | Whether the entitlement is active |
| ↳ `will_renew` | boolean | Whether the entitlement will renew |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) |
| ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal |
| ↳ `store` | string | Store the entitlement was granted from |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects |
| `metadata` | object | Subscriber summary metadata |
| ↳ `app_user_id` | string | The app user ID |
| ↳ `first_seen` | string | ISO 8601 date when the subscriber was first seen |
| ↳ `active_entitlements` | number | Number of active entitlements |
| ↳ `active_subscriptions` | number | Number of active subscriptions |
### `revenuecat_delete_customer`
Permanently delete a subscriber and all associated data
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) |
| `appUserId` | string | Yes | The app user ID of the subscriber to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the subscriber was deleted |
| `app_user_id` | string | The deleted app user ID |
### `revenuecat_create_purchase`
Record a purchase (receipt) for a subscriber via the REST API
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat API key \(public or secret\) |
| `appUserId` | string | Yes | The app user ID of the subscriber |
| `fetchToken` | string | Yes | The receipt token or purchase token from the store \(App Store receipt, Google Play purchase token, or Stripe subscription ID\) |
| `productId` | string | Yes | The product identifier for the purchase |
| `price` | number | No | The price of the product in the currency specified |
| `currency` | string | No | ISO 4217 currency code \(e.g., USD, EUR\) |
| `isRestore` | boolean | No | Whether this is a restore of a previous purchase |
| `platform` | string | No | Platform of the purchase \(ios, android, amazon, macos, stripe\). Required for Stripe and Paddle purchases. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `subscriber` | object | The updated subscriber object after recording the purchase |
| ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen |
| ↳ `original_app_user_id` | string | Original app user ID |
| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase |
| ↳ `management_url` | string | URL for managing the subscriber subscriptions |
| ↳ `subscriptions` | object | Map of product identifiers to subscription objects |
| ↳ `store_transaction_id` | string | Store transaction identifier |
| ↳ `original_transaction_id` | string | Original transaction identifier |
| ↳ `purchase_date` | string | ISO 8601 purchase date |
| ↳ `original_purchase_date` | string | ISO 8601 date of the original purchase |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `is_sandbox` | boolean | Whether this is a sandbox purchase |
| ↳ `unsubscribe_detected_at` | string | ISO 8601 date when unsubscribe was detected |
| ↳ `billing_issues_detected_at` | string | ISO 8601 date when billing issues were detected |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `ownership_type` | string | Ownership type \(purchased, family_shared\) |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional, prepaid\) |
| ↳ `store` | string | Store the subscription was purchased from \(app_store, play_store, stripe, etc.\) |
| ↳ `refunded_at` | string | ISO 8601 date when subscription was refunded |
| ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume |
| ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) |
| ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects |
| ↳ `grant_date` | string | ISO 8601 grant date |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `product_identifier` | string | Product identifier |
| ↳ `is_active` | boolean | Whether the entitlement is active |
| ↳ `will_renew` | boolean | Whether the entitlement will renew |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) |
| ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal |
| ↳ `store` | string | Store the entitlement was granted from |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects |
### `revenuecat_grant_entitlement`
Grant a promotional entitlement to a subscriber
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) |
| `appUserId` | string | Yes | The app user ID of the subscriber |
| `entitlementIdentifier` | string | Yes | The entitlement identifier to grant |
| `duration` | string | Yes | Duration of the entitlement \(daily, three_day, weekly, monthly, two_month, three_month, six_month, yearly, lifetime\) |
| `startTimeMs` | number | No | Optional start time in milliseconds since Unix epoch. Set to a past time to achieve custom durations shorter than daily. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `subscriber` | object | The updated subscriber object after granting the entitlement |
| ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen |
| ↳ `original_app_user_id` | string | Original app user ID |
| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase |
| ↳ `management_url` | string | URL for managing the subscriber subscriptions |
| ↳ `subscriptions` | object | Map of product identifiers to subscription objects |
| ↳ `store_transaction_id` | string | Store transaction identifier |
| ↳ `original_transaction_id` | string | Original transaction identifier |
| ↳ `purchase_date` | string | ISO 8601 purchase date |
| ↳ `original_purchase_date` | string | ISO 8601 date of the original purchase |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `is_sandbox` | boolean | Whether this is a sandbox purchase |
| ↳ `unsubscribe_detected_at` | string | ISO 8601 date when unsubscribe was detected |
| ↳ `billing_issues_detected_at` | string | ISO 8601 date when billing issues were detected |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `ownership_type` | string | Ownership type \(purchased, family_shared\) |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional, prepaid\) |
| ↳ `store` | string | Store the subscription was purchased from \(app_store, play_store, stripe, etc.\) |
| ↳ `refunded_at` | string | ISO 8601 date when subscription was refunded |
| ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume |
| ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) |
| ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects |
| ↳ `grant_date` | string | ISO 8601 grant date |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `product_identifier` | string | Product identifier |
| ↳ `is_active` | boolean | Whether the entitlement is active |
| ↳ `will_renew` | boolean | Whether the entitlement will renew |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) |
| ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal |
| ↳ `store` | string | Store the entitlement was granted from |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects |
### `revenuecat_revoke_entitlement`
Revoke all promotional entitlements for a specific entitlement identifier
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) |
| `appUserId` | string | Yes | The app user ID of the subscriber |
| `entitlementIdentifier` | string | Yes | The entitlement identifier to revoke |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `subscriber` | object | The updated subscriber object after revoking the entitlement |
| ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen |
| ↳ `original_app_user_id` | string | Original app user ID |
| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase |
| ↳ `management_url` | string | URL for managing the subscriber subscriptions |
| ↳ `subscriptions` | object | Map of product identifiers to subscription objects |
| ↳ `store_transaction_id` | string | Store transaction identifier |
| ↳ `original_transaction_id` | string | Original transaction identifier |
| ↳ `purchase_date` | string | ISO 8601 purchase date |
| ↳ `original_purchase_date` | string | ISO 8601 date of the original purchase |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `is_sandbox` | boolean | Whether this is a sandbox purchase |
| ↳ `unsubscribe_detected_at` | string | ISO 8601 date when unsubscribe was detected |
| ↳ `billing_issues_detected_at` | string | ISO 8601 date when billing issues were detected |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `ownership_type` | string | Ownership type \(purchased, family_shared\) |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional, prepaid\) |
| ↳ `store` | string | Store the subscription was purchased from \(app_store, play_store, stripe, etc.\) |
| ↳ `refunded_at` | string | ISO 8601 date when subscription was refunded |
| ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume |
| ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) |
| ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects |
| ↳ `grant_date` | string | ISO 8601 grant date |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `product_identifier` | string | Product identifier |
| ↳ `is_active` | boolean | Whether the entitlement is active |
| ↳ `will_renew` | boolean | Whether the entitlement will renew |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) |
| ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal |
| ↳ `store` | string | Store the entitlement was granted from |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects |
### `revenuecat_list_offerings`
List all offerings configured for the project
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat API key |
| `appUserId` | string | Yes | An app user ID to retrieve offerings for |
| `platform` | string | No | Platform to filter offerings \(ios, android, stripe, etc.\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `current_offering_id` | string | The identifier of the current offering |
| `offerings` | array | List of offerings |
| ↳ `identifier` | string | Offering identifier |
| ↳ `description` | string | Offering description |
| ↳ `packages` | array | List of packages in the offering |
| ↳ `identifier` | string | Package identifier |
| ↳ `platform_product_identifier` | string | Platform-specific product identifier |
| `metadata` | object | Offerings metadata |
| ↳ `count` | number | Number of offerings returned |
| ↳ `current_offering_id` | string | Current offering identifier |
### `revenuecat_update_subscriber_attributes`
Update custom subscriber attributes (e.g., $email, $displayName, or custom key-value pairs)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) |
| `appUserId` | string | Yes | The app user ID of the subscriber |
| `attributes` | json | Yes | JSON object of attributes to set. Each key maps to an object with a "value" field. Example: \{"$email": \{"value": "user@example.com"\}, "$displayName": \{"value": "John"\}\} |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `updated` | boolean | Whether the subscriber attributes were successfully updated |
| `app_user_id` | string | The app user ID of the updated subscriber |
### `revenuecat_defer_google_subscription`
Defer a Google Play subscription by extending its billing date by a number of days (Google Play only)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) |
| `appUserId` | string | Yes | The app user ID of the subscriber |
| `productId` | string | Yes | The Google Play product identifier of the subscription to defer \(use the part before the colon for products set up after Feb 2023\) |
| `extendByDays` | number | Yes | Number of days to extend the subscription by \(1-365\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `subscriber` | object | The updated subscriber object after deferring the Google subscription |
| ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen |
| ↳ `original_app_user_id` | string | Original app user ID |
| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase |
| ↳ `management_url` | string | URL for managing the subscriber subscriptions |
| ↳ `subscriptions` | object | Map of product identifiers to subscription objects |
| ↳ `store_transaction_id` | string | Store transaction identifier |
| ↳ `original_transaction_id` | string | Original transaction identifier |
| ↳ `purchase_date` | string | ISO 8601 purchase date |
| ↳ `original_purchase_date` | string | ISO 8601 date of the original purchase |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `is_sandbox` | boolean | Whether this is a sandbox purchase |
| ↳ `unsubscribe_detected_at` | string | ISO 8601 date when unsubscribe was detected |
| ↳ `billing_issues_detected_at` | string | ISO 8601 date when billing issues were detected |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `ownership_type` | string | Ownership type \(purchased, family_shared\) |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional, prepaid\) |
| ↳ `store` | string | Store the subscription was purchased from \(app_store, play_store, stripe, etc.\) |
| ↳ `refunded_at` | string | ISO 8601 date when subscription was refunded |
| ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume |
| ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) |
| ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects |
| ↳ `grant_date` | string | ISO 8601 grant date |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `product_identifier` | string | Product identifier |
| ↳ `is_active` | boolean | Whether the entitlement is active |
| ↳ `will_renew` | boolean | Whether the entitlement will renew |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) |
| ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal |
| ↳ `store` | string | Store the entitlement was granted from |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects |
### `revenuecat_refund_google_subscription`
Refund and optionally revoke a Google Play subscription (Google Play only)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) |
| `appUserId` | string | Yes | The app user ID of the subscriber |
| `productId` | string | Yes | The Google Play product identifier of the subscription to refund |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `subscriber` | object | The updated subscriber object after refunding the Google subscription |
| ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen |
| ↳ `original_app_user_id` | string | Original app user ID |
| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase |
| ↳ `management_url` | string | URL for managing the subscriber subscriptions |
| ↳ `subscriptions` | object | Map of product identifiers to subscription objects |
| ↳ `store_transaction_id` | string | Store transaction identifier |
| ↳ `original_transaction_id` | string | Original transaction identifier |
| ↳ `purchase_date` | string | ISO 8601 purchase date |
| ↳ `original_purchase_date` | string | ISO 8601 date of the original purchase |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `is_sandbox` | boolean | Whether this is a sandbox purchase |
| ↳ `unsubscribe_detected_at` | string | ISO 8601 date when unsubscribe was detected |
| ↳ `billing_issues_detected_at` | string | ISO 8601 date when billing issues were detected |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `ownership_type` | string | Ownership type \(purchased, family_shared\) |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional, prepaid\) |
| ↳ `store` | string | Store the subscription was purchased from \(app_store, play_store, stripe, etc.\) |
| ↳ `refunded_at` | string | ISO 8601 date when subscription was refunded |
| ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume |
| ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) |
| ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects |
| ↳ `grant_date` | string | ISO 8601 grant date |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `product_identifier` | string | Product identifier |
| ↳ `is_active` | boolean | Whether the entitlement is active |
| ↳ `will_renew` | boolean | Whether the entitlement will renew |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) |
| ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal |
| ↳ `store` | string | Store the entitlement was granted from |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects |
### `revenuecat_revoke_google_subscription`
Immediately revoke access to a Google Play subscription and issue a refund (Google Play only)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) |
| `appUserId` | string | Yes | The app user ID of the subscriber |
| `productId` | string | Yes | The Google Play product identifier of the subscription to revoke |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `subscriber` | object | The updated subscriber object after revoking the Google subscription |
| ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen |
| ↳ `original_app_user_id` | string | Original app user ID |
| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase |
| ↳ `management_url` | string | URL for managing the subscriber subscriptions |
| ↳ `subscriptions` | object | Map of product identifiers to subscription objects |
| ↳ `store_transaction_id` | string | Store transaction identifier |
| ↳ `original_transaction_id` | string | Original transaction identifier |
| ↳ `purchase_date` | string | ISO 8601 purchase date |
| ↳ `original_purchase_date` | string | ISO 8601 date of the original purchase |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `is_sandbox` | boolean | Whether this is a sandbox purchase |
| ↳ `unsubscribe_detected_at` | string | ISO 8601 date when unsubscribe was detected |
| ↳ `billing_issues_detected_at` | string | ISO 8601 date when billing issues were detected |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `ownership_type` | string | Ownership type \(purchased, family_shared\) |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional, prepaid\) |
| ↳ `store` | string | Store the subscription was purchased from \(app_store, play_store, stripe, etc.\) |
| ↳ `refunded_at` | string | ISO 8601 date when subscription was refunded |
| ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume |
| ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) |
| ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects |
| ↳ `grant_date` | string | ISO 8601 grant date |
| ↳ `expires_date` | string | ISO 8601 expiration date |
| ↳ `product_identifier` | string | Product identifier |
| ↳ `is_active` | boolean | Whether the entitlement is active |
| ↳ `will_renew` | boolean | Whether the entitlement will renew |
| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) |
| ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal |
| ↳ `store` | string | Store the entitlement was granted from |
| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date |
| ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects |

View File

@@ -0,0 +1,351 @@
---
title: Table
description: User-defined data tables for storing and querying structured data
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="table"
color="#10B981"
/>
Tables allow you to create and manage custom data tables directly within Sim. Store, query, and manipulate structured data within your workflows without needing external database integrations.
**Why Use Tables?**
- **No external setup**: Create tables instantly without configuring external databases
- **Workflow-native**: Data persists across workflow executions and is accessible from any workflow in your workspace
- **Flexible schema**: Define columns with types (string, number, boolean, date, json) and constraints (required, unique)
- **Powerful querying**: Filter, sort, and paginate data using MongoDB-style operators
- **Agent-friendly**: Tables can be used as tools by AI agents for dynamic data storage and retrieval
**Key Features:**
- Create tables with custom schemas
- Insert, update, upsert, and delete rows
- Query with filters and sorting
- Batch operations for bulk inserts
- Bulk updates and deletes by filter
- Up to 10,000 rows per table, 100 tables per workspace
## Creating Tables
Tables are created from the **Tables** section in the sidebar. Each table requires:
- **Name**: Alphanumeric with underscores (e.g., `customer_leads`)
- **Description**: Optional description of the table's purpose
- **Schema**: Define columns with name, type, and optional constraints
### Column Types
| Type | Description | Example Values |
|------|-------------|----------------|
| `string` | Text data | `"John Doe"`, `"active"` |
| `number` | Numeric data | `42`, `99.99` |
| `boolean` | True/false values | `true`, `false` |
| `date` | Date/time values | `"2024-01-15T10:30:00Z"` |
| `json` | Complex nested data | `{"address": {"city": "NYC"}}` |
### Column Constraints
- **Required**: Column must have a value (cannot be null)
- **Unique**: Values must be unique across all rows (enables upsert matching)
## Usage Instructions
Create and manage custom data tables. Store, query, and manipulate structured data within workflows.
## Tools
### `table_query_rows`
Query rows from a table with filtering, sorting, and pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `filter` | object | No | Filter conditions using MongoDB-style operators |
| `sort` | object | No | Sort order as \{column: "asc"\|"desc"\} |
| `limit` | number | No | Maximum rows to return \(default: 100, max: 1000\) |
| `offset` | number | No | Number of rows to skip \(default: 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether query succeeded |
| `rows` | array | Query result rows |
| `rowCount` | number | Number of rows returned |
| `totalCount` | number | Total rows matching filter |
| `limit` | number | Limit used in query |
| `offset` | number | Offset used in query |
### `table_insert_row`
Insert a new row into a table
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `data` | object | Yes | Row data as JSON object matching the table schema |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was inserted |
| `row` | object | Inserted row data including generated ID |
| `message` | string | Status message |
### `table_upsert_row`
Insert or update a row based on unique column constraints. If a row with matching unique field exists, update it; otherwise insert a new row.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `data` | object | Yes | Row data to insert or update |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was upserted |
| `row` | object | Upserted row data |
| `operation` | string | Operation performed: "insert" or "update" |
| `message` | string | Status message |
### `table_batch_insert_rows`
Insert multiple rows at once (up to 1000 rows per batch)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `rows` | array | Yes | Array of row data objects to insert |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether batch insert succeeded |
| `rows` | array | Array of inserted rows with IDs |
| `insertedCount` | number | Number of rows inserted |
| `message` | string | Status message |
### `table_update_row`
Update a specific row by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `rowId` | string | Yes | Row ID to update |
| `data` | object | Yes | Data to update \(partial update supported\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was updated |
| `row` | object | Updated row data |
| `message` | string | Status message |
### `table_update_rows_by_filter`
Update multiple rows matching a filter condition
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `filter` | object | Yes | Filter to match rows for update |
| `data` | object | Yes | Data to apply to matching rows |
| `limit` | number | No | Maximum rows to update \(default: 1000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether update succeeded |
| `updatedCount` | number | Number of rows updated |
| `updatedRowIds` | array | IDs of updated rows |
| `message` | string | Status message |
### `table_delete_row`
Delete a specific row by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `rowId` | string | Yes | Row ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was deleted |
| `deletedCount` | number | Number of rows deleted \(1 or 0\) |
| `message` | string | Status message |
### `table_delete_rows_by_filter`
Delete multiple rows matching a filter condition
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `filter` | object | Yes | Filter to match rows for deletion |
| `limit` | number | No | Maximum rows to delete \(default: 1000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether delete succeeded |
| `deletedCount` | number | Number of rows deleted |
| `deletedRowIds` | array | IDs of deleted rows |
| `message` | string | Status message |
### `table_get_row`
Get a single row by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `rowId` | string | Yes | Row ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was found |
| `row` | object | Row data |
| `message` | string | Status message |
### `table_get_schema`
Get the schema definition for a table
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether schema was retrieved |
| `name` | string | Table name |
| `columns` | array | Array of column definitions |
| `message` | string | Status message |
## Filter Operators
Filters use MongoDB-style operators for flexible querying:
| Operator | Description | Example |
|----------|-------------|---------|
| `$eq` | Equals | `{"status": {"$eq": "active"}}` or `{"status": "active"}` |
| `$ne` | Not equals | `{"status": {"$ne": "deleted"}}` |
| `$gt` | Greater than | `{"age": {"$gt": 18}}` |
| `$gte` | Greater than or equal | `{"score": {"$gte": 80}}` |
| `$lt` | Less than | `{"price": {"$lt": 100}}` |
| `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` |
| `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` |
| `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` |
| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` |
### Combining Filters
Multiple field conditions are combined with AND logic:
```json
{
"status": "active",
"age": {"$gte": 18}
}
```
Use `$or` for OR logic:
```json
{
"$or": [
{"status": "active"},
{"status": "pending"}
]
}
```
## Sort Specification
Specify sort order with column names and direction:
```json
{
"createdAt": "desc"
}
```
Multi-column sorting:
```json
{
"priority": "desc",
"name": "asc"
}
```
## Built-in Columns
Every row automatically includes:
| Column | Type | Description |
|--------|------|-------------|
| `id` | string | Unique row identifier |
| `createdAt` | date | When the row was created |
| `updatedAt` | date | When the row was last modified |
These can be used in filters and sorting.
## Limits
| Resource | Limit |
|----------|-------|
| Tables per workspace | 100 |
| Rows per table | 10,000 |
| Columns per table | 50 |
| Max row size | 100KB |
| String value length | 10,000 characters |
| Query limit | 1,000 rows |
| Batch insert size | 1,000 rows |
| Bulk update/delete | 1,000 rows |
## Notes
- Category: `blocks`
- Type: `table`
- Tables are scoped to workspaces and accessible from any workflow within that workspace
- Data persists across workflow executions
- Use unique constraints to enable upsert functionality
- The visual filter/sort builder provides an easy way to construct queries without writing JSON

View File

@@ -0,0 +1,357 @@
---
title: Upstash
description: Serverless Redis with Upstash
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="upstash"
color="#181C1E"
/>
{/* MANUAL-CONTENT-START:intro */}
[Upstash](https://upstash.com/) is a serverless data platform designed for modern applications that need fast, simple, and scalable data storage with minimal setup. Upstash specializes in providing Redis and Kafka as fully managed, pay-per-request cloud services, making it a popular choice for developers building serverless, edge, and event-driven architectures.
With Upstash Redis, you can:
- **Store and retrieve data instantly**: Read and write key-value pairs, hashes, lists, sets, and more—all over a high-performance REST API.
- **Scale serverlessly**: No infrastructure to manage. Upstash automatically scales with your app and charges only for what you use.
- **Access globally**: Deploy near your users with multi-region support and global distribution.
- **Integrate easily**: Use Upstashs REST API in serverless functions, edge workers, Next.js, Vercel, Cloudflare Workers, and more.
- **Automate with scripts**: Run Lua scripts for advanced transactions and automation.
- **Ensure security**: Protect your data with built-in authentication and TLS encryption.
In Sim, the Upstash integration empowers your agents and workflows to read, write, and manage data in Upstash Redis using simple, unified commands—perfect for building scalable automations, caching results, managing queues, and more, all without dealing with server management.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to Upstash Redis to perform key-value, hash, list, and utility operations via the REST API.
## Tools
### `upstash_redis_get`
Get the value of a key from Upstash Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was retrieved |
| `value` | json | The value of the key \(string\), or null if not found |
### `upstash_redis_set`
Set the value of a key in Upstash Redis with an optional expiration time in seconds.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to set |
| `value` | string | Yes | The value to store |
| `ex` | number | No | Expiration time in seconds \(optional\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was set |
| `result` | string | The result of the SET operation \(typically "OK"\) |
### `upstash_redis_delete`
Delete a key from Upstash Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was deleted |
| `deletedCount` | number | Number of keys deleted \(0 if key did not exist, 1 if deleted\) |
### `upstash_redis_keys`
List keys matching a pattern in Upstash Redis. Defaults to listing all keys (*).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `pattern` | string | No | Pattern to match keys \(e.g., "user:*"\). Defaults to "*" for all keys. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `pattern` | string | The pattern used to match keys |
| `keys` | array | List of keys matching the pattern |
| `count` | number | Number of keys found |
### `upstash_redis_command`
Execute an arbitrary Redis command against Upstash Redis. Pass the full command as a JSON array (e.g., [
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `command` | string | Yes | Redis command as a JSON array \(e.g., \["HSET", "myhash", "field1", "value1"\]\) or a simple command string \(e.g., "PING"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `command` | string | The command that was executed |
| `result` | json | The result of the Redis command |
### `upstash_redis_hset`
Set a field in a hash stored at a key in Upstash Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The hash key |
| `field` | string | Yes | The field name within the hash |
| `value` | string | Yes | The value to store in the hash field |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The hash key |
| `field` | string | The field that was set |
| `result` | number | Number of new fields added \(0 if field was updated, 1 if new\) |
### `upstash_redis_hget`
Get the value of a field in a hash stored at a key in Upstash Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The hash key |
| `field` | string | Yes | The field name to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The hash key |
| `field` | string | The field that was retrieved |
| `value` | json | The value of the hash field \(string\), or null if not found |
### `upstash_redis_hgetall`
Get all fields and values of a hash stored at a key in Upstash Redis.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The hash key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The hash key |
| `fields` | object | All field-value pairs in the hash, keyed by field name |
| `fieldCount` | number | Number of fields in the hash |
### `upstash_redis_incr`
Atomically increment the integer value of a key by one in Upstash Redis. If the key does not exist, it is set to 0 before incrementing.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to increment |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was incremented |
| `value` | number | The new value after incrementing |
### `upstash_redis_expire`
Set a timeout on a key in Upstash Redis. After the timeout, the key is deleted.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to set expiration on |
| `seconds` | number | Yes | Timeout in seconds |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that expiration was set on |
| `result` | number | 1 if the timeout was set, 0 if the key does not exist |
### `upstash_redis_ttl`
Get the remaining time to live of a key in Upstash Redis. Returns -1 if the key has no expiration, -2 if the key does not exist.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to check TTL for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key checked |
| `ttl` | number | Remaining TTL in seconds. Positive integer if the key has a TTL set, -1 if the key exists with no expiration, -2 if the key does not exist. |
### `upstash_redis_lpush`
Prepend a value to the beginning of a list in Upstash Redis. Creates the list if it does not exist.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The list key |
| `value` | string | Yes | The value to prepend to the list |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The list key |
| `length` | number | The length of the list after the push |
### `upstash_redis_lrange`
Get a range of elements from a list in Upstash Redis. Use 0 and -1 for start and stop to get all elements.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The list key |
| `start` | number | Yes | Start index \(0-based, negative values count from end\) |
| `stop` | number | Yes | Stop index \(inclusive, -1 for last element\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The list key |
| `values` | array | List of elements in the specified range |
| `count` | number | Number of elements returned |
### `upstash_redis_exists`
Check if a key exists in Upstash Redis. Returns true if the key exists, false otherwise.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to check |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was checked |
| `exists` | boolean | Whether the key exists \(true\) or not \(false\) |
### `upstash_redis_setnx`
Set the value of a key only if it does not already exist. Returns true if the key was set, false if it already existed.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to set |
| `value` | string | Yes | The value to store if the key does not exist |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was attempted to set |
| `wasSet` | boolean | Whether the key was set \(true\) or already existed \(false\) |
### `upstash_redis_incrby`
Increment the integer value of a key by a given amount. Use a negative value to decrement. If the key does not exist, it is set to 0 before the operation.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `restUrl` | string | Yes | Upstash Redis REST URL |
| `restToken` | string | Yes | Upstash Redis REST Token |
| `key` | string | Yes | The key to increment |
| `increment` | number | Yes | Amount to increment by \(use negative value to decrement\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The key that was incremented |
| `value` | number | The new value after incrementing |

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { createMockLogger, createMockRequest } from '@sim/testing'
import { createMockLogger, createMockRequest, mockHybridAuth } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('OAuth Token API Routes', () => {
@@ -12,7 +12,7 @@ describe('OAuth Token API Routes', () => {
const mockRefreshTokenIfNeeded = vi.fn()
const mockGetOAuthToken = vi.fn()
const mockAuthorizeCredentialUse = vi.fn()
const mockCheckSessionOrInternalAuth = vi.fn()
let mockCheckSessionOrInternalAuth: ReturnType<typeof vi.fn>
const mockLogger = createMockLogger()
@@ -41,9 +41,7 @@ describe('OAuth Token API Routes', () => {
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
;({ mockCheckSessionOrInternalAuth } = mockHybridAuth())
})
afterEach(() => {
@@ -73,23 +71,18 @@ describe('OAuth Token API Routes', () => {
refreshed: false,
})
// Create mock request
const req = createMockRequest('POST', {
credentialId: 'credential-id',
})
// Import handler after setting up mocks
const { POST } = await import('@/app/api/auth/oauth/token/route')
// Call handler
const response = await POST(req)
const data = await response.json()
// Verify request was handled correctly
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
// Verify mocks were called correctly
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { loggerMock, requestUtilsMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
@@ -94,9 +94,7 @@ vi.mock('@/lib/core/utils/sse', () => ({
},
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: vi.fn().mockResolvedValue({ decrypted: 'test-password' }),

View File

@@ -1,4 +1,4 @@
import { databaseMock, loggerMock } from '@sim/testing'
import { databaseMock, loggerMock, requestUtilsMock } from '@sim/testing'
import type { NextResponse } from 'next/server'
/**
* Tests for chat API utils
@@ -37,9 +37,7 @@ vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
vi.mock('@/lib/core/config/feature-flags', () => ({
isDev: true,

View File

@@ -2,6 +2,7 @@ import {
createMockRequest,
mockAuth,
mockCryptoUuid,
mockHybridAuth,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
@@ -28,13 +29,12 @@ function setupFileApiMocks(
authMocks.setUnauthenticated()
}
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
}),
}))
const { mockCheckSessionOrInternalAuth } = mockHybridAuth()
mockCheckSessionOrInternalAuth.mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),

View File

@@ -8,6 +8,7 @@ import {
createMockRequest,
mockAuth,
mockCryptoUuid,
mockHybridAuth,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
@@ -34,13 +35,12 @@ function setupFileApiMocks(
authMocks.setUnauthenticated()
}
vi.doMock('@/lib/auth/hybrid', () => ({
checkInternalAuth: vi.fn().mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
}),
}))
const { mockCheckInternalAuth } = mockHybridAuth()
mockCheckInternalAuth.mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),

View File

@@ -1,4 +1,10 @@
import { mockAuth, mockCryptoUuid, mockUuid, setupCommonApiMocks } from '@sim/testing'
import {
mockAuth,
mockCryptoUuid,
mockHybridAuth,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -28,13 +34,12 @@ function setupFileApiMocks(
authMocks.setUnauthenticated()
}
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
}),
}))
const { mockCheckHybridAuth } = mockHybridAuth()
mockCheckHybridAuth.mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),

View File

@@ -7,6 +7,7 @@ import {
defaultMockUser,
mockAuth,
mockCryptoUuid,
mockHybridAuth,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
@@ -54,12 +55,11 @@ describe('File Serve API Route', () => {
withUploadUtils: true,
})
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'test-user-id',
}),
}))
const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth()
serveAuthMock.mockResolvedValue({
success: true,
userId: 'test-user-id',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),
@@ -164,12 +164,11 @@ describe('File Serve API Route', () => {
findLocalFile: vi.fn().mockReturnValue('/test/uploads/nested/path/file.txt'),
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'test-user-id',
}),
}))
const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth()
serveAuthMock.mockResolvedValue({
success: true,
userId: 'test-user-id',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),
@@ -225,12 +224,11 @@ describe('File Serve API Route', () => {
USE_BLOB_STORAGE: false,
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'test-user-id',
}),
}))
const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth()
serveAuthMock.mockResolvedValue({
success: true,
userId: 'test-user-id',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),
@@ -290,12 +288,11 @@ describe('File Serve API Route', () => {
readFile: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')),
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'test-user-id',
}),
}))
const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth()
serveAuthMock.mockResolvedValue({
success: true,
userId: 'test-user-id',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(false), // File not found = no access
@@ -349,12 +346,11 @@ describe('File Serve API Route', () => {
for (const test of contentTypeTests) {
it(`should serve ${test.ext} file with correct content type`, async () => {
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'test-user-id',
}),
}))
const { mockCheckSessionOrInternalAuth: ctAuthMock } = mockHybridAuth()
ctAuthMock.mockResolvedValue({
success: true,
userId: 'test-user-id',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),

View File

@@ -3,7 +3,13 @@
*
* @vitest-environment node
*/
import { mockAuth, mockCryptoUuid, mockUuid, setupCommonApiMocks } from '@sim/testing'
import {
mockAuth,
mockCryptoUuid,
mockHybridAuth,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -27,13 +33,12 @@ function setupFileApiMocks(
authMocks.setUnauthenticated()
}
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
}),
}))
const { mockCheckHybridAuth } = mockHybridAuth()
mockCheckHybridAuth.mockResolvedValue({
success: authenticated,
userId: authenticated ? 'test-user-id' : undefined,
error: authenticated ? undefined : 'Unauthorized',
})
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),

View File

@@ -10,6 +10,7 @@ import {
createMockRequest,
mockConsoleLogger,
mockKnowledgeSchemas,
requestUtilsMock,
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -29,9 +30,7 @@ mockKnowledgeSchemas()
vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-api-key' }))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn(() => 'test-request-id'),
}))
vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
vi.mock('@/lib/documents/utils', () => ({
retryWithExponentialBackoff: vi.fn().mockImplementation((fn) => fn()),

View File

@@ -163,17 +163,18 @@ export async function checkKnowledgeBaseAccess(
const kbData = kb[0]
// Case 1: User owns the knowledge base directly
if (kbData.userId === userId) {
return { hasAccess: true, knowledgeBase: kbData }
}
// Case 2: Knowledge base belongs to a workspace the user has permissions for
if (kbData.workspaceId) {
// Workspace KB: use workspace permissions only
const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId)
if (userPermission !== null) {
return { hasAccess: true, knowledgeBase: kbData }
}
return { hasAccess: false }
}
// Legacy non-workspace KB: allow owner access
if (kbData.userId === userId) {
return { hasAccess: true, knowledgeBase: kbData }
}
return { hasAccess: false }
@@ -182,8 +183,8 @@ export async function checkKnowledgeBaseAccess(
/**
* Check if a user has write access to a knowledge base
* Write access is granted if:
* 1. User owns the knowledge base directly, OR
* 2. User has write or admin permissions on the knowledge base's workspace
* 1. KB has a workspace: user has write or admin permissions on that workspace
* 2. KB has no workspace (legacy): user owns the KB directly
*/
export async function checkKnowledgeBaseWriteAccess(
knowledgeBaseId: string,
@@ -206,17 +207,18 @@ export async function checkKnowledgeBaseWriteAccess(
const kbData = kb[0]
// Case 1: User owns the knowledge base directly
if (kbData.userId === userId) {
return { hasAccess: true, knowledgeBase: kbData }
}
// Case 2: Knowledge base belongs to a workspace and user has write/admin permissions
if (kbData.workspaceId) {
// Workspace KB: use workspace permissions only
const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId)
if (userPermission === 'write' || userPermission === 'admin') {
return { hasAccess: true, knowledgeBase: kbData }
}
return { hasAccess: false }
}
// Legacy non-workspace KB: allow owner access
if (kbData.userId === userId) {
return { hasAccess: true, knowledgeBase: kbData }
}
return { hasAccess: false }

View File

@@ -3,10 +3,11 @@
*
* @vitest-environment node
*/
import { mockHybridAuth } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockCheckHybridAuth = vi.fn()
let mockCheckHybridAuth: ReturnType<typeof vi.fn>
const mockGetUserEntityPermissions = vi.fn()
const mockGenerateInternalToken = vi.fn()
const mockDbSelect = vi.fn()
@@ -61,9 +62,7 @@ describe('MCP Serve Route', () => {
isDeployed: 'isDeployed',
},
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
}))
;({ mockCheckHybridAuth } = mockHybridAuth())
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))

View File

@@ -19,6 +19,7 @@ const configSchema = z.object({
allowedModelProviders: z.array(z.string()).nullable().optional(),
hideTraceSpans: z.boolean().optional(),
hideKnowledgeBaseTab: z.boolean().optional(),
hideTablesTab: z.boolean().optional(),
hideCopilot: z.boolean().optional(),
hideApiKeysTab: z.boolean().optional(),
hideEnvironmentTab: z.boolean().optional(),

View File

@@ -20,6 +20,7 @@ const configSchema = z.object({
allowedModelProviders: z.array(z.string()).nullable().optional(),
hideTraceSpans: z.boolean().optional(),
hideKnowledgeBaseTab: z.boolean().optional(),
hideTablesTab: z.boolean().optional(),
hideCopilot: z.boolean().optional(),
hideApiKeysTab: z.boolean().optional(),
hideEnvironmentTab: z.boolean().optional(),

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { auditMock, databaseMock, loggerMock } from '@sim/testing'
import { auditMock, databaseMock, loggerMock, requestUtilsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -31,9 +31,7 @@ vi.mock('drizzle-orm', () => ({
eq: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: () => 'test-request-id',
}))
vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
vi.mock('@sim/logger', () => loggerMock)

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { databaseMock, loggerMock } from '@sim/testing'
import { databaseMock, loggerMock, requestUtilsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -43,9 +43,7 @@ vi.mock('drizzle-orm', () => ({
isNull: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: () => 'test-request-id',
}))
vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
vi.mock('@sim/logger', () => loggerMock)

View File

@@ -0,0 +1,138 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteTable, type TableSchema } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn, verifyTableWorkspace } from '../utils'
const logger = createLogger('TableDetailAPI')
const GetTableSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
interface TableRouteParams {
params: Promise<{ tableId: string }>
}
/** GET /api/table/[tableId] - Retrieves a single table's details. */
export async function GET(request: NextRequest, { params }: TableRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized table access attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const validated = GetTableSchema.parse({
workspaceId: searchParams.get('workspaceId'),
})
const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
logger.info(`[${requestId}] Retrieved table ${tableId} for user ${authResult.userId}`)
const schemaData = table.schema as TableSchema
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error getting table:`, error)
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
}
}
/** DELETE /api/table/[tableId] - Deletes a table and all its rows. */
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized table delete attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const validated = GetTableSchema.parse({
workspaceId: searchParams.get('workspaceId'),
})
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
await deleteTable(tableId, requestId)
return NextResponse.json({
success: true,
data: {
message: 'Table deleted successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting table:`, error)
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
}
}

View File

@@ -0,0 +1,276 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { RowData, TableSchema } from '@/lib/table'
import { validateRowData } from '@/lib/table'
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
const logger = createLogger('TableRowAPI')
const GetRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
const UpdateRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
const DeleteRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
interface RowRouteParams {
params: Promise<{ tableId: string; rowId: string }>
}
/** GET /api/table/[tableId]/rows/[rowId] - Retrieves a single row. */
export async function GET(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const validated = GetRowSchema.parse({
workspaceId: searchParams.get('workspaceId'),
})
const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [row] = await db
.select({
id: userTableRows.id,
data: userTableRows.data,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
.from(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.info(`[${requestId}] Retrieved row ${rowId} from table ${tableId}`)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error getting row:`, error)
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
}
}
/** PATCH /api/table/[tableId]/rows/[rowId] - Updates a single row (supports partial updates). */
export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = UpdateRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
// Fetch existing row to support partial updates
const [existingRow] = await db
.select({ data: userTableRows.data })
.from(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.limit(1)
if (!existingRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
// Merge existing data with incoming partial data (incoming takes precedence)
const mergedData = {
...(existingRow.data as RowData),
...(validated.data as RowData),
}
const validation = await validateRowData({
rowData: mergedData,
schema: table.schema as TableSchema,
tableId,
excludeRowId: rowId,
})
if (!validation.valid) return validation.response
const now = new Date()
const [updatedRow] = await db
.update(userTableRows)
.set({
data: mergedData,
updatedAt: now,
})
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.returning()
if (!updatedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.info(`[${requestId}] Updated row ${rowId} in table ${tableId}`)
return NextResponse.json({
success: true,
data: {
row: {
id: updatedRow.id,
data: updatedRow.data,
createdAt: updatedRow.createdAt.toISOString(),
updatedAt: updatedRow.updatedAt.toISOString(),
},
message: 'Row updated successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error updating row:`, error)
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
}
}
/** DELETE /api/table/[tableId]/rows/[rowId] - Deletes a single row. */
export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = DeleteRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [deletedRow] = await db
.delete(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.returning()
if (!deletedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`)
return NextResponse.json({
success: true,
data: {
message: 'Row deleted successfully',
deletedCount: 1,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting row:`, error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
}
}

View File

@@ -0,0 +1,725 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
import {
checkUniqueConstraintsDb,
getUniqueColumns,
TABLE_LIMITS,
USER_TABLE_ROWS_SQL_NAME,
validateBatchRows,
validateRowAgainstSchema,
validateRowData,
validateRowSize,
} from '@/lib/table'
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
import { accessError, checkAccess } from '../../utils'
const logger = createLogger('TableRowsAPI')
const InsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
const BatchInsertRowsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
rows: z
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
.min(1, 'At least one row is required')
.max(1000, 'Cannot insert more than 1000 rows per batch'),
})
const QueryRowsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown()).optional(),
sort: z.record(z.enum(['asc', 'desc'])).optional(),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`)
.optional()
.default(100),
offset: z.coerce
.number({ required_error: 'Offset must be a number' })
.int('Offset must be an integer')
.min(0, 'Offset must be 0 or greater')
.optional()
.default(0),
})
const UpdateRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(1000, 'Cannot update more than 1000 rows per operation')
.optional(),
})
const DeleteRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(1000, 'Cannot delete more than 1000 rows per operation')
.optional(),
})
const DeleteRowsByIdsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
rowIds: z
.array(z.string().min(1), { required_error: 'Row IDs are required' })
.min(1, 'At least one row ID is required')
.max(1000, 'Cannot delete more than 1000 rows per operation'),
})
const DeleteRowsRequestSchema = z.union([DeleteRowsByFilterSchema, DeleteRowsByIdsSchema])
interface TableRowsRouteParams {
params: Promise<{ tableId: string }>
}
async function handleBatchInsert(
requestId: string,
tableId: string,
body: z.infer<typeof BatchInsertRowsSchema>,
userId: string
): Promise<NextResponse> {
const validated = BatchInsertRowsSchema.parse(body)
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const workspaceId = validated.workspaceId
const remainingCapacity = table.maxRows - table.rowCount
if (remainingCapacity < validated.rows.length) {
return NextResponse.json(
{
error: `Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${table.rowCount}/${table.maxRows} rows)`,
},
{ status: 400 }
)
}
const validation = await validateBatchRows({
rows: validated.rows as RowData[],
schema: table.schema as TableSchema,
tableId,
})
if (!validation.valid) return validation.response
const now = new Date()
const rowsToInsert = validated.rows.map((data) => ({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId,
workspaceId,
data,
createdAt: now,
updatedAt: now,
createdBy: userId,
}))
const insertedRows = await db.insert(userTableRows).values(rowsToInsert).returning()
logger.info(`[${requestId}] Batch inserted ${insertedRows.length} rows into table ${tableId}`)
return NextResponse.json({
success: true,
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
insertedCount: insertedRows.length,
message: `Successfully inserted ${insertedRows.length} rows`,
},
})
}
/** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */
export async function POST(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
if (
typeof body === 'object' &&
body !== null &&
'rows' in body &&
Array.isArray((body as Record<string, unknown>).rows)
) {
return handleBatchInsert(
requestId,
tableId,
body as z.infer<typeof BatchInsertRowsSchema>,
authResult.userId
)
}
const validated = InsertRowSchema.parse(body)
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const workspaceId = validated.workspaceId
const rowData = validated.data as RowData
const validation = await validateRowData({
rowData,
schema: table.schema as TableSchema,
tableId,
})
if (!validation.valid) return validation.response
if (table.rowCount >= table.maxRows) {
return NextResponse.json(
{ error: `Table row limit reached (${table.maxRows} rows max)` },
{ status: 400 }
)
}
const rowId = `row_${crypto.randomUUID().replace(/-/g, '')}`
const now = new Date()
const [row] = await db
.insert(userTableRows)
.values({
id: rowId,
tableId,
workspaceId,
data: validated.data,
createdAt: now,
updatedAt: now,
createdBy: authResult.userId,
})
.returning()
logger.info(`[${requestId}] Inserted row ${rowId} into table ${tableId}`)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
message: 'Row inserted successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error inserting row:`, error)
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
}
}
/** GET /api/table/[tableId]/rows - Queries rows with filtering, sorting, and pagination. */
export async function GET(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
const filterParam = searchParams.get('filter')
const sortParam = searchParams.get('sort')
const limit = searchParams.get('limit')
const offset = searchParams.get('offset')
let filter: Record<string, unknown> | undefined
let sort: Sort | undefined
try {
if (filterParam) {
filter = JSON.parse(filterParam) as Record<string, unknown>
}
if (sortParam) {
sort = JSON.parse(sortParam) as Sort
}
} catch {
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
}
const validated = QueryRowsSchema.parse({
workspaceId,
filter,
sort,
limit,
offset,
})
const accessResult = await checkAccess(tableId, authResult.userId, 'read')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
if (validated.filter) {
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
}
let query = db
.select({
id: userTableRows.id,
data: userTableRows.data,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
.from(userTableRows)
.where(and(...baseConditions))
if (validated.sort) {
const schema = table.schema as TableSchema
const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
if (sortClause) {
query = query.orderBy(sortClause) as typeof query
}
} else {
query = query.orderBy(userTableRows.createdAt) as typeof query
}
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(userTableRows)
.where(and(...baseConditions))
const [{ count: totalCount }] = await countQuery
const rows = await query.limit(validated.limit).offset(validated.offset)
logger.info(
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})`
)
return NextResponse.json({
success: true,
data: {
rows: rows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
rowCount: rows.length,
totalCount: Number(totalCount),
limit: validated.limit,
offset: validated.offset,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error querying rows:`, error)
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
}
}
/** PUT /api/table/[tableId]/rows - Updates rows matching filter criteria. */
export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = UpdateRowsByFilterSchema.parse(body)
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updateData = validated.data as RowData
const sizeValidation = validateRowSize(updateData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Invalid row data', details: sizeValidation.errors },
{ status: 400 }
)
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
let matchingRowsQuery = db
.select({
id: userTableRows.id,
data: userTableRows.data,
})
.from(userTableRows)
.where(and(...baseConditions))
if (validated.limit) {
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
}
const matchingRows = await matchingRowsQuery
if (matchingRows.length === 0) {
return NextResponse.json(
{
success: true,
data: {
message: 'No rows matched the filter criteria',
updatedCount: 0,
},
},
{ status: 200 }
)
}
if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
logger.warn(`[${requestId}] Updating ${matchingRows.length} rows. This may take some time.`)
}
for (const row of matchingRows) {
const existingData = row.data as RowData
const mergedData = { ...existingData, ...updateData }
const rowValidation = validateRowAgainstSchema(mergedData, table.schema as TableSchema)
if (!rowValidation.valid) {
return NextResponse.json(
{
error: 'Updated data does not match schema',
details: rowValidation.errors,
affectedRowId: row.id,
},
{ status: 400 }
)
}
}
const uniqueColumns = getUniqueColumns(table.schema as TableSchema)
if (uniqueColumns.length > 0) {
// If updating multiple rows, check that updateData doesn't set any unique column
// (would cause all rows to have the same value, violating uniqueness)
if (matchingRows.length > 1) {
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in updateData)
if (uniqueColumnsInUpdate.length > 0) {
return NextResponse.json(
{
error: 'Cannot set unique column values when updating multiple rows',
details: [
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`,
],
},
{ status: 400 }
)
}
}
// Check unique constraints against database for each row
for (const row of matchingRows) {
const existingData = row.data as RowData
const mergedData = { ...existingData, ...updateData }
const uniqueValidation = await checkUniqueConstraintsDb(
tableId,
mergedData,
table.schema as TableSchema,
row.id
)
if (!uniqueValidation.valid) {
return NextResponse.json(
{
error: 'Unique constraint violation',
details: uniqueValidation.errors,
affectedRowId: row.id,
},
{ status: 400 }
)
}
}
}
const now = new Date()
await db.transaction(async (trx) => {
let totalUpdated = 0
for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
const updatePromises = batch.map((row) => {
const existingData = row.data as RowData
return trx
.update(userTableRows)
.set({
data: { ...existingData, ...updateData },
updatedAt: now,
})
.where(eq(userTableRows.id, row.id))
})
await Promise.all(updatePromises)
totalUpdated += batch.length
logger.info(
`[${requestId}] Updated batch ${Math.floor(i / TABLE_LIMITS.UPDATE_BATCH_SIZE) + 1} (${totalUpdated}/${matchingRows.length} rows)`
)
}
})
logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${tableId}`)
return NextResponse.json({
success: true,
data: {
message: 'Rows updated successfully',
updatedCount: matchingRows.length,
updatedRowIds: matchingRows.map((r) => r.id),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error updating rows by filter:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to update rows: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
}
}
/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria. */
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = DeleteRowsRequestSchema.parse(body)
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
let rowIds: string[] = []
let missingRowIds: string[] | undefined
let requestedCount: number | undefined
if ('rowIds' in validated) {
const uniqueRequestedRowIds = Array.from(new Set(validated.rowIds))
requestedCount = uniqueRequestedRowIds.length
const matchingRows = await db
.select({ id: userTableRows.id })
.from(userTableRows)
.where(
and(
...baseConditions,
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
uniqueRequestedRowIds.map((id) => sql`${id}`),
sql`, `
)}])`
)
)
const matchedRowIds = matchingRows.map((r) => r.id)
const matchedIdSet = new Set(matchedRowIds)
missingRowIds = uniqueRequestedRowIds.filter((id) => !matchedIdSet.has(id))
rowIds = matchedRowIds
} else {
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
let matchingRowsQuery = db
.select({ id: userTableRows.id })
.from(userTableRows)
.where(and(...baseConditions))
if (validated.limit) {
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
}
const matchingRows = await matchingRowsQuery
rowIds = matchingRows.map((r) => r.id)
}
if (rowIds.length === 0) {
return NextResponse.json(
{
success: true,
data: {
message:
'rowIds' in validated
? 'No matching rows found for the provided IDs'
: 'No rows matched the filter criteria',
deletedCount: 0,
deletedRowIds: [],
...(requestedCount !== undefined ? { requestedCount } : {}),
...(missingRowIds ? { missingRowIds } : {}),
},
},
{ status: 200 }
)
}
if (rowIds.length > TABLE_LIMITS.DELETE_BATCH_SIZE) {
logger.warn(`[${requestId}] Deleting ${rowIds.length} rows. This may take some time.`)
}
await db.transaction(async (trx) => {
let totalDeleted = 0
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
await trx.delete(userTableRows).where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
batch.map((id) => sql`${id}`),
sql`, `
)}])`
)
)
totalDeleted += batch.length
logger.info(
`[${requestId}] Deleted batch ${Math.floor(i / TABLE_LIMITS.DELETE_BATCH_SIZE) + 1} (${totalDeleted}/${rowIds.length} rows)`
)
}
})
logger.info(`[${requestId}] Deleted ${rowIds.length} rows from table ${tableId}`)
return NextResponse.json({
success: true,
data: {
message: 'Rows deleted successfully',
deletedCount: rowIds.length,
deletedRowIds: rowIds,
...(requestedCount !== undefined ? { requestedCount } : {}),
...(missingRowIds ? { missingRowIds } : {}),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting rows by filter:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to delete rows: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
}
}

View File

@@ -0,0 +1,182 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { RowData, TableSchema } from '@/lib/table'
import { getUniqueColumns, validateRowData } from '@/lib/table'
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
const logger = createLogger('TableUpsertAPI')
const UpsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
interface UpsertRouteParams {
params: Promise<{ tableId: string }>
}
/** POST /api/table/[tableId]/rows/upsert - Inserts or updates based on unique columns. */
export async function POST(request: NextRequest, { params }: UpsertRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = UpsertRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const schema = table.schema as TableSchema
const rowData = validated.data as RowData
const validation = await validateRowData({
rowData,
schema,
tableId,
checkUnique: false,
})
if (!validation.valid) return validation.response
const uniqueColumns = getUniqueColumns(schema)
if (uniqueColumns.length === 0) {
return NextResponse.json(
{
error:
'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.',
},
{ status: 400 }
)
}
const uniqueFilters = uniqueColumns.map((col) => {
const value = rowData[col.name]
if (value === undefined || value === null) {
return null
}
return sql`${userTableRows.data}->>${col.name} = ${String(value)}`
})
const validUniqueFilters = uniqueFilters.filter((f): f is Exclude<typeof f, null> => f !== null)
if (validUniqueFilters.length === 0) {
return NextResponse.json(
{
error: `Upsert requires values for at least one unique field: ${uniqueColumns.map((c) => c.name).join(', ')}`,
},
{ status: 400 }
)
}
const [existingRow] = await db
.select()
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
or(...validUniqueFilters)
)
)
.limit(1)
const now = new Date()
if (!existingRow && table.rowCount >= table.maxRows) {
return NextResponse.json(
{ error: `Table row limit reached (${table.maxRows} rows max)` },
{ status: 400 }
)
}
const upsertResult = await db.transaction(async (trx) => {
if (existingRow) {
const [updatedRow] = await trx
.update(userTableRows)
.set({
data: validated.data,
updatedAt: now,
})
.where(eq(userTableRows.id, existingRow.id))
.returning()
return {
row: updatedRow,
operation: 'update' as const,
}
}
const [insertedRow] = await trx
.insert(userTableRows)
.values({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId,
workspaceId: validated.workspaceId,
data: validated.data,
createdAt: now,
updatedAt: now,
createdBy: authResult.userId,
})
.returning()
return {
row: insertedRow,
operation: 'insert' as const,
}
})
logger.info(
`[${requestId}] Upserted (${upsertResult.operation}) row ${upsertResult.row.id} in table ${tableId}`
)
return NextResponse.json({
success: true,
data: {
row: {
id: upsertResult.row.id,
data: upsertResult.row.data,
createdAt: upsertResult.row.createdAt.toISOString(),
updatedAt: upsertResult.row.updatedAt.toISOString(),
},
operation: upsertResult.operation,
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error upserting row:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to upsert row: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
}
}

View File

@@ -0,0 +1,258 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
canCreateTable,
createTable,
getWorkspaceTableLimits,
listTables,
TABLE_LIMITS,
type TableSchema,
} from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { normalizeColumn } from './utils'
const logger = createLogger('TableAPI')
const ColumnSchema = z.object({
name: z
.string()
.min(1, 'Column name is required')
.max(
TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH,
`Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
)
.regex(
/^[a-z_][a-z0-9_]*$/i,
'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores'
),
type: z.enum(['string', 'number', 'boolean', 'date', 'json'], {
errorMap: () => ({
message: 'Column type must be one of: string, number, boolean, date, json',
}),
}),
required: z.boolean().optional().default(false),
unique: z.boolean().optional().default(false),
})
const CreateTableSchema = z.object({
name: z
.string()
.min(1, 'Table name is required')
.max(
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH,
`Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less`
)
.regex(
/^[a-z_][a-z0-9_]*$/i,
'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores'
),
description: z
.string()
.max(
TABLE_LIMITS.MAX_DESCRIPTION_LENGTH,
`Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`
)
.optional(),
schema: z.object({
columns: z
.array(ColumnSchema)
.min(1, 'Table must have at least one column')
.max(
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
`Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
),
}),
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
const ListTablesSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
interface WorkspaceAccessResult {
hasAccess: boolean
canWrite: boolean
}
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceAccessResult> {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission === null) {
return { hasAccess: false, canWrite: false }
}
const canWrite = permission === 'admin' || permission === 'write'
return { hasAccess: true, canWrite }
}
/** POST /api/table - Creates a new user-defined table. */
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const params = CreateTableSchema.parse(body)
const { hasAccess, canWrite } = await checkWorkspaceAccess(
params.workspaceId,
authResult.userId
)
if (!hasAccess || !canWrite) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check billing plan limits
const existingTables = await listTables(params.workspaceId)
const { canCreate, maxTables } = await canCreateTable(params.workspaceId, existingTables.length)
if (!canCreate) {
return NextResponse.json(
{
error: `Workspace has reached the maximum table limit (${maxTables}) for your plan. Please upgrade to create more tables.`,
},
{ status: 403 }
)
}
// Get plan-based row limits
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
const maxRowsPerTable = planLimits.maxRowsPerTable
const normalizedSchema: TableSchema = {
columns: params.schema.columns.map(normalizeColumn),
}
const table = await createTable(
{
name: params.name,
description: params.description,
schema: normalizedSchema,
workspaceId: params.workspaceId,
userId: authResult.userId,
maxRows: maxRowsPerTable,
},
requestId
)
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
message: 'Table created successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
if (
error.message.includes('Invalid table name') ||
error.message.includes('Invalid schema') ||
error.message.includes('already exists') ||
error.message.includes('maximum table limit')
) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
logger.error(`[${requestId}] Error creating table:`, error)
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
}
}
/** GET /api/table - Lists all tables in a workspace. */
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
const validation = ListTablesSchema.safeParse({ workspaceId })
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation error', details: validation.error.errors },
{ status: 400 }
)
}
const params = validation.data
const { hasAccess } = await checkWorkspaceAccess(params.workspaceId, authResult.userId)
if (!hasAccess) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const tables = await listTables(params.workspaceId)
logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`)
return NextResponse.json({
success: true,
data: {
tables: tables.map((t) => {
const schemaData = t.schema as TableSchema
return {
...t,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
}
}),
totalCount: tables.length,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error listing tables:`, error)
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
}
}

View File

@@ -0,0 +1,164 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type { ColumnDefinition, TableDefinition } from '@/lib/table'
import { getTableById } from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('TableUtils')
export interface TableAccessResult {
hasAccess: true
table: TableDefinition
}
export interface TableAccessDenied {
hasAccess: false
notFound?: boolean
reason?: string
}
export type TableAccessCheck = TableAccessResult | TableAccessDenied
export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 }
export interface ApiErrorResponse {
error: string
details?: unknown
}
/**
* Check if a user has read access to a table.
* Read access requires any workspace permission (read, write, or admin).
*/
export async function checkTableAccess(tableId: string, userId: string): Promise<TableAccessCheck> {
const table = await getTableById(tableId)
if (!table) {
return { hasAccess: false, notFound: true }
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
if (userPermission !== null) {
return { hasAccess: true, table }
}
return { hasAccess: false, reason: 'User does not have access to this table' }
}
/**
* Check if a user has write access to a table.
* Write access requires write or admin workspace permission.
*/
export async function checkTableWriteAccess(
tableId: string,
userId: string
): Promise<TableAccessCheck> {
const table = await getTableById(tableId)
if (!table) {
return { hasAccess: false, notFound: true }
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
if (userPermission === 'write' || userPermission === 'admin') {
return { hasAccess: true, table }
}
return { hasAccess: false, reason: 'User does not have write access to this table' }
}
/**
* Access check returning `{ ok, table }` or `{ ok: false, status }`.
* Uses workspace permissions only.
*/
export async function checkAccess(
tableId: string,
userId: string,
level: 'read' | 'write' | 'admin' = 'read'
): Promise<AccessResult> {
const table = await getTableById(tableId)
if (!table) {
return { ok: false, status: 404 }
}
const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
const hasAccess =
permission !== null &&
(level === 'read' ||
(level === 'write' && (permission === 'write' || permission === 'admin')) ||
(level === 'admin' && permission === 'admin'))
return hasAccess ? { ok: true, table } : { ok: false, status: 403 }
}
export function accessError(
result: { ok: false; status: 404 | 403 },
requestId: string,
context?: string
): NextResponse {
const message = result.status === 404 ? 'Table not found' : 'Access denied'
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
return NextResponse.json({ error: message }, { status: result.status })
}
/**
* Converts a TableAccessDenied result to an appropriate HTTP response.
* Use with checkTableAccess or checkTableWriteAccess.
*/
export function tableAccessError(
result: TableAccessDenied,
requestId: string,
context?: string
): NextResponse {
const status = result.notFound ? 404 : 403
const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied')
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
return NextResponse.json({ error: message }, { status })
}
export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
const table = await getTableById(tableId)
return table?.workspaceId === workspaceId
}
export function errorResponse(
message: string,
status: number,
details?: unknown
): NextResponse<ApiErrorResponse> {
const body: ApiErrorResponse = { error: message }
if (details !== undefined) {
body.details = details
}
return NextResponse.json(body, { status })
}
export function badRequestResponse(message: string, details?: unknown) {
return errorResponse(message, 400, details)
}
export function unauthorizedResponse(message = 'Authentication required') {
return errorResponse(message, 401)
}
export function forbiddenResponse(message = 'Access denied') {
return errorResponse(message, 403)
}
export function notFoundResponse(message = 'Resource not found') {
return errorResponse(message, 404)
}
export function serverErrorResponse(message = 'Internal server error') {
return errorResponse(message, 500)
}
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
return {
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
}
}

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { createMockRequest, loggerMock } from '@sim/testing'
import { createMockRequest, loggerMock, mockHybridAuth } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -180,13 +180,12 @@ describe('Custom Tools API Routes', () => {
getSession: vi.fn().mockResolvedValue(mockSession),
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'user-123',
authType: 'session',
}),
}))
const { mockCheckSessionOrInternalAuth: hybridAuthMock } = mockHybridAuth()
hybridAuthMock.mockResolvedValue({
success: true,
userId: 'user-123',
authType: 'session',
})
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
@@ -261,12 +260,11 @@ describe('Custom Tools API Routes', () => {
'http://localhost:3000/api/tools/custom?workspaceId=workspace-123'
)
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: false,
error: 'Unauthorized',
}),
}))
const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth()
unauthMock.mockResolvedValue({
success: false,
error: 'Unauthorized',
})
const { GET } = await import('@/app/api/tools/custom/route')
@@ -297,12 +295,11 @@ describe('Custom Tools API Routes', () => {
*/
describe('POST /api/tools/custom', () => {
it('should reject unauthorized requests', async () => {
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: false,
error: 'Unauthorized',
}),
}))
const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth()
unauthMock.mockResolvedValue({
success: false,
error: 'Unauthorized',
})
const req = createMockRequest('POST', { tools: [], workspaceId: 'workspace-123' })
@@ -384,13 +381,12 @@ describe('Custom Tools API Routes', () => {
})
it('should prevent unauthorized deletion of user-scoped tool', async () => {
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'user-456',
authType: 'session',
}),
}))
const { mockCheckSessionOrInternalAuth: diffUserMock } = mockHybridAuth()
diffUserMock.mockResolvedValue({
success: true,
userId: 'user-456',
authType: 'session',
})
const userScopedTool = { ...sampleTools[0], workspaceId: null, userId: 'user-123' }
const mockLimitUserScoped = vi.fn().mockResolvedValue([userScopedTool])
@@ -408,12 +404,11 @@ describe('Custom Tools API Routes', () => {
})
it('should reject unauthorized requests', async () => {
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
success: false,
error: 'Unauthorized',
}),
}))
const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth()
unauthMock.mockResolvedValue({
success: false,
error: 'Unauthorized',
})
const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1')

View File

@@ -0,0 +1,145 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('DataverseUploadFileAPI')
const DataverseUploadFileSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
environmentUrl: z.string().min(1, 'Environment URL is required'),
entitySetName: z.string().min(1, 'Entity set name is required'),
recordId: z.string().min(1, 'Record ID is required'),
fileColumn: z.string().min(1, 'File column is required'),
fileName: z.string().min(1, 'File name is required'),
file: RawFileInputSchema.optional().nullable(),
fileContent: z.string().optional().nullable(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized Dataverse upload attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated Dataverse upload request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = DataverseUploadFileSchema.parse(body)
logger.info(`[${requestId}] Uploading file to Dataverse`, {
entitySetName: validatedData.entitySetName,
recordId: validatedData.recordId,
fileColumn: validatedData.fileColumn,
fileName: validatedData.fileName,
hasFile: !!validatedData.file,
hasFileContent: !!validatedData.fileContent,
})
let fileBuffer: Buffer
if (validatedData.file) {
const rawFile = validatedData.file
logger.info(`[${requestId}] Processing UserFile upload: ${rawFile.name}`)
let userFile
try {
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to process file',
},
{ status: 400 }
)
}
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
} else if (validatedData.fileContent) {
fileBuffer = Buffer.from(validatedData.fileContent, 'base64')
} else {
return NextResponse.json(
{ success: false, error: 'Either file or fileContent must be provided' },
{ status: 400 }
)
}
const baseUrl = validatedData.environmentUrl.replace(/\/$/, '')
const uploadUrl = `${baseUrl}/api/data/v9.2/${validatedData.entitySetName}(${validatedData.recordId})/${validatedData.fileColumn}`
const response = await fetch(uploadUrl, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
'Content-Type': 'application/octet-stream',
'OData-MaxVersion': '4.0',
'OData-Version': '4.0',
'x-ms-file-name': validatedData.fileName,
},
body: new Uint8Array(fileBuffer),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData?.error?.message ??
`Dataverse API error: ${response.status} ${response.statusText}`
logger.error(`[${requestId}] Dataverse upload file failed`, {
errorData,
status: response.status,
})
return NextResponse.json({ success: false, error: errorMessage }, { status: response.status })
}
logger.info(`[${requestId}] File uploaded to Dataverse successfully`, {
entitySetName: validatedData.entitySetName,
recordId: validatedData.recordId,
fileColumn: validatedData.fileColumn,
})
return NextResponse.json({
success: true,
output: {
recordId: validatedData.recordId,
fileColumn: validatedData.fileColumn,
fileName: validatedData.fileName,
success: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ success: false, error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error uploading file to Dataverse:`, error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,57 @@
import { createLogger } from '@sim/logger'
import Redis from 'ioredis'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
const logger = createLogger('RedisAPI')
const RequestSchema = z.object({
url: z.string().min(1, 'Redis connection URL is required'),
command: z.string().min(1, 'Redis command is required'),
args: z.array(z.union([z.string(), z.number()])).default([]),
})
export async function POST(request: NextRequest) {
let client: Redis | null = null
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { url, command, args } = RequestSchema.parse(body)
client = new Redis(url, {
connectTimeout: 10000,
commandTimeout: 10000,
maxRetriesPerRequest: 1,
lazyConnect: true,
})
await client.connect()
const cmd = command.toUpperCase()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (client as any).call(cmd, ...args)
await client.quit()
client = null
return NextResponse.json({ result })
} catch (error) {
logger.error('Redis command failed', { error })
const errorMessage = error instanceof Error ? error.message : 'Redis command failed'
return NextResponse.json({ error: errorMessage }, { status: 500 })
} finally {
if (client) {
try {
await client.quit()
} catch {
client.disconnect()
}
}
}
}

View File

@@ -766,7 +766,7 @@ async function transcribeWithGemini(
const error = await response.json()
if (response.status === 404) {
throw new Error(
`Model not found: ${modelName}. Use gemini-3-pro-preview, gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, or gemini-2.0-flash-exp`
`Model not found: ${modelName}. Use gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, or gemini-2.0-flash-exp`
)
}
const errorMessage = error.error?.message || JSON.stringify(error)

View File

@@ -10,6 +10,7 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { enrichTableSchema } from '@/lib/table/llm/wand'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils'
import { getModelPricing } from '@/providers/utils'
@@ -48,6 +49,7 @@ interface RequestBody {
history?: ChatMessage[]
workflowId?: string
generationType?: string
wandContext?: Record<string, unknown>
}
function safeStringify(value: unknown): string {
@@ -58,6 +60,38 @@ function safeStringify(value: unknown): string {
}
}
/**
* Wand enricher function type.
* Enrichers add context to the system prompt based on generationType.
*/
type WandEnricher = (
workspaceId: string | null,
context: Record<string, unknown>
) => Promise<string | null>
/**
* Registry of wand enrichers by generationType.
* Each enricher returns additional context to append to the system prompt.
*/
const wandEnrichers: Partial<Record<string, WandEnricher>> = {
timestamp: async () => {
const now = new Date()
return `Current date and time context for reference:
- Current UTC timestamp: ${now.toISOString()}
- Current Unix timestamp (seconds): ${Math.floor(now.getTime() / 1000)}
- Current Unix timestamp (milliseconds): ${now.getTime()}
- Current date (UTC): ${now.toISOString().split('T')[0]}
- Current year: ${now.getUTCFullYear()}
- Current month: ${now.getUTCMonth() + 1}
- Current day of month: ${now.getUTCDate()}
- Current day of week: ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getUTCDay()]}
Use this context to calculate relative dates like "yesterday", "last week", "beginning of this month", etc.`
},
'table-schema': enrichTableSchema,
}
async function updateUserStatsForWand(
userId: string,
usage: {
@@ -147,7 +181,15 @@ export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as RequestBody
const { prompt, systemPrompt, stream = false, history = [], workflowId, generationType } = body
const {
prompt,
systemPrompt,
stream = false,
history = [],
workflowId,
generationType,
wandContext = {},
} = body
if (!prompt) {
logger.warn(`[${requestId}] Invalid request: Missing prompt.`)
@@ -222,20 +264,15 @@ export async function POST(req: NextRequest) {
systemPrompt ||
'You are a helpful AI assistant. Generate content exactly as requested by the user.'
if (generationType === 'timestamp') {
const now = new Date()
const currentTimeContext = `\n\nCurrent date and time context for reference:
- Current UTC timestamp: ${now.toISOString()}
- Current Unix timestamp (seconds): ${Math.floor(now.getTime() / 1000)}
- Current Unix timestamp (milliseconds): ${now.getTime()}
- Current date (UTC): ${now.toISOString().split('T')[0]}
- Current year: ${now.getUTCFullYear()}
- Current month: ${now.getUTCMonth() + 1}
- Current day of month: ${now.getUTCDate()}
- Current day of week: ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getUTCDay()]}
Use this context to calculate relative dates like "yesterday", "last week", "beginning of this month", etc.`
finalSystemPrompt += currentTimeContext
// Apply enricher if one exists for this generationType
if (generationType) {
const enricher = wandEnrichers[generationType]
if (enricher) {
const enrichment = await enricher(workspaceId, wandContext)
if (enrichment) {
finalSystemPrompt += `\n\n${enrichment}`
}
}
}
if (generationType === 'cron-expression') {

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { createMockRequest, loggerMock } from '@sim/testing'
import { createMockRequest, loggerMock, requestUtilsMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/** Mock execution dependencies for webhook tests */
@@ -348,9 +348,7 @@ vi.mock('postgres', () => vi.fn().mockReturnValue({}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'

View File

@@ -3,11 +3,11 @@
*
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { loggerMock, mockHybridAuth } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockCheckSessionOrInternalAuth = vi.fn()
let mockCheckSessionOrInternalAuth: ReturnType<typeof vi.fn>
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockDbSelect = vi.fn()
const mockDbFrom = vi.fn()
@@ -48,9 +48,7 @@ describe('Workflow Chat Status Route', () => {
workflowId: 'workflowId',
},
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
;({ mockCheckSessionOrInternalAuth } = mockHybridAuth())
vi.doMock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
}))

View File

@@ -3,11 +3,11 @@
*
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { loggerMock, mockHybridAuth } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockCheckSessionOrInternalAuth = vi.fn()
let mockCheckSessionOrInternalAuth: ReturnType<typeof vi.fn>
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockDbSelect = vi.fn()
const mockDbFrom = vi.fn()
@@ -43,9 +43,7 @@ describe('Workflow Form Status Route', () => {
isActive: 'isActive',
},
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
;({ mockCheckSessionOrInternalAuth } = mockHybridAuth())
vi.doMock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
}))

View File

@@ -5,11 +5,19 @@
* @vitest-environment node
*/
import { auditMock, loggerMock, setupGlobalFetchMock } from '@sim/testing'
import {
auditMock,
envMock,
loggerMock,
requestUtilsMock,
setupGlobalFetchMock,
telemetryMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetSession = vi.fn()
const mockCheckHybridAuth = vi.fn()
const mockCheckSessionOrInternalAuth = vi.fn()
const mockLoadWorkflowFromNormalizedTables = vi.fn()
const mockGetWorkflowById = vi.fn()
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
@@ -17,10 +25,34 @@ const mockDbDelete = vi.fn()
const mockDbUpdate = vi.fn()
const mockDbSelect = vi.fn()
/**
* Helper to set mock auth state consistently across getSession and hybrid auth.
*/
function mockGetSession(session: { user: { id: string } } | null) {
if (session) {
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: session.user.id })
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: session.user.id })
} else {
mockCheckHybridAuth.mockResolvedValue({ success: false })
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
}
}
vi.mock('@/lib/auth', () => ({
getSession: () => mockGetSession(),
getSession: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
}))
vi.mock('@/lib/core/config/env', () => envMock)
vi.mock('@/lib/core/telemetry', () => telemetryMock)
vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/audit/log', () => auditMock)
@@ -30,20 +62,14 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
mockLoadWorkflowFromNormalizedTables(workflowId),
}))
vi.mock('@/lib/workflows/utils', async () => {
const actual =
await vi.importActual<typeof import('@/lib/workflows/utils')>('@/lib/workflows/utils')
return {
...actual,
getWorkflowById: (workflowId: string) => mockGetWorkflowById(workflowId),
authorizeWorkflowByWorkspacePermission: (params: {
workflowId: string
userId: string
action?: 'read' | 'write' | 'admin'
}) => mockAuthorizeWorkflowByWorkspacePermission(params),
}
})
vi.mock('@/lib/workflows/utils', () => ({
getWorkflowById: (workflowId: string) => mockGetWorkflowById(workflowId),
authorizeWorkflowByWorkspacePermission: (params: {
workflowId: string
userId: string
action?: 'read' | 'write' | 'admin'
}) => mockAuthorizeWorkflowByWorkspacePermission(params),
}))
vi.mock('@sim/db', () => ({
db: {
@@ -73,7 +99,7 @@ describe('Workflow By ID API Route', () => {
describe('GET /api/workflows/[id]', () => {
it('should return 401 when user is not authenticated', async () => {
mockGetSession.mockResolvedValue(null)
mockGetSession(null)
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
@@ -86,9 +112,7 @@ describe('Workflow By ID API Route', () => {
})
it('should return 404 when workflow does not exist', async () => {
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(null)
@@ -118,9 +142,7 @@ describe('Workflow By ID API Route', () => {
isFromNormalizedTables: true,
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -158,9 +180,7 @@ describe('Workflow By ID API Route', () => {
isFromNormalizedTables: true,
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -190,9 +210,7 @@ describe('Workflow By ID API Route', () => {
workspaceId: 'workspace-456',
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -229,9 +247,7 @@ describe('Workflow By ID API Route', () => {
isFromNormalizedTables: true,
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -264,9 +280,7 @@ describe('Workflow By ID API Route', () => {
workspaceId: 'workspace-456',
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -308,9 +322,7 @@ describe('Workflow By ID API Route', () => {
workspaceId: 'workspace-456',
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -353,9 +365,7 @@ describe('Workflow By ID API Route', () => {
workspaceId: 'workspace-456',
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -392,9 +402,7 @@ describe('Workflow By ID API Route', () => {
workspaceId: 'workspace-456',
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -419,6 +427,16 @@ describe('Workflow By ID API Route', () => {
})
describe('PUT /api/workflows/[id]', () => {
function mockDuplicateCheck(results: Array<{ id: string }> = []) {
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(results),
}),
}),
})
}
it('should allow user with write permission to update workflow', async () => {
const mockWorkflow = {
id: 'workflow-123',
@@ -430,9 +448,7 @@ describe('Workflow By ID API Route', () => {
const updateData = { name: 'Updated Workflow' }
const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() }
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -442,6 +458,8 @@ describe('Workflow By ID API Route', () => {
workspacePermission: 'write',
})
mockDuplicateCheck([])
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
@@ -474,9 +492,7 @@ describe('Workflow By ID API Route', () => {
const updateData = { name: 'Updated Workflow' }
const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() }
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -486,6 +502,8 @@ describe('Workflow By ID API Route', () => {
workspacePermission: 'write',
})
mockDuplicateCheck([])
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
@@ -517,9 +535,7 @@ describe('Workflow By ID API Route', () => {
const updateData = { name: 'Updated Workflow' }
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -551,9 +567,7 @@ describe('Workflow By ID API Route', () => {
workspaceId: 'workspace-456',
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
@@ -577,13 +591,238 @@ describe('Workflow By ID API Route', () => {
const data = await response.json()
expect(data.error).toBe('Invalid request data')
})
it('should reject rename when duplicate name exists in same folder', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Original Name',
folderId: 'folder-1',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDuplicateCheck([{ id: 'workflow-other' }])
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ name: 'Duplicate Name' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(409)
const data = await response.json()
expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder')
})
it('should reject rename when duplicate name exists at root level', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Original Name',
folderId: null,
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDuplicateCheck([{ id: 'workflow-other' }])
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ name: 'Duplicate Name' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(409)
const data = await response.json()
expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder')
})
it('should allow rename when no duplicate exists in same folder', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Original Name',
folderId: 'folder-1',
workspaceId: 'workspace-456',
}
const updatedWorkflow = { ...mockWorkflow, name: 'Unique Name', updatedAt: new Date() }
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDuplicateCheck([])
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
}),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ name: 'Unique Name' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.workflow.name).toBe('Unique Name')
})
it('should allow same name in different folders', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'My Workflow',
folderId: 'folder-1',
workspaceId: 'workspace-456',
}
const updatedWorkflow = { ...mockWorkflow, folderId: 'folder-2', updatedAt: new Date() }
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
// No duplicate in target folder
mockDuplicateCheck([])
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
}),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ folderId: 'folder-2' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.workflow.folderId).toBe('folder-2')
})
it('should reject moving to a folder where same name already exists', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'My Workflow',
folderId: 'folder-1',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
// Duplicate exists in target folder
mockDuplicateCheck([{ id: 'workflow-other' }])
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ folderId: 'folder-2' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(409)
const data = await response.json()
expect(data.error).toBe('A workflow named "My Workflow" already exists in this folder')
})
it('should skip duplicate check when only updating non-name/non-folder fields', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
const updatedWorkflow = { ...mockWorkflow, color: '#FF0000', updatedAt: new Date() }
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
}),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ color: '#FF0000' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(200)
// db.select should NOT have been called since no name/folder change
expect(mockDbSelect).not.toHaveBeenCalled()
})
})
describe('Error handling', () => {
it.concurrent('should handle database errors gracefully', async () => {
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockRejectedValue(new Error('Database connection timeout'))

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { templates, webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, eq, isNull, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
@@ -411,6 +411,45 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
if (updates.name !== undefined || updates.folderId !== undefined) {
const targetName = updates.name ?? workflowData.name
const targetFolderId =
updates.folderId !== undefined ? updates.folderId : workflowData.folderId
if (!workflowData.workspaceId) {
logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
const conditions = [
eq(workflow.workspaceId, workflowData.workspaceId),
eq(workflow.name, targetName),
ne(workflow.id, workflowId),
]
if (targetFolderId) {
conditions.push(eq(workflow.folderId, targetFolderId))
} else {
conditions.push(isNull(workflow.folderId))
}
const [duplicate] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...conditions))
.limit(1)
if (duplicate) {
logger.warn(
`[${requestId}] Duplicate workflow name "${targetName}" in folder ${targetFolderId ?? 'root'}`
)
return NextResponse.json(
{ error: `A workflow named "${targetName}" already exists in this folder` },
{ status: 409 }
)
}
}
// Update the workflow
const [updatedWorkflow] = await db
.update(workflow)

View File

@@ -1,11 +1,16 @@
/**
* @vitest-environment node
*/
import { auditMock, createMockRequest, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
import {
auditMock,
createMockRequest,
mockConsoleLogger,
mockHybridAuth,
setupCommonApiMocks,
} from '@sim/testing'
import { drizzleOrmMock } from '@sim/testing/mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCheckSessionOrInternalAuth = vi.fn()
const mockGetUserEntityPermissions = vi.fn()
const mockDbSelect = vi.fn()
const mockDbInsert = vi.fn()
@@ -30,6 +35,7 @@ describe('Workflows API Route - POST ordering', () => {
randomUUID: vi.fn().mockReturnValue('workflow-new-id'),
})
const { mockCheckSessionOrInternalAuth } = mockHybridAuth()
mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-123',
@@ -45,10 +51,6 @@ describe('Workflows API Route - POST ordering', () => {
},
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
}))
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args),
workspaceExists: vi.fn(),

View File

@@ -0,0 +1,31 @@
'use client'
import { Trash2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
interface ActionBarProps {
selectedCount: number
onDelete: () => void
onClearSelection: () => void
}
export function ActionBar({ selectedCount, onDelete, onClearSelection }: ActionBarProps) {
return (
<div className='flex h-[36px] shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-[16px]'>
<div className='flex items-center gap-[12px]'>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{selectedCount} {selectedCount === 1 ? 'row' : 'rows'} selected
</span>
<Button variant='ghost' size='sm' onClick={onClearSelection}>
<X className='mr-[4px] h-[10px] w-[10px]' />
Clear
</Button>
</div>
<Button variant='destructive' size='sm' onClick={onDelete}>
<Trash2 className='mr-[4px] h-[10px] w-[10px]' />
Delete
</Button>
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { Plus } from 'lucide-react'
import { Button, TableCell, TableRow } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import type { ColumnDefinition } from '@/lib/table'
interface LoadingRowsProps {
columns: ColumnDefinition[]
}
export function LoadingRows({ columns }: LoadingRowsProps) {
return (
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
<TableRow key={rowIndex}>
<TableCell>
<Skeleton className='h-[14px] w-[14px]' />
</TableCell>
{columns.map((col, colIndex) => {
const baseWidth =
col.type === 'json'
? 200
: col.type === 'string'
? 160
: col.type === 'number'
? 80
: col.type === 'boolean'
? 50
: col.type === 'date'
? 100
: 120
const variation = ((rowIndex + colIndex) % 3) * 20
const width = baseWidth + variation
return (
<TableCell key={col.name}>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</TableCell>
)
})}
</TableRow>
))}
</>
)
}
interface EmptyRowsProps {
columnCount: number
hasFilter: boolean
onAddRow: () => void
}
export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) {
return (
<TableRow>
<TableCell colSpan={columnCount + 1} className='h-[160px]'>
<div className='-translate-x-1/2 fixed left-1/2'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{hasFilter ? 'No rows match your filter' : 'No data'}
</span>
{!hasFilter && (
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
</Button>
)}
</div>
</div>
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,99 @@
import type { ColumnDefinition } from '@/lib/table'
import { STRING_TRUNCATE_LENGTH } from '../lib/constants'
import type { CellViewerData } from '../lib/types'
interface CellRendererProps {
value: unknown
column: ColumnDefinition
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
}
export function CellRenderer({ value, column, onCellClick }: CellRendererProps) {
const isNull = value === null || value === undefined
if (isNull) {
return <span className='text-[var(--text-muted)] italic'></span>
}
if (column.type === 'json') {
const jsonStr = JSON.stringify(value)
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'json')
}}
title='Click to view full JSON'
>
{jsonStr}
</button>
)
}
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{boolValue ? 'true' : 'false'}
</span>
)
}
if (column.type === 'number') {
return (
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
)
}
if (column.type === 'date') {
try {
const date = new Date(String(value))
const formatted = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return (
<button
type='button'
className='cursor-pointer select-none text-left text-[12px] text-[var(--text-secondary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:text-[var(--text-primary)] hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'date')
}}
title='Click to view ISO format'
>
{formatted}
</button>
)
} catch {
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
}
const strValue = String(value)
if (strValue.length > STRING_TRUNCATE_LENGTH) {
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate text-left text-[var(--text-primary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'text')
}}
title='Click to view full text'
>
{strValue}
</button>
)
}
return <span className='text-[var(--text-primary)]'>{strValue}</span>
}

View File

@@ -0,0 +1,84 @@
import { Copy, X } from 'lucide-react'
import { Badge, Button, Modal, ModalBody, ModalContent } from '@/components/emcn'
import type { CellViewerData } from '../lib/types'
interface CellViewerModalProps {
cellViewer: CellViewerData | null
onClose: () => void
onCopy: () => void
copied: boolean
}
export function CellViewerModal({ cellViewer, onClose, onCopy, copied }: CellViewerModalProps) {
if (!cellViewer) return null
return (
<Modal open={!!cellViewer} onOpenChange={(open) => !open && onClose()}>
<ModalContent className='w-[640px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{cellViewer.columnName}
</span>
<Badge
variant={
cellViewer.type === 'json' ? 'blue' : cellViewer.type === 'date' ? 'purple' : 'gray'
}
size='sm'
>
{cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'}
</Badge>
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
<Button variant={copied ? 'tertiary' : 'default'} size='sm' onClick={onCopy}>
<Copy className='mr-[4px] h-[12px] w-[12px]' />
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
<ModalBody className='p-0'>
{cellViewer.type === 'json' ? (
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
{JSON.stringify(cellViewer.value, null, 2)}
</pre>
) : cellViewer.type === 'date' ? (
<div className='m-[16px] space-y-[12px]'>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
Formatted
</div>
<div className='text-[14px] text-[var(--text-primary)]'>
{new Date(String(cellViewer.value)).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})}
</div>
</div>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
ISO Format
</div>
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
{String(cellViewer.value)}
</div>
</div>
</div>
) : (
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
{String(cellViewer.value)}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,49 @@
import { Edit, Trash2 } from 'lucide-react'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { ContextMenuState } from '../lib/types'
interface ContextMenuProps {
contextMenu: ContextMenuState
onClose: () => void
onEdit: () => void
onDelete: () => void
}
export function ContextMenu({ contextMenu, onClose, onEdit, onDelete }: ContextMenuProps) {
return (
<Popover
open={contextMenu.isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={onEdit}>
<Edit className='mr-[8px] h-[12px] w-[12px]' />
Edit row
</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={onDelete} className='text-[var(--text-error)]'>
<Trash2 className='mr-[8px] h-[12px] w-[12px]' />
Delete row
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,63 @@
import { Info, RefreshCw } from 'lucide-react'
import { Badge, Button, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
interface HeaderBarProps {
tableName: string
totalCount: number
isLoading: boolean
onNavigateBack: () => void
onShowSchema: () => void
onRefresh: () => void
}
export function HeaderBar({
tableName,
totalCount,
isLoading,
onNavigateBack,
onShowSchema,
onRefresh,
}: HeaderBarProps) {
return (
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b px-[16px]'>
<div className='flex items-center gap-[8px]'>
<button
onClick={onNavigateBack}
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Tables
</button>
<span className='text-[var(--text-muted)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>{tableName}</span>
{isLoading ? (
<Skeleton className='h-[18px] w-[60px] rounded-full' />
) : (
<Badge variant='gray-secondary' size='sm'>
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
</Badge>
)}
</div>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onShowSchema}>
<Info className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>View Schema</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onRefresh}>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
export * from './action-bar'
export * from './body-states'
export * from './cell-renderer'
export * from './cell-viewer-modal'
export * from './context-menu'
export * from './header-bar'
export * from './pagination'
export * from './query-builder'
export * from './row-modal'
export * from './schema-modal'
export * from './table-viewer'

View File

@@ -0,0 +1,40 @@
import { Button } from '@/components/emcn'
interface PaginationProps {
currentPage: number
totalPages: number
totalCount: number
onPreviousPage: () => void
onNextPage: () => void
}
export function Pagination({
currentPage,
totalPages,
totalCount,
onPreviousPage,
onNextPage,
}: PaginationProps) {
if (totalPages <= 1) return null
return (
<div className='flex h-[40px] shrink-0 items-center justify-between border-[var(--border)] border-t px-[16px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>
Page {currentPage + 1} of {totalPages} ({totalCount} rows)
</span>
<div className='flex items-center gap-[4px]'>
<Button variant='ghost' size='sm' onClick={onPreviousPage} disabled={currentPage === 0}>
Previous
</Button>
<Button
variant='ghost'
size='sm'
onClick={onNextPage}
disabled={currentPage === totalPages - 1}
>
Next
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { X } from 'lucide-react'
import { Button, Combobox, Input } from '@/components/emcn'
import type { FilterRule } from '@/lib/table/query-builder/constants'
interface FilterRowProps {
rule: FilterRule
index: number
columnOptions: Array<{ value: string; label: string }>
comparisonOptions: Array<{ value: string; label: string }>
logicalOptions: Array<{ value: string; label: string }>
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
onRemove: (id: string) => void
onApply: () => void
}
export function FilterRow({
rule,
index,
columnOptions,
comparisonOptions,
logicalOptions,
onUpdate,
onRemove,
onApply,
}: FilterRowProps) {
return (
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(rule.id)}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
{index === 0 ? (
<Combobox
size='sm'
options={[{ value: 'where', label: 'where' }]}
value='where'
disabled
/>
) : (
<Combobox
size='sm'
options={logicalOptions}
value={rule.logicalOperator}
onChange={(value) => onUpdate(rule.id, 'logicalOperator', value as 'and' | 'or')}
/>
)}
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={rule.column}
onChange={(value) => onUpdate(rule.id, 'column', value)}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={rule.operator}
onChange={(value) => onUpdate(rule.id, 'operator', value)}
/>
</div>
<Input
className='h-[28px] min-w-[200px] flex-1 text-[12px]'
value={rule.value}
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
placeholder='Value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
onApply()
}
}}
/>
</div>
)
}

View File

@@ -0,0 +1,137 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ArrowUpAZ, Loader2, Plus } from 'lucide-react'
import { nanoid } from 'nanoid'
import { Button } from '@/components/emcn'
import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants'
import { filterRulesToFilter, sortRuleToSort } from '@/lib/table/query-builder/converters'
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
import type { ColumnDefinition } from '@/lib/table/types'
import type { QueryOptions } from '../../lib/types'
import { FilterRow } from './filter-row'
import { SortRow } from './sort-row'
type Column = Pick<ColumnDefinition, 'name' | 'type'>
interface QueryBuilderProps {
columns: Column[]
onApply: (options: QueryOptions) => void
onAddRow: () => void
isLoading?: boolean
}
export function QueryBuilder({ columns, onApply, onAddRow, isLoading = false }: QueryBuilderProps) {
const [rules, setRules] = useState<FilterRule[]>([])
const [sortRule, setSortRule] = useState<SortRule | null>(null)
const columnOptions = useMemo(
() => columns.map((col) => ({ value: col.name, label: col.name })),
[columns]
)
const {
comparisonOptions,
logicalOptions,
sortDirectionOptions,
addRule: handleAddRule,
removeRule: handleRemoveRule,
updateRule: handleUpdateRule,
} = useFilterBuilder({
columns: columnOptions,
rules,
setRules,
})
const handleAddSort = useCallback(() => {
setSortRule({
id: nanoid(),
column: columns[0]?.name || '',
direction: 'asc',
})
}, [columns])
const handleRemoveSort = useCallback(() => {
setSortRule(null)
}, [])
const handleApply = useCallback(() => {
const filter = filterRulesToFilter(rules)
const sort = sortRuleToSort(sortRule)
onApply({ filter, sort })
}, [rules, sortRule, onApply])
const handleClear = useCallback(() => {
setRules([])
setSortRule(null)
onApply({
filter: null,
sort: null,
})
}, [onApply])
const hasChanges = rules.length > 0 || sortRule !== null
return (
<div className='flex flex-col gap-[8px]'>
{rules.map((rule, index) => (
<FilterRow
key={rule.id}
rule={rule}
index={index}
columnOptions={columnOptions}
comparisonOptions={comparisonOptions}
logicalOptions={logicalOptions}
onUpdate={handleUpdateRule}
onRemove={handleRemoveRule}
onApply={handleApply}
/>
))}
{sortRule && (
<SortRow
sortRule={sortRule}
columnOptions={columnOptions}
sortDirectionOptions={sortDirectionOptions}
onChange={setSortRule}
onRemove={handleRemoveSort}
/>
)}
<div className='flex items-center gap-[8px]'>
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add row
</Button>
<Button variant='default' size='sm' onClick={handleAddRule}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add filter
</Button>
{!sortRule && (
<Button variant='default' size='sm' onClick={handleAddSort}>
<ArrowUpAZ className='mr-[4px] h-[12px] w-[12px]' />
Add sort
</Button>
)}
{hasChanges && (
<>
<Button variant='default' size='sm' onClick={handleApply} disabled={isLoading}>
{isLoading && <Loader2 className='mr-[4px] h-[12px] w-[12px] animate-spin' />}
{isLoading ? 'Applying...' : 'Apply'}
</Button>
<button
onClick={handleClear}
className='text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Clear all
</button>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { ArrowDownAZ, ArrowUpAZ, X } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn'
import type { SortRule } from '@/lib/table/query-builder/constants'
interface SortRowProps {
sortRule: SortRule
columnOptions: Array<{ value: string; label: string }>
sortDirectionOptions: Array<{ value: string; label: string }>
onChange: (rule: SortRule | null) => void
onRemove: () => void
}
export function SortRow({
sortRule,
columnOptions,
sortDirectionOptions,
onChange,
onRemove,
}: SortRowProps) {
return (
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={onRemove}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
<Combobox size='sm' options={[{ value: 'order', label: 'order' }]} value='order' disabled />
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={sortRule.column}
onChange={(value) => onChange({ ...sortRule, column: value })}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={sortDirectionOptions}
value={sortRule.direction}
onChange={(value) => onChange({ ...sortRule, direction: value as 'asc' | 'desc' })}
/>
</div>
<div className='flex items-center text-[12px] text-[var(--text-tertiary)]'>
{sortRule.direction === 'asc' ? (
<ArrowUpAZ className='h-[14px] w-[14px]' />
) : (
<ArrowDownAZ className='h-[14px] w-[14px]' />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,363 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertCircle } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
Checkbox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import {
useCreateTableRow,
useDeleteTableRow,
useDeleteTableRows,
useUpdateTableRow,
} from '@/hooks/queries/tables'
const logger = createLogger('RowModal')
export interface RowModalProps {
mode: 'add' | 'edit' | 'delete'
isOpen: boolean
onClose: () => void
table: TableInfo
row?: TableRow
rowIds?: string[]
onSuccess: () => void
}
function createInitialRowData(columns: ColumnDefinition[]): Record<string, unknown> {
const initial: Record<string, unknown> = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = ''
}
})
return initial
}
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record<string, unknown>
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
if (typeof value === 'string') {
if (value === '') {
cleanData[col.name] = null
} else {
try {
cleanData[col.name] = JSON.parse(value)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
}
} else {
cleanData[col.name] = value
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
})
return cleanData
}
function formatValueForInput(value: unknown, type: string): string {
if (value === null || value === undefined) return ''
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
if (type === 'date' && value) {
try {
const date = new Date(String(value))
return date.toISOString().split('T')[0]
} catch {
return String(value)
}
}
return String(value)
}
function getInitialRowData(
mode: RowModalProps['mode'],
columns: ColumnDefinition[],
row?: TableRow
): Record<string, unknown> {
if (mode === 'add' && columns.length > 0) {
return createInitialRowData(columns)
}
if (mode === 'edit' && row) {
return row.data
}
return {}
}
export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const tableId = table.id
const schema = table?.schema
const columns = schema?.columns || []
const [rowData, setRowData] = useState<Record<string, unknown>>(() =>
getInitialRowData(mode, columns, row)
)
const [error, setError] = useState<string | null>(null)
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId })
const isSubmitting =
createRowMutation.isPending ||
updateRowMutation.isPending ||
deleteRowMutation.isPending ||
deleteRowsMutation.isPending
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
try {
const cleanData = cleanRowData(columns, rowData)
if (mode === 'add') {
await createRowMutation.mutateAsync(cleanData)
} else if (mode === 'edit' && row) {
await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData })
}
onSuccess()
} catch (err) {
logger.error(`Failed to ${mode} row:`, err)
setError(err instanceof Error ? err.message : `Failed to ${mode} row`)
}
}
const handleDelete = async () => {
setError(null)
const idsToDelete = rowIds ?? (row ? [row.id] : [])
try {
if (idsToDelete.length === 1) {
await deleteRowMutation.mutateAsync(idsToDelete[0])
} else {
await deleteRowsMutation.mutateAsync(idsToDelete)
}
onSuccess()
} catch (err) {
logger.error('Failed to delete row(s):', err)
setError(err instanceof Error ? err.message : 'Failed to delete row(s)')
}
}
const handleClose = () => {
setRowData({})
setError(null)
onClose()
}
// Delete mode UI
if (mode === 'delete') {
const deleteCount = rowIds?.length ?? (row ? 1 : 0)
const isSingleRow = deleteCount === 1
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[480px]'>
<ModalHeader>
<div className='flex items-center gap-[10px]'>
<div className='flex h-[36px] w-[36px] items-center justify-center rounded-[8px] bg-[var(--bg-error)] text-[var(--text-error)]'>
<AlertCircle className='h-[18px] w-[18px]' />
</div>
<h2 className='font-semibold text-[16px]'>
Delete {isSingleRow ? 'Row' : `${deleteCount} Rows`}
</h2>
</div>
</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<ErrorMessage error={error} />
<p className='text-[14px] text-[var(--text-secondary)]'>
Are you sure you want to delete {isSingleRow ? 'this row' : 'these rows'}? This
action cannot be undone.
</p>
</div>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type='button'
variant='destructive'
onClick={handleDelete}
disabled={isSubmitting}
className='min-w-[120px]'
>
{isSubmitting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const isAddMode = mode === 'add'
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[600px]'>
<ModalHeader>
<div className='flex flex-col gap-[4px]'>
<h2 className='font-semibold text-[16px]'>{isAddMode ? 'Add New Row' : 'Edit Row'}</h2>
<p className='font-normal text-[13px] text-[var(--text-tertiary)]'>
{isAddMode ? 'Fill in the values for' : 'Update values for'} {table?.name ?? 'table'}
</p>
</div>
</ModalHeader>
<ModalBody className='max-h-[60vh] overflow-y-auto'>
<form onSubmit={handleFormSubmit} className='flex flex-col gap-[16px]'>
<ErrorMessage error={error} />
{columns.map((column) => (
<ColumnField
key={column.name}
column={column}
value={rowData[column.name]}
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
/>
))}
</form>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleFormSubmit}
disabled={isSubmitting}
className='min-w-[120px]'
>
{isSubmitting
? isAddMode
? 'Adding...'
: 'Updating...'
: isAddMode
? 'Add Row'
: 'Update Row'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
function ErrorMessage({ error }: { error: string | null }) {
if (!error) return null
return (
<div className='rounded-[8px] border border-[var(--status-error-border)] bg-[var(--status-error-bg)] px-[14px] py-[12px] text-[13px] text-[var(--status-error-text)]'>
{error}
</div>
)
}
interface ColumnFieldProps {
column: ColumnDefinition
value: unknown
onChange: (value: unknown) => void
}
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
return (
<div className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
</span>
)}
</Label>
{column.type === 'boolean' ? (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{value ? 'True' : 'False'}
</Label>
</div>
) : column.type === 'json' ? (
<Textarea
id={column.name}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
/>
) : (
<Input
id={column.name}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getTypeBadgeVariant } from '../lib/utils'
interface SchemaModalProps {
isOpen: boolean
onClose: () => void
columns: ColumnDefinition[]
tableName?: string
}
export function SchemaModal({ isOpen, onClose, columns, tableName }: SchemaModalProps) {
const columnCount = columns.length
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='md'>
<ModalHeader>Table Schema</ModalHeader>
<ModalBody className='max-h-[60vh] overflow-y-auto'>
<div className='mb-[10px] flex items-center justify-between gap-[8px]'>
{tableName ? (
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{tableName}
</span>
) : (
<div />
)}
<Badge variant='gray' size='sm'>
{columnCount} {columnCount === 1 ? 'column' : 'columns'}
</Badge>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Column</TableHead>
<TableHead>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell className='font-mono'>{column.name}</TableCell>
<TableCell>
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell>
<div className='flex items-center gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='purple' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'>None</span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,308 @@
'use client'
import { useCallback, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import {
Badge,
Checkbox,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { TableRow as TableRowType } from '@/lib/table'
import { useContextMenu, useRowSelection, useTableData } from '../hooks'
import type { CellViewerData, QueryOptions } from '../lib/types'
import { ActionBar } from './action-bar'
import { EmptyRows, LoadingRows } from './body-states'
import { CellRenderer } from './cell-renderer'
import { CellViewerModal } from './cell-viewer-modal'
import { ContextMenu } from './context-menu'
import { HeaderBar } from './header-bar'
import { Pagination } from './pagination'
import { QueryBuilder } from './query-builder'
import { RowModal } from './row-modal'
import { SchemaModal } from './schema-modal'
export function TableViewer() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const tableId = params.tableId as string
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filter: null,
sort: null,
})
const [currentPage, setCurrentPage] = useState(0)
const [showAddModal, setShowAddModal] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowType | null>(null)
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [showSchemaModal, setShowSchemaModal] = useState(false)
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
const [copied, setCopied] = useState(false)
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows, refetchRows } =
useTableData({
workspaceId,
tableId,
queryOptions,
currentPage,
})
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
const { contextMenu, handleRowContextMenu, closeContextMenu } = useContextMenu()
const columns = tableData?.schema?.columns || []
const selectedCount = selectedRows.size
const hasSelection = selectedCount > 0
const isAllSelected = rows.length > 0 && selectedCount === rows.length
const handleNavigateBack = useCallback(() => {
router.push(`/workspace/${workspaceId}/tables`)
}, [router, workspaceId])
const handleShowSchema = useCallback(() => {
setShowSchemaModal(true)
}, [])
const handleAddRow = useCallback(() => {
setShowAddModal(true)
}, [])
const handleApplyQueryOptions = useCallback((options: QueryOptions) => {
setQueryOptions(options)
setCurrentPage(0)
}, [])
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
const handleContextMenuEdit = useCallback(() => {
if (contextMenu.row) {
setEditingRow(contextMenu.row)
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
setDeletingRows([contextMenu.row.id])
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleCopyCellValue = useCallback(async () => {
if (cellViewer) {
let text: string
if (cellViewer.type === 'json') {
text = JSON.stringify(cellViewer.value, null, 2)
} else if (cellViewer.type === 'date') {
text = String(cellViewer.value)
} else {
text = String(cellViewer.value)
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [cellViewer])
const handleCellClick = useCallback(
(columnName: string, value: unknown, type: CellViewerData['type']) => {
setCellViewer({ columnName, value, type })
},
[]
)
if (isLoadingTable) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-tertiary)]'>Loading table...</span>
</div>
)
}
if (!tableData) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-error)]'>Table not found</span>
</div>
)
}
return (
<div className='flex h-full flex-col'>
<HeaderBar
tableName={tableData.name}
totalCount={totalCount}
isLoading={isLoadingRows}
onNavigateBack={handleNavigateBack}
onShowSchema={handleShowSchema}
onRefresh={refetchRows}
/>
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
<QueryBuilder
columns={columns}
onApply={handleApplyQueryOptions}
onAddRow={handleAddRow}
isLoading={isLoadingRows}
/>
{hasSelection && (
<span className='text-[11px] text-[var(--text-tertiary)]'>{selectedCount} selected</span>
)}
</div>
{hasSelection && (
<ActionBar
selectedCount={selectedCount}
onDelete={handleDeleteSelected}
onClearSelection={clearSelection}
/>
)}
<div className='flex-1 overflow-auto'>
<Table>
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
<TableRow>
<TableHead className='w-[40px]'>
<Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
</TableHead>
{columns.map((column) => (
<TableHead key={column.name}>
<div className='flex items-center gap-[6px]'>
<span className='text-[12px]'>{column.name}</span>
<Badge variant='outline' size='sm'>
{column.type}
</Badge>
{column.required && (
<span className='text-[10px] text-[var(--text-error)]'>*</span>
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{isLoadingRows ? (
<LoadingRows columns={columns} />
) : rows.length === 0 ? (
<EmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={handleAddRow}
/>
) : (
rows.map((row) => (
<TableRow
key={row.id}
className={cn(
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]'
)}
onContextMenu={(e) => handleRowContextMenu(e, row)}
>
<TableCell>
<Checkbox
size='sm'
checked={selectedRows.has(row.id)}
onCheckedChange={() => handleSelectRow(row.id)}
/>
</TableCell>
{columns.map((column) => (
<TableCell key={column.name}>
<div className='max-w-[300px] truncate text-[13px]'>
<CellRenderer
value={row.data[column.name]}
column={column}
onCellClick={handleCellClick}
/>
</div>
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
onPreviousPage={() => setCurrentPage((p) => Math.max(0, p - 1))}
onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
/>
{showAddModal && (
<RowModal
mode='add'
isOpen={true}
onClose={() => setShowAddModal(false)}
table={tableData}
onSuccess={() => {
setShowAddModal(false)
}}
/>
)}
{editingRow && (
<RowModal
mode='edit'
isOpen={true}
onClose={() => setEditingRow(null)}
table={tableData}
row={editingRow}
onSuccess={() => {
setEditingRow(null)
}}
/>
)}
{deletingRows.length > 0 && (
<RowModal
mode='delete'
isOpen={true}
onClose={() => setDeletingRows([])}
table={tableData}
rowIds={deletingRows}
onSuccess={() => {
setDeletingRows([])
clearSelection()
}}
/>
)}
<SchemaModal
isOpen={showSchemaModal}
onClose={() => setShowSchemaModal(false)}
columns={columns}
tableName={tableData.name}
/>
<CellViewerModal
cellViewer={cellViewer}
onClose={() => setCellViewer(null)}
onCopy={handleCopyCellValue}
copied={copied}
/>
<ContextMenu
contextMenu={contextMenu}
onClose={closeContextMenu}
onEdit={handleContextMenuEdit}
onDelete={handleContextMenuDelete}
/>
</div>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, ArrowLeft, RefreshCw } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
const logger = createLogger('TableViewerError')
interface TableViewerErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function TableViewerError({ error, reset }: TableViewerErrorProps) {
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
useEffect(() => {
logger.error('Table viewer error:', { error: error.message, digest: error.digest })
}, [error])
return (
<div className='flex h-full flex-1 flex-col'>
{/* Header */}
<div className='flex h-[48px] shrink-0 items-center border-[var(--border)] border-b px-[16px]'>
<button
onClick={() => router.push(`/workspace/${workspaceId}/tables`)}
className='flex items-center gap-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
<ArrowLeft className='h-[14px] w-[14px]' />
Back to Tables
</button>
</div>
{/* Error Content */}
<div className='flex flex-1 items-center justify-center'>
<div className='flex flex-col items-center gap-[16px] text-center'>
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-4)]'>
<AlertTriangle className='h-[24px] w-[24px] text-[var(--text-error)]' />
</div>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
Failed to load table
</h2>
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
Something went wrong while loading this table. The table may have been deleted or you
may not have permission to view it.
</p>
</div>
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={() => router.push(`/workspace/${workspaceId}/tables`)}
>
<ArrowLeft className='mr-[6px] h-[14px] w-[14px]' />
Go back
</Button>
<Button variant='default' size='sm' onClick={reset}>
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
Try again
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export * from './use-context-menu'
export * from './use-row-selection'
export * from './use-table-data'

View File

@@ -0,0 +1,37 @@
import { useCallback, useState } from 'react'
import type { TableRow } from '@/lib/table'
import type { ContextMenuState } from '../lib/types'
interface UseContextMenuReturn {
contextMenu: ContextMenuState
handleRowContextMenu: (e: React.MouseEvent, row: TableRow) => void
closeContextMenu: () => void
}
export function useContextMenu(): UseContextMenuReturn {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
position: { x: 0, y: 0 },
row: null,
})
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRow) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
})
}, [])
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))
}, [])
return {
contextMenu,
handleRowContextMenu,
closeContextMenu,
}
}

View File

@@ -0,0 +1,65 @@
import { useCallback, useMemo, useState } from 'react'
import type { TableRow } from '@/lib/table'
interface UseRowSelectionReturn {
selectedRows: Set<string>
handleSelectAll: () => void
handleSelectRow: (rowId: string) => void
clearSelection: () => void
}
export function useRowSelection(rows: TableRow[]): UseRowSelectionReturn {
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
const [prevRowsSignature, setPrevRowsSignature] = useState('')
const currentRowIds = useMemo(() => new Set(rows.map((r) => r.id)), [rows])
const rowsSignature = useMemo(() => rows.map((r) => r.id).join('|'), [rows])
if (rowsSignature !== prevRowsSignature) {
setPrevRowsSignature(rowsSignature)
setSelectedRows((prev) => {
if (prev.size === 0) return prev
const filtered = new Set([...prev].filter((id) => currentRowIds.has(id)))
return filtered.size !== prev.size ? filtered : prev
})
}
const visibleSelectedRows = useMemo(
() => new Set([...selectedRows].filter((id) => currentRowIds.has(id))),
[selectedRows, currentRowIds]
)
const handleSelectAll = useCallback(() => {
if (visibleSelectedRows.size === rows.length) {
setSelectedRows(new Set())
} else {
setSelectedRows(new Set(rows.map((r) => r.id)))
}
}, [rows, visibleSelectedRows.size])
const handleSelectRow = useCallback(
(rowId: string) => {
setSelectedRows((prev) => {
const newSet = new Set([...prev].filter((id) => currentRowIds.has(id)))
if (newSet.has(rowId)) {
newSet.delete(rowId)
} else {
newSet.add(rowId)
}
return newSet
})
},
[currentRowIds]
)
const clearSelection = useCallback(() => {
setSelectedRows(new Set())
}, [])
return {
selectedRows: visibleSelectedRows,
handleSelectAll,
handleSelectRow,
clearSelection,
}
}

View File

@@ -0,0 +1,58 @@
import type { TableDefinition, TableRow } from '@/lib/table'
import { useTable, useTableRows } from '@/hooks/queries/tables'
import { ROWS_PER_PAGE } from '../lib/constants'
import type { QueryOptions } from '../lib/types'
interface UseTableDataParams {
workspaceId: string
tableId: string
queryOptions: QueryOptions
currentPage: number
}
interface UseTableDataReturn {
tableData: TableDefinition | undefined
isLoadingTable: boolean
rows: TableRow[]
totalCount: number
totalPages: number
isLoadingRows: boolean
refetchRows: () => void
}
export function useTableData({
workspaceId,
tableId,
queryOptions,
currentPage,
}: UseTableDataParams): UseTableDataReturn {
const { data: tableData, isLoading: isLoadingTable } = useTable(workspaceId, tableId)
const {
data: rowsData,
isLoading: isLoadingRows,
refetch: refetchRows,
} = useTableRows({
workspaceId,
tableId,
limit: ROWS_PER_PAGE,
offset: currentPage * ROWS_PER_PAGE,
filter: queryOptions.filter,
sort: queryOptions.sort,
enabled: Boolean(workspaceId && tableId),
})
const rows = (rowsData?.rows || []) as TableRow[]
const totalCount = rowsData?.totalCount || 0
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
return {
tableData,
isLoadingTable,
rows,
totalCount,
totalPages,
isLoadingRows,
refetchRows,
}
}

View File

@@ -0,0 +1,2 @@
export const ROWS_PER_PAGE = 100
export const STRING_TRUNCATE_LENGTH = 50

View File

@@ -0,0 +1,3 @@
export * from './constants'
export * from './types'
export * from './utils'

View File

@@ -0,0 +1,27 @@
import type { Filter, Sort, TableRow } from '@/lib/table'
/**
* Query options for filtering and sorting table data
*/
export interface QueryOptions {
filter: Filter | null
sort: Sort | null
}
/**
* Data for viewing a cell's full content in a modal
*/
export interface CellViewerData {
columnName: string
value: unknown
type: 'json' | 'text' | 'date' | 'boolean' | 'number'
}
/**
* State for the row context menu (right-click)
*/
export interface ContextMenuState {
isOpen: boolean
position: { x: number; y: number }
row: TableRow | null
}

View File

@@ -0,0 +1,21 @@
type BadgeVariant = 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray'
/**
* Returns the appropriate badge color variant for a column type
*/
export function getTypeBadgeVariant(type: string): BadgeVariant {
switch (type) {
case 'string':
return 'green'
case 'number':
return 'blue'
case 'boolean':
return 'purple'
case 'json':
return 'orange'
case 'date':
return 'teal'
default:
return 'gray'
}
}

View File

@@ -0,0 +1,5 @@
import { TableViewer } from './components'
export default function TablePage() {
return <TableViewer />
}

View File

@@ -0,0 +1,330 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Trash2 } from 'lucide-react'
import { nanoid } from 'nanoid'
import { useParams } from 'next/navigation'
import {
Button,
Checkbox,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition } from '@/lib/table'
import { useCreateTable } from '@/hooks/queries/tables'
const logger = createLogger('CreateModal')
interface CreateModalProps {
isOpen: boolean
onClose: () => void
}
const COLUMN_TYPE_OPTIONS: Array<{ value: ColumnDefinition['type']; label: string }> = [
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'date', label: 'Date' },
{ value: 'json', label: 'JSON' },
]
interface ColumnWithId extends ColumnDefinition {
id: string
}
function createEmptyColumn(): ColumnWithId {
return { id: nanoid(), name: '', type: 'string', required: true, unique: false }
}
export function CreateModal({ isOpen, onClose }: CreateModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [tableName, setTableName] = useState('')
const [description, setDescription] = useState('')
const [columns, setColumns] = useState<ColumnWithId[]>([createEmptyColumn()])
const [error, setError] = useState<string | null>(null)
const createTable = useCreateTable(workspaceId)
const handleAddColumn = () => {
setColumns([...columns, createEmptyColumn()])
}
const handleRemoveColumn = (columnId: string) => {
if (columns.length > 1) {
setColumns(columns.filter((col) => col.id !== columnId))
}
}
const handleColumnChange = (
columnId: string,
field: keyof ColumnDefinition,
value: string | boolean
) => {
setColumns(columns.map((col) => (col.id === columnId ? { ...col, [field]: value } : col)))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!tableName.trim()) {
setError('Table name is required')
return
}
// Validate column names
const validColumns = columns.filter((col) => col.name.trim())
if (validColumns.length === 0) {
setError('At least one column is required')
return
}
// Check for duplicate column names
const columnNames = validColumns.map((col) => col.name.toLowerCase())
const uniqueNames = new Set(columnNames)
if (uniqueNames.size !== columnNames.length) {
setError('Duplicate column names found')
return
}
// Strip internal IDs before sending to API
const columnsForApi = validColumns.map(({ id: _id, ...col }) => col)
try {
await createTable.mutateAsync({
name: tableName,
description: description || undefined,
schema: {
columns: columnsForApi,
},
})
// Reset form
resetForm()
onClose()
} catch (err) {
logger.error('Failed to create table:', err)
setError(err instanceof Error ? err.message : 'Failed to create table')
}
}
const resetForm = () => {
setTableName('')
setDescription('')
setColumns([createEmptyColumn()])
setError(null)
}
const handleClose = () => {
resetForm()
onClose()
}
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent size='lg'>
<ModalHeader>Create Table</ModalHeader>
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Define your table schema with columns and constraints.
</p>
{error && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='tableName'>Name</Label>
<Input
id='tableName'
value={tableName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setTableName(e.target.value)
}
placeholder='customers, orders, products'
className={cn(
error === 'Table name is required' && 'border-[var(--text-error)]'
)}
required
/>
<p className='text-[11px] text-[var(--text-muted)]'>
Use lowercase with underscores (e.g., customer_orders)
</p>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='description'>Description</Label>
<Textarea
id='description'
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setDescription(e.target.value)
}
placeholder='Optional description for this table'
rows={3}
className='resize-none'
/>
</div>
<div className='space-y-[8px]'>
<div className='flex items-center justify-between'>
<Label>Columns*</Label>
<Button
type='button'
size='sm'
variant='default'
onClick={handleAddColumn}
className='h-[30px] rounded-[6px] px-[12px] text-[12px]'
>
<Plus className='mr-[4px] h-[14px] w-[14px]' />
Add Column
</Button>
</div>
<div className='space-y-[8px]'>
{columns.map((column, index) => (
<ColumnRow
key={column.id}
index={index}
column={column}
isRemovable={columns.length > 1}
onChange={handleColumnChange}
onRemove={handleRemoveColumn}
/>
))}
</div>
<p className='text-[11px] text-[var(--text-muted)]'>
Mark columns as <span className='font-medium'>unique</span> to prevent duplicate
values (e.g., id, email)
</p>
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
<div className='flex w-full items-center justify-end gap-[8px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
disabled={createTable.isPending}
>
Cancel
</Button>
<Button
type='submit'
variant='tertiary'
disabled={createTable.isPending}
className='min-w-[120px]'
>
{createTable.isPending ? 'Creating...' : 'Create Table'}
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}
interface ColumnRowProps {
index: number
column: ColumnWithId
isRemovable: boolean
onChange: (columnId: string, field: keyof ColumnDefinition, value: string | boolean) => void
onRemove: (columnId: string) => void
}
function ColumnRow({ index, column, isRemovable, onChange, onRemove }: ColumnRowProps) {
return (
<div className='rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-1)] p-[10px]'>
<div className='mb-[8px] flex items-center justify-between'>
<span className='font-medium text-[11px] text-[var(--text-tertiary)]'>
Column {index + 1}
</span>
<Button
type='button'
size='sm'
variant='ghost'
onClick={() => onRemove(column.id)}
disabled={!isRemovable}
className='h-[28px] w-[28px] p-0 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-error)] hover:text-[var(--text-error)]'
>
<Trash2 className='h-[15px] w-[15px]' />
</Button>
</div>
<div className='grid grid-cols-[minmax(0,1fr)_120px_76px_76px] items-end gap-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label
htmlFor={`column-name-${column.id}`}
className='text-[11px] text-[var(--text-muted)]'
>
Name
</Label>
<Input
id={`column-name-${column.id}`}
value={column.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(column.id, 'name', e.target.value)
}
placeholder='column_name'
className='h-[36px]'
/>
</div>
<div className='flex flex-col gap-[6px]'>
<Label
htmlFor={`column-type-${column.id}`}
className='text-[11px] text-[var(--text-muted)]'
>
Type
</Label>
<Combobox
options={COLUMN_TYPE_OPTIONS}
value={column.type}
selectedValue={column.type}
onChange={(value) => onChange(column.id, 'type', value as ColumnDefinition['type'])}
placeholder='Type'
editable={false}
filterOptions={false}
className='h-[36px]'
/>
</div>
<div className='flex flex-col items-center gap-[8px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>Required</span>
<Checkbox
checked={column.required}
onCheckedChange={(checked) => onChange(column.id, 'required', checked === true)}
/>
</div>
<div className='flex flex-col items-center gap-[8px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>Unique</span>
<Checkbox
checked={column.unique}
onCheckedChange={(checked) => onChange(column.id, 'unique', checked === true)}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
interface EmptyStateProps {
hasSearchQuery: boolean
}
export function EmptyState({ hasSearchQuery }: EmptyStateProps) {
return (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{hasSearchQuery ? 'No tables found' : 'No tables yet'}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{hasSearchQuery
? 'Try a different search term'
: 'Create your first table to store structured data for your workflows'}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
interface ErrorStateProps {
error: unknown
}
export function ErrorState({ error }: ErrorStateProps) {
return (
<div className='col-span-full flex h-64 items-center justify-center rounded-[4px] bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Error loading tables</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
export * from './create-modal'
export * from './empty-state'
export * from './error-state'
export * from './loading-state'
export * from './table-card'
export * from './tables-view'

View File

@@ -0,0 +1,31 @@
export function LoadingState() {
return (
<>
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className='flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] dark:bg-[var(--surface-4)]'
>
<div className='flex items-center justify-between gap-[8px]'>
<div className='h-[17px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[22px] w-[90px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-[15px] w-[60px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<div className='flex h-[36px] flex-col gap-[6px]'>
<div className='h-[15px] w-full animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[75%] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
</div>
</div>
))}
</>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Columns, Rows3 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import type { TableDefinition } from '@/lib/table'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDeleteTable } from '@/hooks/queries/tables'
import { SchemaModal } from '../[tableId]/components/schema-modal'
import { formatAbsoluteDate, formatRelativeTime } from '../lib/utils'
const logger = createLogger('TableCard')
interface TableCardProps {
table: TableDefinition
workspaceId: string
}
export function TableCard({ table, workspaceId }: TableCardProps) {
const router = useRouter()
const userPermissions = useUserPermissionsContext()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
const deleteTable = useDeleteTable(workspaceId)
const {
isOpen: isContextMenuOpen,
position: contextMenuPosition,
menuRef,
handleContextMenu,
closeMenu: closeContextMenu,
} = useContextMenu()
const handleDelete = async () => {
try {
await deleteTable.mutateAsync(table.id)
setIsDeleteDialogOpen(false)
} catch (error) {
logger.error('Failed to delete table:', error)
}
}
const navigateToTable = useCallback(() => {
router.push(`/workspace/${workspaceId}/tables/${table.id}`)
}, [router, workspaceId, table.id])
const columnCount = table.schema.columns.length
const shortId = `tb-${table.id.slice(0, 8)}`
return (
<>
<div
role='button'
tabIndex={0}
data-table-card
className='h-full cursor-pointer'
onClick={(e) => {
if (isContextMenuOpen) {
e.preventDefault()
return
}
navigateToTable()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigateToTable()
}
}}
onContextMenu={handleContextMenu}
>
<div className='group flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover:bg-[var(--surface-5)]'>
<div className='flex items-center justify-between gap-[8px]'>
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{table.name}
</h3>
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px] text-[12px] text-[var(--text-tertiary)]'>
<span className='flex items-center gap-[4px]'>
<Columns className='h-[12px] w-[12px]' />
{columnCount} {columnCount === 1 ? 'col' : 'cols'}
</span>
<span className='flex items-center gap-[4px]'>
<Rows3 className='h-[12px] w-[12px]' />
{table.rowCount} {table.rowCount === 1 ? 'row' : 'rows'}
</span>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='text-[12px] text-[var(--text-tertiary)]'>
{formatRelativeTime(table.updatedAt)}
</span>
</Tooltip.Trigger>
<Tooltip.Content>{formatAbsoluteDate(table.updatedAt)}</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
{table.description || 'No description'}
</p>
</div>
</div>
</div>
<TableContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={menuRef}
onClose={closeContextMenu}
onViewSchema={() => setIsSchemaModalOpen(true)}
onCopyId={() => navigator.clipboard.writeText(table.id)}
onDelete={() => setIsDeleteDialogOpen(true)}
disableDelete={userPermissions.canEdit !== true}
/>
{/* Delete Confirmation Modal */}
<Modal open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Table</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{table.name}</span>? This
will permanently delete all {table.rowCount} rows.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => setIsDeleteDialogOpen(false)}
disabled={deleteTable.isPending}
>
Cancel
</Button>
<Button variant='default' onClick={handleDelete} disabled={deleteTable.isPending}>
{deleteTable.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Schema Viewer Modal */}
<SchemaModal
isOpen={isSchemaModalOpen}
onClose={() => setIsSchemaModalOpen(false)}
columns={table.schema.columns}
tableName={table.name}
/>
</>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface TableContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: React.RefObject<HTMLDivElement | null>
onClose: () => void
onViewSchema?: () => void
onCopyId?: () => void
onDelete?: () => void
disableDelete?: boolean
}
export function TableContextMenu({
isOpen,
position,
menuRef,
onClose,
onViewSchema,
onCopyId,
onDelete,
disableDelete = false,
}: TableContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{onViewSchema && (
<PopoverItem
onClick={() => {
onViewSchema()
onClose()
}}
>
View Schema
</PopoverItem>
)}
{onViewSchema && (onCopyId || onDelete) && <PopoverDivider />}
{onCopyId && (
<PopoverItem
onClick={() => {
onCopyId()
onClose()
}}
>
Copy ID
</PopoverItem>
)}
{onCopyId && onDelete && <PopoverDivider />}
{onDelete && (
<PopoverItem
disabled={disableDelete}
onClick={() => {
onDelete()
onClose()
}}
>
Delete
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
interface TablesListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: React.RefObject<HTMLDivElement | null>
onClose: () => void
onCreateTable?: () => void
disableCreate?: boolean
}
export function TablesListContextMenu({
isOpen,
position,
menuRef,
onClose,
onCreateTable,
disableCreate = false,
}: TablesListContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{onCreateTable && (
<PopoverItem
disabled={disableCreate}
onClick={() => {
onCreateTable()
onClose()
}}
>
Create table
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { useCallback, useState } from 'react'
import { Database, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Input, Tooltip } from '@/components/emcn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useTablesList } from '@/hooks/queries/tables'
import { useDebounce } from '@/hooks/use-debounce'
import { CreateModal } from './create-modal'
import { EmptyState } from './empty-state'
import { ErrorState } from './error-state'
import { LoadingState } from './loading-state'
import { TableCard } from './table-card'
import { TablesListContextMenu } from './tables-list-context-menu'
export function TablesView() {
const params = useParams()
const workspaceId = params.workspaceId as string
const userPermissions = useUserPermissionsContext()
const { data: tables = [], isLoading, error } = useTablesList(workspaceId)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
menuRef: listMenuRef,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
const isOnCard = target.closest('[data-table-card]')
const isOnInteractive = target.closest('button, input, a, [role="button"]')
if (!isOnCard && !isOnInteractive) {
handleListContextMenu(e)
}
},
[handleListContextMenu]
)
// Filter tables by search query
const filteredTables = tables.filter((table) => {
if (!debouncedSearchQuery) return true
const query = debouncedSearchQuery.toLowerCase()
return (
table.name.toLowerCase().includes(query) || table.description?.toLowerCase().includes(query)
)
})
return (
<>
<div className='flex h-full flex-1 flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div
className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'
onContextMenu={handleContentContextMenu}
>
{/* Header */}
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#3B82F6] bg-[#EFF6FF] dark:border-[#1E40AF] dark:bg-[#1E3A5F]'>
<Database className='h-[14px] w-[14px] text-[#3B82F6] dark:text-[#60A5FA]' />
</div>
<h1 className='font-medium text-[18px]'>Tables</h1>
</div>
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
Create and manage data tables for your workflows.
</p>
</div>
{/* Search and Actions */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
<Input
placeholder='Search'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={() => setIsCreateModalOpen(true)}
disabled={userPermissions.canEdit !== true}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
<Plus className='mr-[6px] h-[14px] w-[14px]' />
Create Table
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to create tables</Tooltip.Content>
)}
</Tooltip.Root>
</div>
</div>
{/* Content */}
<div className='mt-[24px] grid grid-cols-1 gap-[20px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{isLoading ? (
<LoadingState />
) : error ? (
<ErrorState error={error} />
) : filteredTables.length === 0 ? (
<EmptyState hasSearchQuery={!!searchQuery} />
) : (
filteredTables.map((table) => (
<TableCard key={table.id} table={table} workspaceId={workspaceId} />
))
)}
</div>
</div>
</div>
</div>
<TablesListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
menuRef={listMenuRef}
onClose={closeListContextMenu}
onCreateTable={() => setIsCreateModalOpen(true)}
disableCreate={userPermissions.canEdit !== true}
/>
<CreateModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
</>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn'
const logger = createLogger('TablesError')
interface TablesErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function TablesError({ error, reset }: TablesErrorProps) {
useEffect(() => {
logger.error('Tables error:', { error: error.message, digest: error.digest })
}, [error])
return (
<div className='flex h-full flex-1 items-center justify-center bg-white dark:bg-[var(--bg)]'>
<div className='flex flex-col items-center gap-[16px] text-center'>
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-4)]'>
<AlertTriangle className='h-[24px] w-[24px] text-[var(--text-error)]' />
</div>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
Failed to load tables
</h2>
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
Something went wrong while loading the tables. Please try again.
</p>
</div>
<Button variant='default' size='sm' onClick={reset}>
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
Try again
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function TablesLayout({ children }: { children: React.ReactNode }) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden pl-[var(--sidebar-width)]'>
{children}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1,32 @@
/**
* Formats a date as relative time (e.g., "5m ago", "2d ago")
*/
export function formatRelativeTime(dateValue: string | Date): string {
const dateString = typeof dateValue === 'string' ? dateValue : dateValue.toISOString()
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 604800)}w ago`
if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)}mo ago`
return `${Math.floor(diffInSeconds / 31536000)}y ago`
}
/**
* Formats a date as absolute date string (e.g., "Jan 15, 2024, 10:30 AM")
*/
export function formatAbsoluteDate(dateValue: string | Date): string {
const dateString = typeof dateValue === 'string' ? dateValue : dateValue.toISOString()
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}

View File

@@ -0,0 +1,32 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { TablesView } from './components'
interface TablesPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function TablesPage({ params }: TablesPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideTablesTab) {
redirect(`/workspace/${workspaceId}`)
}
return <TablesView />
}

View File

@@ -259,6 +259,7 @@ export const Code = memo(function Code({
case 'json-schema':
return 'Describe the JSON schema to generate...'
case 'json-object':
case 'table-schema':
return 'Describe the JSON object to generate...'
default:
return 'Describe the JavaScript code to generate...'
@@ -283,9 +284,14 @@ export const Code = memo(function Code({
return wandConfig
}, [wandConfig, languageValue])
const [tableIdValue] = useSubBlockValue<string>(blockId, 'tableId')
const wandHook = useWand({
wandConfig: dynamicWandConfig || { enabled: false, prompt: '' },
currentValue: code,
contextParams: {
tableId: typeof tableIdValue === 'string' ? tableIdValue : null,
},
onStreamStart: () => handleStreamStartRef.current?.(),
onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk),
onGeneratedContent: (content: string) => handleGeneratedContentRef.current?.(content),

View File

@@ -0,0 +1,222 @@
import { useRef } from 'react'
import { Plus } from 'lucide-react'
import {
Badge,
Button,
Combobox,
type ComboboxOption,
Input,
Label,
Trash,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { FilterRule } from '@/lib/table/query-builder/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import type { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
interface FilterRuleRowProps {
blockId: string
subBlockId: string
rule: FilterRule
index: number
columns: ComboboxOption[]
comparisonOptions: ComboboxOption[]
logicalOptions: ComboboxOption[]
isReadOnly: boolean
isPreview: boolean
disabled: boolean
onAdd: () => void
onRemove: (id: string) => void
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
onToggleCollapse: (id: string) => void
inputController: ReturnType<typeof useSubBlockInput>
}
export function FilterRuleRow({
blockId,
rule,
index,
columns,
comparisonOptions,
logicalOptions,
isReadOnly,
onAdd,
onRemove,
onUpdate,
onToggleCollapse,
inputController,
}: FilterRuleRowProps) {
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const valueInputRef = useRef<HTMLInputElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
const syncOverlayScroll = (scrollLeft: number) => {
if (overlayRef.current) overlayRef.current.scrollLeft = scrollLeft
}
const cellKey = `filter-${rule.id}-value`
const fieldState = inputController.fieldHelpers.getFieldState(cellKey)
const handlers = inputController.fieldHelpers.createFieldHandlers(
cellKey,
rule.value,
(newValue) => onUpdate(rule.id, 'value', newValue)
)
const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
cellKey,
rule.value,
(newValue) => onUpdate(rule.id, 'value', newValue)
)
const getOperatorLabel = (value: string) => {
const option = comparisonOptions.find((op) => op.value === value)
return option?.label || value
}
const getColumnLabel = (value: string) => {
const option = columns.find((col) => col.value === value)
return option?.label || value
}
const renderHeader = () => (
<div
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
onClick={() => onToggleCollapse(rule.id)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{rule.collapsed && rule.column ? getColumnLabel(rule.column) : `Condition ${index + 1}`}
</span>
{rule.collapsed && rule.column && (
<Badge variant='type' size='sm'>
{getOperatorLabel(rule.operator)}
</Badge>
)}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' onClick={onAdd} disabled={isReadOnly} className='h-auto p-0'>
<Plus className='h-[14px] w-[14px]' />
<span className='sr-only'>Add Condition</span>
</Button>
<Button
variant='ghost'
onClick={() => onRemove(rule.id)}
disabled={isReadOnly}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Condition</span>
</Button>
</div>
</div>
)
const renderValueInput = () => (
<div className='relative'>
<Input
ref={valueInputRef}
value={rule.value}
onChange={handlers.onChange}
onKeyDown={handlers.onKeyDown}
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
onFocus={handlers.onFocus}
onScroll={(e) => syncOverlayScroll(e.currentTarget.scrollLeft)}
onPaste={() =>
setTimeout(() => {
if (valueInputRef.current) {
syncOverlayScroll(valueInputRef.current.scrollLeft)
}
}, 0)
}
disabled={isReadOnly}
autoComplete='off'
placeholder='Enter value'
className='allow-scroll w-full overflow-auto text-transparent caret-foreground'
/>
<div
ref={overlayRef}
className={cn(
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
!isReadOnly && 'pointer-events-none'
)}
>
<div className='w-full whitespace-pre' style={{ minWidth: 'fit-content' }}>
{formatDisplayText(
rule.value,
accessiblePrefixes ? { accessiblePrefixes } : { highlightAll: true }
)}
</div>
</div>
{fieldState.showTags && (
<TagDropdown
visible={fieldState.showTags}
onSelect={tagSelectHandler}
blockId={blockId}
activeSourceBlockId={fieldState.activeSourceBlockId}
inputValue={rule.value}
cursorPosition={fieldState.cursorPosition}
onClose={() => inputController.fieldHelpers.hideFieldDropdowns(cellKey)}
inputRef={valueInputRef.current ? { current: valueInputRef.current } : undefined}
/>
)}
</div>
)
const renderContent = () => (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
{index > 0 && (
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Logic</Label>
<Combobox
options={logicalOptions}
value={rule.logicalOperator}
onChange={(v) => onUpdate(rule.id, 'logicalOperator', v as 'and' | 'or')}
disabled={isReadOnly}
/>
</div>
)}
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Column</Label>
<Combobox
options={columns}
value={rule.column}
onChange={(v) => onUpdate(rule.id, 'column', v)}
disabled={isReadOnly}
placeholder='Select column'
/>
</div>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Operator</Label>
<Combobox
options={comparisonOptions}
value={rule.operator}
onChange={(v) => onUpdate(rule.id, 'operator', v)}
disabled={isReadOnly}
placeholder='Select operator'
/>
</div>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label>
{renderValueInput()}
</div>
</div>
)
return (
<div
data-filter-id={rule.id}
className={cn(
'rounded-[4px] border border-[var(--border-1)]',
rule.collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
>
{renderHeader()}
{!rule.collapsed && renderContent()}
</div>
)
}

View File

@@ -0,0 +1,118 @@
'use client'
import { useCallback, useMemo } from 'react'
import type { ComboboxOption } from '@/components/emcn'
import { useTableColumns } from '@/lib/table/hooks'
import type { FilterRule } from '@/lib/table/query-builder/constants'
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { FilterRuleRow } from './components/filter-rule-row'
interface FilterBuilderProps {
blockId: string
subBlockId: string
isPreview?: boolean
previewValue?: FilterRule[] | null
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
}
const createDefaultRule = (columns: ComboboxOption[]): FilterRule => ({
id: crypto.randomUUID(),
logicalOperator: 'and',
column: columns[0]?.value || '',
operator: 'eq',
value: '',
collapsed: false,
})
/** Visual builder for table filter rules in workflow blocks. */
export function FilterBuilder({
blockId,
subBlockId,
isPreview = false,
previewValue,
disabled = false,
columns: propColumns,
tableIdSubBlockId = 'tableId',
}: FilterBuilderProps) {
const [storeValue, setStoreValue] = useSubBlockValue<FilterRule[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const dynamicColumns = useTableColumns({ tableId: tableIdValue })
const columns = useMemo(() => {
if (propColumns && propColumns.length > 0) return propColumns
return dynamicColumns
}, [propColumns, dynamicColumns])
const value = isPreview ? previewValue : storeValue
const rules: FilterRule[] =
Array.isArray(value) && value.length > 0 ? value : [createDefaultRule(columns)]
const isReadOnly = isPreview || disabled
const { comparisonOptions, logicalOptions, addRule, removeRule, updateRule } = useFilterBuilder({
columns,
rules,
setRules: setStoreValue,
isReadOnly,
})
const inputController = useSubBlockInput({
blockId,
subBlockId,
config: {
id: subBlockId,
type: 'filter-builder',
connectionDroppable: true,
},
isPreview,
disabled,
})
const toggleCollapse = useCallback(
(id: string) => {
if (isReadOnly) return
setStoreValue(rules.map((r) => (r.id === id ? { ...r, collapsed: !r.collapsed } : r)))
},
[isReadOnly, rules, setStoreValue]
)
const handleRemoveRule = useCallback(
(id: string) => {
if (isReadOnly) return
if (rules.length === 1) {
setStoreValue([createDefaultRule(columns)])
} else {
removeRule(id)
}
},
[isReadOnly, rules, columns, setStoreValue, removeRule]
)
return (
<div className='space-y-[8px]'>
{rules.map((rule, index) => (
<FilterRuleRow
key={rule.id}
blockId={blockId}
subBlockId={subBlockId}
rule={rule}
index={index}
columns={columns}
comparisonOptions={comparisonOptions}
logicalOptions={logicalOptions}
isReadOnly={isReadOnly}
isPreview={isPreview}
disabled={disabled}
onAdd={addRule}
onRemove={handleRemoveRule}
onUpdate={updateRule}
onToggleCollapse={toggleCollapse}
inputController={inputController}
/>
))}
</div>
)
}

View File

@@ -9,6 +9,7 @@ export { Dropdown } from './dropdown/dropdown'
export { EvalInput } from './eval-input/eval-input'
export { FileSelectorInput } from './file-selector/file-selector-input'
export { FileUpload } from './file-upload/file-upload'
export { FilterBuilder } from './filter-builder/filter-builder'
export { FolderSelectorInput } from './folder-selector/components/folder-selector-input'
export { GroupedCheckboxList } from './grouped-checkbox-list/grouped-checkbox-list'
export { InputMapping } from './input-mapping/input-mapping'
@@ -27,10 +28,12 @@ export { ShortInput } from './short-input/short-input'
export { SkillInput } from './skill-input/skill-input'
export { SlackSelectorInput } from './slack-selector/slack-selector-input'
export { SliderInput } from './slider-input/slider-input'
export { SortBuilder } from './sort-builder/sort-builder'
export { InputFormat } from './starter/input-format'
export { SubBlockInputController } from './sub-block-input-controller'
export { Switch } from './switch/switch'
export { Table } from './table/table'
export { TableSelector } from './table-selector/table-selector'
export { Text } from './text/text'
export { TimeInput } from './time-input/time-input'
export { ToolInput } from './tool-input/tool-input'

View File

@@ -0,0 +1,110 @@
import { Plus } from 'lucide-react'
import { Badge, Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { SortRule } from '@/lib/table/query-builder/constants'
interface SortRuleRowProps {
rule: SortRule
index: number
columns: ComboboxOption[]
directionOptions: ComboboxOption[]
isReadOnly: boolean
onAdd: () => void
onRemove: (id: string) => void
onUpdate: (id: string, field: keyof SortRule, value: string) => void
onToggleCollapse: (id: string) => void
}
export function SortRuleRow({
rule,
index,
columns,
directionOptions,
isReadOnly,
onAdd,
onRemove,
onUpdate,
onToggleCollapse,
}: SortRuleRowProps) {
const getDirectionLabel = (value: string) => {
const option = directionOptions.find((dir) => dir.value === value)
return option?.label || value
}
const getColumnLabel = (value: string) => {
const option = columns.find((col) => col.value === value)
return option?.label || value
}
const renderHeader = () => (
<div
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
onClick={() => onToggleCollapse(rule.id)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{rule.collapsed && rule.column ? getColumnLabel(rule.column) : `Sort ${index + 1}`}
</span>
{rule.collapsed && rule.column && (
<Badge variant='type' size='sm'>
{getDirectionLabel(rule.direction)}
</Badge>
)}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' onClick={onAdd} disabled={isReadOnly} className='h-auto p-0'>
<Plus className='h-[14px] w-[14px]' />
<span className='sr-only'>Add Sort</span>
</Button>
<Button
variant='ghost'
onClick={() => onRemove(rule.id)}
disabled={isReadOnly}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Sort</span>
</Button>
</div>
</div>
)
const renderContent = () => (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Column</Label>
<Combobox
options={columns}
value={rule.column}
onChange={(v) => onUpdate(rule.id, 'column', v)}
disabled={isReadOnly}
placeholder='Select column'
/>
</div>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Direction</Label>
<Combobox
options={directionOptions}
value={rule.direction}
onChange={(v) => onUpdate(rule.id, 'direction', v as 'asc' | 'desc')}
disabled={isReadOnly}
placeholder='Select direction'
/>
</div>
</div>
)
return (
<div
data-sort-id={rule.id}
className={cn(
'rounded-[4px] border border-[var(--border-1)]',
rule.collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
>
{renderHeader()}
{!rule.collapsed && renderContent()}
</div>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { useCallback, useMemo } from 'react'
import type { ComboboxOption } from '@/components/emcn'
import { useTableColumns } from '@/lib/table/hooks'
import { SORT_DIRECTIONS, type SortRule } from '@/lib/table/query-builder/constants'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { SortRuleRow } from './components/sort-rule-row'
interface SortBuilderProps {
blockId: string
subBlockId: string
isPreview?: boolean
previewValue?: SortRule[] | null
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
}
const createDefaultRule = (columns: ComboboxOption[]): SortRule => ({
id: crypto.randomUUID(),
column: columns[0]?.value || '',
direction: 'asc',
collapsed: false,
})
/** Visual builder for table sort rules in workflow blocks. */
export function SortBuilder({
blockId,
subBlockId,
isPreview = false,
previewValue,
disabled = false,
columns: propColumns,
tableIdSubBlockId = 'tableId',
}: SortBuilderProps) {
const [storeValue, setStoreValue] = useSubBlockValue<SortRule[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const dynamicColumns = useTableColumns({ tableId: tableIdValue, includeBuiltIn: true })
const columns = useMemo(() => {
if (propColumns && propColumns.length > 0) return propColumns
return dynamicColumns
}, [propColumns, dynamicColumns])
const directionOptions = useMemo(
() => SORT_DIRECTIONS.map((dir) => ({ value: dir.value, label: dir.label })),
[]
)
const value = isPreview ? previewValue : storeValue
const rules: SortRule[] =
Array.isArray(value) && value.length > 0 ? value : [createDefaultRule(columns)]
const isReadOnly = isPreview || disabled
const addRule = useCallback(() => {
if (isReadOnly) return
setStoreValue([...rules, createDefaultRule(columns)])
}, [isReadOnly, rules, columns, setStoreValue])
const removeRule = useCallback(
(id: string) => {
if (isReadOnly) return
if (rules.length === 1) {
setStoreValue([createDefaultRule(columns)])
} else {
setStoreValue(rules.filter((r) => r.id !== id))
}
},
[isReadOnly, rules, columns, setStoreValue]
)
const updateRule = useCallback(
(id: string, field: keyof SortRule, newValue: string) => {
if (isReadOnly) return
setStoreValue(rules.map((r) => (r.id === id ? { ...r, [field]: newValue } : r)))
},
[isReadOnly, rules, setStoreValue]
)
const toggleCollapse = useCallback(
(id: string) => {
if (isReadOnly) return
setStoreValue(rules.map((r) => (r.id === id ? { ...r, collapsed: !r.collapsed } : r)))
},
[isReadOnly, rules, setStoreValue]
)
return (
<div className='space-y-[8px]'>
{rules.map((rule, index) => (
<SortRuleRow
key={rule.id}
rule={rule}
index={index}
columns={columns}
directionOptions={directionOptions}
isReadOnly={isReadOnly}
onAdd={addRule}
onRemove={removeRule}
onUpdate={updateRule}
onToggleCollapse={toggleCollapse}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useCallback, useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Combobox, type ComboboxOption } from '@/components/emcn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useTablesList } from '@/hooks/queries/tables'
interface TableSelectorProps {
blockId: string
subBlock: SubBlockConfig
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
}
/**
* Table selector component with dropdown for selecting workspace tables
*
* @remarks
* Provides a dropdown to select workspace tables.
* Uses React Query for efficient data fetching and caching.
* The external link to view the table is rendered in the label row by the parent SubBlock.
*/
export function TableSelector({
blockId,
subBlock,
disabled = false,
isPreview = false,
previewValue,
}: TableSelectorProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlock.id)
const {
data: tables = [],
isLoading,
error,
} = useTablesList(isPreview || disabled ? undefined : workspaceId)
const value = isPreview ? previewValue : storeValue
const tableId = typeof value === 'string' ? value : null
const options = useMemo<ComboboxOption[]>(() => {
return tables.map((table) => ({
label: table.name.toLowerCase(),
value: table.id,
}))
}, [tables])
const handleChange = useCallback(
(selectedValue: string) => {
if (isPreview || disabled) return
setStoreValue(selectedValue)
},
[isPreview, disabled, setStoreValue]
)
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined
return (
<Combobox
options={options}
value={tableId ?? undefined}
onChange={handleChange}
placeholder={subBlock.placeholder || 'Select a table'}
disabled={disabled || isPreview}
editable={false}
isLoading={isLoading}
error={errorMessage}
searchable={options.length > 5}
searchPlaceholder='Search...'
/>
)
}

View File

@@ -19,11 +19,11 @@ interface TableProps {
subBlockId: string
columns: string[]
isPreview?: boolean
previewValue?: TableRow[] | null
previewValue?: WorkflowTableRow[] | null
disabled?: boolean
}
interface TableRow {
interface WorkflowTableRow {
id: string
cells: Record<string, string>
}
@@ -38,7 +38,7 @@ export function Table({
}: TableProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [storeValue, setStoreValue] = useSubBlockValue<TableRow[]>(blockId, subBlockId)
const [storeValue, setStoreValue] = useSubBlockValue<WorkflowTableRow[]>(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
// Use the extended hook for field-level management
@@ -73,7 +73,7 @@ export function Table({
*/
useEffect(() => {
if (!isPreview && !disabled && (!Array.isArray(storeValue) || storeValue.length === 0)) {
const initialRow: TableRow = {
const initialRow: WorkflowTableRow = {
id: crypto.randomUUID(),
cells: { ...emptyCellsTemplate },
}
@@ -110,7 +110,7 @@ export function Table({
}
})
return validatedRows as TableRow[]
return validatedRows as WorkflowTableRow[]
}, [value, emptyCellsTemplate])
// Helper to update a cell value
@@ -164,7 +164,12 @@ export function Table({
</thead>
)
const renderCell = (row: TableRow, rowIndex: number, column: string, cellIndex: number) => {
const renderCell = (
row: WorkflowTableRow,
rowIndex: number,
column: string,
cellIndex: number
) => {
// Defensive programming: ensure row.cells exists and has the expected structure
const hasValidCells = row.cells && typeof row.cells === 'object'
if (!hasValidCells) logger.warn('Table row has malformed cells data:', row)

View File

@@ -357,6 +357,7 @@ const BUILT_IN_TOOL_TYPES = new Set([
'tts',
'stt',
'memory',
'table',
'webhook_request',
'workflow',
])
@@ -614,7 +615,8 @@ export const ToolInput = memo(function ToolInput({
block.type === 'workflow' ||
block.type === 'workflow_input' ||
block.type === 'knowledge' ||
block.type === 'function') &&
block.type === 'function' ||
block.type === 'table') &&
block.type !== 'evaluator' &&
block.type !== 'mcp' &&
block.type !== 'file'

View File

@@ -1,8 +1,17 @@
import { type JSX, type MouseEvent, memo, useCallback, useRef, useState } from 'react'
import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react'
import isEqual from 'lodash/isEqual'
import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react'
import {
AlertTriangle,
ArrowLeftRight,
ArrowUp,
Check,
Clipboard,
ExternalLink,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants'
import {
CheckboxList,
Code,
@@ -15,6 +24,7 @@ import {
EvalInput,
FileSelectorInput,
FileUpload,
FilterBuilder,
FolderSelectorInput,
GroupedCheckboxList,
InputFormat,
@@ -34,8 +44,10 @@ import {
SkillInput,
SlackSelectorInput,
SliderInput,
SortBuilder,
Switch,
Table,
TableSelector,
Text,
TimeInput,
ToolInput,
@@ -202,7 +214,12 @@ const renderLabel = (
copied: boolean
onCopy: () => void
},
labelSuffix?: React.ReactNode
labelSuffix?: React.ReactNode,
externalLink?: {
show: boolean
onClick: () => void
tooltip: string
}
): JSX.Element | null => {
if (config.type === 'switch') return null
if (!config.title) return null
@@ -211,6 +228,7 @@ const renderLabel = (
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview
const showCopy = copyState?.showCopyButton && !wandState?.isPreview
const showExternalLink = externalLink?.show && !wandState?.isPreview
const canonicalToggleDisabledResolved = canonicalToggleIsDisabled ?? canonicalToggle?.disabled
return (
@@ -318,6 +336,23 @@ const renderLabel = (
)}
</>
)}
{showExternalLink && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0'
onClick={externalLink?.onClick}
aria-label={externalLink?.tooltip}
>
<ExternalLink className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>{externalLink?.tooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{showCanonicalToggle && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -415,6 +450,9 @@ function SubBlockComponent({
labelSuffix,
dependencyContext,
}: SubBlockProps): JSX.Element {
const params = useParams()
const workspaceId = params.workspaceId as string
const [isValidJson, setIsValidJson] = useState(true)
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -451,6 +489,54 @@ function SubBlockComponent({
}
}, [webhookManagement?.webhookUrl])
const tableId =
config.type === 'table-selector' && subBlockValues
? (subBlockValues[config.id]?.value as string | null)
: null
const hasSelectedTable = tableId && !tableId.startsWith('<')
const knowledgeBaseId =
config.type === 'knowledge-base-selector' && subBlockValues
? (subBlockValues[config.id]?.value as string | null)
: null
const hasSelectedKnowledgeBase = knowledgeBaseId && !knowledgeBaseId.startsWith('<')
const handleNavigateToTable = useCallback(() => {
if (tableId && workspaceId) {
window.open(`/workspace/${workspaceId}/tables/${tableId}`, '_blank')
}
}, [workspaceId, tableId])
const handleNavigateToKnowledgeBase = useCallback(() => {
if (knowledgeBaseId && workspaceId) {
window.open(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`, '_blank')
}
}, [workspaceId, knowledgeBaseId])
const externalLink = useMemo(() => {
if (config.type === 'table-selector' && hasSelectedTable) {
return {
show: true,
onClick: handleNavigateToTable,
tooltip: 'View table',
}
}
if (config.type === 'knowledge-base-selector' && hasSelectedKnowledgeBase) {
return {
show: true,
onClick: handleNavigateToKnowledgeBase,
tooltip: 'View knowledge base',
}
}
return undefined
}, [
config.type,
hasSelectedTable,
handleNavigateToTable,
hasSelectedKnowledgeBase,
handleNavigateToKnowledgeBase,
])
/**
* Handles wand icon click to activate inline prompt mode.
* Focuses the input after a brief delay to ensure DOM is ready.
@@ -584,6 +670,19 @@ function SubBlockComponent({
</div>
)
case 'table-selector':
return (
<div onMouseDown={handleMouseDown}>
<TableSelector
blockId={blockId}
subBlock={config}
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue as string | null}
/>
</div>
)
case 'combobox':
return (
<div onMouseDown={handleMouseDown}>
@@ -944,6 +1043,28 @@ function SubBlockComponent({
/>
)
case 'filter-builder':
return (
<FilterBuilder
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue as FilterRule[] | null | undefined}
disabled={isDisabled}
/>
)
case 'sort-builder':
return (
<SortBuilder
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue as SortRule[] | null | undefined}
disabled={isDisabled}
/>
)
case 'channel-selector':
case 'user-selector':
return (
@@ -1060,7 +1181,8 @@ function SubBlockComponent({
copied,
onCopy: handleCopy,
},
labelSuffix
labelSuffix,
externalLink
)}
{renderInput()}
</div>

View File

@@ -9,6 +9,7 @@ import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createMcpToolId } from '@/lib/mcp/shared'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import type { FilterRule, SortRule } from '@/lib/table/types'
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
buildCanonicalIndex,
@@ -41,6 +42,7 @@ import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
import { useSkills } from '@/hooks/queries/skills'
import { useTablesList } from '@/hooks/queries/tables'
import { useDeployChildWorkflow } from '@/hooks/queries/workflows'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel'
@@ -55,9 +57,9 @@ const logger = createLogger('WorkflowBlock')
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/**
* Type guard for table row structure
* Type guard for workflow table row structure (sub-block table inputs)
*/
interface TableRow {
interface WorkflowTableRow {
id: string
cells: Record<string, string>
}
@@ -76,7 +78,7 @@ interface FieldFormat {
/**
* Checks if a value is a table row array
*/
const isTableRowArray = (value: unknown): value is TableRow[] => {
const isTableRowArray = (value: unknown): value is WorkflowTableRow[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
@@ -95,7 +97,11 @@ const isFieldFormatArray = (value: unknown): value is FieldFormat[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' && firstItem !== null && 'id' in firstItem && 'name' in firstItem
typeof firstItem === 'object' &&
firstItem !== null &&
'id' in firstItem &&
'name' in firstItem &&
typeof firstItem.name === 'string'
)
}
@@ -161,7 +167,8 @@ const isTagFilterArray = (value: unknown): value is TagFilterItem[] => {
typeof firstItem === 'object' &&
firstItem !== null &&
'tagName' in firstItem &&
'tagValue' in firstItem
'tagValue' in firstItem &&
typeof firstItem.tagName === 'string'
)
}
@@ -183,7 +190,40 @@ const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => {
firstItem !== null &&
'tagName' in firstItem &&
'value' in firstItem &&
!('tagValue' in firstItem) // Distinguish from tag filters
!('tagValue' in firstItem) && // Distinguish from tag filters
typeof firstItem.tagName === 'string'
)
}
/**
* Type guard for filter condition array (used in table block filter builder)
*/
const isFilterConditionArray = (value: unknown): value is FilterRule[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'column' in firstItem &&
'operator' in firstItem &&
'logicalOperator' in firstItem &&
typeof firstItem.column === 'string'
)
}
/**
* Type guard for sort condition array (used in table block sort builder)
*/
const isSortConditionArray = (value: unknown): value is SortRule[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'column' in firstItem &&
'direction' in firstItem &&
typeof firstItem.column === 'string' &&
(firstItem.direction === 'asc' || firstItem.direction === 'desc')
)
}
@@ -231,7 +271,9 @@ export const getDisplayValue = (value: unknown): string => {
}
if (isTagFilterArray(parsedValue)) {
const validFilters = parsedValue.filter((f) => f.tagName?.trim())
const validFilters = parsedValue.filter(
(f) => typeof f.tagName === 'string' && f.tagName.trim() !== ''
)
if (validFilters.length === 0) return '-'
if (validFilters.length === 1) return validFilters[0].tagName
if (validFilters.length === 2) return `${validFilters[0].tagName}, ${validFilters[1].tagName}`
@@ -239,13 +281,54 @@ export const getDisplayValue = (value: unknown): string => {
}
if (isDocumentTagArray(parsedValue)) {
const validTags = parsedValue.filter((t) => t.tagName?.trim())
const validTags = parsedValue.filter(
(t) => typeof t.tagName === 'string' && t.tagName.trim() !== ''
)
if (validTags.length === 0) return '-'
if (validTags.length === 1) return validTags[0].tagName
if (validTags.length === 2) return `${validTags[0].tagName}, ${validTags[1].tagName}`
return `${validTags[0].tagName}, ${validTags[1].tagName} +${validTags.length - 2}`
}
if (isFilterConditionArray(parsedValue)) {
const validConditions = parsedValue.filter(
(c) => typeof c.column === 'string' && c.column.trim() !== ''
)
if (validConditions.length === 0) return '-'
const formatCondition = (c: FilterRule) => {
const opLabels: Record<string, string> = {
eq: '=',
ne: '≠',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
contains: '~',
in: 'in',
}
const op = opLabels[c.operator] || c.operator
return `${c.column} ${op} ${c.value || '?'}`
}
if (validConditions.length === 1) return formatCondition(validConditions[0])
if (validConditions.length === 2) {
return `${formatCondition(validConditions[0])}, ${formatCondition(validConditions[1])}`
}
return `${formatCondition(validConditions[0])}, ${formatCondition(validConditions[1])} +${validConditions.length - 2}`
}
if (isSortConditionArray(parsedValue)) {
const validConditions = parsedValue.filter(
(c) => typeof c.column === 'string' && c.column.trim() !== ''
)
if (validConditions.length === 0) return '-'
const formatSort = (c: SortRule) => `${c.column} ${c.direction === 'desc' ? '↓' : '↑'}`
if (validConditions.length === 1) return formatSort(validConditions[0])
if (validConditions.length === 2) {
return `${formatSort(validConditions[0])}, ${formatSort(validConditions[1])}`
}
return `${formatSort(validConditions[0])}, ${formatSort(validConditions[1])} +${validConditions.length - 2}`
}
if (isTableRowArray(parsedValue)) {
const nonEmptyRows = parsedValue.filter((row) => {
const cellValues = Object.values(row.cells)
@@ -267,7 +350,9 @@ export const getDisplayValue = (value: unknown): string => {
}
if (isFieldFormatArray(parsedValue)) {
const namedFields = parsedValue.filter((field) => field.name && field.name.trim() !== '')
const namedFields = parsedValue.filter(
(field) => typeof field.name === 'string' && field.name.trim() !== ''
)
if (namedFields.length === 0) return '-'
if (namedFields.length === 1) return namedFields[0].name
if (namedFields.length === 2) return `${namedFields[0].name}, ${namedFields[1].name}`
@@ -513,6 +598,15 @@ const SubBlockRow = memo(function SubBlockRow({
return tool?.name ?? null
}, [subBlock?.type, rawValue, mcpToolsData])
const { data: tables = [] } = useTablesList(workspaceId || '')
const tableDisplayName = useMemo(() => {
if (subBlock?.id !== 'tableId' || typeof rawValue !== 'string') {
return null
}
const table = tables.find((t) => t.id === rawValue)
return table?.name ?? null
}, [subBlock?.id, rawValue, tables])
const webhookUrlDisplayValue = useMemo(() => {
if (subBlock?.id !== 'webhookUrlDisplay' || !blockId) {
return null
@@ -619,6 +713,27 @@ const SubBlockRow = memo(function SubBlockRow({
return `${toolNames[0]}, ${toolNames[1]} +${toolNames.length - 2}`
}, [subBlock?.type, rawValue, customTools, workspaceId])
const filterDisplayValue = useMemo(() => {
const isFilterField =
subBlock?.id === 'filter' || subBlock?.id === 'filterCriteria' || subBlock?.id === 'sort'
if (!isFilterField || !rawValue) return null
const parsedValue = tryParseJson(rawValue)
if (isPlainObject(parsedValue) || Array.isArray(parsedValue)) {
try {
const jsonStr = JSON.stringify(parsedValue, null, 0)
if (jsonStr.length <= 35) return jsonStr
return `${jsonStr.slice(0, 32)}...`
} catch {
return null
}
}
return null
}, [subBlock?.id, rawValue])
/**
* Hydrates skill references to display names.
* Resolves skill IDs to their current names from the skills query.
@@ -663,18 +778,21 @@ const SubBlockRow = memo(function SubBlockRow({
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
const isMonospaceField = Boolean(filterDisplayValue)
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
const hydratedName =
credentialName ||
dropdownLabel ||
variablesDisplayValue ||
filterDisplayValue ||
toolsDisplayValue ||
skillsDisplayValue ||
knowledgeBaseDisplayName ||
workflowSelectionName ||
mcpServerDisplayName ||
mcpToolDisplayName ||
tableDisplayName ||
webhookUrlDisplayValue ||
selectorDisplayName
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
@@ -689,7 +807,10 @@ const SubBlockRow = memo(function SubBlockRow({
</span>
{displayValue !== undefined && (
<span
className='flex-1 truncate text-right text-[14px] text-[var(--text-primary)]'
className={cn(
'flex-1 truncate text-right text-[14px] text-[var(--text-primary)]',
isMonospaceField && 'font-mono'
)}
title={displayValue}
>
{displayValue}

View File

@@ -4,23 +4,37 @@ import { useQueryClient } from '@tanstack/react-query'
import { readSSEStream } from '@/lib/core/utils/sse'
import type { GenerationType } from '@/blocks/types'
import { subscriptionKeys } from '@/hooks/queries/subscription'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useWand')
interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
interface BuildWandContextInfoOptions {
currentValue?: string
generationType?: string
}
/**
* Builds rich context information based on current content and generation type
* Builds rich context information based on current content and generation type.
* Note: Table schema context is now fetched server-side in /api/wand for simplicity.
*/
function buildContextInfo(currentValue?: string, generationType?: string): string {
if (!currentValue || currentValue.trim() === '') {
return 'no current content'
}
function buildWandContextInfo({
currentValue,
generationType,
}: BuildWandContextInfoOptions): string {
const hasContent = Boolean(currentValue && currentValue.trim() !== '')
const contentLength = currentValue?.length ?? 0
const lineCount = currentValue ? currentValue.split('\n').length : 0
const contentLength = currentValue.length
const lineCount = currentValue.split('\n').length
let contextInfo = hasContent
? `Current content (${contentLength} characters, ${lineCount} lines):\n${currentValue}`
: 'no current content'
let contextInfo = `Current content (${contentLength} characters, ${lineCount} lines):\n${currentValue}`
if (generationType) {
if (generationType && currentValue) {
switch (generationType) {
case 'javascript-function-body':
case 'typescript-function-body': {
@@ -33,6 +47,7 @@ function buildContextInfo(currentValue?: string, generationType?: string): strin
case 'json-schema':
case 'json-object':
case 'table-schema':
try {
const parsed = JSON.parse(currentValue)
const keys = Object.keys(parsed)
@@ -47,11 +62,6 @@ function buildContextInfo(currentValue?: string, generationType?: string): strin
return contextInfo
}
interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface WandConfig {
enabled: boolean
prompt: string
@@ -63,6 +73,9 @@ export interface WandConfig {
interface UseWandProps {
wandConfig?: WandConfig
currentValue?: string
contextParams?: {
tableId?: string | null
}
onGeneratedContent: (content: string) => void
onStreamChunk?: (chunk: string) => void
onStreamStart?: () => void
@@ -72,12 +85,14 @@ interface UseWandProps {
export function useWand({
wandConfig,
currentValue,
contextParams,
onGeneratedContent,
onStreamChunk,
onStreamStart,
onGenerationComplete,
}: UseWandProps) {
const queryClient = useQueryClient()
const workflowId = useWorkflowRegistry((state) => state.hydration.workflowId)
const [isLoading, setIsLoading] = useState(false)
const [isPromptVisible, setIsPromptVisible] = useState(false)
const [promptInputValue, setPromptInputValue] = useState('')
@@ -148,7 +163,10 @@ export function useWand({
}
try {
const contextInfo = buildContextInfo(currentValue, wandConfig?.generationType)
const contextInfo = buildWandContextInfo({
currentValue,
generationType: wandConfig?.generationType,
})
let systemPrompt = wandConfig?.prompt || ''
if (systemPrompt.includes('{context}')) {
@@ -171,6 +189,8 @@ export function useWand({
stream: true,
history: wandConfig?.maintainHistory ? conversationHistory : [],
generationType: wandConfig?.generationType,
workflowId,
wandContext: contextParams?.tableId ? { tableId: contextParams.tableId } : undefined,
}),
signal: abortControllerRef.current.signal,
cache: 'no-store',
@@ -235,6 +255,8 @@ export function useWand({
onStreamStart,
onGenerationComplete,
queryClient,
contextParams?.tableId,
workflowId,
]
)

View File

@@ -119,6 +119,14 @@ export function SearchModal({
href: `/workspace/${workspaceId}/knowledge`,
hidden: permissionConfig.hideKnowledgeBaseTab,
},
// TODO: Uncomment when working on tables
// {
// id: 'tables',
// name: 'Tables',
// icon: Table,
// href: `/workspace/${workspaceId}/tables`,
// hidden: permissionConfig.hideTablesTab,
// },
{
id: 'help',
name: 'Help',

View File

@@ -268,6 +268,14 @@ export const Sidebar = memo(function Sidebar() {
href: `/workspace/${workspaceId}/knowledge`,
hidden: permissionConfig.hideKnowledgeBaseTab,
},
// TODO: Uncomment when working on tables
// {
// id: 'tables',
// label: 'Tables',
// icon: Table,
// href: `/workspace/${workspaceId}/tables`,
// hidden: permissionConfig.hideTablesTab,
// },
{
id: 'help',
label: 'Help',

View File

@@ -408,6 +408,9 @@ describe.concurrent('Blocks Module', () => {
'workflow-input-mapper',
'text',
'router-input',
'table-selector',
'filter-builder',
'sort-builder',
'skill-input',
]

View File

@@ -485,14 +485,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
],
config: {
tool: (params) => {
// Convert numeric string inputs to numbers
if (params.limit) {
params.limit = Number(params.limit)
}
if (params.offset) {
params.offset = Number(params.offset)
}
switch (params.operation) {
case 'ahrefs_domain_rating':
return 'ahrefs_domain_rating'
@@ -514,6 +506,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return 'ahrefs_domain_rating'
}
},
params: (params) => {
const result: Record<string, unknown> = {}
if (params.limit) result.limit = Number(params.limit)
if (params.offset) result.offset = Number(params.offset)
return result
},
},
},
inputs: {

View File

@@ -0,0 +1,646 @@
import { AlgoliaIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const AlgoliaBlock: BlockConfig = {
type: 'algolia',
name: 'Algolia',
description: 'Search and manage Algolia indices',
longDescription:
'Integrate Algolia into your workflow. Search indices, manage records (add, update, delete, browse), configure index settings, and perform batch operations.',
docsLink: 'https://docs.sim.ai/tools/algolia',
category: 'tools',
bgColor: '#003DFF',
icon: AlgoliaIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Search', id: 'search' },
{ label: 'Add Record', id: 'add_record' },
{ label: 'Get Record', id: 'get_record' },
{ label: 'Get Records', id: 'get_records' },
{ label: 'Partial Update Record', id: 'partial_update_record' },
{ label: 'Delete Record', id: 'delete_record' },
{ label: 'Browse Records', id: 'browse_records' },
{ label: 'Batch Operations', id: 'batch_operations' },
{ label: 'List Indices', id: 'list_indices' },
{ label: 'Get Settings', id: 'get_settings' },
{ label: 'Update Settings', id: 'update_settings' },
{ label: 'Delete Index', id: 'delete_index' },
{ label: 'Copy/Move Index', id: 'copy_move_index' },
{ label: 'Clear Records', id: 'clear_records' },
{ label: 'Delete By Filter', id: 'delete_by_filter' },
],
value: () => 'search',
},
// Index name - needed for all except list_indices
{
id: 'indexName',
title: 'Index Name',
type: 'short-input',
placeholder: 'my_index',
condition: { field: 'operation', value: 'list_indices', not: true },
required: { field: 'operation', value: 'list_indices', not: true },
},
// Search fields
{
id: 'query',
title: 'Search Query',
type: 'short-input',
placeholder: 'Enter search query',
condition: { field: 'operation', value: ['search', 'browse_records'] },
required: { field: 'operation', value: 'search' },
},
{
id: 'hitsPerPage',
title: 'Hits Per Page',
type: 'short-input',
placeholder: '20',
condition: { field: 'operation', value: ['search', 'browse_records'] },
mode: 'advanced',
},
{
id: 'page',
title: 'Page',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'filters',
title: 'Filters',
type: 'short-input',
placeholder: 'category:electronics AND price < 100',
condition: { field: 'operation', value: ['search', 'browse_records'] },
wandConfig: {
enabled: true,
prompt: `Generate an Algolia filter expression based on the user's description.
Available operators: AND, OR, NOT
Comparison: =, !=, <, >, <=, >=
Facet filters: attribute:value
Numeric filters: attribute operator value
Boolean filters: attribute:true / attribute:false
Tag filters: _tags:value
Examples:
- "category:electronics AND price < 100"
- "brand:Apple OR brand:Samsung"
- "inStock:true AND NOT category:deprecated"
- "(category:electronics OR category:books) AND price >= 10"
Return ONLY the filter string, no quotes or explanation.`,
},
},
{
id: 'attributesToRetrieve',
title: 'Attributes to Retrieve',
type: 'short-input',
placeholder: 'name,description,price',
condition: { field: 'operation', value: ['search', 'get_record', 'browse_records'] },
mode: 'advanced',
},
// Browse cursor
{
id: 'cursor',
title: 'Cursor',
type: 'short-input',
placeholder: 'Cursor from previous browse response',
condition: { field: 'operation', value: 'browse_records' },
mode: 'advanced',
},
// Add record fields
{
id: 'record',
title: 'Record',
type: 'long-input',
placeholder: '{"name": "Product", "price": 29.99}',
condition: { field: 'operation', value: 'add_record' },
required: { field: 'operation', value: 'add_record' },
wandConfig: {
enabled: true,
prompt: `Generate a JSON object for an Algolia record based on the user's description.
### CONTEXT
{context}
### GUIDELINES
- Return ONLY a valid JSON object starting with { and ending with }
- Include relevant attributes as key-value pairs
- Do NOT include objectID unless the user explicitly specifies one
- Use appropriate types: strings, numbers, booleans, arrays
### EXAMPLE
User: "A product with name, price, and categories"
Output:
{"name": "Example Product", "price": 29.99, "categories": ["electronics", "gadgets"]}
Return ONLY the JSON object.`,
placeholder: 'Describe the record to add...',
generationType: 'json-object',
},
},
// Partial update fields
{
id: 'attributes',
title: 'Attributes to Update',
type: 'long-input',
placeholder: '{"price": 24.99, "stock": {"_operation": "Decrement", "value": 1}}',
condition: { field: 'operation', value: 'partial_update_record' },
required: { field: 'operation', value: 'partial_update_record' },
wandConfig: {
enabled: true,
prompt: `Generate a JSON object for an Algolia partial update based on the user's description.
### CONTEXT
{context}
### GUIDELINES
- Return ONLY a valid JSON object starting with { and ending with }
- For simple updates, use key-value pairs: {"price": 24.99}
- For built-in operations, use the _operation syntax:
- Increment: {"count": {"_operation": "Increment", "value": 1}}
- Decrement: {"stock": {"_operation": "Decrement", "value": 1}}
- Add to array: {"tags": {"_operation": "Add", "value": "new-tag"}}
- Remove from array: {"tags": {"_operation": "Remove", "value": "old-tag"}}
- AddUnique: {"tags": {"_operation": "AddUnique", "value": "unique-tag"}}
- IncrementFrom: {"version": {"_operation": "IncrementFrom", "value": 0}}
- IncrementSet: {"views": {"_operation": "IncrementSet", "value": 1}}
### EXAMPLE
User: "Decrease stock by 2 and add a sale tag"
Output:
{"stock": {"_operation": "Decrement", "value": 2}, "tags": {"_operation": "Add", "value": "sale"}}
Return ONLY the JSON object.`,
placeholder: 'Describe the attributes to update...',
generationType: 'json-object',
},
},
{
id: 'createIfNotExists',
title: 'Create If Not Exists',
type: 'dropdown',
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
condition: { field: 'operation', value: 'partial_update_record' },
value: () => 'true',
mode: 'advanced',
},
// Batch operations field
{
id: 'requests',
title: 'Batch Requests',
type: 'long-input',
placeholder:
'[{"action": "addObject", "body": {"name": "Item"}}, {"action": "deleteObject", "body": {"objectID": "123"}}]',
condition: { field: 'operation', value: 'batch_operations' },
required: { field: 'operation', value: 'batch_operations' },
wandConfig: {
enabled: true,
prompt: `Generate a JSON array of Algolia batch operations based on the user's description.
### CONTEXT
{context}
### GUIDELINES
- Return ONLY a valid JSON array starting with [ and ending with ]
- Each item must have "action" and "body" properties
- Valid actions: addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear
- For deleteObject, body must include objectID
- For updateObject, body must include objectID
- For addObject, objectID is optional (auto-generated if omitted)
### EXAMPLE
User: "Add two products and delete one with ID old-123"
Output:
[
{"action": "addObject", "body": {"name": "Product A", "price": 19.99}},
{"action": "addObject", "body": {"name": "Product B", "price": 29.99}},
{"action": "deleteObject", "body": {"objectID": "old-123"}}
]
Return ONLY the JSON array.`,
placeholder: 'Describe the batch operations to perform...',
generationType: 'json-object',
},
},
// Update settings fields
{
id: 'settings',
title: 'Settings',
type: 'long-input',
placeholder:
'{"searchableAttributes": ["name", "description"], "customRanking": ["desc(popularity)"]}',
condition: { field: 'operation', value: 'update_settings' },
required: { field: 'operation', value: 'update_settings' },
wandConfig: {
enabled: true,
prompt: `Generate a valid Algolia index settings JSON object based on the user's description.
### CONTEXT
{context}
### GUIDELINES
- Return ONLY a valid JSON object starting with { and ending with }
- Common settings include:
- searchableAttributes: array of attribute names (ordered by priority)
- attributesForFaceting: array of attributes for filtering/faceting (prefix with "filterOnly(" or "searchable(" as needed)
- customRanking: array of "asc(attr)" or "desc(attr)" expressions
- ranking: array of ranking criteria (e.g., "typo", "geo", "words", "filters", "proximity", "attribute", "exact", "custom")
- replicas: array of replica index names
- hitsPerPage: number of results per page
- paginationLimitedTo: max pagination depth
- highlightPreTag / highlightPostTag: HTML tags for highlighting
### EXAMPLE
User: "Make name and description searchable, add category faceting, rank by popularity"
Output:
{"searchableAttributes": ["name", "description"], "attributesForFaceting": ["category"], "customRanking": ["desc(popularity)"]}
Return ONLY the JSON object.`,
placeholder: 'Describe the settings to apply...',
generationType: 'json-object',
},
},
{
id: 'forwardToReplicas',
title: 'Forward to Replicas',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
condition: { field: 'operation', value: 'update_settings' },
value: () => 'false',
mode: 'advanced',
},
// Copy/Move index fields
{
id: 'copyMoveOperation',
title: 'Copy or Move',
type: 'dropdown',
options: [
{ label: 'Copy', id: 'copy' },
{ label: 'Move', id: 'move' },
],
condition: { field: 'operation', value: 'copy_move_index' },
value: () => 'copy',
},
{
id: 'destination',
title: 'Destination Index',
type: 'short-input',
placeholder: 'my_index_backup',
condition: { field: 'operation', value: 'copy_move_index' },
required: { field: 'operation', value: 'copy_move_index' },
},
{
id: 'scope',
title: 'Scope (Copy Only)',
type: 'short-input',
placeholder: '["settings", "synonyms", "rules"]',
condition: { field: 'operation', value: 'copy_move_index' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: `Generate a JSON array of Algolia copy scopes based on the user's description.
### CONTEXT
{context}
### GUIDELINES
- Return ONLY a valid JSON array
- Valid scope values: "settings", "synonyms", "rules"
- Omitting scope copies everything including records
- Only applies to copy operations, not move
### EXAMPLE
User: "Copy only settings and synonyms"
Output:
["settings", "synonyms"]
Return ONLY the JSON array.`,
placeholder: 'Describe what to copy...',
generationType: 'json-object',
},
},
// Delete by filter fields
{
id: 'deleteFilters',
title: 'Filter Expression',
type: 'short-input',
placeholder: 'category:outdated AND price < 10',
condition: { field: 'operation', value: 'delete_by_filter' },
required: { field: 'operation', value: 'delete_by_filter' },
wandConfig: {
enabled: true,
prompt: `Generate an Algolia filter expression for deleting records based on the user's description.
Available operators: AND, OR, NOT
Comparison: =, !=, <, >, <=, >=
Facet filters: attribute:value
Numeric filters: attribute operator value
Examples:
- "category:outdated AND price < 10"
- "status:archived OR lastUpdated < 1609459200"
- "NOT category:active"
Return ONLY the filter string, no quotes or explanation.`,
},
},
{
id: 'facetFilters',
title: 'Facet Filters',
type: 'short-input',
placeholder: '["brand:Acme"]',
condition: { field: 'operation', value: 'delete_by_filter' },
mode: 'advanced',
},
{
id: 'numericFilters',
title: 'Numeric Filters',
type: 'short-input',
placeholder: '["price > 100"]',
condition: { field: 'operation', value: 'delete_by_filter' },
mode: 'advanced',
},
{
id: 'tagFilters',
title: 'Tag Filters',
type: 'short-input',
placeholder: '["published", "archived"]',
condition: { field: 'operation', value: 'delete_by_filter' },
mode: 'advanced',
},
{
id: 'aroundLatLng',
title: 'Around Lat/Lng',
type: 'short-input',
placeholder: '40.71,-74.01',
condition: { field: 'operation', value: 'delete_by_filter' },
mode: 'advanced',
},
{
id: 'aroundRadius',
title: 'Around Radius (m)',
type: 'short-input',
placeholder: '1000 or "all"',
condition: { field: 'operation', value: 'delete_by_filter' },
mode: 'advanced',
},
{
id: 'insideBoundingBox',
title: 'Inside Bounding Box',
type: 'short-input',
placeholder: '[[47.3165,0.757,47.3424,0.8012]]',
condition: { field: 'operation', value: 'delete_by_filter' },
mode: 'advanced',
},
{
id: 'insidePolygon',
title: 'Inside Polygon',
type: 'short-input',
placeholder: '[[47.3165,0.757,47.3424,0.8012,47.33,0.78]]',
condition: { field: 'operation', value: 'delete_by_filter' },
mode: 'advanced',
},
// Get records (batch) field
{
id: 'getRecordsRequests',
title: 'Record Requests',
type: 'long-input',
placeholder: '[{"objectID": "id1"}, {"objectID": "id2", "attributesToRetrieve": ["name"]}]',
condition: { field: 'operation', value: 'get_records' },
required: { field: 'operation', value: 'get_records' },
wandConfig: {
enabled: true,
prompt: `Generate a JSON array of Algolia get-records requests based on the user's description.
### CONTEXT
{context}
### GUIDELINES
- Return ONLY a valid JSON array starting with [ and ending with ]
- Each item must have "objectID" (required)
- Optionally include "indexName" to fetch from a different index
- Optionally include "attributesToRetrieve" as an array of attribute names
### EXAMPLE
User: "Get products with IDs abc and xyz, only returning name and price"
Output:
[{"objectID": "abc", "attributesToRetrieve": ["name", "price"]}, {"objectID": "xyz", "attributesToRetrieve": ["name", "price"]}]
Return ONLY the JSON array.`,
placeholder: 'Describe the records to retrieve...',
generationType: 'json-object',
},
},
// List indices pagination
{
id: 'listPage',
title: 'Page',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'list_indices' },
mode: 'advanced',
},
{
id: 'listHitsPerPage',
title: 'Indices Per Page',
type: 'short-input',
placeholder: '100',
condition: { field: 'operation', value: 'list_indices' },
mode: 'advanced',
},
// Object ID - for add (optional), get, partial update, delete
{
id: 'objectID',
title: 'Object ID',
type: 'short-input',
placeholder: 'my-record-123',
condition: {
field: 'operation',
value: ['add_record', 'get_record', 'partial_update_record', 'delete_record'],
},
required: {
field: 'operation',
value: ['get_record', 'partial_update_record', 'delete_record'],
},
},
// Common credentials
{
id: 'applicationId',
title: 'Application ID',
type: 'short-input',
placeholder: 'Your Algolia Application ID',
password: true,
required: true,
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Your Algolia API Key',
password: true,
required: true,
},
],
tools: {
access: [
'algolia_search',
'algolia_add_record',
'algolia_get_record',
'algolia_get_records',
'algolia_partial_update_record',
'algolia_delete_record',
'algolia_browse_records',
'algolia_batch_operations',
'algolia_list_indices',
'algolia_get_settings',
'algolia_update_settings',
'algolia_delete_index',
'algolia_copy_move_index',
'algolia_clear_records',
'algolia_delete_by_filter',
],
config: {
tool: (params: Record<string, unknown>) => {
const op = params.operation as string
if (op === 'partial_update_record') {
params.createIfNotExists = params.createIfNotExists !== 'false'
}
if (op === 'update_settings' && params.forwardToReplicas === 'true') {
params.forwardToReplicas = true
} else if (op === 'update_settings') {
params.forwardToReplicas = false
}
if (op === 'copy_move_index') {
params.operation = params.copyMoveOperation
}
if (op === 'delete_by_filter') {
params.filters = params.deleteFilters
}
if (op === 'get_records') {
params.requests = params.getRecordsRequests
}
if (op === 'list_indices') {
if (params.listPage !== undefined) params.page = params.listPage
if (params.listHitsPerPage !== undefined) params.hitsPerPage = params.listHitsPerPage
}
return `algolia_${op}`
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
indexName: { type: 'string', description: 'Algolia index name' },
query: { type: 'string', description: 'Search query' },
hitsPerPage: { type: 'string', description: 'Number of hits per page' },
page: { type: 'string', description: 'Page number' },
filters: { type: 'string', description: 'Algolia filter string' },
attributesToRetrieve: { type: 'string', description: 'Attributes to retrieve' },
cursor: { type: 'string', description: 'Browse cursor for pagination' },
record: { type: 'json', description: 'Record data to add' },
attributes: { type: 'json', description: 'Attributes to partially update' },
createIfNotExists: { type: 'string', description: 'Create record if not exists' },
requests: { type: 'json', description: 'Batch operation requests' },
settings: { type: 'json', description: 'Index settings to update' },
forwardToReplicas: { type: 'string', description: 'Forward settings to replicas' },
objectID: { type: 'string', description: 'Object ID' },
copyMoveOperation: { type: 'string', description: 'Copy or move operation' },
destination: { type: 'string', description: 'Destination index name' },
scope: { type: 'json', description: 'Scopes to copy (settings, synonyms, rules)' },
deleteFilters: { type: 'string', description: 'Filter expression for delete by filter' },
facetFilters: { type: 'json', description: 'Facet filters for delete by filter' },
numericFilters: { type: 'json', description: 'Numeric filters for delete by filter' },
tagFilters: {
type: 'json',
description: 'Tag filters using the _tags attribute for delete by filter',
},
aroundLatLng: { type: 'string', description: 'Geo-search coordinates (lat,lng)' },
aroundRadius: { type: 'string', description: 'Geo-search radius in meters or "all"' },
insideBoundingBox: { type: 'json', description: 'Bounding box coordinates for geo-search' },
insidePolygon: { type: 'json', description: 'Polygon coordinates for geo-search' },
getRecordsRequests: {
type: 'json',
description: 'Array of objects with objectID to retrieve multiple records',
},
listPage: { type: 'string', description: 'Page number for list indices pagination' },
listHitsPerPage: { type: 'string', description: 'Indices per page for list indices' },
applicationId: { type: 'string', description: 'Algolia Application ID' },
apiKey: { type: 'string', description: 'Algolia API Key' },
},
outputs: {
hits: { type: 'array', description: 'Search result hits or browsed records' },
nbHits: { type: 'number', description: 'Total number of hits' },
page: { type: 'number', description: 'Current page number (zero-based)' },
nbPages: { type: 'number', description: 'Total number of pages available' },
hitsPerPage: { type: 'number', description: 'Number of hits per page' },
processingTimeMS: {
type: 'number',
description: 'Server-side processing time in milliseconds',
},
query: { type: 'string', description: 'Search query that was executed' },
parsedQuery: { type: 'string', description: 'Query after normalization and stop word removal' },
facets: { type: 'json', description: 'Facet counts by facet name' },
facets_stats: {
type: 'json',
description: 'Statistics (min, max, avg, sum) for numeric facets',
},
exhaustive: { type: 'json', description: 'Exhaustiveness flags for the search results' },
taskID: { type: 'number', description: 'Algolia task ID for tracking async operations' },
objectID: { type: 'string', description: 'Object ID of the affected record' },
objectIDs: { type: 'array', description: 'Object IDs affected by batch operations' },
createdAt: { type: 'string', description: 'ISO 8601 timestamp when the record was created' },
updatedAt: {
type: 'string',
description: 'ISO 8601 timestamp when the record or settings were updated',
},
deletedAt: {
type: 'string',
description: 'ISO 8601 timestamp when the record or index was deleted',
},
record: { type: 'json', description: 'Retrieved record data (user-defined attributes)' },
results: { type: 'array', description: 'Array of retrieved records from get_records' },
cursor: {
type: 'string',
description:
'Opaque cursor string for retrieving the next page of browse results. Absent when no more results exist.',
},
indices: { type: 'array', description: 'List of indices in the application' },
searchableAttributes: { type: 'array', description: 'List of searchable attributes' },
attributesForFaceting: { type: 'array', description: 'Attributes configured for faceting' },
ranking: { type: 'array', description: 'Ranking criteria for the index' },
customRanking: { type: 'array', description: 'Custom ranking criteria' },
replicas: { type: 'array', description: 'List of replica index names' },
maxValuesPerFacet: {
type: 'number',
description: 'Maximum number of facet values returned (default 100)',
},
highlightPreTag: {
type: 'string',
description: 'HTML tag inserted before highlighted parts (default "<em>")',
},
highlightPostTag: {
type: 'string',
description: 'HTML tag inserted after highlighted parts (default "</em>")',
},
paginationLimitedTo: {
type: 'number',
description: 'Maximum number of hits accessible via pagination (default 1000)',
},
},
}

View File

@@ -110,11 +110,6 @@ export const ArxivBlock: BlockConfig<ArxivResponse> = {
access: ['arxiv_search', 'arxiv_get_paper', 'arxiv_get_author_papers'],
config: {
tool: (params) => {
// Convert maxResults to a number for operations that use it
if (params.maxResults) {
params.maxResults = Number(params.maxResults)
}
switch (params.operation) {
case 'arxiv_search':
return 'arxiv_search'
@@ -126,6 +121,11 @@ export const ArxivBlock: BlockConfig<ArxivResponse> = {
return 'arxiv_search'
}
},
params: (params) => {
const result: Record<string, unknown> = {}
if (params.maxResults) result.maxResults = Number(params.maxResults)
return result
},
},
},
inputs: {

Some files were not shown because too many files have changed in this diff Show More