@lotics/app-sdk 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -22,7 +22,7 @@ signature; open the file.**
22
22
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
23
23
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming `items` → `AgentRun`) and `askAi` — plus the fields-vs-file razor for choosing between them. |
24
24
  | [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. |
25
- | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), and the publish chain for package contributors. |
25
+ | [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. |
26
26
 
27
27
  ## Non-negotiables (each detailed in its doc)
28
28
 
@@ -25,8 +25,8 @@ export { useConfig } from "./config.js";
25
25
  export type { AppConfigValue } from "./rpc.js";
26
26
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
27
27
  export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
28
- export { rpc, isEmbedded } from "./rpc.js";
29
- export type { RpcOp } from "./rpc.js";
28
+ export { rpc, isEmbedded, getAppBinding } from "./rpc.js";
29
+ export type { RpcOp, AppBinding } from "./rpc.js";
30
30
  export { openExternal } from "./open_external.js";
31
31
  export { askAi, type AskAiArgs } from "./ask_ai.js";
32
32
  export { downloadFile } from "./download.js";
package/dist/src/index.js CHANGED
@@ -20,7 +20,7 @@ export { useComments, useCommentCounts } from "./comments.js";
20
20
  export { useViewer } from "./viewer.js";
21
21
  export { useConfig } from "./config.js";
22
22
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
23
- export { rpc, isEmbedded } from "./rpc.js";
23
+ export { rpc, isEmbedded, getAppBinding } from "./rpc.js";
24
24
  export { openExternal } from "./open_external.js";
25
25
  export { askAi } from "./ask_ai.js";
26
26
  export { downloadFile } from "./download.js";
package/dist/src/rpc.d.ts CHANGED
@@ -19,7 +19,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
19
19
  * app → host: { id, op, payload }
20
20
  * host → app: { id, type: "result", data } | { id, type: "error", message }
21
21
  */
22
- 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";
22
+ export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "binding" | "openExternal" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
23
23
  /** Payload for starting a streaming agent run. */
24
24
  export interface AgentRunPayload {
25
25
  alias: string;
@@ -110,3 +110,26 @@ export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: st
110
110
  * `parsed` is the JSON.parse of the body, or `null` if it wasn't JSON.
111
111
  */
112
112
  export declare function transportErrorMessage(status: number, parsed: unknown): string;
113
+ /**
114
+ * A package installation's alias→concrete-id maps — what the generated
115
+ * `.lotics/app_fields.ts` of a package project resolves `F`/`OPT`/`ROLE`
116
+ * through at module load. Keys are fully-qualified contract aliases
117
+ * (`entity.field`, `entity.field:opt`, role alias); values are this
118
+ * installation's concrete ids. 404s for a bespoke (non-package) app.
119
+ */
120
+ export interface AppBinding {
121
+ fields: Record<string, string>;
122
+ options: Record<string, string>;
123
+ roles: Record<string, string>;
124
+ }
125
+ /**
126
+ * Fetch the installation's binding, once per boot (module-cached — every
127
+ * `app_fields` import shares the same in-flight promise). Called at module
128
+ * load via top-level await, so it must work before `mount()` — and a failure
129
+ * there rejects the whole module graph (blank frame, no ErrorBoundary can
130
+ * catch module evaluation). Two mitigations, mirroring `boot()`:
131
+ * bounded retries absorb a transient transport blip, and a rejection is never
132
+ * cached so a remount/direct caller can retry rather than replaying the same
133
+ * stale failure forever.
134
+ */
135
+ export declare function getAppBinding(): Promise<AppBinding>;
package/dist/src/rpc.js CHANGED
@@ -452,6 +452,8 @@ function rpcStandalone(op, payload) {
452
452
  return standaloneMembers(payload);
453
453
  case "context":
454
454
  return standaloneContext();
455
+ case "binding":
456
+ return standaloneBinding();
455
457
  case "openExternal":
456
458
  return standaloneOpenExternal(payload);
457
459
  case "askAi":
@@ -482,6 +484,46 @@ function rpcStandalone(op, payload) {
482
484
  function rejectCommentsStandalone() {
483
485
  return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
484
486
  }
487
+ let bindingPromise;
488
+ /**
489
+ * Fetch the installation's binding, once per boot (module-cached — every
490
+ * `app_fields` import shares the same in-flight promise). Called at module
491
+ * load via top-level await, so it must work before `mount()` — and a failure
492
+ * there rejects the whole module graph (blank frame, no ErrorBoundary can
493
+ * catch module evaluation). Two mitigations, mirroring `boot()`:
494
+ * bounded retries absorb a transient transport blip, and a rejection is never
495
+ * cached so a remount/direct caller can retry rather than replaying the same
496
+ * stale failure forever.
497
+ */
498
+ export function getAppBinding() {
499
+ if (bindingPromise === undefined) {
500
+ const attempt = fetchBindingWithRetry();
501
+ bindingPromise = attempt;
502
+ attempt.catch(() => {
503
+ if (bindingPromise === attempt)
504
+ bindingPromise = undefined;
505
+ });
506
+ }
507
+ return bindingPromise;
508
+ }
509
+ async function fetchBindingWithRetry() {
510
+ const delays = [500, 1500];
511
+ for (const delay of delays) {
512
+ try {
513
+ return await rpc("binding", {});
514
+ }
515
+ catch {
516
+ await new Promise((resolve) => setTimeout(resolve, delay));
517
+ }
518
+ }
519
+ return rpc("binding", {});
520
+ }
521
+ async function standaloneBinding() {
522
+ const { app_id } = await boot();
523
+ return (await apiCall("GET", `/v1/apps/${app_id}/binding`, undefined, {
524
+ appId: app_id,
525
+ }));
526
+ }
485
527
  async function standaloneMembers(p) {
486
528
  const { app_id } = await boot();
487
529
  const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
package/docs/runtime.md CHANGED
@@ -166,7 +166,7 @@ surfaces:
166
166
 
167
167
  | Op | Embedded (product) | `lotics app dev` | Standalone |
168
168
  |---|---|---|---|
169
- | `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes | yes |
169
+ | `query`, `field_options`, `workflow`, `members`, `context`, `binding`, `upload`, `urlState.get/set`, `openExternal` | yes | yes | yes |
170
170
  | `comments.*` | yes | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
171
171
  | `agentRun` (streaming, internal to `useAgentRun`) | yes | yes | yes |
172
172
  | `agentRuns`, `agentRun.get`, `agentRun.cancel` | yes | **no** — the dev forwarder doesn't implement them (`"Unknown RPC op: …"`) | yes |
@@ -400,6 +400,43 @@ const { config } = useConfig({ board_title: "Tasks", show_archive: false });
400
400
  - **Read-only.** Config is edited in the product (the installation's settings),
401
401
  never written from app code — there is no setter.
402
402
 
403
+ ## Installation binding — `getAppBinding()` (package apps)
404
+
405
+ **`getAppBinding()`** → `Promise<AppBinding>` — a package installation's
406
+ alias→concrete-id maps: `{ fields, options, roles }`, keyed by fully-qualified
407
+ contract aliases (`"tasks.title"`, `"tasks.status:to_do"`, role alias) with this
408
+ workspace's `fld_`/`opt_`/`grp_` ids as values. Exact signature:
409
+ `dist/src/rpc.d.ts`.
410
+
411
+ You normally never call it yourself: the package project's generated
412
+ `.lotics/app_fields.ts` (written by `lotics package new/extract/dev/sync` from
413
+ `contract.json`) calls it once at **module load** via top-level await and
414
+ exports plain-string `F` / `OPT` / `ROLE` maps — the same import surface as a
415
+ bespoke app's baked codegen, but resolved per-installation at runtime:
416
+
417
+ ```ts
418
+ import { OPT } from "../.lotics/app_fields";
419
+ const STATUS_DONE = OPT.TASKS.status.done; // this installation's "opt_…"
420
+ ```
421
+
422
+ - **One fetch per boot.** The promise is module-cached; every importer shares
423
+ it. The ESM graph awaits it before any dependent module evaluates, so the
424
+ values are ordinary strings everywhere — including module-top-level constants.
425
+ Transient failures are retried (3 attempts, short backoff) and a rejection is
426
+ never cached — a module-load failure rejects the whole graph (blank frame),
427
+ so the fetch absorbs blips rather than bricking the boot on one lost request.
428
+ - **Requires the starter's `build.target: "es2022"`** (top-level await does not
429
+ exist below it). `lotics package extract` refreshes `vite.config.ts` from the
430
+ current starter for exactly this reason.
431
+ - **Fails loud.** A key missing from the binding throws at boot with the alias
432
+ named — the binding is verified complete at install/adopt, so a miss means
433
+ the generated file is stale relative to the installed contract version
434
+ (re-run the codegen, republish).
435
+ - **Bespoke apps 404.** A never-adopted app has no binding; bespoke projects
436
+ keep the baked `lotics app codegen` variant instead. Shared code should not
437
+ call this directly — import from `.lotics/app_fields` and let the project
438
+ kind pick the implementation.
439
+
403
440
  ## For package contributors
404
441
 
405
442
  Everything below concerns changing `@lotics/app-sdk` itself (in the Lotics
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {