@lotics/app-sdk 0.80.0 → 0.81.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/AGENTS.md CHANGED
@@ -24,7 +24,7 @@ signature; open the file.**
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). |
27
- | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
27
+ | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
28
28
 
29
29
  ## Non-negotiables (each detailed in its doc)
30
30
 
@@ -18,9 +18,15 @@
18
18
  * signed-in member: `available` is false, lists stay empty, and a mutation
19
19
  * rejects. Check `available` before rendering a composer.
20
20
  *
21
- * Freshness mirrors `useQuery`: SWR-cached, revalidate on focus / reconnect, and
22
- * an explicit refetch after every mutation. There is no realtime push to apps,
23
- * so a thread updated by another viewer appears on the next focus / refetch.
21
+ * Freshness: SWR-cached, revalidate on focus / reconnect, and an explicit
22
+ * refetch after every mutation so another viewer's comment appears on the
23
+ * next focus or `refetch()`, not on its own.
24
+ *
25
+ * This is the one read hook that does NOT keep itself current, and the reason
26
+ * is worth knowing rather than guessing: the realtime channel carries **record**
27
+ * changes, and a comment is its own entity that emits no record event. Your
28
+ * QUERIES do get pushed (`docs/data_fetching.md`) — do not read this as "apps
29
+ * have no realtime" and go build a poll.
24
30
  */
25
31
  import { useCallback } from "react";
26
32
  import useSWR from "swr";
package/dist/src/hooks.js CHANGED
@@ -21,10 +21,21 @@ import useSWR from "swr";
21
21
  import useSWRInfinite from "swr/infinite";
22
22
  import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeQueriesChanged, } from "./rpc.js";
23
23
  import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, landingOf, proseOf, ABORTED, } from "./agent_stream.js";
24
- import { getMockRows, hasMockFlag } from "./mock.js";
24
+ import { getMockRows, getMockWorkflow, hasMockFlag } from "./mock.js";
25
25
  export { buildChoiceOutput } from "./agent_stream.js";
26
26
  export function useWorkflow(alias) {
27
- return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
27
+ return useCallback(async (inputs) => {
28
+ // Checked per CALL, not per render: a fixture registered after mount or
29
+ // swapped by HMR is picked up, and an app that never runs this workflow
30
+ // does no work here. Mock mode needs BOTH the `?__mock=1` flag and a
31
+ // registered fixture, so a bundle carrying demo data cannot answer real
32
+ // traffic with it.
33
+ const mock = getMockWorkflow(alias);
34
+ if (mock) {
35
+ return typeof mock === "function" ? await mock(inputs ?? {}) : mock;
36
+ }
37
+ return rpc("workflow", { alias, inputs: inputs ?? {} });
38
+ }, [alias]);
28
39
  }
29
40
  // Shared SWR config: surface a failed query immediately, keep the last good
30
41
  // rows (no retry loop that masks the error), and honor the focus/reconnect
