makersclaw 0.6.0 → 0.8.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 (152) hide show
  1. package/README.md +56 -9
  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 +6 -6
  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 +10 -4
  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 +126 -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 +3 -3
  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 +24 -12
  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/chart/cartesian.jsx +1 -1
  67. package/registry/blocks/chart/core.jsx +33 -23
  68. package/registry/blocks/chart/pie.jsx +1 -1
  69. package/registry/blocks/chat/README.md +47 -14
  70. package/registry/blocks/chat/demo.jsx +2 -2
  71. package/registry/blocks/code-editor/README.md +4 -0
  72. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  73. package/registry/blocks/comment-box/README.md +20 -13
  74. package/registry/blocks/contact-section/README.md +7 -6
  75. package/registry/blocks/csv-import/README.md +34 -12
  76. package/registry/blocks/data-table/README.md +41 -8
  77. package/registry/blocks/diagram/diagram.jsx +1 -1
  78. package/registry/blocks/doc-editor/README.md +18 -6
  79. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  80. package/registry/blocks/doc-page/README.md +16 -6
  81. package/registry/blocks/file-upload/README.md +7 -1
  82. package/registry/blocks/flow-canvas/README.md +30 -12
  83. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  84. package/registry/blocks/footer/README.md +7 -4
  85. package/registry/blocks/gantt/README.md +18 -12
  86. package/registry/blocks/job-panel/README.md +1 -1
  87. package/registry/blocks/json-editor/README.md +16 -4
  88. package/registry/blocks/map/README.md +19 -3
  89. package/registry/blocks/map/map-view.jsx +1 -1
  90. package/registry/blocks/map/maplibre.js +1 -1
  91. package/registry/blocks/mind-map/README.md +20 -7
  92. package/registry/blocks/org-chart/README.md +14 -9
  93. package/registry/blocks/page-builder/README.md +14 -7
  94. package/registry/blocks/page-builder/demo.jsx +3 -3
  95. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  96. package/registry/blocks/page-builder/page-view.jsx +1 -1
  97. package/registry/blocks/page-builder/page.js +1 -1
  98. package/registry/blocks/pdf-viewer/README.md +26 -21
  99. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  100. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  101. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  102. package/registry/blocks/product-detail/README.md +13 -7
  103. package/registry/blocks/product-detail/demo.jsx +1 -1
  104. package/registry/blocks/product-grid/README.md +12 -10
  105. package/registry/blocks/record-form/README.md +26 -9
  106. package/registry/blocks/record-notes/README.md +42 -30
  107. package/registry/blocks/search-dialog/README.md +38 -7
  108. package/registry/blocks/settings-list/README.md +29 -17
  109. package/registry/blocks/setup-checklist/README.md +12 -14
  110. package/registry/blocks/share-dialog/README.md +1 -1
  111. package/registry/blocks/sidebar/README.md +23 -13
  112. package/registry/blocks/signature-pad/README.md +15 -4
  113. package/registry/blocks/sketch-canvas/README.md +25 -10
  114. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  115. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  116. package/registry/blocks/spreadsheet/README.md +17 -8
  117. package/registry/blocks/stat-cards/README.md +17 -8
  118. package/registry/blocks/task-list/README.md +14 -11
  119. package/registry/blocks/task-model/README.md +3 -3
  120. package/registry/blocks/transaction-list/README.md +15 -7
  121. package/registry/blocks/whiteboard/README.md +34 -22
  122. package/src/cli.mjs +33 -6
  123. package/src/install.mjs +285 -26
  124. package/src/registry.mjs +109 -1
  125. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  126. package/templates/next/browser/app/layout.jsx +94 -0
  127. package/templates/next/browser/app/page.jsx +60 -0
  128. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  129. package/templates/next/browser/lib/actions.js +16 -0
  130. package/templates/next/browser/lib/data.js +43 -0
  131. package/templates/next/files/README.md +1100 -89
  132. package/templates/next/files/app/frame.js +128 -4
  133. package/templates/next/files/app/shell.jsx +54 -37
  134. package/templates/next/files/check/run.mjs +1514 -45
  135. package/templates/next/files/components/ui/index.js +1 -0
  136. package/templates/next/files/components/ui/item.jsx +2 -2
  137. package/templates/next/files/components/ui/layout.jsx +307 -0
  138. package/templates/next/files/components/ui/notice.jsx +1 -1
  139. package/templates/next/files/components/ui/separator.jsx +1 -1
  140. package/templates/next/files/lib/actions.js +170 -51
  141. package/templates/next/files/lib/brain-server.js +96 -0
  142. package/templates/next/files/lib/brain.js +413 -0
  143. package/templates/next/files/lib/claw.js +15 -1
  144. package/templates/next/files/lib/data.js +119 -29
  145. package/templates/next/files/lib/db.js +97 -17
  146. package/templates/next/files/lib/door.js +680 -0
  147. package/templates/next/files/lib/hooks.js +71 -2
  148. package/templates/next/files/lib/platform.js +93 -4
  149. package/templates/next/files/next.config.mjs +36 -2
  150. package/templates/next/files/surfaces.js +3 -2
  151. package/templates/next/template.json +18 -3
  152. package/templates/next/files/schema.sql +0 -22
