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 +58 -3
- package/dist/db/client-core.d.ts +10 -0
- package/dist/db/client-core.js +17 -0
- package/dist/db/compile-rules.d.ts +31 -0
- package/dist/db/compile-rules.js +67 -0
- package/dist/db/memory-client.d.ts +2 -0
- package/dist/db/memory-client.js +8 -6
- package/dist/db/schema-gen.d.ts +13 -3
- package/dist/db/schema-gen.js +59 -13
- package/dist/failed-requests.d.ts +77 -0
- package/dist/failed-requests.js +127 -0
- package/dist/react.d.ts +6 -0
- package/dist/react.js +6 -0
- package/docs/03-events-and-state.md +9 -4
- package/docs/05-ui.md +28 -0
- package/docs/14-database.md +21 -3
- package/llms-full.txt +57 -6
- package/package.json +1 -1
package/api-reference.md
CHANGED
|
@@ -2583,11 +2583,27 @@ interface ViewerProfile {
|
|
|
2583
2583
|
```
|
|
2584
2584
|
|
|
2585
2585
|
==============================================================================
|
|
2586
|
-
## `esoul-sdk/react` —
|
|
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 (
|
|
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 (
|
|
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
|
|
package/dist/db/client-core.d.ts
CHANGED
|
@@ -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;
|
package/dist/db/client-core.js
CHANGED
|
@@ -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
|
package/dist/db/compile-rules.js
CHANGED
|
@@ -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 {
|
package/dist/db/memory-client.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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) : [];
|
package/dist/db/schema-gen.d.ts
CHANGED
|
@@ -35,8 +35,16 @@
|
|
|
35
35
|
* the platform actually runs.
|
|
36
36
|
*/
|
|
37
37
|
import type { CompiledRules, IndexKind } from "./compile-rules.js";
|
|
38
|
-
|
|
39
|
-
export
|
|
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
|
|
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;
|
package/dist/db/schema-gen.js
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
+
import { checkTableNames, identifierBytes, PG_IDENTIFIER_MAX, snakeCase } from "./compile-rules.js";
|
|
1
2
|
/* ───────────────────────────── naming ─────────────────────────────────── */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
...
|
|
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) =>
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
##
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
package/docs/14-database.md
CHANGED
|
@@ -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
|
|
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
|
|
227
|
-
|
|
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
|
-
##
|
|
878
|
+
## Events only the server emits
|
|
879
879
|
|
|
880
|
-
|
|
881
|
-
|
|
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
|
|
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
|
|
2513
|
-
|
|
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.
|
|
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",
|