@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 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 + stage dates + comments — `useMembers`, `useFieldOptions` (keyed by the alias's OWN columns, every key optional), `useViewer`, `useLifecycleHistory` (option id → the instant the row most recently entered it, folded server-side from the record's audit trail; an option never entered is absent), `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. |
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). |
@@ -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" | "field_history" | "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";
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`), when a row entered each
6
- stage (`useLifecycleHistory`), comments (`useComments`, `useCommentCounts`), and the `@lotics/ui`
7
- components they feed. Read this before building an assign picker, a colored `Status` mark, a
8
- pipeline with dates against its steps, a per-viewer ("my records") screen, or a comment thread. Query mechanics live in [queries](./queries.md); the authority model in
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.96.0",
3
+ "version": "0.97.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- }