softr-vibe-coding 2.8.2 → 2.9.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 CHANGED
@@ -4,6 +4,9 @@ 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.9.0] - 2026-09-29
8
+ - Add runtime facts verified live 2026-09-18
9
+
7
10
  ## [2.8.2] - 2026-09-10
8
11
  - Corrections on evidence: Softr never strips the trailing newline; the array-argument rejection was client-side stringification, not an empty server schema (2.8.2)
9
12
 
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, 21 hard constraints
172
+ │ # components, settings, 27 hard constraints
173
173
  │
174
174
  ├── ui-ux-guidelines.md # Design reference
175
175
  │ # 26 sections: hierarchy, color, typography,
@@ -191,7 +191,10 @@ softr-vibe-coding/
191
191
  │ │ # Softr DB schema + record tools incl. deletes,
192
192
  │ │ # app management/scaffolding, Workflows suite
193
193
  │ │ # (26 tools, 418-node catalog), per-application
194
- │ │ # MCP servers, auth, permissions
194
+ │ │ # MCP servers, auth, permissions; what the server
195
+ │ │ # enforces on block data endpoints, "Preview as"
196
+ │ │ # role testing, search-replace on 100KB+ blocks
197
+ │ │ # (Sep 18 2026)
195
198
  │ ├── advanced-integrations.md # Shadow DOM CSS isolation
196
199
  │ │ # Leaflet, Mapbox, TinyMCE, Quill, FullCalendar
197
200
  │ ├── native-chrome-styling.md # Restyle Softr's native shell (header, footer,
@@ -239,9 +242,14 @@ softr-vibe-coding/
239
242
  ├── overview.md # Comparison matrix, selection guide
240
243
  ├── shared-patterns.md # Index → multi-datasource, reading, writing, fields
241
244
  ├── multi-datasource.md # Several data sources in ONE block: datasource.define(),
242
- │ # the from: parameter, getting the datasource UUIDs
245
+ │ # the from: parameter, getting the datasource UUIDs,
246
+ │ # select: as a module-scope identifier, the union-of-
247
+ │ # selects read payload (a conditional select is not
248
+ │ # privacy), Actions per table (Sep 18 2026)
243
249
  ├── reading.md # useRecords, filtering, sorting, pagination,
244
- │ # metrics, charts, current user
250
+ │ # metrics, charts, current user; no detail-page
251
+ │ # auto-scoping, useRecords ignores enabled:false,
252
+ │ # server-side linked-record filters (Sep 18 2026)
245
253
  ├── writing.md # Mutations, sequential write queues, uploads,
246
254
  │ # linked record format, cross-table writes
247
255
  ├── fields.md # getFieldValue(), field type shapes, record
package/SKILL.md CHANGED
@@ -69,6 +69,10 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
69
69
 
