@lotics/app-sdk 0.83.1 → 0.86.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 +6 -3
- package/dist/src/hooks.js +22 -3
- package/dist/src/index.d.ts +2 -4
- package/dist/src/index.js +1 -2
- package/dist/src/mock.d.ts +1 -1
- package/dist/src/mock.js +1 -1
- package/dist/src/mount.d.ts +1 -1
- package/dist/src/mount.js +0 -6
- package/dist/src/rpc.d.ts +22 -50
- package/dist/src/rpc.js +48 -51
- package/dist/src/viewer.d.ts +1 -3
- package/dist/src/viewer.js +1 -7
- package/docs/data_fetching.md +8 -7
- package/docs/mutations.md +27 -7
- package/docs/runtime.md +18 -109
- package/docs/workflows.md +8 -0
- package/package.json +1 -2
- package/dist/src/analytics.d.ts +0 -1
- package/dist/src/analytics.js +0 -113
- package/dist/src/config.d.ts +0 -24
- package/dist/src/config.js +0 -33
package/AGENTS.md
CHANGED
|
@@ -16,15 +16,15 @@ signature; open the file.**
|
|
|
16
16
|
|---|---|
|
|
17
17
|
| [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
|
|
18
18
|
| [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
|
|
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 silently at 10,000), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, 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), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
|
|
20
|
-
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, 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). |
|
|
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 silently at 10,000), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, 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
|
+
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, 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 + link 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`, `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
23
|
| [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, 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). |
|
|
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,
|
|
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, and the publish chain for SDK contributors. |
|
|
28
28
|
|
|
29
29
|
## Non-negotiables (each detailed in its doc)
|
|
30
30
|
|
|
@@ -34,6 +34,9 @@ signature; open the file.**
|
|
|
34
34
|
A client-supplied member id is an IDOR. → [security](./docs/security.md)
|
|
35
35
|
- **Server data is never copied into `useState`** — hooks are the source of truth; derive with
|
|
36
36
|
`useMemo`. → [data_fetching](./docs/data_fetching.md)
|
|
37
|
+
- **A successful write re-reads the screen for you** — do not chase a `useWorkflow` call with
|
|
38
|
+
`refetch()`. Reach for it only where a write cannot have told you: a poll, a value nothing on
|
|
39
|
+
this screen wrote, a total you supplied. → [mutations](./docs/mutations.md)
|
|
37
40
|
- **Update writes are diffs** — send only changed fields, and *cleared* is a change: an optional
|
|
38
41
|
input clears with `null` (never `""`), which `set_skip_null` drops and `set` performs.
|
|
39
42
|
→ [mutations](./docs/mutations.md)
|
package/dist/src/hooks.js
CHANGED
|
@@ -19,7 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
19
19
|
import { DEFAULT_IMAGE_FIDELITY } from "./upload/optimize.js";
|
|
20
20
|
import useSWR from "swr";
|
|
21
21
|
import useSWRInfinite from "swr/infinite";
|
|
22
|
-
import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeQueriesChanged, } from "./rpc.js";
|
|
22
|
+
import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeQueriesChanged, registerQueryAlias, notifyLocalWrite, } from "./rpc.js";
|
|
23
23
|
import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, landingOf, proseOf, ABORTED, } from "./agent_stream.js";
|
|
24
24
|
import { getMockRows, getMockWorkflow, hasMockFlag } from "./mock.js";
|
|
25
25
|
export { buildChoiceOutput } from "./agent_stream.js";
|
|
@@ -34,7 +34,18 @@ export function useWorkflow(alias) {
|
|
|
34
34
|
if (mock) {
|
|
35
35
|
return typeof mock === "function" ? await mock(inputs ?? {}) : mock;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
const result = await rpc("workflow", { alias, inputs: inputs ?? {} });
|
|
38
|
+
// A workflow is the app's only write path, so a successful one means the
|
|
39
|
+
// rows on screen are stale. Re-read now rather than waiting for the host's
|
|
40
|
+
// realtime push to say what the caller already knows — that wait is what
|
|
41
|
+
// makes a screen feel like it lagged its own button.
|
|
42
|
+
//
|
|
43
|
+
// Only on success: a refused write changed nothing, and refetching after
|
|
44
|
+
// one would replace the values the user is still looking at (and about to
|
|
45
|
+
// correct) with an identical set, for a round trip nobody asked for.
|
|
46
|
+
if (result.status !== "error")
|
|
47
|
+
notifyLocalWrite();
|
|
48
|
+
return result;
|
|
38
49
|
}, [alias]);
|
|
39
50
|
}
|
|
40
51
|
// Shared SWR config: surface a failed query immediately, keep the last good
|
|
@@ -79,10 +90,18 @@ function useHostRefetch(alias, refetch, mockRows) {
|
|
|
79
90
|
useEffect(() => {
|
|
80
91
|
if (hasMockFlag() || mockRows)
|
|
81
92
|
return;
|
|
82
|
-
|
|
93
|
+
// Registered separately from the subscription because the two answer
|
|
94
|
+
// different questions: the subscription is "tell me when MY alias moved",
|
|
95
|
+
// the registration is "I am on screen, so a local write should re-read me".
|
|
96
|
+
const unregister = registerQueryAlias(alias);
|
|
97
|
+
const unsubscribe = subscribeQueriesChanged((aliases) => {
|
|
83
98
|
if (aliases.includes(alias))
|
|
84
99
|
refetch();
|
|
85
100
|
});
|
|
101
|
+
return () => {
|
|
102
|
+
unregister();
|
|
103
|
+
unsubscribe();
|
|
104
|
+
};
|
|
86
105
|
}, [alias, refetch, mockRows]);
|
|
87
106
|
}
|
|
88
107
|
/**
|
package/dist/src/index.d.ts
CHANGED
|
@@ -21,12 +21,10 @@ export type { QueryRow, UploadedFile, AttachedFile, BaseQueryOptions, QueryOptio
|
|
|
21
21
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
22
22
|
export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
|
|
23
23
|
export { useViewer } from "./viewer.js";
|
|
24
|
-
export { useConfig } from "./config.js";
|
|
25
|
-
export type { AppConfigValue } from "./rpc.js";
|
|
26
24
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
27
25
|
export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
|
|
28
|
-
export { rpc, isEmbedded
|
|
29
|
-
export type { RpcOp,
|
|
26
|
+
export { rpc, isEmbedded } from "./rpc.js";
|
|
27
|
+
export type { RpcOp, AiContextValue, AiContextRecordRef } from "./rpc.js";
|
|
30
28
|
export { openExternal } from "./open_external.js";
|
|
31
29
|
export { askAi, type AskAiArgs } from "./ask_ai.js";
|
|
32
30
|
export { downloadFile } from "./download.js";
|
package/dist/src/index.js
CHANGED
|
@@ -18,9 +18,8 @@ export { mount } from "./mount.js";
|
|
|
18
18
|
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
|
|
19
19
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
20
20
|
export { useViewer } from "./viewer.js";
|
|
21
|
-
export { useConfig } from "./config.js";
|
|
22
21
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
23
|
-
export { rpc, isEmbedded
|
|
22
|
+
export { rpc, isEmbedded } from "./rpc.js";
|
|
24
23
|
export { openExternal } from "./open_external.js";
|
|
25
24
|
export { askAi } from "./ask_ai.js";
|
|
26
25
|
export { downloadFile } from "./download.js";
|
package/dist/src/mock.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export declare function registerMockFixture(fixture: AppFixture | undefined): vo
|
|
|
59
59
|
/**
|
|
60
60
|
* True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
|
|
61
61
|
* a malformed URL or missing `window` (SSR / jsdom without location) silently
|
|
62
|
-
* returns false. Distinct from `isMockMode`:
|
|
62
|
+
* returns false. Distinct from `isMockMode`: a caller may key off the raw flag
|
|
63
63
|
* (a design-time / screenshot load emits no events regardless of fixtures),
|
|
64
64
|
* while query mocking additionally requires a registered fixture.
|
|
65
65
|
*/
|
package/dist/src/mock.js
CHANGED
|
@@ -48,7 +48,7 @@ export function registerMockFixture(fixture) {
|
|
|
48
48
|
/**
|
|
49
49
|
* True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
|
|
50
50
|
* a malformed URL or missing `window` (SSR / jsdom without location) silently
|
|
51
|
-
* returns false. Distinct from `isMockMode`:
|
|
51
|
+
* returns false. Distinct from `isMockMode`: a caller may key off the raw flag
|
|
52
52
|
* (a design-time / screenshot load emits no events regardless of fixtures),
|
|
53
53
|
* while query mocking additionally requires a registered fixture.
|
|
54
54
|
*/
|
package/dist/src/mount.d.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* `mount` wires up React 19's createRoot against `#root` in the iframe shell,
|
|
28
28
|
* sets up window.onerror/unhandledrejection forwarding so runtime crashes
|
|
29
29
|
* surface in the parent's debug pane, registers the fixture (if any), renders
|
|
30
|
-
* the user's tree
|
|
30
|
+
* the user's tree.
|
|
31
31
|
*
|
|
32
32
|
* If the bundler doesn't ship #root in the user's `index.html`, we create it
|
|
33
33
|
* — Vite's default scaffold provides one, but defensive creation keeps the
|
package/dist/src/mount.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
2
|
import { registerMockFixture } from "./mock.js";
|
|
3
|
-
import { bootstrapAnalytics } from "./analytics.js";
|
|
4
3
|
export function mount(element, options = {}) {
|
|
5
4
|
registerMockFixture(options.fixture);
|
|
6
5
|
let container = document.getElementById("root");
|
|
@@ -14,11 +13,6 @@ export function mount(element, options = {}) {
|
|
|
14
13
|
// *something* even before the parent's debug telemetry is wired up.
|
|
15
14
|
installVisibleErrorHandlers(container);
|
|
16
15
|
createRoot(container).render(element);
|
|
17
|
-
// Fire-and-forget: resolve the app's identity and start PostHog (explicit
|
|
18
|
-
// events — autocapture is disabled project-wide). Never awaited — analytics
|
|
19
|
-
// must not delay first paint, and is best-effort so its failure can't break
|
|
20
|
-
// the app. No-ops in mock mode and off the deployed app host.
|
|
21
|
-
void bootstrapAnalytics();
|
|
22
16
|
}
|
|
23
17
|
function installVisibleErrorHandlers(container) {
|
|
24
18
|
const showError = (message) => {
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
|
20
20
|
* app → host: { id, op, payload }
|
|
21
21
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
22
22
|
*/
|
|
23
|
-
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "
|
|
23
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
24
24
|
/** Payload for starting a streaming agent run. */
|
|
25
25
|
export interface AgentRunPayload {
|
|
26
26
|
alias: string;
|
|
@@ -37,24 +37,11 @@ export interface AgentRunHandle {
|
|
|
37
37
|
* The app's identity, resolved once at startup to tag PostHog events.
|
|
38
38
|
* Assembled by whichever transport is active:
|
|
39
39
|
*
|
|
40
|
-
* - **Bridged** — the host (authenticated) supplies `member_id
|
|
41
|
-
*
|
|
42
|
-
* from its own context.
|
|
40
|
+
* - **Bridged** — the host (authenticated) supplies `member_id`, plus
|
|
41
|
+
* org/workspace/app from its own context.
|
|
43
42
|
* - **Standalone** — the public `/by-subdomain` endpoint returns identity;
|
|
44
43
|
* `member_id` is null (anonymous visitor).
|
|
45
|
-
*
|
|
46
|
-
* The PostHog key/host are not here — they're the public project key, hardcoded
|
|
47
|
-
* in `analytics.ts`.
|
|
48
|
-
*/
|
|
49
|
-
/**
|
|
50
|
-
* A single installation config value. An app package declares typed config knobs
|
|
51
|
-
* (labels, toggles, theme, column choices) with defaults; an installation stores
|
|
52
|
-
* the customized values, which the SDK's `useConfig()` reads. The canonical type
|
|
53
|
-
* lives in `@lotics/shared` (`AppInstallationConfigValue`); the SDK mirrors it
|
|
54
|
-
* here so it stays a zero-internal-dependency published package (the boundary
|
|
55
|
-
* mirror, like `AppContext` itself).
|
|
56
44
|
*/
|
|
57
|
-
export type AppConfigValue = string | number | boolean;
|
|
58
45
|
/** A raw reference to one record — table + record id, passed through UNRESOLVED.
|
|
59
46
|
* The member's own chat agent may act on it only where that member's IAM already
|
|
60
47
|
* allows; the app never resolves it into data here. */
|
|
@@ -87,10 +74,6 @@ export type HostNotification = {
|
|
|
87
74
|
context: AiContextValue | null;
|
|
88
75
|
};
|
|
89
76
|
export interface AppContext {
|
|
90
|
-
app_id: string;
|
|
91
|
-
app_name: string;
|
|
92
|
-
workspace_id: string;
|
|
93
|
-
organization_id: string;
|
|
94
77
|
member_id: string | null;
|
|
95
78
|
/**
|
|
96
79
|
* Whether the app declared the `comments` capability. `useComments` is
|
|
@@ -98,13 +81,6 @@ export interface AppContext {
|
|
|
98
81
|
* app that didn't opt in, and (vacuously) for standalone visitors.
|
|
99
82
|
*/
|
|
100
83
|
comments_enabled: boolean;
|
|
101
|
-
/**
|
|
102
|
-
* The installation's customization values — the package's config knobs by
|
|
103
|
-
* alias (see `useConfig`). Empty for a bespoke app (no package) and until the
|
|
104
|
-
* context resolves. Read-only; an app changes config through the product, not
|
|
105
|
-
* the SDK.
|
|
106
|
-
*/
|
|
107
|
-
config: Record<string, AppConfigValue>;
|
|
108
84
|
}
|
|
109
85
|
/**
|
|
110
86
|
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
@@ -149,6 +125,25 @@ export declare function postHostNotification(message: HostNotification): void;
|
|
|
149
125
|
* stay on pull-based freshness.
|
|
150
126
|
*/
|
|
151
127
|
export declare function subscribeQueriesChanged(cb: (aliases: string[]) => void): () => void;
|
|
128
|
+
/** Register a mounted query's alias. Returns the matching unregister. */
|
|
129
|
+
export declare function registerQueryAlias(alias: string): () => void;
|
|
130
|
+
/**
|
|
131
|
+
* Re-read the mounted queries because THIS app just wrote.
|
|
132
|
+
*
|
|
133
|
+
* The host's `queriesChanged` push is for changes the app did not make — it
|
|
134
|
+
* travels the realtime path (a version counter, a socket the host owns, and
|
|
135
|
+
* coalescing that is deliberately lazy under load), which is right for another
|
|
136
|
+
* member's edit and far too slow for your own. A writer already knows, so it
|
|
137
|
+
* rings the same bell locally instead of waiting to be told.
|
|
138
|
+
*
|
|
139
|
+
* It names every mounted alias rather than only the ones the write touched: the
|
|
140
|
+
* app cannot know which tables a workflow wrote, and the alternative — asking
|
|
141
|
+
* every call site to declare what it invalidates — is a list that goes stale
|
|
142
|
+
* silently the first time a workflow body grows a second write. The cost is
|
|
143
|
+
* bounded by what is on screen, and it is the work the app was going to do a
|
|
144
|
+
* moment later anyway.
|
|
145
|
+
*/
|
|
146
|
+
export declare function notifyLocalWrite(): void;
|
|
152
147
|
/**
|
|
153
148
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
154
149
|
* (the caller parses it via `agent_stream`); `done` settles when the stream
|
|
@@ -198,26 +193,3 @@ export declare function transportErrorMessage(status: number, parsed: unknown):
|
|
|
198
193
|
* message, and a non-JSON or 5xx body never becomes one.
|
|
199
194
|
*/
|
|
200
195
|
export declare function streamStartError(res: Response): Promise<Error>;
|
|
201
|
-
/**
|
|
202
|
-
* A package installation's alias→concrete-id maps — what the generated
|
|
203
|
-
* `.lotics/app_fields.ts` of a package project resolves `F`/`OPT`/`ROLE`
|
|
204
|
-
* through at module load. Keys are fully-qualified contract aliases
|
|
205
|
-
* (`entity.field`, `entity.field:opt`, role alias); values are this
|
|
206
|
-
* installation's concrete ids. 404s for a bespoke (non-package) app.
|
|
207
|
-
*/
|
|
208
|
-
export interface AppBinding {
|
|
209
|
-
fields: Record<string, string>;
|
|
210
|
-
options: Record<string, string>;
|
|
211
|
-
roles: Record<string, string>;
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Fetch the installation's binding, once per boot (module-cached — every
|
|
215
|
-
* `app_fields` import shares the same in-flight promise). Called at module
|
|
216
|
-
* load via top-level await, so it must work before `mount()` — and a failure
|
|
217
|
-
* there rejects the whole module graph (blank frame, no ErrorBoundary can
|
|
218
|
-
* catch module evaluation). Two mitigations, mirroring `boot()`:
|
|
219
|
-
* bounded retries absorb a transient transport blip, and a rejection is never
|
|
220
|
-
* cached so a remount/direct caller can retry rather than replaying the same
|
|
221
|
-
* stale failure forever.
|
|
222
|
-
*/
|
|
223
|
-
export declare function getAppBinding(): Promise<AppBinding>;
|
package/dist/src/rpc.js
CHANGED
|
@@ -111,6 +111,50 @@ export function subscribeQueriesChanged(cb) {
|
|
|
111
111
|
refetchSubscribers.delete(cb);
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Every alias with a mounted query hook, so a local write can name them.
|
|
116
|
+
*
|
|
117
|
+
* Registered by the hooks themselves rather than derived from the manifest: what
|
|
118
|
+
* matters is what is ON SCREEN, and a screen mounts a handful of an app's
|
|
119
|
+
* queries. Keyed by alias with a count, because two hooks can read one alias at
|
|
120
|
+
* once (a register and the drawer over it) and the first to unmount must not
|
|
121
|
+
* retract the second's registration.
|
|
122
|
+
*/
|
|
123
|
+
const mountedAliases = new Map();
|
|
124
|
+
/** Register a mounted query's alias. Returns the matching unregister. */
|
|
125
|
+
export function registerQueryAlias(alias) {
|
|
126
|
+
mountedAliases.set(alias, (mountedAliases.get(alias) ?? 0) + 1);
|
|
127
|
+
return () => {
|
|
128
|
+
const n = (mountedAliases.get(alias) ?? 1) - 1;
|
|
129
|
+
if (n > 0)
|
|
130
|
+
mountedAliases.set(alias, n);
|
|
131
|
+
else
|
|
132
|
+
mountedAliases.delete(alias);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Re-read the mounted queries because THIS app just wrote.
|
|
137
|
+
*
|
|
138
|
+
* The host's `queriesChanged` push is for changes the app did not make — it
|
|
139
|
+
* travels the realtime path (a version counter, a socket the host owns, and
|
|
140
|
+
* coalescing that is deliberately lazy under load), which is right for another
|
|
141
|
+
* member's edit and far too slow for your own. A writer already knows, so it
|
|
142
|
+
* rings the same bell locally instead of waiting to be told.
|
|
143
|
+
*
|
|
144
|
+
* It names every mounted alias rather than only the ones the write touched: the
|
|
145
|
+
* app cannot know which tables a workflow wrote, and the alternative — asking
|
|
146
|
+
* every call site to declare what it invalidates — is a list that goes stale
|
|
147
|
+
* silently the first time a workflow body grows a second write. The cost is
|
|
148
|
+
* bounded by what is on screen, and it is the work the app was going to do a
|
|
149
|
+
* moment later anyway.
|
|
150
|
+
*/
|
|
151
|
+
export function notifyLocalWrite() {
|
|
152
|
+
if (mountedAliases.size === 0)
|
|
153
|
+
return;
|
|
154
|
+
const aliases = [...mountedAliases.keys()];
|
|
155
|
+
for (const cb of refetchSubscribers)
|
|
156
|
+
cb(aliases);
|
|
157
|
+
}
|
|
114
158
|
const pending = new Map();
|
|
115
159
|
const streaming = new Map();
|
|
116
160
|
/** `useUrlState` subscribers — notified when the host broadcasts new params
|
|
@@ -413,11 +457,11 @@ function readSessionCookie() {
|
|
|
413
457
|
return null;
|
|
414
458
|
}
|
|
415
459
|
/**
|
|
416
|
-
* Resolve the app's identity
|
|
460
|
+
* Resolve the app's identity from its own subdomain. Shared
|
|
417
461
|
* promise so the context bootstrap and the first data call coalesce into one
|
|
418
|
-
* `/by-subdomain` fetch. Does NOT touch the password gate —
|
|
419
|
-
*
|
|
420
|
-
*
|
|
462
|
+
* `/by-subdomain` fetch. Does NOT touch the password gate — the app's own
|
|
463
|
+
* identity is not the gated thing, so a password-gated app still resolves it
|
|
464
|
+
* before the prompt.
|
|
421
465
|
*/
|
|
422
466
|
function resolveAppInfo() {
|
|
423
467
|
if (appInfoPromise)
|
|
@@ -609,8 +653,6 @@ function rpcStandalone(op, payload) {
|
|
|
609
653
|
return standaloneMembers(payload);
|
|
610
654
|
case "context":
|
|
611
655
|
return standaloneContext();
|
|
612
|
-
case "binding":
|
|
613
|
-
return standaloneBinding();
|
|
614
656
|
case "openExternal":
|
|
615
657
|
return standaloneOpenExternal(payload);
|
|
616
658
|
case "askAi":
|
|
@@ -641,46 +683,6 @@ function rpcStandalone(op, payload) {
|
|
|
641
683
|
function rejectCommentsStandalone() {
|
|
642
684
|
return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
|
|
643
685
|
}
|
|
644
|
-
let bindingPromise;
|
|
645
|
-
/**
|
|
646
|
-
* Fetch the installation's binding, once per boot (module-cached — every
|
|
647
|
-
* `app_fields` import shares the same in-flight promise). Called at module
|
|
648
|
-
* load via top-level await, so it must work before `mount()` — and a failure
|
|
649
|
-
* there rejects the whole module graph (blank frame, no ErrorBoundary can
|
|
650
|
-
* catch module evaluation). Two mitigations, mirroring `boot()`:
|
|
651
|
-
* bounded retries absorb a transient transport blip, and a rejection is never
|
|
652
|
-
* cached so a remount/direct caller can retry rather than replaying the same
|
|
653
|
-
* stale failure forever.
|
|
654
|
-
*/
|
|
655
|
-
export function getAppBinding() {
|
|
656
|
-
if (bindingPromise === undefined) {
|
|
657
|
-
const attempt = fetchBindingWithRetry();
|
|
658
|
-
bindingPromise = attempt;
|
|
659
|
-
attempt.catch(() => {
|
|
660
|
-
if (bindingPromise === attempt)
|
|
661
|
-
bindingPromise = undefined;
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
return bindingPromise;
|
|
665
|
-
}
|
|
666
|
-
async function fetchBindingWithRetry() {
|
|
667
|
-
const delays = [500, 1500];
|
|
668
|
-
for (const delay of delays) {
|
|
669
|
-
try {
|
|
670
|
-
return await rpc("binding", {});
|
|
671
|
-
}
|
|
672
|
-
catch {
|
|
673
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
return rpc("binding", {});
|
|
677
|
-
}
|
|
678
|
-
async function standaloneBinding() {
|
|
679
|
-
const { app_id } = await boot();
|
|
680
|
-
return (await apiCall("GET", `/v1/apps/${app_id}/binding`, undefined, {
|
|
681
|
-
appId: app_id,
|
|
682
|
-
}));
|
|
683
|
-
}
|
|
684
686
|
async function standaloneMembers(p) {
|
|
685
687
|
const { app_id } = await boot();
|
|
686
688
|
const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
|
|
@@ -720,15 +722,10 @@ async function standaloneUrlStateSet(p) {
|
|
|
720
722
|
async function standaloneContext() {
|
|
721
723
|
const info = await resolveAppInfo();
|
|
722
724
|
return {
|
|
723
|
-
app_id: info.app_id,
|
|
724
|
-
app_name: info.app_name,
|
|
725
|
-
workspace_id: info.workspace_id,
|
|
726
|
-
organization_id: info.organization_id,
|
|
727
725
|
// No host session in standalone mode — the visitor is anonymous, so
|
|
728
726
|
// comments are unavailable regardless of the capability flag.
|
|
729
727
|
member_id: null,
|
|
730
728
|
comments_enabled: info.comments_enabled,
|
|
731
|
-
config: info.config ?? {},
|
|
732
729
|
};
|
|
733
730
|
}
|
|
734
731
|
async function standaloneQuery(p) {
|
package/dist/src/viewer.d.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { type AppConfigValue } from "./rpc.js";
|
|
2
1
|
/**
|
|
3
2
|
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
4
3
|
* The host (the product iframe, or `lotics app dev`) supplies the signed-in
|
|
5
|
-
* member
|
|
4
|
+
* member and the app's declared capabilities.
|
|
6
5
|
*/
|
|
7
6
|
export declare function useAppContext(): {
|
|
8
7
|
memberId: string | null;
|
|
9
8
|
commentsEnabled: boolean;
|
|
10
|
-
config: Record<string, AppConfigValue>;
|
|
11
9
|
resolved: boolean;
|
|
12
10
|
};
|
|
13
11
|
/**
|
package/dist/src/viewer.js
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import useSWR from "swr";
|
|
2
2
|
import { rpc } from "./rpc.js";
|
|
3
|
-
// Stable identity for the not-yet-resolved / bespoke-app case. An inline `{}`
|
|
4
|
-
// fallback would be a fresh reference every render, defeating useConfig's
|
|
5
|
-
// memo (its result would recompute — and re-render consumers — on every
|
|
6
|
-
// render even with unchanged values).
|
|
7
|
-
const EMPTY_CONFIG = {};
|
|
8
3
|
/**
|
|
9
4
|
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
10
5
|
* The host (the product iframe, or `lotics app dev`) supplies the signed-in
|
|
11
|
-
* member
|
|
6
|
+
* member and the app's declared capabilities.
|
|
12
7
|
*/
|
|
13
8
|
export function useAppContext() {
|
|
14
9
|
const { data } = useSWR("app-context", () => rpc("context", {}), {
|
|
@@ -20,7 +15,6 @@ export function useAppContext() {
|
|
|
20
15
|
return {
|
|
21
16
|
memberId: data?.member_id ?? null,
|
|
22
17
|
commentsEnabled: data?.comments_enabled ?? false,
|
|
23
|
-
config: data?.config ?? EMPTY_CONFIG,
|
|
24
18
|
resolved: data !== undefined,
|
|
25
19
|
};
|
|
26
20
|
}
|
package/docs/data_fetching.md
CHANGED
|
@@ -116,9 +116,9 @@ server validates system conditions by `type` and never reads `field_key` on them
|
|
|
116
116
|
renders the cached rows instantly *and* revalidates them in the background, so a list reflects what
|
|
117
117
|
another screen changed while you were away. Identical concurrent reads dedupe to one request.
|
|
118
118
|
Freshness comes from four places — arrival, window focus / tab return / network reconnect
|
|
119
|
-
(`revalidateOnFocus`, default on), a **realtime push**, and
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
(`revalidateOnFocus`, default on), a **realtime push**, and **this app's own successful write**,
|
|
120
|
+
which re-reads every mounted query without being asked (see
|
|
121
|
+
[./mutations.md](./mutations.md)). `refetch()` covers what none of those can know about.
|
|
122
122
|
- **Realtime push keeps an already-open screen current.** When a table one of your queries reads
|
|
123
123
|
changes — another member, a workflow, the chat agent, or an external agent writing over the CLI or
|
|
124
124
|
MCP — that query refetches within about a second. Nothing to wire: it follows from the query's own
|
|
@@ -153,10 +153,11 @@ server validates system conditions by `type` and never reads `field_key` on them
|
|
|
153
153
|
number, while `error !== null`. Note a key CHANGE resets this: a new `params`/`filter`/`sort`
|
|
154
154
|
is a fresh key with no prior rows, so the "last successful rows stay rendered" behaviour above
|
|
155
155
|
does not save you.
|
|
156
|
-
- **`refetch()`** re-runs the query.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
156
|
+
- **`refetch()`** re-runs the query. A successful `useWorkflow` call already re-reads the mounted
|
|
157
|
+
queries on its own (see [./mutations.md](./mutations.md)), so reach for this only where a write
|
|
158
|
+
cannot have told you: a poll, a value that changes without anything on this screen writing, or a
|
|
159
|
+
total you supplied yourself. `usePaginatedQuery.refetch()` refreshes the page, and the count when
|
|
160
|
+
the hook owns it (a caller-supplied `total` is the caller's to refresh).
|
|
160
161
|
- **Ambient-chat mutations refetch automatically.** When the member's ambient chat agent (see
|
|
161
162
|
[./ai.md](./ai.md#useaicontextslot-context--tell-the-ambient-chat-what-the-member-is-looking-at))
|
|
162
163
|
finishes a turn that mutated records, the host pushes **every mounted query hook** to re-read —
|
package/docs/mutations.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Everything about writing data from an app: `useWorkflow` (the only write path), the
|
|
4
4
|
`WorkflowResult` contract and its resolve-never-throw failure model, declaring typed workflow
|
|
5
5
|
inputs (file/member/optional inputs), returning structured data with `return({ data })`,
|
|
6
|
-
|
|
6
|
+
the automatic re-read a successful write triggers, the diff-before-update discipline, locked records
|
|
7
7
|
(`readLocked` + `request_locked_record_change`), and optimistic reconciliation
|
|
8
8
|
(`useOptimistic`). Read this before building any screen that creates, updates, or deletes
|
|
9
9
|
records. What may be written **inside** the workflow body — the JS subset, steps, helpers,
|
|
@@ -89,7 +89,7 @@ if (result.status === "error") {
|
|
|
89
89
|
showError(result.message ?? "Something went wrong.");
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
// Success. Mounted queries re-read themselves — see "Refetch after a mutation".
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
**Warning:** a `try/catch` with no `status` check silently treats every workflow failure as
|
|
@@ -312,9 +312,9 @@ is [workflows](./workflows.md#writing-records).
|
|
|
312
312
|
|
|
313
313
|
## Refetch after a mutation
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
**A successful workflow re-reads the queries that are mounted, on its own.** A workflow is the
|
|
316
|
+
app's only write path, so the rows on screen are stale the moment one succeeds — the SDK
|
|
317
|
+
re-reads them rather than making every call site remember to. Nothing to call:
|
|
318
318
|
|
|
319
319
|
```tsx
|
|
320
320
|
const items = useQuery("items");
|
|
@@ -323,10 +323,24 @@ const closeItem = useWorkflow("closeItem");
|
|
|
323
323
|
const onClose = async (recordId: string) => {
|
|
324
324
|
const r = await closeItem({ record_id: recordId });
|
|
325
325
|
if (r.status === "error") { showError(r.message); return; }
|
|
326
|
-
items.
|
|
326
|
+
// items re-reads itself — a successful write already said so.
|
|
327
327
|
};
|
|
328
328
|
```
|
|
329
329
|
|
|
330
|
+
Only on **success**: a refused write changed nothing, and re-reading after one would swap the
|
|
331
|
+
values the user is still looking at, and about to correct, for an identical set.
|
|
332
|
+
|
|
333
|
+
It names every MOUNTED alias, not the ones the write touched — an app cannot know which tables
|
|
334
|
+
a workflow body wrote, and a per-call-site list of what to invalidate goes stale silently the
|
|
335
|
+
first time that body grows a second write. The cost is bounded by what is on screen.
|
|
336
|
+
|
|
337
|
+
This is separate from the host's realtime push, which carries changes **other** people make and
|
|
338
|
+
travels a socket the host owns. That path is right for someone else's edit and far too slow for
|
|
339
|
+
your own — waiting on it is what makes a screen feel like it lagged its own button.
|
|
340
|
+
|
|
341
|
+
`refetch()` remains for the reads a write cannot know about: a poll, a value the user expects to
|
|
342
|
+
change without writing anything, or a total you supplied yourself (below).
|
|
343
|
+
|
|
330
344
|
`refetch` re-runs the query in the background while the current rows stay on screen (no
|
|
331
345
|
flash to a spinner — `loading` stays false during revalidation). `usePaginatedQuery`'s
|
|
332
346
|
`refetch` re-runs the current page, and the count when the hook owns it — a total you supplied
|
|
@@ -411,7 +425,13 @@ Why a full-form snapshot save is a bug, not a style choice — three independent
|
|
|
411
425
|
3. **Snapshots clobber concurrent edits.** An update writes exactly the fields present in the
|
|
412
426
|
payload; unsent fields keep their current value. A diff therefore coexists with a
|
|
413
427
|
colleague's concurrent edit to a *different* field — a snapshot overwrites it with the
|
|
414
|
-
stale load-time value.
|
|
428
|
+
stale load-time value. The coexistence stops at field granularity: sending the full array
|
|
429
|
+
of a multi-value field is a snapshot *inside* that field, so two writers appending to it
|
|
430
|
+
each keep the array they read and the later one wins, both reporting success. When the
|
|
431
|
+
append is the system's rather than a person's — an upload settling, a workflow attaching a
|
|
432
|
+
generated document — name the ITEMS with `add_to` / `remove_from`
|
|
433
|
+
([workflows](./workflows.md#writing-records)) and the write path resolves them against the
|
|
434
|
+
record as it stands.
|
|
415
435
|
|
|
416
436
|
```tsx
|
|
417
437
|
// load: snapshot the editable fields from the row
|
package/docs/runtime.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# The app runtime
|
|
2
2
|
|
|
3
3
|
How a custom-code app boots and talks to the platform: **`mount()`** (the entry
|
|
4
|
-
point, including the design-time mock harness
|
|
4
|
+
point, including the design-time mock harness),
|
|
5
5
|
the **two transports** the SDK switches between (embedded postMessage bridge vs.
|
|
6
6
|
standalone direct API — app code never branches), the raw **`rpc()`** escape
|
|
7
7
|
hatch, and the browser capabilities the sandbox would otherwise block —
|
|
@@ -32,9 +32,6 @@ mount(<App />);
|
|
|
32
32
|
visible in the iframe itself, even before any host telemetry is wired up.
|
|
33
33
|
Banners are informational only; they are not removed automatically.
|
|
34
34
|
4. **Renders the tree** with React 19's `createRoot`.
|
|
35
|
-
5. **Boots analytics** fire-and-forget (see [Automatic analytics](#automatic-analytics-posthog)).
|
|
36
|
-
Never awaited — it cannot delay first paint, and any failure leaves the app
|
|
37
|
-
fully functional and untracked.
|
|
38
35
|
|
|
39
36
|
### The mock harness (`options.fixture` + `?__mock=1`)
|
|
40
37
|
|
|
@@ -239,14 +236,11 @@ semantics are documented:
|
|
|
239
236
|
The **streaming** agent-run op is *not* reachable through `rpc()` — its
|
|
240
237
|
response is a chunk stream, not a single value; it's internal to `useAgentRun`.
|
|
241
238
|
|
|
242
|
-
`rpc("context", {})` resolves the
|
|
243
|
-
|
|
244
|
-
`
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
the context type is not exported from the package root — type the result
|
|
248
|
-
yourself via the `rpc<T>` generic (`AppConfigValue`, the config value type, *is*
|
|
249
|
-
exported).
|
|
239
|
+
`rpc("context", {})` resolves the viewer: `{ member_id, comments_enabled }`.
|
|
240
|
+
`member_id` is the signed-in member when embedded, `null` standalone — read it
|
|
241
|
+
through `useViewer()` rather than this op. **Limitation:** the context type is
|
|
242
|
+
not exported from the package root, so type the result yourself via the `rpc<T>`
|
|
243
|
+
generic.
|
|
250
244
|
|
|
251
245
|
## `openExternal()` — open a link in a new tab
|
|
252
246
|
|
|
@@ -359,106 +353,21 @@ await checkIn({ latitude: r.coords.latitude, longitude: r.coords.longitude });
|
|
|
359
353
|
there is no workflow log to find. If field failures need diagnosing, surface
|
|
360
354
|
the `reason` visibly in the UI.
|
|
361
355
|
|
|
362
|
-
##
|
|
356
|
+
## Analytics
|
|
363
357
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
358
|
+
The SDK ships no analytics and no hook for app-defined events. Don't add
|
|
359
|
+
`posthog-js` or another browser SDK to an app either: the app host serves every
|
|
360
|
+
response under a `Content-Security-Policy` whose `connect-src` reaches only the
|
|
361
|
+
Lotics API, so a third-party collector's requests are blocked and the only
|
|
362
|
+
visible result is console noise.
|
|
369
363
|
|
|
370
|
-
|
|
371
|
-
Everything an app *does* is already recorded by the platform — a comment, an
|
|
364
|
+
Most of what an app does is already recorded platform-side — a comment, an
|
|
372
365
|
upload, a workflow or agent run each leave a row, and a run's outcome, including
|
|
373
|
-
a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
init rather than relied on project-wide: the server-side setting fails open,
|
|
377
|
-
and an app screen renders the customer's own records.
|
|
378
|
-
- `app_opened` carries the app identity as super-properties — `app_id`,
|
|
379
|
-
`workspace_id`, `organization_id`, ids only, since a name is the customer's
|
|
380
|
-
own words — and rolls up under the `organization` group. Embedded apps `identify` the signed-in member — app and
|
|
381
|
-
product events share one person; standalone visitors stay anonymous.
|
|
382
|
-
- Uncaught exceptions are captured (PostHog error tracking) in addition to
|
|
383
|
-
`mount()`'s visible banner — the app's one error channel.
|
|
384
|
-
- **Tracking is gated to the deployed app host** (`*.lotics.app`). `lotics app
|
|
385
|
-
dev` (localhost) and any `?__mock=1` load emit nothing. Best-effort
|
|
386
|
-
throughout: a failed init or a failed `context` resolution leaves the app
|
|
387
|
-
fully working and untracked.
|
|
388
|
-
- **Limitation:** PostHog's default bot/user-agent filter applies — headless
|
|
389
|
-
browsers (e.g. Playwright) are never tracked, so analytics cannot be verified
|
|
390
|
-
through headless automation.
|
|
391
|
-
|
|
392
|
-
## Installation config — `useConfig()`
|
|
393
|
-
|
|
394
|
-
**`useConfig(defaults?)`** → `{ config, loading }` — the App-Packages customization
|
|
395
|
-
knobs for this installation (labels, toggles, theme, column choices), keyed by the
|
|
396
|
-
alias the package contract declares. Exact signature: `dist/src/config.d.ts`.
|
|
366
|
+
a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`. App opens are
|
|
367
|
+
counted at the serving edge. If a funnel needs something those cannot answer,
|
|
368
|
+
request it as a platform change.
|
|
397
369
|
|
|
398
|
-
|
|
399
|
-
const { config } = useConfig({ board_title: "Tasks", show_archive: false });
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
- **Pass the contract's defaults.** They fill un-overridden knobs and paint
|
|
403
|
-
flicker-free before the context resolves; the installation's stored values
|
|
404
|
-
overlay them (a stored value always wins over the same-key default). `loading`
|
|
405
|
-
is true only until the **one-shot** context RPC settles (it never revalidates)
|
|
406
|
-
— gate on it only for config-derived layout that must not flash a default.
|
|
407
|
-
- Values are `string | number | boolean` (`AppConfigValue`, exported from the
|
|
408
|
-
package root). The returned object is referentially stable across renders while
|
|
409
|
-
the values are unchanged — the merge is memoized, and the defaults are keyed by
|
|
410
|
-
their *content*, so passing a fresh inline `{…}` literal every render is fine
|
|
411
|
-
(no churn); it's safe as a `useEffect`/`useMemo` dependency.
|
|
412
|
-
- **Bespoke apps** (not installed from a package) get `{}` from the host — the
|
|
413
|
-
hook resolves to just the defaults, so shared code needs no package check.
|
|
414
|
-
- **A standalone (public) app behind a password never receives its stored config
|
|
415
|
-
in the client.** The knobs are gated server-side (they can carry business
|
|
416
|
-
terms), and the SDK resolves the app's identity once — before the visitor
|
|
417
|
-
authenticates — so `useConfig` returns the defaults only, even after unlock.
|
|
418
|
-
Design a public, password-gated app to be correct on its defaults alone.
|
|
419
|
-
(Embedded and standalone-*unprotected* apps both get the stored config.)
|
|
420
|
-
- **Read-only.** Config is edited in the product (the installation's settings),
|
|
421
|
-
never written from app code — there is no setter.
|
|
422
|
-
|
|
423
|
-
## Installation binding — `getAppBinding()` (package apps)
|
|
424
|
-
|
|
425
|
-
**`getAppBinding()`** → `Promise<AppBinding>` — a package installation's
|
|
426
|
-
alias→concrete-id maps: `{ fields, options, roles }`, keyed by fully-qualified
|
|
427
|
-
contract aliases (`"tasks.title"`, `"tasks.status:to_do"`, role alias) with this
|
|
428
|
-
workspace's `fld_`/`opt_`/`grp_` ids as values. Exact signature:
|
|
429
|
-
`dist/src/rpc.d.ts`.
|
|
430
|
-
|
|
431
|
-
You normally never call it yourself: on a package installation (a published
|
|
432
|
-
origin, or an install of someone's package) `lotics app codegen` emits a
|
|
433
|
-
binding-form `.lotics/app_fields.ts` that calls it once at **module load** via
|
|
434
|
-
top-level await and exports plain-string `F` / `OPT` / `ROLE` maps — the same
|
|
435
|
-
import surface as a bespoke app's baked codegen, but resolved per-installation
|
|
436
|
-
at runtime:
|
|
437
|
-
|
|
438
|
-
```ts
|
|
439
|
-
import { OPT } from "../.lotics/app_fields";
|
|
440
|
-
const STATUS_DONE = OPT.TASKS.status.done; // this installation's "opt_…"
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
- **One fetch per boot.** The promise is module-cached; every importer shares
|
|
444
|
-
it. The ESM graph awaits it before any dependent module evaluates, so the
|
|
445
|
-
values are ordinary strings everywhere — including module-top-level constants.
|
|
446
|
-
Transient failures are retried (3 attempts, short backoff) and a rejection is
|
|
447
|
-
never cached — a module-load failure rejects the whole graph (blank frame),
|
|
448
|
-
so the fetch absorbs blips rather than bricking the boot on one lost request.
|
|
449
|
-
- **Requires the starter's `build.target: "es2022"`** (top-level await does not
|
|
450
|
-
exist below it). An app scaffolded before the starter set that target must
|
|
451
|
-
set it in `vite.config.ts` before shipping the binding form.
|
|
452
|
-
- **Fails loud.** A key missing from the binding throws at boot with the alias
|
|
453
|
-
named — the binding is verified complete at install/adopt, so a miss means
|
|
454
|
-
the generated file is stale relative to the installed contract version
|
|
455
|
-
(re-run the codegen, republish).
|
|
456
|
-
- **Bespoke apps 404.** An app that is not a package installation has no
|
|
457
|
-
binding; codegen emits the baked variant for it instead. Shared code should
|
|
458
|
-
not call this directly — import from `.lotics/app_fields` and let codegen
|
|
459
|
-
pick the form.
|
|
460
|
-
|
|
461
|
-
## For package contributors
|
|
370
|
+
## For SDK contributors
|
|
462
371
|
|
|
463
372
|
Everything below concerns changing `@lotics/app-sdk` itself (in the Lotics
|
|
464
373
|
monorepo), not building apps with it.
|
|
@@ -518,7 +427,7 @@ is a transport that wasn't wired.
|
|
|
518
427
|
- **Data + RPC only — zero UI.** Never re-export a `@lotics/ui` component; the
|
|
519
428
|
SDK stays off the React-Native-Web dependency tree. Apps import `@lotics/ui`
|
|
520
429
|
directly.
|
|
521
|
-
- **Keep the dependency set minimal.** Runtime deps are `
|
|
430
|
+
- **Keep the dependency set minimal.** Runtime deps are `ai` and `swr`;
|
|
522
431
|
`react`/`react-dom` are peers. `react-router` is an **optional** peer
|
|
523
432
|
pulled in only by the `@lotics/app-sdk/router` subpath export — the root entry
|
|
524
433
|
must never import it. (`react-router` is the canonical package; the
|
package/docs/workflows.md
CHANGED
|
@@ -482,6 +482,14 @@ differ from `<` / `>` (code-unit order) and from the database's collation.
|
|
|
482
482
|
| `add_to` / `remove_from` / `replace` | `{ fld_x: [items] }` | surgical edits on multi-value fields (files, multi select, multi member, record links) |
|
|
483
483
|
| `field_edits` | `[{ field, op, value }]` | the same surgical ops with the field named by a **string expression** — the only way to target a field chosen at run time |
|
|
484
484
|
|
|
485
|
+
`add_to` / `remove_from` are RELATIVE: the server resolves them against the record as it stands
|
|
486
|
+
when the write lands, inside the lock it already takes. Two runs appending to one field at the
|
|
487
|
+
same time therefore each keep their item — which reading the field and writing the whole array
|
|
488
|
+
back through `set` does not, since both fold onto the array they read and the later write drops
|
|
489
|
+
the earlier, both reporting success. Parallel runs are ordinary (a trigger firing twice, an agent
|
|
490
|
+
emitting two calls in one step), so reach for the ops rather than a read-modify-write whenever
|
|
491
|
+
the workflow is ADDING to a field rather than stating it.
|
|
492
|
+
|
|
485
493
|
Inside `set` and `create_records.records`: `null` clears (persisted), `undefined` or an omitted
|
|
486
494
|
key preserves. So passing a possibly-null read straight through is safe. Use
|
|
487
495
|
`coalesce(x, fallback)` only when you want a real fallback, never to "strip" null.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.86.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": {
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"ai": "^7.0.30",
|
|
30
|
-
"posthog-js": "^1.352.0",
|
|
31
30
|
"swr": "^2.4.1"
|
|
32
31
|
},
|
|
33
32
|
"peerDependencies": {
|
package/dist/src/analytics.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function bootstrapAnalytics(): Promise<void>;
|
package/dist/src/analytics.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PostHog analytics for custom-code apps.
|
|
3
|
-
*
|
|
4
|
-
* Apps are a separate cross-origin bundle at `<slug>.lotics.app`, invisible to
|
|
5
|
-
* the product's PostHog — so `mount()` boots a PostHog instance per app.
|
|
6
|
-
*
|
|
7
|
-
* **Why explicit events, not autocapture:** DOM autocapture ships `$el_text` —
|
|
8
|
-
* the visible text of whatever was clicked, i.e. the customer's own record
|
|
9
|
-
* values — and Điều 3.2 of the service contract warrants that our telemetry
|
|
10
|
-
* carries no record or document content. The flags below are what enforce
|
|
11
|
-
* that; the project-level setting is NOT a backstop, since it is server-side
|
|
12
|
-
* and fails open when the config fetch fails.
|
|
13
|
-
*
|
|
14
|
-
* **What earns an event:** who KNOWS the fact. An effect the platform performs
|
|
15
|
-
* or persists is the platform's — a comment, an upload, a workflow or agent run
|
|
16
|
-
* all leave rows, and a client copy would duplicate one AND keep arriving from
|
|
17
|
-
* apps pinned to an old SDK long after the code is deleted. A FAILURE is not a
|
|
18
|
-
* gesture either; it is system telemetry, and this bundle has no log sink to
|
|
19
|
-
* carry it, so it is not an event here by default.
|
|
20
|
-
*
|
|
21
|
-
* That leaves exactly one: `app_opened`. Apps are a separate origin the
|
|
22
|
-
* product's own analytics cannot see, and the app-host gate log records gate
|
|
23
|
-
* DECISIONS rather than routine serves, so nothing else counts an app being
|
|
24
|
-
* used. Everything a run does is already in `app_agent_runs` — including a
|
|
25
|
-
* user's Stop, which stamps `cancel_requested_at`.
|
|
26
|
-
*
|
|
27
|
-
* Every event is tagged with app identity and rolls up under the existing
|
|
28
|
-
* `organization` group; embedded apps `identify` the member the host passes
|
|
29
|
-
* down so app + product events share one person. Exception capture is on (the
|
|
30
|
-
* project opts in); session replay is off (on by default project-wide, but
|
|
31
|
-
* apps — including anonymous public visitors — were never scoped for it).
|
|
32
|
-
*
|
|
33
|
-
* The key is the public, write-only project key (already shipped in every
|
|
34
|
-
* browser), hardcoded here. Tracking is gated to the deployed app host
|
|
35
|
-
* (`*.lotics.app`); `lotics app dev` runs on localhost and stays untracked.
|
|
36
|
-
* Best-effort: any failure leaves the app fully functional and untracked.
|
|
37
|
-
*/
|
|
38
|
-
import posthog from "posthog-js";
|
|
39
|
-
import { rpc } from "./rpc.js";
|
|
40
|
-
import { hasMockFlag } from "./mock.js";
|
|
41
|
-
const POSTHOG_KEY = "phc_N1nyqSRdo9XMK3DODxrxX2Y9jG3dppybruOuMznbz62";
|
|
42
|
-
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
43
|
-
const APP_HOST_SUFFIX = ".lotics.app";
|
|
44
|
-
function onDeployedAppHost() {
|
|
45
|
-
try {
|
|
46
|
-
return window.location.hostname.endsWith(APP_HOST_SUFFIX);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
export async function bootstrapAnalytics() {
|
|
53
|
-
// Only the deployed app host tracks: a design-time/screenshot load
|
|
54
|
-
// (?__mock=1) and `lotics app dev` (localhost) emit nothing. This replaces
|
|
55
|
-
// the old "no key off-prod" gate now that the key is hardcoded.
|
|
56
|
-
if (hasMockFlag() || !onDeployedAppHost())
|
|
57
|
-
return;
|
|
58
|
-
let ctx;
|
|
59
|
-
try {
|
|
60
|
-
ctx = await rpc("context", {});
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// Best-effort: a context-resolution failure must never break the app.
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
posthog.init(POSTHOG_KEY, {
|
|
67
|
-
// Pin the modern posthog-js init contract (the product uses the same).
|
|
68
|
-
defaults: "2025-05-24",
|
|
69
|
-
api_host: POSTHOG_HOST,
|
|
70
|
-
// App analytics is explicit events — not DOM autocapture or pageviews.
|
|
71
|
-
// Set here rather than relied on project-wide: the server-side setting
|
|
72
|
-
// fails open, and an app screen renders the customer's own records.
|
|
73
|
-
autocapture: false,
|
|
74
|
-
mask_all_text: true,
|
|
75
|
-
capture_pageview: false,
|
|
76
|
-
capture_pageleave: false,
|
|
77
|
-
// Dead-click autocapture (enabled by the defaults preset) is a UX-research
|
|
78
|
-
// signal nobody consumes; explicit user actions + exception capture cover
|
|
79
|
-
// the app's monitoring needs.
|
|
80
|
-
capture_dead_clicks: false,
|
|
81
|
-
// Error tracking (the project opts in). mount() only renders a local
|
|
82
|
-
// banner, so this is the app's one exception channel.
|
|
83
|
-
capture_exceptions: true,
|
|
84
|
-
// Session replay is on by default project-wide; never on the app surface.
|
|
85
|
-
disable_session_recording: true,
|
|
86
|
-
// posthog-js keeps its default bot/user-agent filter, so headless/bot
|
|
87
|
-
// traffic is never tracked (real users are unaffected). One consequence:
|
|
88
|
-
// analytics can't be verified through headless Playwright — it's filtered.
|
|
89
|
-
//
|
|
90
|
-
// Tag + capture from `loaded` (once PostHog has initialized) — the robust
|
|
91
|
-
// point to register super-properties and emit the first event.
|
|
92
|
-
loaded: (ph) => {
|
|
93
|
-
// Ids only. A super-property rides every event and every `$exception`
|
|
94
|
-
// the session sends, and an app's NAME is the customer's own words —
|
|
95
|
-
// routinely a counterparty — which Điều 3.2 keeps out of telemetry. The
|
|
96
|
-
// id resolves to the name for anyone entitled to it.
|
|
97
|
-
ph.register({
|
|
98
|
-
app_id: ctx.app_id,
|
|
99
|
-
workspace_id: ctx.workspace_id,
|
|
100
|
-
organization_id: ctx.organization_id,
|
|
101
|
-
});
|
|
102
|
-
// Guarded: a bridged host that answered `context` before its auth member
|
|
103
|
-
// loaded sends an empty org — never group on an empty key.
|
|
104
|
-
if (ctx.organization_id)
|
|
105
|
-
ph.group("organization", ctx.organization_id);
|
|
106
|
-
// Embedded apps attach to the same person as the product; standalone
|
|
107
|
-
// visitors stay anonymous (member_id null).
|
|
108
|
-
if (ctx.member_id)
|
|
109
|
-
ph.identify(ctx.member_id);
|
|
110
|
-
ph.capture("app_opened");
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
}
|
package/dist/src/config.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { AppConfigValue } from "./rpc.js";
|
|
2
|
-
/**
|
|
3
|
-
* Read the installation's customization config — the first rung of the App
|
|
4
|
-
* Packages customization ladder (see docs/packages.md § The customization
|
|
5
|
-
* ladder). A package declares typed config knobs with defaults (labels, feature
|
|
6
|
-
* toggles, theme, column choices); each installation stores the customized values
|
|
7
|
-
* and the app reads them here. Updates flow: editing config in the product
|
|
8
|
-
* changes what this returns, with no re-deploy.
|
|
9
|
-
*
|
|
10
|
-
* `defaults` is the package contract's declared defaults — the canonical fallback
|
|
11
|
-
* (the codegen surface emits them so the call is fully typed). They fill any knob
|
|
12
|
-
* the installation hasn't overridden and provide a flicker-free first paint:
|
|
13
|
-
* before the context resolves the stored map is empty, so the defaults show
|
|
14
|
-
* immediately and the stored values overlay them once resolved.
|
|
15
|
-
*
|
|
16
|
-
* const { config } = useConfig({ "deal.label": "Deal", "show_archived": false });
|
|
17
|
-
* <Text>{config["deal.label"]}</Text>
|
|
18
|
-
*
|
|
19
|
-
* Gate on `loading` only for config-derived layout that must not flash a default.
|
|
20
|
-
*/
|
|
21
|
-
export declare function useConfig<T extends Record<string, AppConfigValue> = Record<string, AppConfigValue>>(defaults?: T): {
|
|
22
|
-
config: T;
|
|
23
|
-
loading: boolean;
|
|
24
|
-
};
|
package/dist/src/config.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
|
-
import { useAppContext } from "./viewer.js";
|
|
3
|
-
/**
|
|
4
|
-
* Read the installation's customization config — the first rung of the App
|
|
5
|
-
* Packages customization ladder (see docs/packages.md § The customization
|
|
6
|
-
* ladder). A package declares typed config knobs with defaults (labels, feature
|
|
7
|
-
* toggles, theme, column choices); each installation stores the customized values
|
|
8
|
-
* and the app reads them here. Updates flow: editing config in the product
|
|
9
|
-
* changes what this returns, with no re-deploy.
|
|
10
|
-
*
|
|
11
|
-
* `defaults` is the package contract's declared defaults — the canonical fallback
|
|
12
|
-
* (the codegen surface emits them so the call is fully typed). They fill any knob
|
|
13
|
-
* the installation hasn't overridden and provide a flicker-free first paint:
|
|
14
|
-
* before the context resolves the stored map is empty, so the defaults show
|
|
15
|
-
* immediately and the stored values overlay them once resolved.
|
|
16
|
-
*
|
|
17
|
-
* const { config } = useConfig({ "deal.label": "Deal", "show_archived": false });
|
|
18
|
-
* <Text>{config["deal.label"]}</Text>
|
|
19
|
-
*
|
|
20
|
-
* Gate on `loading` only for config-derived layout that must not flash a default.
|
|
21
|
-
*/
|
|
22
|
-
export function useConfig(defaults) {
|
|
23
|
-
const ctx = useAppContext();
|
|
24
|
-
const stored = ctx.config;
|
|
25
|
-
// Key the memo on the defaults CONTENT, not identity. An inline literal is a
|
|
26
|
-
// fresh reference each render, so a content hash keeps the result reference
|
|
27
|
-
// stable while values are unchanged AND recomputes when a default value changes
|
|
28
|
-
// (e.g. an i18n label after a locale switch) — which a ref-captured defaults
|
|
29
|
-
// would silently miss. Config maps are tiny, so the stringify cost is trivial.
|
|
30
|
-
const defaultsKey = JSON.stringify(defaults ?? {});
|
|
31
|
-
const config = useMemo(() => ({ ...(defaults ?? {}), ...stored }), [stored, defaultsKey]);
|
|
32
|
-
return { config, loading: !ctx.resolved };
|
|
33
|
-
}
|