makersclaw 0.6.0 → 0.7.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.
Files changed (141) hide show
  1. package/README.md +48 -8
  2. package/package.json +1 -1
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +3 -3
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +3 -3
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +121 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +14 -5
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chat/README.md +43 -13
  67. package/registry/blocks/code-editor/README.md +4 -0
  68. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  69. package/registry/blocks/comment-box/README.md +20 -13
  70. package/registry/blocks/contact-section/README.md +7 -6
  71. package/registry/blocks/csv-import/README.md +34 -12
  72. package/registry/blocks/data-table/README.md +41 -8
  73. package/registry/blocks/diagram/diagram.jsx +1 -1
  74. package/registry/blocks/doc-editor/README.md +18 -6
  75. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  76. package/registry/blocks/doc-page/README.md +16 -6
  77. package/registry/blocks/file-upload/README.md +7 -1
  78. package/registry/blocks/flow-canvas/README.md +16 -5
  79. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  80. package/registry/blocks/footer/README.md +4 -2
  81. package/registry/blocks/gantt/README.md +18 -12
  82. package/registry/blocks/job-panel/README.md +1 -1
  83. package/registry/blocks/json-editor/README.md +16 -4
  84. package/registry/blocks/map/README.md +19 -3
  85. package/registry/blocks/map/map-view.jsx +1 -1
  86. package/registry/blocks/map/maplibre.js +1 -1
  87. package/registry/blocks/mind-map/README.md +16 -5
  88. package/registry/blocks/org-chart/README.md +10 -7
  89. package/registry/blocks/page-builder/README.md +13 -6
  90. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  91. package/registry/blocks/page-builder/page-view.jsx +1 -1
  92. package/registry/blocks/page-builder/page.js +1 -1
  93. package/registry/blocks/pdf-viewer/README.md +22 -20
  94. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  95. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  96. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  97. package/registry/blocks/product-detail/README.md +13 -7
  98. package/registry/blocks/product-detail/demo.jsx +1 -1
  99. package/registry/blocks/product-grid/README.md +12 -10
  100. package/registry/blocks/record-form/README.md +26 -9
  101. package/registry/blocks/record-notes/README.md +42 -30
  102. package/registry/blocks/search-dialog/README.md +38 -7
  103. package/registry/blocks/settings-list/README.md +29 -17
  104. package/registry/blocks/setup-checklist/README.md +12 -14
  105. package/registry/blocks/share-dialog/README.md +1 -1
  106. package/registry/blocks/signature-pad/README.md +15 -4
  107. package/registry/blocks/sketch-canvas/README.md +13 -4
  108. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  109. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  110. package/registry/blocks/spreadsheet/README.md +17 -8
  111. package/registry/blocks/stat-cards/README.md +17 -8
  112. package/registry/blocks/task-list/README.md +14 -11
  113. package/registry/blocks/task-model/README.md +3 -3
  114. package/registry/blocks/transaction-list/README.md +15 -7
  115. package/registry/blocks/whiteboard/README.md +19 -13
  116. package/src/cli.mjs +31 -4
  117. package/src/install.mjs +263 -21
  118. package/src/registry.mjs +78 -1
  119. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  120. package/templates/next/browser/app/layout.jsx +94 -0
  121. package/templates/next/browser/app/page.jsx +60 -0
  122. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  123. package/templates/next/browser/lib/actions.js +16 -0
  124. package/templates/next/browser/lib/data.js +43 -0
  125. package/templates/next/files/README.md +810 -79
  126. package/templates/next/files/app/frame.js +128 -4
  127. package/templates/next/files/check/run.mjs +1438 -41
  128. package/templates/next/files/components/ui/item.jsx +2 -2
  129. package/templates/next/files/components/ui/notice.jsx +1 -1
  130. package/templates/next/files/components/ui/separator.jsx +1 -1
  131. package/templates/next/files/lib/actions.js +170 -51
  132. package/templates/next/files/lib/brain-server.js +96 -0
  133. package/templates/next/files/lib/brain.js +413 -0
  134. package/templates/next/files/lib/claw.js +11 -0
  135. package/templates/next/files/lib/data.js +119 -29
  136. package/templates/next/files/lib/db.js +97 -17
  137. package/templates/next/files/lib/door.js +680 -0
  138. package/templates/next/files/next.config.mjs +36 -2
  139. package/templates/next/files/surfaces.js +3 -2
  140. package/templates/next/template.json +9 -3
  141. package/templates/next/files/schema.sql +0 -22
