@lotics/app-sdk 0.12.0 → 0.15.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/dist/src/analytics.d.ts +1 -0
- package/dist/src/analytics.js +75 -0
- package/dist/src/index.d.ts +13 -5
- package/dist/src/index.js +12 -5
- package/dist/src/mock.d.ts +8 -0
- package/dist/src/mock.js +14 -7
- package/dist/src/mount.d.ts +2 -2
- package/dist/src/mount.js +6 -0
- package/dist/src/row.d.ts +34 -0
- package/dist/src/row.js +56 -0
- package/dist/src/rpc.d.ts +23 -1
- package/dist/src/rpc.js +42 -9
- package/dist/src/use_optimistic.d.ts +22 -0
- package/dist/src/use_optimistic.js +27 -0
- package/package.json +9 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function bootstrapAnalytics(): Promise<void>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog analytics for custom-code apps.
|
|
3
|
+
*
|
|
4
|
+
* Apps run as a separate cross-origin bundle at `<slug>.lotics.app`, so the
|
|
5
|
+
* product's PostHog instance (on `app.lotics.ai`) can't see them — every app
|
|
6
|
+
* was previously dark. `bootstrapAnalytics` runs once from `mount()` and gives
|
|
7
|
+
* every app tracking for free, with no per-app wiring: PostHog **autocapture**
|
|
8
|
+
* (clicks, pageview, pageleave) records the user actions, plus exception
|
|
9
|
+
* capture for error visibility. Every event is tagged with app identity so it's
|
|
10
|
+
* attributable per app/workspace/org and rolls up under the existing
|
|
11
|
+
* `organization` group; embedded apps `identify` the authenticated member the
|
|
12
|
+
* host passes down, so app + product events share one person. See `rpc.ts`
|
|
13
|
+
* `AppContext` for how identity is resolved per transport.
|
|
14
|
+
*
|
|
15
|
+
* Autocapture is the only event source. RPC/data lifecycle (query / workflow /
|
|
16
|
+
* upload) is a system signal, not a user action — and the interaction that
|
|
17
|
+
* triggers each is already autocaptured — so the SDK emits no custom events.
|
|
18
|
+
*
|
|
19
|
+
* Best-effort: any failure (offline, an un-updated host that doesn't know the
|
|
20
|
+
* `context` op, tracking disabled for the environment) leaves the app fully
|
|
21
|
+
* functional and silently untracked.
|
|
22
|
+
*/
|
|
23
|
+
import posthog from "posthog-js";
|
|
24
|
+
import { rpc } from "./rpc.js";
|
|
25
|
+
import { hasMockFlag } from "./mock.js";
|
|
26
|
+
export async function bootstrapAnalytics() {
|
|
27
|
+
// A design-time / screenshot load (?__mock=1) is never real usage.
|
|
28
|
+
if (hasMockFlag())
|
|
29
|
+
return;
|
|
30
|
+
let ctx;
|
|
31
|
+
try {
|
|
32
|
+
ctx = await rpc("context", {});
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Best-effort: a context-resolution failure (offline, an un-updated host
|
|
36
|
+
// that doesn't know the op) must never break the app.
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// No key → tracking disabled for this environment (dev/preview/test, or a
|
|
40
|
+
// host running in a non-production build). Mirrors the frontend's prod gate.
|
|
41
|
+
if (!ctx.posthog_key)
|
|
42
|
+
return;
|
|
43
|
+
posthog.init(ctx.posthog_key, {
|
|
44
|
+
api_host: ctx.posthog_host,
|
|
45
|
+
autocapture: true,
|
|
46
|
+
capture_pageview: true,
|
|
47
|
+
capture_pageleave: true,
|
|
48
|
+
// Error tracking for the app: this is the app bundle's only exception
|
|
49
|
+
// channel (mount() renders a local banner but never posts), and we run
|
|
50
|
+
// customer apps — their crashes are ours to see.
|
|
51
|
+
capture_exceptions: true,
|
|
52
|
+
// Session replay is on by default at the project level (docs/security.md
|
|
53
|
+
// §11.4), but apps were never scoped for it and it would silently record
|
|
54
|
+
// anonymous public-app visitors. Keep it off on this surface.
|
|
55
|
+
disable_session_recording: true,
|
|
56
|
+
});
|
|
57
|
+
// Tag every event (autocapture included) so app traffic is attributable and
|
|
58
|
+
// filterable — any event carrying `app_id` is an app event. `app_name` spares
|
|
59
|
+
// an id lookup when debugging; the org's name lives on the `organization`
|
|
60
|
+
// group, not denormalized here.
|
|
61
|
+
posthog.register({
|
|
62
|
+
app_id: ctx.app_id,
|
|
63
|
+
app_name: ctx.app_name,
|
|
64
|
+
workspace_id: ctx.workspace_id,
|
|
65
|
+
organization_id: ctx.organization_id,
|
|
66
|
+
});
|
|
67
|
+
// Guarded: a bridged host that answered `context` before its auth member
|
|
68
|
+
// loaded sends an empty org — never group on an empty key.
|
|
69
|
+
if (ctx.organization_id)
|
|
70
|
+
posthog.group("organization", ctx.organization_id);
|
|
71
|
+
// Embedded apps attach to the same person as the product; standalone
|
|
72
|
+
// visitors stay anonymous (member_id null).
|
|
73
|
+
if (ctx.member_id)
|
|
74
|
+
posthog.identify(ctx.member_id);
|
|
75
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
* app at build time. Apps `import { mount, useQuery, useWorkflow } from
|
|
4
4
|
* "@lotics/app-sdk"` and ship the resulting bundle via `lotics app deploy`.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* This SDK is data + RPC only — it deliberately does NOT re-export any
|
|
7
|
+
* `@lotics/ui` components, so it ships without pulling in packages/ui's
|
|
8
|
+
* React Native Web dependency tree. That is a packaging choice, NOT a
|
|
9
|
+
* limitation on apps: apps import `@lotics/ui` directly as a normal dep.
|
|
10
|
+
* The starter scaffold (`packages/sdk/src/starter_template.ts`) wires the
|
|
11
|
+
* full setup — `@lotics/ui` + react-native + react-native-web, the
|
|
12
|
+
* react-native→react-native-web Vite alias, `@lotics/ui/index.css` +
|
|
13
|
+
* `fonts.css`, and a `PortalHost` for overlays. Build dashboards by
|
|
14
|
+
* composing `@lotics/ui` components (Card, KpiCard, charts, DataGrid, …),
|
|
15
|
+
* not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
|
|
11
16
|
*/
|
|
12
17
|
export { mount } from "./mount.js";
|
|
13
18
|
export type { MountOptions } from "./mount.js";
|
|
@@ -19,3 +24,6 @@ export { readMembers } from "./members.js";
|
|
|
19
24
|
export type { ResolvedMember } from "./members.js";
|
|
20
25
|
export type { AppFixture } from "./mock.js";
|
|
21
26
|
export type { AppWorkflows, AppQueries } from "./types.js";
|
|
27
|
+
export { row } from "./row.js";
|
|
28
|
+
export { useOptimistic } from "./use_optimistic.js";
|
|
29
|
+
export type { OptimisticApi } from "./use_optimistic.js";
|
package/dist/src/index.js
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
* app at build time. Apps `import { mount, useQuery, useWorkflow } from
|
|
4
4
|
* "@lotics/app-sdk"` and ship the resulting bundle via `lotics app deploy`.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* This SDK is data + RPC only — it deliberately does NOT re-export any
|
|
7
|
+
* `@lotics/ui` components, so it ships without pulling in packages/ui's
|
|
8
|
+
* React Native Web dependency tree. That is a packaging choice, NOT a
|
|
9
|
+
* limitation on apps: apps import `@lotics/ui` directly as a normal dep.
|
|
10
|
+
* The starter scaffold (`packages/sdk/src/starter_template.ts`) wires the
|
|
11
|
+
* full setup — `@lotics/ui` + react-native + react-native-web, the
|
|
12
|
+
* react-native→react-native-web Vite alias, `@lotics/ui/index.css` +
|
|
13
|
+
* `fonts.css`, and a `PortalHost` for overlays. Build dashboards by
|
|
14
|
+
* composing `@lotics/ui` components (Card, KpiCard, charts, DataGrid, …),
|
|
15
|
+
* not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
|
|
11
16
|
*/
|
|
12
17
|
export { mount } from "./mount.js";
|
|
13
18
|
export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
|
|
14
19
|
export { rpc } from "./rpc.js";
|
|
15
20
|
export { readMembers } from "./members.js";
|
|
21
|
+
export { row } from "./row.js";
|
|
22
|
+
export { useOptimistic } from "./use_optimistic.js";
|
package/dist/src/mock.d.ts
CHANGED
|
@@ -36,6 +36,14 @@ export interface AppFixture {
|
|
|
36
36
|
* for HMR.
|
|
37
37
|
*/
|
|
38
38
|
export declare function registerMockFixture(fixture: AppFixture | undefined): void;
|
|
39
|
+
/**
|
|
40
|
+
* True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
|
|
41
|
+
* a malformed URL or missing `window` (SSR / jsdom without location) silently
|
|
42
|
+
* returns false. Distinct from `isMockMode`: analytics keys off the raw flag
|
|
43
|
+
* (a design-time / screenshot load emits no events regardless of fixtures),
|
|
44
|
+
* while query mocking additionally requires a registered fixture.
|
|
45
|
+
*/
|
|
46
|
+
export declare function hasMockFlag(): boolean;
|
|
39
47
|
/**
|
|
40
48
|
* Returns the fixture rows for an alias when mock mode is active *and* the
|
|
41
49
|
* fixture has an entry for that alias. Otherwise null — the hook falls
|
package/dist/src/mock.js
CHANGED
|
@@ -36,14 +36,13 @@ export function registerMockFixture(fixture) {
|
|
|
36
36
|
registeredFixture = fixture;
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
|
-
* True iff the iframe URL carries the `__mock=1` activation flag
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
* True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
|
|
40
|
+
* a malformed URL or missing `window` (SSR / jsdom without location) silently
|
|
41
|
+
* returns false. Distinct from `isMockMode`: analytics keys off the raw flag
|
|
42
|
+
* (a design-time / screenshot load emits no events regardless of fixtures),
|
|
43
|
+
* while query mocking additionally requires a registered fixture.
|
|
43
44
|
*/
|
|
44
|
-
function
|
|
45
|
-
if (!registeredFixture)
|
|
46
|
-
return false;
|
|
45
|
+
export function hasMockFlag() {
|
|
47
46
|
try {
|
|
48
47
|
return new URLSearchParams(window.location.search).get("__mock") === "1";
|
|
49
48
|
}
|
|
@@ -51,6 +50,14 @@ function isMockMode() {
|
|
|
51
50
|
return false;
|
|
52
51
|
}
|
|
53
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* True iff the iframe URL carries the `__mock=1` activation flag AND a
|
|
55
|
+
* fixture was registered. Safe to call before mount — returns false when no
|
|
56
|
+
* fixture exists.
|
|
57
|
+
*/
|
|
58
|
+
function isMockMode() {
|
|
59
|
+
return Boolean(registeredFixture) && hasMockFlag();
|
|
60
|
+
}
|
|
54
61
|
/**
|
|
55
62
|
* Returns the fixture rows for an alias when mock mode is active *and* the
|
|
56
63
|
* fixture has an entry for that alias. Otherwise null — the hook falls
|
package/dist/src/mount.d.ts
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
*
|
|
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
|
-
* surface in the parent's debug pane, registers the fixture (if any),
|
|
30
|
-
*
|
|
29
|
+
* surface in the parent's debug pane, registers the fixture (if any), renders
|
|
30
|
+
* the user's tree, and bootstraps PostHog analytics (see `./analytics.ts`).
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
2
|
import { registerMockFixture } from "./mock.js";
|
|
3
|
+
import { bootstrapAnalytics } from "./analytics.js";
|
|
3
4
|
export function mount(element, options = {}) {
|
|
4
5
|
registerMockFixture(options.fixture);
|
|
5
6
|
let container = document.getElementById("root");
|
|
@@ -13,6 +14,11 @@ export function mount(element, options = {}) {
|
|
|
13
14
|
// *something* even before the parent's debug telemetry is wired up.
|
|
14
15
|
installVisibleErrorHandlers(container);
|
|
15
16
|
createRoot(container).render(element);
|
|
17
|
+
// Fire-and-forget: resolve the app's identity + analytics config and start
|
|
18
|
+
// PostHog autocapture. Never awaited — analytics must not delay first paint,
|
|
19
|
+
// and is best-effort so its failure can't break the app. No-ops in mock mode
|
|
20
|
+
// and when tracking is disabled for the environment.
|
|
21
|
+
void bootstrapAnalytics();
|
|
16
22
|
}
|
|
17
23
|
function installVisibleErrorHandlers(container) {
|
|
18
24
|
const showError = (message) => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessors that coerce raw `useQuery` row values into typed values. Each cell
|
|
3
|
+
* of a query row is `unknown`: the query layer serializes a select column as its
|
|
4
|
+
* `opt_` id (and, for some shapes, a single-element array or a `{ id }` object),
|
|
5
|
+
* a date/datetime as a `YYYY-MM-DD[THH:mm…]` string, a number/boolean as itself.
|
|
6
|
+
*
|
|
7
|
+
* These live in app-sdk — next to `useQuery`, whose serialization contract they
|
|
8
|
+
* decode — so a change to that contract updates them in one place, and so apps
|
|
9
|
+
* stop re-implementing the same coercions. They are pure (`unknown` in, value
|
|
10
|
+
* out) and never throw.
|
|
11
|
+
*/
|
|
12
|
+
/** Select → option id. Handles a bare id, a single-element array, or `{ id }`. */
|
|
13
|
+
declare function opt(v: unknown): string | null;
|
|
14
|
+
/** Text/markdown/autonumber → string (numbers stringified; everything else ""). */
|
|
15
|
+
declare function text(v: unknown): string;
|
|
16
|
+
/** Number → number (NaN/Infinity and unparseable strings → 0). */
|
|
17
|
+
declare function num(v: unknown): number;
|
|
18
|
+
/** Checkbox/boolean → boolean (accepts the string "true"). */
|
|
19
|
+
declare function bool(v: unknown): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
|
|
22
|
+
* calendar/gantt placement never shifts across timezones. Parses the leading
|
|
23
|
+
* `YYYY-MM-DD` of the serialized string; null if absent or unparseable. (Range
|
|
24
|
+
* fields are not handled here — they have no consumer yet.)
|
|
25
|
+
*/
|
|
26
|
+
declare function date(v: unknown): Date | null;
|
|
27
|
+
export declare const row: {
|
|
28
|
+
opt: typeof opt;
|
|
29
|
+
text: typeof text;
|
|
30
|
+
num: typeof num;
|
|
31
|
+
bool: typeof bool;
|
|
32
|
+
date: typeof date;
|
|
33
|
+
};
|
|
34
|
+
export {};
|
package/dist/src/row.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessors that coerce raw `useQuery` row values into typed values. Each cell
|
|
3
|
+
* of a query row is `unknown`: the query layer serializes a select column as its
|
|
4
|
+
* `opt_` id (and, for some shapes, a single-element array or a `{ id }` object),
|
|
5
|
+
* a date/datetime as a `YYYY-MM-DD[THH:mm…]` string, a number/boolean as itself.
|
|
6
|
+
*
|
|
7
|
+
* These live in app-sdk — next to `useQuery`, whose serialization contract they
|
|
8
|
+
* decode — so a change to that contract updates them in one place, and so apps
|
|
9
|
+
* stop re-implementing the same coercions. They are pure (`unknown` in, value
|
|
10
|
+
* out) and never throw.
|
|
11
|
+
*/
|
|
12
|
+
/** Select → option id. Handles a bare id, a single-element array, or `{ id }`. */
|
|
13
|
+
function opt(v) {
|
|
14
|
+
if (typeof v === "string")
|
|
15
|
+
return v || null;
|
|
16
|
+
if (Array.isArray(v))
|
|
17
|
+
return v.length ? opt(v[0]) : null;
|
|
18
|
+
if (v && typeof v === "object") {
|
|
19
|
+
const id = v.id;
|
|
20
|
+
return typeof id === "string" ? id : null;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** Text/markdown/autonumber → string (numbers stringified; everything else ""). */
|
|
25
|
+
function text(v) {
|
|
26
|
+
if (typeof v === "string")
|
|
27
|
+
return v;
|
|
28
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
29
|
+
return String(v);
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
/** Number → number (NaN/Infinity and unparseable strings → 0). */
|
|
33
|
+
function num(v) {
|
|
34
|
+
if (typeof v === "number")
|
|
35
|
+
return Number.isFinite(v) ? v : 0;
|
|
36
|
+
if (typeof v === "string" && v.trim()) {
|
|
37
|
+
const n = Number(v);
|
|
38
|
+
return Number.isFinite(n) ? n : 0;
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
/** Checkbox/boolean → boolean (accepts the string "true"). */
|
|
43
|
+
function bool(v) {
|
|
44
|
+
return v === true || v === "true";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
|
|
48
|
+
* calendar/gantt placement never shifts across timezones. Parses the leading
|
|
49
|
+
* `YYYY-MM-DD` of the serialized string; null if absent or unparseable. (Range
|
|
50
|
+
* fields are not handled here — they have no consumer yet.)
|
|
51
|
+
*/
|
|
52
|
+
function date(v) {
|
|
53
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(text(v));
|
|
54
|
+
return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : null;
|
|
55
|
+
}
|
|
56
|
+
export const row = { opt, text, num, bool, date };
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -18,5 +18,27 @@
|
|
|
18
18
|
* app → host: { id, op, payload }
|
|
19
19
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
20
20
|
*/
|
|
21
|
-
export type RpcOp = "query" | "workflow" | "upload";
|
|
21
|
+
export type RpcOp = "query" | "workflow" | "upload" | "context";
|
|
22
|
+
/**
|
|
23
|
+
* The app's identity + analytics config, resolved once at startup to bootstrap
|
|
24
|
+
* PostHog. Assembled by whichever transport is active:
|
|
25
|
+
*
|
|
26
|
+
* - **Bridged** — the host (authenticated) supplies `member_id` so app events
|
|
27
|
+
* attach to the same PostHog person as the product, plus org/workspace/app
|
|
28
|
+
* from its own context and the PostHog key from its env.
|
|
29
|
+
* - **Standalone** — the public `/by-subdomain` endpoint returns identity +
|
|
30
|
+
* config; `member_id` is null (anonymous visitor).
|
|
31
|
+
*
|
|
32
|
+
* `posthog_key` is null when tracking is disabled for the environment
|
|
33
|
+
* (dev/preview/test, or a non-production host build) — the SDK then no-ops.
|
|
34
|
+
*/
|
|
35
|
+
export interface AppContext {
|
|
36
|
+
app_id: string;
|
|
37
|
+
app_name: string;
|
|
38
|
+
workspace_id: string;
|
|
39
|
+
organization_id: string;
|
|
40
|
+
member_id: string | null;
|
|
41
|
+
posthog_key: string | null;
|
|
42
|
+
posthog_host: string;
|
|
43
|
+
}
|
|
22
44
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
package/dist/src/rpc.js
CHANGED
|
@@ -3,11 +3,10 @@ import { runUploadPipeline } from "./upload/pipeline.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* The embedding Lotics host's origin — present iff the app is bridged.
|
|
5
5
|
*
|
|
6
|
-
* Lazy + memoized so the module's top level doesn't touch `window`.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* imports the SDK.
|
|
6
|
+
* Lazy + memoized so the module's top level doesn't touch `window`. A test
|
|
7
|
+
* runner can pull this module in at evaluation time before its jsdom
|
|
8
|
+
* environment finishes setting up `window.location` — an eager read would
|
|
9
|
+
* crash every suite that transitively imports the SDK.
|
|
11
10
|
*
|
|
12
11
|
* `undefined` = not yet computed; `string | null` = computed result.
|
|
13
12
|
*/
|
|
@@ -69,6 +68,7 @@ const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
|
|
|
69
68
|
* `/by-subdomain` fetch and at most one password prompt.
|
|
70
69
|
*/
|
|
71
70
|
let bootPromise = null;
|
|
71
|
+
let appInfoPromise = null;
|
|
72
72
|
let sessionToken = null;
|
|
73
73
|
function sessionStorageKey(appId) {
|
|
74
74
|
return `lotics_app_session:${appId}`;
|
|
@@ -109,12 +109,32 @@ function clearStoredSession(appId) {
|
|
|
109
109
|
// ignore
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the app's identity + analytics config from its own subdomain. Shared
|
|
114
|
+
* promise so the context bootstrap and the first data call coalesce into one
|
|
115
|
+
* `/by-subdomain` fetch. Does NOT touch the password gate — identity is needed
|
|
116
|
+
* for analytics regardless of whether the visitor has unlocked the data, so a
|
|
117
|
+
* password-gated app still resolves (and tracks) before the prompt.
|
|
118
|
+
*/
|
|
119
|
+
function resolveAppInfo() {
|
|
120
|
+
if (appInfoPromise)
|
|
121
|
+
return appInfoPromise;
|
|
122
|
+
const slug = window.location.hostname.split(".")[0];
|
|
123
|
+
const attempt = apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`);
|
|
124
|
+
// Don't cache a rejection — a transient failure on first load would brick
|
|
125
|
+
// every later data call. Clear the slot so the next call retries.
|
|
126
|
+
attempt.catch(() => {
|
|
127
|
+
if (appInfoPromise === attempt)
|
|
128
|
+
appInfoPromise = null;
|
|
129
|
+
});
|
|
130
|
+
appInfoPromise = attempt;
|
|
131
|
+
return attempt;
|
|
132
|
+
}
|
|
112
133
|
async function boot() {
|
|
113
134
|
if (bootPromise)
|
|
114
135
|
return bootPromise;
|
|
115
|
-
const slug = window.location.hostname.split(".")[0];
|
|
116
136
|
const attempt = (async () => {
|
|
117
|
-
const info =
|
|
137
|
+
const info = await resolveAppInfo();
|
|
118
138
|
if (info.requires_password) {
|
|
119
139
|
const stored = readStoredSession(info.app_id);
|
|
120
140
|
if (stored) {
|
|
@@ -126,8 +146,6 @@ async function boot() {
|
|
|
126
146
|
}
|
|
127
147
|
return info;
|
|
128
148
|
})();
|
|
129
|
-
// Don't cache a rejection — a transient failure on first load would brick
|
|
130
|
-
// every later data call. Clear the slot so the next call retries.
|
|
131
149
|
attempt.catch(() => {
|
|
132
150
|
if (bootPromise === attempt)
|
|
133
151
|
bootPromise = null;
|
|
@@ -199,8 +217,23 @@ function rpcStandalone(op, payload) {
|
|
|
199
217
|
return standaloneWorkflow(payload);
|
|
200
218
|
case "upload":
|
|
201
219
|
return standaloneUpload(payload.file);
|
|
220
|
+
case "context":
|
|
221
|
+
return standaloneContext();
|
|
202
222
|
}
|
|
203
223
|
}
|
|
224
|
+
async function standaloneContext() {
|
|
225
|
+
const info = await resolveAppInfo();
|
|
226
|
+
return {
|
|
227
|
+
app_id: info.app_id,
|
|
228
|
+
app_name: info.app_name,
|
|
229
|
+
workspace_id: info.workspace_id,
|
|
230
|
+
organization_id: info.organization_id,
|
|
231
|
+
// No host session in standalone mode — the visitor is anonymous.
|
|
232
|
+
member_id: null,
|
|
233
|
+
posthog_key: info.posthog_key,
|
|
234
|
+
posthog_host: info.posthog_host,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
204
237
|
async function standaloneQuery(p) {
|
|
205
238
|
const { app_id } = await boot();
|
|
206
239
|
const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params }, { appId: app_id }));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface OptimisticApi<T> {
|
|
2
|
+
/** `base` with any pending optimistic patches applied. */
|
|
3
|
+
items: T[];
|
|
4
|
+
/**
|
|
5
|
+
* Optimistically merge `next` into the item keyed `id`, then run `persist`.
|
|
6
|
+
* On resolve → `onSettled?.()` (pass your query's `refetch`); the patch is
|
|
7
|
+
* kept (it already matches the refetched value, so no flicker). On reject →
|
|
8
|
+
* the patch is reverted.
|
|
9
|
+
*/
|
|
10
|
+
patch: (id: string, next: Partial<T>, persist: () => Promise<unknown>, opts?: {
|
|
11
|
+
onSettled?: () => void;
|
|
12
|
+
}) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Optimistic list overrides for a `useQuery` result feeding an interactive view
|
|
16
|
+
* (calendar drag, gantt resize, kanban move). Pure React state: it takes the
|
|
17
|
+
* already-mapped items + a key function + a caller-supplied `persist` thunk, so
|
|
18
|
+
* it has no coupling to any specific mutation transport (the app threads its own
|
|
19
|
+
* `useWorkflow` call + `refetch`). It lives in app-sdk as the *reconcile* leg of
|
|
20
|
+
* the read (`useQuery`) → mutate (`useWorkflow`) → reconcile loop.
|
|
21
|
+
*/
|
|
22
|
+
export declare function useOptimistic<T>(base: T[], keyOf: (item: T) => string): OptimisticApi<T>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Optimistic list overrides for a `useQuery` result feeding an interactive view
|
|
4
|
+
* (calendar drag, gantt resize, kanban move). Pure React state: it takes the
|
|
5
|
+
* already-mapped items + a key function + a caller-supplied `persist` thunk, so
|
|
6
|
+
* it has no coupling to any specific mutation transport (the app threads its own
|
|
7
|
+
* `useWorkflow` call + `refetch`). It lives in app-sdk as the *reconcile* leg of
|
|
8
|
+
* the read (`useQuery`) → mutate (`useWorkflow`) → reconcile loop.
|
|
9
|
+
*/
|
|
10
|
+
export function useOptimistic(base, keyOf) {
|
|
11
|
+
const [overrides, setOverrides] = useState({});
|
|
12
|
+
const items = useMemo(() => base.map((item) => {
|
|
13
|
+
const o = overrides[keyOf(item)];
|
|
14
|
+
return o ? { ...item, ...o } : item;
|
|
15
|
+
}), [base, overrides, keyOf]);
|
|
16
|
+
const patch = useCallback((id, next, persist, opts) => {
|
|
17
|
+
setOverrides((m) => ({ ...m, [id]: { ...m[id], ...next } }));
|
|
18
|
+
persist().then(() => opts?.onSettled?.(), () => setOverrides((m) => {
|
|
19
|
+
if (!(id in m))
|
|
20
|
+
return m;
|
|
21
|
+
const cleared = { ...m };
|
|
22
|
+
delete cleared[id];
|
|
23
|
+
return cleared;
|
|
24
|
+
}));
|
|
25
|
+
}, []);
|
|
26
|
+
return { items, patch };
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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": {
|
|
@@ -14,17 +14,21 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsgo",
|
|
16
16
|
"typecheck": "tsgo --noEmit",
|
|
17
|
+
"test": "vitest run",
|
|
17
18
|
"prepublishOnly": "npm run build"
|
|
18
19
|
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"posthog-js": "^1.352.0"
|
|
22
|
+
},
|
|
19
23
|
"peerDependencies": {
|
|
20
|
-
"react": "^19.
|
|
21
|
-
"react-dom": "^19.
|
|
24
|
+
"react": "^19.2.0",
|
|
25
|
+
"react-dom": "^19.2.0"
|
|
22
26
|
},
|
|
23
27
|
"devDependencies": {
|
|
24
28
|
"@types/react": "^19.0.0",
|
|
25
29
|
"@types/react-dom": "^19.0.0",
|
|
26
|
-
"react": "^19.
|
|
27
|
-
"react-dom": "^19.
|
|
30
|
+
"react": "^19.2.0",
|
|
31
|
+
"react-dom": "^19.2.0"
|
|
28
32
|
},
|
|
29
33
|
"keywords": [
|
|
30
34
|
"lotics",
|