esoul-sdk 0.6.0 → 0.8.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 CHANGED
@@ -160,6 +160,26 @@ something: does the write survive?
160
160
 
161
161
  ## Versions
162
162
 
163
+ - **0.8.0** — the three things making an app LOOK like a product turned out to
164
+ need: `generateAppImage` (one call — generates, brings the bytes to web
165
+ weight, hands back an https URL; a 2.4 MB PNG is not a page), `setAppRole` /
166
+ `listAppRoles` (an owner gives one esoul account one of your role words, by
167
+ email; the platform does every check and records it on the app's timeline),
168
+ and `wall.ask()` — attempt a read and let a `login-required` refusal answer,
169
+ because `viewer.signedIn` is this page's guess and can disagree with the
170
+ session your ops run under. An app that gated on the guess showed a
171
+ signed-in shopper a sign-in wall over a full basket.
172
+ - **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
173
+ `{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
174
+ `{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
175
+ and a search term are questions for the database, with `cursor` paging. `contains` ignores
176
+ case in both clients (a search box that misses "earl grey" is broken quietly). Asking a list a
177
+ scalar's question (or the reverse) is refused with the right one named. **`ctx.emit`** — an op
178
+ records on its OWN timeline, so a fact reaches the fold whether a person or an agent caused it.
179
+ A list field defaults to `[]` (it used to refuse every create), a rule-less model defaults to
180
+ the role your manifest calls the owner, and `viewerProfile` is a real export rather than a
181
+ declaration. Documented the client surface that was already there: `aggregate`, `groupBy`,
182
+ `$transaction`, the `*Many` writes.
163
183
  - **0.6.0** — the full-stack release. Your own tables (`db`, `pluginDb`, rules, scopes, sealed
164
184
  fields, additive migrations applied on install). `viewer` on every seam, app `roles`, per-surface
165
185
  access levels with the sign-in wall. Realtime audiences: a topic says who hears it and the mint
@@ -48,7 +48,9 @@ export interface Core {
48
48
  }
49
49
  export declare const DEFAULT_TAKE = 50;
50
50
  export declare const MAX_TAKE = 200;
51
- export declare const FILTER_OPS: readonly ["in", "notIn", "not", "gt", "gte", "lt", "lte", "contains"];
51
+ export declare const FILTER_OPS: readonly ["in", "notIn", "not", "gt", "gte", "lt", "lte", "contains", "has"];
52
+ /** The only operator a LIST field takes, and the only one a scalar does not. */
53
+ export declare const LIST_ONLY_OPS: readonly ["has"];
52
54
  export type FilterOp = (typeof FILTER_OPS)[number];
53
55
  export declare function bad(c: Core, detail: string): never;
54
56
  /**
@@ -66,6 +68,19 @@ export declare function assertNoInjectedKeys(c: Core, obj: Record<string, unknow
66
68
  export declare function assertFilterable(c: Core, keys: string[], where: string): void;
67
69
  /** The filter operators a `where` value may use — the same list both evaluators implement. */
68
70
  export declare function assertFilterShape(c: Core, where: Record<string, unknown> | undefined): void;
71
+ /**
72
+ * A `contains` MATCHES REGARDLESS OF CASE, in both clients.
73
+ *
74
+ * A search box that answers nothing to "earl grey" and something to "Earl Grey"
75
+ * is broken, and it breaks quietly: the shopper reads an empty shelf. Postgres
76
+ * `LIKE` is case-sensitive and `String.includes` is too, so each client had to
77
+ * be told — the memory one lowercases both sides, and this turns the condition
78
+ * into the `mode: "insensitive"` Prisma needs (ILIKE, which a `gin_trgm_ops`
79
+ * index still serves, so the declaration keeps its meaning).
80
+ *
81
+ * One decision, two consumers, which is why it lives here beside `FILTER_OPS`.
82
+ */
83
+ export declare function insensitiveContains(where: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
69
84
  export type OrderBy = Record<string, "asc" | "desc"> | Record<string, "asc" | "desc">[];
70
85
  export declare function orderTerms(c: Core, orderBy: OrderBy | undefined): Record<string, "asc" | "desc">[];
71
86
  export declare function pageWindow(c: Core, args: {
@@ -31,7 +31,9 @@ export const defaultRefuse = (code, message, detail) => {
31
31
  };
32
32
  export const DEFAULT_TAKE = 50;
33
33
  export const MAX_TAKE = 200;
34
- export const FILTER_OPS = ["in", "notIn", "not", "gt", "gte", "lt", "lte", "contains"];
34
+ export const FILTER_OPS = ["in", "notIn", "not", "gt", "gte", "lt", "lte", "contains", "has"];
35
+ /** The only operator a LIST field takes, and the only one a scalar does not. */
36
+ export const LIST_ONLY_OPS = ["has"];
35
37
  /* ───────────────────────── argument validation ────────────────────────── */
36
38
  export function bad(c, detail) {
37
39
  return c.refuse("invalid", "invalid", detail);
@@ -74,14 +76,54 @@ export function assertFilterShape(c, where) {
74
76
  if (!where)
75
77
  return;
76
78
  for (const [key, cond] of Object.entries(where)) {
79
+ const list = !!c.model.fields[key]?.list;
77
80
  if (cond !== null && typeof cond === "object" && !(cond instanceof Date) && !Array.isArray(cond)) {
78
81
  for (const op of Object.keys(cond)) {
79
82
  if (!FILTER_OPS.includes(op)) {
80
83
  bad(c, `${c.model.name}.where.${key}: unknown filter "${op}" — allowed: ${FILTER_OPS.join(", ")}`);
81
84
  }
85
+ // A list and a scalar take different questions, and asking the wrong
86
+ // one is an author's mistake worth naming rather than an empty result.
87
+ if (list && !LIST_ONLY_OPS.includes(op)) {
88
+ bad(c, `${c.model.name}.where.${key}: "${key}" is a list — ask { has: … }, not { ${op}: … }`);
89
+ }
90
+ if (!list && LIST_ONLY_OPS.includes(op)) {
91
+ bad(c, `${c.model.name}.where.${key}: "has" is for a list field; "${key}" is not one`);
92
+ }
82
93
  }
83
94
  }
95
+ else if (list) {
96
+ bad(c, `${c.model.name}.where.${key}: "${key}" is a list — ask { has: … } rather than a bare value`);
97
+ }
98
+ }
99
+ }
100
+ /**
101
+ * A `contains` MATCHES REGARDLESS OF CASE, in both clients.
102
+ *
103
+ * A search box that answers nothing to "earl grey" and something to "Earl Grey"
104
+ * is broken, and it breaks quietly: the shopper reads an empty shelf. Postgres
105
+ * `LIKE` is case-sensitive and `String.includes` is too, so each client had to
106
+ * be told — the memory one lowercases both sides, and this turns the condition
107
+ * into the `mode: "insensitive"` Prisma needs (ILIKE, which a `gin_trgm_ops`
108
+ * index still serves, so the declaration keeps its meaning).
109
+ *
110
+ * One decision, two consumers, which is why it lives here beside `FILTER_OPS`.
111
+ */
112
+ export function insensitiveContains(where) {
113
+ if (!where)
114
+ return where;
115
+ let changed = false;
116
+ const out = {};
117
+ for (const [key, cond] of Object.entries(where)) {
118
+ if (cond !== null && typeof cond === "object" && !Array.isArray(cond) && !(cond instanceof Date) && "contains" in cond) {
119
+ out[key] = { ...cond, mode: "insensitive" };
120
+ changed = true;
121
+ }
122
+ else {
123
+ out[key] = cond;
124
+ }
84
125
  }
126
+ return changed ? out : where;
85
127
  }
86
128
  export function orderTerms(c, orderBy) {
87
129
  if (!orderBy)
@@ -94,6 +94,28 @@ export interface CompiledRule {
94
94
  /** When set, the only fields this rule permits writing at all. */
95
95
  fields: string[] | null;
96
96
  }
97
+ /**
98
+ * HOW an index is built, named by what the author wants to DO with it rather
99
+ * than by the mechanism:
100
+ *
101
+ * - `btree` (a plain `["a","b"]` group) — equality, ranges, ordering. Led by
102
+ * the scope column, because every query the platform issues narrows to an
103
+ * instance first.
104
+ * - `contains` — a LIST field, so `where: { tags: { has: "gifts" } }` is an
105
+ * index lookup instead of a scan. Becomes a GIN index.
106
+ * - `text` — a string or text field, so `where: { name: { contains: "grey" } }`
107
+ * is an index lookup. Becomes a GIN trigram index, which is the only kind
108
+ * that can serve a substring search; a btree cannot.
109
+ *
110
+ * The last two are single-column and NOT scope-led: without `btree_gin` a GIN
111
+ * index cannot lead with the scope column, so Postgres combines it with the
112
+ * scope index instead. That is the right plan and it is worth knowing.
113
+ */
114
+ export type IndexKind = "btree" | "contains" | "text";
115
+ export interface CompiledIndex {
116
+ fields: string[];
117
+ kind: IndexKind;
118
+ }
97
119
  export interface CompiledModel {
98
120
  /** As declared: `Order`. */
99
121
  name: string;
@@ -105,7 +127,7 @@ export interface CompiledModel {
105
127
  fields: Record<string, CompiledField>;
106
128
  sealed: string[];
107
129
  unique: string[][];
108
- indexes: string[][];
130
+ indexes: CompiledIndex[];
109
131
  /**
110
132
  * Every field a `where` or `orderBy` may name. An unindexed filter is a
111
133
  * table scan waiting to happen at a customer's scale, so it is refused with
@@ -133,7 +155,15 @@ export interface ManifestDbModel {
133
155
  fields: Record<string, string>;
134
156
  sealed?: string[];
135
157
  unique?: string[][];
136
- indexes?: string[][];
158
+ /**
159
+ * A plain group (`["active","createdAt"]`) is a btree. An object names a
160
+ * kind: `{ fields: ["tags"], kind: "contains" }` for a list, or
161
+ * `{ fields: ["title"], kind: "text" }` for substring search.
162
+ */
163
+ indexes?: (string[] | {
164
+ fields: string[];
165
+ kind?: "btree" | "contains" | "text";
166
+ })[];
137
167
  rules?: Record<string, unknown>;
138
168
  }
139
169
  export interface CompileInput {
@@ -205,7 +205,43 @@ export function compileRules(input) {
205
205
  return groups.map((g) => [...g]);
206
206
  };
207
207
  const unique = checkColumns(raw.unique, "unique");
208
- const indexes = checkColumns(raw.indexes, "indexes");
208
+ /**
209
+ * `indexes` takes a plain group (`["active","createdAt"]`) or a kind
210
+ * (`{ "fields": ["tags"], "kind": "contains" }`). A kind is refused when
211
+ * the field cannot support it, by name — an author who indexed a list for
212
+ * text search would otherwise get a working build and a sequential scan
213
+ * at their customers' expense.
214
+ */
215
+ const indexes = (raw.indexes ?? []).map((entry, i) => {
216
+ const where = `${at}.indexes[${i}]`;
217
+ if (Array.isArray(entry))
218
+ return { fields: checkColumns([entry], "indexes")[0], kind: "btree" };
219
+ if (!entry || typeof entry !== "object") {
220
+ throw new RuleCompileError(where, 'an index is a list of columns (["a","b"]) or { "fields": [...], "kind": "contains" | "text" }');
221
+ }
222
+ const e = entry;
223
+ if (!Array.isArray(e.fields) || !e.fields.length)
224
+ throw new RuleCompileError(`${where}.fields`, "name at least one field");
225
+ const flds = checkColumns([e.fields.map(String)], "indexes")[0];
226
+ const kind = e.kind === undefined ? "btree" : String(e.kind);
227
+ if (kind !== "btree" && kind !== "contains" && kind !== "text") {
228
+ throw new RuleCompileError(`${where}.kind`, `unknown kind "${kind}" — allowed: btree, contains, text`);
229
+ }
230
+ if (kind !== "btree") {
231
+ if (flds.length !== 1)
232
+ throw new RuleCompileError(`${where}.fields`, `a "${kind}" index covers exactly one field`);
233
+ const f = fields[flds[0]];
234
+ if (!f)
235
+ throw new RuleCompileError(`${where}.fields`, `"${flds[0]}" is a platform column; a "${kind}" index covers one of your own fields`);
236
+ if (kind === "contains" && !f.list) {
237
+ throw new RuleCompileError(`${where}`, `"${flds[0]}" is not a list — a "contains" index is for a list field (e.g. "tags": "string[]"). For substring search on text use "kind": "text".`);
238
+ }
239
+ if (kind === "text" && (f.list || (f.type !== "string" && f.type !== "text"))) {
240
+ throw new RuleCompileError(`${where}`, `"${flds[0]}" is ${f.list ? "a list" : `a ${f.type}`} — a "text" index is for a single string or text field.`);
241
+ }
242
+ }
243
+ return { fields: flds, kind };
244
+ });
209
245
  const sealed = raw.sealed ?? [];
210
246
  for (const f of sealed) {
211
247
  if (!fieldNames.includes(f)) {
@@ -235,7 +271,7 @@ export function compileRules(input) {
235
271
  ...new Set([
236
272
  ...PLATFORM_FILTERABLE,
237
273
  ...unique.flat(),
238
- ...indexes.flat(),
274
+ ...indexes.flatMap((i) => i.fields),
239
275
  ]),
240
276
  ].sort();
241
277
  models[name] = {
@@ -86,7 +86,16 @@ export function createMemoryDb(options) {
86
86
  return false;
87
87
  break;
88
88
  case "contains":
89
- if (typeof value !== "string" || !value.includes(String(operand)))
89
+ // Regardless of case, matching the Prisma arm's ILIKE — a search
90
+ // box that answers "Earl Grey" and not "earl grey" is broken, and
91
+ // it breaks by showing an empty shelf.
92
+ if (typeof value !== "string" || !value.toLowerCase().includes(String(operand).toLowerCase()))
93
+ return false;
94
+ break;
95
+ case "has":
96
+ // A LIST field holds this value. The one question a list takes,
97
+ // and the one a GIN index answers.
98
+ if (!Array.isArray(value) || !value.some((x) => x === operand))
90
99
  return false;
91
100
  break;
92
101
  default:
@@ -34,13 +34,17 @@
34
34
  * start with the scope column is one the planner cannot use for the query
35
35
  * the platform actually runs.
36
36
  */
37
- import type { CompiledRules } from "./compile-rules.js";
37
+ import type { CompiledRules, IndexKind } from "./compile-rules.js";
38
38
  /** `ShipAddress` → `ship_address`; `Order` → `order`. */
39
39
  export declare function snakeCase(name: string): string;
40
40
  /** The Prisma model name: `plugin_shop_min__Order`. */
41
41
  export declare function prismaModelName(applicationType: string, model: string): string;
42
42
  /** The table it maps to: `plugin_shop_min__order`. */
43
43
  export declare function prismaTableName(applicationType: string, model: string): string;
44
+ export interface IndexSpec {
45
+ fields: string[];
46
+ kind: IndexKind;
47
+ }
44
48
  /**
45
49
  * The Prisma schema fragment for one app — every model, platform columns
46
50
  * first, scope-led indexes, mapped to a prefixed table. No datasource and no
@@ -84,6 +88,8 @@ export interface TableColumn {
84
88
  export interface TableIndex {
85
89
  name: string;
86
90
  columns: string[];
91
+ /** Absent on an index read back from a live database (see `introspect`). */
92
+ kind?: IndexKind;
87
93
  }
88
94
  export interface TableSpec {
89
95
  name: string;
@@ -71,12 +71,12 @@ function indexGroups(model) {
71
71
  const lead = scopeColumn(model.scope);
72
72
  const out = [];
73
73
  const seen = new Set();
74
- const push = (cols) => {
75
- const key = cols.join(",");
74
+ const push = (fields, kind = "btree") => {
75
+ const key = `${kind}:${fields.join(",")}`;
76
76
  if (seen.has(key))
77
77
  return;
78
78
  seen.add(key);
79
- out.push(cols);
79
+ out.push({ fields, kind });
80
80
  };
81
81
  // The scope index every query starts from.
82
82
  push(model.scope === "instance" ? ["workspaceId", "nodeId"] : [lead]);
@@ -84,11 +84,37 @@ function indexGroups(model) {
84
84
  push(["ownerId"]);
85
85
  // Declared groups, `unique` first then `indexes`, each led by the scope
86
86
  // column. A group that already names the lead column is not doubled.
87
- for (const group of [...model.unique, ...model.indexes]) {
87
+ for (const group of model.unique)
88
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);
89
97
  }
90
98
  return out;
91
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
+ }
92
118
  function renderModel(model, applicationType) {
93
119
  const cols = [
94
120
  ...platformColumns(model.scope),
@@ -100,7 +126,7 @@ function renderModel(model, applicationType) {
100
126
  const head = ` ${c.name.padEnd(nameW)} ${c.type.padEnd(typeW)}`;
101
127
  return (c.attrs ? `${head} ${c.attrs}` : head).trimEnd();
102
128
  });
103
- const idx = indexGroups(model).map((g) => ` @@index([${g.join(", ")}])`);
129
+ const idx = indexGroups(model).map((g) => ` @@index(${prismaIndexArgs(g)})`);
104
130
  return [
105
131
  `model ${prismaModelName(applicationType, model.name)} {`,
106
132
  ...lines,
@@ -202,8 +228,12 @@ export function dbTypings(rules, opts) {
202
228
  " | { lt: V }",
203
229
  " | { lte: V }",
204
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 };",
205
233
  "/** 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 };",
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
+ "};",
207
237
  "export type OrderBy<K extends string> = { [P in K]?: \"asc\" | \"desc\" };",
208
238
  "",
209
239
  "export interface FindManyArgs<T, K extends keyof T & string> {",
@@ -306,7 +336,7 @@ export function tableSpecs(rules, opts) {
306
336
  return {
307
337
  name: table,
308
338
  columns: cols.map(sqlColumn),
309
- indexes: indexGroups(model).map((g) => ({ name: indexName(table, g), columns: g })),
339
+ indexes: indexGroups(model).map((g) => ({ name: indexName(table, g.fields), columns: g.fields, kind: g.kind })),
310
340
  };
311
341
  });
312
342
  }
@@ -318,6 +348,10 @@ export function createTableSql(t) {
318
348
  return [`CREATE TABLE ${q(t.name)} (`, ...t.columns.map((c) => ` ${columnDef(c)},`), "", ` CONSTRAINT ${q(`${t.name}_pkey`)} PRIMARY KEY ("id")`, ");"].join("\n");
319
349
  }
320
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(", ")});`;
321
355
  return `CREATE INDEX ${q(idx.name)} ON ${q(table)}(${idx.columns.map(q).join(", ")});`;
322
356
  }
323
357
  export function addColumnSql(table, c) {
package/dist/helpers.d.ts CHANGED
@@ -40,8 +40,13 @@ export declare function incompleteStateNotice(args: {
40
40
  * from the URL rather than plumbed through the app, because the URL is the one
41
41
  * thing that is true on every surface without the app having to care.
42
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.)
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.
45
50
  */
46
51
  export declare function currentShareId(): string | null;
47
52
  /**
package/dist/helpers.js CHANGED
@@ -66,12 +66,20 @@ export function incompleteStateNotice(args) {
66
66
  * from the URL rather than plumbed through the app, because the URL is the one
67
67
  * thing that is true on every surface without the app having to care.
68
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.)
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.
71
76
  */
72
77
  export function currentShareId() {
73
78
  if (typeof window === "undefined")
74
79
  return null;
80
+ const told = window.__esoulShareId;
81
+ if (typeof told === "string" && told)
82
+ return told;
75
83
  const m = /^\/share\/([^/?#]+)/.exec(window.location.pathname);
76
84
  return m ? decodeURIComponent(m[1]) : null;
77
85
  }
@@ -565,7 +565,16 @@ export declare const PluginManifestSchema: z.ZodObject<{
565
565
  fields: z.ZodRecord<z.ZodString, z.ZodString>;
566
566
  sealed: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
567
567
  unique: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodString, "many">, "many">>;
568
- indexes: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodString, "many">, "many">>;
568
+ indexes: z.ZodOptional<z.ZodArray<z.ZodUnion<[z.ZodArray<z.ZodString, "many">, z.ZodObject<{
569
+ fields: z.ZodArray<z.ZodString, "many">;
570
+ kind: z.ZodOptional<z.ZodEnum<["btree", "contains", "text"]>>;
571
+ }, "strict", z.ZodTypeAny, {
572
+ fields: string[];
573
+ kind?: "btree" | "contains" | "text" | undefined;
574
+ }, {
575
+ fields: string[];
576
+ kind?: "btree" | "contains" | "text" | undefined;
577
+ }>]>, "many">>;
569
578
  rules: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
570
579
  }, "strict", z.ZodTypeAny, {
571
580
  fields: Record<string, string>;
@@ -573,7 +582,10 @@ export declare const PluginManifestSchema: z.ZodObject<{
573
582
  scope?: "workspace" | "instance" | "user" | undefined;
574
583
  sealed?: string[] | undefined;
575
584
  unique?: string[][] | undefined;
576
- indexes?: string[][] | undefined;
585
+ indexes?: (string[] | {
586
+ fields: string[];
587
+ kind?: "btree" | "contains" | "text" | undefined;
588
+ })[] | undefined;
577
589
  rules?: Record<string, unknown> | undefined;
578
590
  }, {
579
591
  fields: Record<string, string>;
@@ -581,7 +593,10 @@ export declare const PluginManifestSchema: z.ZodObject<{
581
593
  scope?: "workspace" | "instance" | "user" | undefined;
582
594
  sealed?: string[] | undefined;
583
595
  unique?: string[][] | undefined;
584
- indexes?: string[][] | undefined;
596
+ indexes?: (string[] | {
597
+ fields: string[];
598
+ kind?: "btree" | "contains" | "text" | undefined;
599
+ })[] | undefined;
585
600
  rules?: Record<string, unknown> | undefined;
586
601
  }>>>;
587
602
  }, "strict", z.ZodTypeAny, {
@@ -677,7 +692,10 @@ export declare const PluginManifestSchema: z.ZodObject<{
677
692
  scope?: "workspace" | "instance" | "user" | undefined;
678
693
  sealed?: string[] | undefined;
679
694
  unique?: string[][] | undefined;
680
- indexes?: string[][] | undefined;
695
+ indexes?: (string[] | {
696
+ fields: string[];
697
+ kind?: "btree" | "contains" | "text" | undefined;
698
+ })[] | undefined;
681
699
  rules?: Record<string, unknown> | undefined;
682
700
  }> | undefined;
683
701
  }, {
@@ -773,7 +791,10 @@ export declare const PluginManifestSchema: z.ZodObject<{
773
791
  scope?: "workspace" | "instance" | "user" | undefined;
774
792
  sealed?: string[] | undefined;
775
793
  unique?: string[][] | undefined;
776
- indexes?: string[][] | undefined;
794
+ indexes?: (string[] | {
795
+ fields: string[];
796
+ kind?: "btree" | "contains" | "text" | undefined;
797
+ })[] | undefined;
777
798
  rules?: Record<string, unknown> | undefined;
778
799
  }> | undefined;
779
800
  }>;
package/dist/manifest.js CHANGED
@@ -331,7 +331,14 @@ export const PluginManifestSchema = z.object({
331
331
  fields: z.record(z.string().regex(/^[a-z][A-Za-z0-9]*$/), z.string()),
332
332
  sealed: z.array(z.string()).optional(),
333
333
  unique: z.array(z.array(z.string())).optional(),
334
- indexes: z.array(z.array(z.string())).optional(),
334
+ // A plain group is a btree; an object names a kind — `contains` for
335
+ // a list field, `text` for substring search (docs/14).
336
+ indexes: z
337
+ .array(z.union([
338
+ z.array(z.string()),
339
+ z.object({ fields: z.array(z.string()).min(1), kind: z.enum(["btree", "contains", "text"]).optional() }).strict(),
340
+ ]))
341
+ .optional(),
335
342
  rules: z.record(z.string(), z.unknown()).optional(),
336
343
  })
337
344
  .strict())
package/dist/server.d.ts CHANGED
@@ -98,6 +98,23 @@ export interface PluginOpContext {
98
98
  role: string;
99
99
  };
100
100
  }): Promise<void>;
101
+ /**
102
+ * RECORD SOMETHING ON YOUR OWN TIMELINE, from the server half.
103
+ *
104
+ * The fold is for what is shared, small and worth scrubbing. Until this
105
+ * existed, only the UI could put anything there — so a shop whose products
106
+ * were added by its AGENT had a catalogue and no departments, because the
107
+ * departments were only recorded by the owner's form (2026-09-12). Anything
108
+ * a person can cause through your UI, an agent can cause through a tool, and
109
+ * the server is the only place that sees both.
110
+ *
111
+ * The event is your own (`eventName` from your schema), it goes through the
112
+ * platform's spine — your `dataCreator` mints, your reducer folds, triggers
113
+ * fire — and it is recorded against THIS instance. Give it a change id you
114
+ * can derive again (`product:<id>`, never a random one) so a retry, or the
115
+ * UI's own optimistic dispatch of the same fact, is a no-op.
116
+ */
117
+ emit(eventName: string, eventData: Record<string, unknown>): Promise<void>;
101
118
  /**
102
119
  * The apps standing in this app's SLOTS (manifest `uses`), by slot name
103
120
  * (S9). A slot the owner has not filled is ABSENT — `ctx.apps.stock?.call(…)`
@@ -205,6 +222,97 @@ export declare function pluginDb<T = unknown>(_ctx: {
205
222
  across?: "owned-instances" | "my-rows";
206
223
  viaBinding?: boolean;
207
224
  }): Promise<T>;
225
+ /**
226
+ * A PICTURE FOR YOUR APP, FROM ONE CALL.
227
+ *
228
+ * Generates an image, brings it to web weight and returns an **https URL** to
229
+ * store on your own row and render. You do not choose a model, resize
230
+ * anything, or know where the bytes live.
231
+ *
232
+ * const pic = await generateAppImage(ctx, {
233
+ * name: product.sku ?? product.id,
234
+ * prompt: "A small dish of loose-leaf black tea beside a glass cup of amber tea.",
235
+ * // the SAME style for every picture in the app, so a shelf looks
236
+ * // photographed on one table instead of bought from a library:
237
+ * style: "On warm oatmeal linen, soft window light from the left, shallow depth of field.",
238
+ * shape: "square",
239
+ * });
240
+ * if (pic.ok) await db.product.update({ where: { id }, data: { imageUrl: pic.url } });
241
+ *
242
+ * A refusal comes back as `{ ok: false, error }` naming every provider that
243
+ * said no — an account denied by one and unkeyed for another are different
244
+ * problems. Flat, so an app compiled with `strict: false` can read it.
245
+ *
246
+ * HOST-ONLY. It spends money; keep it behind an op only the owner may call.
247
+ */
248
+ export declare function generateAppImage(_ctx: {
249
+ pluginId: string;
250
+ nodeId: string;
251
+ }, _args: {
252
+ name: string;
253
+ prompt: string;
254
+ style?: string;
255
+ shape?: "square" | "wide" | "tall";
256
+ }): Promise<GeneratedAppImage>;
257
+ export interface GeneratedAppImage {
258
+ ok: boolean;
259
+ /** Store this on your row and put it in an `img src`. */
260
+ url?: string;
261
+ bytes?: number;
262
+ model?: string;
263
+ error?: string;
264
+ }
265
+ /**
266
+ * WHO ELSE MAY USE THIS INSTANCE, AND AS WHAT.
267
+ *
268
+ * Your app declares its own role words (`roles.vocabulary`), and the owner can
269
+ * hand one to another esoul account by EMAIL. The platform does all of it: it
270
+ * checks the caller owns the app, resolves the email against a real account
271
+ * once, refuses a role your manifest never declared, and records the grant on
272
+ * this instance's own timeline — so your op is a pass-through and your app
273
+ * cannot forge a grant.
274
+ *
275
+ * `role: ""` takes it back. The next time that person opens the app,
276
+ * `ctx.viewer.role` is the word you gave them, and your `db` rules decide the
277
+ * rest. It does NOT make them a writer of the workspace: a grant changes your
278
+ * app's word for someone, never the platform's capabilities.
279
+ *
280
+ * HOST-ONLY.
281
+ */
282
+ export declare function setAppRole(_ctx: {
283
+ pluginId: string;
284
+ workspaceId: string;
285
+ nodeId: string;
286
+ viewer: PluginViewer;
287
+ }, _args: {
288
+ email: string;
289
+ role: string;
290
+ }): Promise<AppRoleResult>;
291
+ /** Everyone the owner has given a role in THIS instance. HOST-ONLY. */
292
+ export declare function listAppRoles(_ctx: {
293
+ pluginId: string;
294
+ workspaceId: string;
295
+ nodeId: string;
296
+ viewer: PluginViewer;
297
+ }): Promise<{
298
+ people: AppRolePerson[];
299
+ roles: string[];
300
+ }>;
301
+ export interface AppRolePerson {
302
+ userId: string;
303
+ /** As the owner typed it, for the screen. Never what is checked. */
304
+ email: string | null;
305
+ role: string;
306
+ grantedAt?: number;
307
+ }
308
+ /** Flat, not a discriminated union: an app compiled with `strict: false` cannot narrow one. */
309
+ export interface AppRoleResult {
310
+ ok: boolean;
311
+ userId?: string;
312
+ email?: string;
313
+ role?: string;
314
+ error?: string;
315
+ }
208
316
  export interface EmitPluginAppEventArgs {
209
317
  source: {
210
318
  pluginId: string;
package/dist/server.js CHANGED
@@ -46,6 +46,56 @@ export function getPluginConnectionCredentials(_connectionId, _pluginId) {
46
46
  export function pluginDb(_ctx, _reach) {
47
47
  return hostOnly("pluginDb");
48
48
  }
49
+ /**
50
+ * A PICTURE FOR YOUR APP, FROM ONE CALL.
51
+ *
52
+ * Generates an image, brings it to web weight and returns an **https URL** to
53
+ * store on your own row and render. You do not choose a model, resize
54
+ * anything, or know where the bytes live.
55
+ *
56
+ * const pic = await generateAppImage(ctx, {
57
+ * name: product.sku ?? product.id,
58
+ * prompt: "A small dish of loose-leaf black tea beside a glass cup of amber tea.",
59
+ * // the SAME style for every picture in the app, so a shelf looks
60
+ * // photographed on one table instead of bought from a library:
61
+ * style: "On warm oatmeal linen, soft window light from the left, shallow depth of field.",
62
+ * shape: "square",
63
+ * });
64
+ * if (pic.ok) await db.product.update({ where: { id }, data: { imageUrl: pic.url } });
65
+ *
66
+ * A refusal comes back as `{ ok: false, error }` naming every provider that
67
+ * said no — an account denied by one and unkeyed for another are different
68
+ * problems. Flat, so an app compiled with `strict: false` can read it.
69
+ *
70
+ * HOST-ONLY. It spends money; keep it behind an op only the owner may call.
71
+ */
72
+ export function generateAppImage(_ctx, _args) {
73
+ return hostOnly("generateAppImage");
74
+ }
75
+ /**
76
+ * WHO ELSE MAY USE THIS INSTANCE, AND AS WHAT.
77
+ *
78
+ * Your app declares its own role words (`roles.vocabulary`), and the owner can
79
+ * hand one to another esoul account by EMAIL. The platform does all of it: it
80
+ * checks the caller owns the app, resolves the email against a real account
81
+ * once, refuses a role your manifest never declared, and records the grant on
82
+ * this instance's own timeline — so your op is a pass-through and your app
83
+ * cannot forge a grant.
84
+ *
85
+ * `role: ""` takes it back. The next time that person opens the app,
86
+ * `ctx.viewer.role` is the word you gave them, and your `db` rules decide the
87
+ * rest. It does NOT make them a writer of the workspace: a grant changes your
88
+ * app's word for someone, never the platform's capabilities.
89
+ *
90
+ * HOST-ONLY.
91
+ */
92
+ export function setAppRole(_ctx, _args) {
93
+ return hostOnly("setAppRole");
94
+ }
95
+ /** Everyone the owner has given a role in THIS instance. HOST-ONLY. */
96
+ export function listAppRoles(_ctx) {
97
+ return hostOnly("listAppRoles");
98
+ }
49
99
  /**
50
100
  * Cross-app events: dispatch the TARGET app's own events through the
51
101
  * platform spine (target's dataCreator mints; triggers fire; every event is
@@ -33,6 +33,8 @@ export declare function fakeViewer(kind: ViewerKind, opts?: {
33
33
  userId?: string | null;
34
34
  viewerIds?: string[];
35
35
  role?: string;
36
+ name?: string | null;
37
+ email?: string | null;
36
38
  }): RuleViewer;
37
39
  /**
38
40
  * The app's own word for a kind of caller, read from the manifest's `roles`.
@@ -39,6 +39,15 @@ const DEFAULT_IDS = {
39
39
  export function fakeViewer(kind, opts = {}) {
40
40
  const userId = opts.userId !== undefined ? opts.userId : DEFAULT_IDS[kind];
41
41
  const viewerIds = opts.viewerIds ?? (userId ? [userId] : []);
42
+ // THE ACCOUNT BEHIND THE VIEWER, for `viewerProfile`. A test that names one
43
+ // gets it back from the op under test; a test that names none gets null —
44
+ // and never a database. Registered on globalThis because the host's
45
+ // `viewerProfile` is the one that answers, and this is how it learns that a
46
+ // test is asking (the same seam `runAsViewer` uses).
47
+ if (userId && (opts.name !== undefined || opts.email !== undefined)) {
48
+ const g = globalThis;
49
+ (g.__esoulFakeProfiles ??= {})[userId] = { userId, name: opts.name ?? null, email: opts.email ?? null, picture: null };
50
+ }
42
51
  return { kind, userId, viewerIds, role: opts.role ?? PLATFORM_DEFAULT_ROLE[kind] };
43
52
  }
44
53
  /**
package/docs/05-ui.md CHANGED
@@ -114,3 +114,31 @@ installed, only the grants in `plugin.json` `workspaceTools` are allowed. Declar
114
114
  The platform is built as craft: a sticky-notes wall is linen and paper with a gummed strip, not a
115
115
  grid of yellow rectangles. Derive any randomness from stable ids, never `Math.random()` in
116
116
  render, so nothing twitches between renders. Motion is brief and respects reduced-motion.
117
+
118
+ ## Pictures
119
+
120
+ `generateAppImage` is one call: it generates, brings the bytes to web weight and
121
+ returns an **https URL** to store on your own row.
122
+
123
+ ```ts
124
+ const pic = await generateAppImage(ctx, {
125
+ name: product.sku ?? product.id,
126
+ prompt: "A small dish of loose-leaf black tea beside a glass cup of amber tea.",
127
+ // THE SAME style for every picture in the app. This is what makes a shelf
128
+ // look photographed on one table instead of bought from a library.
129
+ style: "On warm oatmeal linen, soft window light from the left, shallow depth of field.",
130
+ shape: "square", // or "wide" for a hero, "tall"
131
+ });
132
+ if (pic.ok) await db.product.update({ where: { id }, data: { imageUrl: pic.url } });
133
+ ```
134
+
135
+ It costs real money, so keep it behind an op only the owner may call. A
136
+ workbench refuses it on purpose — a preview that billed on every hot reload
137
+ would be a bad surprise, and one that returned a fake URL a worse one.
138
+
139
+ **Draw the empty case first.** A generated picture is the second version of a
140
+ screen; the first is the one with no picture at all, which is what every app
141
+ looks like on the day it is installed. An `aspect-square` box with a small
142
+ inline SVG pattern and the thing's icon reads as "not photographed yet"; a grey
143
+ rectangle reads as broken. Fix the aspect ratio in both branches and nothing
144
+ moves when a photograph arrives.
package/docs/06-server.md CHANGED
@@ -60,6 +60,35 @@ Gated exactly like the UI: the manifest must declare `"my_computer:claude_task"`
60
60
  instance with `targetNodeId` instead of `appType`. This is how an app runs a `my_computer`
61
61
  (commands, Claude Code sessions, results), fills a spreadsheet from a job, or books a calendar.
62
62
 
63
+ ## `ctx.emit` — record on your OWN timeline
64
+
65
+ Anything a person can cause through your UI, an agent can cause through a tool,
66
+ and the server is the only place that sees both. So when a change belongs on the
67
+ fold, the OP records it, not the screen:
68
+
69
+ ```ts
70
+ async function addProduct(ctx: PluginOpContext) {
71
+ const p = await (await pluginDb<ShopDb>(ctx)).product.create({ data: … });
72
+ // Derive the change id. A retry — and the UI's own optimistic dispatch of the
73
+ // same fact — is then a no-op rather than a second bump.
74
+ await ctx.emit("plugin_shop_catalogue_changed", { changeId: `product:${p.id}`, tags: p.tags });
75
+ return { id: p.id };
76
+ }
77
+ ```
78
+
79
+ It is your own event (`eventName` from your schema), it goes through the
80
+ platform's spine — your `dataCreator` mints, your reducer folds, triggers fire —
81
+ and it lands on THIS instance.
82
+
83
+ **The failure this exists to prevent.** An app offered its departments from the
84
+ fold and recorded them only in the owner's add-product FORM. Stocked by its
85
+ agent instead, it therefore had a full catalogue and no departments at all,
86
+ live on a customer's site. If a fact belongs on the timeline, the op is where
87
+ it is written — a screen is one of its callers, never the only one.
88
+
89
+ Treat it as a courtesy around a completed write, like `notify`: catch a failure
90
+ and report it, rather than letting it undo a product that exists.
91
+
63
92
  ## `emitPluginAppEvent`
64
93
 
65
94
  Emit ANOTHER app's own events (its `dataCreator` shape) into the same workspace, actor-stamped as
@@ -46,6 +46,10 @@ profile to read, so an app cannot look somebody up. It is null for anyone withou
46
46
  which is the same thing `requires: "account"` is about. Use it to pre-fill a form rather than
47
47
  asking a signed-in person to type what the platform already knows.
48
48
 
49
+ In a test, `fakeViewer("visitor", { userId: "u_ada", role: "requester", name: "Ada", email: "ada@x.test" })`
50
+ is what `viewerProfile` answers for that viewer; a fake viewer with no name or email answers null.
51
+ No test ever reaches a database for it.
52
+
49
53
  ## Roles — your app's own vocabulary
50
54
 
51
55
  The platform's words are about a workspace. Your app's words are about your app. A help desk has
@@ -146,3 +150,47 @@ record" but "does the OTHER one see it".
146
150
 
147
151
  `look_at_app` takes the same personas, so you can photograph the screen each of them gets — the
148
152
  one screen an author can otherwise never see, because the author is always the owner.
153
+
154
+ ## Who has an account: ASK, never assume
155
+
156
+ `viewer.signedIn` is the platform's guess from THIS PAGE's capabilities. On a
157
+ shared link it can disagree with the session your ops actually run under — so
158
+ an app that gates its reads on it shows a signed-in person an empty basket and
159
+ a sign-in wall while the server is holding their order. That is a real bug, seen
160
+ on a real shop.
161
+
162
+ So never gate a read. Attempt it, and let the refusal answer:
163
+
164
+ ```tsx
165
+ const wall = useSignInWall();
166
+
167
+ const load = useCallback(() => {
168
+ // `ask` runs the call whatever this page believes, and hands back `null`
169
+ // when the SERVER says there is no account — which also raises the wall.
170
+ wall.ask<Cart>(() => callPluginOp(PLUGIN_ID, "view-cart", nodeId)).then((c) => c && setCart(c));
171
+ }, [wall, nodeId]);
172
+ ```
173
+
174
+ `wall.serverSays` is `null` until the server answers, then `"account"` or
175
+ `"no-account"`. Use `viewer.signedIn` for WORDING if you like (it saves a
176
+ flicker) and `serverSays` for anything that hides a screen. And if you hold
177
+ data the server already gave you — lines in a basket — show it: a wall over a
178
+ full basket is never right.
179
+
180
+ ## Giving one person access to one app
181
+
182
+ An owner can hand another esoul account one of YOUR role words, by email:
183
+
184
+ ```ts
185
+ const r = await setAppRole(ctx, { email: "helper@example.com", role: "staff" });
186
+ if (!r.ok) throw new Error(r.error); // "no esoul account has that email"
187
+ const { people, roles } = await listAppRoles(ctx);
188
+ ```
189
+
190
+ The platform does the deciding: only the app's owner may call it, the email is
191
+ resolved once against a real (non-guest) account, and the role must be one your
192
+ manifest declares. The grant lands on this instance's own timeline, so it can be
193
+ read back, explained and scrubbed. Next time that person opens the app,
194
+ `ctx.viewer.role` is the word you gave them and your `db` rules do the rest —
195
+ it does not make them a writer of the workspace, and it can never demote the
196
+ owner.
@@ -88,6 +88,112 @@ query is refused as `invalid` rather than quietly overridden.
88
88
  Ordering and filtering are limited to the fields you indexed — an unindexed `orderBy` is a compile
89
89
  error in your editor, not a slow query in production.
90
90
 
91
+ ### The whole surface
92
+
93
+ Your generated type is the reference (open `.esoul/db.d.ts`), and this is all of it:
94
+
95
+ | | |
96
+ |---|---|
97
+ | read | `findMany` · `findFirst` · `findUnique({ where: { id } })` · `count` |
98
+ | summarise | `aggregate({ where?, _count, _sum, _avg, _min, _max })` · `groupBy({ by, … })` |
99
+ | write | `create` · `createMany` · `update({ where: { id }, data })` · `updateMany` · `upsert` · `delete` · `deleteMany` |
100
+ | together | `$transaction(fn)` |
101
+
102
+ `findMany` takes `where`, `orderBy`, `take`, `skip`, `cursor: { id }`. Every `where` — including
103
+ an `aggregate`'s — obeys the index wall above: a field you did not index is not a question, and
104
+ asking it is refused as `invalid` rather than answered slowly. **Count and sum in the database**
105
+ rather than paging rows to add them up in JavaScript; `groupBy` still returns only the rows this
106
+ viewer may read, so a customer's own total is their own and an aggregate is never a way around a
107
+ rule.
108
+
109
+ ### Transactions
110
+
111
+ `$transaction` gives you the one guarantee a pair of writes cannot give itself: either both
112
+ happened, or neither did.
113
+
114
+ ```ts
115
+ const order = await db.$transaction(async (tx) => {
116
+ const written = await tx.order.create({ data: { totalCents, shipTo } });
117
+ for (const l of lines) await tx.orderLine.create({ data: { order: written.id, ...l } });
118
+ await tx.cartLine.deleteMany(); // the basket that made it
119
+ return written;
120
+ });
121
+ ```
122
+
123
+ Every gap between two writes is a real state somebody's session can end in. An order committed
124
+ and a basket emptied a moment later is not a detail: the moment in between is "paid for, still in
125
+ the basket", which is how a person buys the same thing twice. Put the writes that must be true
126
+ together in one call, and let a throw undo all of them.
127
+
128
+ Rules still apply inside, per statement, for the same viewer — a transaction is atomicity, never
129
+ elevation.
130
+
131
+ **What a transaction cannot hold.** A call into ANOTHER app (`ctx.apps.<slot>.call(…)`, an email,
132
+ a payment) is outside it by construction: the other app's writes are its own. For those, hold →
133
+ write → and **release on failure**, yourself:
134
+
135
+ ```ts
136
+ const held = [];
137
+ try {
138
+ for (const l of lines) { await stock.call("reserve_stock", l); held.push(l); }
139
+ return await db.$transaction(…);
140
+ } catch (err) {
141
+ for (const h of held) await stock.call("release_stock", h); // never throws out of here
142
+ throw err;
143
+ }
144
+ ```
145
+
146
+ A reservation no order ever used is worse than a refusal: nothing marks it stale, so the shelf
147
+ reads empty while the goods sit there.
148
+
149
+ ## Indexes for a catalogue
150
+
151
+ A plain group is a btree: equality, ranges, ordering. Two other kinds exist because a real
152
+ catalogue asks two questions a btree cannot answer.
153
+
154
+ ```json
155
+ "indexes": [
156
+ ["active", "createdAt"],
157
+ { "fields": ["tags"], "kind": "contains" },
158
+ { "fields": ["title"], "kind": "text" }
159
+ ]
160
+ ```
161
+
162
+ | kind | the field | what it lets you ask |
163
+ |---|---|---|
164
+ | `btree` (a plain group) | any | `where: { active: true }`, `orderBy: { createdAt: "desc" }` |
165
+ | `contains` | a LIST | `where: { tags: { has: "gifts" } }` |
166
+ | `text` | a string or text | `where: { title: { contains: "grey" } }` — **regardless of case** |
167
+
168
+ ```ts
169
+ // A department and a search term, both as a WHERE — with a cursor, so the
170
+ // hundredth page costs what the first one did.
171
+ db.product.findMany({
172
+ where: { active: true, tags: { has: dept }, title: { contains: term } },
173
+ orderBy: { createdAt: "asc" },
174
+ take: 24,
175
+ cursor: after ? { id: after } : undefined,
176
+ });
177
+ ```
178
+
179
+ `contains` ignores case in both clients (`ILIKE`, which a trigram index still serves), because a
180
+ search box that finds "Earl Grey" and not "earl grey" is broken in the quietest possible way — the
181
+ shopper just reads an empty shelf.
182
+
183
+ **A list takes one question and a scalar takes the others.** `{ tags: { has: … } }` on a list,
184
+ never a bare value and never `contains`; `{ title: { contains: … } }` on text, never `has`. Asking
185
+ the wrong one is refused as `invalid` with the right one named, because an empty result would look
186
+ like an empty shelf. (A refusal's `message` is the code; the sentence written for you is on
187
+ `err.detail`.)
188
+
189
+ Both of the new kinds become GIN indexes, and neither is led by the scope column — a GIN index
190
+ cannot be, so the database combines it with the scope index instead. That is the right plan, and
191
+ it is why `contains` and `text` cover exactly one field each.
192
+
193
+ **Why this matters more than it looks.** Without them, a shop with 100 000 products can only
194
+ return a page and filter it in the browser, so a department with nothing on the first page looks
195
+ empty. The declaration is the whole difference between a catalogue and a list.
196
+
91
197
  ## Migrations
92
198
 
93
199
  On install the platform computes what your declaration means for the live database and applies it
package/llms-full.txt CHANGED
@@ -164,6 +164,26 @@ something: does the write survive?
164
164
 
165
165
  ## Versions
166
166
 
167
+ - **0.8.0** — the three things making an app LOOK like a product turned out to
168
+ need: `generateAppImage` (one call — generates, brings the bytes to web
169
+ weight, hands back an https URL; a 2.4 MB PNG is not a page), `setAppRole` /
170
+ `listAppRoles` (an owner gives one esoul account one of your role words, by
171
+ email; the platform does every check and records it on the app's timeline),
172
+ and `wall.ask()` — attempt a read and let a `login-required` refusal answer,
173
+ because `viewer.signedIn` is this page's guess and can disagree with the
174
+ session your ops run under. An app that gated on the guess showed a
175
+ signed-in shopper a sign-in wall over a full basket.
176
+ - **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
177
+ `{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
178
+ `{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
179
+ and a search term are questions for the database, with `cursor` paging. `contains` ignores
180
+ case in both clients (a search box that misses "earl grey" is broken quietly). Asking a list a
181
+ scalar's question (or the reverse) is refused with the right one named. **`ctx.emit`** — an op
182
+ records on its OWN timeline, so a fact reaches the fold whether a person or an agent caused it.
183
+ A list field defaults to `[]` (it used to refuse every create), a rule-less model defaults to
184
+ the role your manifest calls the owner, and `viewerProfile` is a real export rather than a
185
+ declaration. Documented the client surface that was already there: `aggregate`, `groupBy`,
186
+ `$transaction`, the `*Many` writes.
167
187
  - **0.6.0** — the full-stack release. Your own tables (`db`, `pluginDb`, rules, scopes, sealed
168
188
  fields, additive migrations applied on install). `viewer` on every seam, app `roles`, per-surface
169
189
  access levels with the sign-in wall. Realtime audiences: a topic says who hears it and the mint
@@ -657,6 +677,34 @@ The platform is built as craft: a sticky-notes wall is linen and paper with a gu
657
677
  grid of yellow rectangles. Derive any randomness from stable ids, never `Math.random()` in
658
678
  render, so nothing twitches between renders. Motion is brief and respects reduced-motion.
659
679
 
680
+ ## Pictures
681
+
682
+ `generateAppImage` is one call: it generates, brings the bytes to web weight and
683
+ returns an **https URL** to store on your own row.
684
+
685
+ ```ts
686
+ const pic = await generateAppImage(ctx, {
687
+ name: product.sku ?? product.id,
688
+ prompt: "A small dish of loose-leaf black tea beside a glass cup of amber tea.",
689
+ // THE SAME style for every picture in the app. This is what makes a shelf
690
+ // look photographed on one table instead of bought from a library.
691
+ style: "On warm oatmeal linen, soft window light from the left, shallow depth of field.",
692
+ shape: "square", // or "wide" for a hero, "tall"
693
+ });
694
+ if (pic.ok) await db.product.update({ where: { id }, data: { imageUrl: pic.url } });
695
+ ```
696
+
697
+ It costs real money, so keep it behind an op only the owner may call. A
698
+ workbench refuses it on purpose — a preview that billed on every hot reload
699
+ would be a bad surprise, and one that returned a fake URL a worse one.
700
+
701
+ **Draw the empty case first.** A generated picture is the second version of a
702
+ screen; the first is the one with no picture at all, which is what every app
703
+ looks like on the day it is installed. An `aspect-square` box with a small
704
+ inline SVG pattern and the thing's icon reads as "not photographed yet"; a grey
705
+ rectangle reads as broken. Fix the aspect ratio in both branches and nothing
706
+ moves when a photograph arrives.
707
+
660
708
 
661
709
 
662
710
  ==============================================================================
@@ -722,6 +770,35 @@ Gated exactly like the UI: the manifest must declare `"my_computer:claude_task"`
722
770
  instance with `targetNodeId` instead of `appType`. This is how an app runs a `my_computer`
723
771
  (commands, Claude Code sessions, results), fills a spreadsheet from a job, or books a calendar.
724
772
 
773
+ ## `ctx.emit` — record on your OWN timeline
774
+
775
+ Anything a person can cause through your UI, an agent can cause through a tool,
776
+ and the server is the only place that sees both. So when a change belongs on the
777
+ fold, the OP records it, not the screen:
778
+
779
+ ```ts
780
+ async function addProduct(ctx: PluginOpContext) {
781
+ const p = await (await pluginDb<ShopDb>(ctx)).product.create({ data: … });
782
+ // Derive the change id. A retry — and the UI's own optimistic dispatch of the
783
+ // same fact — is then a no-op rather than a second bump.
784
+ await ctx.emit("plugin_shop_catalogue_changed", { changeId: `product:${p.id}`, tags: p.tags });
785
+ return { id: p.id };
786
+ }
787
+ ```
788
+
789
+ It is your own event (`eventName` from your schema), it goes through the
790
+ platform's spine — your `dataCreator` mints, your reducer folds, triggers fire —
791
+ and it lands on THIS instance.
792
+
793
+ **The failure this exists to prevent.** An app offered its departments from the
794
+ fold and recorded them only in the owner's add-product FORM. Stocked by its
795
+ agent instead, it therefore had a full catalogue and no departments at all,
796
+ live on a customer's site. If a fact belongs on the timeline, the op is where
797
+ it is written — a screen is one of its callers, never the only one.
798
+
799
+ Treat it as a courtesy around a completed write, like `notify`: catch a failure
800
+ and report it, rather than letting it undo a product that exists.
801
+
725
802
  ## `emitPluginAppEvent`
726
803
 
727
804
  Emit ANOTHER app's own events (its `dataCreator` shape) into the same workspace, actor-stamped as
@@ -1222,6 +1299,10 @@ profile to read, so an app cannot look somebody up. It is null for anyone withou
1222
1299
  which is the same thing `requires: "account"` is about. Use it to pre-fill a form rather than
1223
1300
  asking a signed-in person to type what the platform already knows.
1224
1301
 
1302
+ In a test, `fakeViewer("visitor", { userId: "u_ada", role: "requester", name: "Ada", email: "ada@x.test" })`
1303
+ is what `viewerProfile` answers for that viewer; a fake viewer with no name or email answers null.
1304
+ No test ever reaches a database for it.
1305
+
1225
1306
  ## Roles — your app's own vocabulary
1226
1307
 
1227
1308
  The platform's words are about a workspace. Your app's words are about your app. A help desk has
@@ -1323,6 +1404,50 @@ record" but "does the OTHER one see it".
1323
1404
  `look_at_app` takes the same personas, so you can photograph the screen each of them gets — the
1324
1405
  one screen an author can otherwise never see, because the author is always the owner.
1325
1406
 
1407
+ ## Who has an account: ASK, never assume
1408
+
1409
+ `viewer.signedIn` is the platform's guess from THIS PAGE's capabilities. On a
1410
+ shared link it can disagree with the session your ops actually run under — so
1411
+ an app that gates its reads on it shows a signed-in person an empty basket and
1412
+ a sign-in wall while the server is holding their order. That is a real bug, seen
1413
+ on a real shop.
1414
+
1415
+ So never gate a read. Attempt it, and let the refusal answer:
1416
+
1417
+ ```tsx
1418
+ const wall = useSignInWall();
1419
+
1420
+ const load = useCallback(() => {
1421
+ // `ask` runs the call whatever this page believes, and hands back `null`
1422
+ // when the SERVER says there is no account — which also raises the wall.
1423
+ wall.ask<Cart>(() => callPluginOp(PLUGIN_ID, "view-cart", nodeId)).then((c) => c && setCart(c));
1424
+ }, [wall, nodeId]);
1425
+ ```
1426
+
1427
+ `wall.serverSays` is `null` until the server answers, then `"account"` or
1428
+ `"no-account"`. Use `viewer.signedIn` for WORDING if you like (it saves a
1429
+ flicker) and `serverSays` for anything that hides a screen. And if you hold
1430
+ data the server already gave you — lines in a basket — show it: a wall over a
1431
+ full basket is never right.
1432
+
1433
+ ## Giving one person access to one app
1434
+
1435
+ An owner can hand another esoul account one of YOUR role words, by email:
1436
+
1437
+ ```ts
1438
+ const r = await setAppRole(ctx, { email: "helper@example.com", role: "staff" });
1439
+ if (!r.ok) throw new Error(r.error); // "no esoul account has that email"
1440
+ const { people, roles } = await listAppRoles(ctx);
1441
+ ```
1442
+
1443
+ The platform does the deciding: only the app's owner may call it, the email is
1444
+ resolved once against a real (non-guest) account, and the role must be one your
1445
+ manifest declares. The grant lands on this instance's own timeline, so it can be
1446
+ read back, explained and scrubbed. Next time that person opens the app,
1447
+ `ctx.viewer.role` is the word you gave them and your `db` rules do the rest —
1448
+ it does not make them a writer of the workspace, and it can never demote the
1449
+ owner.
1450
+
1326
1451
 
1327
1452
 
1328
1453
  ==============================================================================
@@ -1416,6 +1541,112 @@ query is refused as `invalid` rather than quietly overridden.
1416
1541
  Ordering and filtering are limited to the fields you indexed — an unindexed `orderBy` is a compile
1417
1542
  error in your editor, not a slow query in production.
1418
1543
 
1544
+ ### The whole surface
1545
+
1546
+ Your generated type is the reference (open `.esoul/db.d.ts`), and this is all of it:
1547
+
1548
+ | | |
1549
+ |---|---|
1550
+ | read | `findMany` · `findFirst` · `findUnique({ where: { id } })` · `count` |
1551
+ | summarise | `aggregate({ where?, _count, _sum, _avg, _min, _max })` · `groupBy({ by, … })` |
1552
+ | write | `create` · `createMany` · `update({ where: { id }, data })` · `updateMany` · `upsert` · `delete` · `deleteMany` |
1553
+ | together | `$transaction(fn)` |
1554
+
1555
+ `findMany` takes `where`, `orderBy`, `take`, `skip`, `cursor: { id }`. Every `where` — including
1556
+ an `aggregate`'s — obeys the index wall above: a field you did not index is not a question, and
1557
+ asking it is refused as `invalid` rather than answered slowly. **Count and sum in the database**
1558
+ rather than paging rows to add them up in JavaScript; `groupBy` still returns only the rows this
1559
+ viewer may read, so a customer's own total is their own and an aggregate is never a way around a
1560
+ rule.
1561
+
1562
+ ### Transactions
1563
+
1564
+ `$transaction` gives you the one guarantee a pair of writes cannot give itself: either both
1565
+ happened, or neither did.
1566
+
1567
+ ```ts
1568
+ const order = await db.$transaction(async (tx) => {
1569
+ const written = await tx.order.create({ data: { totalCents, shipTo } });
1570
+ for (const l of lines) await tx.orderLine.create({ data: { order: written.id, ...l } });
1571
+ await tx.cartLine.deleteMany(); // the basket that made it
1572
+ return written;
1573
+ });
1574
+ ```
1575
+
1576
+ Every gap between two writes is a real state somebody's session can end in. An order committed
1577
+ and a basket emptied a moment later is not a detail: the moment in between is "paid for, still in
1578
+ the basket", which is how a person buys the same thing twice. Put the writes that must be true
1579
+ together in one call, and let a throw undo all of them.
1580
+
1581
+ Rules still apply inside, per statement, for the same viewer — a transaction is atomicity, never
1582
+ elevation.
1583
+
1584
+ **What a transaction cannot hold.** A call into ANOTHER app (`ctx.apps.<slot>.call(…)`, an email,
1585
+ a payment) is outside it by construction: the other app's writes are its own. For those, hold →
1586
+ write → and **release on failure**, yourself:
1587
+
1588
+ ```ts
1589
+ const held = [];
1590
+ try {
1591
+ for (const l of lines) { await stock.call("reserve_stock", l); held.push(l); }
1592
+ return await db.$transaction(…);
1593
+ } catch (err) {
1594
+ for (const h of held) await stock.call("release_stock", h); // never throws out of here
1595
+ throw err;
1596
+ }
1597
+ ```
1598
+
1599
+ A reservation no order ever used is worse than a refusal: nothing marks it stale, so the shelf
1600
+ reads empty while the goods sit there.
1601
+
1602
+ ## Indexes for a catalogue
1603
+
1604
+ A plain group is a btree: equality, ranges, ordering. Two other kinds exist because a real
1605
+ catalogue asks two questions a btree cannot answer.
1606
+
1607
+ ```json
1608
+ "indexes": [
1609
+ ["active", "createdAt"],
1610
+ { "fields": ["tags"], "kind": "contains" },
1611
+ { "fields": ["title"], "kind": "text" }
1612
+ ]
1613
+ ```
1614
+
1615
+ | kind | the field | what it lets you ask |
1616
+ |---|---|---|
1617
+ | `btree` (a plain group) | any | `where: { active: true }`, `orderBy: { createdAt: "desc" }` |
1618
+ | `contains` | a LIST | `where: { tags: { has: "gifts" } }` |
1619
+ | `text` | a string or text | `where: { title: { contains: "grey" } }` — **regardless of case** |
1620
+
1621
+ ```ts
1622
+ // A department and a search term, both as a WHERE — with a cursor, so the
1623
+ // hundredth page costs what the first one did.
1624
+ db.product.findMany({
1625
+ where: { active: true, tags: { has: dept }, title: { contains: term } },
1626
+ orderBy: { createdAt: "asc" },
1627
+ take: 24,
1628
+ cursor: after ? { id: after } : undefined,
1629
+ });
1630
+ ```
1631
+
1632
+ `contains` ignores case in both clients (`ILIKE`, which a trigram index still serves), because a
1633
+ search box that finds "Earl Grey" and not "earl grey" is broken in the quietest possible way — the
1634
+ shopper just reads an empty shelf.
1635
+
1636
+ **A list takes one question and a scalar takes the others.** `{ tags: { has: … } }` on a list,
1637
+ never a bare value and never `contains`; `{ title: { contains: … } }` on text, never `has`. Asking
1638
+ the wrong one is refused as `invalid` with the right one named, because an empty result would look
1639
+ like an empty shelf. (A refusal's `message` is the code; the sentence written for you is on
1640
+ `err.detail`.)
1641
+
1642
+ Both of the new kinds become GIN indexes, and neither is led by the scope column — a GIN index
1643
+ cannot be, so the database combines it with the scope index instead. That is the right plan, and
1644
+ it is why `contains` and `text` cover exactly one field each.
1645
+
1646
+ **Why this matters more than it looks.** Without them, a shop with 100 000 products can only
1647
+ return a page and filter it in the browser, so a department with nothing on the first page looks
1648
+ empty. The declaration is the whole difference between a catalogue and a list.
1649
+
1419
1650
  ## Migrations
1420
1651
 
1421
1652
  On install the platform computes what your declaration means for the live database and applies it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esoul-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Build a full product on ExternalSoul: your own tables with per-person rules, a viewer on every seam, app roles, access levels, realtime with audiences, durable tasks, and bindings to other apps.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -540,10 +540,38 @@
540
540
  "indexes": {
541
541
  "type": "array",
542
542
  "items": {
543
- "type": "array",
544
- "items": {
545
- "type": "string"
546
- }
543
+ "anyOf": [
544
+ {
545
+ "type": "array",
546
+ "items": {
547
+ "type": "string"
548
+ }
549
+ },
550
+ {
551
+ "type": "object",
552
+ "properties": {
553
+ "fields": {
554
+ "type": "array",
555
+ "items": {
556
+ "type": "string"
557
+ },
558
+ "minItems": 1
559
+ },
560
+ "kind": {
561
+ "type": "string",
562
+ "enum": [
563
+ "btree",
564
+ "contains",
565
+ "text"
566
+ ]
567
+ }
568
+ },
569
+ "required": [
570
+ "fields"
571
+ ],
572
+ "additionalProperties": false
573
+ }
574
+ ]
547
575
  }
548
576
  },
549
577
  "rules": {