esoul-sdk 0.16.0 → 0.17.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/api-reference.md CHANGED
@@ -2583,11 +2583,27 @@ interface ViewerProfile {
2583
2583
  ```
2584
2584
 
2585
2585
  ==============================================================================
2586
- ## `esoul-sdk/react` — 46 exports
2586
+ ## `esoul-sdk/react` — 52 exports
2587
2587
 
2588
2588
  The app's UI: hooks for the viewer, the app's state, realtime, workspace files and tools.
2589
2589
 
2590
- ### Functions and values (22)
2590
+ ### Functions and values (27)
2591
+
2592
+ #### `describeFailure` — function · src/failed-requests.ts
2593
+
2594
+ Words and retryability for a thrown request: `PluginCallError` carries status + code; a fetch that never reached the server throws a TypeError.
2595
+
2596
+ ```ts
2597
+ function describeFailure(error: unknown): { why: string; retryable: boolean }
2598
+ ```
2599
+
2600
+ #### `dismissFailedRequest` — function · src/failed-requests.ts
2601
+
2602
+ Take a request off the banner — it landed after all, or the person dismissed it.
2603
+
2604
+ ```ts
2605
+ function dismissFailedRequest(key: string): void
2606
+ ```
2591
2607
 
2592
2608
  #### `FilesBrowseError` — class · src/react.ts
2593
2609
 
@@ -2605,6 +2621,14 @@ A polygon labeller for one image — the Explorer's mask editor as a component:
2605
2621
  function ImageLabeler(_props: ImageLabelerProps): any
2606
2622
  ```
2607
2623
 
2624
+ #### `isRetryableFailure` — function · src/failed-requests.ts
2625
+
2626
+ Is this failure worth a Try again? A lost connection, a timeout, a server error or a rate limit — yes. A refusal (the server read the request and said no, with a reason) — retrying sends the same no; the app shows the reason instead.
2627
+
2628
+ ```ts
2629
+ function isRetryableFailure(r: { reason?: string; status?: number }): boolean
2630
+ ```
2631
+
2608
2632
  #### `listFileEntries` — function · src/react.ts
2609
2633
 
2610
2634
  One page of a folder, as a plain call (no hook).
@@ -2629,6 +2653,14 @@ Folders matching what is being typed: names that START with it, then names that
2629
2653
  function rankFolderSuggestions(entries: FileEntry[], partial: string, limit = 50): FileEntry[]
2630
2654
  ```
2631
2655
 
2656
+ #### `reportFailedRequest` — function · src/failed-requests.ts
2657
+
2658
+ Report a request that failed after the screen already showed it; the banner names it (and offers Try again when `retry` is given). The same `key` replaces, never stacks.
2659
+
2660
+ ```ts
2661
+ function reportFailedRequest(r: Omit<FailedRequest, "at" | "retrying"> & { at?: number }): void
2662
+ ```
2663
+
2632
2664
  #### `resolveFilePath` — function · src/react.ts
2633
2665
 
2634
2666
  Walk "a/b/c" from the source's root by exact names, as a plain call.
@@ -2637,6 +2669,14 @@ Walk "a/b/c" from the source's root by exact names, as a plain call.
2637
2669
  function resolveFilePath(_workspaceId: string, _sourceId: string, _path: string): Promise<ResolvedFilePath>
2638
2670
  ```
2639
2671
 
2672
+ #### `runOptimistic` — function · src/failed-requests.ts
2673
+
2674
+ The optimistic-UI rule in one call. `apply` puts the result on screen at once; `send` does the work; on success any earlier failure of the same `key` clears. On failure `revert` puts the screen back and the request is reported: a connection loss, timeout or server error gets Try again (which runs the whole thing again, apply included), a refusal shows its reason only — retrying would send the same no.
2675
+
2676
+ ```ts
2677
+ async function runOptimistic<T>(r: { key: string; what: string; apply?: () => void; revert?: () => void; send: () => Promise<T>; }): Promise<{ ok: true; result: T } | { ok: false; error: unknown }>
2678
+ ```
2679
+
2640
2680
  #### `splitTypedPath` — function · src/react.ts
2641
2681
 
2642
2682
  "sheets/quality/Ty" → `{ parentPath: "sheets/quality", partial: "Ty" }`; a trailing "/" means "inside it". Pure.
@@ -2765,7 +2805,22 @@ Invoke tools of OTHER apps in the workspace (append a spreadsheet row, add a cal
2765
2805
  function useWorkspaceTools(_identity: { workspaceId: string; nodeId: string }): WorkspaceTools
2766
2806
  ```
2767
2807
 
2768
- ### Types (24)
2808
+ ### Types (25)
2809
+
2810
+ #### `FailedRequest` — interface · src/failed-requests.ts
2811
+
2812
+ The platform's list of requests that failed after the UI already showed their result (the optimistic-UI rule, app-style-guide.md "Interaction"): an app updates the screen at once, and when the request behind it fails the app puts the screen back and reports here — `<FailedRequestBanner/>` then names the request and offers Try again, in the same place and style as the "new version is available" pill. Pure and framework-free, so any app (built-in or Forge) and any test can use it. A Forge app imports it from `esoul-sdk/react`; the platform's `src/lib/failed-requests.ts` re-exports this very module, so both reach one list.
2813
+
2814
+ ```ts
2815
+ interface FailedRequest {
2816
+ key: string;
2817
+ what: string;
2818
+ why?: string;
2819
+ retry?: () => Promise<boolean> | boolean;
2820
+ at: number;
2821
+ retrying?: boolean;
2822
+ }
2823
+ ```
2769
2824
 
2770
2825
  #### `FileEntriesState` — interface · src/react.ts
2771
2826
 
@@ -92,9 +92,19 @@ export declare function assertFilterShape(c: Core, where: Record<string, unknown
92
92
  export declare function insensitiveContains(where: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
93
93
  export type OrderBy = Record<string, "asc" | "desc"> | Record<string, "asc" | "desc">[];
94
94
  export declare function orderTerms(c: Core, orderBy: OrderBy | undefined): Record<string, "asc" | "desc">[];
95
+ /**
96
+ * The order a page is read in, made TOTAL: the author's terms, then `id`. A cursor
97
+ * names a row by id, so the rows after it are well defined only when no two rows
98
+ * tie — ordering by `date` alone let Postgres return rows sharing the cursor's date
99
+ * twice or not at all (Achievement Network's demo seed read an achievement twice on
100
+ * its second page, 2026-09-29). Without an orderBy and without a cursor the order is
101
+ * left alone (insertion order in memory, the database's own otherwise).
102
+ */
103
+ export declare function totalOrder(terms: Record<string, "asc" | "desc">[], cursor: unknown): Record<string, "asc" | "desc">[];
95
104
  export declare function pageWindow(c: Core, args: {
96
105
  take?: number;
97
106
  skip?: number;
107
+ cursor?: unknown;
98
108
  } | undefined): {
99
109
  take: number;
100
110
  skip: number;
@@ -142,6 +142,19 @@ export function orderTerms(c, orderBy) {
142
142
  assertFilterable(c, Object.keys(t), "orderBy");
143
143
  return terms;
144
144
  }
145
+ /**
146
+ * The order a page is read in, made TOTAL: the author's terms, then `id`. A cursor
147
+ * names a row by id, so the rows after it are well defined only when no two rows
148
+ * tie — ordering by `date` alone let Postgres return rows sharing the cursor's date
149
+ * twice or not at all (Achievement Network's demo seed read an achievement twice on
150
+ * its second page, 2026-09-29). Without an orderBy and without a cursor the order is
151
+ * left alone (insertion order in memory, the database's own otherwise).
152
+ */
153
+ export function totalOrder(terms, cursor) {
154
+ if (!terms.length && !cursor)
155
+ return terms;
156
+ return terms.some((t) => "id" in t) ? terms : [...terms, { id: "asc" }];
157
+ }
145
158
  export function pageWindow(c, args) {
146
159
  const take = args?.take ?? DEFAULT_TAKE;
147
160
  if (take > MAX_TAKE)
@@ -151,6 +164,10 @@ export function pageWindow(c, args) {
151
164
  const skip = args?.skip ?? 0;
152
165
  if (skip < 0)
153
166
  bad(c, "skip must not be negative");
167
+ // Prisma's idiom is `cursor` + `skip: 1` (its cursor row is included); here the rows
168
+ // AFTER the cursor come back, so that `skip: 1` silently dropped a row per page.
169
+ if (args?.cursor && skip)
170
+ bad(c, "skip with cursor: the cursor's own row is already left out — drop skip");
154
171
  return { take, skip };
155
172
  }
156
173
  /** Seeing soft-deleted rows is the owner's (or the app's own task's). */
@@ -189,6 +189,13 @@ export interface ManifestDbModel {
189
189
  }
190
190
  export interface CompileInput {
191
191
  pluginId: string;
192
+ /**
193
+ * The app's `applicationType` — the prefix of every table it owns. Optional
194
+ * because an author's tests may compile a bare `db` block; when it is known,
195
+ * the compiler refuses a table name Postgres would truncate (and the
196
+ * generator, which always knows it, refuses the same).
197
+ */
198
+ applicationType?: string;
192
199
  db: Record<string, ManifestDbModel>;
193
200
  /** From `roles.vocabulary`; a rule may only name a role this app declares. */
194
201
  vocabulary?: string[];
@@ -216,6 +223,7 @@ export interface CompileInput {
216
223
  */
217
224
  export interface ManifestForRules {
218
225
  id?: string;
226
+ applicationType?: string;
219
227
  db?: Record<string, ManifestDbModel> | Record<string, never> | null;
220
228
  roles?: {
221
229
  vocabulary?: string[];
@@ -238,6 +246,29 @@ export declare class RuleCompileError extends Error {
238
246
  readonly keyPath: string;
239
247
  constructor(keyPath: string, message: string);
240
248
  }
249
+ /**
250
+ * An app's tables, columns and indexes are Postgres identifiers in ONE shared
251
+ * schema. Postgres silently TRUNCATES an identifier past 63 bytes
252
+ * (NAMEDATALEN − 1) — so a table name past the limit would be created under a
253
+ * name nobody asked for, and every later lookup by the full name would miss it.
254
+ * Such names are refused here, at compile, with the model named; index and
255
+ * primary-key names are derived, so the generator caps those instead
256
+ * (`schema-gen.ts` `capIdentifier`).
257
+ */
258
+ export declare const PG_IDENTIFIER_MAX = 63;
259
+ /** A declared field (a column): camelCase, a-z first, then ASCII letters and digits. */
260
+ export declare const FIELD_NAME_RE: RegExp;
261
+ export declare function identifierBytes(s: string): number;
262
+ /** `ShipAddress` → `ship_address`; `Order` → `order`. */
263
+ export declare function snakeCase(name: string): string;
264
+ /**
265
+ * The table names an app's models become, checked: no two models of one app
266
+ * on the same table (`HTTPLog` and `HttpLog` are both `http_log`), an
267
+ * application type whose tables cannot be mistaken for another app's (no `__`
268
+ * inside it, no trailing `_` — `plugin_x__y__order` must be `plugin_x__y`'s,
269
+ * never `plugin_x`'s), and no table name past 63 bytes.
270
+ */
271
+ export declare function checkTableNames(modelNames: string[], applicationType?: string): void;
241
272
  export declare function camelKey(model: string): string;
242
273
  /**
243
274
  * Compile a manifest's `db` block. Throws `RuleCompileError` with the key path
@@ -47,6 +47,7 @@ const surfaceNamesOf = (x) => (Array.isArray(x) ? x.map(String) : x && typeof x
47
47
  export function rulesInputFromManifest(m, pluginId) {
48
48
  return {
49
49
  pluginId: pluginId ?? m.id ?? "app",
50
+ applicationType: m.applicationType,
50
51
  db: (m.db ?? {}),
51
52
  vocabulary: m.roles?.vocabulary,
52
53
  ownerRole: m.roles?.default?.owner,
@@ -68,6 +69,65 @@ export class RuleCompileError extends Error {
68
69
  this.name = "RuleCompileError";
69
70
  }
70
71
  }
72
+ /* ───────────────────────────── identifiers ───────────────────────────── */
73
+ /**
74
+ * An app's tables, columns and indexes are Postgres identifiers in ONE shared
75
+ * schema. Postgres silently TRUNCATES an identifier past 63 bytes
76
+ * (NAMEDATALEN − 1) — so a table name past the limit would be created under a
77
+ * name nobody asked for, and every later lookup by the full name would miss it.
78
+ * Such names are refused here, at compile, with the model named; index and
79
+ * primary-key names are derived, so the generator caps those instead
80
+ * (`schema-gen.ts` `capIdentifier`).
81
+ */
82
+ export const PG_IDENTIFIER_MAX = 63;
83
+ /** A declared field (a column): camelCase, a-z first, then ASCII letters and digits. */
84
+ export const FIELD_NAME_RE = /^[a-z][A-Za-z0-9]*$/;
85
+ export function identifierBytes(s) {
86
+ let n = 0;
87
+ for (const ch of s) {
88
+ const c = ch.codePointAt(0);
89
+ n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
90
+ }
91
+ return n;
92
+ }
93
+ /** `ShipAddress` → `ship_address`; `Order` → `order`. */
94
+ export function snakeCase(name) {
95
+ return name
96
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
97
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
98
+ .toLowerCase();
99
+ }
100
+ /**
101
+ * The table names an app's models become, checked: no two models of one app
102
+ * on the same table (`HTTPLog` and `HttpLog` are both `http_log`), an
103
+ * application type whose tables cannot be mistaken for another app's (no `__`
104
+ * inside it, no trailing `_` — `plugin_x__y__order` must be `plugin_x__y`'s,
105
+ * never `plugin_x`'s), and no table name past 63 bytes.
106
+ */
107
+ export function checkTableNames(modelNames, applicationType) {
108
+ const seen = new Map();
109
+ for (const m of modelNames) {
110
+ const snake = snakeCase(m);
111
+ const prev = seen.get(snake);
112
+ if (prev !== undefined) {
113
+ throw new RuleCompileError(`db.${m}`, `models "${prev}" and "${m}" would both be the table "…__${snake}" — rename one of them`);
114
+ }
115
+ seen.set(snake, m);
116
+ }
117
+ if (applicationType === undefined || !modelNames.length)
118
+ return;
119
+ if (applicationType.includes("__") || applicationType.endsWith("_")) {
120
+ throw new RuleCompileError("applicationType", `"${applicationType}" has "__" in it or ends with "_" — an app with tables names them "<applicationType>__<model>", and that name must not be readable as another app's table. Use single underscores (e.g. plugin_my_app).`);
121
+ }
122
+ for (const m of modelNames) {
123
+ const table = `${applicationType}__${snakeCase(m)}`;
124
+ const n = identifierBytes(table);
125
+ if (n > PG_IDENTIFIER_MAX) {
126
+ const room = PG_IDENTIFIER_MAX - identifierBytes(applicationType) - 2;
127
+ throw new RuleCompileError(`db.${m}`, `the table "${table}" is ${n} bytes; Postgres names are at most ${PG_IDENTIFIER_MAX} — shorten the model name so "${snakeCase(m)}" is at most ${Math.max(room, 0)} characters (or shorten the applicationType)`);
128
+ }
129
+ }
130
+ }
71
131
  const FIELD_TYPES = ["string", "text", "int", "float", "boolean", "datetime", "json"];
