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 +20 -0
- package/dist/db/client-core.d.ts +16 -1
- package/dist/db/client-core.js +43 -1
- package/dist/db/compile-rules.d.ts +32 -2
- package/dist/db/compile-rules.js +38 -2
- package/dist/db/memory-client.js +10 -1
- package/dist/db/schema-gen.d.ts +7 -1
- package/dist/db/schema-gen.js +41 -7
- package/dist/helpers.d.ts +7 -2
- package/dist/helpers.js +10 -2
- package/dist/manifest.d.ts +26 -5
- package/dist/manifest.js +8 -1
- package/dist/server.d.ts +108 -0
- package/dist/server.js +50 -0
- package/dist/testing/db.d.ts +2 -0
- package/dist/testing/db.js +9 -0
- package/docs/05-ui.md +28 -0
- package/docs/06-server.md +29 -0
- package/docs/13-people-and-access.md +48 -0
- package/docs/14-database.md +106 -0
- package/llms-full.txt +231 -0
- package/package.json +1 -1
- package/schemas/plugin.schema.json +32 -4
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
|
package/dist/db/client-core.d.ts
CHANGED
|
@@ -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: {
|
package/dist/db/client-core.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 {
|
package/dist/db/compile-rules.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
274
|
+
...indexes.flatMap((i) => i.fields),
|
|
239
275
|
]),
|
|
240
276
|
].sort();
|
|
241
277
|
models[name] = {
|
package/dist/db/memory-client.js
CHANGED
|
@@ -86,7 +86,16 @@ export function createMemoryDb(options) {
|
|
|
86
86
|
return false;
|
|
87
87
|
break;
|
|
88
88
|
case "contains":
|
|
89
|
-
|
|
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:
|
package/dist/db/schema-gen.d.ts
CHANGED
|
@@ -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;
|
package/dist/db/schema-gen.js
CHANGED
|
@@ -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 = (
|
|
75
|
-
const key =
|
|
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(
|
|
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
|
|
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(
|
|
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> = {
|
|
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
|
-
*
|
|
44
|
-
* server-side and is
|
|
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
|
-
*
|
|
70
|
-
* server-side and is
|
|
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
|
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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">,
|
|
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[]
|
|
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[]
|
|
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[]
|
|
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[]
|
|
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
|
-
|
|
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
|
package/dist/testing/db.d.ts
CHANGED
|
@@ -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`.
|
package/dist/testing/db.js
CHANGED
|
@@ -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.
|
package/docs/14-database.md
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
544
|
-
|
|
545
|
-
|
|
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": {
|