@lotics/app-sdk 0.96.0 → 0.97.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 +1 -1
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.js +0 -1
- package/dist/src/rpc.d.ts +1 -1
- package/dist/src/rpc.js +0 -13
- 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
|
@@ -20,7 +20,7 @@ signature; open the file.**
|
|
|
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
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 +
|
|
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/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)}` : "";
|
|
@@ -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
|
-
}
|