@@ -157,22 +157,46 @@ const getSlots = useCallback(({ from, to }) =>
157
157
  ## Example
158
158
 
159
159
  ```jsx
160
+ // surfaces/schedule.jsx — client: the tasks on a calendar
160
161
  "use client";
161
162
  import { useState } from "react";
162
- import { Sheet } from "./ui";
163
- import { CalendarView } from "./blocks/calendar-view";
163
+ import { useRows } from "../lib/data";
164
+ import { deleteRow, insertRow, updateRow } from "../lib/actions";
165
+ import { Sheet } from "../components/ui";
166
+ import { CalendarView } from "../components/blocks/calendar-view";
167
+ import { changeWrites } from "../components/blocks/task-model";
168
+
169
+ const COLUMNS = ["id", "title", "status", "assignees", "start", "due", "project"];
170
+
171
+ // One change of the calendar's, as the rows it writes (task-list's README has the same).
172
+ async function save(change) {
173
+ for (const w of changeWrites(change, { columns: COLUMNS, keepNewIds: false })) {
174
+ const r =
175
+ w.op === "insert" ? await insertRow({ source: "tasks", values: w.values, detail: w.values.title })
176
+ : w.op === "update" ? await updateRow({ source: "tasks", id: w.id, patch: w.patch })
177
+ : await deleteRow({ source: "tasks", id: w.id });
178
+ if (r?.ok === false) return r;
179
+ }
180
+ return { ok: true };
181
+ }
164
182
 
165
- export default function Schedule({ tasks, people, projects, onChange }) {
183
+ export default function Schedule() {
184
+ const tasks = useRows({ source: "tasks", orderBy: "start asc" });
185
+ const people = useRows({ source: "people" });
186
+ const projects = useRows({ source: "projects" });
166
187
  const [open, setOpen] = useState(null);
167
188
  return (
168
189
  <>
169
- <CalendarView tasks={tasks} people={people} projects={projects} onChange={onChange} onOpen={setOpen} defaultMode="week" />
190
+ <CalendarView tasks={tasks.rows} people={people.rows} projects={projects.rows} onChange={save} onOpen={setOpen} defaultMode="week"
191
+ loading={tasks.loading || people.loading || projects.loading} error={tasks.error} />
170
192
  <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>…</Sheet>
171
193
  </>
172
194
  );
173
195
  }
174
196
  ```
175
197
 
198
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
199
+
176
200
  ## What it will not do
177
201
 
178
202
  - Recurring events, time zones, or events that are not tasks: a repeat
@@ -94,33 +94,35 @@ The checkout step:
94
94
  ## Example
95
95
 
96
96
  ```jsx
97
- // components/Basket.jsx — client
97
+ // surfaces/basket.jsx — client: the cart, read and changed
98
98
  "use client";
99
99
  import { useState } from "react";
100
+ import { useRows } from "../lib/data";
100
101
  import { deleteRow, updateRow } from "../lib/actions";
101
- import { Cart } from "./blocks/cart";
102
+ import { Cart } from "../components/blocks/cart";
102
103
 
103
- export default function Basket({ items, error }) {
104
+ export default function Basket() {
105
+ const { rows: items, error, loading } = useRows({ source: "cart_items", orderBy: "created_at asc" });
104
106
  const [step, setStep] = useState("cart");
105
107
  return (
106
108
  <Cart
107
109
  items={items}
110
+ loading={loading}
108
111
  error={error}
109
112
  step={step}
110
113
  shipping={0}
111
114
  tax="Calculated at payment"
112
115
  note="Free delivery on every order this month."
113
- onQuantityChange={(item, quantity) => updateRow({ source: "public.cart_items", id: item.id, patch: { quantity } })}
114
- onRemove={(item) => deleteRow({ source: "public.cart_items", id: item.id })}
116
+ onQuantityChange={(item, quantity) => updateRow({ source: "cart_items", id: item.id, patch: { quantity } })}
117
+ onRemove={(item) => deleteRow({ source: "cart_items", id: item.id })}
115
118
  onCheckout={() => setStep("checkout")}
116
119
  onBack={() => setStep("cart")}
117
120
  continueHref="/shop"
118
121
  terms="I agree to the terms of sale"
119
- onPay={async (details) => {
120
- const res = await fetch("/api/checkout", { method: "POST", body: JSON.stringify(details) });
121
- if (!res.ok) return { ok: false, error: "We could not start the payment. Try again." };
122
- const { transactionId } = await res.json();
123
- window.Paddle?.Checkout.open({ transactionId });
122
+ onPay={async () => {
123
+ // Paddle.js opens its checkout in the browser from price ids: no route of the app's own.
124
+ if (!window.Paddle) return { ok: false, error: "Payments have not loaded. Try again." };
125
+ window.Paddle.Checkout.open({ items: items.map((i) => ({ priceId: i.price_id, quantity: i.quantity })) });
124
126
  return { ok: true };
125
127
  }}
126
128
  />
@@ -128,6 +130,8 @@ export default function Basket({ items, error }) {
128
130
  }
129
131
  ```
130
132
 
133
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
134
+
131
135
  ## What it will not do
132
136
 
133
137
  - Take a card number, expiry or CVC. Payment is Paddle's overlay or page,
@@ -244,15 +244,20 @@ sharing one key, say).
244
244
  ## Example
