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.
- package/README.md +135 -24
- package/dist/audience.d.ts +103 -0
- package/dist/audience.js +142 -0
- package/dist/bindings.d.ts +164 -0
- package/dist/bindings.js +163 -0
- package/dist/db/client-core.d.ts +154 -0
- package/dist/db/client-core.js +274 -0
- package/dist/db/compile-rules.d.ts +199 -0
- package/dist/db/compile-rules.js +390 -0
- package/dist/db/memory-client.d.ts +136 -0
- package/dist/db/memory-client.js +323 -0
- package/dist/db/schema-gen.d.ts +103 -0
- package/dist/db/schema-gen.js +329 -0
- package/dist/helpers.d.ts +67 -0
- package/dist/helpers.js +125 -8
- package/dist/index.d.ts +24 -0
- package/dist/index.js +22 -0
- package/dist/manifest.d.ts +445 -13
- package/dist/manifest.js +211 -5
- package/dist/react.d.ts +29 -0
- package/dist/react.js +10 -0
- package/dist/roles.d.ts +43 -0
- package/dist/roles.js +56 -0
- package/dist/server.d.ts +165 -0
- package/dist/server.js +80 -0
- package/dist/testing/db.d.ts +69 -0
- package/dist/testing/db.js +94 -0
- package/dist/testing/index.d.ts +14 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/ops.d.ts +84 -0
- package/dist/testing/ops.js +76 -0
- package/dist/types.d.ts +22 -1
- package/docs/04-tools.md +5 -2
- package/docs/05-ui.md +30 -0
- package/docs/06-server.md +49 -0
- package/docs/07-background-tasks.md +29 -3
- package/docs/10-testing.md +18 -0
- package/docs/12-rules.md +3 -2
- package/docs/13-people-and-access.md +148 -0
- package/docs/14-database.md +115 -0
- package/docs/15-realtime.md +88 -0
- package/docs/16-bindings.md +79 -0
- package/llms-full.txt +715 -31
- package/llms.txt +4 -0
- package/package.json +7 -3
- 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
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/testing/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
66
|
-
|
|
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)` —
|
|
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
|
|
46
|
-
|
|
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
|
package/docs/10-testing.md
CHANGED
|
@@ -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.
|