72
132
  // Positional groups, not named ones: the repo-wide tsc targets below ES2018.
73
133
  // [1] base type, [2] "[]" for a list, [3] "?" for optional, [4] the default.
@@ -267,6 +327,12 @@ export function compileRules(input) {
267
327
  throw new RuleCompileError(`${at}.fields`, "a model declares at least one field");
268
328
  const fields = {};
269
329
  for (const f of fieldNames) {
330
+ if (!FIELD_NAME_RE.test(f)) {
331
+ throw new RuleCompileError(`${at}.fields.${f}`, "a field name is camelCase: a lowercase letter a-z first, then ASCII letters and digits");
332
+ }
333
+ if (identifierBytes(f) > PG_IDENTIFIER_MAX) {
334
+ throw new RuleCompileError(`${at}.fields.${f}`, `a field name is a column name, at most ${PG_IDENTIFIER_MAX} characters — this one is ${identifierBytes(f)}`);
335
+ }
270
336
  if (PLATFORM_COLUMNS.includes(f)) {
271
337
  throw new RuleCompileError(`${at}.fields.${f}`, `"${f}" is a column the platform owns and stamps — it may not be declared. Platform columns: ${PLATFORM_COLUMNS.join(", ")}`);
272
338
  }
@@ -373,6 +439,7 @@ export function compileRules(input) {
373
439
  rules,
374
440
  };
375
441
  }
442
+ checkTableNames(modelNames, input.applicationType);
376
443
  const custom = compileEnvelope(input.custom, Object.fromEntries(Object.entries(models).map(([name, m]) => [name, { fields: Object.keys(m.fields), filterable: m.filterable }])), input.surfaces ?? [], attributes);
377
444
  return { version: 1, pluginId: input.pluginId, models, custom, attributes };
378
445
  }
