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
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/* ───────────────────────────── naming ─────────────────────────────────── */
|
|
2
|
+
/** `ShipAddress` → `ship_address`; `Order` → `order`. */
|
|
3
|
+
export function snakeCase(name) {
|
|
4
|
+
return name
|
|
5
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
6
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
/** The Prisma model name: `plugin_shop_min__Order`. */
|
|
10
|
+
export function prismaModelName(applicationType, model) {
|
|
11
|
+
return `${applicationType}__${model}`;
|
|
12
|
+
}
|
|
13
|
+
/** The table it maps to: `plugin_shop_min__order`. */
|
|
14
|
+
export function prismaTableName(applicationType, model) {
|
|
15
|
+
return `${applicationType}__${snakeCase(model)}`;
|
|
16
|
+
}
|
|
17
|
+
/* ───────────────────────── the Prisma fragment ────────────────────────── */
|
|
18
|
+
const PRISMA_SCALAR = {
|
|
19
|
+
string: "String",
|
|
20
|
+
text: "String",
|
|
21
|
+
int: "Int",
|
|
22
|
+
float: "Float",
|
|
23
|
+
boolean: "Boolean",
|
|
24
|
+
datetime: "DateTime",
|
|
25
|
+
json: "Json",
|
|
26
|
+
ref: "String",
|
|
27
|
+
};
|
|
28
|
+
function prismaDefault(field) {
|
|
29
|
+
if (field.default === undefined || field.list)
|
|
30
|
+
return null;
|
|
31
|
+
switch (field.type) {
|
|
32
|
+
case "string":
|
|
33
|
+
case "text":
|
|
34
|
+
return `@default(${JSON.stringify(String(field.default))})`;
|
|
35
|
+
case "int":
|
|
36
|
+
case "float":
|
|
37
|
+
return `@default(${Number(field.default)})`;
|
|
38
|
+
case "boolean":
|
|
39
|
+
return `@default(${field.default ? "true" : "false"})`;
|
|
40
|
+
default:
|
|
41
|
+
// A default on json/datetime/ref has no Prisma spelling worth guessing;
|
|
42
|
+
// the client applies it on create, which is where it matters.
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function platformColumns(scope) {
|
|
47
|
+
return [
|
|
48
|
+
{ name: "id", type: "String", attrs: "@id @default(uuid())" },
|
|
49
|
+
{ name: "workspaceId", type: "String", attrs: "" },
|
|
50
|
+
// A user-scoped row belongs to an account, not an instance; the memory
|
|
51
|
+
// client stores null there and the database must accept the same row.
|
|
52
|
+
{ name: "nodeId", type: scope === "user" ? "String?" : "String", attrs: "" },
|
|
53
|
+
{ name: "ownerId", type: "String?", attrs: "" },
|
|
54
|
+
{ name: "createdBy", type: "Json?", attrs: "" },
|
|
55
|
+
{ name: "createdAt", type: "DateTime", attrs: "@default(now())" },
|
|
56
|
+
{ name: "updatedAt", type: "DateTime", attrs: "@updatedAt" },
|
|
57
|
+
{ name: "deletedAt", type: "DateTime?", attrs: "" },
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
function declaredColumn(name, field, sealed) {
|
|
61
|
+
const base = sealed ? "String" : PRISMA_SCALAR[field.type];
|
|
62
|
+
// Prisma scalar lists are never optional and carry no scalar default.
|
|
63
|
+
const type = field.list ? `${base}[]` : field.optional ? `${base}?` : base;
|
|
64
|
+
const def = sealed ? null : prismaDefault(field);
|
|
65
|
+
return { name, type, attrs: def ?? "" };
|
|
66
|
+
}
|
|
67
|
+
function scopeColumn(scope) {
|
|
68
|
+
return scope === "user" ? "ownerId" : scope === "workspace" ? "workspaceId" : "nodeId";
|
|
69
|
+
}
|
|
70
|
+
function indexGroups(model) {
|
|
71
|
+
const lead = scopeColumn(model.scope);
|
|
72
|
+
const out = [];
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const push = (cols) => {
|
|
75
|
+
const key = cols.join(",");
|
|
76
|
+
if (seen.has(key))
|
|
77
|
+
return;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
out.push(cols);
|
|
80
|
+
};
|
|
81
|
+
// The scope index every query starts from.
|
|
82
|
+
push(model.scope === "instance" ? ["workspaceId", "nodeId"] : [lead]);
|
|
83
|
+
if (model.ownedByCreator && model.scope !== "user")
|
|
84
|
+
push(["ownerId"]);
|
|
85
|
+
// Declared groups, `unique` first then `indexes`, each led by the scope
|
|
86
|
+
// column. A group that already names the lead column is not doubled.
|
|
87
|
+
for (const group of [...model.unique, ...model.indexes]) {
|
|
88
|
+
push([lead, ...group.filter((c) => c !== lead)]);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function renderModel(model, applicationType) {
|
|
93
|
+
const cols = [
|
|
94
|
+
...platformColumns(model.scope),
|
|
95
|
+
...Object.keys(model.fields).map((name) => declaredColumn(name, model.fields[name], model.sealed.includes(name))),
|
|
96
|
+
];
|
|
97
|
+
const nameW = Math.max(...cols.map((c) => c.name.length));
|
|
98
|
+
const typeW = Math.max(...cols.map((c) => c.type.length));
|
|
99
|
+
const lines = cols.map((c) => {
|
|
100
|
+
const head = ` ${c.name.padEnd(nameW)} ${c.type.padEnd(typeW)}`;
|
|
101
|
+
return (c.attrs ? `${head} ${c.attrs}` : head).trimEnd();
|
|
102
|
+
});
|
|
103
|
+
const idx = indexGroups(model).map((g) => ` @@index([${g.join(", ")}])`);
|
|
104
|
+
return [
|
|
105
|
+
`model ${prismaModelName(applicationType, model.name)} {`,
|
|
106
|
+
...lines,
|
|
107
|
+
"",
|
|
108
|
+
...idx,
|
|
109
|
+
` @@map(${JSON.stringify(prismaTableName(applicationType, model.name))})`,
|
|
110
|
+
"}",
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The Prisma schema fragment for one app — every model, platform columns
|
|
115
|
+
* first, scope-led indexes, mapped to a prefixed table. No datasource and no
|
|
116
|
+
* generator: this is a FRAGMENT that joins the platform's schema folder.
|
|
117
|
+
*/
|
|
118
|
+
export function prismaFragment(rules, opts) {
|
|
119
|
+
const models = Object.keys(rules.models).map((name) => rules.models[name]);
|
|
120
|
+
const body = models.map((m) => renderModel(m, opts.applicationType)).join("\n\n");
|
|
121
|
+
return [
|
|
122
|
+
`/// AUTO-GENERATED from plugin "${rules.pluginId}" (plugin.json \`db\`) — DO NOT EDIT.`,
|
|
123
|
+
`/// Regenerated by \`yarn plugins:sync\`. Tables are prefixed \`${opts.applicationType}__\`;`,
|
|
124
|
+
"/// every row carries the platform's scope columns; indexes lead with the scope column;",
|
|
125
|
+
"/// declared `unique` groups are plain indexes (uniqueness is enforced among live rows by the client).",
|
|
126
|
+
"",
|
|
127
|
+
body,
|
|
128
|
+
"",
|
|
129
|
+
].join("\n");
|
|
130
|
+
}
|
|
131
|
+
/* ─────────────────────────── the typings ──────────────────────────────── */
|
|
132
|
+
const TS_SCALAR = {
|
|
133
|
+
string: "string",
|
|
134
|
+
text: "string",
|
|
135
|
+
int: "number",
|
|
136
|
+
float: "number",
|
|
137
|
+
boolean: "boolean",
|
|
138
|
+
datetime: "Date",
|
|
139
|
+
json: "unknown",
|
|
140
|
+
ref: "string",
|
|
141
|
+
};
|
|
142
|
+
function rowFieldType(field) {
|
|
143
|
+
const base = TS_SCALAR[field.type];
|
|
144
|
+
if (field.list)
|
|
145
|
+
return `${base}[]`;
|
|
146
|
+
return field.optional ? `${base} | null` : base;
|
|
147
|
+
}
|
|
148
|
+
function createFieldType(field) {
|
|
149
|
+
const base = TS_SCALAR[field.type];
|
|
150
|
+
if (field.list)
|
|
151
|
+
return `${base}[]`;
|
|
152
|
+
return field.optional ? `${base} | null` : base;
|
|
153
|
+
}
|
|
154
|
+
function quoteKey(name) {
|
|
155
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
156
|
+
}
|
|
157
|
+
function renderRows(model) {
|
|
158
|
+
const platform = [
|
|
159
|
+
" id: string;",
|
|
160
|
+
" workspaceId: string;",
|
|
161
|
+
` nodeId: ${model.scope === "user" ? "string | null" : "string"};`,
|
|
162
|
+
" ownerId: string | null;",
|
|
163
|
+
" createdAt: Date;",
|
|
164
|
+
" updatedAt: Date;",
|
|
165
|
+
];
|
|
166
|
+
const declared = Object.keys(model.fields).map((n) => ` ${quoteKey(n)}: ${rowFieldType(model.fields[n])};`);
|
|
167
|
+
return [`export interface ${model.name}Row {`, ...platform, ...declared, "}"].join("\n");
|
|
168
|
+
}
|
|
169
|
+
function renderCreate(model) {
|
|
170
|
+
const declared = Object.keys(model.fields).map((n) => {
|
|
171
|
+
const f = model.fields[n];
|
|
172
|
+
const optional = f.optional || f.default !== undefined || f.list;
|
|
173
|
+
return ` ${quoteKey(n)}${optional ? "?" : ""}: ${createFieldType(f)};`;
|
|
174
|
+
});
|
|
175
|
+
return [`export interface ${model.name}Create {`, ...declared, "}"].join("\n");
|
|
176
|
+
}
|
|
177
|
+
function renderFilterable(model) {
|
|
178
|
+
const keys = model.filterable.map((k) => JSON.stringify(k)).join(" | ");
|
|
179
|
+
return `export type ${model.name}Filterable = ${keys || "never"};`;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The `.esoul/db.d.ts` for one app: a `<Model>Row` and `<Model>Create` per
|
|
183
|
+
* model, a `<Model>Filterable` union that is exactly the compiled `filterable`
|
|
184
|
+
* list (so `where` and `orderBy` on an unindexed field are compile errors —
|
|
185
|
+
* the runtime rule, landed in the editor), and one `Collection` shape mirroring
|
|
186
|
+
* the memory client's method surface. Everything an author calls is typed with
|
|
187
|
+
* their own names; nothing here knows the table prefix.
|
|
188
|
+
*/
|
|
189
|
+
export function dbTypings(rules, opts) {
|
|
190
|
+
const models = Object.keys(rules.models).map((name) => rules.models[name]);
|
|
191
|
+
const parts = [
|
|
192
|
+
`/** AUTO-GENERATED from plugin "${rules.pluginId}" (plugin.json \`db\`) — DO NOT EDIT. Regenerated by \`yarn plugins:sync\`. */`,
|
|
193
|
+
"",
|
|
194
|
+
"export type Scalar = string | number | boolean | Date | null;",
|
|
195
|
+
"export type Filter<V> =",
|
|
196
|
+
" | V",
|
|
197
|
+
" | { in: V[] }",
|
|
198
|
+
" | { notIn: V[] }",
|
|
199
|
+
" | { not: V }",
|
|
200
|
+
" | { gt: V }",
|
|
201
|
+
" | { gte: V }",
|
|
202
|
+
" | { lt: V }",
|
|
203
|
+
" | { lte: V }",
|
|
204
|
+
" | { contains: string };",
|
|
205
|
+
"/** Only INDEXED fields may be filtered — the generator emits the union per model. */",
|
|
206
|
+
"export type Where<T, K extends keyof T> = { [P in K]?: T[P] extends Scalar ? Filter<T[P]> : never };",
|
|
207
|
+
"export type OrderBy<K extends string> = { [P in K]?: \"asc\" | \"desc\" };",
|
|
208
|
+
"",
|
|
209
|
+
"export interface FindManyArgs<T, K extends keyof T & string> {",
|
|
210
|
+
" where?: Where<T, K>;",
|
|
211
|
+
" orderBy?: OrderBy<K> | OrderBy<K>[];",
|
|
212
|
+
" take?: number;",
|
|
213
|
+
" skip?: number;",
|
|
214
|
+
" cursor?: { id: string };",
|
|
215
|
+
" includeDeleted?: boolean;",
|
|
216
|
+
"}",
|
|
217
|
+
"export interface AggregateArgs<T, K extends keyof T & string> {",
|
|
218
|
+
" where?: Where<T, K>;",
|
|
219
|
+
" _count?: true;",
|
|
220
|
+
" _sum?: Partial<Record<keyof T & string, true>>;",
|
|
221
|
+
" _avg?: Partial<Record<keyof T & string, true>>;",
|
|
222
|
+
" _min?: Partial<Record<keyof T & string, true>>;",
|
|
223
|
+
" _max?: Partial<Record<keyof T & string, true>>;",
|
|
224
|
+
"}",
|
|
225
|
+
"export interface GroupByArgs<T, K extends keyof T & string> extends AggregateArgs<T, K> {",
|
|
226
|
+
" by: K[];",
|
|
227
|
+
"}",
|
|
228
|
+
"",
|
|
229
|
+
"export interface Collection<Row, Create, K extends keyof Row & string> {",
|
|
230
|
+
" findMany(args?: FindManyArgs<Row, K>): Promise<Row[]>;",
|
|
231
|
+
" findFirst(args?: FindManyArgs<Row, K>): Promise<Row | null>;",
|
|
232
|
+
" findUnique(args: { where: { id: string }; includeDeleted?: boolean }): Promise<Row | null>;",
|
|
233
|
+
" count(args?: { where?: Where<Row, K>; includeDeleted?: boolean }): Promise<number>;",
|
|
234
|
+
" aggregate(args?: AggregateArgs<Row, K>): Promise<Record<string, unknown>>;",
|
|
235
|
+
" groupBy(args: GroupByArgs<Row, K>): Promise<Record<string, unknown>[]>;",
|
|
236
|
+
" create(args: { data: Create }): Promise<Row>;",
|
|
237
|
+
" createMany(args: { data: Create[] }): Promise<{ count: number }>;",
|
|
238
|
+
" update(args: { where: { id: string }; data: Partial<Create> }): Promise<Row>;",
|
|
239
|
+
" updateMany(args: { where?: Where<Row, K>; data: Partial<Create> }): Promise<{ count: number }>;",
|
|
240
|
+
" upsert(args: { where: { id: string }; create: Create; update: Partial<Create> }): Promise<Row>;",
|
|
241
|
+
" delete(args: { where: { id: string } }): Promise<Row>;",
|
|
242
|
+
" deleteMany(args?: { where?: Where<Row, K> }): Promise<{ count: number }>;",
|
|
243
|
+
"}",
|
|
244
|
+
"",
|
|
245
|
+
];
|
|
246
|
+
for (const m of models) {
|
|
247
|
+
parts.push(renderRows(m), renderCreate(m), renderFilterable(m), "");
|
|
248
|
+
}
|
|
249
|
+
parts.push(`export interface ${opts.typeName} {`);
|
|
250
|
+
for (const m of models) {
|
|
251
|
+
parts.push(` ${quoteKey(m.key)}: Collection<${m.name}Row, ${m.name}Create, ${m.name}Filterable>;`);
|
|
252
|
+
}
|
|
253
|
+
parts.push(` $transaction<T>(fn: (tx: ${opts.typeName}) => Promise<T>): Promise<T>;`, "}", "");
|
|
254
|
+
return parts.join("\n");
|
|
255
|
+
}
|
|
256
|
+
/** The compiled artefact as the bytes `.esoul/rules.json` holds. */
|
|
257
|
+
export function rulesJson(rules) {
|
|
258
|
+
return JSON.stringify(rules, null, 2) + "\n";
|
|
259
|
+
}
|
|
260
|
+
const SQL_OF_PRISMA = {
|
|
261
|
+
String: "TEXT",
|
|
262
|
+
Int: "INTEGER",
|
|
263
|
+
Float: "DOUBLE PRECISION",
|
|
264
|
+
Boolean: "BOOLEAN",
|
|
265
|
+
DateTime: "TIMESTAMP(3)",
|
|
266
|
+
Json: "JSONB",
|
|
267
|
+
};
|
|
268
|
+
function sqlColumn(c) {
|
|
269
|
+
const list = c.type.endsWith("[]");
|
|
270
|
+
const optional = c.type.endsWith("?");
|
|
271
|
+
const base = c.type.replace(/\[\]$|\?$/, "");
|
|
272
|
+
const sql = SQL_OF_PRISMA[base];
|
|
273
|
+
if (!sql)
|
|
274
|
+
throw new Error(`[schema-gen] no SQL type for Prisma ${base}`);
|
|
275
|
+
let def = null;
|
|
276
|
+
const m = /@default\((.*)\)/.exec(c.attrs);
|
|
277
|
+
if (m) {
|
|
278
|
+
const raw = m[1];
|
|
279
|
+
if (raw === "now()")
|
|
280
|
+
def = "CURRENT_TIMESTAMP";
|
|
281
|
+
else if (raw === "uuid()")
|
|
282
|
+
def = null; // the client mints ids; Prisma's DDL carries no default either
|
|
283
|
+
else if (/^".*"$/.test(raw))
|
|
284
|
+
def = `'${JSON.parse(raw).replace(/'/g, "''")}'`;
|
|
285
|
+
else
|
|
286
|
+
def = raw; // a number or true/false
|
|
287
|
+
}
|
|
288
|
+
// Prisma emits a scalar list WITHOUT `NOT NULL` (an absent list is an empty
|
|
289
|
+
// one, not a null), so a list column must not carry the constraint either —
|
|
290
|
+
// or the planner would read a difference against the live table forever.
|
|
291
|
+
return { name: c.name, type: list ? `${sql}[]` : sql, nullable: optional || list, default: def };
|
|
292
|
+
}
|
|
293
|
+
/** Prisma's index name: `<table>_<col>_<col>_idx`. */
|
|
294
|
+
export function indexName(table, columns) {
|
|
295
|
+
return `${table}_${columns.join("_")}_idx`;
|
|
296
|
+
}
|
|
297
|
+
/** Every table an app declares, as columns and indexes — the input to the planner and the emitter. */
|
|
298
|
+
export function tableSpecs(rules, opts) {
|
|
299
|
+
return Object.keys(rules.models).map((name) => {
|
|
300
|
+
const model = rules.models[name];
|
|
301
|
+
const table = prismaTableName(opts.applicationType, model.name);
|
|
302
|
+
const cols = [
|
|
303
|
+
...platformColumns(model.scope),
|
|
304
|
+
...Object.keys(model.fields).map((f) => declaredColumn(f, model.fields[f], model.sealed.includes(f))),
|
|
305
|
+
];
|
|
306
|
+
return {
|
|
307
|
+
name: table,
|
|
308
|
+
columns: cols.map(sqlColumn),
|
|
309
|
+
indexes: indexGroups(model).map((g) => ({ name: indexName(table, g), columns: g })),
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const q = (ident) => `"${ident}"`;
|
|
314
|
+
function columnDef(c) {
|
|
315
|
+
return `${q(c.name)} ${c.type}${c.nullable ? "" : " NOT NULL"}${c.default !== null ? ` DEFAULT ${c.default}` : ""}`;
|
|
316
|
+
}
|
|
317
|
+
export function createTableSql(t) {
|
|
318
|
+
return [`CREATE TABLE ${q(t.name)} (`, ...t.columns.map((c) => ` ${columnDef(c)},`), "", ` CONSTRAINT ${q(`${t.name}_pkey`)} PRIMARY KEY ("id")`, ");"].join("\n");
|
|
319
|
+
}
|
|
320
|
+
export function createIndexSql(table, idx) {
|
|
321
|
+
return `CREATE INDEX ${q(idx.name)} ON ${q(table)}(${idx.columns.map(q).join(", ")});`;
|
|
322
|
+
}
|
|
323
|
+
export function addColumnSql(table, c) {
|
|
324
|
+
return `ALTER TABLE ${q(table)} ADD COLUMN ${columnDef(c)};`;
|
|
325
|
+
}
|
|
326
|
+
/** The whole script for a fresh install: tables first, then every index — Prisma's order. */
|
|
327
|
+
export function ddlStatements(specs) {
|
|
328
|
+
return [...specs.map(createTableSql), ...specs.flatMap((t) => t.indexes.map((i) => createIndexSql(t.name, i)))];
|
|
329
|
+
}
|
package/dist/helpers.d.ts
CHANGED
|
@@ -31,10 +31,77 @@ export declare function incompleteStateNotice(args: {
|
|
|
31
31
|
* side rides the session. Throws the op's honest error — relay it, never
|
|
32
32
|
* swallow it.
|
|
33
33
|
*/
|
|
34
|
+
/**
|
|
35
|
+
* The public share this page is being viewed through, or null.
|
|
36
|
+
*
|
|
37
|
+
* A customer browsing a shop is on `/share/<id>` — they have no workspace
|
|
38
|
+
* session at all, so the platform needs the share id to know which door they
|
|
39
|
+
* are standing at before it will let them reach a `public` op or route. Read
|
|
40
|
+
* from the URL rather than plumbed through the app, because the URL is the one
|
|
41
|
+
* thing that is true on every surface without the app having to care.
|
|
42
|
+
*
|
|
43
|
+
* (A share reached through a custom domain or `/u/<handle>` resolves its share
|
|
44
|
+
* server-side and is not in the path; pass `shareId` yourself there.)
|
|
45
|
+
*/
|
|
46
|
+
export declare function currentShareId(): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* VIEW AS, from the browser's side. In a Forge preview the board's switcher
|
|
49
|
+
* writes a persona onto the window; every call the app's UI makes then carries
|
|
50
|
+
* it, so the server sees the same pretend person the screen shows. Absent
|
|
51
|
+
* outside a preview: the platform never reads it, and the header is not sent.
|
|
52
|
+
*/
|
|
53
|
+
export declare function previewPersona(): string | null;
|
|
34
54
|
export declare function callPluginOp<T = unknown>(pluginId: string, op: string, nodeId: string, args?: unknown): Promise<T>;
|
|
55
|
+
/**
|
|
56
|
+
* What a failed op call throws. It CARRIES the platform's refusal code, so a
|
|
57
|
+
* UI can tell `login-required` (raise the sign-in wall) from `forbidden` (an
|
|
58
|
+
* error) — `useSignInWall().raise(err)` reads exactly this. A bare `Error`
|
|
59
|
+
* dropped the code, and the wall never rose (found by the first S6 drive).
|
|
60
|
+
*/
|
|
61
|
+
export declare class PluginCallError extends Error {
|
|
62
|
+
/** HTTP status of the answer. */
|
|
63
|
+
readonly status: number;
|
|
64
|
+
/** `forbidden` | `login-required` | `not-bound` | `rate-limited` | `invalid`, when the platform said which. */
|
|
65
|
+
readonly code?: string;
|
|
66
|
+
/** Where to send the browser to sign in (`login-required` only). */
|
|
67
|
+
readonly signInPath?: string;
|
|
68
|
+
/** The author's explanation — present only in a workbench box. */
|
|
69
|
+
readonly detail?: string;
|
|
70
|
+
constructor(message: string, args: {
|
|
71
|
+
status: number;
|
|
72
|
+
code?: string;
|
|
73
|
+
signInPath?: string;
|
|
74
|
+
detail?: string;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
35
77
|
/**
|
|
36
78
|
* Constant-time string compare for webhook secrets/signatures. A plain
|
|
37
79
|
* `===` leaks length/prefix timing; use THIS in every webhook handler.
|
|
38
80
|
* Pure JS (no node:crypto) so it is safe in any runtime the SDK reaches.
|
|
39
81
|
*/
|
|
40
82
|
export declare function timingSafeEqual(a: string, b: string): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Kick one of your app's background tasks (docs/07) from the browser OR from
|
|
85
|
+
* a tool's `execute` on the server. The task must be listed in the
|
|
86
|
+
* manifest's `kickableTasks`; the platform's send-event route refuses the
|
|
87
|
+
* rest. `data` rides on `ctx.eventData` — keep it small and JSON. The kick
|
|
88
|
+
* is fire-and-forget: the task's result lands as events, or on the channel.
|
|
89
|
+
*/
|
|
90
|
+
export declare function kickPluginTask(args: {
|
|
91
|
+
applicationType: string;
|
|
92
|
+
taskName: string;
|
|
93
|
+
identifier: {
|
|
94
|
+
workspaceId: string;
|
|
95
|
+
nodeId: string;
|
|
96
|
+
instanceName?: string;
|
|
97
|
+
};
|
|
98
|
+
data?: Record<string, unknown>;
|
|
99
|
+
}): Promise<{
|
|
100
|
+
ok: boolean;
|
|
101
|
+
status: number;
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* The URL of one of your server routes (docs/06) for one instance — what an
|
|
105
|
+
* `EventSource` or `fetch` in your UI opens. Same origin; the session rides along.
|
|
106
|
+
*/
|
|
107
|
+
export declare function pluginRouteUrl(pluginId: string, routeName: string, nodeId: string, params?: Record<string, string | number | boolean>): string;
|
package/dist/helpers.js
CHANGED
|
@@ -57,25 +57,92 @@ export function incompleteStateNotice(args) {
|
|
|
57
57
|
* side rides the session. Throws the op's honest error — relay it, never
|
|
58
58
|
* swallow it.
|
|
59
59
|
*/
|
|
60
|
+
/**
|
|
61
|
+
* The public share this page is being viewed through, or null.
|
|
62
|
+
*
|
|
63
|
+
* A customer browsing a shop is on `/share/<id>` — they have no workspace
|
|
64
|
+
* session at all, so the platform needs the share id to know which door they
|
|
65
|
+
* are standing at before it will let them reach a `public` op or route. Read
|
|
66
|
+
* from the URL rather than plumbed through the app, because the URL is the one
|
|
67
|
+
* thing that is true on every surface without the app having to care.
|
|
68
|
+
*
|
|
69
|
+
* (A share reached through a custom domain or `/u/<handle>` resolves its share
|
|
70
|
+
* server-side and is not in the path; pass `shareId` yourself there.)
|
|
71
|
+
*/
|
|
72
|
+
export function currentShareId() {
|
|
73
|
+
if (typeof window === "undefined")
|
|
74
|
+
return null;
|
|
75
|
+
const m = /^\/share\/([^/?#]+)/.exec(window.location.pathname);
|
|
76
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* VIEW AS, from the browser's side. In a Forge preview the board's switcher
|
|
80
|
+
* writes a persona onto the window; every call the app's UI makes then carries
|
|
81
|
+
* it, so the server sees the same pretend person the screen shows. Absent
|
|
82
|
+
* outside a preview: the platform never reads it, and the header is not sent.
|
|
83
|
+
*/
|
|
84
|
+
export function previewPersona() {
|
|
85
|
+
if (typeof window === "undefined")
|
|
86
|
+
return null;
|
|
87
|
+
if (!window.location.pathname.includes("/internal/plugin-preview"))
|
|
88
|
+
return null;
|
|
89
|
+
const p = window.__esoulPreviewViewer;
|
|
90
|
+
return typeof p === "string" && p ? p : null;
|
|
91
|
+
}
|
|
92
|
+
const PREVIEW_VIEWER_HEADER = "x-esoul-preview-viewer";
|
|
93
|
+
function previewViewerHeaders() {
|
|
94
|
+
const p = previewPersona();
|
|
95
|
+
return p ? { [PREVIEW_VIEWER_HEADER]: p } : {};
|
|
96
|
+
}
|
|
60
97
|
export async function callPluginOp(pluginId, op, nodeId, args) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (typeof window === "undefined" && process.env.INTERNAL_TOOL_SECRET) {
|
|
98
|
+
const onServer = typeof window === "undefined";
|
|
99
|
+
const serverBase = onServer ? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" : "";
|
|
100
|
+
const headers = { "Content-Type": "application/json", ...previewViewerHeaders() };
|
|
101
|
+
if (onServer && process.env.INTERNAL_TOOL_SECRET) {
|
|
66
102
|
headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
|
|
67
103
|
}
|
|
68
|
-
|
|
104
|
+
// A Forge preview serves ops on its own lean route over the workbench store (docs/10).
|
|
105
|
+
const inPreview = onServer ? process.env.WORKBENCH_STORE === "1" : window.location.pathname.includes("/internal/plugin-preview");
|
|
106
|
+
const res = await fetch(inPreview ? `${serverBase}/internal/plugin-preview/op/${pluginId}/${op}` : `${serverBase}/api/plugins/${pluginId}/op/${op}`, {
|
|
69
107
|
method: "POST",
|
|
70
108
|
headers,
|
|
71
|
-
body: JSON.stringify({ nodeId, args }),
|
|
109
|
+
body: JSON.stringify({ nodeId, args, ...(currentShareId() ? { shareId: currentShareId() } : {}) }),
|
|
72
110
|
});
|
|
73
111
|
const body = (await res.json().catch(() => null));
|
|
74
112
|
if (!res.ok || !body?.ok) {
|
|
75
|
-
throw new
|
|
113
|
+
throw new PluginCallError(body?.error ?? `plugin op ${pluginId}/${op} failed (HTTP ${res.status})`, {
|
|
114
|
+
status: res.status,
|
|
115
|
+
code: body?.code,
|
|
116
|
+
signInPath: body?.signInPath,
|
|
117
|
+
detail: body?.detail,
|
|
118
|
+
});
|
|
76
119
|
}
|
|
77
120
|
return body.result;
|
|
78
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* What a failed op call throws. It CARRIES the platform's refusal code, so a
|
|
124
|
+
* UI can tell `login-required` (raise the sign-in wall) from `forbidden` (an
|
|
125
|
+
* error) — `useSignInWall().raise(err)` reads exactly this. A bare `Error`
|
|
126
|
+
* dropped the code, and the wall never rose (found by the first S6 drive).
|
|
127
|
+
*/
|
|
128
|
+
export class PluginCallError extends Error {
|
|
129
|
+
/** HTTP status of the answer. */
|
|
130
|
+
status;
|
|
131
|
+
/** `forbidden` | `login-required` | `not-bound` | `rate-limited` | `invalid`, when the platform said which. */
|
|
132
|
+
code;
|
|
133
|
+
/** Where to send the browser to sign in (`login-required` only). */
|
|
134
|
+
signInPath;
|
|
135
|
+
/** The author's explanation — present only in a workbench box. */
|
|
136
|
+
detail;
|
|
137
|
+
constructor(message, args) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.name = "PluginCallError";
|
|
140
|
+
this.status = args.status;
|
|
141
|
+
this.code = args.code;
|
|
142
|
+
this.signInPath = args.signInPath;
|
|
143
|
+
this.detail = args.detail;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
79
146
|
/* ── webhook secret comparison ──────────────────────────────────────── */
|
|
80
147
|
/**
|
|
81
148
|
* Constant-time string compare for webhook secrets/signatures. A plain
|
|
@@ -91,3 +158,53 @@ export function timingSafeEqual(a, b) {
|
|
|
91
158
|
}
|
|
92
159
|
return diff === 0;
|
|
93
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Kick one of your app's background tasks (docs/07) from the browser OR from
|
|
163
|
+
* a tool's `execute` on the server. The task must be listed in the
|
|
164
|
+
* manifest's `kickableTasks`; the platform's send-event route refuses the
|
|
165
|
+
* rest. `data` rides on `ctx.eventData` — keep it small and JSON. The kick
|
|
166
|
+
* is fire-and-forget: the task's result lands as events, or on the channel.
|
|
167
|
+
*/
|
|
168
|
+
export async function kickPluginTask(args) {
|
|
169
|
+
const onServer = typeof window === "undefined";
|
|
170
|
+
const base = onServer ? (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000") : "";
|
|
171
|
+
const headers = { "Content-Type": "application/json", ...previewViewerHeaders() };
|
|
172
|
+
if (onServer && process.env.INTERNAL_TOOL_SECRET)
|
|
173
|
+
headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
|
|
174
|
+
// A Forge preview has no Inngest: its dev server runs the task in-process behind the same
|
|
175
|
+
// ctx contract, kicked through the preview's own route. Same app code, a different transport.
|
|
176
|
+
const inPreview = onServer ? process.env.WORKBENCH_STORE === "1" : window.location.pathname.includes("/internal/plugin-preview");
|
|
177
|
+
const res = await fetch(inPreview ? `${base}/internal/plugin-preview/kick` : `${base}/api/inngest/send-event`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers,
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
name: `${args.applicationType}/${args.taskName}`,
|
|
182
|
+
data: {
|
|
183
|
+
workspaceId: args.identifier.workspaceId,
|
|
184
|
+
nodeId: args.identifier.nodeId,
|
|
185
|
+
applicationType: args.applicationType,
|
|
186
|
+
instanceName: args.identifier.instanceName,
|
|
187
|
+
...(args.data ?? {}),
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
return { ok: res.ok, status: res.status };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The URL of one of your server routes (docs/06) for one instance — what an
|
|
195
|
+
* `EventSource` or `fetch` in your UI opens. Same origin; the session rides along.
|
|
196
|
+
*/
|
|
197
|
+
export function pluginRouteUrl(pluginId, routeName, nodeId, params) {
|
|
198
|
+
const share = currentShareId();
|
|
199
|
+
const persona = previewPersona();
|
|
200
|
+
const q = new URLSearchParams({
|
|
201
|
+
nodeId,
|
|
202
|
+
...(share ? { shareId: share } : {}),
|
|
203
|
+
...(persona ? { viewer: persona } : {}),
|
|
204
|
+
...Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)])),
|
|
205
|
+
});
|
|
206
|
+
const inPreview = typeof window !== "undefined" && window.location.pathname.includes("/internal/plugin-preview");
|
|
207
|
+
return inPreview
|
|
208
|
+
? `/internal/plugin-preview/route/${encodeURIComponent(pluginId)}/${encodeURIComponent(routeName)}?${q.toString()}`
|
|
209
|
+
: `/api/plugins/${encodeURIComponent(pluginId)}/route/${encodeURIComponent(routeName)}?${q.toString()}`;
|
|
210
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,5 +2,29 @@ export * from "./types.js";
|
|
|
2
2
|
export * from "./manifest.js";
|
|
3
3
|
export * from "./helpers.js";
|
|
4
4
|
export * from "./files.js";
|
|
5
|
+
export * from "./bindings.js";
|
|
6
|
+
/** WHO hears a realtime message (S8): topic audiences, and who may aim one. */
|
|
7
|
+
export * from "./audience.js";
|
|
8
|
+
/** The app's own words for the platform's kinds of caller (S3). */
|
|
9
|
+
export * from "./roles.js";
|
|
5
10
|
/** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
|
|
6
11
|
export { nanoid } from "nanoid";
|
|
12
|
+
/**
|
|
13
|
+
* Declare an app's realtime channel — one per instance. Inside the host this
|
|
14
|
+
* is the platform's real channel; outside it (tests, your editor) a
|
|
15
|
+
* descriptor of the same shape. Put it on the schema as `channel` and pass it
|
|
16
|
+
* to `usePluginRealtime`; a task publishes on it with `ctx.notify(topic, data)`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function definePluginChannel<T extends Record<string, {
|
|
19
|
+
schema: unknown;
|
|
20
|
+
}>>(args: {
|
|
21
|
+
applicationType: string;
|
|
22
|
+
topics: T;
|
|
23
|
+
}): ((p: {
|
|
24
|
+
workspaceId: string;
|
|
25
|
+
nodeId: string;
|
|
26
|
+
}) => Record<keyof T, unknown> & {
|
|
27
|
+
name: string;
|
|
28
|
+
}) & {
|
|
29
|
+
topicNames: (keyof T & string)[];
|
|
30
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -2,5 +2,27 @@ export * from "./types.js";
|
|
|
2
2
|
export * from "./manifest.js";
|
|
3
3
|
export * from "./helpers.js";
|
|
4
4
|
export * from "./files.js";
|
|
5
|
+
export * from "./bindings.js";
|
|
6
|
+
/** WHO hears a realtime message (S8): topic audiences, and who may aim one. */
|
|
7
|
+
export * from "./audience.js";
|
|
8
|
+
/** The app's own words for the platform's kinds of caller (S3). */
|
|
9
|
+
export * from "./roles.js";
|
|
5
10
|
/** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
|
|
6
11
|
export { nanoid } from "nanoid";
|
|
12
|
+
/**
|
|
13
|
+
* Declare an app's realtime channel — one per instance. Inside the host this
|
|
14
|
+
* is the platform's real channel; outside it (tests, your editor) a
|
|
15
|
+
* descriptor of the same shape. Put it on the schema as `channel` and pass it
|
|
16
|
+
* to `usePluginRealtime`; a task publishes on it with `ctx.notify(topic, data)`.
|
|
17
|
+
*/
|
|
18
|
+
export function definePluginChannel(args) {
|
|
19
|
+
const type = args.applicationType.replace(/[^a-z0-9_]/gi, "_");
|
|
20
|
+
const factory = ((p) => {
|
|
21
|
+
const out = { name: `plugin:${type}:${p.workspaceId}:${p.nodeId}` };
|
|
22
|
+
for (const t of Object.keys(args.topics))
|
|
23
|
+
out[t] = { topic: t, channel: out.name };
|
|
24
|
+
return out;
|
|
25
|
+
});
|
|
26
|
+
factory.topicNames = Object.keys(args.topics);
|
|
27
|
+
return factory;
|
|
28
|
+
}
|