esoul-sdk 0.3.0 → 0.6.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.
Files changed (46) hide show
  1. package/README.md +135 -24
  2. package/dist/audience.d.ts +103 -0
  3. package/dist/audience.js +142 -0
  4. package/dist/bindings.d.ts +164 -0
  5. package/dist/bindings.js +163 -0
  6. package/dist/db/client-core.d.ts +154 -0
  7. package/dist/db/client-core.js +274 -0
  8. package/dist/db/compile-rules.d.ts +199 -0
  9. package/dist/db/compile-rules.js +390 -0
  10. package/dist/db/memory-client.d.ts +136 -0
  11. package/dist/db/memory-client.js +323 -0
  12. package/dist/db/schema-gen.d.ts +103 -0
  13. package/dist/db/schema-gen.js +329 -0
  14. package/dist/helpers.d.ts +67 -0
  15. package/dist/helpers.js +125 -8
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.js +22 -0
  18. package/dist/manifest.d.ts +445 -13
  19. package/dist/manifest.js +211 -5
  20. package/dist/react.d.ts +29 -0
  21. package/dist/react.js +10 -0
  22. package/dist/roles.d.ts +43 -0
  23. package/dist/roles.js +56 -0
  24. package/dist/server.d.ts +165 -0
  25. package/dist/server.js +80 -0
  26. package/dist/testing/db.d.ts +69 -0
  27. package/dist/testing/db.js +94 -0
  28. package/dist/testing/index.d.ts +14 -0
  29. package/dist/testing/index.js +9 -0
  30. package/dist/testing/ops.d.ts +84 -0
  31. package/dist/testing/ops.js +76 -0
  32. package/dist/types.d.ts +22 -1
  33. package/docs/04-tools.md +5 -2
  34. package/docs/05-ui.md +30 -0
  35. package/docs/06-server.md +49 -0
  36. package/docs/07-background-tasks.md +29 -3
  37. package/docs/10-testing.md +18 -0
  38. package/docs/12-rules.md +3 -2
  39. package/docs/13-people-and-access.md +148 -0
  40. package/docs/14-database.md +115 -0
  41. package/docs/15-realtime.md +88 -0
  42. package/docs/16-bindings.md +79 -0
  43. package/llms-full.txt +715 -31
  44. package/llms.txt +4 -0
  45. package/package.json +7 -3
  46. package/schemas/plugin.schema.json +323 -9
package/dist/server.js CHANGED
@@ -8,6 +8,18 @@
8
8
  * Inside the host, `esoul-sdk/server` is aliased to the real
9
9
  * implementations.
10
10
  */
11
+ /**
12
+ * WHO IS THIS, IN WORDS. The caller's own name and email, for an app with a
13
+ * reason to ask — a receipt, an address form they should not retype.
14
+ *
15
+ * SERVER ONLY, and only ever about the CALLER: there is no argument for whose
16
+ * profile to read, so an app cannot look up a person who is not talking to it.
17
+ * Null for anyone without an account. One database read: call it where you
18
+ * need it, not on every request.
19
+ */
20
+ export function viewerProfile(_viewer) {
21
+ return hostOnly("viewerProfile");
22
+ }
11
23
  const hostOnly = (name) => {
12
24
  throw new Error(`${name} runs only inside the ExternalSoul host (the app aliases esoul-sdk to its real implementations). In unit tests, mock this module.`);
13
25
  };