@@ -82,6 +82,8 @@ export interface PluginDbClient {
82
82
  export interface MemoryStore {
83
83
  rows: Record<string, Row[]>;
84
84
  seq: number;
85
+ /** Rows ever minted in this store — the ids' ordinal, shared by every client over it. */
86
+ minted?: number;
85
87
  }
86
88
  export declare function createStore(rules: CompiledRules): MemoryStore;
87
89
  export interface MemoryDbOptions {
@@ -1,5 +1,5 @@
1
1
  import { camelKey } from "./compile-rules.js";
2
- import { assertFilterShape, assertFilterable, assertIncludeDeleted, assertNoInjectedKeys, assertRefTargets, assertTransition, assertUnique, checkWritableData, hideFields, narrowMatches, withinScope, declaredValues, defaultRefuse, mayUnseal, orderTerms, ownerFilter, pageWindow, readPlan, scopeMatches, stampNewRow, writeGate, } from "./client-core.js";
2
+ import { assertFilterShape, assertFilterable, assertIncludeDeleted, assertNoInjectedKeys, assertRefTargets, assertTransition, assertUnique, checkWritableData, hideFields, narrowMatches, withinScope, declaredValues, defaultRefuse, mayUnseal, orderTerms, totalOrder, ownerFilter, pageWindow, readPlan, scopeMatches, stampNewRow, writeGate, } from "./client-core.js";
3
3
  export function createStore(rules) {
4
4
  const rows = {};
5
5
  for (const name of Object.keys(rules.models))
@@ -11,8 +11,10 @@ export function createMemoryDb(options) {
11
11
  const { rules, viewer, scope, across, ownedWorkspaceIds = [], viaBinding = false, refuse = defaultRefuse, } = options;
12
12
  const store = options.store ?? createStore(rules);
13
13
  const now = options.now ?? (() => new Date());
14
- let idSeq = 0;
15
- const newId = options.newId ?? (() => `row_${++idSeq}_${Math.random().toString(36).slice(2, 8)}`);
14
+ // Zero-padded and counted on the STORE (every viewer's client shares it), so id order
15
+ // is insertion order: a page breaks ties by id (totalOrder).
16
+ const newId = options.newId ??
17
+ (() => `row_${String((store.minted = (store.minted ?? 0) + 1)).padStart(9, "0")}_${Math.random().toString(36).slice(2, 8)}`);
16
18
  const db = {};
17
19
  for (const model of Object.values(rules.models)) {
18
20
  db[model.key] = makeCollection(model);
@@ -126,8 +128,8 @@ export function createMemoryDb(options) {
126
128
  rows = rows.filter((r) => Object.entries(where).every(([k, v]) => matchOne(r[k], v)));
127
129
  return rows;
128
130
  }
129
- function sortRows(c, rows, orderBy) {
130
- const terms = orderTerms(c, orderBy);
131
+ function sortRows(c, rows, orderBy, cursor) {
132
+ const terms = totalOrder(orderTerms(c, orderBy), cursor);
131
133
  if (!terms.length)
132
134
  return rows;
133
135
  return [...rows].sort((a, b) => {
@@ -151,7 +153,7 @@ export function createMemoryDb(options) {
151
153
  }
152
154
  function page(c, rows, args) {
153
155
  const { take, skip } = pageWindow(c, args);
154
- let out = sortRows(c, rows, args?.orderBy);
156
+ let out = sortRows(c, rows, args?.orderBy, args?.cursor);
155
157
  if (args?.cursor) {
156
158
  const at = out.findIndex((r) => r.id === args.cursor.id);
157
159
  out = at >= 0 ? out.slice(at + 1) : [];
@@ -35,8 +35,16 @@
35
35
  * the platform actually runs.
36
36
  */
37
37
  import type { CompiledRules, IndexKind } from "./compile-rules.js";
38
- /** `ShipAddress` → `ship_address`; `Order` → `order`. */
39
- export declare function snakeCase(name: string): string;
38
+ import { snakeCase } from "./compile-rules.js";
39
+ export { snakeCase };
40
+ /**
41
+ * A derived identifier (an index or a primary key) that fits Postgres's 63
42
+ * bytes. A name that already fits is returned UNCHANGED — every table, index
43
+ * and key already in production keeps its name. One that does not becomes
44
+ * `<first N chars>_<8-hex hash of the full name><suffix>`: deterministic, and
45
+ * two long names differing only past the cut still differ.
46
+ */
47
+ export declare function capIdentifier(full: string, suffix: string): string;
40
48
  /** The Prisma model name: `plugin_shop_min__Order`. */
41
49
  export declare function prismaModelName(applicationType: string, model: string): string;
42
50
  /** The table it maps to: `plugin_shop_min__order`. */
@@ -96,8 +104,10 @@ export interface TableSpec {
96
104
  columns: TableColumn[];
97
105
  indexes: TableIndex[];
98
106
  }
99
- /** Prisma's index name: `<table>_<col>_<col>_idx`. */
107
+ /** Prisma's index name, `<table>_<col>_<col>_idx` — capped (`capIdentifier`) only when it would pass 63 bytes. */
100
108
  export declare function indexName(table: string, columns: string[]): string;
109
+ /** Prisma's primary-key name, `<table>_pkey` — capped only when it would pass 63 bytes. */
110
+ export declare function pkeyName(table: string): string;
101
111
  /** Every table an app declares, as columns and indexes — the input to the planner and the emitter. */
102
112
  export declare function tableSpecs(rules: CompiledRules, opts: {
103
113
  applicationType: string;
@@ -1,10 +1,37 @@
1
+ import { checkTableNames, identifierBytes, PG_IDENTIFIER_MAX, snakeCase } from "./compile-rules.js";
1
2
  /* ───────────────────────────── naming ─────────────────────────────────── */
2
- /** `ShipAddress` → `ship_address`; `Order` → `order`. */
3
- export function snakeCase(name) {
4
- return name
5
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
6
- .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
7
- .toLowerCase();
3
+ // One implementation, in the compiler (which refuses colliding and over-long
4
+ // table names at compile); re-exported so every caller of the generator keeps
5
+ // its import.
6
+ export { snakeCase };
7
+ /** FNV-1a, 32 bits, as 8 hex digits — pure and stable, so a capped name is the same on every run. */
8
+ function hash8(s) {
9
+ let h = 0x811c9dc5;
10
+ for (let i = 0; i < s.length; i++) {
11
+ h ^= s.charCodeAt(i);
12
+ h = Math.imul(h, 0x01000193) >>> 0;
13
+ }
14
+ return h.toString(16).padStart(8, "0");
15
+ }
16
+ /**
17
+ * A derived identifier (an index or a primary key) that fits Postgres's 63
18
+ * bytes. A name that already fits is returned UNCHANGED — every table, index
19
+ * and key already in production keeps its name. One that does not becomes
20
+ * `<first N chars>_<8-hex hash of the full name><suffix>`: deterministic, and
21
+ * two long names differing only past the cut still differ.
22
+ */
23
+ export function capIdentifier(full, suffix) {
24
+ if (identifierBytes(full) <= PG_IDENTIFIER_MAX)
25
+ return full;
26
+ const stem = full.endsWith(suffix) ? full.slice(0, full.length - suffix.length) : full;
27
+ const room = PG_IDENTIFIER_MAX - 1 - 8 - identifierBytes(suffix);
28
+ let cut = "";
29
+ for (const ch of stem) {
30
+ if (identifierBytes(cut + ch) > room)
31
+ break;
32
+ cut += ch;
33
+ }
34
+ return `${cut}_${hash8(full)}${suffix}`;
8
35
  }
9
36
  /** The Prisma model name: `plugin_shop_min__Order`. */
10
37
  export function prismaModelName(applicationType, model) {
@@ -116,8 +143,14 @@ function prismaIndexArgs(g) {
116
143
  return `[${g.fields.join(", ")}]`;
117
144
  }
118
145
  function renderModel(model, applicationType) {
146
+ const table = prismaTableName(applicationType, model.name);
147
+ const pkey = pkeyName(table);
148
+ // Prisma names a key `<table>_pkey` and an index `<table>_<cols>_idx`; only
149
+ // a name the cap CHANGED is spelled out with `map:`, so a fragment whose
150
+ // names fit is byte-for-byte what it always was.
151
+ const platform = platformColumns(model.scope).map((c) => c.name === "id" && pkey !== `${table}_pkey` ? { ...c, attrs: c.attrs.replace("@id", `@id(map: ${JSON.stringify(pkey)})`) } : c);
119
152
  const cols = [
120
- ...platformColumns(model.scope),
153
+ ...platform,
121
154
  ...Object.keys(model.fields).map((name) => declaredColumn(name, model.fields[name], model.sealed.includes(name))),
122
155
  ];
123
156
  const nameW = Math.max(...cols.map((c) => c.name.length));
@@ -126,13 +159,17 @@ function renderModel(model, applicationType) {
126
159
  const head = ` ${c.name.padEnd(nameW)} ${c.type.padEnd(typeW)}`;
127
160
  return (c.attrs ? `${head} ${c.attrs}` : head).trimEnd();
128
161
  });
129
- const idx = indexGroups(model).map((g) => ` @@index(${prismaIndexArgs(g)})`);
162
+ const idx = indexGroups(model).map((g) => {
163
+ const name = indexName(table, g.fields);
164
+ const map = name === `${table}_${g.fields.join("_")}_idx` ? "" : `, map: ${JSON.stringify(name)}`;
165
+ return ` @@index(${prismaIndexArgs(g)}${map})`;
166
+ });
130
167
  return [
131
168
  `model ${prismaModelName(applicationType, model.name)} {`,
132
169
  ...lines,
133
170
  "",
134
171
  ...idx,
135
- ` @@map(${JSON.stringify(prismaTableName(applicationType, model.name))})`,
172
+ ` @@map(${JSON.stringify(table)})`,
136
173
  "}",
137
174
  ].join("\n");
138
175
  }
@@ -142,6 +179,7 @@ function renderModel(model, applicationType) {
142
179
  * generator: this is a FRAGMENT that joins the platform's schema folder.
143
180
  */
144
181
  export function prismaFragment(rules, opts) {
182
+ checkTableNames(Object.keys(rules.models), opts.applicationType);
145
183
  const models = Object.keys(rules.models).map((name) => rules.models[name]);
146
184
  const body = models.map((m) => renderModel(m, opts.applicationType)).join("\n\n");
147
185
  return [
@@ -314,12 +352,19 @@ function sqlColumn(c) {
314
352
  // or the planner would read a difference against the live table forever.
315
353
  return { name: c.name, type: list ? `${sql}[]` : sql, nullable: optional || list, default: def };
316
354
  }
317
- /** Prisma's index name: `<table>_<col>_<col>_idx`. */
355
+ /** Prisma's index name, `<table>_<col>_<col>_idx` — capped (`capIdentifier`) only when it would pass 63 bytes. */
318
356
  export function indexName(table, columns) {
319
- return `${table}_${columns.join("_")}_idx`;
357
+ return capIdentifier(`${table}_${columns.join("_")}_idx`, "_idx");
358
+ }
359
+ /** Prisma's primary-key name, `<table>_pkey` — capped only when it would pass 63 bytes. */
360
+ export function pkeyName(table) {
361
+ return capIdentifier(`${table}_pkey`, "_pkey");
320
362
  }
321
363
  /** Every table an app declares, as columns and indexes — the input to the planner and the emitter. */
322
364
  export function tableSpecs(rules, opts) {
365
+ // A compiled artefact may come from a compile that did not know the type
366
+ // (an author's bare `db`): the generator always does, so it checks again.
367
+ checkTableNames(Object.keys(rules.models), opts.applicationType);
323
368
  return Object.keys(rules.models).map((name) => {
324
369
  const model = rules.models[name];
325
370
  const table = prismaTableName(opts.applicationType, model.name);
@@ -334,12 +379,13 @@ export function tableSpecs(rules, opts) {
334
379
  };
335
380
  });
336
381
  }
337
- const q = (ident) => `"${ident}"`;
382
+ /** A quoted identifier; a `"` inside one is doubled, as SQL spells it. */
383
+ const q = (ident) => `"${ident.replace(/"/g, '""')}"`;
338
384
  function columnDef(c) {
339
385
  return `${q(c.name)} ${c.type}${c.nullable ? "" : " NOT NULL"}${c.default !== null ? ` DEFAULT ${c.default}` : ""}`;
340
386
  }
341
387
  export function createTableSql(t) {
342
- return [`CREATE TABLE ${q(t.name)} (`, ...t.columns.map((c) => ` ${columnDef(c)},`), "", ` CONSTRAINT ${q(`${t.name}_pkey`)} PRIMARY KEY ("id")`, ");"].join("\n");
388
+ return [`CREATE TABLE ${q(t.name)} (`, ...t.columns.map((c) => ` ${columnDef(c)},`), "", ` CONSTRAINT ${q(pkeyName(t.name))} PRIMARY KEY ("id")`, ");"].join("\n");
343
389
  }
344
390
  export function createIndexSql(table, idx) {
345
391
  if (idx.kind === "text")
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The platform's list of requests that failed after the UI already showed their result
3
+ * (the optimistic-UI rule, app-style-guide.md "Interaction"): an app updates the screen at
4
+ * once, and when the request behind it fails the app puts the screen back and reports here —
5
+ * `<FailedRequestBanner/>` then names the request and offers Try again, in the same place and
6
+ * style as the "new version is available" pill. Pure and framework-free, so any app (built-in or
7
+ * Forge) and any test can use it. A Forge app imports it from `esoul-sdk/react`; the platform's
8
+ * `src/lib/failed-requests.ts` re-exports this very module, so both reach one list.
9
+ */
10
+ export interface FailedRequest {
11
+ /** Stable per logical request: reporting the same key again replaces the entry, never stacks. */
12
+ key: string;
13
+ /** What the person did, in their words: "Accept the hint", "Grant more budget". */
14
+ what: string;
15
+ /** Why, when known and readable: "no connection", "the server refused: over the cap". */
16
+ why?: string;
17
+ /** Re-send the request. Resolve `true` when it landed; anything else keeps the entry. */
18
+ retry?: () => Promise<boolean> | boolean;
19
+ at: number;
20
+ /** Set while a retry is in flight. */
21
+ retrying?: boolean;
22
+ }
23
+ type Listener = () => void;
24
+ /** Report a request that failed after the screen already showed it; the banner names it (and offers Try again when `retry` is given). The same `key` replaces, never stacks. */
25
+ export declare function reportFailedRequest(r: Omit<FailedRequest, "at" | "retrying"> & {
26
+ at?: number;
27
+ }): void;
28
+ /** Take a request off the banner — it landed after all, or the person dismissed it. */
29
+ export declare function dismissFailedRequest(key: string): void;
30
+ /** Clear an entry because the same request later landed some other way (the person redid it). */
31
+ export declare const resolveFailedRequest: typeof dismissFailedRequest;
32
+ export declare function retryFailedRequest(key: string): Promise<boolean>;
33
+ export declare function listFailedRequests(): FailedRequest[];
34
+ export declare function subscribeFailedRequests(l: Listener): () => void;
35
+ /** Test-only: start from nothing. */
36
+ export declare function __resetFailedRequests(): void;
37
+ /**
38
+ * Is this failure worth a Try again? A lost connection, a timeout, a server error or a
39
+ * rate limit — yes. A refusal (the server read the request and said no, with a reason) —
40
+ * retrying sends the same no; the app shows the reason instead.
41
+ */
42
+ export declare function isRetryableFailure(r: {
43
+ reason?: string;
44
+ status?: number;
45
+ }): boolean;
46
+ /**
47
+ * The optimistic-UI rule in one call. `apply` puts the result on screen at once; `send` does the
48
+ * work; on success any earlier failure of the same `key` clears. On failure `revert` puts the
49
+ * screen back and the request is reported: a connection loss, timeout or server error gets
50
+ * Try again (which runs the whole thing again, apply included), a refusal shows its reason only —
51
+ * retrying would send the same no.
52
+ *
53
+ * await runOptimistic({ key: `cheer:${id}`, what: "Cheer", apply: () => setCheered(true),
54
+ * revert: () => setCheered(false), send: () => callPluginOp(PLUGIN_ID, "cheer", nodeId, { id }) });
55
+ *
56
+ * State that lives in the app's events needs none of this: `usePluginEventDispatch` already shows
57
+ * the event at once and delivers it durably. This is for work that needs the server (an op).
58
+ */
59
+ export declare function runOptimistic<T>(r: {
60
+ key: string;
61
+ what: string;
62
+ apply?: () => void;
63
+ revert?: () => void;
64
+ send: () => Promise<T>;
65
+ }): Promise<{
66
+ ok: true;
67
+ result: T;
68
+ } | {
69
+ ok: false;
70
+ error: unknown;
71
+ }>;
72
+ /** Words and retryability for a thrown request: `PluginCallError` carries status + code; a fetch that never reached the server throws a TypeError. */
73
+ export declare function describeFailure(error: unknown): {
74
+ why: string;
75
+ retryable: boolean;
76
+ };
77
+ export {};
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The platform's list of requests that failed after the UI already showed their result
3
+ * (the optimistic-UI rule, app-style-guide.md "Interaction"): an app updates the screen at
4
+ * once, and when the request behind it fails the app puts the screen back and reports here —
5
+ * `<FailedRequestBanner/>` then names the request and offers Try again, in the same place and
6
+ * style as the "new version is available" pill. Pure and framework-free, so any app (built-in or
7
+ * Forge) and any test can use it. A Forge app imports it from `esoul-sdk/react`; the platform's
8
+ * `src/lib/failed-requests.ts` re-exports this very module, so both reach one list.
9
+ */
10
+ const MAX = 5;
11
+ let items = [];
12
+ const listeners = new Set();
13
+ function emit() {
14
+ for (const l of [...listeners]) {
15
+ try {
16
+ l();
17
+ }
18
+ catch {
19
+ /* a listener's throw must not stop the others */
20
+ }
21
+ }
22
+ }
23
+ /** Report a request that failed after the screen already showed it; the banner names it (and offers Try again when `retry` is given). The same `key` replaces, never stacks. */
24
+ export function reportFailedRequest(r) {
25
+ const entry = { ...r, at: r.at ?? Date.now() };
26
+ items = [...items.filter((i) => i.key !== r.key), entry].slice(-MAX);
27
+ emit();
28
+ }
29
+ /** Take a request off the banner — it landed after all, or the person dismissed it. */
30
+ export function dismissFailedRequest(key) {
31
+ const next = items.filter((i) => i.key !== key);
32
+ if (next.length === items.length)
33
+ return;
34
+ items = next;
35
+ emit();
36
+ }
37
+ /** Clear an entry because the same request later landed some other way (the person redid it). */
38
+ export const resolveFailedRequest = dismissFailedRequest;
39
+ export async function retryFailedRequest(key) {
40
+ const it = items.find((i) => i.key === key);
41
+ if (!it?.retry || it.retrying)
42
+ return false;
43
+ items = items.map((i) => (i.key === key ? { ...i, retrying: true } : i));
44
+ emit();
45
+ let ok = false;
46
+ try {
47
+ ok = (await it.retry()) === true;
48
+ }
49
+ catch {
50
+ ok = false;
51
+ }
52
+ items = ok ? items.filter((i) => i.key !== key) : items.map((i) => (i.key === key ? { ...i, retrying: false, at: Date.now() } : i));
53
+ emit();
54
+ return ok;
55
+ }
56
+ export function listFailedRequests() {
57
+ return items;
58
+ }
59
+ export function subscribeFailedRequests(l) {
60
+ listeners.add(l);
61
+ return () => {
62
+ listeners.delete(l);
63
+ };
64
+ }
65
+ /** Test-only: start from nothing. */
66
+ export function __resetFailedRequests() {
67
+ items = [];
68
+ emit();
69
+ }
70
+ /**
71
+ * Is this failure worth a Try again? A lost connection, a timeout, a server error or a
72
+ * rate limit — yes. A refusal (the server read the request and said no, with a reason) —
73
+ * retrying sends the same no; the app shows the reason instead.
74
+ */
75
+ export function isRetryableFailure(r) {
76
+ if (r.reason === "network" || r.reason === "timeout")
77
+ return true;
78
+ const s = r.status ?? 0;
79
+ return s === 0 || s === 408 || s === 429 || s >= 500;
80
+ }
81
+ /**
82
+ * The optimistic-UI rule in one call. `apply` puts the result on screen at once; `send` does the
83
+ * work; on success any earlier failure of the same `key` clears. On failure `revert` puts the
84
+ * screen back and the request is reported: a connection loss, timeout or server error gets
85
+ * Try again (which runs the whole thing again, apply included), a refusal shows its reason only —
86
+ * retrying would send the same no.
87
+ *
88
+ * await runOptimistic({ key: `cheer:${id}`, what: "Cheer", apply: () => setCheered(true),
89
+ * revert: () => setCheered(false), send: () => callPluginOp(PLUGIN_ID, "cheer", nodeId, { id }) });
90
+ *
91
+ * State that lives in the app's events needs none of this: `usePluginEventDispatch` already shows
92
+ * the event at once and delivers it durably. This is for work that needs the server (an op).
93
+ */
94
+ export async function runOptimistic(r) {
95
+ r.apply?.();
96
+ try {
97
+ const result = await r.send();
98
+ dismissFailedRequest(r.key);
99
+ return { ok: true, result };
100
+ }
101
+ catch (error) {
102
+ try {
103
+ r.revert?.();
104
+ }
105
+ catch {
106
+ /* a revert that throws must not hide the failure */
107
+ }
108
+ const f = describeFailure(error);
109
+ reportFailedRequest({
110
+ key: r.key,
111
+ what: r.what,
112
+ why: f.why,
113
+ retry: f.retryable ? async () => (await runOptimistic(r)).ok : undefined,
114
+ });
115
+ return { ok: false, error };
116
+ }
117
+ }
118
+ /** Words and retryability for a thrown request: `PluginCallError` carries status + code; a fetch that never reached the server throws a TypeError. */
119
+ export function describeFailure(error) {
120
+ const e = error;
121
+ if (!e || (e.name === "TypeError" && !e.status))
122
+ return { why: "no connection", retryable: true };
123
+ const status = typeof e.status === "number" ? e.status : 0;
124
+ if (isRetryableFailure({ status }))
125
+ return { why: status >= 500 ? "the server did not answer" : status === 429 ? "too many requests — wait a moment" : "no connection", retryable: true };
126
+ return { why: (e.message ?? "refused").slice(0, 160), retryable: false };
127
+ }
package/dist/react.d.ts CHANGED
@@ -388,3 +388,9 @@ export interface RemoteReconcile<T> {
388
388
  * docs/17-editing-and-merging.md.
389
389
  */
390
390
  export declare function useRemoteReconcile<T>(_opts: RemoteReconcileOptions<T>): RemoteReconcile<T>;
391
+ /**
392
+ * The optimistic-UI rule (a click shows its result at once; a failed request puts the screen back
393
+ * and the platform's banner names it with Try again). Real code, not host-only: the list is pure,
394
+ * and in the host this module is the platform's own, so the banner shows what an app reports.
395
+ */
396
+ export { runOptimistic, reportFailedRequest, dismissFailedRequest, isRetryableFailure, describeFailure, type FailedRequest, } from "./failed-requests.js";
package/dist/react.js CHANGED
@@ -148,3 +148,9 @@ export function useSignInWall() {
148
148
  export function useRemoteReconcile(_opts) {
149
149
  return hostOnly("useRemoteReconcile");
150
150
  }
151
+ /**
152
+ * The optimistic-UI rule (a click shows its result at once; a failed request puts the screen back
153
+ * and the platform's banner names it with Try again). Real code, not host-only: the list is pure,
154
+ * and in the host this module is the platform's own, so the banner shows what an app reports.
155
+ */
156
+ export { runOptimistic, reportFailedRequest, dismissFailedRequest, isRetryableFailure, describeFailure, } from "./failed-requests.js";
@@ -86,10 +86,15 @@ folded.
86
86
  Set it. It tells the platform your state IS the fold, which is what enables replay-on-load,
87
87
  snapshot baselines and the durability layers. Every shipped plugin and the scaffold set it.
88
88
 
89
- ## Server-typed events
90
-
91
- `EventTypes.Server` marks events that only server code emits (a task, a webhook). The UI must
92
- not dispatch them, and the processor still obeys the three rules.
89
+ ## Events only the server emits
90
+
91
+ There is no "server-only" event type: `EventTypes` has `Client` (what the UI and tools dispatch,
92
+ and what a task's `dispatchEvent` or an op's `ctx.emit` writes through the same processors),
93
+ `Workflow` and `Workspace` (the platform's own). An event that only a task or a webhook should
94
+ produce is declared like any other; the guard is where its `dataCreator` is called — keep it out
95
+ of the UI's reach and say so in a comment. (An earlier version of this page named
96
+ `EventTypes.Server`, which does not exist: the Pantry's first type check in a box found it,
97
+ 2026-09-28.) The processor still obeys the three rules.
93
98
 
94
99
  ## Durability you get for free
95
100
 
package/docs/05-ui.md CHANGED
@@ -20,6 +20,34 @@ export function StickyNotesUi({ state }: { state: StickyNotesData }) {
20
20
  - The schema module (`app.tsx`) is **never** `"use client"`; the UI module is. The schema imports
21
21
  the UI, not the other way around (it closes a module cycle).
22
22
 
23
+ ## A click shows its result at once
24
+
25
+ The person never waits to see what they just did. Two cases, one rule:
26
+
27
+ - **State in your events** — `dispatch(…)` IS the optimistic update: the fold shows it this frame
28
+ and the platform delivers the event durably (kept on the device, retried in order, never lost).
29
+ Prefer this for everything a person does to the app's own state.
30
+ - **Work that needs the server** (an op: a table write, a check only the server can make) — wrap
31
+ it in `runOptimistic` from `esoul-sdk/react`:
32
+
33
+ ```tsx
34
+ import { runOptimistic } from "esoul-sdk/react";
35
+
36
+ await runOptimistic({
37
+ key: `cheer:${recordId}`, // one entry per logical request; a retry replaces it
38
+ what: "Cheer", // what the person did, in their words
39
+ apply: () => setCheered(true), // the screen, now
40
+ revert: () => setCheered(false), // the screen as it was, if it fails
41
+ send: () => callPluginOp(PLUGIN_ID, "cheer", nodeId, { recordId }),
42
+ });
43
+ ```
44
+
45
+ On failure the screen goes back and the platform's banner (bottom-centre, the same pill as "a new
46
+ version is available") names it: *Cheer failed — no connection · Try again*. A lost connection, a
47
+ timeout or a server error offers Try again, which runs the whole thing again, `apply` included; a
48
+ refusal shows its reason and no retry, because retrying sends the same no. Do not draw your own
49
+ failure banner — the one banner is where a person looks, in the box and installed alike.
50
+
23
51
  ## Editors: a local copy that someone else can change
24
52
 
25
53
  If your UI holds a local copy of what it edits (a draft, a canvas, a form) it MUST take remote changes
@@ -105,7 +105,16 @@ Your generated type is the reference (open `.esoul/db.d.ts`), and this is all of
105
105
  | write | `create` · `createMany` · `update({ where: { id }, data })` · `updateMany` · `upsert` · `delete` · `deleteMany` |
106
106
  | together | `$transaction(fn)` |
107
107
 
108
- `findMany` takes `where`, `orderBy`, `take`, `skip`, `cursor: { id }`. Every `where` — including
108
+ `findMany` takes `where`, `orderBy`, `take` (200 at most), `skip`, `cursor: { id }`.
109
+
110
+ **Paging.** A page with `cursor` returns the rows AFTER that row — not Prisma's inclusive cursor,
111
+ so never add `skip: 1` (the client refuses `skip` with `cursor` by name; it used to drop a row per
112
+ page). Every ordered or cursored page ends in `id`, so rows that tie on your `orderBy` (a date, a
113
+ status) come in one fixed order and a cursor names one place; without it Postgres returned tied
114
+ rows twice or not at all. To read everything, loop `findMany({ …, take: 200, cursor })` until a
115
+ page is shorter than 200 — or better, ask the database (`count`, `aggregate`, `groupBy`).
116
+
117
+ Every `where` — including
109
118
  an `aggregate`'s — obeys the index wall above: a field you did not index is not a question, and
110
119
  asking it is refused as `invalid` rather than answered slowly. **Count and sum in the database**
111
120
  rather than paging rows to add them up in JavaScript; `groupBy` still returns only the rows this
@@ -223,5 +232,14 @@ await runOp(pluginServer, "raise-ticket", { viewer: ada, args: TICKET, db: db.as
223
232
  expect(await db.as(lin).ticket.count()).toBe(0);
224
233
  ```
225
234
 
226
- The in-memory client and the production one are proven equal by a differential test on every
227
- build, so a rule you prove here is a rule the database keeps.
235
+ The in-memory client and Postgres share one rule compiler and one core, and a conformance suite
236
+ runs against both — but the Postgres half needs a database (`DATABASE_URL_TEST`) and does not run
237
+ in the box or on every build. So the box's tables are a faithful MODEL of production's, not the
238
+ same thing. Where a model can hide a bug, test for it on purpose:
239
+
240
+ - **Past one page.** Anything that reads a list: seed more than 200 rows and assert every row
241
+ comes back exactly once. A seed or import of "about a hundred" proves nothing about paging.
242
+ - **Ties.** Order by a field many rows share and page through it.
243
+ - **Twice, and half-way.** A long op (a seed, an import, a bulk issue) runs 10–30× slower installed
244
+ than in the box and can stop part-way. Run it twice in a test; make the second run finish or
245
+ refuse by name, never write duplicates.
package/llms-full.txt CHANGED
@@ -875,10 +875,15 @@ folded.
875
875
  Set it. It tells the platform your state IS the fold, which is what enables replay-on-load,
876
876
  snapshot baselines and the durability layers. Every shipped plugin and the scaffold set it.
877
877
 
878
- ## Server-typed events
878
+ ## Events only the server emits
879
879
 
880
- `EventTypes.Server` marks events that only server code emits (a task, a webhook). The UI must
881
- not dispatch them, and the processor still obeys the three rules.
880
+ There is no "server-only" event type: `EventTypes` has `Client` (what the UI and tools dispatch,
881
+ and what a task's `dispatchEvent` or an op's `ctx.emit` writes through the same processors),
882
+ `Workflow` and `Workspace` (the platform's own). An event that only a task or a webhook should
883
+ produce is declared like any other; the guard is where its `dataCreator` is called — keep it out
884
+ of the UI's reach and say so in a comment. (An earlier version of this page named
885
+ `EventTypes.Server`, which does not exist: the Pantry's first type check in a box found it,
886
+ 2026-09-28.) The processor still obeys the three rules.
882
887
 
883
888
  ## Durability you get for free
884
889
 
@@ -1037,6 +1042,34 @@ export function StickyNotesUi({ state }: { state: StickyNotesData }) {
1037
1042
  - The schema module (`app.tsx`) is **never** `"use client"`; the UI module is. The schema imports
1038
1043
  the UI, not the other way around (it closes a module cycle).
1039
1044
 
1045
+ ## A click shows its result at once
1046
+
1047
+ The person never waits to see what they just did. Two cases, one rule:
1048
+
1049
+ - **State in your events** — `dispatch(…)` IS the optimistic update: the fold shows it this frame
1050
+ and the platform delivers the event durably (kept on the device, retried in order, never lost).
1051
+ Prefer this for everything a person does to the app's own state.
1052
+ - **Work that needs the server** (an op: a table write, a check only the server can make) — wrap
1053
+ it in `runOptimistic` from `esoul-sdk/react`:
1054
+
1055
+ ```tsx
1056
+ import { runOptimistic } from "esoul-sdk/react";
1057
+
1058
+ await runOptimistic({
1059
+ key: `cheer:${recordId}`, // one entry per logical request; a retry replaces it
1060
+ what: "Cheer", // what the person did, in their words
1061
+ apply: () => setCheered(true), // the screen, now
1062
+ revert: () => setCheered(false), // the screen as it was, if it fails
1063
+ send: () => callPluginOp(PLUGIN_ID, "cheer", nodeId, { recordId }),
1064
+ });
1065
+ ```
1066
+
1067
+ On failure the screen goes back and the platform's banner (bottom-centre, the same pill as "a new
1068
+ version is available") names it: *Cheer failed — no connection · Try again*. A lost connection, a
1069
+ timeout or a server error offers Try again, which runs the whole thing again, `apply` included; a
1070
+ refusal shows its reason and no retry, because retrying sends the same no. Do not draw your own
1071
+ failure banner — the one banner is where a person looks, in the box and installed alike.
1072
+
1040
1073
  ## Editors: a local copy that someone else can change
1041
1074
 
1042
1075
  If your UI holds a local copy of what it edits (a draft, a canvas, a form) it MUST take remote changes
@@ -2391,7 +2424,16 @@ Your generated type is the reference (open `.esoul/db.d.ts`), and this is all of
2391
2424
  | write | `create` · `createMany` · `update({ where: { id }, data })` · `updateMany` · `upsert` · `delete` · `deleteMany` |
2392
2425
  | together | `$transaction(fn)` |
2393
2426
 
2394
- `findMany` takes `where`, `orderBy`, `take`, `skip`, `cursor: { id }`. Every `where` — including
2427
+ `findMany` takes `where`, `orderBy`, `take` (200 at most), `skip`, `cursor: { id }`.
2428
+
2429
+ **Paging.** A page with `cursor` returns the rows AFTER that row — not Prisma's inclusive cursor,
2430
+ so never add `skip: 1` (the client refuses `skip` with `cursor` by name; it used to drop a row per
2431
+ page). Every ordered or cursored page ends in `id`, so rows that tie on your `orderBy` (a date, a
2432
+ status) come in one fixed order and a cursor names one place; without it Postgres returned tied
2433
+ rows twice or not at all. To read everything, loop `findMany({ …, take: 200, cursor })` until a
2434
+ page is shorter than 200 — or better, ask the database (`count`, `aggregate`, `groupBy`).
2435
+
2436
+ Every `where` — including
2395
2437
  an `aggregate`'s — obeys the index wall above: a field you did not index is not a question, and
2396
2438
  asking it is refused as `invalid` rather than answered slowly. **Count and sum in the database**
2397
2439
  rather than paging rows to add them up in JavaScript; `groupBy` still returns only the rows this
@@ -2509,8 +2551,17 @@ await runOp(pluginServer, "raise-ticket", { viewer: ada, args: TICKET, db: db.as
2509
2551
  expect(await db.as(lin).ticket.count()).toBe(0);
2510
2552
  ```
2511
2553
 
2512
- The in-memory client and the production one are proven equal by a differential test on every
2513
- build, so a rule you prove here is a rule the database keeps.
2554
+ The in-memory client and Postgres share one rule compiler and one core, and a conformance suite
2555
+ runs against both — but the Postgres half needs a database (`DATABASE_URL_TEST`) and does not run
2556
+ in the box or on every build. So the box's tables are a faithful MODEL of production's, not the
2557
+ same thing. Where a model can hide a bug, test for it on purpose:
2558
+
2559
+ - **Past one page.** Anything that reads a list: seed more than 200 rows and assert every row
2560
+ comes back exactly once. A seed or import of "about a hundred" proves nothing about paging.
2561
+ - **Ties.** Order by a field many rows share and page through it.
2562
+ - **Twice, and half-way.** A long op (a seed, an import, a bulk issue) runs 10–30× slower installed
2563
+ than in the box and can stop part-way. Run it twice in a test; make the second run finish or
2564
+ refuse by name, never write duplicates.
2514
2565
 
2515
2566
 
2516
2567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esoul-sdk",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",