makersclaw 0.5.2 → 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.
- package/README.md +73 -7
- package/package.json +3 -2
- package/r/annotate.json +4 -4
- package/r/approval-request.json +3 -3
- package/r/audio-player.json +3 -3
- package/r/auth-form.json +2 -2
- package/r/board.json +4 -4
- package/r/calendar-view.json +4 -4
- package/r/cart.json +3 -3
- package/r/chart.json +3 -3
- package/r/chat.json +3 -3
- package/r/code-editor.json +4 -4
- package/r/comment-box.json +4 -4
- package/r/contact-section.json +3 -3
- package/r/core.json +3 -3
- package/r/csv-import.json +4 -4
- package/r/data-table.json +3 -3
- package/r/diagram.json +3 -3
- package/r/doc-editor.json +4 -4
- package/r/doc-page.json +4 -4
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +3 -3
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +3 -3
- package/r/gantt.json +4 -4
- package/r/job-panel.json +3 -3
- package/r/json-editor.json +4 -4
- package/r/map.json +5 -5
- package/r/menubar.json +2 -2
- package/r/mind-map.json +4 -4
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +6 -6
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +6 -6
- package/r/product-detail.json +3 -3
- package/r/product-grid.json +4 -4
- package/r/record-form.json +3 -3
- package/r/record-notes.json +5 -5
- package/r/registry.json +121 -121
- package/r/search-dialog.json +3 -3
- package/r/settings-list.json +3 -3
- package/r/setup-checklist.json +3 -3
- package/r/share-dialog.json +3 -3
- package/r/sidebar.json +2 -2
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +5 -5
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +4 -4
- package/r/task-list.json +4 -4
- package/r/task-model.json +3 -3
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +3 -3
- package/r/whiteboard.json +4 -4
- package/registry/blocks/annotate/README.md +14 -5
- package/registry/blocks/approval-request/README.md +15 -8
- package/registry/blocks/audio-player/README.md +17 -10
- package/registry/blocks/board/README.md +13 -9
- package/registry/blocks/board/board.jsx +1 -1
- package/registry/blocks/calendar-view/README.md +28 -4
- package/registry/blocks/cart/README.md +14 -10
- package/registry/blocks/chart/README.md +14 -7
- package/registry/blocks/chat/README.md +43 -13
- package/registry/blocks/code-editor/README.md +4 -0
- package/registry/blocks/code-editor/code-editor.jsx +1 -1
- package/registry/blocks/comment-box/README.md +20 -13
- package/registry/blocks/contact-section/README.md +7 -6
- package/registry/blocks/csv-import/README.md +34 -12
- package/registry/blocks/data-table/README.md +41 -8
- package/registry/blocks/diagram/diagram.jsx +1 -1
- package/registry/blocks/doc-editor/README.md +18 -6
- package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
- package/registry/blocks/doc-page/README.md +16 -6
- package/registry/blocks/file-upload/README.md +7 -1
- package/registry/blocks/flow-canvas/README.md +16 -5
- package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
- package/registry/blocks/footer/README.md +4 -2
- package/registry/blocks/gantt/README.md +18 -12
- package/registry/blocks/job-panel/README.md +1 -1
- package/registry/blocks/json-editor/README.md +16 -4
- package/registry/blocks/map/README.md +19 -3
- package/registry/blocks/map/map-view.jsx +1 -1
- package/registry/blocks/map/maplibre.js +1 -1
- package/registry/blocks/mind-map/README.md +16 -5
- package/registry/blocks/org-chart/README.md +10 -7
- package/registry/blocks/page-builder/README.md +13 -6
- package/registry/blocks/page-builder/page-builder.jsx +1 -1
- package/registry/blocks/page-builder/page-view.jsx +1 -1
- package/registry/blocks/page-builder/page.js +1 -1
- package/registry/blocks/pdf-viewer/README.md +22 -20
- package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
- package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
- package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
- package/registry/blocks/product-detail/README.md +13 -7
- package/registry/blocks/product-detail/demo.jsx +1 -1
- package/registry/blocks/product-grid/README.md +12 -10
- package/registry/blocks/record-form/README.md +26 -9
- package/registry/blocks/record-notes/README.md +42 -30
- package/registry/blocks/search-dialog/README.md +38 -7
- package/registry/blocks/settings-list/README.md +29 -17
- package/registry/blocks/setup-checklist/README.md +12 -14
- package/registry/blocks/share-dialog/README.md +1 -1
- package/registry/blocks/signature-pad/README.md +15 -4
- package/registry/blocks/sketch-canvas/README.md +13 -4
- package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
- package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
- package/registry/blocks/spreadsheet/README.md +17 -8
- package/registry/blocks/stat-cards/README.md +17 -8
- package/registry/blocks/task-list/README.md +14 -11
- package/registry/blocks/task-model/README.md +3 -3
- package/registry/blocks/transaction-list/README.md +15 -7
- package/registry/blocks/whiteboard/README.md +19 -13
- package/src/cli.mjs +65 -9
- package/src/install.mjs +360 -21
- package/src/lockfile.mjs +468 -0
- package/src/pm.mjs +473 -0
- package/src/registry.mjs +125 -1
- package/templates/next/browser/app/auto-refresh.jsx +67 -0
- package/templates/next/browser/app/layout.jsx +94 -0
- package/templates/next/browser/app/page.jsx +60 -0
- package/templates/next/browser/app/s/[key]/page.jsx +95 -0
- package/templates/next/browser/lib/actions.js +16 -0
- package/templates/next/browser/lib/data.js +43 -0
- package/templates/next/files/README.md +818 -82
- package/templates/next/files/app/claw-alive/route.js +2 -2
- package/templates/next/files/app/frame.js +134 -9
- package/templates/next/files/app/layout.jsx +2 -2
- package/templates/next/files/check/run.mjs +1491 -53
- package/templates/next/files/components/ui/item.jsx +2 -2
- package/templates/next/files/components/ui/notice.jsx +1 -1
- package/templates/next/files/components/ui/separator.jsx +1 -1
- package/templates/next/files/lib/actions.js +171 -52
- package/templates/next/files/lib/brain-server.js +96 -0
- package/templates/next/files/lib/brain.js +413 -0
- package/templates/next/files/lib/claw.js +11 -0
- package/templates/next/files/lib/data.js +120 -30
- package/templates/next/files/lib/db.js +97 -17
- package/templates/next/files/lib/door.js +680 -0
- package/templates/next/files/next.config.mjs +85 -9
- package/templates/next/files/package.json +3 -3
- package/templates/next/files/{middleware.js → proxy.js} +13 -6
- package/templates/next/files/surfaces.js +3 -2
- package/templates/next/pnpm-lock.yaml +5870 -0
- package/templates/next/template.json +13 -6
- 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
|
+
}
|