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/llms-full.txt
CHANGED
|
@@ -4,65 +4,176 @@ Generated from README.md and docs/. Read it whole. Every rule carries the failur
|
|
|
4
4
|
|
|
5
5
|
# esoul-sdk
|
|
6
6
|
|
|
7
|
-
Build **ExternalSoul
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
Build a **full product** on ExternalSoul — not a widget. An app you write with this SDK gets its
|
|
8
|
+
own database tables, its own server, its own background jobs, its own realtime, its own words for
|
|
9
|
+
the people who use it, and the same agent tools that chat, voice and MCP already call. Once it
|
|
10
|
+
ships it is indistinguishable from the platform's own apps.
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
npm install --save-dev esoul-sdk
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Inside ExternalSoul the package name resolves to the platform's real implementations. Outside it
|
|
17
|
-
(your editor, your tests)
|
|
18
|
-
helpers; the server functions throw "host only" if called, because they run in the
|
|
17
|
+
(your editor, your tests) it gives you the types, the manifest validator, the rule compiler and
|
|
18
|
+
the test helpers; the server functions throw "host only" if called, because they run in the
|
|
19
|
+
platform.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What you can build with it
|
|
24
|
+
|
|
25
|
+
Take a help desk. Strangers may read the published articles. Someone signed in may raise a ticket
|
|
26
|
+
and see their own. The people on duty see every ticket. The owner decides who is on duty. A
|
|
27
|
+
confirmation must go out exactly once, even if the mail service is down for a minute. That is
|
|
28
|
+
every hard thing at once, and it is the shape most real products have.
|
|
29
|
+
|
|
30
|
+
An app like that, written with this SDK, contains **no access checks at all**. They are
|
|
31
|
+
declarations, and the platform enforces them at the seam.
|
|
32
|
+
|
|
33
|
+
| You want | You declare | You never write |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| Your own tables | `db` in the manifest | migrations, a Prisma schema, an ORM |
|
|
36
|
+
| Per-person data | `owner: "creator"` + `rules` | a `WHERE userId = …` anywhere |
|
|
37
|
+
| A public page | `"access": "public"` on an op | an auth check in the handler |
|
|
38
|
+
| A sign-in wall | `"requires": "account"` | the difference between "sign in" and "never" |
|
|
39
|
+
| Words for your people | `roles` | a permissions table |
|
|
40
|
+
| One person's live updates | `"audience": "viewer"` on a topic | a filter on arrival |
|
|
41
|
+
| Durable follow-up work | a `task` | a queue, retries, idempotency plumbing |
|
|
42
|
+
| Another app's help | `uses` + a contract | an integration |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## The seven capabilities
|
|
47
|
+
|
|
48
|
+
**1. Viewer — every seam knows who is calling.** Ops, routes, tasks, tools and the UI all receive
|
|
49
|
+
a `viewer`: the owner, a member, a signed-in visitor, an anonymous one, an agent acting for
|
|
50
|
+
someone, or your app's own server code. The platform resolves it; an app cannot supply, widen or
|
|
51
|
+
forge one. `useViewer()` in the UI, `ctx.viewer` on the server. When you need the person's name
|
|
52
|
+
or email — to address them, or to fill a form in — `viewerProfile(ctx.viewer)` reads the esoul
|
|
53
|
+
account of the CALLER and nobody else, so people use your app with the account they already have.
|
|
54
|
+
|
|
55
|
+
**2. Roles — your app's own vocabulary.** Declare `roles: { vocabulary: ["requester", "agent",
|
|
56
|
+
"supervisor"] }` and a mapping from the platform's kinds. The workspace owner can then assign a role
|
|
57
|
+
per app, per person, so an organisation sees the pages their job needs. A role is a WORD, never a
|
|
58
|
+
permission: it decides what your rules mean, not what the platform allows.
|
|
59
|
+
|
|
60
|
+
**3. Access levels — nothing opens by default.** Every op, route and task is `write` unless you
|
|
61
|
+
say otherwise. `read` admits a read-only collaborator; `public` admits someone on a share link
|
|
62
|
+
who has no workspace access at all; `public` + `requires: "account"` answers `login-required`
|
|
63
|
+
instead of `forbidden`, which is the difference between showing a sign-in wall and showing an
|
|
64
|
+
error. Every public door is listed on the install card before anyone installs your app.
|
|
65
|
+
|
|
66
|
+
**4. The database — tables you declare, scoped by the platform.** A `db` block becomes real
|
|
67
|
+
tables, generated typings, and rules compiled once and enforced everywhere. `pluginDb(ctx)` hands
|
|
68
|
+
you a typed client whose every query is already scoped to this instance and filtered for this
|
|
69
|
+
caller: one person's `findMany` returns their own rows, someone on duty gets the whole queue, and
|
|
70
|
+
neither had to ask.
|
|
71
|
+
Fields can be `sealed` (encrypted at rest, readable only by their owner). Migrations are
|
|
72
|
+
additive-only and applied on install; a change that would drop or retype a column is refused with
|
|
73
|
+
the column named.
|
|
74
|
+
|
|
75
|
+
**5. Realtime with an audience.** A topic declares who hears it: everyone on the app, only the
|
|
76
|
+
person it concerns, or only a role. The platform mints a token per channel, so one person is never
|
|
77
|
+
handed another's messages — not filtered out on arrival, never issued. Aiming a message is a
|
|
78
|
+
separate permission from hearing one.
|
|
79
|
+
|
|
80
|
+
**6. Background tasks.** Durable work on the platform's own scheduler: retried, replay-safe,
|
|
81
|
+
steps on the timeline. This is where the slow and failure-prone things go — sending the
|
|
82
|
+
confirmation, calling somebody else's API — so the request that changed the record stays fast and
|
|
83
|
+
honest.
|
|
84
|
+
|
|
85
|
+
**7. Bindings.** Declare a SLOT and a CONTRACT (`uses: { rooms: { contract: "rooms/v1" } }`) and
|
|
86
|
+
the owner picks which app fills it. The platform checks at bind time that the provider really has
|
|
87
|
+
every tool, event and table the contract names. Your app reaches it through `ctx.apps.rooms` —
|
|
88
|
+
the contract's tools only, with the caller's identity travelling along.
|
|
89
|
+
|
|
90
|
+
---
|
|
19
91
|
|
|
20
92
|
## Where to start
|
|
21
93
|
|
|
22
94
|
- **Build it in the Forge, not on your laptop.** Open a Forge board in your workspace and ask it
|
|
23
|
-
to open a workbench for your app
|
|
24
|
-
|
|
25
|
-
|
|
95
|
+
to open a workbench for your app: a cloud machine with the platform on it, a live preview in
|
|
96
|
+
your frame, your app's tools callable before it is installed, and **VIEW AS** — the switcher
|
|
97
|
+
that shows you your app as each kind of person who will use it, including the stranger. Read
|
|
98
|
+
[docs/01-getting-started.md](docs/01-getting-started.md).
|
|
26
99
|
- **The contract, one page per part:**
|
|
27
100
|
1. [Getting started](docs/01-getting-started.md) — the loop, the package layout
|
|
28
101
|
2. [The manifest](docs/02-manifest.md) — `plugin.json`, every field
|
|
29
102
|
3. [Events and state](docs/03-events-and-state.md) — the heart: dataCreator, processor, replay
|
|
30
103
|
4. [Tools](docs/04-tools.md) — what agents call, on every surface
|
|
31
104
|
5. [The UI](docs/05-ui.md) — React, hooks, theme, responsive rules
|
|
32
|
-
6. [The server half](docs/06-server.md) — ops,
|
|
105
|
+
6. [The server half](docs/06-server.md) — ops, routes, webhooks, calling other apps
|
|
33
106
|
7. [Background tasks](docs/07-background-tasks.md) — durable work, polling, the replay model
|
|
34
107
|
8. [Connections and OAuth](docs/08-connections.md) — tokens the platform holds for you
|
|
35
108
|
9. [Files](docs/09-files.md) — workspace files, Drive, your own provider
|
|
36
|
-
10. [Testing](docs/10-testing.md) — the fold contract as tests
|
|
109
|
+
10. [Testing](docs/10-testing.md) — the fold contract, and your rules, as tests
|
|
37
110
|
11. [Shipping](docs/11-shipping.md) — submit, review, release, install; the import wall
|
|
38
111
|
12. [Rules and failures](docs/12-rules.md) — every rule with the failure that earned it
|
|
112
|
+
13. [People and access](docs/13-people-and-access.md) — viewer, roles, levels, the sign-in wall
|
|
113
|
+
14. [Your own tables](docs/14-database.md) — `db`, rules, scopes, sealed fields, migrations
|
|
114
|
+
15. [Realtime](docs/15-realtime.md) — topics, audiences, who may address whom
|
|
115
|
+
16. [Bindings](docs/16-bindings.md) — slots, contracts, reaching another app
|
|
39
116
|
- **For a coding model:** `llms.txt` (short) and `llms-full.txt` (the whole contract in one file).
|
|
40
117
|
|
|
41
118
|
## The one rule that explains the others
|
|
42
119
|
|
|
43
|
-
**Events are the truth.** Your app's
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
120
|
+
**Events are the truth.** Your app's fold is its events re-run on every replay, scrub and sync. So
|
|
121
|
+
a reducer is pure and idempotent, ids and timestamps are minted in the `dataCreator` (never in a
|
|
122
|
+
reducer), whole-replace events carry a collapse key, and a state description never claims
|
|
123
|
+
something it could not read.
|
|
124
|
+
|
|
125
|
+
The second rule, for everything that is not in the fold: **the platform decides who sees what.**
|
|
126
|
+
Your rules are declarations the platform enforces at the seam. An app that checks access in its
|
|
127
|
+
own handler has two answers to one question, and one of them will be wrong.
|
|
48
128
|
|
|
49
129
|
## What is in the package
|
|
50
130
|
|
|
51
131
|
| Entry | What it gives you |
|
|
52
132
|
|---|---|
|
|
53
|
-
| `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `
|
|
54
|
-
| `esoul-sdk/react` | `
|
|
55
|
-
| `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
|
|
56
|
-
| `esoul-sdk/testing` |
|
|
133
|
+
| `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `definePluginChannel`, `defineBindingEvent`, `checkBinding`, `subscriptionsFor`, `resolveAppRole`, `incompleteStateNotice`, `deterministicReducerId`, `timingSafeEqual`, `nanoid`, `callPluginOp`, `kickPluginTask`, `pluginRouteUrl`, the manifest schema |
|
|
134
|
+
| `esoul-sdk/react` | `useViewer`, `useSignInWall`, `useAppCanEdit`, `usePluginEventDispatch`, `usePluginRealtime`, `useWorkspaceTools`, file hooks |
|
|
135
|
+
| `esoul-sdk/server` | `pluginDb`, `viewerProfile`, `PluginServerModule` (ops, routes, webhooks), `sseStream`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
|
|
136
|
+
| `esoul-sdk/testing` | `memoryDb`, `fakeViewer`, `runOp`, `fakeApps`, `capture`, `startMockOAuth` — your rules run against the client the platform compiles from your own manifest |
|
|
57
137
|
| `esoul-app validate <dir>` | validates a package folder against the manifest schema |
|
|
58
138
|
|
|
139
|
+
## Testing your app
|
|
140
|
+
|
|
141
|
+
The helpers run your REAL server code against an in-memory database built from your own
|
|
142
|
+
`plugin.json`, with the same compiled rules the production client uses. What passes here is what
|
|
143
|
+
the real database will do.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { fakeViewer, memoryDb, runOp } from "esoul-sdk/testing";
|
|
147
|
+
import manifest from "./plugin.json";
|
|
148
|
+
import { pluginServer } from "./server";
|
|
149
|
+
|
|
150
|
+
const ada = fakeViewer("visitor", { userId: "u_ada", role: "requester" });
|
|
151
|
+
const lin = fakeViewer("visitor", { userId: "u_lin", role: "requester" });
|
|
152
|
+
|
|
153
|
+
it("does not let the OTHER one see it", async () => {
|
|
154
|
+
const db = memoryDb(manifest);
|
|
155
|
+
await runOp(pluginServer, "raise-ticket", { viewer: ada, args: TICKET, db: db.as(ada) });
|
|
156
|
+
const { result } = await runOp(pluginServer, "my-tickets", { viewer: lin, args: {}, db: db.as(lin) });
|
|
157
|
+
expect(result).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`runOp` also records what your op NOTIFIED and who it addressed, and `notifyFails` makes
|
|
162
|
+
notifying throw — the question worth asking of any op that tells somebody after it has written
|
|
163
|
+
something: does the write survive?
|
|
164
|
+
|
|
59
165
|
## Versions
|
|
60
166
|
|
|
61
|
-
- **0.
|
|
62
|
-
|
|
63
|
-
|
|
167
|
+
- **0.6.0** — the full-stack release. Your own tables (`db`, `pluginDb`, rules, scopes, sealed
|
|
168
|
+
fields, additive migrations applied on install). `viewer` on every seam, app `roles`, per-surface
|
|
169
|
+
access levels with the sign-in wall. Realtime audiences: a topic says who hears it and the mint
|
|
170
|
+
issues a token per channel. Bindings: `uses`/`provides`, contracts, `ctx.apps.<slot>`. A tool now
|
|
171
|
+
acts for the person who invoked it rather than for the platform. `viewerProfile` — the caller's
|
|
172
|
+
own esoul account, server-side only. Testing: `memoryDb`, `fakeViewer`, `runOp`.
|
|
173
|
+
- 0.5.0 — server routes (`pluginServer.routes`), `sseStream`, plugin realtime.
|
|
174
|
+
- 0.3.0 — renamed from `@externalsoul/plugin-sdk`. `readAppState`, `callWorkspaceTool`, `nanoid`
|
|
175
|
+
on the index. The import wall: an app reaches the platform only through this package.
|
|
64
176
|
- 0.2.0 — file sources and providers.
|
|
65
|
-
- 0.1.0 — the contract: manifest, schema, events, tools, tasks, webhooks, ops, connections.
|
|
66
177
|
|
|
67
178
|
|
|
68
179
|
|
|
@@ -401,8 +512,11 @@ could not do: an update to an id that is not on the wall says "No note X — not
|
|
|
401
512
|
|
|
402
513
|
A tool that must read the real state (not the caller's cache) calls a **plugin op** through
|
|
403
514
|
`callPluginOp(pluginId, op, nodeId, args)` — never `fetch("/api/v1/…")` itself (token-gated, it
|
|
404
|
-
refuses the tool). Ops are yours to write (docs/06). In the workbench
|
|
405
|
-
|
|
515
|
+
refuses the tool). Ops are yours to write (docs/06). In the workbench an op runs over the
|
|
516
|
+
preview's own in-memory tables, so a tool that calls one works there too. A failed call throws a
|
|
517
|
+
`PluginCallError` carrying the platform's `code` (`forbidden`, `login-required`, …): pass it to
|
|
518
|
+
`useSignInWall().raise(err)` in the UI and a `login-required` becomes the sign-in wall instead of
|
|
519
|
+
an error. Relay any other error's message; never swallow it.
|
|
406
520
|
|
|
407
521
|
## Calling other apps
|
|
408
522
|
|
|
@@ -491,6 +605,36 @@ The app renders inside the platform frame at any size: a desktop window, a maxim
|
|
|
491
605
|
`look_at_app` in the workbench screenshots desktop-light, desktop-dark and phone-light. Open the
|
|
492
606
|
images; a phone shot with a horizontal scrollbar is a bug.
|
|
493
607
|
|
|
608
|
+
## Live data from a background task
|
|
609
|
+
|
|
610
|
+
```tsx
|
|
611
|
+
import { usePluginRealtime } from "esoul-sdk/react";
|
|
612
|
+
import { kickPluginTask } from "esoul-sdk";
|
|
613
|
+
import { stopwatchChannel } from "./channel";
|
|
614
|
+
|
|
615
|
+
const live = usePluginRealtime<{ runId: string; elapsedMs: number; serverNow: number }>({
|
|
616
|
+
channel: stopwatchChannel,
|
|
617
|
+
workspaceId: state.workspaceId,
|
|
618
|
+
nodeId: state.nodeId,
|
|
619
|
+
topics: stopwatchChannel.topicNames,
|
|
620
|
+
});
|
|
621
|
+
// live.latestData → { topic: "tick", data: {...} } — the newest message, or null.
|
|
622
|
+
// live.data → everything received this mount, oldest first.
|
|
623
|
+
|
|
624
|
+
const start = () =>
|
|
625
|
+
kickPluginTask({
|
|
626
|
+
applicationType: "plugin_stopwatch",
|
|
627
|
+
taskName: "tick",
|
|
628
|
+
identifier: state,
|
|
629
|
+
data: { runId, kickedAt: Date.now() },
|
|
630
|
+
});
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
The subscription is per instance and read-only; the token the platform mints for it carries only
|
|
634
|
+
the topics your schema declares. Treat messages as nudges: what the UI must still show after a
|
|
635
|
+
refresh comes from state, so a task that changes anything durable dispatches an event as well
|
|
636
|
+
(docs/07 has the whole loop, including the stop).
|
|
637
|
+
|
|
494
638
|
## Cross-app from the UI
|
|
495
639
|
|
|
496
640
|
```ts
|
|
@@ -595,6 +739,55 @@ Only `esoul-sdk` / `esoul-sdk/server`, relative files, `server-only`, and npm pa
|
|
|
595
739
|
platform already depends on. No `prisma`, no `node:*`, no `next`, no internals — the import wall
|
|
596
740
|
(docs/11) refuses them, and a reviewer relies on that.
|
|
597
741
|
|
|
742
|
+
## Routes — the backend your app brings with it
|
|
743
|
+
|
|
744
|
+
An op answers one JSON question. A **route** owns the whole Response: it can stream, return
|
|
745
|
+
bytes, set headers, and run for as long as one request lasts (minutes on Fluid compute). It is
|
|
746
|
+
how a user app gets what a native app gets from its API routes — mounted on install at
|
|
747
|
+
`GET|POST /api/plugins/<id>/route/<name>?nodeId=<instance>`.
|
|
748
|
+
|
|
749
|
+
```json
|
|
750
|
+
// plugin.json
|
|
751
|
+
"routes": ["ticks"]
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
// server.ts
|
|
756
|
+
import { readAppState, sseStream, type PluginServerModule } from "esoul-sdk/server";
|
|
757
|
+
|
|
758
|
+
export const pluginServer: PluginServerModule = {
|
|
759
|
+
routes: {
|
|
760
|
+
// A clock streamed straight from the server: one Node process, no scheduler hops.
|
|
761
|
+
ticks: async (ctx) =>
|
|
762
|
+
sseStream(async (send, signal) => {
|
|
763
|
+
while (!signal.aborted) {
|
|
764
|
+
const app = await readAppState(ctx.nodeId); // the fold, ~0.3 s here
|
|
765
|
+
const cur = (app?.state as { current?: { runId: string; kickedAt: number } }).current;
|
|
766
|
+
if (cur) send("tick", { runId: cur.runId, elapsedMs: Date.now() - cur.kickedAt });
|
|
767
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
768
|
+
}
|
|
769
|
+
}, { signal: ctx.request.signal }),
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
```tsx
|
|
775
|
+
// ui — the session rides along; a public-share viewer can watch, not write.
|
|
776
|
+
import { pluginRouteUrl } from "esoul-sdk";
|
|
777
|
+
const es = new EventSource(pluginRouteUrl("stopwatch", "ticks", state.nodeId));
|
|
778
|
+
es.addEventListener("tick", (e) => setTick(JSON.parse((e as MessageEvent).data)));
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
The platform resolves the instance, the enabled flag and the caller's access before your
|
|
782
|
+
handler runs — READ to reach the route at all, and `ctx.canWrite` says whether the caller may
|
|
783
|
+
mutate (check that one boolean before you `emitPluginAppEvent`). An internal caller
|
|
784
|
+
(`INTERNAL_TOOL_SECRET`) is a writer.
|
|
785
|
+
|
|
786
|
+
**Route or task?** A route lives exactly as long as a request: close the tab and the clock in
|
|
787
|
+
the example stops streaming — nothing durable happened unless the handler wrote events. A
|
|
788
|
+
task (docs/07) survives everything and costs seconds per hop. The stopwatch ships both and
|
|
789
|
+
records which one drove each run; measure before you choose.
|
|
790
|
+
|
|
598
791
|
|
|
599
792
|
|
|
600
793
|
==============================================================================
|
|
@@ -628,7 +821,8 @@ tasks: [
|
|
|
628
821
|
- `ctx.getState()` — the app's state, fresh.
|
|
629
822
|
- `ctx.dispatchEvent(eventName, eventData)` — through the full pipeline (append, fold, watermark),
|
|
630
823
|
using YOUR processors, so a task's mutation is identical to a tap's.
|
|
631
|
-
- `ctx.notify(topic, data)` —
|
|
824
|
+
- `ctx.notify(topic, data)` — publish on the app's realtime channel (see *Live tasks* below). A
|
|
825
|
+
nudge, never the truth: the UI hears it while mounted; a refresh only sees the timeline.
|
|
632
826
|
- `ctx.logger`.
|
|
633
827
|
|
|
634
828
|
## The replay model — read this twice
|
|
@@ -642,14 +836,39 @@ replay; mint them inside. Step names are unique per logical operation; loops inc
|
|
|
642
836
|
## How a task gets kicked
|
|
643
837
|
|
|
644
838
|
- **From a webhook**: `ctx.sendInngestEvent("<applicationType>/<task>", payload)` (docs/06).
|
|
645
|
-
- **From the browser**: list the task in `kickableTasks
|
|
646
|
-
|
|
839
|
+
- **From the browser or a tool**: list the task in `kickableTasks`, then
|
|
840
|
+
`kickPluginTask({ applicationType, taskName, identifier, data })` (from `esoul-sdk`; works in
|
|
841
|
+
a component and in a tool's `execute`). The platform's send-event route allows only listed
|
|
842
|
+
tasks and stamps nothing — `data` is exactly what `ctx.eventData` sees.
|
|
843
|
+
- **In a Forge preview** the same call runs the task IN the preview's dev server, behind the
|
|
844
|
+
same `ctx` (steps, `getState`, `dispatchEvent`, `notify`), against an in-process store the
|
|
845
|
+
page mirrors — so you can press Start in the frame before the app is installed. What the
|
|
846
|
+
preview does not give you: durability across a process death, retries, a scheduler, and the
|
|
847
|
+
platform's `if` on `waitForEvent` (the preview matches the event name and your node).
|
|
647
848
|
- **On a cadence**: `pollTasks: [{ task, everyMinutes }]` — one shared platform sweep kicks each
|
|
648
849
|
live instance at 5-minute granularity (minimum 5). This is your cron. There are deliberately
|
|
649
850
|
no per-app Inngest functions: function ids are fixed at module load, and the plan caps
|
|
650
851
|
concurrency; a shared sweep costs nothing per app. Handlers must be idempotent anyway, because
|
|
651
852
|
sweeps and pushes overlap by design.
|
|
652
853
|
|
|
854
|
+
|
|
855
|
+
## Task or route? Measured (2026-09-10, the stopwatch)
|
|
856
|
+
|
|
857
|
+
| | a server route (docs/06) | a durable task |
|
|
858
|
+
|---|---|---|
|
|
859
|
+
| kick → first tick | 0.2 s | 2–15 s |
|
|
860
|
+
| stop landed → run finished | 0.8 s | 0.7–12 s |
|
|
861
|
+
| tick cadence | 1 s | ~3 s |
|
|
862
|
+
| a fold read | 60 ms | ~2.8 s |
|
|
863
|
+
| survives the tab closing | no — nothing outlives the request | yes — everything |
|
|
864
|
+
| survives a deploy / crash | no | yes |
|
|
865
|
+
|
|
866
|
+
Every step of a task is a round trip through the scheduler into the platform's function.
|
|
867
|
+
So: **the user's time is measured on the user's clock** (the two presses), the task is the
|
|
868
|
+
durable witness, and the route is the live view. Ship both when the difference matters, and
|
|
869
|
+
record which one drove each run — the timeline is the measurement. Never make a task keep
|
|
870
|
+
sub-second time; never make a route keep a promise past its request.
|
|
871
|
+
|
|
653
872
|
## Concurrency
|
|
654
873
|
|
|
655
874
|
`concurrency: { limit, scope: "per-app" | "global" }`. Keep the limit small; a parked wait holds
|
|
@@ -818,6 +1037,24 @@ platform) and commit `fold-corpus.json` + hashes. From then on the checks refuse
|
|
|
818
1037
|
alters what history MEANS — the strongest protection an app with data can have. Re-recording is
|
|
819
1038
|
declaring a migration; say so in the changelog.
|
|
820
1039
|
|
|
1040
|
+
## What runs in a Forge preview (parity)
|
|
1041
|
+
|
|
1042
|
+
Since 2026-09-10 a workbench keeps an in-process stand-in for the database, Inngest and the
|
|
1043
|
+
broker, so before the app is installed:
|
|
1044
|
+
|
|
1045
|
+
| | in the preview | not in the preview |
|
|
1046
|
+
|---|---|---|
|
|
1047
|
+
| events, tools (`call_app_tool`), `read_app_state` | yes | |
|
|
1048
|
+
| tasks (`kickPluginTask`, from the UI or a tool) | yes — same `ctx`, in-process | durability, retries, a scheduler; `waitForEvent`'s `if` (name + node only) |
|
|
1049
|
+
| ops (`callPluginOp`, `readAppState`) | yes — on a preview-only route, over the preview's fold | a real database |
|
|
1050
|
+
| routes (`pluginRouteUrl` → a preview-only route) | yes — the caller is a writer | auth (the sandbox is single-tenant) |
|
|
1051
|
+
| realtime (`ctx.notify` → `usePluginRealtime`) | yes — polled from the store | the broker |
|
|
1052
|
+
| `emitPluginAppEvent` from a route or op | yes | cross-app targets outside the preview |
|
|
1053
|
+
|
|
1054
|
+
The board is told which of these a box has: the preview announces `tasks`, `ops`, `routes`,
|
|
1055
|
+
`realtime` in its greeting once the store answers, and the frame reloads itself when the
|
|
1056
|
+
server side is a newer build than the bundle it runs.
|
|
1057
|
+
|
|
821
1058
|
|
|
822
1059
|
|
|
823
1060
|
==============================================================================
|
|
@@ -930,5 +1167,452 @@ platform it runs on.
|
|
|
930
1167
|
- "State reverts after reload" → `reconstructStateFromEventLog` unset, or a processor minted ids.
|
|
931
1168
|
- "check_app is red on registry" → the manifest failed validation or the import wall refused a
|
|
932
1169
|
file; the detail names it.
|
|
933
|
-
- "My tool needs the database" →
|
|
934
|
-
|
|
1170
|
+
- "My tool needs the database" → declare the tables in the manifest's `db` and write an op over
|
|
1171
|
+
`pluginDb(ctx)`; call it with `callPluginOp`. The workbench runs it over in-memory tables with
|
|
1172
|
+
the same rules, so VIEW AS shows what each person may read.
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
==============================================================================
|
|
1177
|
+
# 13 · People and access
|
|
1178
|
+
|
|
1179
|
+
Your app will be used by people with different relationships to it: the person who installed it,
|
|
1180
|
+
the people they invited, and everyone else. This page is how the platform tells them apart, and
|
|
1181
|
+
how you say what each may do — without writing a single access check.
|
|
1182
|
+
|
|
1183
|
+
## The viewer
|
|
1184
|
+
|
|
1185
|
+
Every seam of your app receives a `viewer`. The UI reads it with `useViewer()`; the server gets
|
|
1186
|
+
`ctx.viewer` on an op, a route and a tool; a task carries `ctx.kickedBy` (who asked) and runs as
|
|
1187
|
+
your app's own code.
|
|
1188
|
+
|
|
1189
|
+
```ts
|
|
1190
|
+
viewer.kind // "owner" | "member" | "visitor" | "anonymous" | "agent" | "internal"
|
|
1191
|
+
viewer.userId // the account, or null
|
|
1192
|
+
viewer.role // YOUR app's word for them (see below)
|
|
1193
|
+
viewer.canWrite // may they change the workspace at all
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
- **owner** — whose workspace this is.
|
|
1197
|
+
- **member** — someone the owner invited, with edit or read-only access.
|
|
1198
|
+
- **visitor** — signed in, but not in the workspace: someone on a share link.
|
|
1199
|
+
- **anonymous** — not signed in. Still has an identity (a cookie) so rows they create can be
|
|
1200
|
+
theirs, and stay theirs after they sign in.
|
|
1201
|
+
- **agent** — a run acting for one of the above. It gains nothing: everything about what it may
|
|
1202
|
+
see comes from the person it acts for.
|
|
1203
|
+
- **internal** — your own app's server code (a task, the install job). Rules do not apply to it;
|
|
1204
|
+
scope still does.
|
|
1205
|
+
|
|
1206
|
+
The platform resolves this. An app cannot supply, widen or forge one — the import wall keeps you
|
|
1207
|
+
from reading a cookie or a header to invent an identity.
|
|
1208
|
+
|
|
1209
|
+
## Who they actually are
|
|
1210
|
+
|
|
1211
|
+
A viewer is an identity, not a person's details. When your app needs the details — a name to put
|
|
1212
|
+
on a confirmation, an address to send one to — ask for the CALLER's own account:
|
|
1213
|
+
|
|
1214
|
+
```ts
|
|
1215
|
+
import { viewerProfile } from "esoul-sdk/server";
|
|
1216
|
+
|
|
1217
|
+
const me = await viewerProfile(ctx.viewer); // { userId, email, name, picture } | null
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
Server only, one database read, and only ever about the caller: there is no argument for whose
|
|
1221
|
+
profile to read, so an app cannot look somebody up. It is null for anyone without an account,
|
|
1222
|
+
which is the same thing `requires: "account"` is about. Use it to pre-fill a form rather than
|
|
1223
|
+
asking a signed-in person to type what the platform already knows.
|
|
1224
|
+
|
|
1225
|
+
## Roles — your app's own vocabulary
|
|
1226
|
+
|
|
1227
|
+
The platform's words are about a workspace. Your app's words are about your app. A help desk has
|
|
1228
|
+
requesters and agents; a library has readers and librarians; a clinic has patients and staff.
|
|
1229
|
+
|
|
1230
|
+
```json
|
|
1231
|
+
"roles": {
|
|
1232
|
+
"vocabulary": ["requester", "agent", "supervisor"],
|
|
1233
|
+
"default": {
|
|
1234
|
+
"owner": "supervisor",
|
|
1235
|
+
"member-edit": "agent",
|
|
1236
|
+
"member-readonly": "agent",
|
|
1237
|
+
"visitor": "requester",
|
|
1238
|
+
"anonymous": "requester",
|
|
1239
|
+
"agent": "inherit"
|
|
1240
|
+
},
|
|
1241
|
+
"describe": {
|
|
1242
|
+
"requester": "raises tickets and sees their own",
|
|
1243
|
+
"agent": "answers every ticket; never the billing notes",
|
|
1244
|
+
"supervisor": "everything, including who handled what"
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
`default` maps the platform's kinds onto your words. Two sentinels are the platform's, not yours:
|
|
1250
|
+
`inherit` (an agent takes the role of whoever it acts for) and `none` (this app has no word for
|
|
1251
|
+
that kind of caller, so no rule can match them — the right answer when a stranger has no business
|
|
1252
|
+
here at all).
|
|
1253
|
+
|
|
1254
|
+
**The workspace owner can override it per person, per app.** In workspace sharing, an app that
|
|
1255
|
+
declares a vocabulary gets a picker showing your words and your descriptions. So one colleague is
|
|
1256
|
+
an `agent` in the help desk and another a `supervisor`, in the same workspace, and everyone
|
|
1257
|
+
outside is a `requester` whether or not they are signed in.
|
|
1258
|
+
|
|
1259
|
+
A role is a WORD, never a permission. It decides what your rules mean. Whether someone may write
|
|
1260
|
+
the workspace at all is still the platform's answer: a read-only collaborator you call `agent`
|
|
1261
|
+
still cannot write.
|
|
1262
|
+
|
|
1263
|
+
## Access levels — nothing opens by default
|
|
1264
|
+
|
|
1265
|
+
Every op, route and task is `write` unless you say otherwise: the owner and editing members only.
|
|
1266
|
+
|
|
1267
|
+
```json
|
|
1268
|
+
"ops": {
|
|
1269
|
+
"list-public-articles": { "access": "public" },
|
|
1270
|
+
"raise-ticket": { "access": "public", "requires": "account" },
|
|
1271
|
+
"my-tickets": { "access": "public", "requires": "account" },
|
|
1272
|
+
"close-ticket": {}
|
|
1273
|
+
},
|
|
1274
|
+
"routes": { "queue-stream": { "access": "read" } },
|
|
1275
|
+
"kickableTasks": { "escalate": { "access": "write" } }
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
| level | who reaches it |
|
|
1279
|
+
|---|---|
|
|
1280
|
+
| `write` (default) | the owner and members who may edit |
|
|
1281
|
+
| `read` | anyone who can SEE the workspace, including read-only members |
|
|
1282
|
+
| `public` | anyone holding the app's share link, with no workspace access at all |
|
|
1283
|
+
| `public` + `requires: "account"` | the same, but they must be signed in |
|
|
1284
|
+
|
|
1285
|
+
**`read` is not "outsiders".** It means workspace members. Someone on a share link is reached with
|
|
1286
|
+
`public`. Getting this wrong is the commonest mistake: an app that declares "my own records" as
|
|
1287
|
+
`read` refuses the very people it was written for.
|
|
1288
|
+
|
|
1289
|
+
**`requires: "account"` is the sign-in wall.** Without it an anonymous caller gets `forbidden`,
|
|
1290
|
+
which a UI can only render as an error. With it they get `login-required` and a way back, and
|
|
1291
|
+
`useSignInWall()` turns it into a wall:
|
|
1292
|
+
|
|
1293
|
+
```tsx
|
|
1294
|
+
const wall = useSignInWall();
|
|
1295
|
+
try { await op("raise-ticket", args); }
|
|
1296
|
+
catch (e) { wall.raise(e); } // login-required → the wall; anything else rethrows
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
Every `public` door is listed on the install card, by name, before anyone installs your app.
|
|
1300
|
+
|
|
1301
|
+
## What a refusal looks like
|
|
1302
|
+
|
|
1303
|
+
One shape everywhere, with a code your UI can act on:
|
|
1304
|
+
|
|
1305
|
+
| code | status | means |
|
|
1306
|
+
|---|---|---|
|
|
1307
|
+
| `forbidden` | 403 | the rules say no, and signing in would not change it |
|
|
1308
|
+
| `login-required` | 401 | sign in and try again; `signInPath` says where |
|
|
1309
|
+
| `not-bound` | 409 | a slot this app needs is not filled yet |
|
|
1310
|
+
| `rate-limited` | 429 | too many calls at a public door |
|
|
1311
|
+
| `invalid` | 400 | the call itself is malformed |
|
|
1312
|
+
|
|
1313
|
+
In a workbench the refusal also carries `detail`, written for YOU: which rule refused and what to
|
|
1314
|
+
change. It is never returned in production, and never contains the caller's data.
|
|
1315
|
+
|
|
1316
|
+
## Seeing it before anyone installs
|
|
1317
|
+
|
|
1318
|
+
A workbench has **VIEW AS**: a switcher for the platform's kinds — owner, member, read-only
|
|
1319
|
+
member, two different visitors, anonymous, agent. Your app resolves each one through your own
|
|
1320
|
+
`roles`. Two visitors on purpose: the question worth asking is not "does a person see their own
|
|
1321
|
+
record" but "does the OTHER one see it".
|
|
1322
|
+
|
|
1323
|
+
`look_at_app` takes the same personas, so you can photograph the screen each of them gets — the
|
|
1324
|
+
one screen an author can otherwise never see, because the author is always the owner.
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
==============================================================================
|
|
1329
|
+
# 14 · Your own tables
|
|
1330
|
+
|
|
1331
|
+
The fold (docs/03) is for what is shared, small and worth scrubbing. Records that belong to one
|
|
1332
|
+
person, that grow without limit, and that must never revert because somebody scrubbed a timeline
|
|
1333
|
+
are none of those. Those go in your app's own tables.
|
|
1334
|
+
|
|
1335
|
+
You declare them. The platform generates the schema, the typings and the rules, applies the
|
|
1336
|
+
migration on install, and scopes every query. You never write a migration, an ORM, or a
|
|
1337
|
+
`WHERE userId = …`.
|
|
1338
|
+
|
|
1339
|
+
## Declaring
|
|
1340
|
+
|
|
1341
|
+
```json
|
|
1342
|
+
"db": {
|
|
1343
|
+
"Article": {
|
|
1344
|
+
"fields": { "title": "string", "body": "text", "published": "boolean=true" },
|
|
1345
|
+
"unique": [["title"]],
|
|
1346
|
+
"indexes": [["published"]],
|
|
1347
|
+
"rules": { "read": ["anyone"], "write": ["supervisor"] }
|
|
1348
|
+
},
|
|
1349
|
+
"Ticket": {
|
|
1350
|
+
"owner": "creator",
|
|
1351
|
+
"fields": { "status": "string=open", "subject": "string", "detail": "text", "article": "ref:Article?" },
|
|
1352
|
+
"indexes": [["status"], ["createdAt"]],
|
|
1353
|
+
"rules": {
|
|
1354
|
+
"read": ["creator", "agent", "supervisor"],
|
|
1355
|
+
"create": { "roles": ["requester", "agent"], "requires": "account" },
|
|
1356
|
+
"update": { "roles": ["agent", "supervisor"], "creatorMay": ["detail"] },
|
|
1357
|
+
"delete": ["supervisor"]
|
|
1358
|
+
}
|
|
1359
|
+
},
|
|
1360
|
+
"ContactDetails": {
|
|
1361
|
+
"scope": "user",
|
|
1362
|
+
"sealed": ["phone"],
|
|
1363
|
+
"fields": { "label": "string", "phone": "string" }
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
```
|
|
1367
|
+
|
|
1368
|
+
**Field types.** `string`, `text`, `int`, `float`, `boolean`, `datetime`, `json`, `ref:Model`.
|
|
1369
|
+
A trailing `?` is optional; `=value` is a default; `[]` is a list. Platform columns (`id`,
|
|
1370
|
+
`workspaceId`, `nodeId`, `ownerId`, `createdBy`, `createdAt`, `updatedAt`, `deletedAt`) are added
|
|
1371
|
+
for you and may not be declared.
|
|
1372
|
+
|
|
1373
|
+
**Scope** decides what "this app's rows" means:
|
|
1374
|
+
|
|
1375
|
+
| scope | rows belong to | use it for |
|
|
1376
|
+
|---|---|---|
|
|
1377
|
+
| `instance` (default) | ONE app instance | this help desk's tickets |
|
|
1378
|
+
| `workspace` | every instance in the workspace | something shared between apps |
|
|
1379
|
+
| `user` | one person, across every instance | contact details that follow them |
|
|
1380
|
+
|
|
1381
|
+
**`owner: "creator"`** makes each row belong to whoever made it, which is what `creator` in a rule
|
|
1382
|
+
matches against. It matches every id that person has acted under, so a record created before
|
|
1383
|
+
signing in is still theirs afterwards.
|
|
1384
|
+
|
|
1385
|
+
**`sealed`** fields are encrypted at rest and readable only by the row's owner (and your app's own
|
|
1386
|
+
internal code, which is how a background job can still use one). Anyone else reading a sealed
|
|
1387
|
+
field gets null, not ciphertext.
|
|
1388
|
+
|
|
1389
|
+
## Rules
|
|
1390
|
+
|
|
1391
|
+
A rule names PRINCIPALS: `"anyone"`, `"creator"`, a role from your vocabulary, or `"apps"` (apps
|
|
1392
|
+
bound to yours — see docs/16). The long form adds `requires: "account"` and `creatorMay`, which
|
|
1393
|
+
lists the fields a row's creator may change even when they may not otherwise update it.
|
|
1394
|
+
|
|
1395
|
+
Rules are compiled once, at build time, and the SAME compiled artefact drives the in-memory client
|
|
1396
|
+
your tests use, the Prisma client production uses, and the workbench. A rule that names a role
|
|
1397
|
+
your app does not declare fails the build, with the key path.
|
|
1398
|
+
|
|
1399
|
+
## Using
|
|
1400
|
+
|
|
1401
|
+
```ts
|
|
1402
|
+
import { pluginDb } from "esoul-sdk/server";
|
|
1403
|
+
import type { HelpdeskDb } from "./.esoul/db"; // generated from your manifest
|
|
1404
|
+
|
|
1405
|
+
async function myTickets(ctx: PluginOpContext) {
|
|
1406
|
+
const db = await pluginDb<HelpdeskDb>(ctx);
|
|
1407
|
+
return db.ticket.findMany({ orderBy: { createdAt: "desc" }, take: 50 });
|
|
1408
|
+
}
|
|
1409
|
+
```
|
|
1410
|
+
|
|
1411
|
+
That is the whole access story. The same call returns one person's own tickets and an agent's
|
|
1412
|
+
whole queue, because `ctx.viewer` is already in the client. There is no argument you could pass to
|
|
1413
|
+
read someone else's rows: `workspaceId`, `nodeId` and `ownerId` are injected, and naming one in a
|
|
1414
|
+
query is refused as `invalid` rather than quietly overridden.
|
|
1415
|
+
|
|
1416
|
+
Ordering and filtering are limited to the fields you indexed — an unindexed `orderBy` is a compile
|
|
1417
|
+
error in your editor, not a slow query in production.
|
|
1418
|
+
|
|
1419
|
+
## Migrations
|
|
1420
|
+
|
|
1421
|
+
On install the platform computes what your declaration means for the live database and applies it
|
|
1422
|
+
in one transaction, recording it in a ledger. **Additive only.** Adding a table, a column or an
|
|
1423
|
+
index is applied; changing a column's type, or adding a required column with no default, is
|
|
1424
|
+
REFUSED with the column named, and the install does not proceed. A field you removed keeps its
|
|
1425
|
+
column and its data — dropping it is a separate, deliberate act.
|
|
1426
|
+
|
|
1427
|
+
Uninstalling never drops a table. The rows are somebody's records.
|
|
1428
|
+
|
|
1429
|
+
## Testing
|
|
1430
|
+
|
|
1431
|
+
```ts
|
|
1432
|
+
import { fakeViewer, memoryDb, runOp } from "esoul-sdk/testing";
|
|
1433
|
+
|
|
1434
|
+
const db = memoryDb(manifest); // your rules, compiled from your own plugin.json
|
|
1435
|
+
const ada = fakeViewer("visitor", { userId: "u_ada", role: "requester" });
|
|
1436
|
+
const lin = fakeViewer("visitor", { userId: "u_lin", role: "requester" });
|
|
1437
|
+
|
|
1438
|
+
await runOp(pluginServer, "raise-ticket", { viewer: ada, args: TICKET, db: db.as(ada) });
|
|
1439
|
+
expect(await db.as(lin).ticket.count()).toBe(0);
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
The in-memory client and the production one are proven equal by a differential test on every
|
|
1443
|
+
build, so a rule you prove here is a rule the database keeps.
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
==============================================================================
|
|
1448
|
+
# 15 · Realtime, and who hears it
|
|
1449
|
+
|
|
1450
|
+
Your app can push. A screen that is already open moves without anyone asking it to, which is the
|
|
1451
|
+
difference between an application and a form.
|
|
1452
|
+
|
|
1453
|
+
Two declarations, both needed. The manifest says WHO hears each topic; the schema says what the
|
|
1454
|
+
topics are and what rides on them.
|
|
1455
|
+
|
|
1456
|
+
```json
|
|
1457
|
+
"channel": {
|
|
1458
|
+
"topics": {
|
|
1459
|
+
"notice": { "description": "something everyone watching should see" },
|
|
1460
|
+
"ticket-status": { "audience": "viewer", "mayAddress": ["agent", "supervisor"] },
|
|
1461
|
+
"new-ticket": { "audience": "role:agent", "mayAddress": ["requester", "agent", "supervisor"] }
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
```
|
|
1465
|
+
|
|
1466
|
+
```ts
|
|
1467
|
+
export const channel = definePluginChannel({
|
|
1468
|
+
applicationType: APP_TYPE,
|
|
1469
|
+
topics: {
|
|
1470
|
+
notice: { schema: z.object({ what: z.string() }) },
|
|
1471
|
+
"ticket-status": { schema: z.object({ ticketId: z.string(), status: z.string() }) },
|
|
1472
|
+
"new-ticket": { schema: z.object({ ticketId: z.string(), subject: z.string() }) },
|
|
1473
|
+
},
|
|
1474
|
+
});
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
## Audiences
|
|
1478
|
+
|
|
1479
|
+
| `audience` | the channel |
|
|
1480
|
+
|---|---|
|
|
1481
|
+
| `all` (default) | one per instance — anyone watching the app |
|
|
1482
|
+
| `viewer` | one per person — "your ticket was answered" |
|
|
1483
|
+
| `role:<name>` | one per role — the queue everyone on duty watches |
|
|
1484
|
+
|
|
1485
|
+
The platform mints a token PER CHANNEL, and the browser asks for a KIND of channel, never for an
|
|
1486
|
+
identifier. The server fills in whose channel that is from the viewer it resolved. So one person
|
|
1487
|
+
cannot ask for another's channel: the id is not theirs to supply, and a role's channel is simply
|
|
1488
|
+
not minted for someone outside that role. Nothing is filtered on arrival, because nothing wrong
|
|
1489
|
+
ever arrives.
|
|
1490
|
+
|
|
1491
|
+
An app that declares no audiences keeps the single channel it always had.
|
|
1492
|
+
|
|
1493
|
+
## Sending
|
|
1494
|
+
|
|
1495
|
+
```ts
|
|
1496
|
+
await ctx.notify("ticket-status", { ticketId, status }); // the CALLER's own channel
|
|
1497
|
+
await ctx.notify("ticket-status", { ticketId, status }, { to: { viewerIds: [row.ownerId] } });
|
|
1498
|
+
await ctx.notify("new-ticket", { ticketId, subject }, { to: { role: "agent" } });
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
Unaddressed, a message goes where the topic says. Addressed, **aiming is a separate permission
|
|
1502
|
+
from hearing**: a caller may address only ITSELF unless the topic's `mayAddress` names its role.
|
|
1503
|
+
Your app's own tasks always may, which is why a follow-up task can tell the person who waited.
|
|
1504
|
+
|
|
1505
|
+
Two traps worth naming. Both cost the reference app a live drive, and both are easy to repeat:
|
|
1506
|
+
|
|
1507
|
+
- **Everyone who may DO the thing must be allowed to announce it.** An app that let two roles
|
|
1508
|
+
create a record but only one of them announce it refused the others their own action — by their
|
|
1509
|
+
own announcement. List every role your `create` rule allows.
|
|
1510
|
+
- **A courtesy must not undo a completed write.** The record is already in the table when you
|
|
1511
|
+
announce it. Catch a failed notify and report it; do not let it throw out of the op, or a person
|
|
1512
|
+
is told "forbidden" about something that exists and tries again.
|
|
1513
|
+
|
|
1514
|
+
## Listening
|
|
1515
|
+
|
|
1516
|
+
```tsx
|
|
1517
|
+
const live = usePluginRealtime<{ ticketId?: string; status?: string }>({
|
|
1518
|
+
channel,
|
|
1519
|
+
workspaceId: state.workspaceId,
|
|
1520
|
+
nodeId: state.nodeId,
|
|
1521
|
+
topics: channel.topicNames,
|
|
1522
|
+
enabled: !!state.nodeId && viewer.signedIn,
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
useEffect(() => {
|
|
1526
|
+
if (live.latestData?.topic === "ticket-status") reload();
|
|
1527
|
+
}, [live.latestData, reload]);
|
|
1528
|
+
```
|
|
1529
|
+
|
|
1530
|
+
**Treat a message as a nudge, not as data.** Re-read through your op, so the rules decide what
|
|
1531
|
+
comes back. A payload that carried the row would be a second way to learn about it, and only one
|
|
1532
|
+
of them is checked.
|
|
1533
|
+
|
|
1534
|
+
In a workbench the preview shows a persona only what their channels would carry, so VIEW AS tells
|
|
1535
|
+
you the truth: switch to the other visitor and the message is not there.
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
==============================================================================
|
|
1540
|
+
# 16 · Bindings — leaning on another app
|
|
1541
|
+
|
|
1542
|
+
An app is an island until it can use another one. A booking app that wants real room availability
|
|
1543
|
+
either keeps its own counts and guesses, or names one particular rooms app and is married to it.
|
|
1544
|
+
Neither is what a person means by "use my room list for this".
|
|
1545
|
+
|
|
1546
|
+
So you declare a SLOT and the CONTRACT that must stand in it. The owner picks which app fills it.
|
|
1547
|
+
|
|
1548
|
+
## Declaring
|
|
1549
|
+
|
|
1550
|
+
The consumer:
|
|
1551
|
+
|
|
1552
|
+
```json
|
|
1553
|
+
"uses": { "rooms": { "contract": "rooms/v1", "label": "Rooms", "optional": true } }
|
|
1554
|
+
```
|
|
1555
|
+
|
|
1556
|
+
The provider:
|
|
1557
|
+
|
|
1558
|
+
```json
|
|
1559
|
+
"provides": {
|
|
1560
|
+
"rooms/v1": { "tools": ["hold_room", "release_room"], "events": ["room_freed"], "models": ["Room"] }
|
|
1561
|
+
}
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
A consumer must also fold the binding event, because the owner's choice lives on your app's own
|
|
1565
|
+
timeline — a binding is CONSENT, and consent that is only a column cannot be scrubbed, explained
|
|
1566
|
+
or audited:
|
|
1567
|
+
|
|
1568
|
+
```ts
|
|
1569
|
+
export const bindingSetEvent = defineBindingEvent<BookingData>({ applicationType: APP_TYPE, slots: ["rooms"] });
|
|
1570
|
+
// …and put it in the schema's `events`.
|
|
1571
|
+
```
|
|
1572
|
+
|
|
1573
|
+
The build refuses an app that declares `uses` without it.
|
|
1574
|
+
|
|
1575
|
+
## What the platform checks, and when
|
|
1576
|
+
|
|
1577
|
+
At BIND time, once, loudly — never at call time in front of somebody using the app:
|
|
1578
|
+
|
|
1579
|
+
- The provider must claim the same contract NAME at a version at least as high. **Versions grow by
|
|
1580
|
+
adding**, so a `rooms/v2` provider fills a `rooms/v1` slot; the reverse is refused, because v1
|
|
1581
|
+
never heard of what v2 added. That rule is what lets your app keep working while its provider
|
|
1582
|
+
moves on.
|
|
1583
|
+
- The provider must really HAVE what it claims: the tools it actually mints, the events its schema
|
|
1584
|
+
declares, the tables its `db` generates. A manifest is a claim, and a claim that is short is
|
|
1585
|
+
refused with the missing part named.
|
|
1586
|
+
|
|
1587
|
+
## Using
|
|
1588
|
+
|
|
1589
|
+
```ts
|
|
1590
|
+
const rooms = ctx.apps?.rooms; // absent when nothing is bound
|
|
1591
|
+
if (rooms) {
|
|
1592
|
+
const held = await rooms.call("hold_room", { roomId, from, to });
|
|
1593
|
+
if (!held.ok) throw new Error(`cannot take this booking: ${held.text}`);
|
|
1594
|
+
}
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
- **An unfilled slot is ABSENT**, not present and broken, so `ctx.apps.rooms?` reads as the
|
|
1598
|
+
question it is. An optional slot means your app still works without one; a required slot that is
|
|
1599
|
+
empty refuses at the seam with `not-bound`, which a UI should render as "connect one" rather
|
|
1600
|
+
than as an error.
|
|
1601
|
+
- **A slot reaches the CONTRACT's tools, not the provider's toolkit.** A consumer bound through
|
|
1602
|
+
`rooms/v1` may hold a room and release it; it may not call the rooms app's `retire_room`, though
|
|
1603
|
+
it has one. The binding is consent to a contract.
|
|
1604
|
+
- **The caller travels.** The provider's rules meet the actual person, so a visitor reaching the
|
|
1605
|
+
rooms app through your app sees what a visitor may see.
|
|
1606
|
+
- **Writes never cross directly.** The provider's own tools and ops are the only writers of its
|
|
1607
|
+
tables, which is how a ledger keeps refusing to oversell no matter who asked.
|
|
1608
|
+
|
|
1609
|
+
## Being a good provider
|
|
1610
|
+
|
|
1611
|
+
A provider's whole value is usually one refusal: you cannot hold what is not free. Keep it in ONE
|
|
1612
|
+
place — the app's own server — so it holds however the hold was asked for: through a binding, a
|
|
1613
|
+
tool, or a person working in the app itself. That is what makes an app worth binding to. An app
|
|
1614
|
+
that counted the same thing in its own fold could not make the promise, because two people can
|
|
1615
|
+
read the same fold in the same instant.
|
|
1616
|
+
|
|
1617
|
+
Say no in your own words, too. A refusal that reads `cannot hold 2: only 1 free (1 free, 1 already
|
|
1618
|
+
held)` reaches the consumer, and the consumer can put it in front of a person instead of a shrug.
|