245
245
 
246
246
  ```jsx
247
- // surfaces/sales.jsx — server: load, hand over
248
- import { loadRows } from "../lib/data";
247
+ // surfaces/sales.jsx — client: read, hand over
248
+ "use client";
249
+ import { useRows } from "../lib/data";
249
250
  import SalesCharts from "../components/SalesCharts";
250
251
 
251
- export default async function SalesSurface() {
252
- const { rows: months, error } = await loadRows({ source: "public.revenue_by_month", orderBy: "month asc" });
253
- const { rows: stages } = await loadRows({ source: "public.deals_by_stage", orderBy: "position asc" });
254
- const { rows: visits } = await loadRows({ source: "public.visits_by_slot", orderBy: "position asc" });
255
- return <SalesCharts months={months} stages={stages} visits={visits} error={error} />;
252
+ export default function SalesSurface() {
253
+ const months = useRows({ source: "revenue_by_month", orderBy: "month asc" });
254
+ const stages = useRows({ source: "deals_by_stage", orderBy: "position asc" });
255
+ const visits = useRows({ source: "visits_by_slot", orderBy: "position asc" });
256
+ if (months.loading || stages.loading || visits.loading) return null;
257
+ return (
258
+ <SalesCharts months={months.rows} stages={stages.rows} visits={visits.rows}
259
+ error={months.error ?? stages.error ?? visits.error} />
260
+ );
256
261
  }
257
262
  ```
258
263
 
@@ -303,6 +308,8 @@ export default function SalesCharts({ months, stages, visits, error }) {
303
308
  }
304
309
  ```
305
310
 
311
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
312
+
306
313
  ## What it will not do
307
314
 
308
315
  - Sort, group or sum: the rows come shaped. Totals by month or by stage
@@ -214,29 +214,53 @@ and the hint in its bar, Send at the end. The picker leaves out files
214
214
  ## Example
215
215
 
216
216
  ```jsx
217
- // components/SupportChat.jsx — client: one ticket's conversation
217
+ // surfaces/ticket.jsx — client: one ticket's conversation
218
218
  "use client";
219
219
  import { useState } from "react";
220
+ import { useSearchParams } from "next/navigation";
221
+ import { loadRows, useRow, useRows, whenLanded } from "../lib/data";
220
222
  import { insertRow } from "../lib/actions";
221
- import { ChatThread } from "./blocks/chat";
222
-
223
- export default function SupportChat({ ticket, initial }) {
224
- const [messages, setMessages] = useState(initial);
225
- const patch = (id, change) => setMessages((all) => all.map((m) => (m.id === id ? { ...m, ...change } : m)));
223
+ import { ChatThread } from "../components/blocks/chat";
224
+
225
+ const source = "ticket_messages";
226
+ const PAGE = 200;
227
+ const oldestFirst = (rows) => [...rows].reverse();
228
+ const asMessage = (r) => ({ id: r.id, role: r.from_agent ? "assistant" : "user", content: r.body, createdAt: r.created_at });
229
+
230
+ export default function SupportChat() {
231
+ const id = useSearchParams().get("row");
232
+ const { row: ticket } = useRow({ source: "tickets", id });
233
+ // The newest page, read again after every write.
234
+ const { rows, loading } = useRows({ source, filter: { ticket_id: id }, orderBy: "created_at desc", limit: PAGE });
235
+ const [earlier, setEarlier] = useState({ rows: [], more: true }); // what onLoadMore read, oldest first
236
+ const [sending, setSending] = useState([]); // sent, not read back yet
237
+ const seen = new Map([...earlier.rows, ...oldestFirst(rows)].map((r) => [r.id, r]));
238
+ const shown = [...seen.values()];
239
+
240
+ async function loadMore() {
241
+ const oldest = shown[0]?.created_at;
242
+ if (!oldest) return;
243
+ const r = await loadRows({ source, filter: { ticket_id: id, created_at: { lt: oldest } }, orderBy: "created_at desc", limit: PAGE });
244
+ // What is on screen is kept too: new messages push the oldest out of the page above.
245
+ if (!r.error) setEarlier({ rows: [...oldestFirst(r.rows), ...shown], more: r.rows.length === PAGE });
246
+ }
226
247
 
227
248
  async function send(text) {
228
- const id = crypto.randomUUID();
229
- const draft = { id, role: "user", content: text, createdAt: new Date().toISOString(), status: "sending" };
230
- setMessages((all) => [...all, draft]);
231
- const r = await insertRow({ source: "public.ticket_messages", values: { id, ticket_id: ticket.id, body: text }, detail: text });
232
- patch(id, r.ok ? { status: undefined } : { status: "failed", error: r.error });
249
+ const mid = crypto.randomUUID();
250
+ setSending((all) => [...all, { id: mid, role: "user", content: text, createdAt: new Date().toISOString(), status: "sending" }]);
251
+ const r = await insertRow({ source, values: { id: mid, ticket_id: id, body: text }, detail: text });
252
+ if (r.ok) await whenLanded(); // read back: it is in `rows` now
253
+ setSending((all) => (r.ok ? all.filter((m) => m.id !== mid) : all.map((m) => (m.id === mid ? { ...m, status: "failed", error: r.error } : m))));
233
254
  }
234
255
 
256
+ if (loading || !ticket) return null;
235
257
  return (
236
258
  <ChatThread
237
- messages={messages}
259
+ messages={[...shown.map(asMessage), ...sending.filter((m) => !seen.has(m.id))]}
238
260
  onSend={send}
239
- onRetry={(m) => { setMessages((all) => all.filter((x) => x.id !== m.id)); send(m.content); }}
261
+ onRetry={(m) => { setSending((all) => all.filter((x) => x.id !== m.id)); send(m.content); }}
262
+ onLoadMore={loadMore}
263
+ hasMore={rows.length === PAGE && earlier.more}
240
264
  assistantName={ticket.agent_name}
241
265
  userName="You"
242
266
  label={`Ticket ${ticket.number}`}
@@ -245,6 +269,12 @@ export default function SupportChat({ ticket, initial }) {
245
269
  }
246
270
  ```
247
271
 
272
+ `onLoadMore` reads the page before the oldest message shown:
273
+ `loadRows({ source, filter: { ticket_id, created_at: { lt: oldest } } })`,
274
+ the same filter in a server app's `loadRows` and the door.
275
+
276
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
277
+
248
278
  With the AI SDK in a shadcn project, `useChat()` already has the shape:
249
279
  `<ChatThread messages={messages} onSend={(text) => sendMessage({ text })}
250
280
  streaming={status === "streaming" || status === "submitted"} onStop={stop}
@@ -193,6 +193,10 @@ export function QueryBox({ runQuery }) {
193
193
  }
194
194
  ```
195
195
 
196
+ Running SQL a member typed is a server app's only: `runQuery` is a
197
+ Server Action of the app's own. A browser app has no such call, since no
198
+ route of the data door runs SQL a member typed.
199
+
196
200
  JSON, read-only, fixed height:
197
201
 
198
202
  ```jsx
@@ -5,7 +5,7 @@
5
5
  // as you type) or plain text; line numbers, problems drawn on the text,
6
6
  // find and replace, and a run hook on Cmd/Ctrl+Enter.
7
7
  //
8
- // Client only, one static bundle. CodeMirror needs the DOM, and the app's
8
+ // Client only, one bundle. CodeMirror needs the DOM, and the app's
9
9
  // check refuses import() (so no next/dynamic): the modules load with the
10
10
  // page, and the editor itself is created only after the first effect (the
11
11
  // mount guard). Until then — on the server and in the first client paint —
@@ -79,32 +79,39 @@ The other exports, for a thread of your own:
79
79
  ## Example
80
80
 
81
81
  ```jsx
82
- // components/TaskComments.jsx — client
82
+ // surfaces/task.jsx — client: one task's comments
83
83
  "use client";
84
- import { useState } from "react";
84
+ import { useSearchParams } from "next/navigation";
85
+ import { useArtifact } from "../lib/hooks";
86
+ import { useRows, whenLanded } from "../lib/data";
85
87
  import { insertRow } from "../lib/actions";
86
- import { CommentBox } from "./blocks/comment-box";
87
- import { docToText } from "./blocks/doc-editor";
88
-
89
- export default function TaskComments({ task, me, team, initial }) {
90
- const [comments, setComments] = useState(initial);
88
+ import { CommentBox } from "../components/blocks/comment-box";
89
+ import { docToText } from "../components/blocks/doc-editor";
90
+
91
+ export default function TaskComments() {
92
+ const taskId = useSearchParams().get("row");
93
+ const { user } = useArtifact(); // the member signed in: { id, email }
94
+ const { rows, loading } = useRows({ source: "comments", filter: { task_id: taskId }, orderBy: "created_at asc" });
95
+ const { rows: team } = useRows({ source: "people", orderBy: "name asc" });
96
+ // created_by: a before-insert trigger sets it from the member, never the page (the app's README §5)
97
+ const comments = rows.map((c) => ({ id: c.id, author: c.created_by, body: c.body, createdAt: c.created_at }));
91
98
  const findPeople = async (q) =>
92
99
  team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }));
93
100
 
94
101
  async function post(doc) {
95
- const res = await insertRow({
96
- source: "public.comments",
97
- values: { task_id: task.id, author_id: me.id, body: doc, body_text: docToText(doc) },
98
- });
102
+ const res = await insertRow({ source: "comments", values: { task_id: taskId, body: doc, body_text: docToText(doc) } });
99
103
  if (!res.ok) return { ok: false, error: res.error };
100
- setComments((c) => [...c, { id: res.id, author: me, body: doc, createdAt: new Date() }]);
104
+ await whenLanded(); // the comment is read back before the box clears
101
105
  return { ok: true };
102
106
  }
103
107
 
104
- return <CommentBox comments={comments} onPost={post} author={me} mentions={findPeople} />;
108
+ if (loading) return null;
109
+ return <CommentBox comments={comments} onPost={post} author={user ? { name: user.email } : undefined} mentions={findPeople} />;
105
110
  }
106
111
  ```
107
112
 
113
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
114
+
108
115
  ## What it will not do
109
116
 
110
117
  - Edit or delete a posted comment, react, or reply in a nested thread: the
@@ -107,10 +107,10 @@ It is a client component: render it from a `"use client"` component.
107
107
  ## Example
108
108
 
109
109
  ```jsx
110
- // components/Contact.jsx
110
+ // components/Contact.jsx — client: a message is a row
111
111
  "use client";
112
+ import { insertRow } from "../lib/actions";
112
113
  import { ContactSection } from "./blocks/contact-section";
113
- import { sendContactMessage } from "../lib/actions"; // your server action
114
114
 
115
115
  export default function Contact() {
116
116
  return (
@@ -124,15 +124,16 @@ export default function Contact() {
124
124
  ]}
125
125
  topics={{ options: ["Sales", "Support", "Partnerships"], required: true }}
126
126
  consent="I agree to the privacy policy."
127
- onSubmit={async (values) => {
128
- const res = await sendContactMessage(values);
129
- return res.ok ? { ok: true } : { ok: false, error: res.error };
130
- }}
127
+ onSubmit={(values) => insertRow({ source: "contact_messages", values })} // { ok, id } or { ok: false, error }
131
128
  />
132
129
  );
133
130
  }
134
131
  ```
135
132
 
133
+ The row is the message: the app's agent, or a job, answers it.
134
+
135
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
136
+
136
137
  ## What it will not do
137
138
 
138
139
  - Send anything: `onSubmit` is yours (save a row, email the team).
@@ -93,33 +93,55 @@ creates, fields }` (each row's `__problems` is `{ key: message }`) ·
93
93
  ## Example
94
94
 
95
95
  ```jsx
96
+ // surfaces/import.jsx — client: contacts from a spreadsheet
96
97
  "use client";
97
98
  import { useRouter } from "next/navigation";
98
- import { insertRow } from "../lib/actions";
99
- import { CsvImport } from "./blocks/csv-import";
99
+ import { useRows } from "../lib/data";
100
+ import { runAction } from "../lib/actions";
101
+ import { CsvImport } from "../components/blocks/csv-import";
100
102
 
101
- export default function ImportContacts({ fields, team }) {
103
+ const FIELDS = [
104
+ { key: "name", name: "Name", type: "text", required: true },
105
+ { key: "email", name: "Email", type: "text", required: true },
106
+ { key: "owner", name: "Owner", type: "person" },
107
+ ];
108
+
109
+ export default function ImportContacts() {
102
110
  const router = useRouter();
111
+ const { rows: team } = useRows({ source: "people", orderBy: "name asc" });
103
112
  return (
104
113
  <CsvImport
105
- fields={fields}
114
+ fields={FIELDS}
106
115
  people={team}
107
116
  noun="contact"
108
117
  onImport={async ({ rows }) => {
109
- let imported = 0;
110
- for (const { id, ...values } of rows) {
111
- const r = await insertRow({ source: "public.contacts", values });
112
- if (!r.ok) return { ok: false, error: `Stopped after ${imported} contacts: ${r.error}` };
113
- imported++;
114
- }
115
- return { ok: true, imported };
118
+ // One action, one transaction: a write per row would stop halfway,
119
+ // and the door takes 300 writes a minute.
120
+ const r = await runAction({ name: "import_contacts", args: { rows: rows.map(({ id, ...values }) => values) } });
121
+ return r.ok ? { ok: true, imported: r.result?.imported } : r;
116
122
  }}
117
- onDone={() => router.push("/contacts")}
123
+ onDone={() => router.push("/s/contacts")}
118
124
  />
119
125
  );
120
126
  }
121
127
  ```
122
128
 
129
+ The action, named in `references/actions.md` and made with `sql`:
130
+
131
+ ```sql
132
+ create or replace function import_contacts(args jsonb) returns jsonb
133
+ language sql as $$
134
+ with made as (
135
+ insert into contacts (name, email, owner)
136
+ select name, email, owner from jsonb_populate_recordset(null::contacts, args->'rows')
137
+ returning 1
138
+ )
139
+ select jsonb_build_object('imported', count(*)) from made
140
+ $$;
141
+ ```
142
+
143
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
144
+
123
145
  ## What it will not do
124
146
 
125
147
  - Excel files (.xlsx) are not read: save the sheet as CSV first (the
@@ -252,8 +252,19 @@ argument, `ids`, is every selected id, including rows not on this page.
252
252
 
253
253
  ```jsx
254
254
  const [q, setQ] = useState({ search: "", filters: {}, sort: [], page: 1, pageSize: 25 });
255
- // …read rows + total for q on the server (a Server Action, a route)…
256
- <DataTable manual rows={pageRows} rowCount={total} loading={pending}
255
+ // One page through the door: the filter grammar, an order, limit and offset.
256
+ const filter = {
257
+ ...Object.fromEntries(Object.entries(q.filters).filter(([, values]) => values.length)),
258
+ customer: q.search ? { prefix: q.search } : undefined, // starts with, case ignored
259
+ };
260
+ const page = useRows({ source: "invoices", filter, orderBy: q.sort.map((s) => `${s.key} ${s.dir}`).join(", ") || undefined,
261
+ limit: q.pageSize, offset: (q.page - 1) * q.pageSize });
262
+ const total = useCount({ source: "invoices", filter });
263
+ const { status, ...others } = filter; // a facet counts over the other filters
264
+ const byStatus = useCountBy({ source: "invoices", column: "status", filter: others });
265
+ const statusCounts = Object.entries(byStatus.counts).map(([value, count]) => ({ value, label: value, count }));
266
+
267
+ <DataTable manual rows={page.rows} rowCount={total.count} loading={page.loading || total.loading}
257
268
  search={q.search} onSearchChange={(search) => setQ((s) => ({ ...s, search }))}
258
269
  filters={q.filters} onFiltersChange={(filters) => setQ((s) => ({ ...s, filters }))}
259
270
  sort={q.sort} onSortChange={(sort) => setQ((s) => ({ ...s, sort }))}
@@ -262,6 +273,12 @@ const [q, setQ] = useState({ search: "", filters: {}, sort: [], page: 1, pageSiz
262
273
  columns={COLUMNS} facets={[{ key: "status", options: statusCounts }]} />
263
274
  ```
264
275
 
276
+ `useRows`, `useCount` and `useCountBy` are `lib/data.js`'s in a browser
277
+ app. `offset` goes up to 10,000; past it, page with `cursor` (the last
278
+ answer's `next`, given when `orderBy` names one column). A server app's
279
+ own `loadRows` takes no `offset`: its `"use client"` component takes
280
+ these hooks from `lib/door.js`.
281
+
265
282
  The pure helpers the table uses are exported too — `processRows`,
266
283
  `searchRows`, `filterRows`, `sortRows`, `facetOptions`, `nextSort`,
267
284
  `groupRows`, `groupValueOf` and `useControllable` — so a surface can compute the same answer the table
@@ -269,12 +286,25 @@ shows (a total over the filtered rows) without drawing it twice.
269
286
 
270
287
  ## Example
271
288
 
289
+ ```jsx
290
+ // surfaces/invoices.jsx — client: read, hand over
291
+ "use client";
292
+ import { useRows } from "../lib/data";
293
+ import Invoices from "../components/Invoices";
294
+
295
+ export default function InvoicesSurface() {
296
+ const { rows, error, loading } = useRows({ source: "invoices", orderBy: "due asc" });
297
+ if (loading) return null; // rows only once read: to useFixture, a read in flight looks like an empty table
298
+ return <Invoices invoices={rows} error={error} />;
299
+ }
300
+ ```
301
+
272
302
  ```jsx
273
303
  // components/Invoices.jsx — client: the table, the row sheet
274
304
  "use client";
275
305
  import { useState } from "react";
276
306
  import { updateRow } from "../lib/actions";
277
- import { Badge, Button, Sheet } from "./ui";
307
+ import { Badge, Button, Notice, Sheet } from "./ui";
278
308
  import { FixtureNotice, useFixture } from "./parts";
279
309
  import { DataTable } from "./blocks/data-table";
280
310
 
@@ -283,7 +313,7 @@ const COLUMNS = [
283
313
  { key: "number", header: "Invoice", sortable: true, hideable: false },
284
314
  { key: "customer", header: "Customer", sortable: true },
285
315
  { key: "status", header: "Status", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },
286
- { key: "amount", header: "Amount", sortable: true, align: "end", cell: (r) => `$${r.amount.toLocaleString()}` },
316
+ { key: "amount", header: "Amount", sortable: true, align: "end", cell: (r) => `$${Number(r.amount).toLocaleString()}` }, // numeric arrives as text
287
317
  { key: "due", header: "Due", sortable: true },
288
318
  ];
289
319
  const SAMPLE = [
@@ -291,14 +321,15 @@ const SAMPLE = [
291
321
  { number: "INV-1002", customer: "Acme Ltd", status: "overdue", amount: 450, due: "2026-09-01" },
292
322
  ];
293
323
 
294
- export default function Invoices({ invoices }) {
324
+ export default function Invoices({ invoices, error }) {
295
325
  const fixture = useFixture(invoices, SAMPLE);
296
326
  const [open, setOpen] = useState(null);
297
327
  const markPaid = (rows) =>
298
- Promise.all(rows.map((r) => updateRow({ source: "public.invoices", id: r.id, patch: { status: "paid" } })))
328
+ Promise.all(rows.map((r) => updateRow({ source: "invoices", id: r.id, patch: { status: "paid" } })))
299
329
  .then((all) => all.find((r) => r.ok === false) ?? { ok: true });
300
330
  return (
301
331
  <div className="grid gap-4">
332
+ {error ? <Notice tone="danger">{error}</Notice> : null}
302
333
  {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}
303
334
  <DataTable
304
335
  label="Invoices"
@@ -323,6 +354,8 @@ export default function Invoices({ invoices }) {
323
354
  }
324
355
  ```
325
356
 
357
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
358
+
326
359
  ### Details, groups and totals
327
360
 
328
361
  A budget: line items under departments, sorted within them, a subtotal
@@ -364,8 +397,8 @@ the row and the rest in the menu:
364
397
  ]} />
365
398
  ```
366
399
 
367
- `setStatus` and `remove` return a promise of `{ ok }`, as a Server Action
368
- does; a failure is said above the table.
400
+ `setStatus` and `remove` return a promise of `{ ok }`, as `updateRow` and
401
+ `deleteRow` do; a failure is said above the table.
369
402
 
370
403
  ## What it will not do
371
404
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  // Diagram — Mermaid text drawn in the workspace theme, with zoom and pan.
4
4
  //
5
- // Client only, one static bundle: Mermaid needs the DOM to measure text,
5
+ // Client only, one bundle: Mermaid needs the DOM to measure text,
6
6
  // and the app's check refuses import() (so no next/dynamic). The modules
7
7
  // load with the page; the drawing happens after the first effect (the
8
8
  // mount guard). Until then — on the server and in the first client paint —
@@ -144,20 +144,30 @@ on a partial one):
144
144
  ## Example
145
145
 
146
146
  ```jsx
147
- // components/DealNotes.jsx — client
147
+ // surfaces/deal.jsx — client: one deal's notes
148
148
  "use client";
149
149
  import { useState } from "react";
150
+ import { useSearchParams } from "next/navigation";
151
+ import { useRow, useRows } from "../lib/data";
150
152
  import { updateRow } from "../lib/actions";
151
- import { DocEditor } from "./blocks/doc-editor";
153
+ import { DocEditor } from "../components/blocks/doc-editor";
154
+
155
+ export default function DealSurface() {
156
+ const id = useSearchParams().get("row");
157
+ const { row: deal, loading } = useRow({ source: "deals", id });
158
+ const { rows: team } = useRows({ source: "people", orderBy: "name asc" });
159
+ if (loading || !deal) return null;
160
+ return <DealNotes key={deal.id} deal={deal} team={team} />;
161
+ }
152
162
 
153
- export default function DealNotes({ deal, team }) {
154
- const [doc, setDoc] = useState(deal.notes); // jsonb, may be null
163
+ function DealNotes({ deal, team }) {
164
+ const [doc, setDoc] = useState(deal.notes); // jsonb, may be null; read once per deal, so a read never moves the cursor
155
165
  return (
156
166
  <DocEditor
157
167
  value={doc}
158
168
  onChange={(d) => {
159
169
  setDoc(d);
160
- updateRow({ source: "public.deals", id: deal.id, patch: { notes: d } }); // debounce this in a real app
170
+ updateRow({ source: "deals", id: deal.id, patch: { notes: d } }); // debounce this in a real app
161
171
  }}
162
172
  mentions={async (q) => team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }))}
163
173
  placeholder="Notes on this deal…"
@@ -167,13 +177,15 @@ export default function DealNotes({ deal, team }) {
167
177
  }
168
178
  ```
169
179
 
180
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
181
+
170
182
  A comment box that posts on Cmd/Ctrl+Enter and clears itself:
171
183
 
172
184
  ```jsx
173
185
  const box = useDocEditor();
174
186
  const post = async (doc) => {
175
187
  if (isDocEmpty(doc)) return;
176
- const res = await insertRow({ source: "public.comments", values: { body: doc, body_text: docToText(doc) } });
188
+ const res = await insertRow({ source: "comments", values: { body: doc, body_text: docToText(doc) } });
177
189
  if (res.ok) box.current?.clear();
178
190
  };
179
191
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  // DocEditor — BlockNote, in the workspace theme, behind one set of props.
4
4
  //
5
- // Client only, one static bundle. BlockNote needs the DOM, and the app's
5
+ // Client only, one bundle. BlockNote needs the DOM, and the app's
6
6
  // check refuses import() (so no next/dynamic): the modules load with the
7
7
  // page, and the editor itself is created only after the first effect
8
8
  // (the mount guard). Until then — on the server and in the first client
@@ -75,22 +75,27 @@ out without waiting for the answer.
75
75
  ## Example
76
76
 
77
77
  ```jsx
78
- // components/WikiPage.jsx — client
78
+ // surfaces/wiki.jsx — client: one page, read and saved
79
79
  "use client";
80
+ import { useSearchParams } from "next/navigation";
81
+ import { useRow, useRows } from "../lib/data";
80
82
  import { updateRow } from "../lib/actions";
81
- import { DocPage } from "./blocks/doc-page";
83
+ import { DocPage } from "../components/blocks/doc-page";
82
84
 
83
- export default function WikiPage({ page, team, canEdit }) {
85
+ export default function WikiPage() {
86
+ const id = useSearchParams().get("row");
87
+ const { row: page, loading } = useRow({ source: "wiki_pages", id });
88
+ const { rows: team } = useRows({ source: "people", orderBy: "name asc" });
89
+ if (loading || !page) return null;
84
90
  return (
85
91
  <DocPage
86
92
  id={page.id}
87
93
  title={page.title}
88
94
  icon={page.icon}
89
95
  doc={page.body}
90
- readOnly={!canEdit}
91
- meta={`Edited by ${page.edited_by_name}`}
96
+ meta={`Edited by ${page.edited_by}`} // a before-update trigger sets edited_by (the app's README §5)
92
97
  onSave={({ id, title, icon, doc }) =>
93
- updateRow({ source: "public.wiki_pages", id, patch: { title, icon, body: doc } })
98
+ updateRow({ source: "wiki_pages", id, patch: { title, icon, body: doc } })
94
99
  }
95
100
  mentions={async (q) =>
96
101
  team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }))
@@ -100,6 +105,11 @@ export default function WikiPage({ page, team, canEdit }) {
100
105
  }
101
106
  ```
102
107
 
108
+ The page reads `title`, `icon` and `doc` when `id` changes and owns the
109
+ draft in between, so the read after each save never moves the cursor.
110
+
111
+ In a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.
112
+
103
113
  For search or a preview, keep a plain-text copy beside the doc:
104
114
  `docToText(doc)` from the doc-editor block, saved in the same `onSave`.
105
115