@@ -0,0 +1,680 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { claw } from "./claw";
5
+ import { surfaceOfPath } from "./platform";
6
+
7
+ // The app's own tables through the data door, from the browser (claw
8
+ // docs/brain/STATIC-SPEC.md §3; the door's routes are CONTRACTS §8, "The
9
+ // own-tables half").
10
+ //
11
+ // A browser app has no server of its own. It reads and writes its tables
12
+ // through _claw/data, which the Makersclaw gateway answers under the
13
+ // app's path: the grant cookie says who is asking, the work runs on the
14
+ // app's own database role in one transaction that knows the member, and
15
+ // the door writes the activity line. This file is the door's client,
16
+ // with the names and shapes of the server data layer (lib/data.js's
17
+ // reads, lib/actions.js's writes), so a surface reads the same in both
18
+ // modes. A browser app's lib/data.js and lib/actions.js re-export it under
19
+ // those names; in a server app a "use client" component may take its
20
+ // hooks from here.
21
+ //
22
+ // The rules it keeps:
23
+ // - Every function resolves and never throws. A read that fails keeps
24
+ // its empty values ({ rows: [], error, code }, and unavailable: true
25
+ // when no door answers: an older Makersclaw, or the app on its own).
26
+ // A write that fails is { ok: false, error, code }, never undefined:
27
+ // a block reads undefined as success.
28
+ // - A filter goes to the door as it is: the one grammar lib/data.js's
29
+ // whereClause reads in a server app. orderBy goes as `order`.
30
+ // - The door's JSON is handed on as it came. A date is its day, a
31
+ // numeric or a bigint is text, as the server layer gives them.
32
+ // - Writes go one at a time, in the order they were called, as Next ran
33
+ // a client's Server Actions: a slow older save never lands after a
34
+ // newer one.
35
+ // - Every write that answers ok calls dataChanged(). Every mounted hook
36
+ // reads again, and whenLanded() resolves once they have all handed
37
+ // out their new rows, in one pass.
38
+ // - Nothing here logs activity: the door writes the line. `data` on
39
+ // updateRow is taken and never sent (a value never goes in a line).
40
+
41
+ /** What a call needs and was not given; its words follow the name. */
42
+ class Refused extends Error {}
43
+
44
+ /** One path segment, encoded, so "a/b" stays one. */
45
+ function segment(value, what) {
46
+ if (value === undefined || value === null || value === "") throw new Refused(`needs ${what}`);
47
+ return encodeURIComponent(String(value));
48
+ }
49
+
50
+ /** "?a=1&b=2", leaving out what was not given. */
51
+ function query(params) {
52
+ const q = new URLSearchParams();
53
+ for (const [key, value] of Object.entries(params)) {
54
+ if (value === undefined || value === null || value === "") continue;
55
+ q.set(key, String(value));
56
+ }
57
+ const text = q.toString();
58
+ return text ? `?${text}` : "";
59
+ }
60
+
61
+ /** A filter as the door reads it: JSON in the query. One that names
62
+ * nothing (every condition undefined, as JSON drops them) is none. */
63
+ function whereOf(filter) {
64
+ if (filter === undefined || filter === null) return undefined;
65
+ const text = JSON.stringify(filter);
66
+ return text === "{}" ? undefined : text;
67
+ }
68
+
69
+ /** id_column, sent only when it is not the door's default, id. */
70
+ function idColumnOf(idColumn) {
71
+ return idColumn && idColumn !== "id" ? String(idColumn) : undefined;
72
+ }
73
+
74
+ /** A whole number from 1 to `most`, `fallback` for anything else, as
75
+ * the server's loadRows reads its limit. */
76
+ function clamp(value, fallback, most) {
77
+ return Math.max(1, Math.min(most, Math.floor(Number(value)) || fallback));
78
+ }
79
+
80
+ /** A positive whole number, or none. */
81
+ function skip(value) {
82
+ const n = Math.floor(Number(value));
83
+ return Number.isFinite(n) && n > 0 ? n : undefined;
84
+ }
85
+
86
+ /** The surface the page is on, for the door's activity line: the <key>
87
+ * of /s/<key> in its path. */
88
+ function pageSurface() {
89
+ return typeof location === "undefined" ? undefined : surfaceOfPath(location.pathname) || undefined;
90
+ }
91
+
92
+ /** A write's surface: the caller's key (a string, or the page's
93
+ * `surface` object), else the page's. null sends none. */
94
+ function surfaceOf(explicit) {
95
+ if (explicit === null) return undefined;
96
+ if (typeof explicit === "string" && explicit) return explicit;
97
+ if (explicit && typeof explicit === "object" && typeof explicit.key === "string" && explicit.key) return explicit.key;
98
+ return pageSurface();
99
+ }
100
+
101
+ /** The keys of an object whose values are given. */
102
+ function given(values) {
103
+ return values && typeof values === "object" && !Array.isArray(values) ? Object.keys(values).filter((k) => values[k] !== undefined) : [];
104
+ }
105
+
106
+ /** A claw() answer → { data }, the door's JSON, or { fail }, why not:
107
+ * { error, code } always, unavailable or aborted when so. */
108
+ function readAnswer(answer) {
109
+ const data = answer?.data;
110
+ const json = Boolean(data) && typeof data === "object" && !Array.isArray(data);
111
+ if (answer?.ok && json && data.ok !== false) return { data };
112
+ if (answer?.ok) {
113
+ // A 2xx that is not the door's success: its refusal, or not its JSON.
114
+ if (!json) return { fail: { error: "the door answered something that is not its JSON", code: "failed" } };
115
+ return { fail: { error: String(data.error || "the door refused"), code: typeof data.code === "string" && data.code ? data.code : "failed" } };
116
+ }
117
+ const fail = { error: String(answer?.error || "the door refused") };
118
+ fail.code = answer?.code || (answer?.aborted ? "aborted" : answer?.unavailable ? "unavailable" : "failed");
119
+ if (answer?.unavailable) fail.unavailable = true;
120
+ if (answer?.aborted) fail.aborted = true;
121
+ return { fail };
122
+ }
123
+
124
+ /** A call refused here, before anything was sent, said as the door would. */
125
+ function refusedHere(name, error, empty = {}) {
126
+ const words = error instanceof Refused ? `${name} ${error.message}` : `${name}: ${error?.message ?? error}`;
127
+ return { ...empty, error: words, code: "bad_request" };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Reads. They resolve and never throw: lib/data.js's names and shapes.
132
+
133
+ /**
134
+ * The reads, each as: `empty()` (what a failure keeps, fresh each
135
+ * time), `path(args)` (under _claw/data; null asks nothing and answers
136
+ * the empty values), `pick(json)` (the answer's values) and, for loadRow,
137
+ * `absent(code)` (a refusal that means "no such row", which the server
138
+ * reads as row: null).
139
+ */
140
+ const READS = {
141
+ loadRows: {
142
+ empty: () => ({ rows: [], next: null }),
143
+ path: ({ source, idColumn, orderBy, limit = 500, filter, offset, cursor }) =>
144
+ `tables/${segment(source, "a source")}/rows` +
145
+ query({ where: whereOf(filter), order: orderBy, limit: clamp(limit, 500, 5000), offset: skip(offset), cursor, id_column: idColumnOf(idColumn) }),
146
+ pick: (json) => ({ rows: listOf(json.rows), next: json.next ?? null }),
147
+ },
148
+ loadRow: {
149
+ empty: () => ({ row: null }),
150
+ path: ({ source, idColumn, id }) => {
151
+ const table = segment(source, "a source");
152
+ if (id === undefined || id === null || id === "") return null;
153
+ return `tables/${table}/rows/${segment(id, "an id")}` + query({ id_column: idColumnOf(idColumn) });
154
+ },
155
+ pick: (json) => ({ row: json.row ?? null }),
156
+ absent: (code) => code === "not_found",
157
+ },
158
+ countRows: {
159
+ empty: () => ({ count: 0 }),
160
+ path: ({ source, filter }) => `tables/${segment(source, "a source")}/counts` + query({ where: whereOf(filter) }),
161
+ pick: (json) => ({ count: json.count ?? 0 }),
162
+ },
163
+ countBy: {
164
+ empty: () => ({ counts: {} }),
165
+ path: ({ source, column, filter }) => {
166
+ const table = segment(source, "a source");
167
+ if (!column) throw new Refused("needs a column");
168
+ return `tables/${table}/counts` + query({ by: column, where: whereOf(filter) });
169
+ },
170
+ pick: (json) => ({ counts: json.by && typeof json.by === "object" && !Array.isArray(json.by) ? json.by : {} }),
171
+ },
172
+ loadActivity: {
173
+ empty: () => ({ rows: [] }),
174
+ path: ({ limit = 100, surface, since } = {}) =>
175
+ "activity" + query({ surface, since: since instanceof Date ? since.toISOString() : since, limit: clamp(limit, 100, 1000) }),
176
+ pick: (json) => ({ rows: listOf(json.rows) }),
177
+ },
178
+ };
179
+
180
+ class NotItsJson extends Error {}
181
+
182
+ function listOf(value) {
183
+ if (!Array.isArray(value)) throw new NotItsJson();
184
+ return value;
185
+ }
186
+
187
+ /** Run one of READS. Never throws. */
188
+ async function read(name, args) {
189
+ const { empty, path, pick, absent } = READS[name];
190
+ let sub;
191
+ try {
192
+ sub = path(args ?? {});
193
+ } catch (error) {
194
+ return refusedHere(name, error, empty());
195
+ }
196
+ if (sub === null) return { ...empty(), error: null };
197
+ let answer;
198
+ try {
199
+ answer = await claw(`data/${sub}`, { signal: args?.signal });
200
+ } catch (error) {
201
+ answer = { ok: false, error: String(error?.message ?? error) };
202
+ }
203
+ const { data, fail } = readAnswer(answer);
204
+ if (fail) return absent?.(fail.code) ? { ...empty(), error: null } : { ...empty(), ...fail };
205
+ try {
206
+ return { ...pick(data), error: null };
207
+ } catch {
208
+ return { ...empty(), error: "the door answered something that is not its JSON", code: "failed" };
209
+ }
210
+ }
211
+
212
+ /** GET _claw/data/tables/<source>/rows: rows of one of the app's own
213
+ * tables or views. `filter` is { column: condition } in the door's
214
+ * grammar (lib/data.js's whereClause), `orderBy` "updated_at desc"
215
+ * (default: idColumn desc), `limit` 500 (at most 5,000), `offset` at
216
+ * most 10,000; `cursor` the last answer's `next`, which the door gives
217
+ * only when orderBy names one column. → { rows, error, next } */
218
+ export function loadRows(args) {
219
+ return read("loadRows", args);
220
+ }
221
+
222
+ /** GET _claw/data/tables/<source>/rows/<id>: one row. `row` is null when
223
+ * there is none, and when no id is given (nothing is asked).
224
+ * → { row, error } */
225
+ export function loadRow(args) {
226
+ return read("loadRow", args);
227
+ }
228
+
229
+ /** GET _claw/data/tables/<source>/counts: how many rows match `filter`.
230
+ * → { count, error } */
231
+ export function countRows(args) {
232
+ return read("countRows", args);
233
+ }
234
+
235
+ /** GET _claw/data/tables/<source>/counts?by=<column>: rows per value of
236
+ * `column`, "null" for none. → { counts: { [value]: n }, error } */
237
+ export function countBy(args) {
238
+ return read("countBy", args);
239
+ }
240
+
241
+ /** GET _claw/data/activity: this app's activity lines, newest first.
242
+ * `surface` narrows to one surface key, `since` (ISO or a Date) to
243
+ * lines after it, `limit` 100 (at most 1,000). Rows: { id, at, action,
244
+ * surface, row_ref, detail, data, by }. → { rows, error } */
245
+ export function loadActivity(args) {
246
+ return read("loadActivity", args);
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Writes. lib/actions.js's names and shapes, and runAction.
251
+
252
+ // One write at a time, in the order called. Next ran a client's Server
253
+ // Actions so; plain fetches overlap, and a whole-document save that
254
+ // answered slowly would land over the newer one after it.
255
+ let turn = Promise.resolve();
256
+
257
+ /** Run `work` once every write called before it has answered. The
258
+ * brain's writes (lib/brain.js) take their turn here too. */
259
+ export function inTurn(work) {
260
+ const run = turn.then(work, work);
261
+ turn = run.then(
262
+ () => undefined,
263
+ () => undefined,
264
+ );
265
+ return run;
266
+ }
267
+
268
+ /** One write: `build(args)` → { path, method, body }. Refused here with
269
+ * nothing sent when an argument is missing; else sent in its turn. A
270
+ * success is the door's JSON as it came, and calls dataChanged(). */
271
+ async function write(name, args, build) {
272
+ let req;
273
+ try {
274
+ req = build(args ?? {});
275
+ // claw() would read a body that cannot be JSON as "not reachable".
276
+ JSON.stringify(req.body);
277
+ } catch (error) {
278
+ return { ok: false, ...refusedHere(name, error) };
279
+ }
280
+ return inTurn(async () => {
281
+ let answer;
282
+ try {
283
+ answer = await claw(`data/${req.path}`, { method: req.method ?? "POST", body: req.body });
284
+ } catch (error) {
285
+ answer = { ok: false, error: String(error?.message ?? error) };
286
+ }
287
+ const { data, fail } = readAnswer(answer);
288
+ if (fail) return { ok: false, ...fail };
289
+ dataChanged();
290
+ return data;
291
+ });
292
+ }
293
+
294
+ /** The row's path: tables/<source>/rows/<id>. */
295
+ function rowPath(source, id) {
296
+ const table = segment(source, "a source");
297
+ return `tables/${table}/rows/${segment(id, "an id")}`;
298
+ }
299
+
300
+ /** A patch: the given columns, or refused. */
301
+ function patchOf(patch) {
302
+ const cols = given(patch);
303
+ if (!cols.length) throw new Refused("has nothing to change");
304
+ return Object.fromEntries(cols.map((c) => [c, patch[c]]));
305
+ }
306
+
307
+ /** Add a row: POST _claw/data/tables/<source>/rows. `values` is
308
+ * { column: value }; the door answers the new row's id (idColumn).
309
+ * `action` defaults to "added" at the door. → { ok, id } */
310
+ export function insertRow(args) {
311
+ return write("insertRow", args, ({ source, idColumn, values, surface, action, detail }) => {
312
+ const table = segment(source, "a source");
313
+ const cols = given(values);
314
+ if (!cols.length) throw new Refused("has nothing to add");
315
+ return {
316
+ path: `tables/${table}/rows`,
317
+ body: {
318
+ values: Object.fromEntries(cols.map((c) => [c, values[c]])),
319
+ id_column: idColumnOf(idColumn),
320
+ action,
321
+ surface: surfaceOf(surface),
322
+ detail,
323
+ },
324
+ };
325
+ });
326
+ }
327
+
328
+ /** Change columns of one row: PATCH _claw/data/tables/<source>/rows/<id>.
329
+ * The door stamps updated_at as the server's updateRow does. `log:
330
+ * false` writes no activity line (a save that is typing, not a
331
+ * decision). `data` is taken and not sent. → { ok } */
332
+ export function updateRow(args) {
333
+ return write("updateRow", args, ({ source, idColumn, id, patch, surface, action, detail, log }) => ({
334
+ method: "PATCH",
335
+ path: rowPath(source, id),
336
+ body: {
337
+ patch: patchOf(patch),
338
+ id_column: idColumnOf(idColumn),
339
+ action,
340
+ surface: surfaceOf(surface),
341
+ detail,
342
+ log: log === false ? false : undefined,
343
+ },
344
+ }));
345
+ }
346
+
347
+ /** Move a row to another state (a board column, a stage): updateRow of
348
+ * `stateColumn`, its line "moved", "to <to>". → { ok } */
349
+ export function moveRow(args) {
350
+ return write("moveRow", args, ({ source, idColumn, id, stateColumn, to, surface }) => {
351
+ const path = rowPath(source, id);
352
+ if (!stateColumn) throw new Refused("needs a stateColumn");
353
+ if (to === undefined) throw new Refused("needs `to`, the state to move to");
354
+ return {
355
+ method: "PATCH",
356
+ path,
357
+ body: { patch: { [stateColumn]: to }, id_column: idColumnOf(idColumn), action: "moved", surface: surfaceOf(surface), detail: `to ${to}` },
358
+ };
359
+ });
360
+ }
361
+
362
+ /** Remove a row: POST _claw/data/tables/<source>/rows/<id>/delete.
363
+ * → { ok } */
364
+ export function deleteRow(args) {
365
+ return write("deleteRow", args, ({ source, idColumn, id, surface, detail }) => ({
366
+ path: `${rowPath(source, id)}/delete`,
367
+ body: { id_column: idColumnOf(idColumn), surface: surfaceOf(surface), detail },
368
+ }));
369
+ }
370
+
371
+ /**
372
+ * Settings, one key: POST _claw/data/tables/<source>/setting. Table mode
373
+ * updates the (keyColumn, valueColumn) row and inserts one when there is
374
+ * none; json mode sets `key` inside the jsonb `column` of the row `id`.
375
+ * The door does either in one transaction. → { ok }
376
+ */
377
+ export function saveSetting(args) {
378
+ return write("saveSetting", args, ({ mode = "table", source, keyColumn = "key", valueColumn = "value", column, idColumn, id, key, value, surface }) => {
379
+ const table = segment(source, "a source");
380
+ if (mode !== "table" && mode !== "json") throw new Refused(`takes mode "table" or "json", not ${JSON.stringify(mode)}`);
381
+ if (!key) throw new Refused("needs a key");
382
+ const body = { mode, key, value: value === undefined ? null : value, surface: surfaceOf(surface) };
383
+ if (mode === "json") {
384
+ if (!column) throw new Refused("needs the jsonb column, in json mode");
385
+ if (id === undefined || id === null || id === "") throw new Refused("needs the row's id, in json mode");
386
+ Object.assign(body, { column, id, id_column: idColumnOf(idColumn) });
387
+ } else {
388
+ Object.assign(body, { key_column: keyColumn !== "key" ? keyColumn : undefined, value_column: valueColumn !== "value" ? valueColumn : undefined });
389
+ }
390
+ return { path: `tables/${table}/setting`, body };
391
+ });
392
+ }
393
+
394
+ /**
395
+ * Run an action: POST _claw/data/actions/<name>, which calls the app's
396
+ * function `<name>(args jsonb) returns jsonb` in one transaction, as the
397
+ * member. `args` defaults to {}. The door logs the call: `action`
398
+ * defaults to the name, `row_ref` is `rowRef` or the result's id; `log:
399
+ * false` writes no line. A function's own `raise exception` comes back as
400
+ * its sentence, code action_refused. → { ok, result }
401
+ */
402
+ export function runAction(args) {
403
+ return write("runAction", args, ({ name, args: input, surface, action, detail, rowRef, log }) => ({
404
+ path: `actions/${segment(name, "a name")}`,
405
+ body: {
406
+ args: input === undefined ? {} : input,
407
+ action,
408
+ row_ref: rowRef,
409
+ surface: surfaceOf(surface),
410
+ detail,
411
+ log: log === false ? false : undefined,
412
+ },
413
+ }));
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // The refresh. Every mounted hook is a reader. dataChanged() starts one
418
+ // landing: every reader reads again, and when all have answered, their
419
+ // new values are handed out in one pass, so the page moves at once. A
420
+ // change while a landing is out starts one more after it: an answer read
421
+ // before the change must not be the last one shown.
422
+
423
+ /** How long a landing waits for its slowest read before it hands out
424
+ * what has come; a late answer is then handed out when it comes. */
425
+ const LANDING_WAIT_MS = 10_000;
426
+
427
+ const readers = new Set();
428
+ let landing = null;
429
+ let again = false;
430
+ let waiting = [];
431
+
432
+ /** Something changed: every hook on the page reads again. Every write
433
+ * here that answers ok calls it, and so does a browser app's
434
+ * auto-refresh when the door says the tables or the brain moved. */
435
+ export function dataChanged() {
436
+ if (landing) {
437
+ again = true;
438
+ return;
439
+ }
440
+ startLanding();
441
+ }
442
+
443
+ /** Resolves when every read the last dataChanged() started has answered
444
+ * and its hook has handed out its rows. Nothing out: at once. */
445
+ export function whenLanded() {
446
+ if (!landing && !again) return Promise.resolve();
447
+ return new Promise((resolve) => waiting.push(resolve));
448
+ }
449
+
450
+ function startLanding() {
451
+ again = false;
452
+ const run = { open: true, pending: new Set(), handOut: [], timer: null };
453
+ landing = run;
454
+ for (const reader of [...readers]) {
455
+ try {
456
+ reader.reread(run);
457
+ } catch {
458
+ /* a reader that cannot read waits for nothing */
459
+ }
460
+ }
461
+ if (!run.pending.size) {
462
+ closeLanding(run);
463
+ return;
464
+ }
465
+ run.timer = setTimeout(() => closeLanding(run), LANDING_WAIT_MS);
466
+ }
467
+
468
+ /** A read of this landing answered; `handOut` sets its hook's state, or
469
+ * is null (aborted, unmounted). The last one closes the landing. */
470
+ function landed(run, flight, handOut) {
471
+ if (!run.open) {
472
+ handOut?.();
473
+ return;
474
+ }
475
+ run.pending.delete(flight);
476
+ if (handOut) run.handOut.push(handOut);
477
+ if (!run.pending.size) closeLanding(run);
478
+ }
479
+
480
+ function closeLanding(run) {
481
+ if (!run.open) return;
482
+ run.open = false;
483
+ clearTimeout(run.timer);
484
+ // Every hook's new values in one synchronous pass: React renders them
485
+ // together.
486
+ for (const handOut of run.handOut.splice(0)) {
487
+ try {
488
+ handOut();
489
+ } catch {
490
+ /* one hook's failure is not the others' */
491
+ }
492
+ }
493
+ if (landing === run) landing = null;
494
+ if (again) {
495
+ startLanding();
496
+ return;
497
+ }
498
+ for (const resolve of waiting.splice(0)) resolve();
499
+ }
500
+
501
+ // A hook's options are compared by value on every render: a new object
502
+ // with the same values reads nothing. A time is the exception. A server
503
+ // component read once per request, so a bound worked out as it ran
504
+ // (`since: new Date(Date.now() - 7 * 864e5)`) cost one read. A hook runs
505
+ // on every render, and the answer is itself a render: compared to the
506
+ // millisecond, that bound was new options on each one, and the hook
507
+ // blanked and read again forever (a 30 ms round trip made 36 reads a
508
+ // second, over the door's rate limit, with loading true throughout). So
509
+ // a time in a bound (`filter`, the brain's `where`, `since`) is compared
510
+ // to the minute, and when only a time moved the hook keeps what it shows
511
+ // while it reads again. The read always sends the time it was given.
512
+
513
+ /** The options that hold bounds, where a time may be worked out on each
514
+ * render. Only these: an `id` that looks like a time is still an id. */
515
+ const BOUNDS = new Set(["filter", "where", "since"]);
516
+
517
+ /** A time as text: a day and a time of day ("2026-09-30T08:15:00Z"). A
518
+ * day alone does not move between renders, and is compared as it is. */
519
+ const TIME_TEXT = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
520
+ const MINUTE_MS = 60_000;
521
+
522
+ /** `value` with each time in it (a Date, or a time as text) replaced by
523
+ * `as(ms)`, through lists and conditions. */
524
+ function withTimes(value, as) {
525
+ if (value instanceof Date) return as(value.getTime());
526
+ if (typeof value === "string") {
527
+ const ms = TIME_TEXT.test(value) ? Date.parse(value) : NaN;
528
+ return Number.isFinite(ms) ? as(ms) : value;
529
+ }
530
+ if (Array.isArray(value)) return value.map((v) => withTimes(v, as));
531
+ // An object JSON would read through its own toJSON is left to it.
532
+ if (value && typeof value === "object" && typeof value.toJSON !== "function") {
533
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, withTimes(v, as)]));
534
+ }
535
+ return value;
536
+ }
537
+
538
+ const toMinute = (ms) => `\u0000minute ${Math.floor(ms / MINUTE_MS)}`;
539
+ const anyTime = () => "\u0000time";
540
+
541
+ /** A hook's options as text, each time in a bound replaced by `as(ms)`.
542
+ * The top-level `signal` is the hook's own. */
543
+ function optionsText(opts, as) {
544
+ try {
545
+ if (!opts || typeof opts !== "object" || Array.isArray(opts)) return JSON.stringify(opts ?? null);
546
+ const plain = {};
547
+ for (const [name, value] of Object.entries(opts)) {
548
+ if (name !== "signal") plain[name] = BOUNDS.has(name) ? withTimes(value, as) : value;
549
+ }
550
+ return JSON.stringify(plain);
551
+ } catch {
552
+ return "unreadable";
553
+ }
554
+ }
555
+
556
+ /** The key: a change of any value reads again, a time's to the minute. */
557
+ const keyOf = (opts) => optionsText(opts, toMinute);
558
+
559
+ /** The question without its times: the same shape and a new key means
560
+ * only a time moved, and what is shown stays until the new answer. */
561
+ const shapeOf = (opts) => optionsText(opts, anyTime);
562
+
563
+ /** A read's answer into a hook's state. A failure keeps what is shown
564
+ * for these options, or for these options at an earlier time, with the
565
+ * error; with nothing shown yet, the empty values. */
566
+ function settle(state, key, shape, answer, empty) {
567
+ if (answer && answer.error === null) {
568
+ const { aborted, ...values } = answer;
569
+ return { ...values, key, shape, loading: false };
570
+ }
571
+ const why = { error: String(answer?.error ?? "the read failed") };
572
+ if (answer?.code) why.code = answer.code;
573
+ if (answer?.unavailable) why.unavailable = true;
574
+ if ((state.key === key || state.shape === shape) && !state.loading) {
575
+ const { code, unavailable, ...shown } = state;
576
+ return { ...shown, ...why, key };
577
+ }
578
+ return { key, shape, ...empty(), ...why, loading: false };
579
+ }
580
+
581
+ /**
582
+ * The hooks' one machine, for lib/data's five and lib/brain.js's four:
583
+ * `read(args)` is one of the reads above (it resolves, never throws, and
584
+ * takes a `signal`), `empty()` the values before the first answer, and
585
+ * `idle(opts)` true when there is nothing to ask (useRow with no id).
586
+ *
587
+ * - `loading` is true until the first answer for these options, and
588
+ * again when they change. A re-read keeps the last values, and
589
+ * loading false, until the new ones land.
590
+ * - Options are compared by value; a time in a bound to the minute.
591
+ * When only such a time moved, the hook reads again as a re-read
592
+ * does: the last values stay, loading false (above, keyOf).
593
+ * - Every answer is a new array (a new object), even when the rows
594
+ * are equal: the blocks' optimistic overrides wait for one.
595
+ * - A read no longer needed is aborted: on unmount, when the options
596
+ * change, when a newer read of the same hook starts.
597
+ * - A failed re-read keeps the last values and sets `error`.
598
+ * → { ...the read's values, error, loading, reload }
599
+ */
600
+ export function useDoorRead(read, opts, { empty = () => ({}), idle } = {}) {
601
+ const key = keyOf(opts);
602
+ const shape = shapeOf(opts);
603
+ const still = Boolean(idle?.(opts ?? {}));
604
+ const blank = useMemo(() => ({ key, shape, ...empty(), error: null, loading: !still }), [key, shape, still]);
605
+ const [state, setState] = useState(blank);
606
+ const latest = useRef({ read, opts, empty });
607
+ latest.current = { read, opts, empty };
608
+ const own = useRef(null);
609
+
610
+ useEffect(() => {
611
+ if (still) return undefined;
612
+ let alive = true;
613
+ let flight = null;
614
+ const reader = (run) => {
615
+ // An answer still out may predate what changed: this read replaces it.
616
+ flight?.controller.abort();
617
+ const mine = { controller: new AbortController() };
618
+ flight = mine;
619
+ if (run) run.pending.add(mine);
620
+ const { read: readNow, opts: now, empty: emptyNow } = latest.current;
621
+ return Promise.resolve()
622
+ .then(() => readNow({ ...(now && typeof now === "object" ? now : {}), signal: mine.controller.signal }))
623
+ .catch((error) => ({ ...emptyNow(), error: String(error?.message ?? error), code: "failed" }))
624
+ .then((answer) => {
625
+ if (flight === mine) flight = null;
626
+ const wanted = alive && !mine.controller.signal.aborted && !answer?.aborted;
627
+ const handOut = wanted ? () => setState((s) => settle(s, key, shape, answer, emptyNow)) : null;
628
+ if (run) landed(run, mine, handOut);
629
+ else handOut?.();
630
+ });
631
+ };
632
+ const entry = { reread: reader };
633
+ readers.add(entry);
634
+ own.current = reader;
635
+ reader(null);
636
+ return () => {
637
+ alive = false;
638
+ readers.delete(entry);
639
+ if (own.current === reader) own.current = null;
640
+ flight?.controller.abort();
641
+ };
642
+ }, [key, shape, still]);
643
+
644
+ const reload = useCallback(() => (own.current ? own.current(null) : Promise.resolve()), []);
645
+ // New options show the empty values, loading, until they answer: useRow
646
+ // with a new id must never show the old record. Only a time that moved
647
+ // keeps what is shown, while the effect above reads again.
648
+ const current = state.key === key || (!still && state.shape === shape) ? state : blank;
649
+ const { key: _key, shape: _shape, ...shown } = current;
650
+ return { ...shown, reload };
651
+ }
652
+
653
+ const readOf = (name) => (args) => read(name, args);
654
+ const idOf = (opts) => opts?.id === undefined || opts?.id === null || opts?.id === "";
655
+
656
+ /** loadRows as a hook. → { rows, next, error, loading, reload } */
657
+ export function useRows(opts) {
658
+ return useDoorRead(readOf("loadRows"), opts, { empty: READS.loadRows.empty });
659
+ }
660
+
661
+ /** loadRow as a hook; no id asks nothing and answers row: null.
662
+ * → { row, error, loading, reload } */
663
+ export function useRow(opts) {
664
+ return useDoorRead(readOf("loadRow"), opts, { empty: READS.loadRow.empty, idle: idOf });
665
+ }
666
+
667
+ /** countRows as a hook. → { count, error, loading, reload } */
668
+ export function useCount(opts) {
669
+ return useDoorRead(readOf("countRows"), opts, { empty: READS.countRows.empty });
670
+ }
671
+
672
+ /** countBy as a hook. → { counts, error, loading, reload } */
673
+ export function useCountBy(opts) {
674
+ return useDoorRead(readOf("countBy"), opts, { empty: READS.countBy.empty });
675
+ }
676
+
677
+ /** loadActivity as a hook. → { rows, error, loading, reload } */
678
+ export function useActivity(opts) {
679
+ return useDoorRead(readOf("loadActivity"), opts, { empty: READS.loadActivity.empty });
680
+ }