70
70
  6. **Self-validate before delivering.** Before presenting the code as complete, verify. (Data-hook items apply only to data-connected blocks; static marketing blocks swap in the checklist deltas from [references/static-blocks.md](references/static-blocks.md#workflow-deltas).)
71
71
  - Every data hook is called with an **inline options object literal** — `useRecords({ ... })` written through a variable or wrapper function fails to compile (verified live 2026-08-25). Share `q.select` mappings between hooks, never whole options objects
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
+ - 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
+ - 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
+ - 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)
72
76
  - All imports use named imports (no `import React from 'react'`)
73
77
  - `export default function Block()` is present
74
78
  - Container + content wrappers present (`<div className="container py-0"><div className="content">`) — OR a deliberate full-bleed layout recorded in the `// BLOCK PLACEMENT:` comment (see "Block Placement & Page Spacing")
@@ -556,7 +560,7 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
556
560
  restore worked. Softr's default for a `genericActions` ADD_RECORD is `ALL_USERS`, i.e. writable by
557
561
  logged-OUT visitors, and the MCP call that re-tightens it (`set_vibe_coding_block_action_visibility`)
558
562
  can itself fail with no fallback (see the array-argument quirk in
559
- [references/softr-mcp.md](references/softr-mcp.md#the-array-argument-serialization-quirk-and-why-it-is-a-security-issue)).
563
+ [references/softr-mcp.md](references/softr-mcp.md#the-array-argument-rejection-and-why-it-is-a-security-issue)).
560
564
  A push that returns `errors: null` can still have left public write access on the block.
561
565
  **If any action is still `ALL_USERS`, report it WITH its severity and let the builder decide.**
562
566
  Check the page's own VIEW permission first (`get_page_permissions`): a page gated to logged-in
@@ -574,6 +578,31 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
574
578
  are shared, and promise nothing beyond them (the class strings usually differ in layout and padding,
575
579
  and do not need to match). The same rule governs repeated page chrome -- see **Block Placement &
576
580
  Page Spacing**.
581
+ 23. **One connection = one read payload; a conditional select is not privacy** -- the records
582
+ endpoint is per block + connection and returns the UNION of every field named by any READ
583
+ `q.select` on that connection, to every viewer. A second or ternary select "only for admins"
584
+ hides nothing. Put a private field on a **second connection of the same table** (allowed) read
585
+ only by a hook non-privileged browsers never run, or in a group-gated block. Page VIEW permission
586
+ is enforced on these endpoints, but on a page any logged-in user may view, every connected
587
+ datasource is readable by any logged-in user who crafts the request -- Source conditions are the
588
+ only server-side ROW gate. Verified live 2026-09-18. See
589
+ [datasources/multi-datasource.md](datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects).
590
+ 24. **Multi-datasource: `select:` is a plain module-scope identifier** -- a ternary
591
+ (`select: a ? X : Y`) cannot be attributed to a connection and the query returns `fields: {}`, no
592
+ error. Treat an inline `q.select({...})` inside hook options the same way: hoist it. Verified
593
+ live 2026-09-18.
594
+ 25. **No detail-page auto-scoping** -- the runtime sends `pageContext: null`;
595
+ `useRecords({ count: 1 })` returns the table's FIRST row, not the URL's record. Fetch detail
596
+ records with `useRecord({ select, recordId, enabled: !!recordId })` (`useCurrentRecordId()` does
597
+ return the URL's `recordId`) and verify `data.id === recordId` -- a null id falls back to a list
598
+ call. Verified live 2026-09-18. See [datasources/reading.md](datasources/reading.md#userecord----fetch-a-single-record).
599
+ 26. **`useRecords` ignores `enabled: false`** -- literal or variable, it fetches anyway. `useRecord`
600
+ honours it. Make a list query conditional by mounting it in a child component only when needed,
601
+ or with a match-nothing `where`. Verified live 2026-09-18.
602
+ 27. **Mutation Actions register per TABLE, not per connection** -- several `useRecordUpdate` hooks on
603
+ one table merge into ONE UPDATE_RECORD action (field list = the union), filed under the table's
604
+ FIRST connection even when a hook points at a second one. Point writes at the first connection.
605
+ Verified live 2026-09-18. See [datasources/writing.md](datasources/writing.md#actions-register-per-table-not-per-hook-or-connection).
577
606
 
578
607
  ## Style Conventions
579
608
 
@@ -60,7 +60,7 @@ You'll see exactly which field is an object. Add `getFieldValue()` around it.
60
60
  | Date Range | `{ from: string, to: string }` |
61
61
  | Rating, Duration | `string or number or null` |
62
62
  | Select | `{ label: string, id: string }` |
63
- | Linked Record (via useRecord/useRecords) | `{ label: string, id: string }` |
63
+ | Linked Record (via useRecord/useRecords) | `{ label: string, id: string }` — usually an array of these, but a link can arrive as a **single object** (verified live 2026-09-18); normalise with `Array.isArray(v) ? v : (v ? [v] : [])` |
64
64
  | Linked Record (via useLinkedRecords) | `{ id: string, title: string }` -- different! |
65
65
  | User, Created By, Updated By | `{ avatarUrl, id, name, email }` |
66
66
  | Attachment | `{ filename, id, type, url }` |
@@ -64,6 +64,97 @@ var ds = datasource.define({ people: "74d2cbfd-…" });
64
64
  The error text is explicit, so this one fails fast rather than silently — but it's an easy
65
65
  reflex to hoist "magic strings" into named constants, and that reflex is wrong here.
66
66
 
67
+ ## `select:` must be a plain module-scope identifier
68
+
69
+ *Verified live 2026-09-18 (Softr Database; probe block + network capture in a draft preview).*
70
+
71
+ With more than one connection, Softr has to attribute every `q.select` to the connection it is
72
+ used with. The observed behaviour says it does that statically, from the identifier you pass as
73
+ `select:` (the mechanism is inferred; the outcome below is what was captured). An expression
74
+ breaks the attribution:
75
+
76
+ ```jsx
77
+ // WRONG — compiles, runs, and the query returns records with `fields: {}`. No error.
78
+ var order = useRecord({ from: ds.orders, select: isAdmin ? adminSelect : publicSelect, recordId: id });
79
+
80
+ // CORRECT — one module-scope identifier per hook
81
+ var orderSelect = q.select({ title: "FIELD_ID1", status: "FIELD_ID2" });
82
+ var order = useRecord({ from: ds.orders, select: orderSelect, recordId: id });
83
+ ```
84
+
85
+ Treat an inline `q.select({...})` written inside the hook options the same way: hoist it to
86
+ module scope and pass the identifier (the pattern at the top of this file already does). The
87
+ same goes for a mutation hook's `fields:`.
88
+
89
+ In a **single-datasource** block the same ternary *works* — there is nothing to attribute — but
90
+ it behaves as a **union** of both branches, not a choice between them. Which is the next rule.
91
+
92
+ ## One connection = one read payload (the union of its selects)
93
+
94
+ *Verified live 2026-09-18.*
95
+
96
+ The records endpoint is per block + connection —
97
+ `/blocks/<blockId>/datasources/<dataSourceId>/records` — and it returns the **UNION of every field
98
+ named by any READ `q.select` attributed to that connection**. Two selects on one connection do
99
+ NOT produce two payloads: every read hook on that connection gets all the fields, for every
100
+ viewer.
101
+
102
+ **So "request the private field only for admins" is not privacy.** A second select, or a ternary
103
+ between a public and an admin select, still ships the private field to every browser that loads
104
+ the block — it is simply not rendered. Anyone can read it in the network tab.
105
+
106
+ What does *not* join the union: a mutation hook's `fields:` select. Write-only fields stay out of
107
+ the read payload.
108
+
109
+ **Remedy.** Connect the **same table a second time** — Softr allows it, and the second connection
110
+ gets its own `dataSourceId` — and read the private field only through that connection, from a
111
+ hook that non-privileged browsers never run:
112
+
113
+ ```jsx
114
+ var ds = datasource.define({
115
+ orders: "11111111-…", // everyone: public fields only
116
+ ordersAdmin: "22222222-…", // same table, second connection: the private fields
117
+ });
118
+
119
+ var orderSelect = q.select({ title: "FIELD_ID1", status: "FIELD_ID2" });
120
+ var orderAdminSelect = q.select({ internalNotes: "FIELD_ID9" });
121
+
122
+ // Mounted by Block() ONLY when the viewer is an admin — so a non-admin browser never
123
+ // issues the request. (`useRecord` honours `enabled: false`; `useRecords` does NOT —
124
+ // see reading.md — which is why the gate is the mount, not an option.)
125
+ function AdminNotes({ recordId }) {
126
+ var admin = useRecord({ from: ds.ordersAdmin, select: orderAdminSelect, recordId: recordId, enabled: !!recordId });
127
+ // …
128
+ }
129
+ ```
130
+
131
+ Or put the private field in a separate block whose visibility is group-gated.
132
+
133
+ Know what this buys you. Not *rendering* the hook keeps the field out of ordinary browsers, but
134
+ the endpoint still exists: page VIEW permission is enforced on it (a viewer who cannot view the
135
+ page gets a 403), yet on a page any logged-in user may view, **every connected datasource is
136
+ readable by any logged-in user who crafts the request**. A connection's **Source conditions are
137
+ the only server-side ROW gate**; the only server-side gate on the *field* is a page or block the
138
+ viewer cannot see. See [../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
139
+
140
+ Alias → field attribution is per connection, so two selects on different connections may reuse
141
+ an alias name (`customer` on both) without colliding — including in `where` filters.
142
+
143
+ ## Mutation Actions register per TABLE, not per connection
144
+
145
+ *Verified live 2026-09-18.*
146
+
147
+ The second connection above is for **reads**. Actions are filed per table:
148
+
149
+ - Several `useRecordUpdate` hooks on one table merge into **ONE `UPDATE_RECORD` action** whose
150
+ field list is the union of their `fields:` selects.
151
+ - A hook pointed at the *second* connection of a table (`from: ds.ordersAdmin`) was still filed
152
+ under the **first** connection's `dataSourceId`.
153
+
154
+ So **point every write at the table's first connection**, and expect one action per table and
155
+ operation in `get_vibe_coding_block_settings` / the Actions tab — that is the row you re-tighten
156
+ after each push. Details in [writing.md](writing.md#actions-register-per-table-not-per-hook-or-connection).
157
+
67
158
  ## Getting the datasource ids — ask for CODE, never for a value
68
159
 
69
160
  The id is a plain **UUID**. It is *not* the underlying table id (`tbl…` in Airtable), and not
@@ -5,11 +5,11 @@ Fetching, filtering, sorting, pagination, metrics, charts, and current user.
5
5
  ## Table of Contents
6
6
 
7
7
  - [Query Builder](#query-builder)
8
- - [useRecords -- Fetch a Paginated List](#userecords----fetch-a-paginated-list)
9
- - [useRecord -- Fetch a Single Record](#userecord----fetch-a-single-record)
8
+ - [useRecords -- Fetch a Paginated List](#userecords----fetch-a-paginated-list) — incl. [`enabled: false` is ignored](#userecords-ignores-enabled-false)
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)
12
+ - [Filtering](#filtering) — incl. [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
13
13
  - [Sorting](#sorting)
14
14
  - [Current User](#current-user)
15
15
  - [Metrics](#metrics)
@@ -29,6 +29,15 @@ var select = q.select({
29
29
  });
30
30
  ```
31
31
 
32
+ **Declare every `q.select` at module scope and pass it by identifier** (verified live 2026-09-18).
33
+ In a multi-datasource block a `select:` that is a ternary (`select: a ? X : Y`) cannot be
34
+ attributed to a connection and the query returns `fields: {}` with no error; treat an inline
35
+ `q.select({...})` inside hook options the same way and hoist it. And a connection's read payload
36
+ is the **union** of every read select on it — a second or conditional select never narrows what
37
+ the browser receives, so it is not a privacy tool. Both rules, with the remedy, in
38
+ [multi-datasource.md](multi-datasource.md#select-must-be-a-plain-module-scope-identifier).
39
+ (The short inline `q.select` snippets below are single-datasource illustrations.)
40
+
32
41
  ## useRecords -- Fetch a Paginated List
33
42
 
34
43
  ```jsx
@@ -39,7 +48,7 @@ var result = useRecords({
39
48
  count: 6, // records per page (default 6, max 100)
40
49
  where: q.text("name").contains("Alice"), // optional filter
41
50
  orderBy: q.desc("createdAt"), // optional sort
42
- enabled: true, // optional, defer loading
51
+ enabled: true, // accepted, but `false` is IGNORED — see below
43
52
  });
44
53
 
45
54
  var data = result.data;
@@ -66,6 +75,33 @@ objects.
66
75
 
67
76
  A block can connect to **several data sources** and call `useRecords` once per source — declare them with `datasource.define()` and pass `from:` on every hook. See [multi-datasource.md](multi-datasource.md). (This replaces the old one-table-per-block limit; blocks no longer need an invisible helper block just to read a second table.)
68
77
 
78
+ ### `useRecords` ignores `enabled: false`
79
+
80
+ *Verified live 2026-09-18 (network capture).* `useRecords({ ..., enabled: false })` **fetches
81
+ anyway** — with a literal `false` and with a variable alike. `useRecord` is different: it honours
82
+ `enabled: false` and issues no request. (`useLinkedRecords`, `useMetric` and `useChartData` were
83
+ not probed — don't assume either behaviour for them.)
84
+
85
+ So `enabled` cannot make a list query conditional, and it cannot keep a query away from viewers
86
+ who should not run it. Two things that do work:
87
+
88
+ ```jsx
89
+ // 1. Gate by MOUNT — put the hook in a child component and render it only when needed.
90
+ function CommentsSection({ orderId }) {
91
+ var comments = useRecords({ from: ds.comments, select: commentSelect, count: 50,
92
+ where: q.array("order").hasAllOf([orderId]) });
93
+ // …
94
+ }
95
+ // in Block(): {canSeeComments && <CommentsSection orderId={recordId} />}
96
+
97
+ // 2. Keep the hook mounted but give it a match-nothing `where` until it should load.
98
+ var rows = useRecords({ select: select, count: 50,
99
+ where: q.text("email").is(email || "__no_match__") });
100
+ ```
101
+
102
+ Option 1 is the only one that sends no request at all; option 2 still calls the endpoint and gets
103
+ zero rows back. Remember the child must be defined at **module scope** (SKILL.md Self-validate).
104
+
69
105
  ### Loading All Records (Auto-Pagination)
70
106
 
71
107
  ```jsx
@@ -85,14 +121,45 @@ useEffect(function() {
85
121
  ```jsx
86
122
  import { useRecord, useCurrentRecordId, q } from "@/lib/datasource";
87
123
 
88
- var recordId = useCurrentRecordId(); // resolves from URL context, can be null
124
+ var detailSelect = q.select({ title: "FIELD_ID1", description: "FIELD_ID2" });
125
+
126
+ var recordId = useCurrentRecordId(); // the URL's `recordId` param — can be null
89
127
  var result = useRecord({
90
- select: q.select({ title: "FIELD_ID1", description: "FIELD_ID2" }),
128
+ select: detailSelect,
91
129
  recordId: recordId,
130
+ enabled: !!recordId, // honoured by useRecord: no id → no request
92
131
  });
132
+
133
+ var record = result.data && result.data.id === recordId ? result.data : null; // trust only a matching id
93
134
  ```
94
135
 
95
- **`recordId` can be omitted when Softr Studio supplies the record context.** `useRecord({ select })` with no `recordId` loads the record the block is bound to via its data-source binding in Studio — verified by deployed block, July 2026 (an Airtable-backed stats block rendered live values this way). Keep `useCurrentRecordId()` + explicit `recordId` as the pattern for URL-driven detail pages (`/page?recordId=...`). When editing an existing **working** block that already omits `recordId`, leave the call shape as-is: adding an explicit `recordId` from `useCurrentRecordId()` can change behavior on pages whose URL carries no `recordId` param. Corollary for reviews: a recordId-less `useRecord` is NOT by itself a defect — check whether the block is deployed and loading data before flagging it.
136
+ **There is NO detail-page auto-scoping (verified live 2026-09-18, Softr Database, network
137
+ capture).** The runtime sends `pageContext: null` with the block's data requests — nothing tells
138
+ the server which record the page is "about". Consequences:
139
+
140
+ - `useRecords({ count: 1 })` on a detail page returns the **FIRST row of the table**, not the
141
+ URL's record. It looks right on the first record you test and wrong on every other.
142
+ - `useCurrentRecordId()` **does** return the URL's `recordId`, and
143
+ `useRecord({ from, select, recordId })` fetches exactly that record (it hits `/records/<id>`).
144
+ That is the detail-page pattern — the only one.
145
+ - `useRecord` with a **null / missing id falls back to a list call** and hands back whatever that
146
+ returns. So always pass `enabled: !!recordId` (`useRecord` honours `enabled: false` — no
147
+ request is made) and verify `data.id === recordId` before rendering or, worse, writing.
148
+
149
+ **A recordId-less `useRecord` — what the older note here meant, and its limits.** This file used
150
+ to say that `useRecord({ select })` with no `recordId` "loads the record the block is bound to
151
+ via its data-source binding in Studio" (seen on one deployed Airtable-backed stats block, July
152
+ 2026, which rendered live values that way). Read that in the light of the capture above: no
153
+ record context is sent, and a null-id `useRecord` falls back to a list call — so the likeliest
154
+ explanation of the July block is that it was showing the list fallback's row, which is the
155
+ "right" record only when the connection's Source conditions/sort leave exactly that row first.
156
+ (That reading is an inference: the Airtable block was not re-probed, and the 2026-09-18 capture
157
+ was on Softr Database.) Either way it is not a binding you can rely on, and never the way to
158
+ build a detail page. The review corollary
159
+ survives in a narrower form: a recordId-less `useRecord` in a **working, deployed** block is not
160
+ by itself proof of a defect — check what it actually loads (and for which viewers) before
161
+ flagging it, and when editing such a block, know that adding an explicit `recordId` changes what
162
+ it loads on pages whose URL carries no `recordId` param.
96
163
 
97
164
  ## useLinkedRecords -- Fetch Linked/Related Options
98
165
 
@@ -189,6 +256,39 @@ where: q.and(
189
256
  )
190
257
  ```
191
258
 
259
+ ### Filtering by a linked record (server-side)
260
+
261
+ *Verified live 2026-09-18 (Softr Database, network capture).* A linked-record field filters on
262
+ the server with the array builder and the linked record's id — no need to load the whole child
263
+ table and filter client-side:
264
+
265
+ ```jsx
266
+ var commentSelect = q.select({ order: "LINK_FIELD_ID", body: "FIELD_ID2" });
267
+
268
+ var comments = useRecords({
269
+ from: ds.comments,
270
+ select: commentSelect,
271
+ count: 50,
272
+ where: q.array("order").hasAllOf([orderId]), // "order" = the link field's ALIAS
273
+ });
274
+ ```
275
+
276
+ On the wire the alias is resolved to the field id:
277
+ `{ subject: <fieldId>, type: "ARRAY", operator: "HAS_ALL_OF", value: [orderId] }`. Alias → field
278
+ attribution is **per datasource**, so two selects on different connections may use the same alias
279
+ name for different fields and each filter still resolves against its own connection.
280
+
281
+ `orderId` must be a real id when the hook runs — `useRecords` cannot be switched off with
282
+ `enabled: false` ([above](#userecords-ignores-enabled-false)), so mount this query in a child
283
+ component that only renders once the parent record has loaded.
284
+
285
+ **Reading the link back:** a linked field can arrive as a **single `{ id, label }` object**, not
286
+ only as an array of them. Normalise before you `.map()` or compare ids:
287
+
288
+ ```jsx
289
+ var links = Array.isArray(v) ? v : (v ? [v] : []);
290
+ ```
291
+
192
292
  ## Sorting
193
293
 
194
294
  ```jsx
@@ -31,6 +31,29 @@ implication: do the Actions-tab tightening pass only AFTER the last redeploy of
31
31
  re-check every tightened block after any future redeploy. Hit across a 15-block production
32
32
  deployment; treat it as standing platform behavior, not a one-off.
33
33
 
34
+ ### Actions register per TABLE, not per hook or connection
35
+
36
+ *Verified live 2026-09-18 (Softr Database; probe block + network capture in a draft preview).*
37
+
38
+ - **Several `useRecordUpdate` hooks on one table merge into ONE `UPDATE_RECORD` action** whose
39
+ field list is the UNION of all their `fields:` selects. Splitting a table's writes across hooks
40
+ ("one hook for the status, one for the admin-only fields") does not produce separately
41
+ permissionable actions — there is one action, and one visibility setting, per table and operation.
42
+ (What follows from that, deduced rather than separately tested: two user groups needing
43
+ different write rights on the same table cannot be expressed inside one block — use a second,
44
+ group-gated block, or a Softr Workflow that does the privileged write.)
45
+ - **When the same table is connected twice** (the private-field pattern in
46
+ [multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)),
47
+ a mutation hook pointed at the SECOND connection was still filed under the FIRST connection's
48
+ `dataSourceId`. **Point writes at the table's first connection** and keep the second one
49
+ read-only, so the code says what the platform does.
50
+ - A mutation hook's `fields:` select does **not** join the connection's read union — write-only
51
+ fields are not shipped to the browser by the records endpoint.
52
+
53
+ Practical upshot for the post-push permission pass (see
54
+ [softr-mcp.md](../references/softr-mcp.md#the-array-argument-rejection-and-why-it-is-a-security-issue)):
55
+ expect one row per table + operation, and re-tighten that row.
56
+
34
57
  The `enabled` boolean on a mutation hook is a combined signal — it's `true` only when BOTH conditions are met:
35
58
 
36
59
  1. **The Action was successfully derived from the code** (parser side). Causes of failure here:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.8.2",
3
+ "version": "2.9.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,6 +19,10 @@ 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/<dsId>/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 gates the endpoint 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
+ | `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
+ | `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
+ | `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) |
22
26
 
23
27
  ## Mutations
24
28
 
@@ -40,6 +44,7 @@ Run through this catalog before delivering any block. Every row is a violation o
40
44
  | Tightening Actions-tab permissions before the block's final redeploy | Every code recompile **resets the auto-registered Actions to default permissions** (verified live 2026-08-25). Tighten permissions after the LAST redeploy, and re-check after any future one |
41
45
  | Assuming a comment-only edit is "safe" and leaves Action permissions alone | There is no cosmetic-edit exemption. Any save recompiles, and every recompile rebuilds the Actions at default visibility — a `search_replace` changing nothing but a code comment resets them exactly like a rewrite (verified live 2026-09-09, on two blocks at once). Re-check after EVERY push, including cosmetic ones |
42
46
  | Treating the permission-restore call as done because you issued it | Read the permissions back with `get_vibe_coding_block_settings` and confirm each one changed. `set_vibe_coding_block_action_visibility` can fail outright on the array-argument serialization quirk, and it has **no fallback** — the default for a `genericActions` ADD_RECORD is `ALL_USERS`, so a routine push silently leaves the block publicly writable while returning `errors: null`. Verified live 2026-09-09: four ADD_RECORD actions left open across two blocks. Report the list with its severity — check the page's VIEW permission with `get_page_permissions`, since a logged-in-gated page makes this housekeeping while a public page makes it a real hole — and let the builder decide whether it holds their release. A human sets them on the block's Actions tab |
47
+ | Splitting one table's writes across several `useRecordUpdate` hooks (or across two connections of the same table) to get separately-permissioned Actions | Actions register per **TABLE**: the hooks merge into ONE UPDATE_RECORD action whose field list is the union, and a hook pointed at a second connection of the table is still filed under the FIRST connection's dataSourceId (verified live 2026-09-18). Point writes at the table's first connection; expect one action per table + operation when re-tightening permissions. See [datasources/writing.md](../datasources/writing.md#actions-register-per-table-not-per-hook-or-connection) |
43
48
  | Treating Studio's Actions tab as a separately-managed configuration to keep in sync with code | Actions auto-derive from your `useRecordCreate`/`useRecordUpdate`/`useRecordDelete` + `q.select` on every save. The Actions tab is a read-only inspector; there is no manual delete control. To change an Action, change the code |
44
49
  | One alias in a write-side `q.select` referencing a renamed / non-existent Airtable column | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Symptoms: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve immediately to "not yet ready". Every OTHER field in the same `q.select()` is also lost, even the ones that map cleanly. Diagnostic: bisect the `q.select` — strip down to a known-good minimal set, confirm the Action appears in Studio, then add fields back in halves until it drops out. The culprit is in the last half added. Once narrowed to a single field, grep its name against the freshest Airtable schema export to catch the rename / trailing-space / case-mismatch. Verified 2026-05-21: a `"Photos"` column on Wigs was renamed to `"Before Photos"`, the helper that wrote `photos: "Photos"` had its entire Action disabled even though 11 other fields in the same `q.select` were fine. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) |
45
50
 
@@ -82,11 +82,12 @@ useRecords({
82
82
  ## Single Record (detail pages)
83
83
 
84
84
  ```jsx
85
- var recordId = useCurrentRecordId();
86
- var result = useRecord({ recordId: recordId, select: select });
85
+ var recordId = useCurrentRecordId(); // the URL's recordId — can be null
86
+ var result = useRecord({ recordId: recordId, select: select, enabled: !!recordId });
87
+ var record = result.data && result.data.id === recordId ? result.data : null;
87
88
  ```
88
89
 
89
- `recordId` may be omitted when the block's Studio data binding supplies the record context (verified by deployed block, July 2026) — see [reading.md](../datasources/reading.md#userecord----fetch-a-single-record).
90
+ There is **no detail-page auto-scoping** (verified live 2026-09-18): `useRecords({ count: 1 })` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call — hence `enabled: !!recordId` (honoured by `useRecord`; **ignored by `useRecords`**) and the `data.id` check. The older "recordId may be omitted when Studio supplies the record context" note is qualified in [reading.md](../datasources/reading.md#userecord----fetch-a-single-record).
90
91
 
91
92
  ## Current User
92
93
 
@@ -13,10 +13,10 @@ The official Softr MCP server (`https://mcp.softr.io/mcp`) gives an AI assistant
13
13
  - [What it covers](#what-it-covers)
14
14
  - [Connection and auth](#connection-and-auth)
15
15
  - [Permissions model](#permissions-model)
16
- - [Vibe coding block tools](#vibe-coding-block-tools)
16
+ - [Vibe coding block tools](#vibe-coding-block-tools) — incl. [what the server enforces on a block's data endpoints](#what-the-server-enforces-on-a-blocks-data-endpoints)
17
17
  - [Adopting Studio-AI-generated code](#adopting-studio-ai-generated-code)
18
18
  - [Vibe coding gotchas (official)](#vibe-coding-gotchas-official)
19
- - [Application management tools](#application-management-tools)
19
+ - [Application management tools](#application-management-tools) — incl. [testing as any user via "Preview as"](#testing-as-any-app-user-without-logins--the-preview-as-switcher)
20
20
  - [Browsing integrations (external data sources)](#browsing-integrations-external-data-sources)
21
21
  - [Softr Database tools](#softr-database-tools)
22
22
  - [Workflows](#workflows)
@@ -96,6 +96,23 @@ not name, so **each block keeps its own pair and the swap step disappears entire
96
96
  2026-09-09 across a report block deployed to two pages). It is also the safer option on large files:
97
97
  retransmitting ~100KB verbatim to change one class string is its own corruption risk.
98
98
 
99
+ **Search-replace on a 100KB+ block — the working recipe (verified live 2026-09-18).** Sent a real
100
+ array of `{ search, replace }` objects (not a JSON string — see
101
+ [the array-argument rejection](#the-array-argument-rejection-and-why-it-is-a-security-issue)), the
102
+ tool patches large blocks reliably, and nothing but the fragments passes through the model's
103
+ context. Keep the local mirror in step mechanically rather than by hand:
104
+
105
+ 1. Prove deployed == disk first ([below](#verifying-a-push--the-deployed-source-is-the-only-proof)).
106
+ 2. Write the ops once, as data. Send them to the tool, and apply the **identical** ops to the local
107
+ mirror with a script that asserts each `search` occurs exactly once before replacing it.
108
+ 3. Several rounds of ops are fine — **byte-verify once at the end**: fetch `sourceCode`, compare to
109
+ the mirror, and a mismatch means an op landed differently on one side.
110
+
111
+ One encoding trap: JSON `\uXXXX` escapes inside the ops are **decoded to the real characters** on
112
+ Softr's side (`"—"` is stored as `—`). The mirror must therefore hold raw UTF-8 — apply the
113
+ ops to it *after* JSON-decoding them, never as the escaped text, or the final byte comparison
114
+ fails on every non-ASCII character.
115
+
99
116
  **Reach for the full replace when the change is structural** — reordering JSX, moving logic between
100
117
  components, adding a hook — where being sure of "the exact current text" of a dozen scattered fragments
101
118
  is harder than being sure of the whole file. Also use it when the local file is the source of truth and
@@ -121,7 +138,9 @@ unverified until you have pulled the source back down and compared it.
121
138
  --log-level=error --outfile=/dev/null`) plus eslint with `@babel/eslint-parser`. The bugs that
122
139
  actually bite Softr blocks are semantic — `useRecordUpdate({ select: … })` instead of `fields:`,
123
140
  an invented identifier — and the push is the first thing that reports them.
124
- 3. Push the **entire** file.
141
+ 3. Push the **entire** file — or, for a targeted patch on a large block, send search-replace ops
142
+ and apply the identical ops to the mirror
143
+ ([recipe above](#which-edit-tool-full-replace-vs-targeted-search-replace)).
125
144
  4. **Fetch it back and compare again.** Identical, or you are not done: diff, fix, re-push.
126
145
 
127
146
  **Compare byte for byte, trailing newline included.** Softr stores exactly what it receives: across
@@ -140,7 +159,7 @@ expectation (disk for the first, disk-with-swap for the second). Never save the
140
159
  the local mirror — the mirror records which page it belongs to, and the block's header comment
141
160
  records the other page's pair. Search-replace would avoid the swap altogether
142
161
  ([above](#which-edit-tool-full-replace-vs-targeted-search-replace)) — when the client can send its
143
- array argument ([below](#the-array-argument-serialization-quirk-and-why-it-is-a-security-issue)).
162
+ array argument ([below](#the-array-argument-rejection-and-why-it-is-a-security-issue)).
144
163
 
145
164
  **Do not read a 100KB block into a model's context to push it.** The full-replace tool takes the
146
165
  whole file as a string parameter, so the source has to pass through whatever is making the call. A
@@ -211,10 +230,15 @@ one push left four ADD_RECORD actions open across two blocks.
211
230
  block the publish.** It is not your app, and the person whose app it is needs the finding and the
212
231
  severity, not a veto.
213
232
 
214
- One caveat worth stating: page visibility and action permissions are *separate* gates, and whether
215
- Softr enforces page VIEW on the action endpoint itself is unverified here. The reason to treat a
216
- gated page as low-severity is the practical difficulty and low blast radius, not a proof that the
217
- action is unreachable. Say that plainly rather than implying the action is safe.
233
+ One caveat worth stating: page visibility and action permissions are *separate* gates. Page VIEW
234
+ **is** enforced on the block's datasource **records** endpoint (verified live 2026-09-18 — a
235
+ viewer who cannot view the page gets a 403 whose message names "block/action visibility rules";
236
+ see [below](#what-the-server-enforces-on-a-blocks-data-endpoints)). The *action* (write) endpoint
237
+ was not exercised separately; the message wording suggests the same gate covers it, but that part
238
+ is inference. So the reason to treat a gated page as low-severity is still the practical
239
+ difficulty and low blast radius — and note that "gated to logged-in users" keeps out anonymous
240
+ visitors only: any logged-in user can view that page, and therefore reach its endpoints. Say that
241
+ plainly rather than implying the action is safe.
218
242
 
219
243
  **Calibration matters.** This guidance read "do not publish" in v2.5.1 and immediately fired at
220
244
  maximum severity on a logged-in-gated app where the real exposure was junk records. A warning that
@@ -228,6 +252,34 @@ reverts the code along with the permissions, undoing the change you just pushed.
228
252
 
229
253
  Both edit paths recompile, so both reset Action permissions either way (Hard Constraint 21).
230
254
 
255
+ ### What the server enforces on a block's data endpoints
256
+
257
+ *Verified live 2026-09-18 (Softr Database; draft preview, "Preview as" different users, requests
258
+ captured from the app iframe).* A block's data lives behind per-connection endpoints —
259
+ `/blocks/<blockId>/datasources/<dataSourceId>/records` for lists, `/records/<id>` for one record —
260
+ and these are the gates that actually exist on them:
261
+
262
+ | Gate | Enforced server-side? |
263
+ |---|---|
264
+ | **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 |
265
+ | **The connection's Source conditions** (Source tab / `set_vibe_coding_block_data_source_record_filters`) | **Yes — and they are the only server-side ROW gate** |
266
+ | A `where` filter in the block's code | No — it is a request parameter the caller controls |
267
+ | 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)) |
268
+
269
+ The consequence to design around: **on a page any logged-in user may view, every datasource
270
+ connected to its blocks is readable by any logged-in user who crafts the request** — all rows the
271
+ Source conditions allow, all fields the block's read selects name. A per-record access check in
272
+ React ("is this viewer a party to this record?") shapes the UI; it is not access control. When rows
273
+ must be private per user, put it in the Source conditions (e.g. a logged-in-user condition) or on a
274
+ page only the right group can view. When a field must be private, a second connection of the table
275
+ keeps it out of every ordinary browser's payload — but not away from a crafted request by someone
276
+ who may view the page; for that it has to live on a page (or in a group-gated block) the viewer
277
+ cannot see. (The verified 403 case was page VIEW; the message's "block/action visibility rules"
278
+ wording suggests block visibility is checked the same way, which is inference.)
279
+
280
+ This is also what makes the open-`ADD_RECORD` finding above severity-dependent on the page's VIEW
281
+ permission rather than uniformly critical.
282
+
231
283
  ## Adopting Studio-AI-generated code
232
284
 
233
285
  When you pull a Studio-AI-generated block via `get_vibe_coding_block_code` to adopt into a project repo as source of truth: its output renders fine but ships with predictable defects. **Functional patterns in Studio output are platform-support evidence** (it surfaces undocumented capabilities before the docs do — see SKILL.md's "Platform truth sources"); **its code hygiene is not a pattern to imitate.** Cleanup pass before committing:
@@ -268,6 +320,37 @@ Combined with the database tools (`create_database` / `create_table` / `create_f
268
320
 
269
321
  > **preview_app links are auth tokens.** Per the server's own instructions, a preview link **signs its opener in as the user who requested it** and lasts about a day. Give it only to that user, and mint a fresh one with another `preview_app` call rather than re-sending an old link. Never paste a preview link into a shared channel.
270
322
 
323
+ ### Testing as any app user without logins — the "Preview as" switcher
324
+
325
+ *Verified live 2026-09-18.* The `preview_app` link does not open the app directly: it opens a
326
+ **toolbar shell** with a **"Preview as" user switcher**, and runs the draft app in an **iframe**
327
+ whose URL carries `?autoUser=true`. That is a complete role-testing rig — every user group, no
328
+ passwords, no test accounts to create:
329
+
330
+ - The switcher is a **Choices.js** select listing the app's users. Picking one raises a
331
+ confirmation modal ("Ok, I understand"); after confirming, the app runs as that user, and the
332
+ choice persists per browser.
333
+ - **With the Browser pane visible**, click through it like a person would.
334
+ - **With the pane hidden, drive it by script** in the shell page: dispatch `mouseover` then
335
+ `mousedown` on `.choices__item--choice[data-value="<email>"]` (Choices.js acts on
336
+ `mousedown`, not `click`), then click
337
+ `#userConfirmationModal button.submit-btn`.
338
+ - **To see what a block really sends and receives**, set `iframe.src` to the page under test and
339
+ wrap `iframe.contentWindow.fetch` immediately afterwards, recording each request body and
340
+ response. This is how the union-of-selects, `pageContext: null` and `enabled: false` findings in
341
+ [../datasources/](../datasources/) were established — read the wire, not the rendered UI.
342
+ - Blocks render in shadow roots inside that iframe: read the DOM through
343
+ `iframe.contentDocument` and each block host's `shadowRoot`, not `document.querySelector`.
344
+
345
+ > **The preview is wired to the LIVE datasource.** Anything clicked there — a Save, a status
346
+ > change, a form submit — writes real records, as the previewed user. Keep preview sessions to
347
+ > read-only checks unless the record is a marked test record, and never run a write path "just to
348
+ > see" against client data.
349
+
350
+ Limits: a page gated to a user group nobody belongs to cannot be previewed until someone is in the
351
+ group, and these selectors are Softr's shell internals — observed, not documented, so re-inspect
352
+ the shell if a selector stops matching rather than assuming the feature is gone.
353
+
271
354
  ## Browsing integrations (external data sources)
272
355
 
273
356
  An integration is an external data source connected once per workspace (the builder says "integrations", the tools say "data sources" — same thing). Five read-only tools drill down from workspace to fields; each level needs an ID from the level above: