@lotics/app-sdk 0.1.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.
@@ -0,0 +1,39 @@
1
+ import type { QueryAst, TableRow, WorkspaceTables } from "./types.js";
2
+ interface QueryState<R> {
3
+ rows: R[];
4
+ loading: boolean;
5
+ error: string | null;
6
+ }
7
+ /**
8
+ * Read all rows from a table. Re-fetches when the table id changes.
9
+ * For derived/joined data, see `useQuery(ast)`.
10
+ */
11
+ export declare function useTable<K extends keyof WorkspaceTables & string>(table_id: K): QueryState<TableRow<K>>;
12
+ export declare function useTable(table_id: string): QueryState<TableRow<string>>;
13
+ /**
14
+ * Mutation surface for a table. Returns imperative functions; doesn't manage
15
+ * cache invalidation in v1 — components that read via useTable will pick up
16
+ * changes on next remount or via realtime channels in v2.
17
+ */
18
+ export declare function useMutate<K extends keyof WorkspaceTables & string>(table_id: K): {
19
+ update: (records: Array<{
20
+ id: string;
21
+ data: Partial<RowFor<K>>;
22
+ }>) => Promise<unknown>;
23
+ };
24
+ export declare function useMutate(table_id: string): {
25
+ update: (records: Array<{
26
+ id: string;
27
+ data: Record<string, unknown>;
28
+ }>) => Promise<unknown>;
29
+ };
30
+ type RowFor<K extends keyof WorkspaceTables & string> = WorkspaceTables[K];
31
+ /** Trigger a workflow by action_id. Returns a callable that runs it. */
32
+ export declare function useAction(action_id: string): (inputs?: Record<string, unknown>) => Promise<unknown>;
33
+ /**
34
+ * Escape hatch — pass a raw query AST. Same shape the server validates via
35
+ * `parseQueryNode`. Use for joins, group-by, projections, and other shapes
36
+ * `useTable`/`useRecord` don't express.
37
+ */
38
+ export declare function useQuery(ast: QueryAst): QueryState<Record<string, unknown>>;
39
+ export {};
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Typed React hooks for Lotics app data access.
3
+ *
4
+ * Every hook is a thin wrapper over the postMessage RPC bridge — the parent
5
+ * does the actual API calls with the user's session, results stream back
6
+ * through `rpc()`. Hooks manage their own local cache via `useState`; we
7
+ * intentionally don't ship a global store in v1.
8
+ *
9
+ * For users with the workspace-types augmentation in tsconfig, table IDs
10
+ * passed to these hooks are typed against the actual schemas. Without it,
11
+ * IDs are plain strings and rows are `Record<string, unknown>` — code still
12
+ * runs, just untyped.
13
+ */
14
+ import { useCallback, useEffect, useState } from "react";
15
+ import { rpc } from "./rpc.js";
16
+ export function useTable(table_id) {
17
+ const [state, setState] = useState({
18
+ rows: [],
19
+ loading: true,
20
+ error: null,
21
+ });
22
+ useEffect(() => {
23
+ let cancelled = false;
24
+ setState((s) => ({ rows: s.rows, loading: true, error: null }));
25
+ // Server's typed-query path accepts `{ kind: "from_table", table_id }`
26
+ // as the simplest AST; the resolver returns rows shaped per the table's
27
+ // schema with source-addressing columns appended.
28
+ rpc("query", {
29
+ ast: { kind: "from_table", table_id },
30
+ })
31
+ .then((result) => {
32
+ if (cancelled)
33
+ return;
34
+ setState({ rows: result.rows ?? [], loading: false, error: null });
35
+ })
36
+ .catch((err) => {
37
+ if (cancelled)
38
+ return;
39
+ setState({ rows: [], loading: false, error: err.message });
40
+ });
41
+ return () => {
42
+ cancelled = true;
43
+ };
44
+ }, [table_id]);
45
+ return state;
46
+ }
47
+ export function useMutate(table_id) {
48
+ return {
49
+ // The parent's RPC bridge currently dispatches all `mutate` ops to update.
50
+ // We omit the `op` field here rather than ship dead data — when create /
51
+ // delete mutations land, we'll add `op` and a parent-side switch together.
52
+ update: (records) => rpc("mutate", { table_id, records }),
53
+ };
54
+ }
55
+ /** Trigger a workflow by action_id. Returns a callable that runs it. */
56
+ export function useAction(action_id) {
57
+ return useCallback((inputs) => rpc("action", { action_id, inputs: inputs ?? {} }), [action_id]);
58
+ }
59
+ /**
60
+ * Escape hatch — pass a raw query AST. Same shape the server validates via
61
+ * `parseQueryNode`. Use for joins, group-by, projections, and other shapes
62
+ * `useTable`/`useRecord` don't express.
63
+ */
64
+ export function useQuery(ast) {
65
+ const astKey = JSON.stringify(ast);
66
+ const [state, setState] = useState({
67
+ rows: [],
68
+ loading: true,
69
+ error: null,
70
+ });
71
+ useEffect(() => {
72
+ let cancelled = false;
73
+ setState((s) => ({ rows: s.rows, loading: true, error: null }));
74
+ rpc("query", { ast })
75
+ .then((result) => {
76
+ if (cancelled)
77
+ return;
78
+ setState({ rows: result.rows ?? [], loading: false, error: null });
79
+ })
80
+ .catch((err) => {
81
+ if (cancelled)
82
+ return;
83
+ setState({ rows: [], loading: false, error: err.message });
84
+ });
85
+ return () => {
86
+ cancelled = true;
87
+ };
88
+ // Dep is the stringified AST so structurally-equal-but-referentially-new
89
+ // objects don't re-fetch on every render.
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [astKey]);
92
+ return state;
93
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Lotics App SDK — the runtime + typed hooks bundled into every custom-code
3
+ * app at build time. Apps `import { mount, useTable, ... } from "@lotics/app-sdk"`
4
+ * and ship the resulting bundle via `lotics app deploy`.
5
+ *
6
+ * Curated UI primitive re-exports from `@lotics/ui` are deliberately NOT
7
+ * included here. Users import them separately from a published `@lotics/ui`
8
+ * package once that's ready, or compose with vanilla HTML/CSS in the
9
+ * meantime. Splitting the package boundaries lets the SDK ship without
10
+ * depending on packages/ui's React Native Web setup.
11
+ */
12
+ export { mount } from "./mount.js";
13
+ export { useTable, useMutate, useAction, useQuery } from "./hooks.js";
14
+ export { rpc } from "./rpc.js";
15
+ export type { RpcOp } from "./rpc.js";
16
+ export type { WorkspaceTables, RowOf, TableRow, SourceAddressing, QueryAst } from "./types.js";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Lotics App SDK — the runtime + typed hooks bundled into every custom-code
3
+ * app at build time. Apps `import { mount, useTable, ... } from "@lotics/app-sdk"`
4
+ * and ship the resulting bundle via `lotics app deploy`.
5
+ *
6
+ * Curated UI primitive re-exports from `@lotics/ui` are deliberately NOT
7
+ * included here. Users import them separately from a published `@lotics/ui`
8
+ * package once that's ready, or compose with vanilla HTML/CSS in the
9
+ * meantime. Splitting the package boundaries lets the SDK ship without
10
+ * depending on packages/ui's React Native Web setup.
11
+ */
12
+ export { mount } from "./mount.js";
13
+ export { useTable, useMutate, useAction, useQuery } from "./hooks.js";
14
+ export { rpc } from "./rpc.js";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Entry point for Lotics custom-code apps.
3
+ *
4
+ * Usage in a user's `src/main.tsx`:
5
+ *
6
+ * ```tsx
7
+ * import { mount } from "@lotics/app-sdk";
8
+ * import App from "./App";
9
+ *
10
+ * mount(<App />);
11
+ * ```
12
+ *
13
+ * `mount` wires up React 19's createRoot against `#root` in the iframe shell,
14
+ * sets up window.onerror/unhandledrejection forwarding so runtime crashes
15
+ * surface in the parent's debug pane (PR 2 of the debug-loop phase), and
16
+ * renders the user's tree.
17
+ *
18
+ * If the bundler doesn't ship #root in the user's `index.html`, we create it
19
+ * — Vite's default scaffold provides one, but defensive creation keeps the
20
+ * mount resilient.
21
+ */
22
+ import type { ReactNode } from "react";
23
+ export declare function mount(element: ReactNode): void;
@@ -0,0 +1,32 @@
1
+ import { createRoot } from "react-dom/client";
2
+ export function mount(element) {
3
+ let container = document.getElementById("root");
4
+ if (!container) {
5
+ container = document.createElement("div");
6
+ container.id = "root";
7
+ document.body.appendChild(container);
8
+ }
9
+ // Surface the most common silent-failure modes (a thrown render error or an
10
+ // unhandled rejection) as visible text in the iframe so the developer sees
11
+ // *something* even before the parent's debug telemetry is wired up.
12
+ installVisibleErrorHandlers(container);
13
+ createRoot(container).render(element);
14
+ }
15
+ function installVisibleErrorHandlers(container) {
16
+ const showError = (message) => {
17
+ const banner = document.createElement("pre");
18
+ banner.style.cssText =
19
+ "position: fixed; top: 0; left: 0; right: 0; padding: 12px 16px; " +
20
+ "background: #fef2f2; color: #991b1b; font: 13px/1.4 monospace; " +
21
+ "white-space: pre-wrap; border-bottom: 1px solid #fecaca; margin: 0; z-index: 99999;";
22
+ banner.textContent = message;
23
+ container.parentElement?.insertBefore(banner, container);
24
+ };
25
+ window.addEventListener("error", (e) => {
26
+ showError(`Uncaught error: ${e.message}\n${e.error?.stack ?? ""}`);
27
+ });
28
+ window.addEventListener("unhandledrejection", (e) => {
29
+ const reason = e.reason instanceof Error ? `${e.reason.message}\n${e.reason.stack ?? ""}` : String(e.reason);
30
+ showError(`Unhandled rejection: ${reason}`);
31
+ });
32
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * postMessage RPC bridge to the parent Lotics frontend.
3
+ *
4
+ * The iframe runs `sandbox="allow-scripts"` with a unique (null) origin, so
5
+ * direct fetch with credentials isn't possible. Every data operation flows
6
+ * through the parent: the iframe posts an op + payload, the parent makes
7
+ * the authenticated API call and posts the result back.
8
+ *
9
+ * Wire protocol (must match `frontend/features/app_ui/app_iframe_host.tsx`):
10
+ * iframe → parent: { id: number, op: string, payload: unknown }
11
+ * parent → iframe: { id: number, type: "result", data } | { id, type: "error", message }
12
+ *
13
+ * Bumping protocol version requires a coordinated change in the parent —
14
+ * old bundles must keep working forever, since we can't force users to
15
+ * redeploy. Add new ops alongside existing ones; never repurpose them.
16
+ */
17
+ export type RpcOp = "query" | "mutate" | "action" | "filter";
18
+ export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * postMessage RPC bridge to the parent Lotics frontend.
3
+ *
4
+ * The iframe runs `sandbox="allow-scripts"` with a unique (null) origin, so
5
+ * direct fetch with credentials isn't possible. Every data operation flows
6
+ * through the parent: the iframe posts an op + payload, the parent makes
7
+ * the authenticated API call and posts the result back.
8
+ *
9
+ * Wire protocol (must match `frontend/features/app_ui/app_iframe_host.tsx`):
10
+ * iframe → parent: { id: number, op: string, payload: unknown }
11
+ * parent → iframe: { id: number, type: "result", data } | { id, type: "error", message }
12
+ *
13
+ * Bumping protocol version requires a coordinated change in the parent —
14
+ * old bundles must keep working forever, since we can't force users to
15
+ * redeploy. Add new ops alongside existing ones; never repurpose them.
16
+ */
17
+ const pending = new Map();
18
+ let nextRpcId = 0;
19
+ let listenerInstalled = false;
20
+ function ensureListener() {
21
+ if (listenerInstalled)
22
+ return;
23
+ listenerInstalled = true;
24
+ window.addEventListener("message", (event) => {
25
+ // Trust only messages from the parent window. The iframe's origin is null
26
+ // (sandboxed), so we can't compare origins meaningfully — we trust the
27
+ // window reference instead.
28
+ if (event.source !== window.parent)
29
+ return;
30
+ const msg = event.data;
31
+ if (!msg || typeof msg.id !== "number")
32
+ return;
33
+ const handler = pending.get(msg.id);
34
+ if (!handler)
35
+ return;
36
+ pending.delete(msg.id);
37
+ if (msg.type === "result") {
38
+ handler.resolve(msg.data);
39
+ }
40
+ else {
41
+ handler.reject(new Error(msg.message ?? "RPC failed"));
42
+ }
43
+ });
44
+ }
45
+ export function rpc(op, payload) {
46
+ ensureListener();
47
+ return new Promise((resolve, reject) => {
48
+ const id = nextRpcId++;
49
+ pending.set(id, { resolve: resolve, reject });
50
+ window.parent.postMessage({ id, op, payload }, "*");
51
+ });
52
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Workspace-specific type augmentation point.
3
+ *
4
+ * The base SDK ships `WorkspaceTables` empty — every table read returns rows
5
+ * of `Record<string, unknown>`. The `lotics types` codegen writes a file
6
+ * (typically `.lotics/types.ts`) that extends this interface with the user's
7
+ * actual table schemas via TypeScript's module augmentation:
8
+ *
9
+ * ```ts
10
+ * // .lotics/types.ts (generated)
11
+ * import "@lotics/app-sdk";
12
+ * declare module "@lotics/app-sdk" {
13
+ * interface WorkspaceTables {
14
+ * "tbl_orders": { container_no: string; status: "active" | "closed" };
15
+ * }
16
+ * }
17
+ * ```
18
+ *
19
+ * After that, `useTable("tbl_orders")` is fully typed. Without the augmented
20
+ * file in tsconfig's include list, table IDs are typed as `string` and rows
21
+ * are `Record<string, unknown>` — code still runs, just untyped.
22
+ */
23
+ export interface WorkspaceTables {
24
+ }
25
+ /**
26
+ * Helper: row type for a known table id, or fallback for unknown ones.
27
+ * Currently unused by exported hooks (useRecord deferred to v2) but kept
28
+ * for the eventual augmented-typing path.
29
+ */
30
+ export type RowOf<K extends string> = K extends keyof WorkspaceTables ? WorkspaceTables[K] : Record<string, unknown>;
31
+ /** Source addressing emitted by the server for every record-derived row. */
32
+ export interface SourceAddressing {
33
+ __source_table_id?: string;
34
+ __source_record_id?: string;
35
+ __source_locked?: boolean;
36
+ }
37
+ /** A single record row plus source-addressing metadata. */
38
+ export type TableRow<K extends string> = RowOf<K> & SourceAddressing;
39
+ /**
40
+ * Query AST passed to `useQuery` for power-user composition. Mirrors
41
+ * `AppDataSourceQuery.root` server-side. Kept opaque (`unknown`) here
42
+ * because the recursive zod schema doesn't translate to a clean TS type.
43
+ * The server validates via `parseQueryNode`.
44
+ */
45
+ export type QueryAst = unknown;
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@lotics/app-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./dist/src/index.js"
8
+ },
9
+ "types": "./dist/src/index.d.ts",
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsgo",
16
+ "typecheck": "tsgo --noEmit",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "peerDependencies": {
20
+ "react": "^19.0.0",
21
+ "react-dom": "^19.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^19.0.0",
25
+ "@types/react-dom": "^19.0.0",
26
+ "react": "^19.0.0",
27
+ "react-dom": "^19.0.0"
28
+ },
29
+ "keywords": [
30
+ "lotics",
31
+ "app-sdk",
32
+ "react",
33
+ "iframe"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/lotics/lotics.git",
42
+ "directory": "packages/app-sdk"
43
+ }
44
+ }