@@ -1,33 +1,53 @@
1
1
  /**
2
- * Demo / design-time fixture support for `useQuery`.
2
+ * Demo / design-time fixture support for `useQuery` and `useWorkflow`.
3
3
  *
4
4
  * Activation contract:
5
5
  *
6
6
  * 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
7
- * is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
8
- * app declared in `package.json#lotics.queries`.
7
+ * is a `{ queries: { alias: Row[] }, workflows: { alias: Result } }` map
8
+ * keyed by the same aliases the app declared in
9
+ * `package.json#lotics.queries` / `#lotics.workflows`.
9
10
  * 2. At runtime, the user (or a screenshot script) loads the app with the
10
11
  * `?__mock=1` URL search param. Without that param the SDK ignores the
11
- * fixture entirely and `useQuery` flows through the RPC bridge as usual.
12
+ * fixture entirely and both hooks flow through the RPC bridge as usual.
12
13
  *
13
14
  * The two-step gate keeps demo data shipping in the bundle from leaking into
14
15
  * normal traffic — the param namespace (`__mock` prefix) is reserved and
15
16
  * unlikely to collide with app-side query state. Apps that don't pass a
16
- * fixture pay nothing: `getMockRows` returns `null` for every alias and the
17
- * hook path is unchanged.
17
+ * fixture pay nothing: `getMockRows` / `getMockWorkflow` return `null` for
18
+ * every alias and the hook path is unchanged.
18
19
  *
19
- * What's deliberately *not* mocked yet:
20
- * - `useWorkflow` mutations have side effects (notifications, audit
21
- * trail). A workflow mock would have to also produce realistic followup
22
- * state, which is more design than this iteration warrants.
23
- * - `useFileUpload` same reason.
20
+ * `workflows` exists because the side-effect argument runs the other way: a
21
+ * mocked workflow does not RUN, so it produces no notification and no audit
22
+ * trail it PREVENTS them. What it cannot produce is the followup state a
23
+ * subsequent `useQuery` would read, which is the author's call and is already
24
+ * true of a mocked query. Without it, an app whose only AI surface is a
25
+ * workflow that reads and calls `agent(...)` — the standard shape — had no
26
+ * non-billing path to its own in-flight / done / error states at all, so those
27
+ * three screens could not be reviewed without spending on a live workspace.
24
28
  *
25
- * If those become needed, extend `AppFixture` with `workflows`/`uploads` and
26
- * route from each hook.
29
+ * A fixture entry may be the RESULT, or a FUNCTION of the inputs. The function
30
+ * form is what makes the in-flight state reachable: resolve on a timer and the
31
+ * app renders the pending branch it otherwise never shows. It also lets one
32
+ * alias answer differently per input, which is how an error branch is reviewed
33
+ * beside a success one.
34
+ *
35
+ * What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
36
+ * different shape from a request/response pair, and nothing has needed it.
37
+ */
38
+ import type { WorkflowResult } from "./hooks.js";
39
+ /**
40
+ * What a mocked workflow answers with: a fixed result, or a function of the
41
+ * inputs it was called with. Return a promise from the function to hold the
42
+ * caller in its pending state for as long as the review needs.
27
43
  */
44
+ export type MockWorkflow = WorkflowResult | ((inputs: Record<string, unknown>) => WorkflowResult | Promise<WorkflowResult>);
28
45
  export interface AppFixture {
29
46
  /** Map of query alias → rows the hook should return when mock mode is on. */
30
47
  queries?: Record<string, Array<Record<string, unknown>>>;
48
+ /** Map of workflow alias → the result it resolves with when mock mode is on.
49
+ * The workflow never executes, so nothing it would have written is written. */
50
+ workflows?: Record<string, MockWorkflow>;
31
51
  }
32
52
  /**
33
53
  * Called by `mount({ fixture })`. Module-level state because the SDK has no
@@ -51,3 +71,13 @@ export declare function hasMockFlag(): boolean;
51
71
  * only some queries and let the rest flow through to real data.
52
72
  */
53
73
  export declare function getMockRows(alias: string): Array<Record<string, unknown>> | null;
74
+ /**
75
+ * The fixture entry for a workflow alias when mock mode is active *and* the
76
+ * fixture has an entry for it. Otherwise null — the hook falls through to the
77
+ * real RPC path, so an app may mock one workflow and let the rest execute.
78
+ *
79
+ * Resolved when the workflow is CALLED rather than when the hook is created, so
80
+ * a fixture registered after mount (or replaced by HMR) is picked up, and an
81
+ * app that never calls the workflow pays nothing.
82
+ */
83
+ export declare function getMockWorkflow(alias: string): MockWorkflow | null;
package/dist/src/mock.js CHANGED
@@ -1,29 +1,39 @@
1
1
  /**
2
- * Demo / design-time fixture support for `useQuery`.
2
+ * Demo / design-time fixture support for `useQuery` and `useWorkflow`.
3
3
  *
4
4
  * Activation contract:
5
5
  *
6
6
  * 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
7
- * is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
8
- * app declared in `package.json#lotics.queries`.
7
+ * is a `{ queries: { alias: Row[] }, workflows: { alias: Result } }` map
8
+ * keyed by the same aliases the app declared in
9
+ * `package.json#lotics.queries` / `#lotics.workflows`.
9
10
  * 2. At runtime, the user (or a screenshot script) loads the app with the
10
11
  * `?__mock=1` URL search param. Without that param the SDK ignores the
11
- * fixture entirely and `useQuery` flows through the RPC bridge as usual.
12
+ * fixture entirely and both hooks flow through the RPC bridge as usual.
12
13
  *
13
14
  * The two-step gate keeps demo data shipping in the bundle from leaking into
14
15
  * normal traffic — the param namespace (`__mock` prefix) is reserved and
15
16
  * unlikely to collide with app-side query state. Apps that don't pass a
16
- * fixture pay nothing: `getMockRows` returns `null` for every alias and the
17
- * hook path is unchanged.
17
+ * fixture pay nothing: `getMockRows` / `getMockWorkflow` return `null` for
18
+ * every alias and the hook path is unchanged.
18
19
  *
19
- * What's deliberately *not* mocked yet:
20
- * - `useWorkflow` mutations have side effects (notifications, audit
21
- * trail). A workflow mock would have to also produce realistic followup
22
- * state, which is more design than this iteration warrants.
23
- * - `useFileUpload` same reason.
20
+ * `workflows` exists because the side-effect argument runs the other way: a
21
+ * mocked workflow does not RUN, so it produces no notification and no audit
22
+ * trail it PREVENTS them. What it cannot produce is the followup state a
23
+ * subsequent `useQuery` would read, which is the author's call and is already
24
+ * true of a mocked query. Without it, an app whose only AI surface is a
25
+ * workflow that reads and calls `agent(...)` — the standard shape — had no
26
+ * non-billing path to its own in-flight / done / error states at all, so those
27
+ * three screens could not be reviewed without spending on a live workspace.
24
28
  *
25
- * If those become needed, extend `AppFixture` with `workflows`/`uploads` and
26
- * route from each hook.
29
+ * A fixture entry may be the RESULT, or a FUNCTION of the inputs. The function
30
+ * form is what makes the in-flight state reachable: resolve on a timer and the
31
+ * app renders the pending branch it otherwise never shows. It also lets one
32
+ * alias answer differently per input, which is how an error branch is reviewed
33
+ * beside a success one.
34
+ *
35
+ * What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
36
+ * different shape from a request/response pair, and nothing has needed it.
27
37
  */
28
38
  let registeredFixture;
29
39
  /**
@@ -70,3 +80,17 @@ export function getMockRows(alias) {
70
80
  const rows = registeredFixture?.queries?.[alias];
71
81
  return rows ?? null;
72
82
  }
83
+ /**
84
+ * The fixture entry for a workflow alias when mock mode is active *and* the
85
+ * fixture has an entry for it. Otherwise null — the hook falls through to the
86
+ * real RPC path, so an app may mock one workflow and let the rest execute.
87
+ *
88
+ * Resolved when the workflow is CALLED rather than when the hook is created, so
89
+ * a fixture registered after mount (or replaced by HMR) is picked up, and an
90
+ * app that never calls the workflow pays nothing.
91
+ */
92
+ export function getMockWorkflow(alias) {
93
+ if (!isMockMode())
94
+ return null;
95
+ return registeredFixture?.workflows?.[alias] ?? null;
96
+ }
@@ -119,8 +119,9 @@ server validates system conditions by `type` and never reads `field_key` on them
119
119
  queries currently on screen (mounted hooks only); it never reaches into app data, it only tells
120
120
  the app its rendered rows may be stale. Inert standalone and in mock mode.
121
121
  - A design-time fixture registered via `mount(<App />, { fixture })` plus the `?__mock=1` URL flag
122
- short-circuits all three hooks (rows come from the fixture, no request, `loading` stays `false`)
123
- see [./runtime.md](./runtime.md).
122
+ short-circuits all three hooks (rows come from the fixture, no request, `loading` stays `false`).
123
+ The same fixture mocks `useWorkflow`, so a screen's in-flight / done / error states are
124
+ reviewable without running anything — see [./runtime.md](./runtime.md).
124
125
 
125
126
  ### Error messages you will actually see
126
127
 
