softr-vibe-coding 1.12.0 → 2.1.1

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.
@@ -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`
101
+ #### CRITICAL: The `useRecordUpdate` payload shape (and the retired `.mutate()`-only rule)
89
102
 
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"`.
91
-
92
- **Requirement 1 — Call `.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
 
@@ -329,20 +414,68 @@ createRecord.mutate({
329
414
  });
330
415
  ```
331
416
 
332
- To upload a file before writing it to a record, see [File Uploads](#file-uploads) above for the full `useUpload` flow.
417
+ To upload a file from the user's machine before writing it to a record, see
418
+ [File Uploads](#file-uploads) above for the full `useUpload` flow.
419
+
420
+ #### A file already on the public web needs no upload step
421
+
422
+ Hand the attachment field any publicly reachable URL and Softr fetches the file, stores **its own copy**,
423
+ and generates preview thumbnails. The saved record points at Softr's bucket rather than the original host,
424
+ so the source can later move or delete the file without breaking the record:
425
+
426
+ ```jsx
427
+ // No useUpload — the URL is the upload
428
+ { photo: { filename: "lamp.jpg", url: "https://cdn.example.com/abc123" } }
429
+ ```
430
+
431
+ Details that matter in practice:
432
+
433
+ - The `filename` you supply is what Softr stores and serves. It does not have to match anything at the
434
+ source, and the **source URL needs no file extension** — Softr sniffs the real content type on fetch.
435
+ - Softr reads the URL **server-side**, so the file must be reachable without auth headers. A signed or
436
+ expiring link works only while it is still valid.
437
+ - The original URL is **not** retained on the record. Store it in a separate URL field when provenance or
438
+ a later re-pull matters.
439
+ - A bulk import of images from another system (Airtable, a vendor CDN, a CSV of image links) is therefore
440
+ a plain loop of record writes with no download-and-re-upload stage.
333
441
 
334
- _Shape matches the example shown in [File Uploads](#file-uploads). Not yet independently verified across all data sources._
442
+ _Write shape verified live 2026-08-26 on Softr Database via the MCP `update_record`: a 20,990-byte
443
+ `image/jpeg` behind an extensionless ImageKit URL came back as a Softr-hosted S3 object of identical size
444
+ and type, with small/medium/large thumbnails generated. Copy-not-link confirmed. The in-block
445
+ `useRecordUpdate` / `createRecord` path takes the same shape, but external-URL ingestion was not separately
446
+ tested there, nor on other data sources._
335
447
 
336
- ### Text / Email / URL / Phone
448
+ ### Text / Email / URL
337
449
 
338
450
  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
451
 
452
+ ### Phone
453
+
454
+ **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):
455
+
456
+ ```tsx
457
+ const sanitizePhone = (raw: string) => raw.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
458
+
459
+ mutate({ phone: sanitizePhone(inputValue) });
460
+ ```
461
+
462
+ Any form block with a phone input should sanitize before `mutate()` — never send the user's typed formatting through.
463
+
340
464
  ## Cross-Table Operations
341
465
 
342
- `useRecordCreate`, `useRecordUpdate`, and `useRecordDelete` only work with the block's configured datasource. Two paths to write across tables:
466
+ **The default path is multi-datasource (supersedes the REST-API-first guidance that used to live
467
+ here).** A block can connect to several data sources at once — declare them with
468
+ `datasource.define({ alias: "uuid", ... })` and pass `from: ds.alias` on every hook, including
469
+ the mutation hooks. Reading two tables and writing a third from one block needs no helper
470
+ block, no REST API, and no exposed key. Required reading:
471
+ [multi-datasource.md](multi-datasource.md). Verified at scale 2026-08-25: a 15-block production
472
+ deployment routed all of its cross-table writes (header + ledger + audit-log rows) through
473
+ `from:`-scoped mutation hooks.
474
+
475
+ Two remaining alternatives, for the cases multi-datasource doesn't cover:
343
476
 
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.
477
+ - **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.
478
+ - **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
479
 
347
480
  **Base URL:** `https://tables-api.softr.io/api/v1/databases/{databaseId}/tables/{tableId}/records`
348
481
 
@@ -358,4 +491,4 @@ Notes:
358
491
  - Use `fieldNames=true` on GET for human-readable field names
359
492
  - Rate limits: Reads 40 req/s, Writes 30 req/s
360
493
 
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).
494
+ 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.12.0",
3
+ "version": "2.1.1",
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
 
@@ -16,7 +16,7 @@ Run through this catalog before delivering any block. Every row is a violation o
16
16
  | Hardcoding API keys for connected API | Use `useProxyFetch` -- key stays server-side |
17
17
  | Using `q.select({})` to dump all fields on Softr Database | Returns record IDs with empty `fields: {}`. Look up field IDs in Studio's Data tab, or use the Softr DB REST API with `fieldNames=true` |
18
18
  | Building an invisible helper block + `window` globals just to read a second table | A block can connect to **multiple data sources**. Declare them with `datasource.define({ alias: "uuid" })` and pass `from: ds.alias` on every hook. One block instead of two, no page-order dependency, no mount-timing race. See [datasources/multi-datasource.md](../datasources/multi-datasource.md). Helper blocks remain correct for genuinely cross-*block* jobs (triggering another block, sharing computed state) — just not for plain multi-table reads |
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 |
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
22
 
@@ -25,15 +25,19 @@ Run through this catalog before delivering any block. Every row is a violation o
25
25
  | Anti-Pattern | Correct Approach |
26
26
  |---|---|
27
27
  | `.mutate({ id: ... })` | `.mutate({ recordId: ... })` -- `id` causes 404 |
28
- | `updateRecord.mutate({ recordId, status: "..." })` — flat payload | `updateRecord.mutate({ recordId, fields: { status: "..." } })` — fields **must** be nested. The flat form can run at runtime but Softr's Action parser doesn't see field references inside it, so the derived Update Action never gets created. The hook's `enabled` stays `false`, the Save button never lights up, the Actions tab in Studio shows "No actions used in this block yet" — all with no error, no warning. The symptom is a button that does nothing and a console log showing `enabled: false, error: null, status: "idle"`. Use the nested form for EVERY mutate call, even single-field updates. See [datasources/writing.md](../datasources/writing.md#critical-two-parser-requirements-for-userecordupdate) |
29
- | `updateRecord.mutateAsync(payload).then(...).catch(...)` | `updateRecord.mutate(payload, { onSuccess, onError })` Softr's Action 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 no Action gets derived, `enabled` stays `false`, the Actions tab shows "No actions used in this block yet". This is the same silent-failure mode as the flat-payload anti-pattern, and the two often appear together because devs reach for `mutateAsync` to chain `.then()/.catch()`. The fix is to use `.mutate(payload, { onSuccess, onError })` per-call handlers go in the second argument (react-query convention). Verified by direct experiment, May 2026. See [datasources/writing.md](../datasources/writing.md#critical-two-parser-requirements-for-userecordupdate) |
28
+ | `updateRecord.mutate({ recordId, status: "..." })` — flat payload | `updateRecord.mutate({ recordId, fields: { status: "..." } })` — fields **must** be nested. The flat form can run at runtime but Softr's Action parser doesn't see field references inside it, so the derived Update Action never gets created. The hook's `enabled` stays `false`, the Save button never lights up, the Actions tab in Studio shows "No actions used in this block yet" — all with no error, no warning. The symptom is a button that does nothing and a console log showing `enabled: false, error: null, status: "idle"`. Use the nested form for EVERY update call, even single-field updates (create payloads are flat — see the dedicated row below). See [datasources/writing.md](../datasources/writing.md#critical-the-userecordupdate-payload-shape-and-the-retired-mutate-only-rule) |
29
+ | Sequencing multi-row saves with nested `.then()/.catch()` chains, or firing the rows in parallel | `await hook.mutateAsync(row)` per row, in order header first, then lines; stop on the first failure with renderable retry state; never re-issue completed writes. `mutateAsync` is **fully supported** on the current platform (verified live 2026-08-25 this supersedes the May 2026 finding that the Action parser only recognized the literal `.mutate(` token; that limitation is gone, and is worth checking only when maintaining an old app whose Action refuses to derive). Full queue pattern: [datasources/writing.md](../datasources/writing.md#sequential-multi-row-writes-mutateasync) |
30
30
  | Assuming `mutation.enabled === false` always means a code bug | `enabled` is BOTH a parser signal AND a permissions signal. Per the official Softr docs, "`enabled` reflects user permissions." When code looks correct and the Actions tab shows the action listed, the cause is almost always permissions. Test by switching "Preview as" in Studio to an Owner / admin; if it then works, the issue is permissions. Three places to check, in priority order: (1) the block's **Visibility** tab (right panel), (2) **Studio → Users → Data Restrictions → Global data restrictions** — an app-wide layer that easily gets overlooked because it's hidden under Users (not on the block); it overlays every block in the app, and a single restriction on the target table will silently disable every mutation against that table for the affected user group, (3) the data-source PAT scope — if granted read-only, every write fails regardless of UI permissions. See [datasources/writing.md](../datasources/writing.md#how-actions-work-studios-actions-tab) |
31
31
  | `deleteRecord.mutate({ id: r.id })` | `deleteRecord.mutate(r.id)` -- just the string |
32
- | `var { mutateAsync } = useRecordUpdate({...})` | `var updateRecord = useRecordUpdate({...})` -- keep full object for `.enabled`, `.status`, `.reset()` |
32
+ | `var { mutateAsync } = useRecordUpdate({...})` -- destructuring the mutate function off the hook | `var updateRecord = useRecordUpdate({...})` -- keep the full object so `.enabled`, `.status`, `.reset()` stay reachable (using `.mutateAsync` itself is fine) |
33
33
  | Not calling `refetch()` after mutations | Always `refetch()` in `onSuccess` |
34
34
  | Including read-only fields (formula / rollup / aiText / lookup / createdTime / lastModifiedTime / autoNumber) in the `fields` q.select passed to `useRecordCreate` or `useRecordUpdate` | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Same all-or-nothing failure mode as a renamed/missing column: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve to "not yet ready", every OTHER writable field in the same q.select is also lost. Reads handle these field types fine — only the write q.select chokes. Fix: split into separate q.selects per the Three Mappings Pattern (`useRecord` / `useRecords` gets the full select with read-only fields; `useRecordCreate` / `useRecordUpdate` gets a writable-only subset). Diagnostic when the symptom shows up: same bisection procedure as the renamed-column case — strip the write q.select to a known-writable minimum, then add fields back in halves until the Action drops out. Verified 2026-05-22: `blocks/wig-details/wig-details-page.jsx` shared one `wigSelect` between `useRecord` and `useRecordUpdate`; the select included `Wig Tag ID` (formula), `Total client price` (rollup), `Total worker pay` (rollup), `Instrucciones` (aiText) — Update Action stayed disabled until those four read-only fields were lifted out into a separate write-only select. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) and [datasources/writing.md](../datasources/writing.md). |
35
- | Linked record as plain string | Must be `[{ id: "..." }]` array |
36
- | Writing dropdown values as `{ id, label }` objects (the read shape) | Write the option UUID as a plain string -- e.g. `status: "822b8d69-..."`, not `status: { id: "...", label: "..." }` |
35
+ | Linked record as bare string, or `[{ id }]` objects on Softr Database | Array of record-id **strings**: `familyLink: [familyId]` — verified live 2026-08-25 on Softr DB. (The `[{ id }]` object shape was the May 2026 verified form on Airtable-backed blocks; try it if a string-array write fails there.) See [datasources/writing.md](../datasources/writing.md#linked-record-format-for-mutations) |
36
+ | Writing dropdown values as `{ id, label }` objects (the read shape) or hunting for option UUIDs | Write the option **LABEL string**, exactly matching a defined choice e.g. `status: "Active"` (verified live 2026-08-25; supersedes the April 2026 UUID rule). Keep vocabularies as greppable constants or fetch live via `useFieldOptions` and write `option.label` |
37
+ | Writing a formatted phone value (`(212) 555-0100`, `212-555-0100`) to a PHONE field | Sanitize to unformatted international format — `+` followed by digits only (`+12125550100`) — before `mutate()`; some datasources (Monday.com, per the official guide) reject formatted values outright. Official sanitizer: `const sanitizePhone = (raw) => raw.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");` See [datasources/writing.md](../datasources/writing.md#phone) |
38
+ | Wrapping a `useRecordCreate` payload in `{ fields: {...} }` (copied from an update call) | Create payloads are **FLAT** — `createRecord.mutate({ name: "Jane" })`. Only update payloads nest: `{ recordId, fields: {...} }`. The asymmetry is by design (verified 2026-08-25) |
39
+ | Passing a data hook's options through a variable or wrapper function: `useRecords(buildOpts())` | **Fails to compile** — the options object must be an inline literal at the call site (verified live 2026-08-25; hit in production, fixed by making the wrapper take the hook's *result* instead). Share `q.select` mappings between hooks, never whole options objects |
40
+ | 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 |
37
41
  | 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 |
38
42
  | 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) |
39
43
 
@@ -51,7 +55,7 @@ Run through this catalog before delivering any block. Every row is a violation o
51
55
  | `import React from 'react'` | Named imports only |
52
56
  | Named export | `export default function Block()` |
53
57
  | Hook declared after conditional `return` | All hooks at top before any conditional `return` -- React error #310 |
54
- | `fetchNextPage()` in render body | Inside `useEffect` only -- in render = infinite loop |
58
+ | `fetchNextPage()` in render body | Never in the render body (render data update → re-render → infinite loop). Call from an event handler — the official Load More pattern: `<button onClick={() => fetchNextPage()} disabled={isFetching}>` — or a guarded `useEffect` for auto-load-all |
55
59
  | `useRef` for IDs used in `useMemo` | `useState` -- ref mutations don't trigger recomputation |
56
60
  | Defining a sub-component INSIDE the `Block()` function body | Define ALL sub-components at MODULE scope (above `export default function Block()`). Sub-components defined inside `Block()` get a brand-new function reference on every render, which makes React unmount/remount their entire DOM subtree every time `Block` re-renders. The user-visible symptom: **inputs lose focus after typing one character** (because each keystroke triggers a `setState` -> re-render -> the `<input>` is destroyed and recreated). Move `function FieldLabel`, `function TextInput`, `function ChipButton`, `function SectionCard`, etc. above `export default function Block()` so React sees stable component identity across renders. Closure-captured `Block`-internal state must be passed as props, not closed over. |
57
61
 
@@ -1,6 +1,6 @@
1
1
  # Common Patterns
2
2
 
3
- Small reusable patterns that come up across Vibe Coding blocks but don't warrant their own reference file. Each is a copy-pasteable snippet using the skill's preferred style (`var`, `function() {}`).
3
+ Small reusable patterns that come up across Vibe Coding blocks but don't warrant their own reference file. Each is a copy-pasteable snippet. The snippets below use legacy var-style (`var`, `function() {}`), which remains valid — write new blocks in modern TS (see SKILL.md Style Conventions).
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -56,7 +56,7 @@ in the published app.
56
56
  Key rules:
57
57
  - The helper block must be on the **same Softr page** as the consumer -- `window` is page-scoped, globals don't cross pages.
58
58
  - **One helper block per foreign table.** Multiple helpers with distinct namespaces coexist fine on the same page.
59
- - **Read-only pattern.** Helpers expose foreign table data for lookups, pickers, and display only. Writes still happen from the main block via its own `useRecordUpdate` / `useRecordCreate`. If the main block needs to write to the helper's table, use a webhook or the Softr Database REST API (see [writing.md Cross-Table Operations](../datasources/writing.md#cross-table-operations)), not the helper.
59
+ - **Read-only pattern.** Helpers expose foreign table data for lookups, pickers, and display only. Writes still happen from the main block via its own `useRecordUpdate` / `useRecordCreate`. **Modern path for writing to another table:** connect it as a second datasource and write with `useRecordCreate({ from: ds.x })` — see [writing.md Cross-Table Operations](../datasources/writing.md#cross-table-operations); a webhook or the Softr Database REST API remain fallbacks for writes the hooks can't express. Never write through the helper.
60
60
 
61
61
  ## Companion Field Helpers
62
62
 
@@ -144,7 +144,9 @@ Key points:
144
144
  import { useRecords, q } from "@/lib/datasource";
145
145
  import { useEffect, useMemo, useRef } from "react";
146
146
 
147
- var select = q.select({ fullName: "fldXXX", isActive: "fldYYY" });
147
+ // q.select values: field NAMES for Airtable/Notion/Google Sheets, field IDs for Softr DB/Supabase.
148
+ // (An Airtable "fldXXX" ID here compiles and saves, then silently returns empty data.)
149
+ var select = q.select({ fullName: "Full Name", isActive: "Active" });
148
150
 
149
151
  function toOption(record) {
150
152
  return { id: record.id, title: record.fields.fullName || "" };
@@ -8,7 +8,7 @@ Softr's native **List / Grid** blocks support category chips and *static* filter
8
8
 
9
9
  > **Sibling to [native-chrome-styling.md](native-chrome-styling.md).** That doc restyles Softr's *shell* (header/footer/nav) with global CSS. This one *drives and augments native blocks* with custom-code JS. Both reach the **main document**, never a block's shadow DOM — so this is a **Custom Code Static block**, not a Vibe Coding (JSX) block.
10
10
 
11
- House code style still applies in the `<script>`: `var`, `function(){}`, **no** optional chaining (`?.`) or nullish coalescing (`??`).
11
+ Modern JS is fine in the `<script>` — Custom Code blocks run unbundled in the browser, and the old `var`/`function(){}`/no-`?.` house style is retired everywhere (the Vibe Coding compiler accepts modern syntax too, verified 2026-08-25).
12
12
 
13
13
  ---
14
14