@lotics/app-sdk 0.59.4 → 0.60.1

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/dist/src/hooks.js CHANGED
@@ -30,10 +30,20 @@ export function useWorkflow(alias) {
30
30
  // Shared SWR config: surface a failed query immediately, keep the last good
31
31
  // rows (no retry loop that masks the error), and honor the focus/reconnect
32
32
  // opt-out.
33
+ //
34
+ // `revalidateIfStale: false` — SWR defaults this to `true`, and its initial
35
+ // -revalidation decision is `isUndefined(data) || revalidateIfStale`, so every
36
+ // re-mount of a screen that already had rows re-queried the backend. An app
37
+ // that navigates between screens (or re-opens a drawer) paid a full query set
38
+ // each time for data it was already showing. A cold mount still fetches
39
+ // (`isUndefined(data)` short-circuits), focus/reconnect still refresh, and the
40
+ // host's `refetchQueries` poke still forces a refresh after a chat turn mutates
41
+ // records — so freshness keeps every path it had except "re-mounted".
33
42
  function swrConfig(revalidateOnFocus) {
34
43
  return {
35
44
  revalidateOnFocus,
36
45
  revalidateOnReconnect: revalidateOnFocus,
46
+ revalidateIfStale: false,
37
47
  shouldRetryOnError: false,
38
48
  };
39
49
  }
package/docs/ai.md CHANGED
@@ -306,18 +306,60 @@ Declarative and lifecycle-bound: mounting or changing `context` pushes it; unmou
306
306
  | `slot` | ≤ 50 chars |
307
307
  | `description` | ≤ 1000 chars (truncated with `…`) |
308
308
  | `records` | ≤ 20 refs |
309
- | `data` | must JSON-serialize to ≤ 2000 chars, else the `data` field is **dropped** (the description is kept) |
309
+ | `data` | must JSON-serialize to ≤ 2000 chars, else it is **replaced by an `omitted` marker** the agent reads (the description is kept) |
310
310
  | slots per app | ≤ 8 (a 9th evicts the least-recently-updated) |
311
311
 
312
- Keep `description` tight and human-readable — it is the line the agent reads. Put anything structured (filter/sort/form values) in `data`, and remember it is dropped whole past its size cap, so don't hide load-bearing facts there that aren't also in `description`.
312
+ Keep `description` tight and human-readable — it is the line the agent reads. Put anything structured (filter/sort/form values) in `data`, and remember it is replaced whole past its size cap: the agent is told the state existed but not what it was, so don't hide load-bearing facts there that aren't also in `description`. It is replaced rather than silently dropped so the agent cannot mistake "too big to send" for "there was nothing".
313
313
 
314
314
  ### Security — push-only, a snapshot of what was already rendered
315
315
 
316
- This is a **one-way push of data the app already showed this member**never a channel for chat to pull app-authority data back out. Two consequences hold it to that:
316
+ This hook is a **one-way push of data the app already showed this member**. (Chat can also PULL, through the app's declared aliases — a separate channel with its own `app:use` gate, below.) Two consequences hold the push to what was rendered:
317
317
 
318
318
  - **`records` are raw `{ table_id, record_id }` refs, passed UNRESOLVED.** The app does not resolve them here; the member's own chat agent may read or act on them only where **that member's IAM already allows**. A ref to a record the member can't see stays inert.
319
319
  - **`description` and `data` enter the agent's prompt as clearly-labeled DATA, never as instructions.** Text an app renders can't hijack the agent — the host wraps it as app-supplied view state (see [security](./security.md) for the labeled-data convention).
320
320
 
321
+ ### The chat can also USE the app, not just be told about it
322
+
323
+ `useAiContext` is a PUSH — a snapshot of what one screen rendered. It cannot
324
+ answer a question about a record that is not on screen, and it is capped, so it
325
+ was never the whole story.
326
+
327
+ While a member has an app open, their chat agent can also **call that app's own
328
+ declared aliases** — named queries via `run_app_query`, workflows via
329
+ `run_app_workflow`. This adds no exposure: every declared alias is already
330
+ callable by that member against `POST /v1/apps/{id}/query` and
331
+ `POST /v1/apps/{id}/workflows/{alias}/execute`, so the manifest already IS
332
+ their reachable surface — the agent simply reaches it without devtools. It is
333
+ gated per turn on `app:use` for the app in the member's workspace, and runs
334
+ under the app's OWNER authority with `is_current_member` bound to the MEMBER
335
+ (exactly as the app's own UI does).
336
+
337
+ **Writes carry a second gate that reads do not: `run_app_workflow` asks the
338
+ member to approve every call from chat.** A wrong read is fixed by asking
339
+ again; a wrong write is not. The alias list bounds what an agent *could* reach;
340
+ the approval is what stops the wrong one. Approval applies to the chat surface
341
+ only — an app agent's run is already an explicit act with no human in the loop
342
+ to answer.
343
+
344
+ The prompt names the action and lists the inputs the agent chose, so **your
345
+ input names are what the member reads before authorizing the write**. Name them
346
+ for that reader (`ma_khach_hang`, `so_tien`), and keep the decisive ones
347
+ top-level — a value nested inside an opaque payload shows as JSON, and long
348
+ values are truncated. Context ids the member never chose (`app_id`,
349
+ `workspace_id`, `organization_id`) are filtered out.
350
+
351
+ Nothing about your app changes to get this — declare a workflow as you already
352
+ do, and it becomes reachable. If a workflow should NOT be agent-reachable, it
353
+ does not belong in the manifest at all, since the member can already fire it
354
+ from the app's own buttons.
355
+
356
+ **What this means for what you push.** Keep `description` — the one line that
357
+ says who is on screen and where they stand, which is what makes "this one"
358
+ resolvable. Don't pack bulk rows into `data` to pre-answer questions that may
359
+ never be asked: the agent can fetch them when needed, fresher than a snapshot,
360
+ and a payload pushed on every message is carried by every later turn (see the
361
+ caps above).
362
+
321
363
  ### Query freshness — the mutation companion
322
364
 
323
365
  When the ambient chat agent's turn ends and it mutated records, the host pushes every mounted query hook to re-read, so the screen the member is looking at reflects the agent's change without a manual refresh. That companion behavior is automatic — you write no code for it — and is documented with the query caching contract in [data_fetching](./data_fetching.md#caching-loading-states-and-errors).
@@ -68,8 +68,13 @@ server validates system conditions by `type` and never reads `field_key` on them
68
68
  the paged hooks). Object *contents* are hashed, not references — passing a fresh inline
69
69
  `{ status: "open" }` each render is the same key; you never need to memoize params.
70
70
  - The cache **survives unmount/remount**: returning to a screen renders the cached rows instantly
71
- and revalidates in the background (stale-while-revalidate). Identical concurrent reads dedupe to
72
- one request.
71
+ and **sends no request**. Identical concurrent reads dedupe to one request.
72
+ - **A re-mount is not a refresh event.** Freshness comes from window focus / tab return / network
73
+ reconnect (`revalidateOnFocus`, default on), the host's post-chat-turn refetch poke, and explicit
74
+ `refetch()`. Re-mounting a screen you already loaded is none of those, so it re-uses the cache.
75
+ A **cold** key (never loaded, or a new `(alias, params, pageSize, sort, filter)` tuple) always
76
+ fetches — this only affects keys that already hold rows.
77
+ After a write the user is watching for, call `refetch()`; never rely on navigation to refresh.
73
78
  - **`loading`** is `true` only on the *initial* load of a key — a request is in flight and there
74
79
  are no rows yet. It stays `false` during background revalidation of a key that already has rows,
75
80
  so consumers never blank loaded data to a spinner on refetch. A key *change* (new params, sort,
package/docs/queries.md CHANGED
@@ -120,6 +120,15 @@ Delivery-layer enrichment (applied to the response, per request):
120
120
  only for authenticated members of the app's own org).
121
121
  - **`select` cells** — bare option-key arrays become `{ key, label }[]` (colors ride in
122
122
  `useFieldOptions`, not the cell — see [members_and_options.md](./members_and_options.md)).
123
+ **Enrichment needs the column's source addressing**, which a passthrough projection carries, a
124
+ union carries when its arms agree (or per-row, when the compiler emits it), and a **link
125
+ extraction** carries as of the target field it reads. The one shape that has none is a select
126
+ reached through a **lookup FIELD**: the column addresses the lookup, whose own type is `lookup`,
127
+ not `select` — so the cell stays bare `opt_*` keys AND `useFieldOptions` omits the column
128
+ entirely, and reading `.label` off it renders the option KEY. Carry the keys out
129
+ (`readSelect(cell).map((o) => o.key)`) and resolve them against an alias that projects the
130
+ underlying field DIRECTLY — `useFieldOptions` takes no params, so any alias whose schema
131
+ contains it works.
123
132
  - **`number` columns** arrive as JS numbers (Postgres `numeric` strings are coerced; a
124
133
  high-precision decimal that would lose digits stays a string). **`date`/`datetime`** columns
125
134
  arrive as canonical wall-clock strings (`YYYY-MM-DD` / `YYYY-MM-DDTHH:mm`).
@@ -350,6 +359,12 @@ target table can silently re-point (or break) the extraction — prefer field **
350
359
  target's names are volatile. Each extraction is a correlated subquery evaluated per output
351
360
  row — cheap on a filtered detail read, expensive over thousands of rows.
352
361
 
362
+ An extracted column **is** addressed to the field it reads on the target table, so the delivery
363
+ layer treats it like any other field-backed column: an extracted `select` enriches to
364
+ `{key,label}` and appears in `useFieldOptions`, and an extracted date resolves relative-date
365
+ filters in the source field's timezone. It is still never **writable** — a correlated subquery is
366
+ not a write path, and `writable_target` rejects it.
367
+
353
368
  ### The expression escape hatch
354
369
 
355
370
  `{ "expression": "…" }` compiles a small jexpr subset to SQL. Column references are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.59.4",
3
+ "version": "0.60.1",
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": {