softr-vibe-coding 1.11.2 → 2.1.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.
@@ -56,7 +56,15 @@ var isRefetching = result.isRefetching;
56
56
  var items = (data && data.pages) ? data.pages.flatMap(function(p) { return p.items; }) : [];
57
57
  ```
58
58
 
59
- **CRITICAL:** Only ONE `useRecords` call per block. Fetch all data in one call and filter client-side. Multiple `useMetric` calls ARE allowed.
59
+ **CRITICAL:** Only ONE `useRecords` call **per datasource**. Fetch that table's data in one call and filter client-side. Multiple `useMetric` calls ARE allowed.
60
+
61
+ **CRITICAL:** The options object must be an **inline literal** at the call site. Passing it
62
+ through a variable or a wrapper function (`useRecords(buildOpts())`) **fails to compile** —
63
+ verified live 2026-08-25, hit in a production block; the fix was changing the wrapper to take
64
+ the hook's *result* instead. Share `q.select` mappings between hooks, never whole options
65
+ objects.
66
+
67
+ 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.)
60
68
 
61
69
  ### Loading All Records (Auto-Pagination)
62
70
 
@@ -97,7 +105,7 @@ var result = useLinkedRecords({
97
105
  sortOrder: "ASC", // "ASC" | "DESC"
98
106
  search: "", // optional search string
99
107
  enabled: true, // defer loading until needed
100
- count: 50,
108
+ count: 50, // optional page size — default 100, max 1000
101
109
  });
102
110
 
103
111
  var options = (result.data && result.data.pages) ? result.data.pages.flatMap(function(p) { return p.items; }) : [];
@@ -126,8 +134,9 @@ var statusOptions = useFieldOptions({
126
134
 
127
135
  // statusOptions → { options: [...], isLoading: bool }
128
136
  // statusOptions.options → [{ id: "sel...", label: "Active", color: "greenLight1" }, ...]
129
- // id — the option's UUID, used in mutate payloads
130
- // label display string
137
+ // id — stable option id (use as a React key; NOT needed in mutate payloads — SELECT
138
+ // fields write by LABEL string on the current platform, verified 2026-08-25)
139
+ // label — display string AND the value to write in mutate payloads
131
140
  // color — Airtable swatch color name (optional; handy for tinting chips)
132
141
  ```
133
142
 
@@ -143,8 +152,8 @@ same `select` object can be shared by both. Reuse one `select` for many fields a
143
152
 
144
153
  **When to use this vs. hardcoding:**
145
154
 
146
- - **Use `useFieldOptions`** when option IDs / labels could change post-deploy — selects with rapidly-evolving lists, user-editable choices, or any case where re-pasting blocks for an option rename is annoying. Cross-table case: to render a select field from table B inside a block bound to table A (e.g. an intake form bound to Jobs that needs the Wigs `Color` options), put the `useRecords` + `useFieldOptions` in a hidden helper block bound to table B and publish the options to a `window` global (see [helper-blocks.md](../references/helper-blocks.md)).
147
- - **Hardcode** when the option set is stable and frequently referenced (e.g. a status enum that drives a state machine), so the IDs live in source and rename-safety is enforced by greppable constants. A robust middle ground: prefer the live options, fall back to a hardcoded list per field so the UI still renders if the helper hasn't published yet.
155
+ - **Use `useFieldOptions`** when option labels could change post-deploy — selects with rapidly-evolving lists, user-editable choices, or any case where re-pasting blocks for an option rename is annoying. Since SELECT fields write by label (verified 2026-08-25), live options also keep write payloads rename-proof: render and write `option.label`. Hardcoded labels remain fine as a display-only loading fallback while the live options fetch. Cross-table case: to render a select field from table B inside a block bound to table A (e.g. an intake form bound to Jobs that needs the Wigs `Color` options), put the `useRecords` + `useFieldOptions` in a hidden helper block bound to table B and publish the options to a `window` global (see [helper-blocks.md](../references/helper-blocks.md)).
156
+ - **Hardcode** when the option set is stable and frequently referenced (e.g. a status enum that drives a state machine), so the label vocabulary lives in source and rename-safety is enforced by greppable constants. A robust middle ground: prefer the live options, fall back to a hardcoded list per field so the UI still renders if the helper hasn't published yet.
148
157
 
149
158
  `useFieldOptions` is the read-side equivalent of using `useLinkedRecords` for foreign records — it abstracts away the field's option store. Items are shaped `{ id, label, color }` (note: `label`, not `title` like `useLinkedRecords`).
150
159
 
@@ -196,9 +205,22 @@ import { useCurrentUser } from "@/lib/user";
196
205
  var user = useCurrentUser();
197
206
  // Returns null if not logged in
198
207
  // Fields: { id, fullName, firstName, lastName, email, avatar } (all string or null)
208
+ // Note: `id` is only present when user sync is enabled.
209
+ ```
210
+
211
+ **Custom user-record fields** are first-class: pass a `properties` map (aliased like a `select` query) and read them under `user.properties`:
212
+
213
+ ```jsx
214
+ var user = useCurrentUser({
215
+ properties: {
216
+ stripeId: "FIELD_ID1",
217
+ plan: "FIELD_ID2",
218
+ },
219
+ });
220
+ // user.properties.stripeId, user.properties.plan
199
221
  ```
200
222
 
201
- **For user groups, role, or custom fields** -- use `window.__softr_current_user` (NOT `useCurrentUser()`):
223
+ **For user groups / role ONLY** -- these are not exposed by `useCurrentUser()` (not even via `properties`); use `window.__softr_current_user`:
202
224
 
203
225
  ```jsx
204
226
  var softrUser = window.__softr_current_user || {};
@@ -100,6 +100,24 @@ export default function Block() {
100
100
  - Softr injects the authentication headers configured in the data source automatically
101
101
  - API keys are **never exposed** in client-side code
102
102
  - The response is the raw API JSON -- access fields directly (e.g., `item.name`, not `record.fields.name`)
103
+ - **The proxy only supports text payloads** -- streams, `FormData`, and file uploads won't work. Serialize request bodies as JSON/text.
104
+
105
+ ### Multiple datasources
106
+
107
+ When the block has more than one datasource, `useProxyFetch` needs to know which source to route through. Unlike the record hooks (which take a `from:` option), it takes the alias as its **function argument**:
108
+
109
+ ```jsx
110
+ import { datasource, useProxyFetch } from "@/lib/datasource";
111
+
112
+ var ds = datasource.define({
113
+ store: "ds_id_1", // the REST API datasource
114
+ orders: "ds_id_2", // e.g. a Softr DB table alongside it
115
+ });
116
+
117
+ var proxyFetch = useProxyFetch(ds.store); // alias as ARGUMENT, not from:
118
+ ```
119
+
120
+ With a single datasource `useProxyFetch()` works with no argument; once there's more than one, omitting the alias **throws**. Define the aliases with `datasource.define`, the same pattern the record hooks use — see [multi-datasource.md](multi-datasource.md).
103
121
 
104
122
  ### Dynamic Query Parameters
105
123
 
@@ -124,7 +142,7 @@ var result = useQuery({
124
142
 
125
143
  ### POST Requests
126
144
 
127
- `proxyFetch` accepts the same options as standard `fetch`:
145
+ `proxyFetch` accepts the same options as standard `fetch`, but bodies must be text (JSON strings etc.) — `FormData`, streams, and file uploads are not supported by the proxy:
128
146
 
129
147
  ```jsx
130
148
  var result = useQuery({
@@ -172,6 +190,7 @@ fetch("https://workflows-api.softr.io/v1/workflows/WORKFLOW_ID/executions/EXECUT
172
190
 
173
191
  - Supports GET and POST methods only in the data source connector (no PUT, PATCH, DELETE -- use Softr Action buttons with custom API call actions for those)
174
192
  - Response data must be parseable JSON
193
+ - **Proxy payloads are text-only** -- streams, `FormData`, and file uploads through `proxyFetch` fail. For file-to-record uploads use `useUpload` (see [writing.md](writing.md#file-uploads)), which targets the connected record datasource; uploading files to an arbitrary external API needs a different mechanism (e.g. a Softr workflow / webhook receiver)
175
194
  - No 2-way user sync
176
195
  - Requires Business or Enterprise plan
177
196
 
@@ -4,6 +4,7 @@ These patterns apply to all data sources that use `useRecords` + `q.select()` (e
4
4
 
5
5
  | Topic | Guide |
6
6
  |---|---|
7
+ | **Multiple data sources in one block** — `datasource.define()`, the `from:` parameter, getting the datasource UUIDs | [multi-datasource.md](multi-datasource.md) |
7
8
  | Query builder, useRecords, useRecord, useLinkedRecords, filtering, sorting, pagination, current user, metrics, chart data | [reading.md](reading.md) |
8
- | Record mutations (create/update/delete), file uploads, linked record format, cross-table REST API writes | [writing.md](writing.md) |
9
+ | Record mutations (create/update/delete), sequential multi-row write queues, file uploads, linked record format, cross-table writes | [writing.md](writing.md) |
9
10
  | `getFieldValue()` helper, field type shapes, record structure, debug utilities (Field Inspector, User Inspector) | [fields.md](fields.md) |
@@ -8,7 +8,7 @@ No setup needed. Softr Database is available by default in every Softr app. Crea
8
8
 
9
9
  ## AI-Assisted Workflows
10
10
 
11
- Softr publishes an official MCP server (`https://mcp.softr.io/mcp`) that lets the AI read Softr DB schema and field IDs directly — eliminating the manual "paste `tablespace-with-tables` JSON" step. For setup, scopes, the 20 tools, and the limitation that this only covers Softr DB (not external sources), see [../references/softr-database-mcp.md](../references/softr-database-mcp.md).
11
+ Softr publishes an official MCP server (`https://mcp.softr.io/mcp`) that lets the AI read Softr DB schema and field IDs directly — eliminating the manual "paste `tablespace-with-tables` JSON" step. The same server can also browse connected Airtable / Google Sheets / Notion / Supabase integrations, and can create and deploy Vibe Coding blocks. For setup, permissions, and the full tool catalog, see [../references/softr-mcp.md](../references/softr-mcp.md).
12
12
 
13
13
  ## Vibe Coding Field IDs
14
14
  Field IDs are short alphanumeric codes (e.g., `"xgETy"`, `"TLhWF"`). These codes are NOT human-readable names.
@@ -23,7 +23,7 @@ q.select({ name: "First Name" })
23
23
 
24
24
  Find field IDs in this order of preference:
25
25
 
26
- 1. **Softr Database MCP** (recommended when working with an AI assistant) — the AI calls schema/list-fields tools directly. See [../references/softr-database-mcp.md](../references/softr-database-mcp.md).
26
+ 1. **Softr MCP server** (recommended when working with an AI assistant) — the AI calls schema/list-fields tools directly. See [../references/softr-mcp.md](../references/softr-mcp.md).
27
27
  2. **`get-softr-database` CLI script (bundled)** — a Python CLI bundled with this skill at `~/.claude/skills/softr-vibe-coding/tools/get-softr-database.py`. Exports the full schema (every table, field, dropdown option UUID) to `~/Desktop/softr-database-<id>-<timestamp>.json`. Stdlib only, no `pip install`. See [Bundled CLI script](#bundled-cli-script-get-softr-database) below.
28
28
  3. **Network inspector** — DevTools -> Network -> filter `tablespace-with-tables` for the full schema including dropdown option UUIDs. Paste the JSON into chat to share with an AI when the MCP isn't installed.
29
29
  4. **Inline in Studio** — click a field's name in the Data tab; the ID appears in the field-edit drawer.
@@ -86,11 +86,11 @@ After `source ~/.zshrc`, just run `get-softr-database <database_id>` from anywhe
86
86
  |----------------|----------|-------|
87
87
  | Text | Yes | |
88
88
  | Number | Yes | |
89
- | Date | Yes | |
89
+ | Date | Yes | Date-semantics fields take `"yyyy-MM-dd"`; timestamp fields take `new Date().toISOString()` (verified 2026-08-25) |
90
90
  | File / Image | Yes | |
91
91
  | Checkbox | Yes | |
92
- | Dropdown | Yes | |
93
- | Relationship | Yes | Linked records to other Softr Database tables |
92
+ | Dropdown | Yes | Write the option **LABEL string**, exactly matching a defined choice (verified 2026-08-25; supersedes the old option-UUID rule). See [writing.md](writing.md#dropdown--single-select-softr-database) |
93
+ | Relationship | Yes | Linked records to other Softr Database tables. Write as an **array of record-id strings**, e.g. `[recordId]` (verified 2026-08-25) |
94
94
  | Formula | Read-only | Booleans return as strings: use `=== "1"` for true, `=== "0"` for false |
95
95
 
96
96
  ## Rate Limits
@@ -98,7 +98,7 @@ No API rate limits. Softr Database queries run internally without external API c
98
98
 
99
99
  ## Gotchas
100
100
  - **Formula boolean values are strings.** A formula that evaluates to true returns `"1"`, not `true`. Always compare with `=== "1"` or `=== "0"`.
101
- - **Field IDs are opaque codes.** You cannot guess them from column names. Use the Field Inspector block to find them.
101
+ - **Field IDs are opaque codes.** You cannot guess them from column names. Look them up via the ranked list above (MCP `list_fields` / bundled CLI / network inspector / Studio field drawer) — the generic Field Inspector block does NOT work for Softr Database.
102
102
  - **Relationships** work similarly to linked records in Airtable but use Softr's internal record IDs.
103
103
 
104
104
  ## Best For
@@ -1,11 +1,12 @@
1
1
  # Writing Data
2
2
 
3
- Record mutations, file uploads, linked record format, and cross-table operations.
3
+ Record mutations, sequential write queues, file uploads, linked record format, and cross-table operations.
4
4
 
5
5
  ## Table of Contents
6
6
 
7
7
  - [How Actions Work (Studio's Actions Tab)](#how-actions-work-studios-actions-tab)
8
8
  - [Record Mutations](#record-mutations)
9
+ - [Sequential Multi-Row Writes (mutateAsync)](#sequential-multi-row-writes-mutateasync)
9
10
  - [File Uploads](#file-uploads)
10
11
  - [Linked Record Format for Mutations](#linked-record-format-for-mutations)
11
12
  - [Writing to Field Types](#writing-to-field-types)
@@ -23,6 +24,13 @@ Each Action's "FIELDS USED" list mirrors the aliases in your `q.select()` mappin
23
24
  - Cosmetic AND structural code edits both update the Action automatically -- you do NOT need to re-prompt the AI assistant after editing code
24
25
  - There is no manual delete control; to remove an Action, remove the mutation hook from the code
25
26
 
27
+ **⚠️ Every recompile resets Action permissions (verified live 2026-08-25).** Each code
28
+ recompile/redeploy re-registers the block's auto-derived Actions with **default permissions** —
29
+ any per-Action permission tightening done in the Actions tab is wiped. Deployment-order
30
+ implication: do the Actions-tab tightening pass only AFTER the last redeploy of a block, and
31
+ re-check every tightened block after any future redeploy. Hit across a 15-block production
32
+ deployment; treat it as standing platform behavior, not a one-off.
33
+
26
34
  The `enabled` boolean on a mutation hook is a combined signal — it's `true` only when BOTH conditions are met:
27
35
 
28
36
  1. **The Action was successfully derived from the code** (parser side). Causes of failure here:
@@ -65,6 +73,11 @@ if (createRecord.enabled) {
65
73
  }
66
74
  ```
67
75
 
76
+ **Create payloads are FLAT — no `{ fields }` wrapper** (verified live 2026-08-25). The payload's
77
+ keys are the aliases from the hook's `fields:` q.select, at the top level. This is deliberately
78
+ asymmetric with `useRecordUpdate`, whose payload nests them: `{ recordId, fields: { ... } }`.
79
+ Wrapping a create payload in `fields:` is a wrong shape — don't copy it from an update call.
80
+
68
81
  ### useRecordUpdate
69
82
 
70
83
  ```jsx
@@ -85,38 +98,31 @@ updateRecord.mutate({
85
98
  });
86
99
  ```
87
100
 
88
- #### CRITICAL: Two parser requirements for `useRecordUpdate`
89
-
90
- Softr's Action parser is strict about both the **method name** and the **payload shape**. Get either wrong and the Action is never derived, `enabled` stays `false`, and any UI gated on it silently does nothing — no error, no warning, console just shows `enabled: false, error: null, status: "idle"`.
101
+ #### CRITICAL: The `useRecordUpdate` payload shape (and the retired `.mutate()`-only rule)
91
102
 
92
- **Requirement 1Call `.mutate()`, NOT `.mutateAsync()`.** The parser scans for the literal `.mutate(` token to detect mutation call sites. `.mutateAsync()` runs fine at runtime (it's just a Promise wrapper), but the parser ignores it and no Update Action gets created. Pass per-call success/error handlers as the second argument (react-query convention):
93
-
94
- ```jsx
95
- // CORRECT — parser sees `.mutate(`, derives the Action
96
- updateRecord.mutate(
97
- { recordId: id, fields: { status: optionId } },
98
- {
99
- onSuccess: function() { toast.success("Saved"); },
100
- onError: function(err) { toast.error(err.message); },
101
- }
102
- );
103
-
104
- // WRONG — parser ignores `.mutateAsync(`, Action never created
105
- updateRecord.mutateAsync({ recordId: id, fields: { status: optionId } })
106
- .then(function() { toast.success("Saved"); });
107
- ```
108
-
109
- **Requirement 2 — Payload must be `{ recordId, fields: {...} }` — not flat.** Field values must be nested inside a `fields: {...}` object. The flat form (`mutate({ recordId, status: "active" })`) can succeed at runtime, but the parser doesn't see field references inside it, so no Action is derived:
103
+ **Payload must be `{ recordId, fields: {...} }` not flat.** Field values must be nested inside a `fields: {...}` object. The flat form (`mutate({ recordId, status: "active" })`) can succeed at runtime, but Softr's Action parser doesn't see field references inside it, so no Update Action is derived `enabled` stays `false`, the UI gated on it silently does nothing, and Studio's Actions tab shows "No actions used in this block yet":
110
104
 
111
105
  ```jsx
112
106
  // CORRECT
113
- updateRecord.mutate({ recordId: id, fields: { status: optionId } }, { onSuccess, onError });
107
+ updateRecord.mutate({ recordId: id, fields: { status: "Active" } }, { onSuccess, onError });
114
108
 
115
109
  // WRONG — Action parser ignores this, hook stays disabled
116
- updateRecord.mutate({ recordId: id, status: optionId }, { onSuccess, onError });
110
+ updateRecord.mutate({ recordId: id, status: "Active" }, { onSuccess, onError });
117
111
  ```
118
112
 
119
- Verified by direct experiment (May 2026): Softr's Studio AI assistant emits both `.mutate()` AND the nested payload shape — and that combination is what produces a derived Update Action. Switching either back to its alternative form (`.mutateAsync()` or flat payload) disables the hook.
113
+ Note the asymmetry: **update payloads nest under `fields:`, create payloads are flat** (no wrapper). Verified live 2026-08-25.
114
+
115
+ **`.mutateAsync()` is fully supported (verified live 2026-08-25 — supersedes the old rule).**
116
+ Until mid-2026 this skill documented that the Action parser only recognized the literal
117
+ `.mutate(` token, and that any mutation written as `.mutateAsync(...)` produced no derived
118
+ Action (verified by direct experiment, May 2026, on the then-current platform). The current
119
+ platform derives Actions for `mutateAsync` call sites too — a 15-block production deployment
120
+ built its entire multi-row write layer on `await hook.mutateAsync(...)` with Actions deriving
121
+ correctly on every block. Use `.mutate(payload, { onSuccess, onError })` for fire-and-forget
122
+ single writes; use `await .mutateAsync(payload)` when the code must sequence writes or branch
123
+ on the result (see [Sequential Multi-Row Writes](#sequential-multi-row-writes-mutateasync)).
124
+ If you're maintaining an old app where an Action refuses to derive, the legacy `.mutate(`-only
125
+ parser is worth checking before deeper debugging.
120
126
 
121
127
  ### useRecordDelete
122
128
 
@@ -153,18 +159,75 @@ var createFields = q.select({ name: "FIELD_1", email: "FIELD_2" });
153
159
  var updateFields = q.select({ name: "FIELD_1", email: "FIELD_2" });
154
160
  ```
155
161
 
162
+ ## Sequential Multi-Row Writes (mutateAsync)
163
+
164
+ `await hook.mutateAsync(...)` is the tool for any save that writes several rows in a required
165
+ order — a header record followed by its line items, a source record followed by ledger rows, a
166
+ batch of rows that must stop cleanly on the first failure. Verified live 2026-08-25: this
167
+ pattern carried every multi-row save in a 15-block production deployment.
168
+
169
+ The battle-tested queue shape:
170
+
171
+ ```tsx
172
+ // Track queue state in component state so a failure is renderable and resumable.
173
+ // { phase: "idle" | "saving" | "failed" | "done", failedIndex: number | null }
174
+
175
+ async function saveAll() {
176
+ setQueue({ phase: "saving", failedIndex: null });
177
+
178
+ // 1. Header first — its id links every line.
179
+ const header = await createHeader.mutateAsync({ name, date }); // create payloads are FLAT
180
+ if (!header?.id) throw new Error("Header created without an id");
181
+
182
+ // 2. Lines in order. STOP on the first failure; never re-issue completed writes.
183
+ for (let i = 0; i < lines.length; i++) {
184
+ if (lines[i].saved) continue; // resume support: skip completed rows
185
+ try {
186
+ await createLine.mutateAsync({ header: [header.id], product: lines[i].product, qty: lines[i].qty });
187
+ markSaved(i);
188
+ } catch (err) {
189
+ setQueue({ phase: "failed", failedIndex: i }); // render the failed line + a Retry button
190
+ return;
191
+ }
192
+ }
193
+ await refetch(); // refresh affected queries BEFORE toasting
194
+ setQueue({ phase: "done", failedIndex: null });
195
+ toast.success("Saved");
196
+ }
197
+ ```
198
+
199
+ Rules that make this safe:
200
+
201
+ - **Header first, then lines in order.** Await each write; never fire the loop in parallel and
202
+ never chain with nested `.then()` callbacks.
203
+ - **Stop on failure, keep the queue.** Render the failed row with its error and a Retry button;
204
+ Retry re-runs only the failed write and resumes the remainder. Completed writes are never
205
+ re-issued.
206
+ - **Guard against ambiguous failures.** A failed *response* doesn't prove a failed *write* (the
207
+ server may have committed and the response been lost). Before a Retry re-writes, re-fetch the
208
+ already-written child rows for that header and skip any the server already has — this is what
209
+ makes the queue double-write-proof.
210
+ - **Guard the created id.** If a create resolves without an id, throw — don't write lines linked
211
+ to `undefined`.
212
+ - **Gate the whole flow on the hooks' `enabled` booleans**, same as any mutation UI.
213
+
156
214
  ## File Uploads
157
215
 
158
216
  ```jsx
159
217
  import { useUpload } from "@/lib/datasource";
160
218
 
161
219
  var upload = useUpload();
220
+ // Returns { uploadAsync, isUploading } — use isUploading to disable the submit
221
+ // button / show a spinner while the upload is in flight:
222
+ // <Button disabled={upload.isUploading || !file}>...
162
223
 
163
224
  // Single file:
164
225
  upload.uploadAsync(file).then(function(results) {
165
226
  var result = results[0];
166
227
  if (result.status === "completed") {
167
228
  // result.url = uploaded file URL, result.file.name = original filename
229
+ } else {
230
+ toast.error((result.error && result.error.message) || "Upload failed");
168
231
  }
169
232
  });
170
233
 
@@ -176,10 +239,19 @@ upload.uploadAsync(file).then(function(results) {
176
239
  name: "Document",
177
240
  attachment: { filename: result.file.name, url: result.url },
178
241
  });
242
+ } else {
243
+ toast.error((result.error && result.error.message) || "Upload failed");
179
244
  }
180
245
  });
246
+
247
+ // Multiple files: uploadAsync accepts an array; filter the completed results.
248
+ upload.uploadAsync(Array.from(e.target.files)).then(function(results) {
249
+ var completed = results.filter(function(r) { return r.status === "completed"; });
250
+ });
181
251
  ```
182
252
 
253
+ Always handle the non-`"completed"` branch — a failed result carries `result.error?.message` for the toast.
254
+
183
255
  ### Async/await style
184
256
 
185
257
  The official Softr Vibe Coding docs use this form; it's more ergonomic when uploading inside a larger async flow:
@@ -188,81 +260,91 @@ The official Softr Vibe Coding docs use this form; it's more ergonomic when uplo
188
260
  var [result] = await upload.uploadAsync(file);
189
261
  if (result.status === "completed") {
190
262
  createRecord.mutate(
191
- { fields: { attachment: { filename: result.file.name, url: result.url } } },
263
+ { attachment: { filename: result.file.name, url: result.url } }, // create payload is FLAT
192
264
  { onSuccess: function() { toast.success("Saved"); } }
193
265
  );
194
266
  }
195
267
  ```
196
268
 
197
- **Stick with `.mutate(...)` even inside async functions** — don't switch to `.mutateAsync(...)` just for the await ergonomics. Softr's Action parser only recognizes the `.mutate(` token, so any mutation written as `.mutateAsync(` produces no derived Action and `enabled` stays `false` (see "Two parser requirements for `useRecordUpdate`" below).
269
+ Inside a larger async flow, `await createRecord.mutateAsync({ attachment: ... })` works just as
270
+ well — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; see the
271
+ supersession note under `useRecordUpdate` above).
198
272
 
199
273
  ## Linked Record Format for Mutations
200
274
 
275
+ Write linked-record fields as an **array of record-id strings** (verified live 2026-08-25 on
276
+ Softr Database — every cross-table link in a 15-block production deployment used this shape):
277
+
201
278
  ```jsx
202
- // CORRECT -- Array of { id } objects
279
+ // CORRECT -- array of record-id strings, even for a single link
203
280
  createRecord.mutate({
204
- parentAccount: [{ id: "RECORD_ID_1" }],
205
- teamMembers: [{ id: "MEMBER_1" }, { id: "MEMBER_2" }],
281
+ parentAccount: ["RECORD_ID_1"],
282
+ teamMembers: ["MEMBER_1", "MEMBER_2"],
206
283
  });
207
284
 
208
- // WRONG -- Plain string or array of strings
209
- parentAccount: "RECORD_ID_1" // Won't work
210
- teamMembers: ["MEMBER_1"] // Won't work
285
+ // WRONG -- bare string, not wrapped in an array
286
+ parentAccount: "RECORD_ID_1"
211
287
  ```
212
288
 
289
+ **Legacy / Airtable note.** This skill previously documented arrays of `{ id }` objects
290
+ (`teamMembers: [{ id: "MEMBER_1" }]`), verified May 2026 on Airtable-backed blocks. The
291
+ string-array shape is the verified current form on Softr Database; if a linked-record write
292
+ fails on an Airtable-backed block, try the `[{ id }]` object shape before deeper debugging.
293
+
213
294
  ## Writing to Field Types
214
295
 
215
296
  Different Softr field types accept different value shapes in mutation payloads. The shape returned when you READ a field is often different from the shape you must SEND when you WRITE.
216
297
 
217
298
  ### Dropdown / Single Select (Softr Database)
218
299
 
219
- Write the option's UUID as a **plain string**, not an object:
300
+ Write the option's **LABEL string** — it must exactly match a defined choice on the field. No
301
+ option-UUID discovery step is needed:
220
302
 
221
303
  ```jsx
222
- // CORRECT -- plain string UUID
304
+ // CORRECT -- the option's display label, exactly as defined
223
305
  createRecord.mutate({
224
- status: "822b8d69-3af4-47b4-90eb-3a80c5d1b85c",
306
+ status: "Active",
225
307
  });
226
308
 
227
309
  // WRONG -- object form (returned on read, but rejected on write)
228
310
  createRecord.mutate({
229
311
  status: { id: "822b8d69-3af4-47b4-90eb-3a80c5d1b85c", label: "Active" },
230
312
  });
231
-
232
- // WRONG -- display label
233
- createRecord.mutate({
234
- status: "Active",
235
- });
236
313
  ```
237
314
 
238
- Option UUIDs are stable. Three ways to retrieve them:
315
+ Verified live 2026-08-25 (Softr Database, `useRecordCreate` AND `useRecordUpdate`): every
316
+ controlled-vocabulary SELECT write in a 15-block production deployment wrote label strings
317
+ (`"Tier 1"`, `"Prospect"`, `"Physical count correction"`, ...) with no UUID discovery step.
239
318
 
240
- - **AI scaffolding** -- Softr's AI assistant in Studio inlines them automatically into `<SelectItem value="...">` when generating a form
241
- - **Network inspector** -- DevTools -> Network -> filter `tablespace-with-tables` returns the full `choices` array for any SELECT field (see [fields.md](fields.md#field-inspector-block) for the full technique). Pasting this JSON into an AI assistant chat is the most reliable way to share UUIDs without transcription errors.
242
- - **Runtime scan** -- learn them at runtime from already-loaded records (useful when the block must work in environments where UUIDs are not known at code time)
319
+ The label must match a defined choice character-for-character a typo fails the write, so
320
+ keep vocabularies as greppable constants, or fetch them live with `useFieldOptions` (see
321
+ [reading.md](reading.md#usefieldoptions----fetch-singlemulti-select-choices)) and write
322
+ `option.label`.
243
323
 
244
- Verified by direct experiment (April 2026) for `useRecordCreate`. The same pattern is expected to apply to `useRecordUpdate` but has not been independently verified.
324
+ **Legacy note.** Until mid-2026 this skill documented the opposite write the option UUID,
325
+ labels rejected (verified April 2026 on the then-current platform). If a label write is
326
+ rejected on an old app, the UUID form is the thing to try; on the current platform it is not
327
+ needed.
245
328
 
246
329
  ### Linked Record
247
330
 
248
- Array of `{ id }` objects. See "Linked Record Format for Mutations" above.
331
+ Array of record-id **strings** (`["RECORD_ID"]`) on Softr Database (verified 2026-08-25); the legacy / Airtable fallback shape is `[{ id }]` objects. See "Linked Record Format for Mutations" above.
249
332
 
250
333
  ### Multi-Select
251
334
 
252
- Array of option UUIDs as plain strings — mirrors Single Select but wrapped in an array:
335
+ Expected: array of option **label strings** — mirrors Single Select (which writes by label,
336
+ verified 2026-08-25) but wrapped in an array:
253
337
 
254
338
  ```jsx
255
- // CORRECT
256
- createRecord.mutate({ tags: ["uuid-1", "uuid-2"] });
339
+ // EXPECTED
340
+ createRecord.mutate({ tags: ["Urgent", "Internal"] });
257
341
 
258
342
  // WRONG -- {id, label} objects (returned on read, rejected on write)
259
343
  tags: [{ id: "uuid-1", label: "Urgent" }]
260
-
261
- // WRONG -- display labels
262
- tags: ["Urgent", "Internal"]
263
344
  ```
264
345
 
265
- _Inferred from the read shape (see [fields.md](fields.md)); verify by experiment before production use._
346
+ _Not independently verified — inferred from the verified single-select label behavior; verify by
347
+ experiment before production use. (The pre-2026-08 platform took option-UUID arrays instead.)_
266
348
 
267
349
  ### Number
268
350
 
@@ -296,7 +378,10 @@ createRecord.mutate({ dueDate: "2025-03-15" });
296
378
  createRecord.mutate({ lastSeenAt: "2025-03-15T14:00:00Z" });
297
379
  ```
298
380
 
299
- _Inferred from the read shape (see [fields.md](fields.md)); verify by experiment before production use._
381
+ Verified live 2026-08-25 (Softr Database): DATETIME fields with **date semantics** (a due date,
382
+ a distribution date) take the `"yyyy-MM-dd"` form; **timestamp** fields (`*_at` audit fields)
383
+ take `new Date().toISOString()`. Writing a full timestamp into a date-semantics field invites
384
+ timezone-shift bugs — compare and display such fields on the `yyyy-MM-dd` slice.
300
385
 
301
386
  ### Date Range
302
387
 
@@ -333,16 +418,37 @@ To upload a file before writing it to a record, see [File Uploads](#file-uploads
333
418
 
334
419
  _Shape matches the example shown in [File Uploads](#file-uploads). Not yet independently verified across all data sources._
335
420
 
336
- ### Text / Email / URL / Phone
421
+ ### Text / Email / URL
337
422
 
338
423
  Plain string. To clear a value, both `null` and `""` work for Softr Database text fields (verified by direct experiment, May 2026, for `useRecordUpdate`). Behavior on other data sources has not been independently verified.
339
424
 
425
+ ### Phone
426
+
427
+ **Unformatted international format only**: `+` followed by digits — nothing else (e.g. `+12125550100`). Some datasources (Monday.com is the one the official guide names) reject values containing spaces, dashes, or parentheses. Strip all non-digit characters except the leading `+` before submitting (official sanitizer from the developer guide):
428
+
429
+ ```tsx
430
+ const sanitizePhone = (raw: string) => raw.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
431
+
432
+ mutate({ phone: sanitizePhone(inputValue) });
433
+ ```
434
+
435
+ Any form block with a phone input should sanitize before `mutate()` — never send the user's typed formatting through.
436
+
340
437
  ## Cross-Table Operations
341
438
 
342
- `useRecordCreate`, `useRecordUpdate`, and `useRecordDelete` only work with the block's configured datasource. Two paths to write across tables:
439
+ **The default path is multi-datasource (supersedes the REST-API-first guidance that used to live
440
+ here).** A block can connect to several data sources at once — declare them with
441
+ `datasource.define({ alias: "uuid", ... })` and pass `from: ds.alias` on every hook, including
442
+ the mutation hooks. Reading two tables and writing a third from one block needs no helper
443
+ block, no REST API, and no exposed key. Required reading:
444
+ [multi-datasource.md](multi-datasource.md). Verified at scale 2026-08-25: a 15-block production
445
+ deployment routed all of its cross-table writes (header + ledger + audit-log rows) through
446
+ `from:`-scoped mutation hooks.
447
+
448
+ Two remaining alternatives, for the cases multi-datasource doesn't cover:
343
449
 
344
- - **For Airtable backends** — usually cleanest to write to the block's own table and let an Airtable automation script handle the cascade. See [../references/airtable-automations.md](../references/airtable-automations.md). Keeps the block simple, avoids exposing an API key in the browser, and lets cross-table logic live next to the data.
345
- - **Softr Database REST API via `fetch()`** — the only option for non-Airtable sources, and the right choice when an automation cycle would be too slow. Details below.
450
+ - **For Airtable backends** — when the cascade logic is heavy, write to the block's own table and let an Airtable automation script handle the cascade. See [../references/airtable-automations.md](../references/airtable-automations.md). Keeps the block simple and lets cross-table logic live next to the data.
451
+ - **Softr Database REST API via `fetch()`** — a fallback for what the hooks can't express (e.g. writes from outside a Vibe block, or admin tooling that must bypass block bindings). Details below.
346
452
 
347
453
  **Base URL:** `https://tables-api.softr.io/api/v1/databases/{databaseId}/tables/{tableId}/records`
348
454
 
@@ -358,4 +464,4 @@ Notes:
358
464
  - Use `fieldNames=true` on GET for human-readable field names
359
465
  - Rate limits: Reads 40 req/s, Writes 30 req/s
360
466
 
361
- Verified by direct experiment (May 2026): POST to this endpoint with field IDs as keys writes successfully, returning HTTP 200 and the full record JSON. The endpoint uses the same field-ID format and the same value shapes as `useRecordCreate` -- plain string UUID for dropdown writes; the response returns the dropdown value as a `{id, label}` object (matching the read shape).
467
+ Verified by direct experiment (May 2026): POST to this endpoint with field IDs as keys writes successfully, returning HTTP 200 and the full record JSON. At that time the endpoint took plain string UUIDs for dropdown writes, and the response returned the dropdown value as a `{id, label}` object (matching the read shape). Note: that observation predates the 2026-08-25 finding that the **in-block hooks** write SELECTs by label — the REST endpoint is a separate surface and may still expect UUIDs; re-verify whichever shape you use here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "1.11.2",
3
+ "version": "2.1.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"
@@ -2,7 +2,7 @@
2
2
 
3
3
  Companion reference for Softr Vibe Coding blocks. Many Softr blocks talk to an Airtable backend, and some flows can't be done from the block side — most commonly **cross-table writes triggered by a record change** (the block can only write to its own configured data source). For those, the right tool is an Airtable Automation Script, written in JavaScript and triggered by Airtable's automation runner.
4
4
 
5
- This guide covers Airtable's two scripting environments + Airtable formulas. **It is NOT about Softr Vibe Coding** — runtime, API surface, and gotchas are entirely different. Don't apply Softr block rules (no `?.`, shadow DOM, etc.) here.
5
+ This guide covers Airtable's two scripting environments + Airtable formulas. **It is NOT about Softr Vibe Coding** — runtime, API surface, and gotchas are entirely different. Don't apply Softr block rules (shadow DOM isolation, data-hook constraints, etc.) here.
6
6
 
7
7
  ## When to reach for an Airtable script vs. a Softr block
8
8