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,413 @@
|
|
|
1
|
+
import { claw } from "./claw";
|
|
2
|
+
import { dataChanged, inTurn, useDoorRead } from "./door";
|
|
3
|
+
import { surfaceOfPath } from "./platform";
|
|
4
|
+
|
|
5
|
+
// The company brain, through the data door.
|
|
6
|
+
//
|
|
7
|
+
// The brain is the workspace's shared records (contacts, companies,
|
|
8
|
+
// deals, whatever kinds the workspace keeps), each in an area: Everyone,
|
|
9
|
+
// Finance… An app never reaches it with SQL: its database login holds
|
|
10
|
+
// none of it. It reads and writes through _claw/data, which the Makersclaw
|
|
11
|
+
// gateway answers itself under this app's path, before anything reaches
|
|
12
|
+
// the app, so it works while the app's machine sleeps (claw
|
|
13
|
+
// docs/brain/CONTRACTS.md §8 and §10). The door hands the app only the
|
|
14
|
+
// kinds its makersclaw.json declares, and only what the member may read.
|
|
15
|
+
//
|
|
16
|
+
// BROWSER, in both modes: call these from "use client" files, in a server
|
|
17
|
+
// app too. The grant cookie is who is asking, and it makes a write a
|
|
18
|
+
// person's press: confirmed, able to overwrite a confirmed value, erase or
|
|
19
|
+
// merge. A write from the app's server would be app code acting:
|
|
20
|
+
// unconfirmed, skipped over a confirmed value, never an erase. So there is
|
|
21
|
+
// no Server Action for the brain. A server component reads with
|
|
22
|
+
// lib/brain-server.js: the same reads, over the proxy's door grant.
|
|
23
|
+
//
|
|
24
|
+
// Every function resolves to a plain object and never throws, like
|
|
25
|
+
// lib/data.js:
|
|
26
|
+
// - a read: the door's JSON as it came, plus `error: null`;
|
|
27
|
+
// - a write: the door's JSON as it came;
|
|
28
|
+
// - a refusal: { ok: false, error, code }. A read keeps its empty values
|
|
29
|
+
// (records: [], next: null…), so a surface renders it like no rows;
|
|
30
|
+
// - a 404 that names no code, or no network: { ok: false, error,
|
|
31
|
+
// unavailable: true }. A claw with no door yet: show a quiet "not
|
|
32
|
+
// connected yet", not an error.
|
|
33
|
+
// Arguments are camelCase (ifVersion); the door's keys are not (if_version).
|
|
34
|
+
//
|
|
35
|
+
// The door writes the activity line for every write it takes, so nothing
|
|
36
|
+
// here calls logActivity. Writes go one at a time, in the order called,
|
|
37
|
+
// in the same turn as lib/door.js's; one that answers ok calls
|
|
38
|
+
// dataChanged(), so every hook on the page reads again: these four
|
|
39
|
+
// (useRecords, useRecord, useRecordCounts, useBrainSearch) and the own
|
|
40
|
+
// tables' (lib/door.js). A server app may also router.refresh().
|
|
41
|
+
|
|
42
|
+
/** A value the door's path needs was not given. */
|
|
43
|
+
class Missing extends Error {}
|
|
44
|
+
|
|
45
|
+
/** One path segment: an id or a kind, encoded, so "a/b" stays one. */
|
|
46
|
+
function segment(value, what) {
|
|
47
|
+
if (value === undefined || value === null || value === "") throw new Missing(`needs ${what}`);
|
|
48
|
+
return encodeURIComponent(String(value));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A list of names as the door reads it, "a,b"; a string is taken as it is. */
|
|
52
|
+
function names(value) {
|
|
53
|
+
if (value === undefined || value === null) return undefined;
|
|
54
|
+
return Array.isArray(value) ? value.map(String).join(",") : String(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A filter as the door reads it: JSON in the query. A string is taken
|
|
58
|
+
* as JSON already. */
|
|
59
|
+
function filter(where) {
|
|
60
|
+
if (where === undefined || where === null) return undefined;
|
|
61
|
+
return typeof where === "string" ? where : JSON.stringify(where);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "?a=1&b=2", leaving out what was not given. */
|
|
65
|
+
function query(params) {
|
|
66
|
+
const q = new URLSearchParams();
|
|
67
|
+
for (const [key, value] of Object.entries(params)) {
|
|
68
|
+
if (value === undefined || value === null || value === "") continue;
|
|
69
|
+
q.set(key, String(value));
|
|
70
|
+
}
|
|
71
|
+
const text = q.toString();
|
|
72
|
+
return text ? `?${text}` : "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The argument off, for a flag the door turns off with "0". */
|
|
76
|
+
const off = (flag) => (flag === false ? 0 : undefined);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The door's read routes: each turns its arguments into the path under
|
|
80
|
+
* _claw/data and names what an empty answer holds. A path of null asks
|
|
81
|
+
* nothing (a blank search). lib/brain-server.js reads the same paths.
|
|
82
|
+
*/
|
|
83
|
+
export const BRAIN_READS = {
|
|
84
|
+
kinds: {
|
|
85
|
+
empty: { kinds: [] },
|
|
86
|
+
path: () => "kinds",
|
|
87
|
+
},
|
|
88
|
+
listRecords: {
|
|
89
|
+
empty: { records: [], next: null },
|
|
90
|
+
path: ({ kind, where, order, limit, cursor, fields, prefix, state }) =>
|
|
91
|
+
`kinds/${segment(kind, "a kind")}/records` +
|
|
92
|
+
query({ where: filter(where), order, limit, cursor, fields: names(fields), prefix, state: off(state) }),
|
|
93
|
+
},
|
|
94
|
+
getRecord: {
|
|
95
|
+
empty: { record: null, links: null },
|
|
96
|
+
path: ({ id, links = false, state }) => `records/${segment(id, "an id")}` + query({ links: links ? 1 : undefined, state: off(state) }),
|
|
97
|
+
},
|
|
98
|
+
countRecords: {
|
|
99
|
+
empty: { count: 0, by: null },
|
|
100
|
+
path: ({ kind, by, where }) => `kinds/${segment(kind, "a kind")}/counts` + query({ by, where: filter(where) }),
|
|
101
|
+
},
|
|
102
|
+
searchBrain: {
|
|
103
|
+
empty: { results: [], meaning: false },
|
|
104
|
+
path: ({ q, kinds, limit, meaning }) =>
|
|
105
|
+
String(q ?? "").trim() ? "search" + query({ q, kinds: names(kinds), limit, meaning: off(meaning) }) : null,
|
|
106
|
+
},
|
|
107
|
+
recordHistory: {
|
|
108
|
+
empty: { changes: [], next: null },
|
|
109
|
+
path: ({ id, limit, cursor }) => `records/${segment(id, "an id")}/history` + query({ limit, cursor }),
|
|
110
|
+
},
|
|
111
|
+
brainChanges: {
|
|
112
|
+
empty: { changes: [], next: null },
|
|
113
|
+
path: ({ since, kinds }) => "changes" + query({ since, kinds: names(kinds) }),
|
|
114
|
+
},
|
|
115
|
+
brainLists: {
|
|
116
|
+
empty: { lists: [] },
|
|
117
|
+
path: ({ kind }) => "lists" + query({ kind }),
|
|
118
|
+
},
|
|
119
|
+
listMembers: {
|
|
120
|
+
empty: { list: null, records: [], next: null },
|
|
121
|
+
path: ({ list, limit, cursor }) => `lists/${segment(list, "a list")}` + query({ limit, cursor }),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** What went wrong before anything was sent, said as the door would. */
|
|
126
|
+
function refusedHere(name, error, empty = {}) {
|
|
127
|
+
const words = error instanceof Missing ? `${name} ${error.message}` : `${name}: ${error?.message ?? error}`;
|
|
128
|
+
return { ...empty, ok: false, error: words, code: "bad_request" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A claw()-shaped answer → the result: the door's JSON as it came (a
|
|
132
|
+
* read's with `error: null` and its empty values under it), or the
|
|
133
|
+
* failure in the same keys. */
|
|
134
|
+
function shaped(answer, empty, read) {
|
|
135
|
+
const data = answer?.data;
|
|
136
|
+
const json = data && typeof data === "object" && !Array.isArray(data);
|
|
137
|
+
if (answer?.ok && json && data.ok !== false) return read ? { ...empty, ...data, error: null } : data;
|
|
138
|
+
const out = { ...empty, ok: false };
|
|
139
|
+
if (answer?.ok) {
|
|
140
|
+
// A 2xx that is not the door's success: its refusal, or not its JSON.
|
|
141
|
+
out.error = json ? String(data.error || "the door refused") : "the door answered something that is not its JSON";
|
|
142
|
+
if (json && typeof data.code === "string" && data.code) out.code = data.code;
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
out.error = String(answer?.error || "the door refused");
|
|
146
|
+
if (answer?.code) out.code = answer.code;
|
|
147
|
+
if (answer?.unavailable) out.unavailable = true;
|
|
148
|
+
if (answer?.aborted) out.aborted = true;
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run one of BRAIN_READS: its path from the arguments, `send(path,
|
|
154
|
+
* signal)` for a claw()-shaped answer, then the result. Never throws.
|
|
155
|
+
* The browser's reads send through claw(); lib/brain-server.js passes a
|
|
156
|
+
* `send` of its own.
|
|
157
|
+
*/
|
|
158
|
+
export async function brainRead(name, args, send) {
|
|
159
|
+
const { empty, path } = BRAIN_READS[name];
|
|
160
|
+
let sub;
|
|
161
|
+
try {
|
|
162
|
+
sub = path(args ?? {});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return refusedHere(name, error, empty);
|
|
165
|
+
}
|
|
166
|
+
if (sub === null) return { ok: true, ...empty, error: null };
|
|
167
|
+
let answer;
|
|
168
|
+
try {
|
|
169
|
+
answer = await send(sub, args?.signal);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
answer = { ok: false, error: String(error?.message ?? error) };
|
|
172
|
+
}
|
|
173
|
+
return shaped(answer, empty, true);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const viaCookie = (path, signal) => claw(`data/${path}`, { signal });
|
|
177
|
+
|
|
178
|
+
/** The surface the page is on, for the door's activity line. */
|
|
179
|
+
function pageSurface() {
|
|
180
|
+
return typeof location === "undefined" ? undefined : surfaceOfPath(location.pathname);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** POST one write route, in its turn (lib/door.js inTurn). `build(args)`
|
|
184
|
+
* → { path, body }, or { done } when there is nothing to send. A write
|
|
185
|
+
* the door takes calls dataChanged(). Never throws. */
|
|
186
|
+
async function post(name, args, build) {
|
|
187
|
+
let built;
|
|
188
|
+
try {
|
|
189
|
+
built = build(args ?? {});
|
|
190
|
+
if (built.done) return built.done;
|
|
191
|
+
// claw() would read a body that cannot be JSON as "not reachable".
|
|
192
|
+
JSON.stringify(built.body);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return refusedHere(name, error);
|
|
195
|
+
}
|
|
196
|
+
return inTurn(async () => {
|
|
197
|
+
let answer;
|
|
198
|
+
try {
|
|
199
|
+
answer = await claw(`data/${built.path}`, { method: "POST", body: built.body });
|
|
200
|
+
} catch (error) {
|
|
201
|
+
answer = { ok: false, error: String(error?.message ?? error) };
|
|
202
|
+
}
|
|
203
|
+
const out = shaped(answer, {}, false);
|
|
204
|
+
if (out.ok !== false) dataChanged();
|
|
205
|
+
return out;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One write as the door reads it. `surface` defaults to the page's
|
|
210
|
+
* surface key (the <key> of /s/<key>, as logActivity's lines name it);
|
|
211
|
+
* null sends none. */
|
|
212
|
+
function writeBody(write) {
|
|
213
|
+
const { kind, id, fields, area, from, ifVersion, owner, surface, detail } = write ?? {};
|
|
214
|
+
return {
|
|
215
|
+
kind,
|
|
216
|
+
id,
|
|
217
|
+
fields,
|
|
218
|
+
area,
|
|
219
|
+
from,
|
|
220
|
+
if_version: ifVersion,
|
|
221
|
+
owner,
|
|
222
|
+
surface: surface === undefined ? pageSurface() : surface,
|
|
223
|
+
detail,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Reads. Each takes an optional `signal` to abort it.
|
|
229
|
+
|
|
230
|
+
/** GET _claw/data/kinds: the kinds this app declared, with the words,
|
|
231
|
+
* fields and counts the member may see.
|
|
232
|
+
* → { ok, brain, kinds: [{ kind, label, plural, fields, key, filters,
|
|
233
|
+
* label_field, reads, writes, count, unconfirmed }], error }. `brain` is
|
|
234
|
+
* false while the workspace has no brain yet (every read is empty). */
|
|
235
|
+
export function kinds(args) {
|
|
236
|
+
return brainRead("kinds", args, viaCookie);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** GET _claw/data/kinds/<kind>/records: one kind's records, newest first.
|
|
240
|
+
* `where` an object (below), `order` "-updated_at" (default) |
|
|
241
|
+
* "updated_at" | "created_at" | "-created_at" | "label" | "-label" |
|
|
242
|
+
* "<field>" | "-<field>", `limit` 50 (at most 200), `cursor` the last
|
|
243
|
+
* answer's `next`, `fields` ["name", "email"] only those, `prefix` the
|
|
244
|
+
* label starts with (pickers), `state: false` leaves each field's state
|
|
245
|
+
* out. → { ok, records, next, error } */
|
|
246
|
+
export function listRecords(args) {
|
|
247
|
+
return brainRead("listRecords", args, viaCookie);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** GET _claw/data/records/<id>: one record; `links: true` adds its links
|
|
251
|
+
* both ways, to records the member may read.
|
|
252
|
+
* → { ok, record, links: { out, in } | null, error } */
|
|
253
|
+
export function getRecord(args) {
|
|
254
|
+
return brainRead("getRecord", args, viaCookie);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** GET _claw/data/kinds/<kind>/counts: how many records match `where`;
|
|
258
|
+
* `by` a field counts per value ("null" for none).
|
|
259
|
+
* → { ok, count, by: { [value]: n } | null, error } */
|
|
260
|
+
export function countRecords(args) {
|
|
261
|
+
return brainRead("countRecords", args, viaCookie);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** GET _claw/data/search: records by words and meaning, across `kinds`
|
|
265
|
+
* (default: every kind the app reads), `limit` at most 50. `meaning:
|
|
266
|
+
* false` is keywords only: nothing is embedded or charged (a picker that
|
|
267
|
+
* searches as the member types). A blank `q` asks nothing.
|
|
268
|
+
* → { ok, meaning, results: [{ record: { id, kind, label, area,
|
|
269
|
+
* updated_at }, score, matched, snippet }], error } */
|
|
270
|
+
export function searchBrain(args) {
|
|
271
|
+
return brainRead("searchBrain", args, viaCookie);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** GET _claw/data/records/<id>/history: who changed what, newest first
|
|
275
|
+
* (`limit` at most 100). → { ok, changes: [{ id, at, op, fields, before,
|
|
276
|
+
* after, by: { member, app, via } }], next, error } */
|
|
277
|
+
export function recordHistory(args) {
|
|
278
|
+
return brainRead("recordHistory", args, viaCookie);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** GET _claw/data/changes: what changed in the member's areas and the
|
|
282
|
+
* app's kinds since `since` (the last answer's `next`; none: from the
|
|
283
|
+
* start). → { ok, changes: [{ id, at, op, kind, record_id }], next, error } */
|
|
284
|
+
export function brainChanges(args) {
|
|
285
|
+
return brainRead("brainChanges", args, viaCookie);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** GET _claw/data/lists: the lists of records, of one `kind` or all.
|
|
289
|
+
* → { ok, lists: [{ id, name, kind, area, count }], error } */
|
|
290
|
+
export function brainLists(args) {
|
|
291
|
+
return brainRead("brainLists", args, viaCookie);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** GET _claw/data/lists/<list>: a list and its records, in order.
|
|
295
|
+
* → { ok, list, records, next, error } */
|
|
296
|
+
export function listMembers(args) {
|
|
297
|
+
return brainRead("listMembers", args, viaCookie);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Writes. The member's press, through the cookie; the door logs each.
|
|
302
|
+
|
|
303
|
+
/** POST _claw/data/write: make or change one record. Without `id` the
|
|
304
|
+
* door finds the record by the kind's key, or makes one. `fields` {
|
|
305
|
+
* name: value | null } (null clears), `area` a new record's area (or a
|
|
306
|
+
* move), `ifVersion` refuses with `changed` when someone saved since,
|
|
307
|
+
* `owner` { kind: "member" | "group", id } | null, `surface` (default the
|
|
308
|
+
* page's) and `detail` (one sentence) for the activity line.
|
|
309
|
+
* → { ok, id, created, changed, version, skipped } | { ok: false, error, code } */
|
|
310
|
+
export function writeRecord(args) {
|
|
311
|
+
return post("writeRecord", args, (write) => ({ path: "write", body: writeBody(write) }));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** POST _claw/data/write with { writes }: up to 100 writes, one
|
|
315
|
+
* transaction, all or none. → { ok, results: [one writeRecord answer
|
|
316
|
+
* each] } | { ok: false, error, code } */
|
|
317
|
+
export function writeRecords(args) {
|
|
318
|
+
return post("writeRecords", args, ({ writes }) => {
|
|
319
|
+
if (!Array.isArray(writes)) throw new Missing("needs a list of writes");
|
|
320
|
+
if (!writes.length) return { done: { ok: true, results: [] } };
|
|
321
|
+
return { path: "write", body: { writes: writes.map(writeBody) } };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** POST _claw/data/records/<id>/confirm: the member says these `fields`
|
|
326
|
+
* are right (none named: every unconfirmed one).
|
|
327
|
+
* → { ok, confirmed, version } */
|
|
328
|
+
export function confirmFields(args) {
|
|
329
|
+
return post("confirmFields", args, ({ id, fields }) => ({ path: `records/${segment(id, "an id")}/confirm`, body: { fields } }));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** POST _claw/data/records/<id>/erase: gone for every app, with no undo.
|
|
333
|
+
* A person's press only. → { ok, erased } */
|
|
334
|
+
export function eraseRecord(args) {
|
|
335
|
+
return post("eraseRecord", args, ({ id, reason }) => ({ path: `records/${segment(id, "an id")}/erase`, body: { reason } }));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** POST _claw/data/records/<keep>/merge: `drop` folds into `keep` (its
|
|
339
|
+
* empty fields filled, its links and lists moved) and goes.
|
|
340
|
+
* → { ok, id, merged, filled, version } */
|
|
341
|
+
export function mergeRecords(args) {
|
|
342
|
+
return post("mergeRecords", args, ({ keep, drop }) => ({ path: `records/${segment(keep, "the id to keep")}/merge`, body: { drop } }));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** POST _claw/data/links { op: "add" }: link two records (`type` default
|
|
346
|
+
* "related"). → { ok, created } */
|
|
347
|
+
export function linkRecords(args) {
|
|
348
|
+
return post("linkRecords", args, ({ from, to, type }) => ({ path: "links", body: { op: "add", from, to, type } }));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** POST _claw/data/links { op: "remove" }: unlink them (no `type`: every
|
|
352
|
+
* type). → { ok, removed } */
|
|
353
|
+
export function unlinkRecords(args) {
|
|
354
|
+
return post("unlinkRecords", args, ({ from, to, type }) => ({ path: "links", body: { op: "remove", from, to, type } }));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** POST _claw/data/lists: a new list of one kind's records.
|
|
358
|
+
* → { ok, id } */
|
|
359
|
+
export function createList(args) {
|
|
360
|
+
return post("createList", args, ({ name, kind, area }) => ({ path: "lists", body: { name, kind, area } }));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** POST _claw/data/lists/<list>/members: `add` and `remove` record ids
|
|
364
|
+
* (at most 500), `position` where the added go. → { ok, added, removed } */
|
|
365
|
+
export function changeList(args) {
|
|
366
|
+
return post("changeList", args, ({ list, add, remove, position }) => ({
|
|
367
|
+
path: `lists/${segment(list, "a list")}/members`,
|
|
368
|
+
body: { add, remove, position },
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Hooks, for "use client" components: a read on mount, again on every
|
|
374
|
+
// dataChanged() (a write of this page's that answered ok, or a browser
|
|
375
|
+
// app's auto-refresh), with lib/door.js's rules: `loading` until the first
|
|
376
|
+
// answer for these arguments, a re-read keeps the last values until the
|
|
377
|
+
// new ones land, each answer a new array, a read no longer needed
|
|
378
|
+
// aborted. Each returns the read's values with `loading` and `reload`.
|
|
379
|
+
|
|
380
|
+
/** A brain read, for a hook: the browser's, through the cookie. */
|
|
381
|
+
const hookRead = (name) => (args) => brainRead(name, args, viaCookie);
|
|
382
|
+
|
|
383
|
+
/** A read's empty values, fresh: a hook hands out a new array each time. */
|
|
384
|
+
const emptyOf = (name) => () => JSON.parse(JSON.stringify(BRAIN_READS[name].empty));
|
|
385
|
+
|
|
386
|
+
/** listRecords as a hook. → { records, next, error, loading, reload } */
|
|
387
|
+
export function useRecords(args) {
|
|
388
|
+
return useDoorRead(hookRead("listRecords"), args, { empty: emptyOf("listRecords") });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** getRecord as a hook; no id asks nothing and answers record: null.
|
|
392
|
+
* → { record, links, error, loading, reload } */
|
|
393
|
+
export function useRecord(args) {
|
|
394
|
+
return useDoorRead(hookRead("getRecord"), args, {
|
|
395
|
+
empty: emptyOf("getRecord"),
|
|
396
|
+
idle: (a) => a.id === undefined || a.id === null || a.id === "",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** countRecords as a hook. → { count, by, error, loading, reload } */
|
|
401
|
+
export function useRecordCounts(args) {
|
|
402
|
+
return useDoorRead(hookRead("countRecords"), args, { empty: emptyOf("countRecords") });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** searchBrain as a hook; a blank `q` asks nothing. For a picker that
|
|
406
|
+
* searches as the member types, pass `meaning: false`: each keystroke
|
|
407
|
+
* aborts the read before it. → { results, meaning, error, loading, reload } */
|
|
408
|
+
export function useBrainSearch(args) {
|
|
409
|
+
return useDoorRead(hookRead("searchBrain"), args, {
|
|
410
|
+
empty: emptyOf("searchBrain"),
|
|
411
|
+
idle: (a) => !String(a.q ?? "").trim(),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// product's path, before it proxies anything to the app
|
|
3
3
|
// (docs/ARTIFACT-CONTRACTS.md §5). It is reachable from the BROWSER only —
|
|
4
4
|
// the grant cookie is the identity — so everything here is client-side.
|
|
5
|
+
// One exception: the data door (lib/brain-server.js) also answers the
|
|
6
|
+
// app's server, with a bearer, and clawAnswer reads its answers there.
|
|
5
7
|
//
|
|
6
8
|
// Every call resolves; nothing throws. A 404 that names no refusal, or a
|
|
7
9
|
// network error, means the gateway has no such route yet, and comes back
|
|
@@ -199,6 +201,15 @@ export async function claw(path, { method = "GET", body, signal } = {}) {
|
|
|
199
201
|
if (error?.name === "AbortError") return { ok: false, status: 0, error: "aborted", aborted: true };
|
|
200
202
|
return { ok: false, status: 0, error: "not reachable", unavailable: true };
|
|
201
203
|
}
|
|
204
|
+
return clawAnswer(res);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A gateway's response read the way claw() reads it: a 404 with or
|
|
208
|
+
* without a code (notFound), a refusal's words and code, or the JSON.
|
|
209
|
+
* lib/brain-server.js reaches the data door from the server, with a
|
|
210
|
+
* bearer instead of the cookie, and reads its answers through this, so
|
|
211
|
+
* a 404 means the same thing on both sides. */
|
|
212
|
+
export async function clawAnswer(res) {
|
|
202
213
|
if (res.status === 404) return notFound(res);
|
|
203
214
|
if (!res.ok) return { ok: false, status: res.status, ...(await readFailure(res)), unavailable: false };
|
|
204
215
|
const text = await res.text().catch(() => "");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { headers } from "next/headers";
|
|
2
|
-
import {
|
|
2
|
+
import { ident, query } from "./db";
|
|
3
3
|
import { markSample } from "./sample";
|
|
4
4
|
|
|
5
5
|
// SERVER reads. Import from server components and server actions only —
|
|
@@ -38,21 +38,111 @@ export function plain(row) {
|
|
|
38
38
|
return out;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
41
|
+
// A filter is the platform data door's grammar (claw's STATIC-SPEC §1,
|
|
42
|
+
// "The routes"), so a surface's filter means the same in a server app,
|
|
43
|
+
// read here, and in a browser one, read by the door. Up to 0.6.x it knew
|
|
44
|
+
// equals, lists and null, and any other object went to pg as a value.
|
|
45
|
+
const WHERE_MAX = 20;
|
|
46
|
+
const LIST_MAX = 1000;
|
|
47
|
+
const BOUNDS = [
|
|
48
|
+
["gt", ">"],
|
|
49
|
+
["gte", ">="],
|
|
50
|
+
["lt", "<"],
|
|
51
|
+
["lte", "<="],
|
|
52
|
+
];
|
|
53
|
+
const GRAMMAR = "takes a value, [values], null, or one of {not}, {gt|gte|lt|lte}, {has}, {prefix}";
|
|
54
|
+
|
|
55
|
+
/** A value a condition compares with: text, a finite number, true or
|
|
56
|
+
* false, or a Date (JSON sends it as its ISO time). */
|
|
57
|
+
function isValue(v) {
|
|
58
|
+
if (typeof v === "string" || typeof v === "boolean") return true;
|
|
59
|
+
if (typeof v === "number") return Number.isFinite(v);
|
|
60
|
+
return v instanceof Date && !Number.isNaN(v.getTime());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A prefix for LIKE: lower-cased, LIKE's own marks escaped (Postgres's
|
|
64
|
+
* default escape is a backslash), then "%". */
|
|
65
|
+
function likePrefix(text) {
|
|
66
|
+
return `${text.toLowerCase().replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One `column: condition` as SQL; `bind` adds a value and names it. */
|
|
70
|
+
function condition(name, cond, bind) {
|
|
71
|
+
const refuse = (what) => {
|
|
72
|
+
throw new Error(`filter: ${name} ${what}`);
|
|
73
|
+
};
|
|
74
|
+
if (name.includes(".")) refuse("is not a column: name the column alone");
|
|
75
|
+
const col = ident(name);
|
|
76
|
+
if (cond === null) return `${col} IS NULL`;
|
|
77
|
+
if (isValue(cond)) return `${col} = ${bind(cond)}`;
|
|
78
|
+
if (Array.isArray(cond)) {
|
|
79
|
+
if (cond.length > LIST_MAX) refuse(`takes at most ${LIST_MAX.toLocaleString("en-US")} values in a list`);
|
|
80
|
+
if (!cond.every(isValue)) refuse("takes a list of values (text, numbers, true or false): filter empty ones with null");
|
|
81
|
+
return `${col} = ANY(${bind(cond)})`;
|
|
82
|
+
}
|
|
83
|
+
if (typeof cond !== "object" || cond instanceof Date) refuse(GRAMMAR);
|
|
84
|
+
// A key left undefined is not there, as JSON leaves it out on its way
|
|
85
|
+
// to the door: { gte: from, lte: to } with no `to` is one bound.
|
|
86
|
+
const keys = Object.keys(cond).filter((k) => cond[k] !== undefined);
|
|
87
|
+
if (keys.length === 1 && keys[0] === "not") {
|
|
88
|
+
if (cond.not === null) return `${col} IS NOT NULL`;
|
|
89
|
+
if (!isValue(cond.not)) refuse("takes {not: value} or {not: null}");
|
|
90
|
+
// Distinct, not <>: a row with no value is not the value either.
|
|
91
|
+
return `${col} IS DISTINCT FROM ${bind(cond.not)}`;
|
|
92
|
+
}
|
|
93
|
+
if (keys.length === 1 && keys[0] === "has") {
|
|
94
|
+
if (!isValue(cond.has)) refuse("takes {has: value}");
|
|
95
|
+
// An array column holds it. (The door also reads a jsonb column's
|
|
96
|
+
// `?`; it knows the column's type, which a filter here does not.)
|
|
97
|
+
return `${bind(cond.has)} = ANY(${col})`;
|
|
98
|
+
}
|
|
99
|
+
if (keys.length === 1 && keys[0] === "prefix") {
|
|
100
|
+
if (typeof cond.prefix !== "string" || !cond.prefix) refuse('wants {prefix: "some text"}');
|
|
101
|
+
return `lower(${col}) LIKE ${bind(likePrefix(cond.prefix))}`;
|
|
102
|
+
}
|
|
103
|
+
const bounds = BOUNDS.map(([k]) => k);
|
|
104
|
+
if (keys.length && keys.every((k) => bounds.includes(k))) {
|
|
105
|
+
const lower = keys.filter((k) => k === "gt" || k === "gte");
|
|
106
|
+
const upper = keys.filter((k) => k === "lt" || k === "lte");
|
|
107
|
+
if (lower.length > 1 || upper.length > 1) refuse("takes one lower bound (gt or gte) and one upper (lt or lte)");
|
|
108
|
+
const parts = [];
|
|
109
|
+
for (const [k, op] of BOUNDS) {
|
|
110
|
+
if (!keys.includes(k)) continue;
|
|
111
|
+
if (!isValue(cond[k])) refuse(`takes {${k}: value}: a number, a day or a time`);
|
|
112
|
+
parts.push(`${col} ${op} ${bind(cond[k])}`);
|
|
54
113
|
}
|
|
114
|
+
return parts.join(" AND ");
|
|
115
|
+
}
|
|
116
|
+
return refuse(GRAMMAR);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `filter` → " WHERE …" text, "" for none; `params` is filled in place and
|
|
121
|
+
* every value is bound. An AND of `{ column: condition }`, at most 20:
|
|
122
|
+
*
|
|
123
|
+
* value equals
|
|
124
|
+
* [value, …] one of (at most 1,000)
|
|
125
|
+
* null empty (IS NULL)
|
|
126
|
+
* { not: value | null } anything else (IS DISTINCT FROM), or not empty
|
|
127
|
+
* { gt | gte | lt | lte: v } a bound; one lower and one upper may combine
|
|
128
|
+
* { has: value } an array column holds it
|
|
129
|
+
* { prefix: "text" } starts with, case ignored
|
|
130
|
+
*
|
|
131
|
+
* A column whose condition is undefined is left out, as JSON leaves it
|
|
132
|
+
* out. Anything else throws, and the read returns it as its error.
|
|
133
|
+
*/
|
|
134
|
+
export function whereClause(filter, params) {
|
|
135
|
+
if (filter === undefined || filter === null) return "";
|
|
136
|
+
if (typeof filter !== "object" || Array.isArray(filter)) {
|
|
137
|
+
throw new Error('filter is an object of column names to conditions, like { stage: "won" }');
|
|
55
138
|
}
|
|
139
|
+
const entries = Object.entries(filter).filter(([, cond]) => cond !== undefined);
|
|
140
|
+
if (entries.length > WHERE_MAX) throw new Error(`filter holds at most ${WHERE_MAX} conditions`);
|
|
141
|
+
const bind = (value) => {
|
|
142
|
+
params.push(value);
|
|
143
|
+
return `$${params.length}`;
|
|
144
|
+
};
|
|
145
|
+
const where = entries.map(([name, cond]) => condition(name, cond, bind));
|
|
56
146
|
return where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
57
147
|
}
|
|
58
148
|
|
|
@@ -63,7 +153,8 @@ function failed(what, error) {
|
|
|
63
153
|
}
|
|
64
154
|
|
|
65
155
|
/**
|
|
66
|
-
* Rows of a source table. `filter` is
|
|
156
|
+
* Rows of a source table. `filter` is whereClause's grammar (above:
|
|
157
|
+
* { column: condition }, the data door's); `orderBy` is
|
|
67
158
|
* "updated_at desc" (default: idColumn desc); `limit` caps at 5000.
|
|
68
159
|
* → { rows, error }
|
|
69
160
|
*/
|
|
@@ -121,37 +212,36 @@ export async function countBy({ source, column, filter }) {
|
|
|
121
212
|
}
|
|
122
213
|
|
|
123
214
|
/**
|
|
124
|
-
* What happened, newest first:
|
|
215
|
+
* What happened, newest first: this app's lines in claw_app.activity
|
|
125
216
|
* (every lib/actions.js write and everything the agent recorded with the
|
|
126
|
-
* sql verb).
|
|
127
|
-
*
|
|
128
|
-
*
|
|
217
|
+
* sql verb). The view holds only this app's lines, so nothing here names
|
|
218
|
+
* the app. `surface` narrows to one surface key; `since` (ISO or Date) to
|
|
219
|
+
* rows after that time. Rows: { id, at, action, surface, row_ref, detail,
|
|
220
|
+
* data, by }. Outside Makersclaw there is no such view: `error` says so.
|
|
221
|
+
* → { rows, error }
|
|
129
222
|
*/
|
|
130
223
|
export async function loadActivity({ limit = 100, surface, since } = {}) {
|
|
131
|
-
const id = artifactId();
|
|
132
|
-
if (!id) {
|
|
133
|
-
return { rows: [], error: failed("public.artifact_activity", "no CLAW_ARTIFACT_ID; product_run sets it") };
|
|
134
|
-
}
|
|
135
224
|
try {
|
|
136
|
-
const params = [
|
|
137
|
-
|
|
225
|
+
const params = [];
|
|
226
|
+
const where = [];
|
|
138
227
|
if (surface) {
|
|
139
228
|
params.push(String(surface));
|
|
140
|
-
where
|
|
229
|
+
where.push(`surface = $${params.length}`);
|
|
141
230
|
}
|
|
142
231
|
if (since) {
|
|
143
232
|
params.push(since instanceof Date ? since.toISOString() : String(since));
|
|
144
|
-
where
|
|
233
|
+
where.push(`at > $${params.length}::timestamptz`);
|
|
145
234
|
}
|
|
146
235
|
params.push(Math.max(1, Math.min(1000, Number(limit) || 100)));
|
|
236
|
+
const filter = where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
147
237
|
const { rows } = await query(
|
|
148
238
|
`SELECT id, at, action, surface, row_ref, detail, data, by
|
|
149
|
-
FROM
|
|
239
|
+
FROM claw_app.activity${filter} ORDER BY at DESC, id DESC LIMIT $${params.length}`,
|
|
150
240
|
params,
|
|
151
241
|
);
|
|
152
242
|
return { rows: rows.map(plain), error: null };
|
|
153
243
|
} catch (error) {
|
|
154
|
-
return { rows: [], error: failed("
|
|
244
|
+
return { rows: [], error: failed("claw_app.activity", error) };
|
|
155
245
|
}
|
|
156
246
|
}
|
|
157
247
|
|
|
@@ -160,7 +250,7 @@ export async function loadActivity({ limit = 100, surface, since } = {}) {
|
|
|
160
250
|
* code keeps one import. */
|
|
161
251
|
export { markSample };
|
|
162
252
|
|
|
163
|
-
/** The surface key of the current request (
|
|
253
|
+
/** The surface key of the current request (proxy.js sets it from
|
|
164
254
|
* /s/<key>), unless the caller names one — a string, or the page's
|
|
165
255
|
* `surface` object. */
|
|
166
256
|
export async function currentSurface(explicit) {
|