esoul-sdk 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -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 +17 -0
- package/dist/testing/db.d.ts +2 -0
- package/dist/testing/db.js +9 -0
- package/docs/06-server.md +29 -0
- package/docs/13-people-and-access.md +4 -0
- package/docs/14-database.md +106 -0
- package/llms-full.txt +150 -0
- package/package.json +1 -1
- package/schemas/plugin.schema.json +32 -4
package/README.md
CHANGED
|
@@ -160,6 +160,17 @@ something: does the write survive?
|
|
|
160
160
|
|
|
161
161
|
## Versions
|
|
162
162
|
|
|
163
|
+
- **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
|
|
164
|
+
`{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
|
|
165
|
+
`{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
|
|
166
|
+
and a search term are questions for the database, with `cursor` paging. `contains` ignores
|
|
167
|
+
case in both clients (a search box that misses "earl grey" is broken quietly). Asking a list a
|
|
168
|
+
scalar's question (or the reverse) is refused with the right one named. **`ctx.emit`** — an op
|
|
169
|
+
records on its OWN timeline, so a fact reaches the fold whether a person or an agent caused it.
|
|
170
|
+
A list field defaults to `[]` (it used to refuse every create), a rule-less model defaults to
|
|
171
|
+
the role your manifest calls the owner, and `viewerProfile` is a real export rather than a
|
|
172
|
+
declaration. Documented the client surface that was already there: `aggregate`, `groupBy`,
|
|
173
|
+
`$transaction`, the `*Many` writes.
|
|
163
174
|
- **0.6.0** — the full-stack release. Your own tables (`db`, `pluginDb`, rules, scopes, sealed
|
|
164
175
|
fields, additive migrations applied on install). `viewer` on every seam, app `roles`, per-surface
|
|
165
176
|
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(…)`
|
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/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
|
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,17 @@ something: does the write survive?
|
|
|
164
164
|
|
|
165
165
|
## Versions
|
|
166
166
|
|
|
167
|
+
- **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
|
|
168
|
+
`{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
|
|
169
|
+
`{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
|
|
170
|
+
and a search term are questions for the database, with `cursor` paging. `contains` ignores
|
|
171
|
+
case in both clients (a search box that misses "earl grey" is broken quietly). Asking a list a
|
|
172
|
+
scalar's question (or the reverse) is refused with the right one named. **`ctx.emit`** — an op
|
|
173
|
+
records on its OWN timeline, so a fact reaches the fold whether a person or an agent caused it.
|
|
174
|
+
A list field defaults to `[]` (it used to refuse every create), a rule-less model defaults to
|
|
175
|
+
the role your manifest calls the owner, and `viewerProfile` is a real export rather than a
|
|
176
|
+
declaration. Documented the client surface that was already there: `aggregate`, `groupBy`,
|
|
177
|
+
`$transaction`, the `*Many` writes.
|
|
167
178
|
- **0.6.0** — the full-stack release. Your own tables (`db`, `pluginDb`, rules, scopes, sealed
|
|
168
179
|
fields, additive migrations applied on install). `viewer` on every seam, app `roles`, per-surface
|
|
169
180
|
access levels with the sign-in wall. Realtime audiences: a topic says who hears it and the mint
|
|
@@ -722,6 +733,35 @@ Gated exactly like the UI: the manifest must declare `"my_computer:claude_task"`
|
|
|
722
733
|
instance with `targetNodeId` instead of `appType`. This is how an app runs a `my_computer`
|
|
723
734
|
(commands, Claude Code sessions, results), fills a spreadsheet from a job, or books a calendar.
|
|
724
735
|
|
|
736
|
+
## `ctx.emit` — record on your OWN timeline
|
|
737
|
+
|
|
738
|
+
Anything a person can cause through your UI, an agent can cause through a tool,
|
|
739
|
+
and the server is the only place that sees both. So when a change belongs on the
|
|
740
|
+
fold, the OP records it, not the screen:
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
async function addProduct(ctx: PluginOpContext) {
|
|
744
|
+
const p = await (await pluginDb<ShopDb>(ctx)).product.create({ data: … });
|
|
745
|
+
// Derive the change id. A retry — and the UI's own optimistic dispatch of the
|
|
746
|
+
// same fact — is then a no-op rather than a second bump.
|
|
747
|
+
await ctx.emit("plugin_shop_catalogue_changed", { changeId: `product:${p.id}`, tags: p.tags });
|
|
748
|
+
return { id: p.id };
|
|
749
|
+
}
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
It is your own event (`eventName` from your schema), it goes through the
|
|
753
|
+
platform's spine — your `dataCreator` mints, your reducer folds, triggers fire —
|
|
754
|
+
and it lands on THIS instance.
|
|
755
|
+
|
|
756
|
+
**The failure this exists to prevent.** An app offered its departments from the
|
|
757
|
+
fold and recorded them only in the owner's add-product FORM. Stocked by its
|
|
758
|
+
agent instead, it therefore had a full catalogue and no departments at all,
|
|
759
|
+
live on a customer's site. If a fact belongs on the timeline, the op is where
|
|
760
|
+
it is written — a screen is one of its callers, never the only one.
|
|
761
|
+
|
|
762
|
+
Treat it as a courtesy around a completed write, like `notify`: catch a failure
|
|
763
|
+
and report it, rather than letting it undo a product that exists.
|
|
764
|
+
|
|
725
765
|
## `emitPluginAppEvent`
|
|
726
766
|
|
|
727
767
|
Emit ANOTHER app's own events (its `dataCreator` shape) into the same workspace, actor-stamped as
|
|
@@ -1222,6 +1262,10 @@ profile to read, so an app cannot look somebody up. It is null for anyone withou
|
|
|
1222
1262
|
which is the same thing `requires: "account"` is about. Use it to pre-fill a form rather than
|
|
1223
1263
|
asking a signed-in person to type what the platform already knows.
|
|
1224
1264
|
|
|
1265
|
+
In a test, `fakeViewer("visitor", { userId: "u_ada", role: "requester", name: "Ada", email: "ada@x.test" })`
|
|
1266
|
+
is what `viewerProfile` answers for that viewer; a fake viewer with no name or email answers null.
|
|
1267
|
+
No test ever reaches a database for it.
|
|
1268
|
+
|
|
1225
1269
|
## Roles — your app's own vocabulary
|
|
1226
1270
|
|
|
1227
1271
|
The platform's words are about a workspace. Your app's words are about your app. A help desk has
|
|
@@ -1416,6 +1460,112 @@ query is refused as `invalid` rather than quietly overridden.
|
|
|
1416
1460
|
Ordering and filtering are limited to the fields you indexed — an unindexed `orderBy` is a compile
|
|
1417
1461
|
error in your editor, not a slow query in production.
|
|
1418
1462
|
|
|
1463
|
+
### The whole surface
|
|
1464
|
+
|
|
1465
|
+
Your generated type is the reference (open `.esoul/db.d.ts`), and this is all of it:
|
|
1466
|
+
|
|
1467
|
+
| | |
|
|
1468
|
+
|---|---|
|
|
1469
|
+
| read | `findMany` · `findFirst` · `findUnique({ where: { id } })` · `count` |
|
|
1470
|
+
| summarise | `aggregate({ where?, _count, _sum, _avg, _min, _max })` · `groupBy({ by, … })` |
|
|
1471
|
+
| write | `create` · `createMany` · `update({ where: { id }, data })` · `updateMany` · `upsert` · `delete` · `deleteMany` |
|
|
1472
|
+
| together | `$transaction(fn)` |
|
|
1473
|
+
|
|
1474
|
+
`findMany` takes `where`, `orderBy`, `take`, `skip`, `cursor: { id }`. Every `where` — including
|
|
1475
|
+
an `aggregate`'s — obeys the index wall above: a field you did not index is not a question, and
|
|
1476
|
+
asking it is refused as `invalid` rather than answered slowly. **Count and sum in the database**
|
|
1477
|
+
rather than paging rows to add them up in JavaScript; `groupBy` still returns only the rows this
|
|
1478
|
+
viewer may read, so a customer's own total is their own and an aggregate is never a way around a
|
|
1479
|
+
rule.
|
|
1480
|
+
|
|
1481
|
+
### Transactions
|
|
1482
|
+
|
|
1483
|
+
`$transaction` gives you the one guarantee a pair of writes cannot give itself: either both
|
|
1484
|
+
happened, or neither did.
|
|
1485
|
+
|
|
1486
|
+
```ts
|
|
1487
|
+
const order = await db.$transaction(async (tx) => {
|
|
1488
|
+
const written = await tx.order.create({ data: { totalCents, shipTo } });
|
|
1489
|
+
for (const l of lines) await tx.orderLine.create({ data: { order: written.id, ...l } });
|
|
1490
|
+
await tx.cartLine.deleteMany(); // the basket that made it
|
|
1491
|
+
return written;
|
|
1492
|
+
});
|
|
1493
|
+
```
|
|
1494
|
+
|
|
1495
|
+
Every gap between two writes is a real state somebody's session can end in. An order committed
|
|
1496
|
+
and a basket emptied a moment later is not a detail: the moment in between is "paid for, still in
|
|
1497
|
+
the basket", which is how a person buys the same thing twice. Put the writes that must be true
|
|
1498
|
+
together in one call, and let a throw undo all of them.
|
|
1499
|
+
|
|
1500
|
+
Rules still apply inside, per statement, for the same viewer — a transaction is atomicity, never
|
|
1501
|
+
elevation.
|
|
1502
|
+
|
|
1503
|
+
**What a transaction cannot hold.** A call into ANOTHER app (`ctx.apps.<slot>.call(…)`, an email,
|
|
1504
|
+
a payment) is outside it by construction: the other app's writes are its own. For those, hold →
|
|
1505
|
+
write → and **release on failure**, yourself:
|
|
1506
|
+
|
|
1507
|
+
```ts
|
|
1508
|
+
const held = [];
|
|
1509
|
+
try {
|
|
1510
|
+
for (const l of lines) { await stock.call("reserve_stock", l); held.push(l); }
|
|
1511
|
+
return await db.$transaction(…);
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
for (const h of held) await stock.call("release_stock", h); // never throws out of here
|
|
1514
|
+
throw err;
|
|
1515
|
+
}
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1518
|
+
A reservation no order ever used is worse than a refusal: nothing marks it stale, so the shelf
|
|
1519
|
+
reads empty while the goods sit there.
|
|
1520
|
+
|
|
1521
|
+
## Indexes for a catalogue
|
|
1522
|
+
|
|
1523
|
+
A plain group is a btree: equality, ranges, ordering. Two other kinds exist because a real
|
|
1524
|
+
catalogue asks two questions a btree cannot answer.
|
|
1525
|
+
|
|
1526
|
+
```json
|
|
1527
|
+
"indexes": [
|
|
1528
|
+
["active", "createdAt"],
|
|
1529
|
+
{ "fields": ["tags"], "kind": "contains" },
|
|
1530
|
+
{ "fields": ["title"], "kind": "text" }
|
|
1531
|
+
]
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
| kind | the field | what it lets you ask |
|
|
1535
|
+
|---|---|---|
|
|
1536
|
+
| `btree` (a plain group) | any | `where: { active: true }`, `orderBy: { createdAt: "desc" }` |
|
|
1537
|
+
| `contains` | a LIST | `where: { tags: { has: "gifts" } }` |
|
|
1538
|
+
| `text` | a string or text | `where: { title: { contains: "grey" } }` — **regardless of case** |
|
|
1539
|
+
|
|
1540
|
+
```ts
|
|
1541
|
+
// A department and a search term, both as a WHERE — with a cursor, so the
|
|
1542
|
+
// hundredth page costs what the first one did.
|
|
1543
|
+
db.product.findMany({
|
|
1544
|
+
where: { active: true, tags: { has: dept }, title: { contains: term } },
|
|
1545
|
+
orderBy: { createdAt: "asc" },
|
|
1546
|
+
take: 24,
|
|
1547
|
+
cursor: after ? { id: after } : undefined,
|
|
1548
|
+
});
|
|
1549
|
+
```
|
|
1550
|
+
|
|
1551
|
+
`contains` ignores case in both clients (`ILIKE`, which a trigram index still serves), because a
|
|
1552
|
+
search box that finds "Earl Grey" and not "earl grey" is broken in the quietest possible way — the
|
|
1553
|
+
shopper just reads an empty shelf.
|
|
1554
|
+
|
|
1555
|
+
**A list takes one question and a scalar takes the others.** `{ tags: { has: … } }` on a list,
|
|
1556
|
+
never a bare value and never `contains`; `{ title: { contains: … } }` on text, never `has`. Asking
|
|
1557
|
+
the wrong one is refused as `invalid` with the right one named, because an empty result would look
|
|
1558
|
+
like an empty shelf. (A refusal's `message` is the code; the sentence written for you is on
|
|
1559
|
+
`err.detail`.)
|
|
1560
|
+
|
|
1561
|
+
Both of the new kinds become GIN indexes, and neither is led by the scope column — a GIN index
|
|
1562
|
+
cannot be, so the database combines it with the scope index instead. That is the right plan, and
|
|
1563
|
+
it is why `contains` and `text` cover exactly one field each.
|
|
1564
|
+
|
|
1565
|
+
**Why this matters more than it looks.** Without them, a shop with 100 000 products can only
|
|
1566
|
+
return a page and filter it in the browser, so a department with nothing on the first page looks
|
|
1567
|
+
empty. The declaration is the whole difference between a catalogue and a list.
|
|
1568
|
+
|
|
1419
1569
|
## Migrations
|
|
1420
1570
|
|
|
1421
1571
|
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.7.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": {
|