esoul-sdk 0.4.0 → 0.7.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 (43) hide show
  1. package/README.md +146 -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 +169 -0
  7. package/dist/db/client-core.js +316 -0
  8. package/dist/db/compile-rules.d.ts +229 -0
  9. package/dist/db/compile-rules.js +426 -0
  10. package/dist/db/memory-client.d.ts +136 -0
  11. package/dist/db/memory-client.js +332 -0
  12. package/dist/db/schema-gen.d.ts +109 -0
  13. package/dist/db/schema-gen.js +363 -0
  14. package/dist/helpers.d.ts +52 -0
  15. package/dist/helpers.js +106 -10
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.js +5 -0
  18. package/dist/manifest.d.ts +466 -13
  19. package/dist/manifest.js +218 -5
  20. package/dist/roles.d.ts +43 -0
  21. package/dist/roles.js +56 -0
  22. package/dist/server.d.ts +182 -0
  23. package/dist/server.js +80 -0
  24. package/dist/testing/db.d.ts +71 -0
  25. package/dist/testing/db.js +103 -0
  26. package/dist/testing/index.d.ts +14 -0
  27. package/dist/testing/index.js +9 -0
  28. package/dist/testing/ops.d.ts +84 -0
  29. package/dist/testing/ops.js +76 -0
  30. package/dist/types.d.ts +22 -1
  31. package/docs/04-tools.md +5 -2
  32. package/docs/06-server.md +78 -0
  33. package/docs/07-background-tasks.md +23 -0
  34. package/docs/10-testing.md +18 -0
  35. package/docs/12-rules.md +3 -2
  36. package/docs/13-people-and-access.md +152 -0
  37. package/docs/14-database.md +221 -0
  38. package/docs/15-realtime.md +88 -0
  39. package/docs/16-bindings.md +79 -0
  40. package/llms-full.txt +829 -28
  41. package/llms.txt +4 -0
  42. package/package.json +7 -3
  43. package/schemas/plugin.schema.json +351 -9
