@lotics/app-sdk 0.96.0 → 0.98.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/AGENTS.md +2 -2
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.js +0 -1
- package/dist/src/row.d.ts +9 -0
- package/dist/src/row.js +35 -21
- package/dist/src/rpc.d.ts +1 -1
- package/dist/src/rpc.js +0 -13
- package/docs/data_fetching.md +6 -3
- package/docs/files.md +7 -0
- package/docs/members_and_options.md +4 -44
- package/docs/security.md +0 -2
- package/package.json +1 -1
- package/dist/src/lifecycle_history.d.ts +0 -26
- package/dist/src/lifecycle_history.js +0 -41
package/AGENTS.md
CHANGED
|
@@ -19,8 +19,8 @@ signature; open the file.**
|
|
|
19
19
|
| [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate at 10,000 — `useQuery` says so with `truncated`), the ROW type (`RowOf` — the alias's projected columns and nothing else, values `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys AND the `useFieldOptions` map keyed against that same projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*` — `row.num` and `row.bool` answer `null` for an EMPTY cell, so none is distinguishable from zero and an unanswered checkbox from a "no" — `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, and `readCreatedAt`/`readUpdatedAt` for the record timestamps every row-level result carries), caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
|
|
20
20
|
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract (`field_errors` locates a refusal on the control it belongs to, where `message` can only say it at the dialog's scope), typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
|
|
21
21
|
| [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + explicit `linked()` descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
|
|
22
|
-
| [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments` (**the add-queue, and which lifecycle it keeps**: a composer clears, a record passes `landed` and each entry leaves as the stored pile takes it over), `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
|
|
23
|
-
| [docs/members_and_options.md](./docs/members_and_options.md) | People + select options +
|
|
22
|
+
| [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments` (**the add-queue, and which lifecycle it keeps**: a composer clears, a record passes `landed` and each entry leaves as the stored pile takes it over), `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted; and a LOOKUP of a files field is one list per linked row, which the reader opens itself), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
|
|
23
|
+
| [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions` (keyed by the alias's OWN columns, every key optional), `useViewer`, `useComments` (each comment carries its own resolved `author`, so a thread crossing a role boundary is legible without declaring member access), and the `@lotics/ui` components they feed. |
|
|
24
24
|
| [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
|
|
25
25
|
| [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
|
|
26
26
|
| [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
|
package/dist/src/index.d.ts
CHANGED
|
@@ -20,8 +20,6 @@ export type { QueryRow, RowOf, UploadedFile, BaseQueryOptions, QueryOptions, Inf
|
|
|
20
20
|
export type { AttachedFile, AttachmentsOptions, AttachmentsState } from "./attachments.js";
|
|
21
21
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
22
22
|
export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
|
|
23
|
-
export { useLifecycleHistory } from "./lifecycle_history.js";
|
|
24
|
-
export type { LifecycleHistory, LifecycleHistoryArgs } from "./lifecycle_history.js";
|
|
25
23
|
export { useViewer } from "./viewer.js";
|
|
26
24
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
27
25
|
export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
|
package/dist/src/index.js
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
export { mount } from "./mount.js";
|
|
17
17
|
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
|
|
18
18
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
19
|
-
export { useLifecycleHistory } from "./lifecycle_history.js";
|
|
20
19
|
export { useViewer } from "./viewer.js";
|
|
21
20
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
22
21
|
export { rpc, isEmbedded } from "./rpc.js";
|
package/dist/src/row.d.ts
CHANGED
|
@@ -116,6 +116,15 @@ export interface AppFile {
|
|
|
116
116
|
* an unservable file. Pass one to `toDisplayFile` from `@lotics/ui/file_thumbnail`
|
|
117
117
|
* for FileThumbnail/Gallery — the kit owns that conversion, so don't re-declare
|
|
118
118
|
* it per app.
|
|
119
|
+
*
|
|
120
|
+
* A LOOKUP OF A FILES FIELD ARRIVES ONE LEVEL DEEPER, exactly as a lookup of a
|
|
121
|
+
* link field does (see {@link readLinks}) — the cell holds one entry per linked
|
|
122
|
+
* record and each entry is THAT record's whole files cell, an array — so read
|
|
123
|
+
* entry-by-entry it decoded to nothing while the pictures were plainly there,
|
|
124
|
+
* and every mark drawn off a looked-up photograph fell back to its glyph. The
|
|
125
|
+
* nesting is flattened here, at the boundary that owns the serialization, rather
|
|
126
|
+
* than at each caller: one level, in the order the rows link, and a file two
|
|
127
|
+
* linked rows both carry is one file, so ids repeat at most once.
|
|
119
128
|
*/
|
|
120
129
|
export declare function readFiles(v: unknown): AppFile[];
|
|
121
130
|
/**
|
package/dist/src/row.js
CHANGED
|
@@ -162,32 +162,46 @@ export function readLinks(v) {
|
|
|
162
162
|
* an unservable file. Pass one to `toDisplayFile` from `@lotics/ui/file_thumbnail`
|
|
163
163
|
* for FileThumbnail/Gallery — the kit owns that conversion, so don't re-declare
|
|
164
164
|
* it per app.
|
|
165
|
+
*
|
|
166
|
+
* A LOOKUP OF A FILES FIELD ARRIVES ONE LEVEL DEEPER, exactly as a lookup of a
|
|
167
|
+
* link field does (see {@link readLinks}) — the cell holds one entry per linked
|
|
168
|
+
* record and each entry is THAT record's whole files cell, an array — so read
|
|
169
|
+
* entry-by-entry it decoded to nothing while the pictures were plainly there,
|
|
170
|
+
* and every mark drawn off a looked-up photograph fell back to its glyph. The
|
|
171
|
+
* nesting is flattened here, at the boundary that owns the serialization, rather
|
|
172
|
+
* than at each caller: one level, in the order the rows link, and a file two
|
|
173
|
+
* linked rows both carry is one file, so ids repeat at most once.
|
|
165
174
|
*/
|
|
166
175
|
export function readFiles(v) {
|
|
167
176
|
if (!Array.isArray(v))
|
|
168
177
|
return [];
|
|
169
178
|
const out = [];
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
179
|
+
const seen = new Set();
|
|
180
|
+
for (const entry of v) {
|
|
181
|
+
const held = Array.isArray(entry) ? entry : [entry];
|
|
182
|
+
for (const f of held) {
|
|
183
|
+
if (!f || typeof f !== "object")
|
|
184
|
+
continue;
|
|
185
|
+
const id = f.id;
|
|
186
|
+
const url = f.url;
|
|
187
|
+
if (typeof id !== "string" || !id || typeof url !== "string" || !url || seen.has(id))
|
|
188
|
+
continue;
|
|
189
|
+
seen.add(id);
|
|
190
|
+
const filename = f.filename;
|
|
191
|
+
const mime = f.mime_type;
|
|
192
|
+
const thumb = f.thumbnail_url;
|
|
193
|
+
const size = f.size;
|
|
194
|
+
const created_at = f.created_at;
|
|
195
|
+
out.push({
|
|
196
|
+
id,
|
|
197
|
+
filename: typeof filename === "string" ? filename : "",
|
|
198
|
+
mime_type: typeof mime === "string" ? mime : "",
|
|
199
|
+
url,
|
|
200
|
+
thumbnail_url: typeof thumb === "string" ? thumb : undefined,
|
|
201
|
+
size: typeof size === "number" ? size : undefined,
|
|
202
|
+
created_at: typeof created_at === "string" ? created_at : undefined,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
191
205
|
}
|
|
192
206
|
return out;
|
|
193
207
|
}
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
|
21
21
|
* app → host: { id, op, payload }
|
|
22
22
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
23
23
|
*/
|
|
24
|
-
export type RpcOp = "query" | "field_options" | "
|
|
24
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
25
25
|
/** Payload for starting a streaming agent run. */
|
|
26
26
|
export interface AgentRunPayload {
|
|
27
27
|
alias: string;
|
package/dist/src/rpc.js
CHANGED
|
@@ -703,8 +703,6 @@ function rpcStandalone(op, payload) {
|
|
|
703
703
|
return standaloneQuery(payload);
|
|
704
704
|
case "field_options":
|
|
705
705
|
return standaloneFieldOptions(payload);
|
|
706
|
-
case "field_history":
|
|
707
|
-
return standaloneFieldHistory(payload);
|
|
708
706
|
case "workflow":
|
|
709
707
|
return standaloneWorkflow(payload);
|
|
710
708
|
case "agentRuns":
|
|
@@ -753,17 +751,6 @@ function rpcStandalone(op, payload) {
|
|
|
753
751
|
function rejectCommentsStandalone() {
|
|
754
752
|
return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
|
|
755
753
|
}
|
|
756
|
-
/**
|
|
757
|
-
* When the row entered each stage — the public-app half of `field_history`.
|
|
758
|
-
* The endpoint is app-authority and `publicAppAccess`, so an anonymous visitor
|
|
759
|
-
* of a shared app reads exactly the dates its own queries already earn it.
|
|
760
|
-
*/
|
|
761
|
-
async function standaloneFieldHistory(p) {
|
|
762
|
-
const { app_id } = await boot();
|
|
763
|
-
const qs = new URLSearchParams({ table_id: p.table_id, field_id: p.field_id });
|
|
764
|
-
const r = (await apiCall("GET", `/v1/apps/${app_id}/records/${encodeURIComponent(p.record_id)}/field-history?${qs.toString()}`, undefined, { appId: app_id }));
|
|
765
|
-
return { entered: r.entered ?? [] };
|
|
766
|
-
}
|
|
767
754
|
async function standaloneMembers(p) {
|
|
768
755
|
const { app_id } = await boot();
|
|
769
756
|
const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
|
package/docs/data_fetching.md
CHANGED
|
@@ -442,7 +442,7 @@ the server's answer is the only check — the rule the filter keys follow too.
|
|
|
442
442
|
| `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty), a LOOKUP of a link field included |
|
|
443
443
|
| `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
|
|
444
444
|
| `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email?, image?, groups? }[]` (`[]` when empty) |
|
|
445
|
-
| `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty) |
|
|
445
|
+
| `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty), a LOOKUP of a files field included |
|
|
446
446
|
| `readLocked(rowObj)` | the whole **row** → `boolean` | the record's lock state from `__source_locked` |
|
|
447
447
|
| `readCreatedAt(rowObj)` | the whole **row** → `Date \| null` | when the record was created, from `__created_at` |
|
|
448
448
|
| `readUpdatedAt(rowObj)` | the whole **row** → `Date \| null` | when the record was last updated, from `__updated_at` |
|
|
@@ -498,8 +498,11 @@ anonymous public-app visitors; re-querying refreshes the TTL naturally. Entries
|
|
|
498
498
|
presign (no `url`) are skipped, so you never render an unservable file. `size` (bytes) and
|
|
499
499
|
`created_at` (ISO upload timestamp) are resolved at serving time; `size` is absent for older files
|
|
500
500
|
not yet backfilled — render it only when present, and surface both as dedicated sortable columns
|
|
501
|
-
over the raw values (a formatted "8.4 MB" string sorts wrong).
|
|
502
|
-
|
|
501
|
+
over the raw values (a formatted "8.4 MB" string sorts wrong). **A LOOKUP of a files field arrives
|
|
502
|
+
one level deeper**, exactly as a looked-up link does — one entry per linked record, each entry that
|
|
503
|
+
record's whole files cell — and `readFiles` opens it, in link order and with a file two linked rows
|
|
504
|
+
both carry named once, so a looked-up picture needs no unwrapping of its own. Previewing and
|
|
505
|
+
uploading files: [./files.md](./files.md).
|
|
503
506
|
|
|
504
507
|
**Warning — the presign ceiling:** signing file URLs is per-entry server work, so a response is
|
|
505
508
|
capped at **2,000 file entries** (server default). A query over the cap **fails** with an error
|
package/docs/files.md
CHANGED
|
@@ -241,6 +241,13 @@ Decode with `readFiles(cell)` → `AppFile[]` (`dist/src/row.d.ts`):
|
|
|
241
241
|
`readFiles` skips entries the server didn't presign (no `url`), so you never render an unservable
|
|
242
242
|
file. It is pure and never throws.
|
|
243
243
|
|
|
244
|
+
- **A LOOKUP of a files field is one list per linked row.** The cell holds one entry per linked
|
|
245
|
+
record and each entry is that record's whole files cell, so the value arrives one level deeper
|
|
246
|
+
than a stored column's — exactly as a looked-up link does. `readFiles` opens that level itself,
|
|
247
|
+
in link order, naming a file two linked rows both carry once, so a picture read through a link
|
|
248
|
+
needs no unwrapping at the call site. Read entry-by-entry it decodes to nothing, which is a mark
|
|
249
|
+
falling back to its glyph while the bytes are plainly there.
|
|
250
|
+
|
|
244
251
|
- **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
|
|
245
252
|
the variant exists; a not-yet-generated variant 404s on fetch. `@lotics/ui`'s `FileThumbnail`
|
|
246
253
|
falls back to `url` on image error — a hand-rolled `<img src={thumbnail_url}>` must do the same.
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
How an app renders and picks **people** and **select-field options**, plus the **record comments**
|
|
4
4
|
surface. Covers the two cell readers (`readSelect`, `readMembers`), the two catalog hooks
|
|
5
|
-
(`useFieldOptions`, `useMembers`), the viewer identity hook (`useViewer`),
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
(`useFieldOptions`, `useMembers`), the viewer identity hook (`useViewer`), comments
|
|
6
|
+
(`useComments`, `useCommentCounts`), and the `@lotics/ui` components they feed. Read this before
|
|
7
|
+
building an assign picker, a colored `Status` mark, a per-viewer ("my records") screen, or a
|
|
8
|
+
comment thread. Query mechanics live in [queries](./queries.md); the authority model in
|
|
9
9
|
[security](./security.md).
|
|
10
10
|
|
|
11
11
|
## Cells vs. catalogs — the model
|
|
@@ -18,7 +18,6 @@ Every select and member value reaches the app in one of two shapes, and most scr
|
|
|
18
18
|
| Populate a select picker, or color a stored value | `useFieldOptions(alias)` | Each select column's **complete** option list — `{ key, label, color }` — plus a `byKey` index |
|
|
19
19
|
| Render a stored member value | `readMembers(cell)` | The members the record actually holds |
|
|
20
20
|
| Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster — every member, not only the referenced ones |
|
|
21
|
-
| Date each step of a pipeline | `useLifecycleHistory(args)` | When the row entered each option — `Map<option id, ISO instant>` |
|
|
22
21
|
|
|
23
22
|
Cells are **self-describing**: the server rewrites raw storage shapes into resolved objects before
|
|
24
23
|
rows reach the app, so an app never maintains a hardcoded key→label or id→name map. Catalogs are
|
|
@@ -238,45 +237,6 @@ template via the `is_current_member` filter operator — the server binds the sa
|
|
|
238
237
|
view-as) with nothing client-supplied to spoof. Write attribution belongs server-side in the
|
|
239
238
|
workflow body (`runtime.triggered_by_member_id`). Full model: [security](./security.md).
|
|
240
239
|
|
|
241
|
-
## When the row entered each stage: `useLifecycleHistory`
|
|
242
|
-
|
|
243
|
-
A pipeline drawn as steps has to say **when** each step happened, and the record carries only where
|
|
244
|
-
it is. The platform is the one that knows: every write to a record lands an audit entry carrying the
|
|
245
|
-
field diff, so the instants are folded server-side and arrive ready to render.
|
|
246
|
-
|
|
247
|
-
```tsx
|
|
248
|
-
const { entered, loading, error } = useLifecycleHistory({
|
|
249
|
-
table_id: row.__source_table_id, // the record's table
|
|
250
|
-
record_id: row.__source_record_id, // the record itself
|
|
251
|
-
field_id: "fld_stage", // the lifecycle select
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
<Step label={option.label} at={entered.get(option.key)} />
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
- `entered` is `ReadonlyMap<option id, ISO 8601 instant>`. The stage the row is on **now** is in
|
|
258
|
-
there too. An option the row never entered is **absent** — read "no date" as "never been there",
|
|
259
|
-
and never as "not loaded yet" (that is `loading`).
|
|
260
|
-
- The instant is the **most recent** entry into that option, so a row that left a stage and came
|
|
261
|
-
back is dated by the return. A write that re-states the stage the row is already on changes
|
|
262
|
-
nothing and dates nothing; on a multi-value select, adding an option does not re-date the ones the
|
|
263
|
-
cell kept.
|
|
264
|
-
- Keyed by `opt_` ids, so a renamed option keeps its date.
|
|
265
|
-
- `field_id` names a **select** — the only field whose values are option ids. Anything else is
|
|
266
|
-
refused.
|
|
267
|
-
- Every part of the address must be a **real** id; an empty string fetches nothing and answers an
|
|
268
|
-
empty map. Narrow `__source_record_id` / `__source_table_id` first — a grouped query emits
|
|
269
|
-
neither.
|
|
270
|
-
- The authority IS a declared query: before it answers, the server runs one of the app's own
|
|
271
|
-
declarations over that table, narrowed to this record, and refuses unless it comes back. A table
|
|
272
|
-
none of them reads is refused; so is a row their filters or the table's row rule exclude. No
|
|
273
|
-
member table grant is needed, and none is a way in.
|
|
274
|
-
- `error` carries why a read failed, as the SDK's other hooks do. An empty `entered` alone cannot
|
|
275
|
-
say it — draw the failure rather than an undated strip.
|
|
276
|
-
- **Freshness:** SWR-cached, keyed by (table, record, field) — two steps of one lifecycle buy one
|
|
277
|
-
read. An audit entry emits no record event, so a stage moved elsewhere appears after this app's
|
|
278
|
-
own write (which re-reads every mounted query) or on the next focus.
|
|
279
|
-
|
|
280
240
|
## Comments: `useComments` / `useCommentCounts`
|
|
281
241
|
|
|
282
242
|
Record comments — member-to-member discussion attached to any record the app reaches
|
package/docs/security.md
CHANGED
|
@@ -9,7 +9,6 @@ Every data operation an app performs — queries, workflows, agent runs — exec
|
|
|
9
9
|
| Named queries (`useQuery`, the query RPC) | App owner | Yes — bound server-side into `is_current_member` / `current_member` filter predicates |
|
|
10
10
|
| Workflows (`useWorkflow`) | App owner | Yes — `runtime.triggered_by_member_id` in the workflow body (`null` for anonymous) |
|
|
11
11
|
| Agent runs (`useAgentRun`) | App owner | Yes — requires an authenticated member; runs are private to that member |
|
|
12
|
-
| Stage history (`useLifecycleHistory`) | App owner — proven by running a declared query narrowed to that record, so reach is the query surface's | Only as a declared query's own `is_current_member` resolves it |
|
|
13
12
|
| Comments (`useComments`) | App authority for **access**; the **author** is always the real member | Always — members-only, anonymous callers are rejected |
|
|
14
13
|
|
|
15
14
|
Consequences of owner authority:
|
|
@@ -99,7 +98,6 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
|
|
|
99
98
|
| File upload (workflow `file` inputs) | Yes — bounded to the app's workspace |
|
|
100
99
|
| Query/workflow file outputs | Yes — file cells and workflow-produced files return direct presigned URLs (24-hour TTL) that anonymous viewers can fetch; see [files](./files.md) |
|
|
101
100
|
| Agent runs (`useAgentRun`) | **No** — rejected: agent runs require an authenticated member, and each member's run history is private to them (a guessed session id cannot read another member's thread) |
|
|
102
|
-
| Stage history (`useLifecycleHistory`) | Yes — for a row a declared query hands back, which is the same IDOR surface as the query itself |
|
|
103
101
|
| Comments (`useComments`) | **No** — members-only |
|
|
104
102
|
| Member roster (`useMembers`) | **No** — same-org members only (below) |
|
|
105
103
|
|
package/package.json
CHANGED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
export interface LifecycleHistoryArgs {
|
|
2
|
-
/** The table the record lives in. */
|
|
3
|
-
table_id: string;
|
|
4
|
-
/** The record whose walk to date. */
|
|
5
|
-
record_id: string;
|
|
6
|
-
/** The `fld_` id of the lifecycle select. */
|
|
7
|
-
field_id: string;
|
|
8
|
-
}
|
|
9
|
-
export interface LifecycleHistory {
|
|
10
|
-
/**
|
|
11
|
-
* Option id → the ISO 8601 instant the row MOST RECENTLY entered it. The
|
|
12
|
-
* stage the row is on now is in here too; an option it never entered is
|
|
13
|
-
* absent rather than present with an empty value, so a caller reads "no date"
|
|
14
|
-
* as "never been there".
|
|
15
|
-
*/
|
|
16
|
-
entered: ReadonlyMap<string, string>;
|
|
17
|
-
/** True on the first load only — false during a background revalidation. */
|
|
18
|
-
loading: boolean;
|
|
19
|
-
/**
|
|
20
|
-
* Why the read failed, or `null`. An undated strip and a strip whose dates
|
|
21
|
-
* could not be read look identical, so the caller is told which it is holding
|
|
22
|
-
* rather than drawing "never entered" over a failure.
|
|
23
|
-
*/
|
|
24
|
-
error: string | null;
|
|
25
|
-
}
|
|
26
|
-
export declare function useLifecycleHistory(args: LifecycleHistoryArgs): LifecycleHistory;
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useLifecycleHistory` — when the row entered each stage.
|
|
3
|
-
*
|
|
4
|
-
* A pipeline drawn as steps has to say when each step happened, and the record
|
|
5
|
-
* carries only where it IS. The platform is the one that knows: every write to
|
|
6
|
-
* a record lands an audit row carrying the field diff, so the instants are
|
|
7
|
-
* derived server-side and arrive already folded — one entry per option the row
|
|
8
|
-
* entered, dated by the MOST RECENT entry into it.
|
|
9
|
-
*
|
|
10
|
-
* The field is a `select`, which is what a lifecycle is, and the keys of
|
|
11
|
-
* `entered` are its `opt_` ids — never a rendered label, so a renamed option
|
|
12
|
-
* keeps its date.
|
|
13
|
-
*
|
|
14
|
-
* SWR-cached like every other read hook: keyed by (table, record, field), so
|
|
15
|
-
* two steps of the same lifecycle share one fetch and the cache survives a
|
|
16
|
-
* remount. There is no realtime channel for an audit row, so a stage moved in
|
|
17
|
-
* another tab appears on the next focus or after the write that moved it
|
|
18
|
-
* invalidates this app's queries.
|
|
19
|
-
*/
|
|
20
|
-
import { useMemo } from "react";
|
|
21
|
-
import useSWR from "swr";
|
|
22
|
-
import { rpc } from "./rpc.js";
|
|
23
|
-
const NOTHING = new Map();
|
|
24
|
-
export function useLifecycleHistory(args) {
|
|
25
|
-
const { table_id, record_id, field_id } = args;
|
|
26
|
-
// Every part of the address has to be real: a hook cannot be called
|
|
27
|
-
// conditionally, and a blank id would fetch the history of nothing and cache
|
|
28
|
-
// the empty answer under a key a real id later reads.
|
|
29
|
-
const addressed = table_id !== "" && record_id !== "" && field_id !== "";
|
|
30
|
-
const swr = useSWR(addressed ? ["app-field-history", table_id, record_id, field_id] : null, () => rpc("field_history", { table_id, record_id, field_id }), { shouldRetryOnError: false });
|
|
31
|
-
const entered = useMemo(() => {
|
|
32
|
-
if (!swr.data)
|
|
33
|
-
return NOTHING;
|
|
34
|
-
return new Map(swr.data.entered.map((entry) => [entry.option_id, entry.entered_at]));
|
|
35
|
-
}, [swr.data]);
|
|
36
|
-
return {
|
|
37
|
-
entered,
|
|
38
|
-
loading: addressed && swr.data === undefined && swr.error === undefined,
|
|
39
|
-
error: swr.error ? swr.error.message : null,
|
|
40
|
-
};
|
|
41
|
-
}
|