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.
- package/README.md +146 -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 +169 -0
- package/dist/db/client-core.js +316 -0
- package/dist/db/compile-rules.d.ts +229 -0
- package/dist/db/compile-rules.js +426 -0
- package/dist/db/memory-client.d.ts +136 -0
- package/dist/db/memory-client.js +332 -0
- package/dist/db/schema-gen.d.ts +109 -0
- package/dist/db/schema-gen.js +363 -0
- package/dist/helpers.d.ts +52 -0
- package/dist/helpers.js +106 -10
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/manifest.d.ts +466 -13
- package/dist/manifest.js +218 -5
- package/dist/roles.d.ts +43 -0
- package/dist/roles.js +56 -0
- package/dist/server.d.ts +182 -0
- package/dist/server.js +80 -0
- package/dist/testing/db.d.ts +71 -0
- package/dist/testing/db.js +103 -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/06-server.md +78 -0
- package/docs/07-background-tasks.md +23 -0
- package/docs/10-testing.md +18 -0
- package/docs/12-rules.md +3 -2
- package/docs/13-people-and-access.md +152 -0
- package/docs/14-database.md +221 -0
- package/docs/15-realtime.md +88 -0
- package/docs/16-bindings.md +79 -0
- package/llms-full.txt +829 -28
- package/llms.txt +4 -0
- package/package.json +7 -3
- 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
|
+
}
|
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/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
|
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.
|
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" →
|
|
37
|
-
|
|
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.
|