@@ -0,0 +1,71 @@
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
+ name?: string | null;
37
+ email?: string | null;
38
+ }): RuleViewer;
39
+ /**
40
+ * The app's own word for a kind of caller, read from the manifest's `roles`.
41
+ *
42
+ * Delegates to `../roles`, which the PLATFORM also uses when it builds a real
43
+ * viewer. That is the whole point: a helper that mapped roles its own way would
44
+ * make a read-only member `staff` in an author's unit test and `viewer` in
45
+ * production, and every rule naming `staff` would change meaning between them.
46
+ *
47
+ * A `member` here is an EDIT member — the test helper's kinds are coarser than
48
+ * the platform's. Pass a `role` to `fakeViewer` for the read-only case.
49
+ */
50
+ export declare function roleForKind(manifest: TestManifest, kind: ViewerKind): string;
51
+ export interface MemoryDbHandle extends MemoryDb {
52
+ /** The same rows seen as somebody else — a kind name, or a full viewer. */
53
+ as(viewer: RuleViewer | ViewerKind, opts?: Record<string, unknown>): MemoryDbHandle;
54
+ /** The compiled rules, if you want to assert on them directly. */
55
+ $rules: CompiledRules;
56
+ }
57
+ export interface MemoryDbOptions {
58
+ viewer?: RuleViewer | ViewerKind;
59
+ workspaceId?: string;
60
+ nodeId?: string;
61
+ store?: MemoryStore;
62
+ viaBinding?: boolean;
63
+ across?: "owned-instances" | "my-rows";
64
+ ownedWorkspaceIds?: string[];
65
+ }
66
+ /**
67
+ * A database for your app, from your manifest. Starts as the app's own code
68
+ * (an `internal` caller) so a test can seed rows, then `.as(someone)` to check
69
+ * what each kind of person may actually see.
70
+ */
71
+ export declare function memoryDb(manifest: TestManifest, options?: MemoryDbOptions): MemoryDbHandle;
@@ -0,0 +1,103 @@
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
+ // THE ACCOUNT BEHIND THE VIEWER, for `viewerProfile`. A test that names one
43
+ // gets it back from the op under test; a test that names none gets null —
44
+ // and never a database. Registered on globalThis because the host's
45
+ // `viewerProfile` is the one that answers, and this is how it learns that a
46
+ // test is asking (the same seam `runAsViewer` uses).
47
+ if (userId && (opts.name !== undefined || opts.email !== undefined)) {
48
+ const g = globalThis;
49
+ (g.__esoulFakeProfiles ??= {})[userId] = { userId, name: opts.name ?? null, email: opts.email ?? null, picture: null };
50
+ }
51
+ return { kind, userId, viewerIds, role: opts.role ?? PLATFORM_DEFAULT_ROLE[kind] };
52
+ }
53
+ /**
54
+ * The app's own word for a kind of caller, read from the manifest's `roles`.
55
+ *
56
+ * Delegates to `../roles`, which the PLATFORM also uses when it builds a real
57
+ * viewer. That is the whole point: a helper that mapped roles its own way would
58
+ * make a read-only member `staff` in an author's unit test and `viewer` in
59
+ * production, and every rule naming `staff` would change meaning between them.
60
+ *
61
+ * A `member` here is an EDIT member — the test helper's kinds are coarser than
62
+ * the platform's. Pass a `role` to `fakeViewer` for the read-only case.
63
+ */
64
+ export function roleForKind(manifest, kind) {
65
+ return resolveAppRole({
66
+ roles: manifest.roles,
67
+ platformRole: PLATFORM_DEFAULT_ROLE[kind],
68
+ key: roleKeyFor(kind),
69
+ });
70
+ }
71
+ /**
72
+ * A database for your app, from your manifest. Starts as the app's own code
73
+ * (an `internal` caller) so a test can seed rows, then `.as(someone)` to check
74
+ * what each kind of person may actually see.
75
+ */
76
+ export function memoryDb(manifest, options = {}) {
77
+ const rules = compileRules({
78
+ pluginId: manifest.id ?? "app",
79
+ db: manifest.db ?? {},
80
+ vocabulary: manifest.roles?.vocabulary,
81
+ ownerRole: manifest.roles?.default?.owner,
82
+ });
83
+ const store = options.store ?? createStore(rules);
84
+ const build = (viewer, extra = {}) => {
85
+ const v = typeof viewer === "string" ? fakeViewer(viewer, { role: roleForKind(manifest, viewer) }) : viewer;
86
+ const db = createMemoryDb({
87
+ rules,
88
+ viewer: v,
89
+ store,
90
+ scope: {
91
+ workspaceId: extra.workspaceId ?? options.workspaceId ?? "ws_test",
92
+ nodeId: extra.nodeId ?? options.nodeId ?? "app_1",
93
+ },
94
+ across: extra.across ?? options.across,
95
+ ownedWorkspaceIds: extra.ownedWorkspaceIds ?? options.ownedWorkspaceIds,
96
+ viaBinding: extra.viaBinding ?? options.viaBinding,
97
+ });
98
+ db.as = (other, o) => build(other, { ...extra, ...o });
99
+ db.$rules = rules;
100
+ return db;
101
+ };
102
+ return build(options.viewer ?? "internal");
103
+ }
@@ -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/06-server.md CHANGED
@@ -60,6 +60,35 @@ Gated exactly like the UI: the manifest must declare `"my_computer:claude_task"`
60
60
  instance with `targetNodeId` instead of `appType`. This is how an app runs a `my_computer`
61
61
  (commands, Claude Code sessions, results), fills a spreadsheet from a job, or books a calendar.
62
62
 
63
+ ## `ctx.emit` — record on your OWN timeline
64
+
65
+ Anything a person can cause through your UI, an agent can cause through a tool,
66
+ and the server is the only place that sees both. So when a change belongs on the
67
+ fold, the OP records it, not the screen:
68
+
69
+ ```ts
70
+ async function addProduct(ctx: PluginOpContext) {
71
+ const p = await (await pluginDb<ShopDb>(ctx)).product.create({ data: … });
72
+ // Derive the change id. A retry — and the UI's own optimistic dispatch of the
73
+ // same fact — is then a no-op rather than a second bump.
74
+ await ctx.emit("plugin_shop_catalogue_changed", { changeId: `product:${p.id}`, tags: p.tags });
75
+ return { id: p.id };
76
+ }
77
+ ```
78
+
79
+ It is your own event (`eventName` from your schema), it goes through the
80
+ platform's spine — your `dataCreator` mints, your reducer folds, triggers fire —
81
+ and it lands on THIS instance.
82
+
83
+ **The failure this exists to prevent.** An app offered its departments from the
84
+ fold and recorded them only in the owner's add-product FORM. Stocked by its
85
+ agent instead, it therefore had a full catalogue and no departments at all,
86
+ live on a customer's site. If a fact belongs on the timeline, the op is where
87
+ it is written — a screen is one of its callers, never the only one.
88
+
89
+ Treat it as a courtesy around a completed write, like `notify`: catch a failure
90
+ and report it, rather than letting it undo a product that exists.
91
+
63
92
  ## `emitPluginAppEvent`