@@ -18,6 +30,22 @@ const hostOnly = (name) => {
18
30
  export function getPluginConnectionCredentials(_connectionId, _pluginId) {
19
31
  return hostOnly("getPluginConnectionCredentials");
20
32
  }
33
+ /**
34
+ * The client for the tables YOUR manifest declared (`db`). Hand it any server
35
+ * context — an op's, a route's, a task's — and get back a client whose reads
36
+ * are already scoped to this instance and this caller, and whose writes refuse
37
+ * what the rules refuse. You write no access checks; you cannot forget one.
38
+ *
39
+ * const db = await pluginDb<ShopDb>(ctx); // ShopDb from ./.esoul/db
40
+ * const mine = await db.order.findMany({ orderBy: { createdAt: "desc" } });
41
+ *
42
+ * `across: "owned-instances"` (the owner, across their shops) and `across:
43
+ * "my-rows"` (an account, across every instance) are read-only reaches.
44
+ * HOST-ONLY: in a unit test use `memoryDb(manifest)` from `esoul-sdk/testing`.
45
+ */
46
+ export function pluginDb(_ctx, _reach) {
47
+ return hostOnly("pluginDb");
48
+ }
21
49
  /**
22
50
  * Cross-app events: dispatch the TARGET app's own events through the
23
51
  * platform spine (target's dataCreator mints; triggers fire; every event is
@@ -56,3 +84,55 @@ export function pluginFiles(_ctx) {
56
84
  export function filesForOp(_ctx) {
57
85
  return hostOnly("filesForOp");
58
86
  }
87
+ /**
88
+ * A Server-Sent Events response. `run` gets `send(event, data)` and the request's
89
+ * abort signal; return when done (or when the signal fires — the client left). A
90
+ * heartbeat comment every 15 s keeps proxies from closing an idle stream. Real code,
91
+ * not host-provided: streaming is the web platform's.
92
+ */
93
+ export function sseStream(run, opts) {
94
+ const enc = new TextEncoder();
95
+ const ac = new AbortController();
96
+ opts?.signal?.addEventListener("abort", () => ac.abort(), { once: true });
97
+ const stream = new ReadableStream({
98
+ start(controller) {
99
+ let closed = false;
100
+ const write = (chunk) => {
101
+ if (closed)
102
+ return;
103
+ try {
104
+ controller.enqueue(enc.encode(chunk));
105
+ }
106
+ catch {
107
+ closed = true;
108
+ }
109
+ };
110
+ const send = (event, data) => write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
111
+ const beat = setInterval(() => write(": keep-alive\n\n"), Math.max(1_000, opts?.heartbeatMs ?? 15_000));
112
+ const finish = () => {
113
+ clearInterval(beat);
114
+ if (closed)
115
+ return;
116
+ closed = true;
117
+ try {
118
+ controller.close();
119
+ }
120
+ catch {
121
+ /* already closed */
122
+ }
123
+ };
124
+ ac.signal.addEventListener("abort", finish, { once: true });
125
+ write(": open\n\n");
126
+ run(send, ac.signal).then(finish, (err) => {
127
+ send("error", { message: err instanceof Error ? err.message : String(err) });
128
+ finish();
129
+ });
130
+ },
131
+ cancel() {
132
+ ac.abort();
133
+ },
134
+ });
135
+ return new Response(stream, {
136
+ headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no" },
137
+ });
138
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Prove your access rules in a unit test, in milliseconds, before anything is
3
+ * installed anywhere.
4
+ *
5
+ * The client these helpers hand you is not a mock of the platform's: it loads
6
+ * the rules compiled from your own `plugin.json` and applies them exactly as
7
+ * production does, down to refusing a filter on a column you forgot to index.
8
+ * So the test every shop should own —
9
+ *
10
+ * expect(await db.as(bob).order.count()).toBe(0)
11
+ *
12
+ * — means the same thing here as it will mean on the real database, which is
13
+ * the point of the whole arrangement.
14
+ */
15
+ import { type CompiledRules, type RuleViewer } from "../db/compile-rules.js";
16
+ import { type MemoryDb, type MemoryStore } from "../db/memory-client.js";
17
+ /** The parts of a plugin.json these helpers read. */
18
+ export interface TestManifest {
19
+ id?: string;
20
+ db?: Record<string, never>;
21
+ roles?: {
22
+ vocabulary?: string[];
23
+ default?: Record<string, string>;
24
+ };
25
+ }
26
+ export type ViewerKind = RuleViewer["kind"];
27
+ /**
28
+ * A caller to run something as. Give it a `role` when your app declares its own
29
+ * vocabulary, or let `memoryDb(manifest).as("visitor")` map it for you from the
30
+ * manifest's own `roles.default`.
31
+ */
32
+ export declare function fakeViewer(kind: ViewerKind, opts?: {
33
+ userId?: string | null;
34
+ viewerIds?: string[];
35
+ role?: string;
36
+ }): RuleViewer;
37
+ /**
38
+ * The app's own word for a kind of caller, read from the manifest's `roles`.
39
+ *
40
+ * Delegates to `../roles`, which the PLATFORM also uses when it builds a real
41
+ * viewer. That is the whole point: a helper that mapped roles its own way would
42
+ * make a read-only member `staff` in an author's unit test and `viewer` in
43
+ * production, and every rule naming `staff` would change meaning between them.
44
+ *
45
+ * A `member` here is an EDIT member — the test helper's kinds are coarser than
46
+ * the platform's. Pass a `role` to `fakeViewer` for the read-only case.
47
+ */
48
+ export declare function roleForKind(manifest: TestManifest, kind: ViewerKind): string;
49
+ export interface MemoryDbHandle extends MemoryDb {
50
+ /** The same rows seen as somebody else — a kind name, or a full viewer. */
51
+ as(viewer: RuleViewer | ViewerKind, opts?: Record<string, unknown>): MemoryDbHandle;
52
+ /** The compiled rules, if you want to assert on them directly. */
53
+ $rules: CompiledRules;
54
+ }
55
+ export interface MemoryDbOptions {
56
+ viewer?: RuleViewer | ViewerKind;
57
+ workspaceId?: string;
58
+ nodeId?: string;
59
+ store?: MemoryStore;
60
+ viaBinding?: boolean;
61
+ across?: "owned-instances" | "my-rows";
62
+ ownedWorkspaceIds?: string[];
63
+ }
64
+ /**
65
+ * A database for your app, from your manifest. Starts as the app's own code
66
+ * (an `internal` caller) so a test can seed rows, then `.as(someone)` to check
67
+ * what each kind of person may actually see.
68
+ */
69
+ export declare function memoryDb(manifest: TestManifest, options?: MemoryDbOptions): MemoryDbHandle;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Prove your access rules in a unit test, in milliseconds, before anything is
3
+ * installed anywhere.
4
+ *
5
+ * The client these helpers hand you is not a mock of the platform's: it loads
6
+ * the rules compiled from your own `plugin.json` and applies them exactly as
7
+ * production does, down to refusing a filter on a column you forgot to index.
8
+ * So the test every shop should own —
9
+ *
10
+ * expect(await db.as(bob).order.count()).toBe(0)
11
+ *
12
+ * — means the same thing here as it will mean on the real database, which is
13
+ * the point of the whole arrangement.
14
+ */
15
+ import { compileRules, } from "../db/compile-rules.js";
16
+ import { createMemoryDb, createStore, } from "../db/memory-client.js";
17
+ import { resolveAppRole, roleKeyFor } from "../roles.js";
18
+ const PLATFORM_DEFAULT_ROLE = {
19
+ owner: "owner",
20
+ member: "editor",
21
+ visitor: "viewer",
22
+ anonymous: "viewer",
23
+ agent: "viewer",
24
+ internal: "owner",
25
+ };
26
+ const DEFAULT_IDS = {
27
+ owner: "u_owner",
28
+ member: "u_member",
29
+ visitor: "u_visitor",
30
+ anonymous: null,
31
+ agent: "u_agent",
32
+ internal: null,
33
+ };
34
+ /**
35
+ * A caller to run something as. Give it a `role` when your app declares its own
36
+ * vocabulary, or let `memoryDb(manifest).as("visitor")` map it for you from the
37
+ * manifest's own `roles.default`.
38
+ */
39
+ export function fakeViewer(kind, opts = {}) {
40
+ const userId = opts.userId !== undefined ? opts.userId : DEFAULT_IDS[kind];
41
+ const viewerIds = opts.viewerIds ?? (userId ? [userId] : []);
42
+ return { kind, userId, viewerIds, role: opts.role ?? PLATFORM_DEFAULT_ROLE[kind] };
43
+ }
44
+ /**
45
+ * The app's own word for a kind of caller, read from the manifest's `roles`.
46
+ *
47
+ * Delegates to `../roles`, which the PLATFORM also uses when it builds a real
48
+ * viewer. That is the whole point: a helper that mapped roles its own way would
49
+ * make a read-only member `staff` in an author's unit test and `viewer` in
50
+ * production, and every rule naming `staff` would change meaning between them.
51
+ *
52
+ * A `member` here is an EDIT member — the test helper's kinds are coarser than
53
+ * the platform's. Pass a `role` to `fakeViewer` for the read-only case.
54
+ */
55
+ export function roleForKind(manifest, kind) {
56
+ return resolveAppRole({
57
+ roles: manifest.roles,
58
+ platformRole: PLATFORM_DEFAULT_ROLE[kind],
59
+ key: roleKeyFor(kind),
60
+ });
61
+ }
62
+ /**
63
+ * A database for your app, from your manifest. Starts as the app's own code
64
+ * (an `internal` caller) so a test can seed rows, then `.as(someone)` to check
65
+ * what each kind of person may actually see.
66
+ */
67
+ export function memoryDb(manifest, options = {}) {
68
+ const rules = compileRules({
69
+ pluginId: manifest.id ?? "app",
70
+ db: manifest.db ?? {},
71
+ vocabulary: manifest.roles?.vocabulary,
72
+ ownerRole: manifest.roles?.default?.owner,
73
+ });
74
+ const store = options.store ?? createStore(rules);
75
+ const build = (viewer, extra = {}) => {
76
+ const v = typeof viewer === "string" ? fakeViewer(viewer, { role: roleForKind(manifest, viewer) }) : viewer;
77
+ const db = createMemoryDb({
78
+ rules,
79
+ viewer: v,
80
+ store,
81
+ scope: {
82
+ workspaceId: extra.workspaceId ?? options.workspaceId ?? "ws_test",
83
+ nodeId: extra.nodeId ?? options.nodeId ?? "app_1",
84
+ },
85
+ across: extra.across ?? options.across,
86
+ ownedWorkspaceIds: extra.ownedWorkspaceIds ?? options.ownedWorkspaceIds,
87
+ viaBinding: extra.viaBinding ?? options.viaBinding,
88
+ });
89
+ db.as = (other, o) => build(other, { ...extra, ...o });
90
+ db.$rules = rules;
91
+ return db;
92
+ };
93
+ return build(options.viewer ?? "internal");
94
+ }
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Test utilities that need NOTHING from the host.
3
+ *
4
+ * Two families:
5
+ * - `memoryDb` / `fakeViewer` / `runOp` / `fakeApps` / `capture` — prove your
6
+ * app's ACCESS RULES: who sees which rows, who is refused, who is told to
7
+ * sign in. The client runs the rules compiled from your own plugin.json,
8
+ * so what passes here is what the real database will do.
9
+ * - `startMockOAuth` — a real tiny OAuth2 provider for the connection flow.
10
+ */
11
+ export { fakeViewer, memoryDb, roleForKind } from "./db.js";
12
+ export type { MemoryDbHandle, MemoryDbOptions, TestManifest, ViewerKind } from "./db.js";
13
+ export { capture, fakeApps, runOp } from "./ops.js";
14
+ export type { BoundAppFixture, EmitCall, NotifyCall, Recorder, RunOpOptions, RunOpResult } from "./ops.js";
1
15
  export interface MockOAuthServer {
2
16
  port: number;
3
17
  url: string;
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * Test utilities that need NOTHING from the host.
3
+ *
4
+ * Two families:
5
+ * - `memoryDb` / `fakeViewer` / `runOp` / `fakeApps` / `capture` — prove your
6
+ * app's ACCESS RULES: who sees which rows, who is refused, who is told to
7
+ * sign in. The client runs the rules compiled from your own plugin.json,
8
+ * so what passes here is what the real database will do.
9
+ * - `startMockOAuth` — a real tiny OAuth2 provider for the connection flow.
3
10
  */
11
+ export { fakeViewer, memoryDb, roleForKind } from "./db.js";
12
+ export { capture, fakeApps, runOp } from "./ops.js";
4
13
  import http from "node:http";
5
14
  import crypto from "node:crypto";
6
15
  /**
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Run one of your server ops the way the platform will, and see what it did.
3
+ *
4
+ * The platform hands an op a viewer, a database, a way to notify and a way to
5
+ * emit. `runOp` hands it the same things with the notifications and events
6
+ * recorded, so a test can assert the part that matters most and is hardest to
7
+ * see: WHO a message went to.
8
+ *
9
+ * const { result, notified } = await runOp(pluginServer, "place-order", { db, viewer: alice, args });
10
+ * expect(notified).toEqual([{ topic: "new-order", to: { role: "staff" }, data: { orderId: result.orderId } }]);
11
+ */
12
+ import type { RuleViewer } from "../db/compile-rules.js";
13
+ import type { MemoryDbHandle } from "./db.js";
14
+ export interface NotifyCall {
15
+ topic: string;
16
+ data: unknown;
17
+ to?: unknown;
18
+ }
19
+ export interface EmitCall {
20
+ eventName: string;
21
+ eventData: Record<string, unknown>;
22
+ }
23
+ export interface BoundAppFixture {
24
+ nodeId?: string;
25
+ applicationType?: string;
26
+ /** The provider's state, as `ctx.apps.<slot>.state()` would return it. */
27
+ state?: Record<string, unknown>;
28
+ /** The provider's tools, as functions. */
29
+ tools?: Record<string, (args?: Record<string, unknown>) => Promise<{
30
+ ok: boolean;
31
+ text: string;
32
+ }> | {
33
+ ok: boolean;
34
+ text: string;
35
+ }>;
36
+ /** The provider's database, for the models it opened to bound apps. */
37
+ db?: MemoryDbHandle;
38
+ }
39
+ /**
40
+ * Fill a `uses` slot with a stand-in provider, so a consumer's op can be
41
+ * tested without the other app existing. Calls to a tool the fixture does not
42
+ * define come back as a refusal, which is what an unbound slot really does.
43
+ */
44
+ export declare function fakeApps(fixtures: Record<string, BoundAppFixture>): Record<string, unknown>;
45
+ export interface RunOpOptions {
46
+ viewer: RuleViewer;
47
+ args?: unknown;
48
+ db?: MemoryDbHandle;
49
+ apps?: Record<string, unknown>;
50
+ pluginId?: string;
51
+ workspaceId?: string;
52
+ nodeId?: string;
53
+ instanceName?: string;
54
+ cloudConnectionId?: string | null;
55
+ /**
56
+ * Make `ctx.notify` THROW, so a test can ask the question worth asking of
57
+ * any op that tells somebody after it has written something: does the write
58
+ * survive? A courtesy must never undo a completed order. Return the error
59
+ * the platform would throw — a refusal carries `code: "forbidden"`.
60
+ */
61
+ notifyFails?: (topic: string, to: unknown) => unknown;
62
+ }
63
+ export interface RunOpResult<T = unknown> {
64
+ result: T;
65
+ notified: NotifyCall[];
66
+ emitted: EmitCall[];
67
+ }
68
+ interface ServerModuleLike {
69
+ ops?: Record<string, (ctx: never) => Promise<unknown>>;
70
+ }
71
+ /**
72
+ * Call `pluginServer.ops[name]` with a context shaped like the platform's.
73
+ * Throws whatever your op throws — including the platform's refusals, so
74
+ * `await expect(runOp(...)).rejects.toMatchObject({ code: "login-required" })`
75
+ * is how you prove the sign-in wall appears for the right person.
76
+ */
77
+ export declare function runOp<T = unknown>(server: ServerModuleLike, opName: string, options: RunOpOptions): Promise<RunOpResult<T>>;
78
+ export interface Recorder<A extends unknown[]> {
79
+ fn: (...args: A) => Promise<void>;
80
+ calls: A[];
81
+ }
82
+ /** A call recorder, for anything `runOp` does not already capture. */
83
+ export declare function capture<A extends unknown[] = unknown[]>(): Recorder<A>;
84
+ export {};
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Fill a `uses` slot with a stand-in provider, so a consumer's op can be
3
+ * tested without the other app existing. Calls to a tool the fixture does not
4
+ * define come back as a refusal, which is what an unbound slot really does.
5
+ */
6
+ export function fakeApps(fixtures) {
7
+ const apps = {};
8
+ for (const [slot, f] of Object.entries(fixtures)) {
9
+ const calls = [];
10
+ apps[slot] = {
11
+ nodeId: f.nodeId ?? `${slot}_node`,
12
+ applicationType: f.applicationType ?? `plugin_${slot}`,
13
+ calls,
14
+ state: async () => f.state ?? {},
15
+ db: f.db,
16
+ emit: async () => undefined,
17
+ call: async (tool, args) => {
18
+ calls.push({ tool, args });
19
+ const impl = f.tools?.[tool];
20
+ if (!impl)
21
+ return { ok: false, text: `${slot} has no tool "${tool}"` };
22
+ return impl(args);
23
+ },
24
+ };
25
+ }
26
+ return apps;
27
+ }
28
+ /**
29
+ * Call `pluginServer.ops[name]` with a context shaped like the platform's.
30
+ * Throws whatever your op throws — including the platform's refusals, so
31
+ * `await expect(runOp(...)).rejects.toMatchObject({ code: "login-required" })`
32
+ * is how you prove the sign-in wall appears for the right person.
33
+ */
34
+ export async function runOp(server, opName, options) {
35
+ const handler = server.ops?.[opName];
36
+ if (!handler) {
37
+ throw new Error(`pluginServer.ops has no "${opName}" — declared ops: ${Object.keys(server.ops ?? {}).join(", ") || "(none)"}`);
38
+ }
39
+ const notified = [];
40
+ const emitted = [];
41
+ const ctx = {
42
+ pluginId: options.pluginId ?? "app",
43
+ opName,
44
+ workspaceId: options.workspaceId ?? "ws_test",
45
+ nodeId: options.nodeId ?? "app_1",
46
+ instanceName: options.instanceName ?? "App 1",
47
+ cloudConnectionId: options.cloudConnectionId ?? null,
48
+ viewer: options.viewer,
49
+ args: options.args,
50
+ db: options.db,
51
+ apps: options.apps ?? {},
52
+ notify: async (topic, data, opts) => {
53
+ notified.push({ topic, data, ...(opts?.to !== undefined ? { to: opts.to } : {}) });
54
+ // A test may make notifying FAIL. Worth asking of any op that tells
55
+ // somebody after it has written something: a courtesy must not undo a
56
+ // completed write, and the only way to know is to break it on purpose.
57
+ if (options.notifyFails)
58
+ throw options.notifyFails(topic, opts?.to);
59
+ },
60
+ emit: async (eventName, eventData) => {
61
+ emitted.push({ eventName, eventData });
62
+ },
63
+ };
64
+ const result = (await handler(ctx));
65
+ return { result, notified, emitted };
66
+ }
67
+ /** A call recorder, for anything `runOp` does not already capture. */
68
+ export function capture() {
69
+ const calls = [];
70
+ return {
71
+ calls,
72
+ fn: async (...args) => {
73
+ calls.push(args);
74
+ },
75
+ };
76
+ }
package/dist/types.d.ts CHANGED
@@ -146,9 +146,30 @@ export interface AppTaskContext<TState extends ApplicationIdentifier> {
146
146
  /** Inngest step API. RULE: every side effect goes inside a step.run. */
147
147
  step: any;
148
148
  logger: any;
149
+ /** Diagnostics: the last `getState()` read's cost split (import of the fold module vs the fold). */
150
+ timing: {
151
+ lastGetState?: {
152
+ importMs: number;
153
+ foldMs: number;
154
+ pingMs?: number;
155
+ region?: string;
156
+ at: number;
157
+ };
158
+ };
149
159
  getState(): Promise<TState>;
150
160
  dispatchEvent(eventName: string, eventData: any): Promise<void>;
151
- notify(topic: string, data: any): Promise<void>;
161
+ /**
162
+ * Publish on one of your channel's topics. The topic's `audience` in your
163
+ * manifest decides who hears it; `to` aims it at one person or one role,
164
+ * which your own task always may (S8).
165
+ */
166
+ notify(topic: string, data: any, opts?: {
167
+ to?: {
168
+ viewerIds: string[];
169
+ } | {
170
+ role: string;
171
+ };
172
+ }): Promise<void>;
152
173
  }
153
174
  export interface AppTaskDefinition<TState extends ApplicationIdentifier> {
154
175
  /** Becomes the Inngest event `<applicationType>/<taskName>`. */
package/docs/04-tools.md CHANGED
@@ -62,8 +62,11 @@ could not do: an update to an id that is not on the wall says "No note X — not
62
62
 
63
63
  A tool that must read the real state (not the caller's cache) calls a **plugin op** through
64
64
  `callPluginOp(pluginId, op, nodeId, args)` — never `fetch("/api/v1/…")` itself (token-gated, it
65
- refuses the tool). Ops are yours to write (docs/06). In the workbench there is no database behind
66
- the preview, so such a tool is refused with a message naming `read_app_state`; that is expected.
65
+ refuses the tool). Ops are yours to write (docs/06). In the workbench an op runs over the
66
+ preview's own in-memory tables, so a tool that calls one works there too. A failed call throws a
67
+ `PluginCallError` carrying the platform's `code` (`forbidden`, `login-required`, …): pass it to
68
+ `useSignInWall().raise(err)` in the UI and a `login-required` becomes the sign-in wall instead of
69
+ an error. Relay any other error's message; never swallow it.
67
70
 
68
71
  ## Calling other apps
69
72
 
package/docs/05-ui.md CHANGED
@@ -63,6 +63,36 @@ The app renders inside the platform frame at any size: a desktop window, a maxim
63
63
  `look_at_app` in the workbench screenshots desktop-light, desktop-dark and phone-light. Open the
64
64
  images; a phone shot with a horizontal scrollbar is a bug.
65
65
 
66
+ ## Live data from a background task
67
+
68
+ ```tsx
69
+ import { usePluginRealtime } from "esoul-sdk/react";
70
+ import { kickPluginTask } from "esoul-sdk";
71
+ import { stopwatchChannel } from "./channel";
72
+
73
+ const live = usePluginRealtime<{ runId: string; elapsedMs: number; serverNow: number }>({
74
+ channel: stopwatchChannel,
75
+ workspaceId: state.workspaceId,
76
+ nodeId: state.nodeId,
77
+ topics: stopwatchChannel.topicNames,
78
+ });
79
+ // live.latestData → { topic: "tick", data: {...} } — the newest message, or null.
80
+ // live.data → everything received this mount, oldest first.
81
+
82
+ const start = () =>
83
+ kickPluginTask({
84
+ applicationType: "plugin_stopwatch",
85
+ taskName: "tick",
86
+ identifier: state,
87
+ data: { runId, kickedAt: Date.now() },
88
+ });
89
+ ```
90
+
91
+ The subscription is per instance and read-only; the token the platform mints for it carries only
92
+ the topics your schema declares. Treat messages as nudges: what the UI must still show after a
93
+ refresh comes from state, so a task that changes anything durable dispatches an event as well
94
+ (docs/07 has the whole loop, including the stop).
95
+
66
96
  ## Cross-app from the UI
67
97
 
68
98
  ```ts
package/docs/06-server.md CHANGED
@@ -76,3 +76,52 @@ Same-workspace only.
76
76
  Only `esoul-sdk` / `esoul-sdk/server`, relative files, `server-only`, and npm packages the
77
77
  platform already depends on. No `prisma`, no `node:*`, no `next`, no internals — the import wall
78
78
  (docs/11) refuses them, and a reviewer relies on that.
79
+
80
+ ## Routes — the backend your app brings with it
81
+
82
+ An op answers one JSON question. A **route** owns the whole Response: it can stream, return
83
+ bytes, set headers, and run for as long as one request lasts (minutes on Fluid compute). It is
84
+ how a user app gets what a native app gets from its API routes — mounted on install at
85
+ `GET|POST /api/plugins/<id>/route/<name>?nodeId=<instance>`.
86
+
87
+ ```json
88
+ // plugin.json
89
+ "routes": ["ticks"]
90
+ ```
91
+
92
+ ```ts
93
+ // server.ts
94
+ import { readAppState, sseStream, type PluginServerModule } from "esoul-sdk/server";
95
+
96
+ export const pluginServer: PluginServerModule = {
97
+ routes: {
98
+ // A clock streamed straight from the server: one Node process, no scheduler hops.
99
+ ticks: async (ctx) =>
100
+ sseStream(async (send, signal) => {
101
+ while (!signal.aborted) {
102
+ const app = await readAppState(ctx.nodeId); // the fold, ~0.3 s here
103
+ const cur = (app?.state as { current?: { runId: string; kickedAt: number } }).current;
104
+ if (cur) send("tick", { runId: cur.runId, elapsedMs: Date.now() - cur.kickedAt });
105
+ await new Promise((r) => setTimeout(r, 1000));
106
+ }
107
+ }, { signal: ctx.request.signal }),
108
+ },
109
+ };
110
+ ```
111
+
112
+ ```tsx
113
+ // ui — the session rides along; a public-share viewer can watch, not write.
114
+ import { pluginRouteUrl } from "esoul-sdk";
115
+ const es = new EventSource(pluginRouteUrl("stopwatch", "ticks", state.nodeId));
116
+ es.addEventListener("tick", (e) => setTick(JSON.parse((e as MessageEvent).data)));
117
+ ```
118
+
119
+ The platform resolves the instance, the enabled flag and the caller's access before your
120
+ handler runs — READ to reach the route at all, and `ctx.canWrite` says whether the caller may
121
+ mutate (check that one boolean before you `emitPluginAppEvent`). An internal caller
122
+ (`INTERNAL_TOOL_SECRET`) is a writer.
123
+
124
+ **Route or task?** A route lives exactly as long as a request: close the tab and the clock in
125
+ the example stops streaming — nothing durable happened unless the handler wrote events. A
126
+ task (docs/07) survives everything and costs seconds per hop. The stopwatch ships both and
127
+ records which one drove each run; measure before you choose.
@@ -28,7 +28,8 @@ tasks: [
28
28
  - `ctx.getState()` — the app's state, fresh.
29
29
  - `ctx.dispatchEvent(eventName, eventData)` — through the full pipeline (append, fold, watermark),
30
30
  using YOUR processors, so a task's mutation is identical to a tap's.
31
- - `ctx.notify(topic, data)` — a realtime nudge to the app's channel; the UI refetches.
31
+ - `ctx.notify(topic, data)` — publish on the app's realtime channel (see *Live tasks* below). A
32
+ nudge, never the truth: the UI hears it while mounted; a refresh only sees the timeline.
32
33
  - `ctx.logger`.
33
34
 
34
35
  ## The replay model — read this twice
@@ -42,14 +43,39 @@ replay; mint them inside. Step names are unique per logical operation; loops inc
42
43
  ## How a task gets kicked
43
44
 
44
45
  - **From a webhook**: `ctx.sendInngestEvent("<applicationType>/<task>", payload)` (docs/06).
45
- - **From the browser**: list the task in `kickableTasks`; the UI posts the event through the
46
- platform's send-event route, which allows only listed tasks.
46
+ - **From the browser or a tool**: list the task in `kickableTasks`, then
47
+ `kickPluginTask({ applicationType, taskName, identifier, data })` (from `esoul-sdk`; works in
48
+ a component and in a tool's `execute`). The platform's send-event route allows only listed
49
+ tasks and stamps nothing — `data` is exactly what `ctx.eventData` sees.
50
+ - **In a Forge preview** the same call runs the task IN the preview's dev server, behind the
51
+ same `ctx` (steps, `getState`, `dispatchEvent`, `notify`), against an in-process store the
52
+ page mirrors — so you can press Start in the frame before the app is installed. What the
53
+ preview does not give you: durability across a process death, retries, a scheduler, and the
54
+ platform's `if` on `waitForEvent` (the preview matches the event name and your node).
47
55
  - **On a cadence**: `pollTasks: [{ task, everyMinutes }]` — one shared platform sweep kicks each
48
56
  live instance at 5-minute granularity (minimum 5). This is your cron. There are deliberately
49
57
  no per-app Inngest functions: function ids are fixed at module load, and the plan caps
50
58
  concurrency; a shared sweep costs nothing per app. Handlers must be idempotent anyway, because
51
59
  sweeps and pushes overlap by design.
52
60
 
61
+
62
+ ## Task or route? Measured (2026-09-10, the stopwatch)
63
+
64
+ | | a server route (docs/06) | a durable task |
65
+ |---|---|---|
66
+ | kick → first tick | 0.2 s | 2–15 s |
67
+ | stop landed → run finished | 0.8 s | 0.7–12 s |
68
+ | tick cadence | 1 s | ~3 s |
69
+ | a fold read | 60 ms | ~2.8 s |
70
+ | survives the tab closing | no — nothing outlives the request | yes — everything |
71
+ | survives a deploy / crash | no | yes |
72
+
73
+ Every step of a task is a round trip through the scheduler into the platform's function.
74
+ So: **the user's time is measured on the user's clock** (the two presses), the task is the
75
+ durable witness, and the route is the live view. Ship both when the difference matters, and
76
+ record which one drove each run — the timeline is the measurement. Never make a task keep
77
+ sub-second time; never make a route keep a promise past its request.
78
+
53
79
  ## Concurrency
54
80
 
55
81
  `concurrency: { limit, scope: "per-app" | "global" }`. Keep the limit small; a parked wait holds
@@ -60,3 +60,21 @@ Once an app has real history, record it (`scripts/plugins/record-fold-corpus.mjs
60
60
  platform) and commit `fold-corpus.json` + hashes. From then on the checks refuse a change that
61
61
  alters what history MEANS — the strongest protection an app with data can have. Re-recording is
62
62
  declaring a migration; say so in the changelog.
63
+
64
+ ## What runs in a Forge preview (parity)
65
+
66
+ Since 2026-09-10 a workbench keeps an in-process stand-in for the database, Inngest and the
67
+ broker, so before the app is installed:
68
+
69
+ | | in the preview | not in the preview |
70
+ |---|---|---|
71
+ | events, tools (`call_app_tool`), `read_app_state` | yes | |
72
+ | tasks (`kickPluginTask`, from the UI or a tool) | yes — same `ctx`, in-process | durability, retries, a scheduler; `waitForEvent`'s `if` (name + node only) |
73
+ | ops (`callPluginOp`, `readAppState`) | yes — on a preview-only route, over the preview's fold | a real database |
74
+ | routes (`pluginRouteUrl` → a preview-only route) | yes — the caller is a writer | auth (the sandbox is single-tenant) |
75
+ | realtime (`ctx.notify` → `usePluginRealtime`) | yes — polled from the store | the broker |
76
+ | `emitPluginAppEvent` from a route or op | yes | cross-app targets outside the preview |
77
+
78
+ The board is told which of these a box has: the preview announces `tasks`, `ops`, `routes`,
79
+ `realtime` in its greeting once the store answers, and the frame reloads itself when the
80
+ server side is a newer build than the bundle it runs.