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,363 @@
|
|
|
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 = (fields, kind = "btree") => {
|
|
75
|
+
const key = `${kind}:${fields.join(",")}`;
|
|
76
|
+
if (seen.has(key))
|
|
77
|
+
return;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
out.push({ fields, kind });
|
|
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)
|
|
88
|
+
push([lead, ...group.filter((c) => c !== lead)]);
|
|
89
|
+
for (const idx of model.indexes) {
|
|
90
|
+
// A GIN index (a list or a trigram search) is single-column and NOT
|
|
91
|
+
// scope-led: without `btree_gin` it cannot lead with the scope column, so
|
|
92
|
+
// Postgres bitmap-ANDs it with the scope index instead.
|
|
93
|
+
if (idx.kind === "btree")
|
|
94
|
+
push([lead, ...idx.fields.filter((c) => c !== lead)]);
|
|
95
|
+
else
|
|
96
|
+
push([...idx.fields], idx.kind);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* One `@@index(...)` argument list. A trigram index needs the operator class
|
|
102
|
+
* spelled out (`gin_trgm_ops`) — that is what makes `contains` an index lookup
|
|
103
|
+
* rather than a scan — and Prisma emits both forms as the SQL we emit below,
|
|
104
|
+
* which is what keeps the fragment and the DDL in step.
|
|
105
|
+
*/
|
|
106
|
+
function prismaIndexArgs(g) {
|
|
107
|
+
// QUALIFIED. `pg_trgm` installs its operator class in `public`, and DDL that
|
|
108
|
+
// runs with a different search_path (the db-test harness makes a throwaway
|
|
109
|
+
// schema and does exactly that) cannot resolve a bare `gin_trgm_ops`:
|
|
110
|
+
// `operator class "gin_trgm_ops" does not exist for access method "gin"`.
|
|
111
|
+
// Measured 2026-09-12 against the real database.
|
|
112
|
+
if (g.kind === "text")
|
|
113
|
+
return `[${g.fields[0]}(ops: raw("public.gin_trgm_ops"))], type: Gin`;
|
|
114
|
+
if (g.kind === "contains")
|
|
115
|
+
return `[${g.fields.join(", ")}], type: Gin`;
|
|
116
|
+
return `[${g.fields.join(", ")}]`;
|
|
117
|
+
}
|
|
118
|
+
function renderModel(model, applicationType) {
|
|
119
|
+
const cols = [
|
|
120
|
+
...platformColumns(model.scope),
|
|
121
|
+
...Object.keys(model.fields).map((name) => declaredColumn(name, model.fields[name], model.sealed.includes(name))),
|
|
122
|
+
];
|
|
123
|
+
const nameW = Math.max(...cols.map((c) => c.name.length));
|
|
124
|
+
const typeW = Math.max(...cols.map((c) => c.type.length));
|
|
125
|
+
const lines = cols.map((c) => {
|
|
126
|
+
const head = ` ${c.name.padEnd(nameW)} ${c.type.padEnd(typeW)}`;
|
|
127
|
+
return (c.attrs ? `${head} ${c.attrs}` : head).trimEnd();
|
|
128
|
+
});
|
|
129
|
+
const idx = indexGroups(model).map((g) => ` @@index(${prismaIndexArgs(g)})`);
|
|
130
|
+
return [
|
|
131
|
+
`model ${prismaModelName(applicationType, model.name)} {`,
|
|
132
|
+
...lines,
|
|
133
|
+
"",
|
|
134
|
+
...idx,
|
|
135
|
+
` @@map(${JSON.stringify(prismaTableName(applicationType, model.name))})`,
|
|
136
|
+
"}",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The Prisma schema fragment for one app — every model, platform columns
|
|
141
|
+
* first, scope-led indexes, mapped to a prefixed table. No datasource and no
|
|
142
|
+
* generator: this is a FRAGMENT that joins the platform's schema folder.
|
|
143
|
+
*/
|
|
144
|
+
export function prismaFragment(rules, opts) {
|
|
145
|
+
const models = Object.keys(rules.models).map((name) => rules.models[name]);
|
|
146
|
+
const body = models.map((m) => renderModel(m, opts.applicationType)).join("\n\n");
|
|
147
|
+
return [
|
|
148
|
+
`/// AUTO-GENERATED from plugin "${rules.pluginId}" (plugin.json \`db\`) — DO NOT EDIT.`,
|
|
149
|
+
`/// Regenerated by \`yarn plugins:sync\`. Tables are prefixed \`${opts.applicationType}__\`;`,
|
|
150
|
+
"/// every row carries the platform's scope columns; indexes lead with the scope column;",
|
|
151
|
+
"/// declared `unique` groups are plain indexes (uniqueness is enforced among live rows by the client).",
|
|
152
|
+
"",
|
|
153
|
+
body,
|
|
154
|
+
"",
|
|
155
|
+
].join("\n");
|
|
156
|
+
}
|
|
157
|
+
/* ─────────────────────────── the typings ──────────────────────────────── */
|
|
158
|
+
const TS_SCALAR = {
|
|
159
|
+
string: "string",
|
|
160
|
+
text: "string",
|
|
161
|
+
int: "number",
|
|
162
|
+
float: "number",
|
|
163
|
+
boolean: "boolean",
|
|
164
|
+
datetime: "Date",
|
|
165
|
+
json: "unknown",
|
|
166
|
+
ref: "string",
|
|
167
|
+
};
|
|
168
|
+
function rowFieldType(field) {
|
|
169
|
+
const base = TS_SCALAR[field.type];
|
|
170
|
+
if (field.list)
|
|
171
|
+
return `${base}[]`;
|
|
172
|
+
return field.optional ? `${base} | null` : base;
|
|
173
|
+
}
|
|
174
|
+
function createFieldType(field) {
|
|
175
|
+
const base = TS_SCALAR[field.type];
|
|
176
|
+
if (field.list)
|
|
177
|
+
return `${base}[]`;
|
|
178
|
+
return field.optional ? `${base} | null` : base;
|
|
179
|
+
}
|
|
180
|
+
function quoteKey(name) {
|
|
181
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
182
|
+
}
|
|
183
|
+
function renderRows(model) {
|
|
184
|
+
const platform = [
|
|
185
|
+
" id: string;",
|
|
186
|
+
" workspaceId: string;",
|
|
187
|
+
` nodeId: ${model.scope === "user" ? "string | null" : "string"};`,
|
|
188
|
+
" ownerId: string | null;",
|
|
189
|
+
" createdAt: Date;",
|
|
190
|
+
" updatedAt: Date;",
|
|
191
|
+
];
|
|
192
|
+
const declared = Object.keys(model.fields).map((n) => ` ${quoteKey(n)}: ${rowFieldType(model.fields[n])};`);
|
|
193
|
+
return [`export interface ${model.name}Row {`, ...platform, ...declared, "}"].join("\n");
|
|
194
|
+
}
|
|
195
|
+
function renderCreate(model) {
|
|
196
|
+
const declared = Object.keys(model.fields).map((n) => {
|
|
197
|
+
const f = model.fields[n];
|
|
198
|
+
const optional = f.optional || f.default !== undefined || f.list;
|
|
199
|
+
return ` ${quoteKey(n)}${optional ? "?" : ""}: ${createFieldType(f)};`;
|
|
200
|
+
});
|
|
201
|
+
return [`export interface ${model.name}Create {`, ...declared, "}"].join("\n");
|
|
202
|
+
}
|
|
203
|
+
function renderFilterable(model) {
|
|
204
|
+
const keys = model.filterable.map((k) => JSON.stringify(k)).join(" | ");
|
|
205
|
+
return `export type ${model.name}Filterable = ${keys || "never"};`;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The `.esoul/db.d.ts` for one app: a `<Model>Row` and `<Model>Create` per
|
|
209
|
+
* model, a `<Model>Filterable` union that is exactly the compiled `filterable`
|
|
210
|
+
* list (so `where` and `orderBy` on an unindexed field are compile errors —
|
|
211
|
+
* the runtime rule, landed in the editor), and one `Collection` shape mirroring
|
|
212
|
+
* the memory client's method surface. Everything an author calls is typed with
|
|
213
|
+
* their own names; nothing here knows the table prefix.
|
|
214
|
+
*/
|
|
215
|
+
export function dbTypings(rules, opts) {
|
|
216
|
+
const models = Object.keys(rules.models).map((name) => rules.models[name]);
|
|
217
|
+
const parts = [
|
|
218
|
+
`/** AUTO-GENERATED from plugin "${rules.pluginId}" (plugin.json \`db\`) — DO NOT EDIT. Regenerated by \`yarn plugins:sync\`. */`,
|
|
219
|
+
"",
|
|
220
|
+
"export type Scalar = string | number | boolean | Date | null;",
|
|
221
|
+
"export type Filter<V> =",
|
|
222
|
+
" | V",
|
|
223
|
+
" | { in: V[] }",
|
|
224
|
+
" | { notIn: V[] }",
|
|
225
|
+
" | { not: V }",
|
|
226
|
+
" | { gt: V }",
|
|
227
|
+
" | { gte: V }",
|
|
228
|
+
" | { lt: V }",
|
|
229
|
+
" | { lte: V }",
|
|
230
|
+
" | { contains: string };",
|
|
231
|
+
"/** A LIST field takes one question: does it hold this? (a `contains` index answers it) */",
|
|
232
|
+
"export type ListFilter<V> = { has: V };",
|
|
233
|
+
"/** Only INDEXED fields may be filtered — the generator emits the union per model. */",
|
|
234
|
+
"export type Where<T, K extends keyof T> = {",
|
|
235
|
+
" [P in K]?: T[P] extends readonly (infer E)[] ? ListFilter<E> : T[P] extends Scalar ? Filter<T[P]> : never;",
|
|
236
|
+
"};",
|
|
237
|
+
"export type OrderBy<K extends string> = { [P in K]?: \"asc\" | \"desc\" };",
|
|
238
|
+
"",
|
|
239
|
+
"export interface FindManyArgs<T, K extends keyof T & string> {",
|
|
240
|
+
" where?: Where<T, K>;",
|
|
241
|
+
" orderBy?: OrderBy<K> | OrderBy<K>[];",
|
|
242
|
+
" take?: number;",
|
|
243
|
+
" skip?: number;",
|
|
244
|
+
" cursor?: { id: string };",
|
|
245
|
+
" includeDeleted?: boolean;",
|
|
246
|
+
"}",
|
|
247
|
+
"export interface AggregateArgs<T, K extends keyof T & string> {",
|
|
248
|
+
" where?: Where<T, K>;",
|
|
249
|
+
" _count?: true;",
|
|
250
|
+
" _sum?: Partial<Record<keyof T & string, true>>;",
|
|
251
|
+
" _avg?: Partial<Record<keyof T & string, true>>;",
|
|
252
|
+
" _min?: Partial<Record<keyof T & string, true>>;",
|
|
253
|
+
" _max?: Partial<Record<keyof T & string, true>>;",
|
|
254
|
+
"}",
|
|
255
|
+
"export interface GroupByArgs<T, K extends keyof T & string> extends AggregateArgs<T, K> {",
|
|
256
|
+
" by: K[];",
|
|
257
|
+
"}",
|
|
258
|
+
"",
|
|
259
|
+
"export interface Collection<Row, Create, K extends keyof Row & string> {",
|
|
260
|
+
" findMany(args?: FindManyArgs<Row, K>): Promise<Row[]>;",
|
|
261
|
+
" findFirst(args?: FindManyArgs<Row, K>): Promise<Row | null>;",
|
|
262
|
+
" findUnique(args: { where: { id: string }; includeDeleted?: boolean }): Promise<Row | null>;",
|
|
263
|
+
" count(args?: { where?: Where<Row, K>; includeDeleted?: boolean }): Promise<number>;",
|
|
264
|
+
" aggregate(args?: AggregateArgs<Row, K>): Promise<Record<string, unknown>>;",
|
|
265
|
+
" groupBy(args: GroupByArgs<Row, K>): Promise<Record<string, unknown>[]>;",
|
|
266
|
+
" create(args: { data: Create }): Promise<Row>;",
|
|
267
|
+
" createMany(args: { data: Create[] }): Promise<{ count: number }>;",
|
|
268
|
+
" update(args: { where: { id: string }; data: Partial<Create> }): Promise<Row>;",
|
|
269
|
+
" updateMany(args: { where?: Where<Row, K>; data: Partial<Create> }): Promise<{ count: number }>;",
|
|
270
|
+
" upsert(args: { where: { id: string }; create: Create; update: Partial<Create> }): Promise<Row>;",
|
|
271
|
+
" delete(args: { where: { id: string } }): Promise<Row>;",
|
|
272
|
+
" deleteMany(args?: { where?: Where<Row, K> }): Promise<{ count: number }>;",
|
|
273
|
+
"}",
|
|
274
|
+
"",
|
|
275
|
+
];
|
|
276
|
+
for (const m of models) {
|
|
277
|
+
parts.push(renderRows(m), renderCreate(m), renderFilterable(m), "");
|
|
278
|
+
}
|
|
279
|
+
parts.push(`export interface ${opts.typeName} {`);
|
|
280
|
+
for (const m of models) {
|
|
281
|
+
parts.push(` ${quoteKey(m.key)}: Collection<${m.name}Row, ${m.name}Create, ${m.name}Filterable>;`);
|
|
282
|
+
}
|
|
283
|
+
parts.push(` $transaction<T>(fn: (tx: ${opts.typeName}) => Promise<T>): Promise<T>;`, "}", "");
|
|
284
|
+
return parts.join("\n");
|
|
285
|
+
}
|
|
286
|
+
/** The compiled artefact as the bytes `.esoul/rules.json` holds. */
|
|
287
|
+
export function rulesJson(rules) {
|
|
288
|
+
return JSON.stringify(rules, null, 2) + "\n";
|
|
289
|
+
}
|
|
290
|
+
const SQL_OF_PRISMA = {
|
|
291
|
+
String: "TEXT",
|
|
292
|
+
Int: "INTEGER",
|
|
293
|
+
Float: "DOUBLE PRECISION",
|
|
294
|
+
Boolean: "BOOLEAN",
|
|
295
|
+
DateTime: "TIMESTAMP(3)",
|
|
296
|
+
Json: "JSONB",
|
|
297
|
+
};
|
|
298
|
+
function sqlColumn(c) {
|
|
299
|
+
const list = c.type.endsWith("[]");
|
|
300
|
+
const optional = c.type.endsWith("?");
|
|
301
|
+
const base = c.type.replace(/\[\]$|\?$/, "");
|
|
302
|
+
const sql = SQL_OF_PRISMA[base];
|
|
303
|
+
if (!sql)
|
|
304
|
+
throw new Error(`[schema-gen] no SQL type for Prisma ${base}`);
|
|
305
|
+
let def = null;
|
|
306
|
+
const m = /@default\((.*)\)/.exec(c.attrs);
|
|
307
|
+
if (m) {
|
|
308
|
+
const raw = m[1];
|
|
309
|
+
if (raw === "now()")
|
|
310
|
+
def = "CURRENT_TIMESTAMP";
|
|
311
|
+
else if (raw === "uuid()")
|
|
312
|
+
def = null; // the client mints ids; Prisma's DDL carries no default either
|
|
313
|
+
else if (/^".*"$/.test(raw))
|
|
314
|
+
def = `'${JSON.parse(raw).replace(/'/g, "''")}'`;
|
|
315
|
+
else
|
|
316
|
+
def = raw; // a number or true/false
|
|
317
|
+
}
|
|
318
|
+
// Prisma emits a scalar list WITHOUT `NOT NULL` (an absent list is an empty
|
|
319
|
+
// one, not a null), so a list column must not carry the constraint either —
|
|
320
|
+
// or the planner would read a difference against the live table forever.
|
|
321
|
+
return { name: c.name, type: list ? `${sql}[]` : sql, nullable: optional || list, default: def };
|
|
322
|
+
}
|
|
323
|
+
/** Prisma's index name: `<table>_<col>_<col>_idx`. */
|
|
324
|
+
export function indexName(table, columns) {
|
|
325
|
+
return `${table}_${columns.join("_")}_idx`;
|
|
326
|
+
}
|
|
327
|
+
/** Every table an app declares, as columns and indexes — the input to the planner and the emitter. */
|
|
328
|
+
export function tableSpecs(rules, opts) {
|
|
329
|
+
return Object.keys(rules.models).map((name) => {
|
|
330
|
+
const model = rules.models[name];
|
|
331
|
+
const table = prismaTableName(opts.applicationType, model.name);
|
|
332
|
+
const cols = [
|
|
333
|
+
...platformColumns(model.scope),
|
|
334
|
+
...Object.keys(model.fields).map((f) => declaredColumn(f, model.fields[f], model.sealed.includes(f))),
|
|
335
|
+
];
|
|
336
|
+
return {
|
|
337
|
+
name: table,
|
|
338
|
+
columns: cols.map(sqlColumn),
|
|
339
|
+
indexes: indexGroups(model).map((g) => ({ name: indexName(table, g.fields), columns: g.fields, kind: g.kind })),
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const q = (ident) => `"${ident}"`;
|
|
344
|
+
function columnDef(c) {
|
|
345
|
+
return `${q(c.name)} ${c.type}${c.nullable ? "" : " NOT NULL"}${c.default !== null ? ` DEFAULT ${c.default}` : ""}`;
|
|
346
|
+
}
|
|
347
|
+
export function createTableSql(t) {
|
|
348
|
+
return [`CREATE TABLE ${q(t.name)} (`, ...t.columns.map((c) => ` ${columnDef(c)},`), "", ` CONSTRAINT ${q(`${t.name}_pkey`)} PRIMARY KEY ("id")`, ");"].join("\n");
|
|
349
|
+
}
|
|
350
|
+
export function createIndexSql(table, idx) {
|
|
351
|
+
if (idx.kind === "text")
|
|
352
|
+
return `CREATE INDEX ${q(idx.name)} ON ${q(table)} USING GIN (${q(idx.columns[0])} public.gin_trgm_ops);`;
|
|
353
|
+
if (idx.kind === "contains")
|
|
354
|
+
return `CREATE INDEX ${q(idx.name)} ON ${q(table)} USING GIN (${idx.columns.map(q).join(", ")});`;
|
|
355
|
+
return `CREATE INDEX ${q(idx.name)} ON ${q(table)}(${idx.columns.map(q).join(", ")});`;
|
|
356
|
+
}
|
|
357
|
+
export function addColumnSql(table, c) {
|
|
358
|
+
return `ALTER TABLE ${q(table)} ADD COLUMN ${columnDef(c)};`;
|
|
359
|
+
}
|
|
360
|
+
/** The whole script for a fresh install: tables first, then every index — Prisma's order. */
|
|
361
|
+
export function ddlStatements(specs) {
|
|
362
|
+
return [...specs.map(createTableSql), ...specs.flatMap((t) => t.indexes.map((i) => createIndexSql(t.name, i)))];
|
|
363
|
+
}
|
package/dist/helpers.d.ts
CHANGED
|
@@ -31,7 +31,54 @@ 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 `/u/<handle>` or a custom domain resolves its share
|
|
44
|
+
* server-side and is NOT in the path. The platform tells the page which share
|
|
45
|
+
* it is standing on (`window.__esoulShareId`, written by the frame that
|
|
46
|
+
* renders the app), and that is read first. The day this was left to the app
|
|
47
|
+
* ("pass `shareId` yourself there") a shop published as a homepage answered
|
|
48
|
+
* `forbidden` to every visitor (2026-09-12): an app cannot know a thing only
|
|
49
|
+
* the platform resolved.
|
|
50
|
+
*/
|
|
51
|
+
export declare function currentShareId(): string | null;
|
|
52
|
+
/**
|
|
53
|
+
* VIEW AS, from the browser's side. In a Forge preview the board's switcher
|
|
54
|
+
* writes a persona onto the window; every call the app's UI makes then carries
|
|
55
|
+
* it, so the server sees the same pretend person the screen shows. Absent
|
|
56
|
+
* outside a preview: the platform never reads it, and the header is not sent.
|
|
57
|
+
*/
|
|
58
|
+
export declare function previewPersona(): string | null;
|
|
34
59
|
export declare function callPluginOp<T = unknown>(pluginId: string, op: string, nodeId: string, args?: unknown): Promise<T>;
|
|
60
|
+
/**
|
|
61
|
+
* What a failed op call throws. It CARRIES the platform's refusal code, so a
|
|
62
|
+
* UI can tell `login-required` (raise the sign-in wall) from `forbidden` (an
|
|
63
|
+
* error) — `useSignInWall().raise(err)` reads exactly this. A bare `Error`
|
|
64
|
+
* dropped the code, and the wall never rose (found by the first S6 drive).
|
|
65
|
+
*/
|
|
66
|
+
export declare class PluginCallError extends Error {
|
|
67
|
+
/** HTTP status of the answer. */
|
|
68
|
+
readonly status: number;
|
|
69
|
+
/** `forbidden` | `login-required` | `not-bound` | `rate-limited` | `invalid`, when the platform said which. */
|
|
70
|
+
readonly code?: string;
|
|
71
|
+
/** Where to send the browser to sign in (`login-required` only). */
|
|
72
|
+
readonly signInPath?: string;
|
|
73
|
+
/** The author's explanation — present only in a workbench box. */
|
|
74
|
+
readonly detail?: string;
|
|
75
|
+
constructor(message: string, args: {
|
|
76
|
+
status: number;
|
|
77
|
+
code?: string;
|
|
78
|
+
signInPath?: string;
|
|
79
|
+
detail?: string;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
35
82
|
/**
|
|
36
83
|
* Constant-time string compare for webhook secrets/signatures. A plain
|
|
37
84
|
* `===` leaks length/prefix timing; use THIS in every webhook handler.
|
|
@@ -58,3 +105,8 @@ export declare function kickPluginTask(args: {
|
|
|
58
105
|
ok: boolean;
|
|
59
106
|
status: number;
|
|
60
107
|
}>;
|
|
108
|
+
/**
|
|
109
|
+
* The URL of one of your server routes (docs/06) for one instance — what an
|
|
110
|
+
* `EventSource` or `fetch` in your UI opens. Same origin; the session rides along.
|
|
111
|
+
*/
|
|
112
|
+
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,100 @@ 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 `/u/<handle>` or a custom domain resolves its share
|
|
70
|
+
* server-side and is NOT in the path. The platform tells the page which share
|
|
71
|
+
* it is standing on (`window.__esoulShareId`, written by the frame that
|
|
72
|
+
* renders the app), and that is read first. The day this was left to the app
|
|
73
|
+
* ("pass `shareId` yourself there") a shop published as a homepage answered
|
|
74
|
+
* `forbidden` to every visitor (2026-09-12): an app cannot know a thing only
|
|
75
|
+
* the platform resolved.
|
|
76
|
+
*/
|
|
77
|
+
export function currentShareId() {
|
|
78
|
+
if (typeof window === "undefined")
|
|
79
|
+
return null;
|
|
80
|
+
const told = window.__esoulShareId;
|
|
81
|
+
if (typeof told === "string" && told)
|
|
82
|
+
return told;
|
|
83
|
+
const m = /^\/share\/([^/?#]+)/.exec(window.location.pathname);
|
|
84
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* VIEW AS, from the browser's side. In a Forge preview the board's switcher
|
|
88
|
+
* writes a persona onto the window; every call the app's UI makes then carries
|
|
89
|
+
* it, so the server sees the same pretend person the screen shows. Absent
|
|
90
|
+
* outside a preview: the platform never reads it, and the header is not sent.
|
|
91
|
+
*/
|
|
92
|
+
export function previewPersona() {
|
|
93
|
+
if (typeof window === "undefined")
|
|
94
|
+
return null;
|
|
95
|
+
if (!window.location.pathname.includes("/internal/plugin-preview"))
|
|
96
|
+
return null;
|
|
97
|
+
const p = window.__esoulPreviewViewer;
|
|
98
|
+
return typeof p === "string" && p ? p : null;
|
|
99
|
+
}
|
|
100
|
+
const PREVIEW_VIEWER_HEADER = "x-esoul-preview-viewer";
|
|
101
|
+
function previewViewerHeaders() {
|
|
102
|
+
const p = previewPersona();
|
|
103
|
+
return p ? { [PREVIEW_VIEWER_HEADER]: p } : {};
|
|
104
|
+
}
|
|
60
105
|
export async function callPluginOp(pluginId, op, nodeId, args) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (typeof window === "undefined" && process.env.INTERNAL_TOOL_SECRET) {
|
|
106
|
+
const onServer = typeof window === "undefined";
|
|
107
|
+
const serverBase = onServer ? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" : "";
|
|
108
|
+
const headers = { "Content-Type": "application/json", ...previewViewerHeaders() };
|
|
109
|
+
if (onServer && process.env.INTERNAL_TOOL_SECRET) {
|
|
66
110
|
headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
|
|
67
111
|
}
|
|
68
|
-
|
|
112
|
+
// A Forge preview serves ops on its own lean route over the workbench store (docs/10).
|
|
113
|
+
const inPreview = onServer ? process.env.WORKBENCH_STORE === "1" : window.location.pathname.includes("/internal/plugin-preview");
|
|
114
|
+
const res = await fetch(inPreview ? `${serverBase}/internal/plugin-preview/op/${pluginId}/${op}` : `${serverBase}/api/plugins/${pluginId}/op/${op}`, {
|
|
69
115
|
method: "POST",
|
|
70
116
|
headers,
|
|
71
|
-
body: JSON.stringify({ nodeId, args }),
|
|
117
|
+
body: JSON.stringify({ nodeId, args, ...(currentShareId() ? { shareId: currentShareId() } : {}) }),
|
|
72
118
|
});
|
|
73
119
|
const body = (await res.json().catch(() => null));
|
|
74
120
|
if (!res.ok || !body?.ok) {
|
|
75
|
-
throw new
|
|
121
|
+
throw new PluginCallError(body?.error ?? `plugin op ${pluginId}/${op} failed (HTTP ${res.status})`, {
|
|
122
|
+
status: res.status,
|
|
123
|
+
code: body?.code,
|
|
124
|
+
signInPath: body?.signInPath,
|
|
125
|
+
detail: body?.detail,
|
|
126
|
+
});
|
|
76
127
|
}
|
|
77
128
|
return body.result;
|
|
78
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* What a failed op call throws. It CARRIES the platform's refusal code, so a
|
|
132
|
+
* UI can tell `login-required` (raise the sign-in wall) from `forbidden` (an
|
|
133
|
+
* error) — `useSignInWall().raise(err)` reads exactly this. A bare `Error`
|
|
134
|
+
* dropped the code, and the wall never rose (found by the first S6 drive).
|
|
135
|
+
*/
|
|
136
|
+
export class PluginCallError extends Error {
|
|
137
|
+
/** HTTP status of the answer. */
|
|
138
|
+
status;
|
|
139
|
+
/** `forbidden` | `login-required` | `not-bound` | `rate-limited` | `invalid`, when the platform said which. */
|
|
140
|
+
code;
|
|
141
|
+
/** Where to send the browser to sign in (`login-required` only). */
|
|
142
|
+
signInPath;
|
|
143
|
+
/** The author's explanation — present only in a workbench box. */
|
|
144
|
+
detail;
|
|
145
|
+
constructor(message, args) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.name = "PluginCallError";
|
|
148
|
+
this.status = args.status;
|
|
149
|
+
this.code = args.code;
|
|
150
|
+
this.signInPath = args.signInPath;
|
|
151
|
+
this.detail = args.detail;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
79
154
|
/* ── webhook secret comparison ──────────────────────────────────────── */
|
|
80
155
|
/**
|
|
81
156
|
* Constant-time string compare for webhook secrets/signatures. A plain
|
|
@@ -101,10 +176,13 @@ export function timingSafeEqual(a, b) {
|
|
|
101
176
|
export async function kickPluginTask(args) {
|
|
102
177
|
const onServer = typeof window === "undefined";
|
|
103
178
|
const base = onServer ? (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000") : "";
|
|
104
|
-
const headers = { "Content-Type": "application/json" };
|
|
179
|
+
const headers = { "Content-Type": "application/json", ...previewViewerHeaders() };
|
|
105
180
|
if (onServer && process.env.INTERNAL_TOOL_SECRET)
|
|
106
181
|
headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
|
|
107
|
-
|
|
182
|
+
// A Forge preview has no Inngest: its dev server runs the task in-process behind the same
|
|
183
|
+
// ctx contract, kicked through the preview's own route. Same app code, a different transport.
|
|
184
|
+
const inPreview = onServer ? process.env.WORKBENCH_STORE === "1" : window.location.pathname.includes("/internal/plugin-preview");
|
|
185
|
+
const res = await fetch(inPreview ? `${base}/internal/plugin-preview/kick` : `${base}/api/inngest/send-event`, {
|
|
108
186
|
method: "POST",
|
|
109
187
|
headers,
|
|
110
188
|
body: JSON.stringify({
|
|
@@ -120,3 +198,21 @@ export async function kickPluginTask(args) {
|
|
|
120
198
|
});
|
|
121
199
|
return { ok: res.ok, status: res.status };
|
|
122
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* The URL of one of your server routes (docs/06) for one instance — what an
|
|
203
|
+
* `EventSource` or `fetch` in your UI opens. Same origin; the session rides along.
|
|
204
|
+
*/
|
|
205
|
+
export function pluginRouteUrl(pluginId, routeName, nodeId, params) {
|
|
206
|
+
const share = currentShareId();
|
|
207
|
+
const persona = previewPersona();
|
|
208
|
+
const q = new URLSearchParams({
|
|
209
|
+
nodeId,
|
|
210
|
+
...(share ? { shareId: share } : {}),
|
|
211
|
+
...(persona ? { viewer: persona } : {}),
|
|
212
|
+
...Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)])),
|
|
213
|
+
});
|
|
214
|
+
const inPreview = typeof window !== "undefined" && window.location.pathname.includes("/internal/plugin-preview");
|
|
215
|
+
return inPreview
|
|
216
|
+
? `/internal/plugin-preview/route/${encodeURIComponent(pluginId)}/${encodeURIComponent(routeName)}?${q.toString()}`
|
|
217
|
+
: `/api/plugins/${encodeURIComponent(pluginId)}/route/${encodeURIComponent(routeName)}?${q.toString()}`;
|
|
218
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,11 @@ 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";
|
|
7
12
|
/**
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,11 @@ 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";
|
|
7
12
|
/**
|