@@ -297,8 +297,11 @@ updateComment, deleteComment, refetch }`.
297
297
  member source: the `useMembers` roster (requires the member-access declaration above) or member
298
298
  cells in your own data. An unresolvable id should render a fallback (`CommentList` has an
299
299
  `unknownMember` label for exactly this).
300
- - **Freshness:** SWR-cached, revalidates on focus/reconnect; there is **no realtime push** to apps,
301
- so another viewer's comment appears on the next focus or explicit `refetch()`.
300
+ - **Freshness:** SWR-cached, revalidates on focus/reconnect, so another viewer's comment appears on
301
+ the next focus or explicit `refetch()` — not on its own. Comments are the one read that does not
302
+ keep itself current, and not because apps lack realtime: the channel carries **record** changes,
303
+ and a comment is its own entity that emits no record event. Your queries DO get pushed
304
+ ([data_fetching.md](./data_fetching.md)) — don't read this line as "no push" and build a poll.
302
305
 
303
306
  ### Counts
304
307
 
package/docs/runtime.md CHANGED
@@ -47,6 +47,16 @@ mount(<App />, {
47
47
  orders: MOCK_ORDERS, // alias → rows, same aliases as package.json#lotics.queries
48
48
  customers: MOCK_CUSTOMERS,
49
49
  },
50
+ workflows: {
51
+ // A fixed result, for the settled state.
52
+ archive: { status: "success", message: "Archived 3 orders." },
53
+ // Or a FUNCTION of the inputs — the only way to reach the IN-FLIGHT state,
54
+ // and the way to put an error branch beside a success one.
55
+ publish: (inputs) =>
56
+ inputs.dryRun
57
+ ? { status: "error", message: "Row 2 has no customer." }
58
+ : new Promise((r) => setTimeout(() => r({ status: "success" }), 1200)),
59
+ },
50
60
  },
51
61
  });
52
62
  ```
@@ -55,7 +65,7 @@ Activation is a **two-step gate** — both must hold, so demo data shipping in t
55
65
  bundle never leaks into normal traffic:
56
66
 
57
67
  1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
58
- `dist/src/mock.d.ts` — `{ queries?: Record<string, Array<Record<string, unknown>>> }`).
68
+ `dist/src/mock.d.ts` — `{ queries?, workflows? }`).
59
69
  2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
60
70
  is completely inert.
61
71
 
@@ -68,9 +78,17 @@ transport. Calling `mount` again (HMR) replaces the registration last-write-wins
68
78
  as fetched rows, so shape them exactly like the query's real output — the same
69
79
  serialized cells your `row.*` / `readSelect` / `readFiles` readers decode —
70
80
  or the readers will decode nothing.
71
- - **Not mocked:** workflows (`useWorkflow`), uploads, `useFieldOptions`, members,
72
- comments, agent runs. In mock mode those still hit the real transport;
73
- mutations have side effects, so there is deliberately no workflow mock.
81
+ - **A mocked workflow does not RUN.** `useWorkflow(alias)` resolves the fixture
82
+ entry and sends nothing, so there is no notification, no audit trail and no
83
+ spend the side-effect argument is the reason to mock it, not a reason not
84
+ to. What a mock cannot produce is the followup state a later query would read;
85
+ that is yours to fixture too, exactly as it already is for queries.
86
+ **Reach the in-flight state with a function** that resolves on a timer: an app
87
+ whose only AI surface is a workflow calling `agent(...)` otherwise has no
88
+ non-billing path to its own thinking / done / error screens, which is how
89
+ those three ship unreviewed.
90
+ - **Not mocked:** uploads, `useFieldOptions`, members, comments, agent runs. In
91
+ mock mode those still hit the real transport.
74
92
  - **Analytics is disabled** whenever `?__mock=1` is present, fixture or not — a
75
93
  screenshot/design-time load emits no events.
76
94
  - The `__mock` param-name prefix is reserved by the SDK; don't use it for
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.80.0",
4
- "description": "Runtime SDK for Lotics custom-code apps typed hooks, postMessage bridge, mount entry point",
3
+ "version": "0.81.1",
4
+ "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {