softr-vibe-coding 2.13.4 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +24 -8
- package/SKILL.md +21 -4
- package/datasources/fields.md +26 -3
- package/datasources/multi-datasource.md +25 -3
- package/datasources/reading.md +167 -10
- package/datasources/writing.md +5 -1
- package/package.json +1 -1
- package/references/anti-patterns.md +5 -1
- package/references/helper-blocks.md +6 -2
- package/references/softr-mcp.md +111 -29
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this skill are documented here. Versions follow [Semantic
|
|
|
4
4
|
|
|
5
5
|
Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
|
|
6
6
|
|
|
7
|
+
## [2.14.0] - 2026-10-06
|
|
8
|
+
- Release 2.14.0
|
|
9
|
+
- Add Softr runtime and Workflows facts verified on a 2026-09-18/19 production build
|
|
10
|
+
|
|
11
|
+
## [2.13.5] - 2026-10-06
|
|
12
|
+
- Release 2.13.5
|
|
13
|
+
- Correct the MCP FILTER-condition claim and eight other conflicts found in the 2026-10-06 audit
|
|
14
|
+
|
|
7
15
|
## [2.13.4] - 2026-10-06
|
|
8
16
|
- Release 2.13.4
|
|
9
17
|
- Add in-block modal above Softr's bars (shadcn Dialog sits under the top bar)
|
package/README.md
CHANGED
|
@@ -169,7 +169,7 @@ Create a contact form that creates records in our Airtable Contacts table
|
|
|
169
169
|
softr-vibe-coding/
|
|
170
170
|
├── SKILL.md # Main skill
|
|
171
171
|
│ # Workflow, code structure, visual baseline,
|
|
172
|
-
│ # components, settings,
|
|
172
|
+
│ # components, settings, 29 hard constraints
|
|
173
173
|
│
|
|
174
174
|
├── ui-ux-guidelines.md # Design reference
|
|
175
175
|
│ # 26 sections: hierarchy, color, typography,
|
|
@@ -190,13 +190,22 @@ softr-vibe-coding/
|
|
|
190
190
|
│ │ # browsing (Airtable/Sheets/Notion/Supabase),
|
|
191
191
|
│ │ # Softr DB schema + record tools incl. deletes,
|
|
192
192
|
│ │ # app management/scaffolding, Workflows suite
|
|
193
|
-
│ │ # (
|
|
193
|
+
│ │ # (28 tools, 418-node catalog), per-application
|
|
194
194
|
│ │ # MCP servers, auth, permissions; what the server
|
|
195
195
|
│ │ # enforces on block data endpoints, "Preview as"
|
|
196
196
|
│ │ # role testing, search-replace on 100KB+ blocks
|
|
197
197
|
│ │ # (Sep 18 2026); Oct 1 2026: tool rename map,
|
|
198
198
|
│ │ # push verification by sourceSha256, stub tools
|
|
199
|
-
│ │ # after a resume, update_field/update_table fixes
|
|
199
|
+
│ │ # after a resume, update_field/update_table fixes;
|
|
200
|
+
│ │ # Oct 6 2026: MCP-written FILTER conditions are
|
|
201
|
+
│ │ # inert (set them in Studio), Workflows tools as
|
|
202
|
+
│ │ # workflow_*, denied by-id fetch per backend;
|
|
203
|
+
│ │ # Workflows engine facts (CUSTOM_CODE contract,
|
|
204
|
+
│ │ # string-array loops, sample-based validation,
|
|
205
|
+
│ │ # replace_node new ids, re-firing triggers,
|
|
206
|
+
│ │ # write modes, serialExecution, continueOnError),
|
|
207
|
+
│ │ # Softr DB row-gating recipe, what a push leaves
|
|
208
|
+
│ │ # alone, DATETIME create shape, offset paging
|
|
200
209
|
│ ├── browser-checks.md # Checking a pushed block in a browser with
|
|
201
210
|
│ │ # the agent-browser CLI (ask before installing):
|
|
202
211
|
│ │ # preview cookie, shadow-DOM refs grepped in the
|
|
@@ -267,15 +276,22 @@ softr-vibe-coding/
|
|
|
267
276
|
│ # select: as a module-scope identifier, the union-of-
|
|
268
277
|
│ # selects read payload (a conditional select is not
|
|
269
278
|
│ # privacy), Actions per table (Sep 18 2026);
|
|
270
|
-
│ # block Visibility gates its endpoints (Oct 5 2026)
|
|
279
|
+
│ # block Visibility gates its endpoints (Oct 5 2026);
|
|
280
|
+
│ # every row carries its record id (Oct 6 2026)
|
|
271
281
|
├── reading.md # useRecords, filtering, sorting, pagination,
|
|
272
282
|
│ # metrics, charts, current user; no detail-page
|
|
273
283
|
│ # auto-scoping, useRecords ignores enabled:false,
|
|
274
|
-
│ # server-side linked-record filters (Sep 18 2026)
|
|
284
|
+
│ # server-side linked-record filters (Sep 18 2026);
|
|
285
|
+
│ # where/orderBy aliases resolve per hook, operator
|
|
286
|
+
│ # semantics, filters fail open, userGroups poll,
|
|
287
|
+
│ # excluded useRecord = no record (Oct 6 2026)
|
|
275
288
|
├── writing.md # Mutations, sequential write queues, uploads,
|
|
276
|
-
│ # linked record format, cross-table writes
|
|
289
|
+
│ # linked record format, cross-table writes;
|
|
290
|
+
│ # Actions register per table (Sep 18 2026)
|
|
277
291
|
├── fields.md # getFieldValue(), field type shapes, record
|
|
278
|
-
│ # structure, debug utilities
|
|
292
|
+
│ # structure, debug utilities; date-only fields
|
|
293
|
+
│ # parsed as local dates, multi-value lookup shape
|
|
294
|
+
│ # (Oct 6 2026)
|
|
279
295
|
├── rest-api.md # useProxyFetch + useQuery (full docs)
|
|
280
296
|
├── softr-database.md # Native DB — field IDs, no rate limits
|
|
281
297
|
├── airtable.md # Column names, PAT vs OAuth, rate limits
|
|
@@ -334,7 +350,7 @@ The skill enforces these automatically, but good to know (verified live against
|
|
|
334
350
|
- No `import React from 'react'` — use named imports (`import { useState } from "react"`)
|
|
335
351
|
- Must use `export default function Block()`
|
|
336
352
|
- Wrap layout in `<div className="container py-0"><div className="content">` for app/content blocks (house convention for width alignment with native blocks) — the platform default is actually full width, so full-bleed marketing blocks (heroes, banners, footers) legitimately omit the wrappers and own their gutters
|
|
337
|
-
-
|
|
353
|
+
- One `useRecords` per **connection** (house rule) — a block can connect to several sources, including the same table twice; declare them with `datasource.define()` and pass `from:` on every hook
|
|
338
354
|
- `fetchNextPage` never in the render body (infinite loop) — call it from an event handler (Load More `onClick`) or a guarded `useEffect`
|
|
339
355
|
- All hooks declared before any conditional `return` — React error #310
|
|
340
356
|
- Every field value rendered in JSX must pass through `getFieldValue()`
|
package/SKILL.md
CHANGED
|
@@ -72,6 +72,10 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
72
72
|
- Multi-datasource block: every `select:` / `fields:` value is a **plain module-scope identifier** — no ternary, no inline `q.select({...})` inside the hook options (the query returns `fields: {}`; Hard Constraint 24)
|
|
73
73
|
- Detail page: the record is fetched with `useRecord({ select, recordId, enabled: !!recordId })` using `useCurrentRecordId()`, and the code checks `data.id === recordId` before rendering — never `useRecords({ count: 1 })`, which returns the table's FIRST row (Hard Constraint 25)
|
|
74
74
|
- No list query relies on `enabled: false` — `useRecords` fetches anyway; conditional list queries live in a child component mounted only when needed, or carry a match-nothing `where` (Hard Constraint 26)
|
|
75
|
+
- Every alias a hook's `where` / `orderBy` names is in **that hook's own** `select` — anything else crashes the block at runtime (Hard Constraint 29)
|
|
76
|
+
- Every `where` has been **seen to narrow** the result (compare row counts with and without it) — a filter on a field outside the connection's read-select union is silently ignored and returns everything ([datasources/reading.md](datasources/reading.md#filters-fail-open))
|
|
77
|
+
- Role checks read `window.__softr_current_user.userGroups` from state with a bounded poll, and role-dependent UI waits until it settles — the global has no change event and an early `[]` means not loaded yet ([datasources/reading.md](datasources/reading.md#current-user))
|
|
78
|
+
- Date-only values are parsed with `toLocalDate()`, never `new Date()` — midnight UTC renders a day early west of Greenwich ([datasources/fields.md](datasources/fields.md#date-only-fields-arrive-as-midnight-utc))
|
|
75
79
|
- No field is "hidden" from some viewers by a conditional / second `select` on the same connection — the browser receives the union of every read select on that connection (Hard Constraint 23)
|
|
76
80
|
- All imports use named imports (no `import React from 'react'`)
|
|
77
81
|
- `export default function Block()` is present
|
|
@@ -614,9 +618,15 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
614
618
|
changing the wrapper to take the hook's *result* instead.)
|
|
615
619
|
11. **Airtable, Notion, Google Sheets: use field NAMES, not IDs** — `q.select()` values are field names for these three sources; Softr Database and Supabase use field IDs (Supabase = SQL column name). Getting this wrong fails silently: the block compiles and saves, then renders empty. See [datasources/airtable.md](datasources/airtable.md).
|
|
616
620
|
12. **Record fields nested under `fields`** — Access via `record.fields.alias`, not `record.alias`.
|
|
617
|
-
13. **
|
|
618
|
-
|
|
619
|
-
|
|
621
|
+
13. **One `useRecords` per connection [house]** — not a documented platform limit (Softr's developer
|
|
622
|
+
guide shows one `useRecords` per datasource but states no rule; checked 2026-10-06). Read each
|
|
623
|
+
connection once and filter client-side when the table is small and every viewer may see all of
|
|
624
|
+
it anyway. When the data must differ, add connections rather than queries: a second table gets
|
|
625
|
+
its own connection, and so does a private read of the same table (Hard Constraint 23). A
|
|
626
|
+
server-side `where` is better for large tables and linked children, but it is a request
|
|
627
|
+
parameter, never access control. A query mounted in a child component (Hard Constraint 26) is
|
|
628
|
+
that connection's one read; don't also read the connection in the parent. Declare connections
|
|
629
|
+
with `datasource.define()` and pass `from:` on every hook. See
|
|
620
630
|
[datasources/multi-datasource.md](datasources/multi-datasource.md). Multiple `useMetric` calls OK.
|
|
621
631
|
14. **React functional components only** — No class components.
|
|
622
632
|
15. **Do NOT `import React from 'react'`** — Use named imports for hooks.
|
|
@@ -650,7 +660,9 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
650
660
|
A push that returns `errors: null` can still have left public write access on the block.
|
|
651
661
|
**If any action is still broader than intended, report it WITH its severity and let the builder decide.**
|
|
652
662
|
Check the page's own VIEW permission first (`application_page_get_permissions`): a page gated to logged-in
|
|
653
|
-
users makes an open action housekeeping, a public page makes it a real hole.
|
|
663
|
+
users makes an open action housekeeping, a public page makes it a real hole. Report the block's own
|
|
664
|
+
Visibility with it -- it gates the block's reads and sets ADD_RECORD's default, but whether it
|
|
665
|
+
refuses writes on its own is untested. Surface the list
|
|
654
666
|
either way -- page, block, action type, data source -- and note that a human sets them on the
|
|
655
667
|
block's Actions tab. Do not unilaterally block a publish; it is not your app.
|
|
656
668
|
22. **Blocks cannot import each other -- cross-block consistency is discipline, not architecture [house]**
|
|
@@ -700,6 +712,11 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
700
712
|
them takes global CSS across Softr's page structure as well as print CSS in the block. Never
|
|
701
713
|
an in-page "print view" either (Leo rejected it by name). Verified live 2026-09-30. See
|
|
702
714
|
[references/printing.md](references/printing.md).
|
|
715
|
+
29. **`where` / `orderBy` may only name aliases from the same hook's `select`** -- aliases resolve per
|
|
716
|
+
hook, not per connection. Naming any other alias crashes the whole block at runtime ("Could not
|
|
717
|
+
find an alias for subject \"undefined\"") although the push compiles clean. Seen live
|
|
718
|
+
2026-09-18 on a `useMetric`. See
|
|
719
|
+
[datasources/reading.md](datasources/reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select).
|
|
703
720
|
|
|
704
721
|
## Style Conventions
|
|
705
722
|
|
package/datasources/fields.md
CHANGED
|
@@ -32,13 +32,35 @@ Property priority: `label` first (most common in Softr formatted fields), then `
|
|
|
32
32
|
|
|
33
33
|
Apply `getFieldValue()` everywhere you read fields:
|
|
34
34
|
- Table cells, badges, tooltips: `<td>{getFieldValue(f.subject)}</td>`
|
|
35
|
-
- Date parsing: `
|
|
35
|
+
- Date parsing: `toLocalDate(getFieldValue(f.dueDate))` for date-only fields ([below](#date-only-fields-arrive-as-midnight-utc)), `new Date(...)` for real timestamps -- raw value might be `{label: "2025-12-01"}`
|
|
36
36
|
- Number parsing: `parseInt(getFieldValue(f.count), 10)` -- formula numbers come back as objects
|
|
37
37
|
- String methods: `getFieldValue(f.status).toLowerCase()`
|
|
38
38
|
- Inside `useMemo` normalizers, BEFORE storing into state -- prevents the object from propagating
|
|
39
39
|
|
|
40
40
|
For companion helpers (`getLinkedNames`, `getLinkedItems`) used in helper block consumers, see [../references/helper-blocks.md](../references/helper-blocks.md).
|
|
41
41
|
|
|
42
|
+
### Date-only fields arrive as midnight UTC
|
|
43
|
+
|
|
44
|
+
*Verified live 2026-09-18 (Softr Database).* A date field without a time arrives as midnight UTC.
|
|
45
|
+
`new Date()` parses it as UTC, so anywhere west of Greenwich (New York, for one) date-fns renders
|
|
46
|
+
the **previous day**. Parse date-only values as local dates, and keep the default parse for real
|
|
47
|
+
timestamps:
|
|
48
|
+
|
|
49
|
+
```jsx
|
|
50
|
+
var DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]00:00(?::00(?:\.0+)?)?(?:Z|\+00:00)?)?$/;
|
|
51
|
+
|
|
52
|
+
function toLocalDate(raw) {
|
|
53
|
+
if (typeof raw === "string") {
|
|
54
|
+
var m = raw.match(DATE_ONLY_RE);
|
|
55
|
+
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
56
|
+
}
|
|
57
|
+
return new Date(raw);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A real timestamp at exactly midnight UTC matches the pattern too, so call `toLocalDate` only on
|
|
62
|
+
fields you know are date-only.
|
|
63
|
+
|
|
42
64
|
## Debugging Error #31
|
|
43
65
|
|
|
44
66
|
If React crashes with error #31 ("Objects are not valid as a React child"), open console on the first record that crashes and run:
|
|
@@ -65,6 +87,7 @@ You'll see exactly which field is an object. Add `getFieldValue()` around it.
|
|
|
65
87
|
| User, Created By, Updated By | `{ avatarUrl, id, name, email }` |
|
|
66
88
|
| Attachment | `{ filename, id, type, url }` |
|
|
67
89
|
| Formula | `string or number` |
|
|
90
|
+
| Lookup (multi-value) | array of **strings**, one element per linked record: `["#1042#"]`, and `[]` when empty (verified live 2026-09-19, Softr Database). `getFieldValue()` joins it for display; a `contains` filter on it tests each element ([reading.md](reading.md#operator-semantics-on-the-server)) |
|
|
68
91
|
|
|
69
92
|
## Record Structure
|
|
70
93
|
|
|
@@ -78,7 +101,7 @@ var name = f.firstName || "";
|
|
|
78
101
|
|
|
79
102
|
## Debug Utilities
|
|
80
103
|
|
|
81
|
-
Two throwaway diagnostic blocks you can drop into a page to diagnose data problems. Neither is meant for production -- delete or hide them once the issue is resolved. During development, drop them on a `/debug` page
|
|
104
|
+
Two throwaway diagnostic blocks you can drop into a page to diagnose data problems. Neither is meant for production -- delete or hide them once the issue is resolved. During development, drop them on a `/debug` page whose VIEW permission is limited to admins. A page that is merely left out of the navigation is still reachable by URL, and its blocks' endpoints answer anyone the page lets in (see [softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints)).
|
|
82
105
|
|
|
83
106
|
### Field Inspector Block
|
|
84
107
|
|
|
@@ -123,7 +146,7 @@ export default function Block() {
|
|
|
123
146
|
|
|
124
147
|
4. **Inline in Studio (one field at a time)** -- in the Data tab, click a field's name to open its edit drawer. The field ID appears next to the "Field name" label (e.g. `ID: 37fts`). Fastest for spot-checking a single field.
|
|
125
148
|
|
|
126
|
-
5. **Softr Database REST API with `fieldNames=true`** -- runtime inspection from inside a Vibe Coding block (
|
|
149
|
+
5. **Softr Database REST API with `fieldNames=true`** -- runtime inspection from inside a Vibe Coding block (only on a page limited to admins, and removed afterwards: the PAT sits in the block's source, and anyone who can load the page can read it and use it against the whole database, past every page, block and Source-condition gate):
|
|
127
150
|
|
|
128
151
|
```jsx
|
|
129
152
|
import { useEffect, useState } from "react";
|
|
@@ -94,7 +94,7 @@ it behaves as a **union** of both branches, not a choice between them. Which is
|
|
|
94
94
|
*Verified live 2026-09-18; the block-visibility gate 2026-10-05.*
|
|
95
95
|
|
|
96
96
|
The records endpoint is per block + connection —
|
|
97
|
-
`/blocks/<blockId>/datasources/<
|
|
97
|
+
`/blocks/<blockId>/datasources/<connection>/records` (`<connection>` was recorded as the connection's id in the 2026-09-18 Softr Database capture and seen as its alias in a 2026-10-05 HubSpot capture; unresolved, and it only matters when reading a network log) — and it returns the **UNION of every field
|
|
98
98
|
named by any READ `q.select` attributed to that connection**. Two selects on one connection do
|
|
99
99
|
NOT produce two payloads: every read hook on that connection gets all the fields, for every
|
|
100
100
|
viewer.
|
|
@@ -106,6 +106,18 @@ the block — it is simply not rendered. Anyone can read it in the network tab.
|
|
|
106
106
|
What does *not* join the union: a mutation hook's `fields:` select. Write-only fields stay out of
|
|
107
107
|
the read payload.
|
|
108
108
|
|
|
109
|
+
**Every row carries its record id, whatever the select.** The union governs *fields*; the records
|
|
110
|
+
endpoint returns each row's own record id beside them however narrow the select is. A connection
|
|
111
|
+
whose only select was one email field still gave anyone who crafted the request every row's id
|
|
112
|
+
with its email (noted in a production block's security review, 2026-09-19). So wherever knowing a
|
|
113
|
+
record id lets someone act (a by-id fetch, an update addressed by `recordId`, a link write that a
|
|
114
|
+
Source condition then keys on), treat ids as access keys: a connection hands every row it releases,
|
|
115
|
+
with its id, to every viewer allowed to call it. Narrowing the select does not withhold ids; only
|
|
116
|
+
fewer rows (a Source condition) or a gated page or block does. A link field in a select ships the
|
|
117
|
+
*linked* records' ids too (`{ id, label }`). To filter by a link without shipping them, filter on
|
|
118
|
+
a readonly key looked up through the link instead
|
|
119
|
+
([reading.md](reading.md#operator-semantics-on-the-server)).
|
|
120
|
+
|
|
109
121
|
**Remedy.** Connect the **same table a second time** — Softr allows it, and the second connection
|
|
110
122
|
gets its own `dataSourceId` — and read the private field only through that connection, from a
|
|
111
123
|
hook that non-privileged browsers never run:
|
|
@@ -146,8 +158,10 @@ user and logged-out visitors 403 on list and by-id. The gate belongs to the bloc
|
|
|
146
158
|
connect the same table to an ungated block and it is open again. See
|
|
147
159
|
[../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
|
|
148
160
|
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
Two selects on different connections may reuse an alias name (`customer` on both) without
|
|
162
|
+
colliding. Aliases resolve **per hook**, though: a hook's `where` / `orderBy` may name only aliases
|
|
163
|
+
from that hook's own `select`, or the block crashes at runtime — see
|
|
164
|
+
[reading.md](reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select).
|
|
151
165
|
|
|
152
166
|
## Mutation Actions register per TABLE, not per connection
|
|
153
167
|
|
|
@@ -206,3 +220,11 @@ right tool for:
|
|
|
206
220
|
`crew-feedback-form.jsx` — a public feedback form that reads a person from **People** by an
|
|
207
221
|
`email` URL param, resolves a **Shifts** record from a job-code param, and writes a row to
|
|
208
222
|
**Feedback** linking both. One block, three sources, no helpers, no `window` globals.
|
|
223
|
+
|
|
224
|
+
**Read it against the rules above before copying it.** On a public page every logged-out visitor
|
|
225
|
+
may call the People connection, and the endpoint returns every row the Source conditions allow,
|
|
226
|
+
with every field the block's read selects name. The `email` URL param narrows nothing on the
|
|
227
|
+
server: it ends up in a `where`, which is a request parameter the caller controls, and there is no
|
|
228
|
+
logged-in user for a Source condition to match. Select only what the form must show, assume every
|
|
229
|
+
row of it is public, and if that is not acceptable resolve the person server-side (a Softr
|
|
230
|
+
Workflow) instead of reading People from the block.
|
package/datasources/reading.md
CHANGED
|
@@ -9,9 +9,9 @@ Fetching, filtering, sorting, pagination, metrics, charts, and current user.
|
|
|
9
9
|
- [useRecord -- Fetch a Single Record](#userecord----fetch-a-single-record) — the detail-page pattern; no auto-scoping
|
|
10
10
|
- [useLinkedRecords -- Fetch Linked/Related Options](#uselinkedrecords----fetch-linkedrelated-options)
|
|
11
11
|
- [useFieldOptions -- Fetch Single/Multi-Select Choices](#usefieldoptions----fetch-singlemulti-select-choices)
|
|
12
|
-
- [Filtering](#filtering) — incl. [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
|
|
12
|
+
- [Filtering](#filtering) — incl. [operator semantics on the server](#operator-semantics-on-the-server), [filters fail open](#filters-fail-open), [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
|
|
13
13
|
- [Sorting](#sorting)
|
|
14
|
-
- [Current User](#current-user)
|
|
14
|
+
- [Current User](#current-user) — user groups need a short, bounded poll
|
|
15
15
|
- [Metrics](#metrics)
|
|
16
16
|
- [Chart Data](#chart-data)
|
|
17
17
|
|
|
@@ -65,7 +65,7 @@ var isRefetching = result.isRefetching;
|
|
|
65
65
|
var items = (data && data.pages) ? data.pages.flatMap(function(p) { return p.items; }) : [];
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
**
|
|
68
|
+
**House rule: one `useRecords` per connection.** It is not a documented platform limit — Hard Constraint 13 in SKILL.md says when to filter client-side, when to add a connection, and when a server-side `where` is the better choice. Multiple `useMetric` calls ARE allowed.
|
|
69
69
|
|
|
70
70
|
**CRITICAL:** The options object must be an **inline literal** at the call site. Passing it
|
|
71
71
|
through a variable or a wrapper function (`useRecords(buildOpts())`) **fails to compile** —
|
|
@@ -80,7 +80,9 @@ A block can connect to **several data sources** and call `useRecords` once per s
|
|
|
80
80
|
*Verified live 2026-09-18 (network capture).* `useRecords({ ..., enabled: false })` **fetches
|
|
81
81
|
anyway** — with a literal `false` and with a variable alike. `useRecord` is different: it honours
|
|
82
82
|
`enabled: false` and issues no request. (`useLinkedRecords`, `useMetric` and `useChartData` were
|
|
83
|
-
not probed — don't assume either behaviour for them.)
|
|
83
|
+
not probed — don't assume either behaviour for them.) The official developer guide still
|
|
84
|
+
describes `enabled` on `useRecords` as an "optional boolean to defer loading" (checked
|
|
85
|
+
2026-10-06); the capture says otherwise, so trust it until a newer one does not.
|
|
84
86
|
|
|
85
87
|
So `enabled` cannot make a list query conditional, and it cannot keep a query away from viewers
|
|
86
88
|
who should not run it. Two things that do work:
|
|
@@ -110,10 +112,12 @@ import { useState, useEffect } from "react";
|
|
|
110
112
|
var result = useRecords({ select: select, count: 100 });
|
|
111
113
|
|
|
112
114
|
useEffect(function() {
|
|
113
|
-
|
|
115
|
+
// `!result.error` is defensive: never re-request a page while the hook reports an error
|
|
116
|
+
// (how a failed later page surfaces has not been verified).
|
|
117
|
+
if (result.hasNextPage && !result.isFetchingNextPage && result.status === "success" && !result.error) {
|
|
114
118
|
result.fetchNextPage();
|
|
115
119
|
}
|
|
116
|
-
}, [result.hasNextPage, result.isFetchingNextPage, result.status, result.fetchNextPage]);
|
|
120
|
+
}, [result.hasNextPage, result.isFetchingNextPage, result.status, result.error, result.fetchNextPage]);
|
|
117
121
|
```
|
|
118
122
|
|
|
119
123
|
## useRecord -- Fetch a Single Record
|
|
@@ -146,6 +150,14 @@ the server which record the page is "about". Consequences:
|
|
|
146
150
|
returns. So always pass `enabled: !!recordId` (`useRecord` honours `enabled: false` — no
|
|
147
151
|
request is made) and verify `data.id === recordId` before rendering or, worse, writing.
|
|
148
152
|
|
|
153
|
+
**A record the connection's Source conditions exclude comes back as no record, not as an error**
|
|
154
|
+
(verified live 2026-09-18, Softr Database). The by-id request answers HTTP 200 with an empty
|
|
155
|
+
body, not 403 or 404, so `useRecord` reports no error and holds no record. Render that as "not
|
|
156
|
+
found"; never wait for a 403/404 to learn the viewer was refused. A production block also sends
|
|
157
|
+
any denial-shaped error (401/403/404, or a JSON parse error on an empty body) to the same "not
|
|
158
|
+
found" state, in case a later build answers differently, and keeps the error panel with a retry
|
|
159
|
+
for real failures.
|
|
160
|
+
|
|
149
161
|
**A recordId-less `useRecord` — what the older note here meant, and its limits.** This file used
|
|
150
162
|
to say that `useRecord({ select })` with no `recordId` "loads the record the block is bound to
|
|
151
163
|
via its data-source binding in Studio" (seen on one deployed Airtable-backed stats block, July
|
|
@@ -171,7 +183,7 @@ var result = useLinkedRecords({
|
|
|
171
183
|
field: "category", // the ALIAS from q.select(), NOT the raw field ID
|
|
172
184
|
sortOrder: "ASC", // "ASC" | "DESC"
|
|
173
185
|
search: "", // optional search string
|
|
174
|
-
enabled: true, //
|
|
186
|
+
enabled: true, // documented as deferral; not verified live (see the useRecords note)
|
|
175
187
|
count: 50, // optional page size — default 100, max 1000
|
|
176
188
|
});
|
|
177
189
|
|
|
@@ -257,6 +269,98 @@ where: q.and(
|
|
|
257
269
|
)
|
|
258
270
|
```
|
|
259
271
|
|
|
272
|
+
### Operator semantics on the server
|
|
273
|
+
|
|
274
|
+
*Softr Database: `is` verified live 2026-09-18, `contains` 2026-09-19.* What the operators do once
|
|
275
|
+
the filter reaches the server:
|
|
276
|
+
|
|
277
|
+
- **Text `is` is case-insensitive.** `q.text("email").is("Ann@Example.com")` matches
|
|
278
|
+
`ann@example.com`. Compare client-side when case matters.
|
|
279
|
+
- **`contains` is a case-insensitive substring test, and on a multi-value lookup it tests each
|
|
280
|
+
element** — never the elements joined into one string. Multi-value lookups arrive in the browser
|
|
281
|
+
as arrays of strings ([fields.md](fields.md#common-field-type-shapes)). To match one whole value inside a
|
|
282
|
+
lookup, wrap every value in delimiters it cannot contain (a formula such as
|
|
283
|
+
`CONCATENATE("#", {Order No}, "#")`, looked up through the link), search for the delimited
|
|
284
|
+
value, and re-check the exact value client-side: an undelimited `contains("1042")` also
|
|
285
|
+
matches `10420`.
|
|
286
|
+
- **`contains("")` returned 0 rows, not every row** (measured 2026-09-19 against a lookup field; a
|
|
287
|
+
plain text field was not probed). Don't build on it either way. When a hook must match nothing
|
|
288
|
+
until a value exists, give it an explicit sentinel, as in option 2 under
|
|
289
|
+
[`useRecords` ignores `enabled: false`](#userecords-ignores-enabled-false); for `contains` the
|
|
290
|
+
sentinel must not be a substring of any real value either.
|
|
291
|
+
|
|
292
|
+
```jsx
|
|
293
|
+
var KEY_NONE = "#no-key#"; // no real "#<number>#" key can contain this
|
|
294
|
+
var orderKey = orderNo ? "#" + orderNo + "#" : "";
|
|
295
|
+
|
|
296
|
+
// orderKeys = a lookup, through the link, of the order's "#<number>#" key formula
|
|
297
|
+
var lines = useRecords({ from: ds.lines, select: lineSelect, count: 100,
|
|
298
|
+
where: q.text("orderKeys").contains(orderKey || KEY_NONE) });
|
|
299
|
+
|
|
300
|
+
// The server test is a substring test: keep only rows whose lookup holds the exact key.
|
|
301
|
+
// (items = the flattened pages of `lines`)
|
|
302
|
+
var mine = !orderKey ? [] : items.filter(function(r) {
|
|
303
|
+
var v = r.fields.orderKeys;
|
|
304
|
+
var keys = Array.isArray(v) ? v : (v ? [String(v)] : []);
|
|
305
|
+
return keys.indexOf(orderKey) !== -1;
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
A key like this also lets a block filter by a link without selecting the link field, which would
|
|
310
|
+
ship the linked records' ids to the browser
|
|
311
|
+
([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)).
|
|
312
|
+
|
|
313
|
+
### Filter and sort aliases must be in the same hook's select
|
|
314
|
+
|
|
315
|
+
*Seen live 2026-09-18 (Softr Database, on a `useMetric`).* Aliases are resolved **per hook**, not
|
|
316
|
+
per connection. A `where` or `orderBy` that names an alias missing from that hook's own `select`
|
|
317
|
+
crashes the whole block at runtime ("Could not find an alias for subject \"undefined\"" and
|
|
318
|
+
Softr's "Oh snap" panel), although the push compiled clean:
|
|
319
|
+
|
|
320
|
+
```jsx
|
|
321
|
+
// WRONG — "status" is not in this hook's select: compiles, then crashes the block
|
|
322
|
+
var countSelect = q.select({ orderNo: "FIELD_ID1" });
|
|
323
|
+
var open = useMetric({ select: countSelect, metric: metric.count(), where: q.text("status").is("Open") });
|
|
324
|
+
|
|
325
|
+
// CORRECT — every alias the where / orderBy names is in the hook's own select
|
|
326
|
+
var countSelect = q.select({ orderNo: "FIELD_ID1", status: "FIELD_ID2" });
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Adding the field to the select also adds it to the connection's read payload
|
|
330
|
+
([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)),
|
|
331
|
+
so put a filter on a private field on the connection that is allowed to carry it.
|
|
332
|
+
|
|
333
|
+
### Filters fail open
|
|
334
|
+
|
|
335
|
+
*Verified live 2026-09-19 (Softr Database).* A filter on a field that is **not in the
|
|
336
|
+
connection's read-select union**
|
|
337
|
+
([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects))
|
|
338
|
+
is **silently ignored**: no error, and the query returns everything, as if there were no `where`.
|
|
339
|
+
(This was recorded for the filter a block sends with its request, the hook's `where`. Source
|
|
340
|
+
conditions were not part of the finding.) How a block's own `where` can name a field outside the
|
|
341
|
+
union while passing the per-hook alias rule above was not established: the session that found it
|
|
342
|
+
also sent hand-built requests to the endpoint, which can name any field. Either way there is no
|
|
343
|
+
way to filter on a field without shipping it: leave it out of every read select and the filter
|
|
344
|
+
stops applying.
|
|
345
|
+
|
|
346
|
+
The two rules fail in opposite directions. An alias missing from the hook's own `select` crashes
|
|
347
|
+
the block ([above](#filter-and-sort-aliases-must-be-in-the-same-hooks-select)); a field missing from
|
|
348
|
+
the connection's union drops the filter without a word. A block that compiles and renders
|
|
349
|
+
plausible rows has passed the first check and proved nothing about the second.
|
|
350
|
+
|
|
351
|
+
Treat every `where` as unproven until you have seen it narrow:
|
|
352
|
+
|
|
353
|
+
1. Confirm each field the `where` names is in a read select on that connection.
|
|
354
|
+
2. Load the block as a viewer who can see more rows than the filter should leave (an admin is
|
|
355
|
+
usually easiest) and compare the count with and without the `where`, or read the response in
|
|
356
|
+
the network tab: 7 rows without it and 3 with it, not 7 and 7.
|
|
357
|
+
3. Where an ignored filter would make the block show the wrong rows (another order's lines on
|
|
358
|
+
this order's page), apply the same condition client-side to every row as well, so the block
|
|
359
|
+
stays correct even if the server returns everything.
|
|
360
|
+
|
|
361
|
+
A `where` is not access control in any case ([Current User](#current-user)); this is about the
|
|
362
|
+
block showing the rows it says it shows.
|
|
363
|
+
|
|
260
364
|
### Filtering by a linked record (server-side)
|
|
261
365
|
|
|
262
366
|
*Verified live 2026-09-18 (Softr Database, network capture).* A linked-record field filters on
|
|
@@ -275,9 +379,14 @@ var comments = useRecords({
|
|
|
275
379
|
```
|
|
276
380
|
|
|
277
381
|
On the wire the alias is resolved to the field id:
|
|
278
|
-
`{ subject: <fieldId>, type: "ARRAY", operator: "HAS_ALL_OF", value: [orderId] }`.
|
|
279
|
-
|
|
280
|
-
name for different fields and each filter
|
|
382
|
+
`{ subject: <fieldId>, type: "ARRAY", operator: "HAS_ALL_OF", value: [orderId] }`. Aliases resolve
|
|
383
|
+
**per hook** ([above](#filter-and-sort-aliases-must-be-in-the-same-hooks-select)), so two selects on
|
|
384
|
+
different connections may use the same alias name for different fields, and each filter resolves
|
|
385
|
+
against its own hook's `select`.
|
|
386
|
+
|
|
387
|
+
**`isOneOf` filters a link the same way**, for "linked to any of these" (verified live 2026-09-18):
|
|
388
|
+
`q.array("order").isOneOf(orderIds)` goes out as `operator: "IS_ONE_OF"` with the id array as
|
|
389
|
+
`value`, next to `hasAllOf([id])`.
|
|
281
390
|
|
|
282
391
|
`orderId` must be a real id when the hook runs — `useRecords` cannot be switched off with
|
|
283
392
|
`enabled: false` ([above](#userecords-ignores-enabled-false)), so mount this query in a child
|
|
@@ -336,6 +445,54 @@ var userGroups = softrUser.userGroups || [];
|
|
|
336
445
|
var isPremium = userGroups.some(function(g) { return g.name === "Premium Member"; });
|
|
337
446
|
```
|
|
338
447
|
|
|
448
|
+
**`window.__softr_current_user` has no change event.** It is a plain global the Softr shell fills
|
|
449
|
+
in, and nothing re-renders the block when it lands. An empty `userGroups` early on means the
|
|
450
|
+
shell is not ready yet, not that the user has no groups; read once at mount, an admin can be
|
|
451
|
+
settled as a non-admin for good. The shell is usually ready before the block's data arrives. Two
|
|
452
|
+
production blocks (2026-09-18) cover the case where it isn't: they hold the user in state, poll
|
|
453
|
+
with a bound, and treat an empty list as not loaded yet, since every logged-in user carries at
|
|
454
|
+
least Softr's predefined groups.
|
|
455
|
+
|
|
456
|
+
```jsx
|
|
457
|
+
import { useState, useEffect } from "react";
|
|
458
|
+
|
|
459
|
+
// Module scope. An empty userGroups means the shell is still filling in: return null until then.
|
|
460
|
+
function readShellUser() {
|
|
461
|
+
var u = window.__softr_current_user || null;
|
|
462
|
+
if (!u || !Array.isArray(u.userGroups) || u.userGroups.length === 0) return null;
|
|
463
|
+
return u;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// In Block():
|
|
467
|
+
var [shellUser, setShellUser] = useState(readShellUser());
|
|
468
|
+
var [groupsSettled, setGroupsSettled] = useState(!!readShellUser());
|
|
469
|
+
|
|
470
|
+
useEffect(function() {
|
|
471
|
+
if (groupsSettled) return;
|
|
472
|
+
var tries = 0;
|
|
473
|
+
var timer = setInterval(function() {
|
|
474
|
+
tries += 1;
|
|
475
|
+
var found = readShellUser();
|
|
476
|
+
if (found) {
|
|
477
|
+
clearInterval(timer);
|
|
478
|
+
setShellUser(found);
|
|
479
|
+
setGroupsSettled(true);
|
|
480
|
+
} else if (tries >= 14) { // about 2 s at 150 ms, then settle with no groups
|
|
481
|
+
clearInterval(timer);
|
|
482
|
+
setGroupsSettled(true);
|
|
483
|
+
}
|
|
484
|
+
}, 150);
|
|
485
|
+
return function() { clearInterval(timer); };
|
|
486
|
+
}, [groupsSettled]);
|
|
487
|
+
|
|
488
|
+
var userGroups = (shellUser && shellUser.userGroups) || [];
|
|
489
|
+
var isAdmin = userGroups.some(function(g) { return g.name === "Admin"; });
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Gate anything role-dependent on `groupsSettled` (show a skeleton until then), so a viewer never
|
|
493
|
+
flashes the wrong panel. The bound settles a viewer whose list never fills in, instead of leaving
|
|
494
|
+
them on a skeleton.
|
|
495
|
+
|
|
339
496
|
## Metrics
|
|
340
497
|
|
|
341
498
|
```jsx
|
package/datasources/writing.md
CHANGED
|
@@ -85,6 +85,10 @@ Because the parser only inspects your hooks and `q.select` mappings (not the JSX
|
|
|
85
85
|
|
|
86
86
|
All mutation hooks expose an `enabled` boolean. You must check it before rendering any mutation UI or calling the mutate function.
|
|
87
87
|
|
|
88
|
+
The snippets below write `fields: q.select({...})` inline for brevity, as single-datasource
|
|
89
|
+
shorthand. In a block with more than one connection, hoist every `fields:` select to module scope
|
|
90
|
+
and pass the identifier ([multi-datasource.md](multi-datasource.md#select-must-be-a-plain-module-scope-identifier)).
|
|
91
|
+
|
|
88
92
|
### useRecordCreate
|
|
89
93
|
|
|
90
94
|
```jsx
|
|
@@ -677,7 +681,7 @@ Three remaining alternatives, for the cases multi-datasource doesn't cover:
|
|
|
677
681
|
**Update:** PATCH `/{recordId}` with `{ fields: { fieldId1: "newValue" } }`
|
|
678
682
|
|
|
679
683
|
Notes:
|
|
680
|
-
- API key
|
|
684
|
+
- The API key ships in the block's source. Anyone who can load the page can read it and use it against the whole database, past page VIEW, block Visibility and Source conditions alike. Use this only on a page limited to a trusted group, and only for what the hooks or a Softr Workflow cannot do
|
|
681
685
|
- When updating linked records or multi-selects, read existing values first, merge, then write
|
|
682
686
|
- Use `fieldNames=true` on GET for human-readable field names
|
|
683
687
|
- Rate limits: Reads 40 req/s, Writes 30 req/s
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"softr-vibe-coding": "bin/cli.js"
|
|
@@ -19,9 +19,11 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
19
19
|
| Omitting `from:` on a hook when the block has more than one datasource | Throws at runtime. `from:` is optional ONLY when exactly one source is connected — then hooks default to it. Applies to `useRecords`, `useRecord`, `useLinkedRecords`, `useFieldOptions`, `useMetric`, `useChartData`, `useRecordCreate`, `useRecordUpdate`, `useRecordDelete`. NOT to `useUpload` / `useCurrentRecordId`, which are app-level. `useProxyFetch` has the same multi-datasource requirement but takes the alias as its **argument** — `useProxyFetch(ds.store)` — not as `from:` |
|
|
20
20
|
| Hoisting datasource ids into constants: `datasource.define({ people: PEOPLE_DS_ID })` | Fails to compile — *"datasource.define() object values must be string literals."* Softr statically analyses the call, same as `q.select()`. Keep the UUIDs **inline**: `datasource.define({ people: "74d2cbfd-…" })`. Fails fast with an explicit message, but hoisting magic strings is a strong reflex — resist it here |
|
|
21
21
|
| Asking Studio's AI chat "what are the datasource IDs?" and pasting the answer | **It fabricates them.** Verified July 2026: asked three times for the same three connected tables, it gave three different UUID sets, once reusing a previously-mentioned table's uuid for a different table — all confidently worded, none hedged. Ask it to **write code** instead (*"write a datasource.define call covering every connected source, plus one useRecords per source, code only"*) — scaffolding is bound to the real connections. Then RUN it: real rows under each heading proves each alias maps where you think. A wrong uuid fails safe (matches nothing → error); a *swapped pair* of valid uuids does not |
|
|
22
|
-
| "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<
|
|
22
|
+
| "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<connection>/records`) and returns the UNION of every field named by any READ `q.select` on that connection — every viewer's browser receives the private field; it is merely not rendered (verified live 2026-09-18, network capture). A mutation hook's `fields:` select does not join the union. Fix: connect the **same table a second time** (allowed — it gets its own dataSourceId), read the private field only from that connection in a hook that non-privileged browsers never run (a child component mounted only for admins), or move it to a group-gated block. Server-side, page VIEW permission and the block's own Visibility gate the endpoint (403 on list and by-id; block gate verified 2026-10-05), and Source conditions gate ROWS; nothing else does. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects) |
|
|
23
23
|
| `select: isAdmin ? adminSelect : publicSelect` (or an inline `q.select({...})` in the hook options) in a **multi-datasource** block | The select cannot be attributed to a connection and the query returns records with `fields: {}` — no compile error, no runtime error, just empty fields (verified live 2026-09-18). `select:` / `fields:` must be a **plain module-scope identifier**. In a single-datasource block the ternary "works", but as a union of both branches (row above) — so it is never the tool it looks like. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#select-must-be-a-plain-module-scope-identifier) |
|
|
24
24
|
| `useRecords({ select, count: 1 })` on a detail page, expecting the page's record — or a `useRecord` with no / null `recordId` | There is **no detail-page auto-scoping**: the runtime sends `pageContext: null`, so `count: 1` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call (verified live 2026-09-18). It passes a test on the first record and fails on every other. Use `useRecord({ from, select, recordId, enabled: !!recordId })` with `recordId = useCurrentRecordId()` (which does return the URL's `recordId`; the call hits `/records/<id>`), and verify `data.id === recordId` before rendering or writing. See [datasources/reading.md](../datasources/reading.md#userecord----fetch-a-single-record) |
|
|
25
|
+
| `where: q.text("status")…` or `orderBy` naming an alias that is not in **that hook's own** `select` | Crashes the whole block at runtime — "Could not find an alias for subject \"undefined\"", Softr's "Oh snap" panel — though the push compiled clean (seen live 2026-09-18 on a `useMetric`). Aliases resolve per hook, not per connection: add the field to the hook's select (it then joins the connection's read payload). Hard Constraint 29; see [datasources/reading.md](../datasources/reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select) |
|
|
26
|
+
| Trusting that a `where` ran because the block compiled and shows plausible rows | A filter on a field that is not in the connection's read-select union is **silently ignored**: no error, and the query returns everything (verified live 2026-09-19). The alias rule (row above) catches only aliases missing from the hook's own select, and it fails loudly; this one fails open. Prove each `where` narrows: as a viewer who can see more rows than it should leave (an admin, say), compare the count with and without it (7 → 3, not 7 → 7), and apply the condition client-side as well wherever an ignored filter would show the wrong rows. See [datasources/reading.md](../datasources/reading.md#filters-fail-open) |
|
|
25
27
|
| `useRecords({ ..., enabled: false })` / `enabled: someFlag` to defer or withhold a list query | **`useRecords` ignores `enabled: false`** — literal or variable, it fetches anyway (verified live 2026-09-18). `useRecord` honours it. To make a list query conditional, mount the hook in a **child component rendered only when needed** (the only option that sends no request), or give it a match-nothing `where`. Never rely on `enabled` to keep a table away from viewers who should not load it. See [datasources/reading.md](../datasources/reading.md#userecords-ignores-enabled-false) |
|
|
26
28
|
|
|
27
29
|
## Mutations
|
|
@@ -57,6 +59,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
57
59
|
|---|---|
|
|
58
60
|
| `field.toLowerCase()` on selects | `getFieldValue(field).toLowerCase()` |
|
|
59
61
|
| `item.fields.formula === true` | Formula booleans: `=== "1"` |
|
|
62
|
+
| `new Date(value)` / date-fns `format(new Date(value))` on a **date-only** field | Date-only values arrive as midnight UTC, so west of Greenwich they render one day early (verified 2026-09-18). Parse them with `toLocalDate()` — see [datasources/fields.md](../datasources/fields.md#date-only-fields-arrive-as-midnight-utc). Keep `new Date()` for real timestamps |
|
|
60
63
|
|
|
61
64
|
## Hooks & React
|
|
62
65
|
|
|
@@ -110,6 +113,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
110
113
|
| Anti-Pattern | Correct Approach |
|
|
111
114
|
|---|---|
|
|
112
115
|
| `currentUser.role` for tiers | `window.__softr_current_user.userGroups` |
|
|
116
|
+
| Reading `window.__softr_current_user.userGroups` once at mount and treating `[]` as "no groups" | The global has **no change event**: nothing re-renders the block when the shell fills it in, and an early empty `userGroups` means not loaded yet. Read once, an admin can be settled as a non-admin for good. Hold it in state, poll with a bound (about 2 s), and gate role-dependent UI on the poll having settled. Pattern from two production blocks, 2026-09-18; see [datasources/reading.md](../datasources/reading.md#current-user) |
|
|
113
117
|
|
|
114
118
|
## Editable Settings
|
|
115
119
|
|
|
@@ -83,7 +83,11 @@ function getLinkedItems(f) {
|
|
|
83
83
|
if (x && typeof x === "object") return { id: x.id || "", title: x.label || x.name || x.title || "" };
|
|
84
84
|
return { id: "", title: String(x) };
|
|
85
85
|
}).filter(function(o) { return o.id || o.title; });
|
|
86
|
-
|
|
86
|
+
if (typeof f === "object") { /* a single link arrives as one { id, label } object */
|
|
87
|
+
var t = f.label || f.name || f.title || "";
|
|
88
|
+
return (f.id || t) ? [{ id: f.id || "", title: t }] : [];
|
|
89
|
+
}
|
|
90
|
+
return [{ id: "", title: String(f) }];
|
|
87
91
|
}
|
|
88
92
|
```
|
|
89
93
|
|
|
@@ -362,7 +366,7 @@ When you change a helper's output shape (e.g., `advisorOffice` from array to str
|
|
|
362
366
|
1. Document the published shape as a comment at the top of the helper file and update all consumers in the same commit.
|
|
363
367
|
2. Version the namespace (`__myapp_projects_v2`) -- old consumers keep reading v1 until migrated.
|
|
364
368
|
|
|
365
|
-
Defensive consumers can use `Array.isArray(x) ? x.map(...) : x` when shape might vary, but don't lean on this -- it hides bugs.
|
|
369
|
+
Defensive consumers can use `Array.isArray(x) ? x.map(...) : x` when shape might vary, but don't lean on this -- it hides bugs. (That is about the shape of a global *you* publish. Linked-record values read from Softr are different: they arrive as a single `{ id, label }` object or as an array, and must always be normalised -- see [fields.md](../datasources/fields.md).)
|
|
366
370
|
|
|
367
371
|
### useState, Not useRef, for IDs Consumed by useMemo
|
|
368
372
|
|
package/references/softr-mcp.md
CHANGED
|
@@ -50,8 +50,8 @@ map below was checked against the tool lists the server delivered on 2026-09-30
|
|
|
50
50
|
**The Workflows tools have since followed** (when exactly is not known; first seen 2026-10-05). The 2026-10-05 roster delivered all 28 as `workflow_*`:
|
|
51
51
|
`workflow_create`, `workflow_get`, `workflow_list`, `workflow_publish`, `workflow_update_node_inputs`,
|
|
52
52
|
`workflow_get_node_specifications`, `workflow_list_node_types`, `workflow_test_node` and the rest,
|
|
53
|
-
i.e. area first, then the old verb and object. [Workflows](#workflows) below
|
|
54
|
-
|
|
53
|
+
i.e. area first, then the old verb and object. [Workflows](#workflows) below uses the new names
|
|
54
|
+
(re-checked against the live roster 2026-10-06: all 28 present). That roster had no `get_workspace_integrations`;
|
|
55
55
|
`integration_list` covers it.
|
|
56
56
|
|
|
57
57
|
Most new names are the old words reordered. These are the ones you would not guess:
|
|
@@ -195,7 +195,7 @@ For block-building work you need **Applications & Forms: Full access** (to creat
|
|
|
195
195
|
|
|
196
196
|
## Vibe coding block tools
|
|
197
197
|
|
|
198
|
-
Before writing any block code through the MCP, call `vibe_coding_block_get_docs` — it returns the current version of the [Vibe Coding Developer Guide](https://docs.softr.io/vibe-coding-developer-guide), which is the authority on hook signatures if it and this skill ever disagree.
|
|
198
|
+
Before writing any block code through the MCP, call `vibe_coding_block_get_docs` — it returns the current version of the [Vibe Coding Developer Guide](https://docs.softr.io/vibe-coding-developer-guide), which is the authority on hook signatures if it and this skill ever disagree. On runtime *behaviour* the guide's prose can lag a live capture, and where it does this skill says so: the guide still describes `useRecords({ enabled })` as a way to defer loading (checked 2026-10-06), while a 2026-09-18 network capture showed `useRecords` fetching anyway ([reading.md](../datasources/reading.md#userecords-ignores-enabled-false)). Trust the capture until a newer one says otherwise.
|
|
199
199
|
|
|
200
200
|
| Group | Tools |
|
|
201
201
|
|---|---|
|
|
@@ -250,9 +250,13 @@ context. Keep the local mirror in step mechanically rather than by hand:
|
|
|
250
250
|
sent, which is what makes it usable here: on this path you never see the merged file yourself.
|
|
251
251
|
|
|
252
252
|
One encoding trap: JSON `\uXXXX` escapes inside the ops are **decoded to the real characters** on
|
|
253
|
-
Softr's side (`"
|
|
253
|
+
Softr's side (`"\u2014"` is stored as `—`). The mirror must therefore hold raw UTF-8 — apply the
|
|
254
254
|
ops to it *after* JSON-decoding them, never as the escaped text, or the final comparison
|
|
255
255
|
fails on every non-ASCII character.
|
|
256
|
+
The same decoding happens to an agent's own tool-call arguments: a `\u2014` typed into a
|
|
257
|
+
file-writing tool lands on disk as `—` (it put a wrong example into this very paragraph
|
|
258
|
+
twice, 2026-09-18 and 2026-10-06). When a file must hold a literal backslash-u sequence, build the
|
|
259
|
+
backslash at runtime (`chr(92)` in Python) and check the bytes afterwards.
|
|
256
260
|
|
|
257
261
|
**Reach for the full replace when the change is structural** — reordering JSX, moving logic between
|
|
258
262
|
components, adding a hook — where being sure of "the exact current text" of a dozen scattered fragments
|
|
@@ -334,6 +338,22 @@ default permissions (see the next section for why that can be a security problem
|
|
|
334
338
|
the restoration). If page-level visibility is the access control in your app, record that decision
|
|
335
339
|
so nobody chases the reset after every round; if it is not, re-tighten and read back.
|
|
336
340
|
|
|
341
|
+
**What a push leaves alone, and when it goes live** (our observations on Softr Database, not
|
|
342
|
+
from the docs):
|
|
343
|
+
|
|
344
|
+
- **A code push does not clear Source conditions** (verified 2026-09-18). Only the Actions reset.
|
|
345
|
+
- **Disconnecting and reconnecting a data source does.** `vibe_coding_block_disconnect_data_source`,
|
|
346
|
+
then `vibe_coding_block_connect_data_source` with the same table, brings the block back bound to
|
|
347
|
+
that table with an **empty** condition (2026-09-10). Other blocks bound to the same data source id
|
|
348
|
+
kept theirs. That makes it a way to clear a broken condition when the filter tool cannot be called,
|
|
349
|
+
and it also means a reconnect done for any other reason drops the row gate: read the conditions
|
|
350
|
+
back afterwards. The next code push re-derives the block's Actions at the defaults, as any push does.
|
|
351
|
+
- **A push lands in the draft.** The live app serves it only after the next app publish, and that
|
|
352
|
+
publish, whoever runs it, takes every other pending draft change live with it, unversioned
|
|
353
|
+
settings edits included. Before calling a block "staged", compare the app's last publish time
|
|
354
|
+
(`application_get`) with the version's `createdAt` (`vibe_coding_block_list_versions`): on
|
|
355
|
+
2026-09-01 a block we believed staged went live with a publish 28 minutes after it was saved.
|
|
356
|
+
|
|
337
357
|
### The array-argument rejection, and why it is a security issue
|
|
338
358
|
|
|
339
359
|
**Several workspace-server tools take an array argument, and a call that sends it as a JSON *string*
|
|
@@ -421,13 +441,17 @@ preserves explicitly set permissions across a recompile, so every recompile need
|
|
|
421
441
|
the call errors rather than lying, but an agent that batches calls can easily miss which one failed.
|
|
422
442
|
5. If any action is still broader than intended (typically ADD_RECORD at `ALL_USERS`), **report it
|
|
423
443
|
and let the builder decide.** Check the page's own VIEW permission first with
|
|
424
|
-
`application_page_get_permissions`, because that is what sets the severity
|
|
444
|
+
`application_page_get_permissions`, because that is what sets the severity, and report the
|
|
445
|
+
block's own Visibility with it (`vibe_coding_block_get_settings`): it gates the block's reads
|
|
446
|
+
and sets ADD_RECORD's default (2026-10-05), but whether it refuses writes on its own is untested
|
|
447
|
+
([below](#what-the-server-enforces-on-a-blocks-data-endpoints)):
|
|
425
448
|
- **Page VIEW is gated** (e.g. `LOGGED_IN_USERS`) — an anonymous visitor cannot load the page at
|
|
426
449
|
all, so exploiting the open action means calling its endpoint directly, and the realistic worst
|
|
427
450
|
case is junk records rather than data exposure or deletion. Housekeeping: worth fixing on the
|
|
428
451
|
next Studio pass, not worth holding a release for.
|
|
429
|
-
- **Page VIEW is `ALL_USERS`** — the action permission is the only gate
|
|
430
|
-
|
|
452
|
+
- **Page VIEW is `ALL_USERS`** — the action permission is the only gate known to hold on writes
|
|
453
|
+
(the block's Visibility may also refuse them; untested). That is a genuine hole and deserves
|
|
454
|
+
to be called one.
|
|
431
455
|
|
|
432
456
|
Report page, block, action type, data source and current group; say which of the two cases applies;
|
|
433
457
|
note that a human sets them on the block's Actions tab in Studio. Then stop — **do not unilaterally
|
|
@@ -438,8 +462,10 @@ preserves explicitly set permissions across a recompile, so every recompile need
|
|
|
438
462
|
**is** enforced on the block's datasource **records** endpoint (verified live 2026-09-18 — a
|
|
439
463
|
viewer who cannot view the page gets a 403 whose message names "block/action visibility rules";
|
|
440
464
|
see [below](#what-the-server-enforces-on-a-blocks-data-endpoints)). The *action* (write) endpoint
|
|
441
|
-
|
|
442
|
-
|
|
465
|
+
refuses outsiders too: on 2026-10-05 a replayed PATCH from outside the block's group got 403
|
|
466
|
+
([hubspot.md](../datasources/hubspot.md#writing)). The block and its actions were both limited to
|
|
467
|
+
that group, so which rule refused it is not known, and whether page VIEW alone gates writes is
|
|
468
|
+
untested. So the reason to treat a gated page as low-severity is still the practical
|
|
443
469
|
difficulty and low blast radius — and note that "gated to logged-in users" keeps out anonymous
|
|
444
470
|
visitors only: any logged-in user can view that page, and therefore reach its endpoints. Say that
|
|
445
471
|
plainly rather than implying the action is safe.
|
|
@@ -462,15 +488,15 @@ Both edit paths recompile, so both reset Action permissions either way (Hard Con
|
|
|
462
488
|
*Verified live 2026-09-18 (Softr Database; draft preview, "Preview as" different users, requests
|
|
463
489
|
captured from the app iframe); the block-visibility row verified 2026-10-05 (HubSpot; preview link,
|
|
464
490
|
impersonated users, direct POSTs).* A block's data lives behind per-connection endpoints —
|
|
465
|
-
`/blocks/<blockId>/datasources/<
|
|
466
|
-
and these are the gates that actually exist on them:
|
|
491
|
+
`/blocks/<blockId>/datasources/<connection>/records` for lists, `/records/<id>` for one record —
|
|
492
|
+
and these are the gates that actually exist on them (`<connection>` was recorded as the connection's id in the 2026-09-18 Softr Database capture and seen as its alias in a 2026-10-05 HubSpot capture; unresolved, and it only matters when reading a network log):
|
|
467
493
|
|
|
468
494
|
| Gate | Enforced server-side? |
|
|
469
495
|
|---|---|
|
|
470
496
|
| **Page VIEW permission** | **Yes.** A viewer who cannot view the page gets **403** ("block/action visibility rules…") from the block's datasource endpoint — crafting the request by hand does not get around it |
|
|
471
497
|
| **The block's Visibility** (`predefinedUserGroup` + `customUserGroupIds`; `vibe_coding_block_set_visibility`) | **Yes.** A viewer outside the block's group gets the same **403**, on list and by-id, even where the page lets them in. The body reads like a write error on a read: "You cannot add or edit a record because either the block/action visibility rules, user group conditions, or the user/record data in the datasource has changed." Per block: the same table on an ungated block stays open. Five code pushes left the setting intact |
|
|
472
|
-
| **The connection's Source conditions** (Source tab / `vibe_coding_block_set_data_source_record_filters`) | **Yes — and they are the only server-side ROW gate** |
|
|
473
|
-
| A `where` filter in the block's code | No — it is a request parameter the caller controls |
|
|
498
|
+
| **The connection's Source conditions** (Source tab / `vibe_coding_block_set_data_source_record_filters`) | **Yes — and they are the only server-side ROW gate.** A by-id request for a record the condition excludes answers differently per backend: **HTTP 200 with an empty body** on Softr Database (2026-09-18), **404** on HubSpot (2026-10-05). Treat both as "not found" |
|
|
499
|
+
| A `where` filter in the block's code | No — it is a request parameter the caller controls. It can also **fail open**: on 2026-09-19 (Softr Database) a request filter on a field that was not in the connection's read-select union was silently ignored, with no error, and the query returned everything. Confirm the filtered field is selected on the connection and prove the `where` narrows the result (compare counts with and without it); see [reading.md](../datasources/reading.md#filters-fail-open). A `where` naming an alias missing from its own hook's `select` fails the other way and crashes the block (Hard Constraint 29) |
|
|
474
500
|
| Which fields the block *renders*, a second / conditional `q.select`, `enabled: false` on `useRecords` | No — the endpoint returns the union of the connection's read selects to anyone allowed to call it (see [multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)) |
|
|
475
501
|
|
|
476
502
|
The consequence to design around: **on a page any logged-in user may view, every datasource
|
|
@@ -506,7 +532,8 @@ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **the
|
|
|
506
532
|
field in this form (Leo set it in the Source tab, and it read back that way), and the same form
|
|
507
533
|
works when written with `vibe_coding_block_set_data_source_record_filters`.
|
|
508
534
|
**It fails closed:** a user whose field is empty, or who has no record in the users' data source,
|
|
509
|
-
gets 0 rows, and a by-id request for a record outside the condition returns 404
|
|
535
|
+
gets 0 rows, and a by-id request for a record outside the condition returns 404 (on HubSpot;
|
|
536
|
+
Softr Database answers HTTP 200 with an empty body).
|
|
510
537
|
- **Use AND between rules.** With one rule OR and AND behave the same, but a second rule added
|
|
511
538
|
under OR widens access (2026-10-05).
|
|
512
539
|
- **The braced user-field spellings fail.** On 2026-09-18, on Softr Database, eleven spellings were
|
|
@@ -528,6 +555,22 @@ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **the
|
|
|
528
555
|
untested (Softr Database so far), store an email on the record and compare it with
|
|
529
556
|
`{USER:::EMAIL}`. Staff who need every row get a group-gated block with unfiltered connections
|
|
530
557
|
([above](#what-the-server-enforces-on-a-blocks-data-endpoints)), not a wider condition.
|
|
558
|
+
- **A Softr Database recipe for "everyone named on the record, plus staff"** (verified 2026-09-18
|
|
559
|
+
in a production app):
|
|
560
|
+
- `CONTAINS` is a case-insensitive **substring** test. It works against a FORMULA text field and
|
|
561
|
+
against a LOOKUP of one (subject `type: "TEXT"`). So a formula that joins every email on the
|
|
562
|
+
record (lower-cased, comma-delimited) gates rows with `<formula> CONTAINS {USER:::EMAIL}`,
|
|
563
|
+
substring trap included (above).
|
|
564
|
+
- Related tables follow the parent through a LOOKUP of that formula, with the same condition on
|
|
565
|
+
the lookup.
|
|
566
|
+
- A LOOKUP that brings an email over a link field works with `IS_ONE_OF {USER:::EMAIL}`.
|
|
567
|
+
- There is no group token, so a **constant** formula listing the staff emails stands in for one:
|
|
568
|
+
`<access formula> CONTAINS {USER:::EMAIL}` OR `<staff formula> CONTAINS {USER:::EMAIL}`. This is
|
|
569
|
+
the OR widening warned about above, chosen on purpose. A staff-only connection carries the
|
|
570
|
+
second rule alone. Adding a staff member then takes two edits: the user group and the formula.
|
|
571
|
+
- The MCP cannot edit a formula after creation, so the staff list is changed in Studio.
|
|
572
|
+
- `vibe_coding_block_set_data_source_record_filters` takes one flat level: the rules joined by a
|
|
573
|
+
single AND or a single OR, no nested groups.
|
|
531
574
|
|
|
532
575
|
For HubSpot specifics (association-based scoping, owner fields), see
|
|
533
576
|
[../datasources/hubspot.md](../datasources/hubspot.md#row-scoping--who-sees-which-records).
|
|
@@ -554,6 +597,11 @@ From the official MCP docs — these hold for MCP-driven and Studio-driven edits
|
|
|
554
597
|
- **Changing the code resets action permissions.** Any code change rebuilds the block's record actions at default visibility — restrictions to user groups must be re-applied. (This is Hard Constraint 21 in SKILL.md, now officially documented: tighten Action permissions only after the LAST redeploy.) The defaults: ADD_RECORD follows the block's own visibility, while UPDATE_RECORD and DELETE_RECORD are reset to logged-in users (per Softr, 2026-10-01).
|
|
555
598
|
- **A block with an unconnected data source saves without complaint**, then errors when the page loads. If a freshly created block looks broken but the code seems right, check its data source connection first.
|
|
556
599
|
|
|
600
|
+
Ours, not from the docs: **Source conditions and Action permissions are not versioned.** The version
|
|
601
|
+
history cannot date a change to either (noted 2026-09-10). Whether restoring a version brings back an
|
|
602
|
+
older condition we have not tested. What a code push does and does not reset is in
|
|
603
|
+
[Verifying a push](#verifying-a-push--the-deployed-source-is-the-only-proof).
|
|
604
|
+
|
|
557
605
|
## Application management tools
|
|
558
606
|
|
|
559
607
|
The Applications area goes well beyond reads (roster as delivered 2026-10-01; behavior not individually exercised unless stated):
|
|
@@ -716,7 +764,14 @@ Known limits and behaviors (per official docs):
|
|
|
716
764
|
is no evidence that nothing changed.
|
|
717
765
|
- **Field descriptions are readable** since 2026-10-01 (per Softr). Before then a description
|
|
718
766
|
could be written but no read returned it.
|
|
719
|
-
-
|
|
767
|
+
- **`database_create_field` for a DATETIME takes `options: {"includeTime": true}`** (ours, verified
|
|
768
|
+
2026-09-18). The options shape `database_list_fields` returns for an existing DATETIME field is
|
|
769
|
+
rejected, so do not copy a field definition from a read into a create.
|
|
770
|
+
- **SINGLE_LINE_TEXT fields carry a 1,024-character `maxLength`** (ours: two fields of a production
|
|
771
|
+
table, found in a 2026-09-10 schema audit and confirmed live 2026-09-18, recorded in a block's code
|
|
772
|
+
comment). A block that appends to such a field has to keep the total under it; what a longer write
|
|
773
|
+
does was not tested. Use LONG_TEXT for anything that grows.
|
|
774
|
+
- Limits: 100 records per `database_create_records` call, 200 records per read (silently capped, not an error), 2 group-by fields in `database_aggregate_records`. For big tables prefer a filter or aggregate over paging. The read cap is per call, not a ceiling: `database_list_records` takes `offset`, and on 2026-09-01 `limit` 200 with `offset` 0 to 2,400 read a 2,549-row table in 13 calls, every record id unique, no gap or overlap (ours).
|
|
720
775
|
|
|
721
776
|
Typical Vibe Coding uses: "list every field on `Wigs` with id, name, type, and dropdown options", "what's the option id for `Payment status` = 'Partially paid'?", "show 3 sample records so we know value shapes", "verify the field id in my `q.select()` exists". This eliminates the field-id-typo / wrong-option-uuid class of bugs entirely.
|
|
722
777
|
|
|
@@ -726,13 +781,13 @@ Softr Workflows are automations built from trigger + action nodes, and the MCP c
|
|
|
726
781
|
|
|
727
782
|
| Group | Tools |
|
|
728
783
|
|---|---|
|
|
729
|
-
| Workflow lifecycle | `
|
|
730
|
-
| Node management | `
|
|
731
|
-
| Discovery / testing | `
|
|
784
|
+
| Workflow lifecycle | `workflow_create`, `workflow_get`, `workflow_get_url`, `workflow_list`, `workflow_rename`, `workflow_update_configuration`, `workflow_publish`, `workflow_unpublish`, `workflow_test` |
|
|
785
|
+
| Node management | `workflow_add_node`, `workflow_add_branch_node`, `workflow_create_branch`, `workflow_delete_node`, `workflow_duplicate_node`, `workflow_rename_node`, `workflow_reorder_node`, `workflow_reorder_multiple_nodes`, `workflow_replace_node`, `workflow_replace_trigger_node`, `workflow_update_node_inputs`, `workflow_update_node_note`, `workflow_update_node_continue_on_error`, `workflow_update_node_retry` |
|
|
786
|
+
| Discovery / testing | `workflow_list_node_types`, `workflow_get_node_specifications`, `workflow_get_dynamic_input_options`, `workflow_test_node`, `workflow_get_node_output` |
|
|
732
787
|
|
|
733
|
-
These are the names
|
|
734
|
-
(`create_workflow` → `workflow_create
|
|
735
|
-
[the rename note](#tool-names--the-2026-10-01-rename).
|
|
788
|
+
These are the current names (live roster, 2026-10-06). Until 2026-10-01 they had no `workflow_`
|
|
789
|
+
prefix (`create_workflow` → `workflow_create`), and older notes, including project docs, still use
|
|
790
|
+
those; see [the rename note](#tool-names--the-2026-10-01-rename).
|
|
736
791
|
|
|
737
792
|
**The node catalog is huge** — live-enumerated 2026-08-31: **418 node types (58 triggers + 360 actions) across 56 applications.** The parts that matter most for this skill:
|
|
738
793
|
|
|
@@ -744,7 +799,7 @@ These are the names as of 2026-10-01. By 2026-10-05 the server delivered them as
|
|
|
744
799
|
|
|
745
800
|
See SKILL.md's NavigationAction action-types list.
|
|
746
801
|
- **Softr-native actions:** `BRANCH`, `FILTER`, `WAIT`, `LOOP_ACTION_GROUP` (run each list item through the same steps), `SOFTR_SEND_EMAIL`, `CALL_API` (REST), `WEBPAGE_SCRAPPER`, `PDF_TO_TEXT`, `COMPRESS_FILES` (zip + download link), `TRANSFORM_DATA`, `RESPONDED_TO_WEBHOOK` (custom HTTP response to the webhook caller); Softr DB record CRUD incl. bulk update/delete and find; Softr Apps user management (find / create / delete / deactivate / activate / invite user, send push notification).
|
|
747
|
-
- **`CUSTOM_CODE`:** runs custom **JavaScript or Python** inside a workflow.
|
|
802
|
+
- **`CUSTOM_CODE`:** runs custom **JavaScript or Python** inside a workflow. Its input and output contract (an object `inputData`, the result under `$.body`) is the `CUSTOM_CODE` bullet in the build-loop findings below.
|
|
748
803
|
- **AI actions:** Softr AI, OpenAI, Anthropic, Gemini, and Mistral each ship Write / Summarize / Categorize / Custom-prompt nodes; OpenAI adds gpt-image-2 image generation. Pinecone, Firecrawl, Replicate, and Linkup nodes exist too.
|
|
749
804
|
- **Integration apps (top of 56):** Stripe (36 nodes), QuickBooks (24), ActiveCampaign (23), SharePoint (22), Asana (18), Gmail/Attio/Brevo/Resend (12 each), ClickUp/Zendesk (10), Airtable/Notion/Cal.com/HubSpot/Xero/DocuSign/Apollo (9 each), Sheets/Excel (8), monday/SQL/Jira (7), Slack/Telegram (6), plus Salesforce, Coda, Calendly, Twilio, Zoom, Linear, Trello, form tools (Typeform/Tally/Jotform/Fillout), and more.
|
|
750
805
|
|
|
@@ -754,19 +809,46 @@ These are the names as of 2026-10-01. By 2026-10-05 the server delivered them as
|
|
|
754
809
|
- **Test-first is mandated:** every testable node needs a test run before its outputs become referenceable by downstream nodes. Each node carries a `testRunMode` — `REAL_ONLY`, `MOCK_ONLY`, or `MOCK_AND_REAL` — so some nodes can only be tested against real side effects while others mock. See the test-safety rules under build-loop findings below before testing anything against a production workspace.
|
|
755
810
|
- **Workflows are owned by a workspace, and can now be pinned to an app** (verified 2026-10-05 from the tool definitions). `workflow_create` still requires a `workspaceId`; its optional `applicationId` "pins the workflow to it, so it is listed on that app's Workflows tab", and `workflow_list({ applicationId })` lists the workflows pinned to an app. Leave `applicationId` out for a workflow that belongs to the workspace as a whole. Pinning or not, `application_preview` / `application_publish` do not apply to workflows. Link a workflow as `https://studio.softr.io/workflow/{workflowId}`.
|
|
756
811
|
|
|
757
|
-
**Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows):**
|
|
812
|
+
**Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows; bullets dated later come from a second production build, 2026-09-18/19):**
|
|
758
813
|
|
|
759
|
-
- **`
|
|
760
|
-
- **FILTER
|
|
761
|
-
- **The official MCP docs
|
|
762
|
-
- **
|
|
763
|
-
- **
|
|
814
|
+
- **`workflow_create` instantiates an OLD version of the trigger node.** Immediately call `workflow_replace_trigger_node` with the **same trigger type** — the replacement lands at the current version with the current inputs. Example: `updateField` on `SOFTR_TABLES_RECORD_UPDATED` (fire only when a watched field changes; it takes an UPDATED_AT-type field, see "Trigger scope" below) only exists at v1.2.0; the version `workflow_create` instantiates doesn't have it. Do it before anything references the trigger: the replacement gets a **new node id** (see the `workflow_replace_node` bullet below).
|
|
815
|
+
- **A FILTER condition written over MCP is inert — set it in Studio** (corrected 2026-10-06; this bullet used to say the MCP writes it). `workflow_update_node_inputs` with inputName `"condition"` accepts an `{operator, conditions: [...]}` object and stores it in the node's `inputs.condition`, a field the engine does not read. The engine evaluates the condition on the FILTER node's **outgoing path** (the `paths` entry whose `fromActionId` is the filter), and only the Studio builder writes that. **A filter built over MCP passes every run.** A 2026-09-19 audit of this very build showed it three ways: the one workflow actually running had empty `inputs` and its whole condition on the path; two others, edited in Studio afterwards, held one condition in `inputs.condition` and a different one on the path, so Studio reads and writes only the path.
|
|
816
|
+
- **The official MCP docs agree:** "Branch and filter conditions can't be set through MCP yet ... deciding what sends a run down each path is something you finish in the builder" (docs.softr.io/mcp/workflows, checked 2026-10-05), and the FILTER spec declares `inputs: {}`. BRANCH conditions were not tested separately here; treat them the same way.
|
|
817
|
+
- **How to build one:** add the FILTER over MCP if that is convenient, then open it in Studio, set its clauses and save. Never trust `inputs.condition` as a record of what the filter does; it can disagree with the path.
|
|
818
|
+
- **How to check one:** read the workflow back with `workflow_get` and find the `paths` entry whose `fromActionId` is the filter; the condition must be there. FILTER nodes cannot be run with `workflow_test_node`, so this read-back is the only check before a real run.
|
|
764
819
|
- **`LOOP_ACTION_GROUP`'s `loopVariables.items` must reference a plain array**, e.g. `$.records` — a `[*]` projection (e.g. `$.records[*].fields.X`) is rejected by the validator. Per-item references **inside** the loop use `{loopActionGroup.<id>:::loopVariables.items.fields.<fieldId>}` (use the bracket form for ids that start with a digit).
|
|
765
|
-
-
|
|
820
|
+
- **Over a plain array of strings** (a `CUSTOM_CODE` node's `$.body.<key>`, say), the item *is* the value: reference it as bare `{loopActionGroup.<id>:::loopVariables.items}`, nothing after `items` (2026-09-19; accepted by the validator, not yet exercised by a run).
|
|
821
|
+
- **References into the loop are rejected until the source node's saved sample holds at least one item** (the validator says so). Test the source node on a record that yields a non-empty array before wiring the steps inside the loop.
|
|
822
|
+
- **To put a step inside the loop**, call `workflow_add_node` with `compositeNodeId: <loopNodeId>`. The step lands in the loop's own `actions` / `paths`, not the workflow's.
|
|
823
|
+
- **An empty loop does not stop the run** (recorded 2026-09-19). Zero items means zero iterations and a normal completion, and the steps after the loop still run. A guard stamp placed after a loop therefore fires even when nobody was emailed, and consumes the notice. A gate on the item count has to cover the loop and the stamp together; gating only the stamp leaves the guard unset, and the next edit sends again.
|
|
824
|
+
- **`workflow_update_node_inputs` batches validate against the STORED node state**, not the batch-in-progress — an update that depends on another update in the same batch fails validation. Split dependent updates into sequential calls.
|
|
825
|
+
- **`CUSTOM_CODE` contract** (2026-09-18/19; found by testing, documented nowhere we know of):
|
|
826
|
+
- `inputData` must be a JSON **object**, name → value. The `[{key, value}]` shape other `KEY_VALUE_MAP` inputs take fails the run with `script_args must be an object.`
|
|
827
|
+
- The code reads `inputData.<name>` and ends with a top-level `return { … }`; the body runs wrapped in a function.
|
|
828
|
+
- **The engine wraps what you return:** the node's output is `{ body: <returned object>, statusCode: 200 }`, so downstream references read **`$.body.<key>`**, never `$.<key>`.
|
|
829
|
+
- **The order of `inputData` keys is not kept.** The stored map came back reordered (2026-09-19). If the code must read some inputs after others, order them in the code (by key name, say), and assert on sets and counts in tests, not on order.
|
|
830
|
+
- **There is no native split / list / array-from-text step.** `workflow_list_node_types` has none, and `TRANSFORM_DATA` takes per-field formulas rather than producing a list. Turning a text field of comma-separated addresses into an array a loop can walk takes a `CUSTOM_CODE` node.
|
|
831
|
+
- Testing it: see the test-safety rules below.
|
|
832
|
+
- **Reference validation runs against saved samples** (2026-09-18/19). `workflow_update_node_inputs` checks every `{outputs.<nodeId>:::$.path}` reference against the referenced node's **saved test sample** and rejects a path that does not resolve there: `path "$.fields.<fieldId>.label" does not resolve against node "…"'s sample output`. A record trigger's sample is not yours to choose: re-running `workflow_test_node` on the trigger returns the same record every time. If that record has the field empty (a blank SELECT has no `.label`), the reference cannot be written over MCP at all. Studio's variable picker offers the path regardless of the sample, so add such a reference in Studio.
|
|
833
|
+
- **Pass link fields bare.** A `[*].label` projection on a link field (`$.fields.<linkId>[*].label`) does not resolve when the link is empty, and kills the node. Pass the bare field (`$.fields.<linkId>`) into a `CUSTOM_CODE` node and read the labels in code (2026-09-19).
|
|
834
|
+
- **`workflow_replace_node` swaps a node's type in place and gives it a new node id** (2026-09-19). The node keeps its place in the graph, which is how a step can be rebuilt as a different type without deleting anything, but every `{outputs.<old id>:::…}` reference to it now points at nothing. Before replacing a node, read the workflow with `workflow_get` and search every input for its id: a node whose output is interpolated into an email body would leave those values blank. `workflow_replace_trigger_node` does the same to the trigger (2026-09-18): new id, so every reference to the trigger has to be re-pointed and the trigger re-tested, which is why it belongs right after `workflow_create`. Both leave the discarded node's sample behind in `nodeSamples`. Nothing references it and it changes nothing, no tool removes it, and it is not evidence that the current node was ever tested.
|
|
835
|
+
- **A workflow's own record write re-fires its record-updated trigger** (seen on a production workflow, 2026-09-01). When the trigger watches the table's last-modified field, the workflow's final write is itself an edit. Two guard patterns, both used in our builds:
|
|
836
|
+
- **A send-once stamp:** a "…Sent" date field that the filter requires to be empty, written once at the end, outside any loop. It is the dedupe and the loop guard in one.
|
|
837
|
+
- **Clear the request in the same write:** when the trigger reacts to a request field, the write that acts on it also empties it (`CLEAR_VALUE`), so the re-fire finds nothing to do.
|
|
838
|
+
|
|
839
|
+
Never remove the guard, and never relax the filter to something that stays true after the write. The filter itself has to be set in Studio (see the FILTER bullet above).
|
|
840
|
+
- **Trigger scope** (2026-09-18):
|
|
841
|
+
- `SOFTR_TABLES_RECORD_UPDATED`'s optional `updateField` takes a field of type **UPDATED_AT** (a last-modified field), not any field. A table without one cannot set it, and the trigger then fires on every edit of every record. It is a plain select input: `workflow_get_dynamic_input_options` rejects it.
|
|
842
|
+
- **A workflow has exactly one trigger**, and "Record added" (`SOFTR_TABLES_NEW_RECORD`) and "Record updated" (`SOFTR_TABLES_RECORD_UPDATED`) are separate trigger types, so reacting to both takes two workflows. Whether creating a record also emits an "updated" event is unknown; make the pair idempotent so it does not matter.
|
|
843
|
+
- **Record-write field modes** (written over MCP 2026-09-18). Each field in a Softr Database record write carries a `modificationType`. `ADD_VALUE` adds to a multi-value field (multi-select, multi-link) and keeps what is there; `REPLACE_VALUE` overwrites the whole value, so on a multi-link it drops every earlier link; `CLEAR_VALUE` empties the field (sent with `value: ""`). Use `ADD_VALUE` to give a record one more link or option. These write steps have not run yet (testing one performs the real write), so how `ADD_VALUE` treats a value that is already present is unverified.
|
|
844
|
+
- **`serialExecution: true`** in a workflow's configuration (set with `workflow_update_configuration`, confirmed by read-back, 2026-09-18) makes runs queue instead of overlapping. We set it on a workflow that appends to a multi-link field: two runs at the same moment would each read-modify-write the same array, and one append could be lost.
|
|
845
|
+
- **`continueOnError` is stored on the path, not on the node** (2026-09-19). Turned on with `workflow_update_node_continue_on_error`, it shows up as a `SUCCEEDED OR FAILED` condition on the node's outgoing `paths` entry. Without it a failed step ends the run, so a guard stamp after it never happens and the whole notice goes out again on the next edit. On the **last step inside a loop** the entry has the condition but **no `toActionId`**, and whether the engine reads that as "carry on with the next item" is unproven. Test it with one deliberately bad item mid-list and check that the items after it still ran.
|
|
846
|
+
- **Time-based sends** (a proof of concept, 2026-09-01): a formula field flips (to `"yes"`, say) on the target day, a filtered view picks up the records where it has flipped, and a "Record enters view" trigger fires when one enters.
|
|
766
847
|
- **Test-safety rules** (which `testRunMode` means what in practice):
|
|
767
848
|
- Record-**write** nodes (`SOFTR_TABLES_UPDATE_RECORD` etc.) are `REAL_ONLY` — **never test them against a production workspace**; the test performs the real write.
|
|
768
849
|
- `SOFTR_SEND_EMAIL` is `MOCK_AND_REAL` — **always pass `mode: "mock"`**.
|
|
769
850
|
- Triggers and `GET_RECORDS` are `REAL_ONLY` but read-only-safe; a record-updated / enters-view trigger test just samples an existing record.
|
|
851
|
+
- `CUSTOM_CODE` is `REAL_ONLY` too, but a node whose code calls no integration and writes nothing is safe to test, and testing it is how you get the non-empty sample later references need (2026-09-19).
|
|
770
852
|
|
|
771
853
|
**Why this matters to block work:** Softr Workflows are now the Softr-native answer to the "block writes to its own table, backend cascades the rest" pattern — for **Softr Database backends** what [airtable-automations.md](airtable-automations.md) is for Airtable backends. See the cross-table alternatives in [../datasources/writing.md](../datasources/writing.md#cross-table-operations).
|
|
772
854
|
|