@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 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 + 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. |
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). |
@@ -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
- for (const f of v) {
171
- if (!f || typeof f !== "object")
172
- continue;
173
- const id = f.id;
174
- const url = f.url;
175
- if (typeof id !== "string" || !id || typeof url !== "string" || !url)
176
- continue;
177
- const filename = f.filename;
178
- const mime = f.mime_type;
179
- const thumb = f.thumbnail_url;
180
- const size = f.size;
181
- const created_at = f.created_at;
182
- out.push({
183
- id,
184
- filename: typeof filename === "string" ? filename : "",
185
- mime_type: typeof mime === "string" ? mime : "",
186
- url,
187
- thumbnail_url: typeof thumb === "string" ? thumb : undefined,
188
- size: typeof size === "number" ? size : undefined,
189
- created_at: typeof created_at === "string" ? created_at : undefined,
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" | "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)}` : "";
@@ -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). Previewing and uploading files:
502
- [./files.md](./files.md).
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`), 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.98.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
- }