64
93
 
65
94
  Emit ANOTHER app's own events (its `dataCreator` shape) into the same workspace, actor-stamped as
@@ -76,3 +105,52 @@ Same-workspace only.
76
105
  Only `esoul-sdk` / `esoul-sdk/server`, relative files, `server-only`, and npm packages the
77
106
  platform already depends on. No `prisma`, no `node:*`, no `next`, no internals — the import wall
78
107
  (docs/11) refuses them, and a reviewer relies on that.
108
+
109
+ ## Routes — the backend your app brings with it
110
+
111
+ An op answers one JSON question. A **route** owns the whole Response: it can stream, return
112
+ bytes, set headers, and run for as long as one request lasts (minutes on Fluid compute). It is
113
+ how a user app gets what a native app gets from its API routes — mounted on install at
114
+ `GET|POST /api/plugins/<id>/route/<name>?nodeId=<instance>`.
115
+
116
+ ```json
117
+ // plugin.json
118
+ "routes": ["ticks"]
119
+ ```
120
+
121
+ ```ts
122
+ // server.ts
123
+ import { readAppState, sseStream, type PluginServerModule } from "esoul-sdk/server";
124
+
125
+ export const pluginServer: PluginServerModule = {
126
+ routes: {
127
+ // A clock streamed straight from the server: one Node process, no scheduler hops.
128
+ ticks: async (ctx) =>
129
+ sseStream(async (send, signal) => {
130
+ while (!signal.aborted) {
131
+ const app = await readAppState(ctx.nodeId); // the fold, ~0.3 s here
132
+ const cur = (app?.state as { current?: { runId: string; kickedAt: number } }).current;
133
+ if (cur) send("tick", { runId: cur.runId, elapsedMs: Date.now() - cur.kickedAt });
134
+ await new Promise((r) => setTimeout(r, 1000));
135
+ }
136
+ }, { signal: ctx.request.signal }),
137
+ },
138
+ };
139
+ ```
140
+
141
+ ```tsx
142
+ // ui — the session rides along; a public-share viewer can watch, not write.
143
+ import { pluginRouteUrl } from "esoul-sdk";
144
+ const es = new EventSource(pluginRouteUrl("stopwatch", "ticks", state.nodeId));
145
+ es.addEventListener("tick", (e) => setTick(JSON.parse((e as MessageEvent).data)));
146
+ ```
147
+
148
+ The platform resolves the instance, the enabled flag and the caller's access before your
149
+ handler runs — READ to reach the route at all, and `ctx.canWrite` says whether the caller may
150
+ mutate (check that one boolean before you `emitPluginAppEvent`). An internal caller
151
+ (`INTERNAL_TOOL_SECRET`) is a writer.
152
+
153
+ **Route or task?** A route lives exactly as long as a request: close the tab and the clock in
154
+ the example stops streaming — nothing durable happened unless the handler wrote events. A
155
+ task (docs/07) survives everything and costs seconds per hop. The stopwatch ships both and
156
+ records which one drove each run; measure before you choose.
@@ -47,12 +47,35 @@ replay; mint them inside. Step names are unique per logical operation; loops inc
47
47
  `kickPluginTask({ applicationType, taskName, identifier, data })` (from `esoul-sdk`; works in
48
48
  a component and in a tool's `execute`). The platform's send-event route allows only listed
49
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).
50
55
  - **On a cadence**: `pollTasks: [{ task, everyMinutes }]` — one shared platform sweep kicks each
51
56
  live instance at 5-minute granularity (minimum 5). This is your cron. There are deliberately
52
57
  no per-app Inngest functions: function ids are fixed at module load, and the plan caps
53
58
  concurrency; a shared sweep costs nothing per app. Handlers must be idempotent anyway, because
54
59
  sweeps and pushes overlap by design.
55
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
+
56
79
  ## Concurrency
57
80
 
58
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.
package/docs/12-rules.md CHANGED
@@ -33,5 +33,6 @@
33
33
  - "State reverts after reload" → `reconstructStateFromEventLog` unset, or a processor minted ids.
34
34
  - "check_app is red on registry" → the manifest failed validation or the import wall refused a
35
35
  file; the detail names it.
36
- - "My tool needs the database" → write an op; call it with `callPluginOp`; expect a refusal in
37
- the workbench (no database behind a preview) and use `read_app_state` there.
36
+ - "My tool needs the database" → declare the tables in the manifest's `db` and write an op over
37
+ `pluginDb(ctx)`; call it with `callPluginOp`. The workbench runs it over in-memory tables with
38
+ the same rules, so VIEW AS shows what each person may read.