@fougere/app 0.3.0-alpha.0 → 0.5.0-alpha.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/src/boot.ts ADDED
@@ -0,0 +1,426 @@
1
+ import { Role } from '@fougere/schema';
2
+ import { Lifecycle } from '@fougere/schema';
3
+ /**
4
+ * Fougere server bootstrap — single entry point for an app's lifecycle,
5
+ * whatever hosts it.
6
+ *
7
+ * Nothing here knows h3, Nitro, Vue or React: a boot reads `fougere.config.ts`,
8
+ * scans fronds off the filesystem and hands back an `App`. That is why it lives
9
+ * in `@fougere/app` rather than in one of the two adapters — the second host
10
+ * would otherwise have copied it, and a copied boot drifts (the seeding loop
11
+ * already did, `core/src/boot/seed.ts`).
12
+ *
13
+ * Default path (zero-config): fougere.config.ts declares `db: 'sqlite'` and
14
+ * everything else; this module auto-resolves the storage handle, builds an
15
+ * ormFactory, runs auto-DDL from the entities, and boots the app.
16
+ *
17
+ * Escape hatch: `configureFougere({ db, ormFactory })` can be called from the
18
+ * host's own startup (a Nitro plugin, a Next instrumentation hook) if the user
19
+ * wants a custom data layer — alternative driver, managed migrations, etc.
20
+ */
21
+ import { createApp, identityFromEnv, Logger, migrating, seeding } from '@fougere/core';
22
+ import { scanProject, loadCascadedConfig, setModuleLoader, frondAliases, resolveConventions } from '@fougere/core/node';
23
+ import type { Extension } from '@fougere/core';
24
+ import { createContainer } from '@fougere/container';
25
+ import type { App, CreateAppOptions, EntityOrm, FougereConfig, Transport } from '@fougere/core';
26
+ import { applyCreate, applyUpdate, type SchemaView } from '@fougere/schema';
27
+
28
+ // ── Public types ─────────────────────────────────
29
+
30
+ export interface FougereServerConfig {
31
+ /** Storage handle. Forwarded to the auth provider via AuthContext.db. */
32
+ db?: unknown;
33
+ /** Per-entity ORM factory. */
34
+ ormFactory?: (entity: SchemaView, name: string) => EntityOrm;
35
+ /**
36
+ * What this app takes on beyond its fronds, each stating what it does and what it undoes.
37
+ *
38
+ * It replaced `afterBoot`, which a host used to CLAIM the whole post-boot to get its own
39
+ * seeding — Nuxt's generated plugin did exactly that, and its copy of the seeding loop
40
+ * drifted. Declaring `{ name: 'seeds', up }` replaces that one member and leaves the
41
+ * rest of the ascent alone.
42
+ */
43
+ extensions?: CreateAppOptions['extensions'];
44
+ /**
45
+ * What the boot line names as the host — 'Nuxt/Nitro', 'Next'. Stated by the
46
+ * adapter, never sniffed: a boot that guesses its host from what happens to be
47
+ * importable is the hidden runtime the doctrine refuses.
48
+ */
49
+ host?: string;
50
+ /**
51
+ * What this app is built from, when the host already knows.
52
+ *
53
+ * Absent, `boot()` reads the fronds off the disk — right on a server, impossible on a
54
+ * runtime that has none: measured on workerd, `readdir` throws through unenv's shim and
55
+ * the app comes up with ZERO fronds, so every page renders and every door answers
56
+ * NOT_FOUND. A host that scanned at BUILD time can say so instead, and `fougere build`
57
+ * writes exactly this value down.
58
+ *
59
+ * Same slot as `CreateAppOptions.scan` and for the same reason: producing the value
60
+ * reads a disk, consuming it does not.
61
+ */
62
+ scan?: CreateAppOptions['scan'];
63
+ /**
64
+ * What this app STATES it hosts — `frond('blog', { entities: [Post] })`.
65
+ *
66
+ * Stating this and no `scan` is how a host stops scanning at boot: nothing reads a
67
+ * disk, nothing loads `typescript`, and what was not named does not exist. It is the
68
+ * one door Next, Vite, React, Svelte and a bare Express share — none of them scans on
69
+ * its own, they all arrive here, and this line used to end in a scan for every one.
70
+ */
71
+ fronds?: CreateAppOptions['fronds'];
72
+ /**
73
+ * What `fougere.config.ts` says, when the host already read it.
74
+ *
75
+ * The same rule as `scan`, and found the same way: `boot()` re-reads the file at
76
+ * runtime, which a Worker cannot do — measured, a consumer's `remotes:` never reached
77
+ * the boot and its pages rendered empty with nothing said. A host that read the config
78
+ * at BUILD time states it here instead.
79
+ *
80
+ * `auth` is deliberately not part of what a codegen'd host can carry: it holds a live
81
+ * provider, not a value. An app that authenticates reads its own config.
82
+ */
83
+ config?: Partial<FougereConfig>;
84
+ /**
85
+ * Who performs an outgoing call, when the default cannot.
86
+ *
87
+ * `boot()` builds an HTTP transport from `remotes:` and that is right nearly
88
+ * everywhere. It is not right on Cloudflare: a Worker calling a sibling's public URL
89
+ * is refused by the edge with error 1042, so two Workers of one account reach each
90
+ * other through a SERVICE BINDING and through nothing else. A binding is a value only
91
+ * the host holds, so only the host can state this.
92
+ *
93
+ * It replaces the default entirely — signing included, since a host that builds its
94
+ * own transport is the one that knows what to put on the wire.
95
+ */
96
+ remoteTransport?: (url: string) => Transport;
97
+ }
98
+
99
+ // ── State ────────────────────────────────────────
100
+
101
+ let _config: FougereServerConfig = {};
102
+ let _appPromise: Promise<App> | null = null;
103
+
104
+ // ── Public API ───────────────────────────────────
105
+
106
+ /**
107
+ * Override the data layer — only needed if you don't want the convention-driven
108
+ * setup based on `config.db` in fougere.config.ts.
109
+ */
110
+ export function configureFougere(config: FougereServerConfig) {
111
+ _config = config;
112
+ _appPromise = null;
113
+ }
114
+
115
+ /**
116
+ * Add to what is already stated, instead of replacing it.
117
+ *
118
+ * A host states its app in PIECES when the pieces are known at different moments: a
119
+ * build writes the scan and the config into a generated plugin, and a value only the
120
+ * running process holds — a Cloudflare service binding — cannot be written there at all.
121
+ * Its dual is `configureFougere`, which replaces; `reloadFougere` depends on that
122
+ * replacement, so merging silently would have broken the turn of the ring.
123
+ */
124
+ export function extendFougere(config: Partial<FougereServerConfig>) {
125
+ _config = { ..._config, ...config };
126
+ _appPromise = null;
127
+ }
128
+
129
+ /** Get the booted Fougere app. Lazy — boots on first call, then caches. */
130
+ export function useFougereApp(): Promise<App> {
131
+ if (!_appPromise) {
132
+ _appPromise = boot();
133
+ }
134
+ return _appPromise;
135
+ }
136
+
137
+ /**
138
+ * Turn the ring: instantiate the app again, then let the previous one go.
139
+ *
140
+ * This is what "reload" means for anything the config CONSUMED — a value that built
141
+ * something cannot move under what it built, so the thing is built again. Its dual is
142
+ * `applyConfig`, for values that are merely consulted and need no turn at all.
143
+ *
144
+ * Every door reaches the app through `useFougereApp()` inside the request it serves and
145
+ * none holds it across two, which is what makes the swap invisible: the next request
146
+ * lands on the new app whether or not the old one has finished being released.
147
+ *
148
+ * A call already running finishes on the OLD app: it is drained before being released,
149
+ * so nothing has its storage closed underneath it. `timeoutMs` bounds that wait, and a
150
+ * drain that runs out REJECTS — the app is left alone rather than released under work,
151
+ * because a caller who cannot wait must choose that on purpose.
152
+ */
153
+ export async function reloadFougere(timeoutMs?: number): Promise<App> {
154
+ const previous = _appPromise;
155
+ _appPromise = null;
156
+ // The new one first: a boot that fails leaves the previous app serving, still whole.
157
+ const next = await useFougereApp();
158
+ if (previous) {
159
+ const old = await previous;
160
+ await old.drain(timeoutMs);
161
+ await old.dispose();
162
+ }
163
+ return next;
164
+ }
165
+
166
+ // ── Boot ─────────────────────────────────────────
167
+
168
+ async function boot(): Promise<App> {
169
+ const bootStart = performance.now();
170
+ const log = new Logger('boot');
171
+
172
+ log.info(`booting (${_config.host ?? 'app'})`);
173
+
174
+ const { createJiti } = await import('jiti');
175
+ // Nitro serves from a bundle, but the scan still reads frond sources from disk — so the
176
+ // named form a frond uses for its neighbour has to resolve here too.
177
+ //
178
+ // Installed twice: the config names the scope the aliases are built from, so reading it
179
+ // must not need them. Nothing in `fougere.config.ts` may import `@fronds/*`.
180
+ //
181
+ // And installed only when something is going to READ a source. A host that handed in
182
+ // both its scan and its config has nothing left to load, and jiti cannot run where
183
+ // there is no module resolver: measured on workerd, `createJiti` threw
184
+ // `Cannot read properties of undefined (reading 'paths')` and every request answered
185
+ // 500 — the loader was being built for files that no longer needed opening.
186
+ const reads = _config.scan === undefined || _config.config === undefined;
187
+ const installLoader = (alias?: Record<string, string>): void => {
188
+ if (!reads) return;
189
+ const jiti = createJiti(import.meta.url, { interopDefault: true, ...(alias ? { alias } : {}) });
190
+ setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
191
+ };
192
+ installLoader();
193
+
194
+ // Config cascades along the workspace→app frontier: the workspace root (via
195
+ // FOUGERE_ROOT, where `remotes`/shared db live) is the base, the app (cwd)
196
+ // overrides. Same boundary the fronds cascade along. No `root` → both equal.
197
+ const configRoot = process.cwd();
198
+ const root = process.env.FOUGERE_ROOT ?? configRoot;
199
+ // The host's word wins, for the reason it wins on `scan`: it read the file already,
200
+ // and where there is no file a second read finds nothing and says nothing.
201
+ const fileConfig: FougereConfig = (_config.config as FougereConfig | undefined)
202
+ ?? (await loadCascadedConfig(root, configRoot));
203
+ const conventions = resolveConventions(fileConfig.conventions);
204
+ // `frondAliases` reads a directory listing, so it is asked only when the loader it
205
+ // feeds is going to exist at all.
206
+ if (reads) installLoader(await frondAliases(root, conventions));
207
+
208
+ // Auto-resolve the data layer from config.db when the user didn't provide a
209
+ // custom one via configureFougere. The resolution itself lives in @fougere/defaults
210
+ // — this host must not know which storage package backs `db:`.
211
+ let db = _config.db;
212
+ let ormFactory = _config.ormFactory;
213
+ // The storage's two halves, kept together: its ascent is an extension, its connection
214
+ // is not — it is opened here, before the container, so it closes after the container.
215
+ let storageMigrate: Extension['up'] | undefined;
216
+ let closeStorage: (() => Promise<void>) | undefined;
217
+ // Where the rows are, and how to open a transaction there — read from the same storage
218
+ // resolution, because a frame's realization is decided by `sources:` and nothing else.
219
+ let sourceOf: ((entityName: string) => string) | undefined;
220
+ let transacted: CreateAppOptions['transacted'];
221
+ if (!ormFactory) {
222
+ const { resolveStorage } = await import('@fougere/defaults');
223
+ const storage = resolveStorage(fileConfig.db as never, (fileConfig as { sources?: unknown }).sources as never);
224
+ if (storage.ormFactory) {
225
+ log.debug('auto-resolving storage from config.db');
226
+ db = storage.db;
227
+ ormFactory = storage.ormFactory;
228
+ sourceOf = storage.sourceOf;
229
+ transacted = storage.transacted as never;
230
+ storageMigrate = storage.migrate;
231
+ closeStorage = storage.close;
232
+ } else {
233
+ log.debug('no db declared — falling back to in-memory ORM');
234
+ ormFactory = createMemoryOrm;
235
+ }
236
+ }
237
+
238
+ // Layer-2 wiring: `remotes: { catalog: 'http://...' }` in fougere.config.ts
239
+ // is all the user writes — the default transport comes from here.
240
+ // The host's word wins here too — and where it speaks, nothing below runs: building
241
+ // the default would import the transport and read the environment for a key, both
242
+ // pointless once the caller has said who carries the call.
243
+ let remoteTransport: ((url: string) => Transport) | undefined = _config.remoteTransport;
244
+ if (!remoteTransport && Object.keys(fileConfig.remotes ?? {}).length > 0) {
245
+ log.debug(`remotes declared (${Object.keys(fileConfig.remotes!).join(', ')}) — wiring HTTP transport`);
246
+ const { createHttpTransport } = await import('@fougere/transport-http');
247
+ // A call that leaves this process carries a proof of who sent it, when the
248
+ // deployment gave one. Without a key it travels as a bare claim, which only a
249
+ // receiver that trusts no root will take.
250
+ const { sign } = await identityFromEnv();
251
+ remoteTransport = (url) => createHttpTransport(url, (sign ? { sign } : {}));
252
+ }
253
+
254
+ const app = await createApp({
255
+ // The host's word wins: it scanned at build, and a second scan here would either
256
+ // repeat that work or — where there is no disk — find nothing and say so quietly.
257
+ // A host that names its fronds never reaches `scanProject` — that is what keeps
258
+ // `typescript` out of a production boot. It may still hand over a scan of its own, and
259
+ // then `hostedBy` merges the two: under Nuxt that scan is a BUILD artifact, so leaning
260
+ // on it costs the runtime nothing. What is never done is scanning a disk BECAUSE a
261
+ // statement was incomplete — half a statement would buy nothing the whole one does.
262
+ ...(_config.fronds ? { fronds: _config.fronds } : {}),
263
+ ...(_config.scan ? { scan: _config.scan } : {}),
264
+ ...(!_config.fronds && !_config.scan
265
+ ? { scan: await scanProject(root, undefined, conventions) }
266
+ : {}),
267
+ createContainer,
268
+ ormFactory,
269
+ sourceOf,
270
+ transacted,
271
+ db,
272
+ auth: fileConfig.auth,
273
+ adapters: fileConfig.adapters,
274
+ remotes: fileConfig.remotes,
275
+ remoteTransport,
276
+ /**
277
+ * The whole ascent, one ordered list: tables, then rows, then what the host adds.
278
+ * A host wanting its OWN seeding declares `{ name: 'seeds', … }` and replaces that
279
+ * member — it no longer has to claim everything after the boot to get it.
280
+ */
281
+ extensions: [
282
+ // The slot is declared even when this host resolved no storage — a host that resolved
283
+ // its own (the Nitro plugin does, for its bundler) then REPLACES this member in place
284
+ // instead of adding one after the seeds, which is rows before tables.
285
+ migrating(storageMigrate),
286
+ seeding((message) => log.info(`[seed]${message}`)),
287
+ ...(_config.extensions ?? []),
288
+ ],
289
+ // Opened before the container, so released after it. Never wired here until now:
290
+ // this host boots the storage and no host closed one, which is what made a reload
291
+ // leak the pool of every app it discarded.
292
+ onDispose: closeStorage,
293
+ });
294
+
295
+ log.info(`ascent: ${app.extensions().join(' → ') || 'nothing declared'}`);
296
+
297
+ const ms = (performance.now() - bootStart).toFixed(0);
298
+ log.info(`ready in ${ms}ms — ${app.fronds.length} frond(s)${app.auth ? ` + auth (${app.auth.basePath})` : ''}`);
299
+
300
+ return app;
301
+ }
302
+
303
+ // ── Fallback ORM ─────────────────────────────────
304
+
305
+ /**
306
+ * The store an app with no `db` runs on.
307
+ *
308
+ * It used to ignore both its arguments — `(_entity, _name)` — so it forced the field
309
+ * name `id`, minted a uuid whatever the entity declared, and realized none of the
310
+ * lifecycle rules: `created()` stamped nothing, a declared default stayed absent. The
311
+ * same page therefore behaved one way here and another way on SQLite.
312
+ *
313
+ * It reads the axes now, through the one realization every storage shares.
314
+ */
315
+ export function createMemoryOrm(entity: SchemaView, name: string): EntityOrm {
316
+ const fields = entity.getFields();
317
+ const pk = Object.entries(fields).find(([, field]) => Role.of(field).isPrimary)?.[0] ?? 'id';
318
+ const store = new Map<string, Record<string, unknown>>();
319
+ // `EntityOrm.findById(id: string)` — but a key can hold a number, and a Map keyed on
320
+ // `1` does not answer `'1'`. SQL never had the question; here the divergence was
321
+ // silent and only on this storage.
322
+ const keyOf = (value: unknown) => String(value);
323
+ // Same contract as SQL: a criterion may name a SET, and an empty set matches nothing.
324
+ const matches = (row: Record<string, unknown>, criteria: Record<string, unknown>) =>
325
+ Object.entries(criteria).every(([key, value]) => Array.isArray(value)
326
+ ? value.some((v) => Object.is(row[key], v))
327
+ : Object.is(row[key], value));
328
+ return {
329
+ client: store,
330
+ async list(options?: any) {
331
+ let items = [...store.values()];
332
+ if (options?.where) items = items.filter((row) => matches(row, options.where));
333
+ // Held before the page is cut, and after the filter: `total` answers "how many
334
+ // match", which is what a paginator divides. Reading `store.size` at the end
335
+ // answered a different question — every row the store holds, including the ones
336
+ // the filter exists to keep out of this caller's sight.
337
+ const matching = items.length;
338
+ const limit = options?.limit;
339
+ const offset = options?.page && limit ? (options.page - 1) * limit : options?.offset ?? 0;
340
+ if (offset > 0) items = items.slice(offset);
341
+ const hasMore = limit ? items.length > limit : false;
342
+ if (limit) items = items.slice(0, limit);
343
+ const result = items as any;
344
+ result.hasMore = hasMore;
345
+ result.endCursor = items.length > 0 ? String((items[items.length - 1] as any)[pk] ?? '') : undefined;
346
+ if (options?.count) result.total = matching;
347
+ return result;
348
+ },
349
+ async findById(id: string) { return store.get(keyOf(id)); },
350
+ async findBy(criteria: Record<string, unknown>) {
351
+ return [...store.values()].find((row) => matches(row, criteria));
352
+ },
353
+ async findAllBy(criteria: Record<string, unknown>) {
354
+ return [...store.values()].filter((row) => matches(row, criteria));
355
+ },
356
+ // Same contract as SQL: a map keyed by the primary key, a miss being an absent key.
357
+ async findByKeys(ids: readonly string[]) {
358
+ const found = new Map<string, Record<string, unknown>>();
359
+ for (const id of ids) {
360
+ const row = store.get(keyOf(id));
361
+ if (row) found.set(String(id), row);
362
+ }
363
+ return found;
364
+ },
365
+ // The dual, same contract as SQL: grouped by the value read off the ROW.
366
+ async findAllByKeys(field: string, keys: readonly string[]) {
367
+ const grouped = new Map<string, Record<string, unknown>[]>();
368
+ if (keys.length === 0) return grouped;
369
+ const wanted = new Set(keys.map(String));
370
+ for (const row of store.values()) {
371
+ const key = String(row[field]);
372
+ if (!wanted.has(key)) continue;
373
+ const held = grouped.get(key);
374
+ if (held) held.push(row); else grouped.set(key, [row]);
375
+ }
376
+ return grouped;
377
+ },
378
+ // Same contract as SQL: the key and the creation stamps survive an overwrite.
379
+ async upsert(input: Partial<Record<string, unknown>>) {
380
+ const record = applyCreate(fields, applyUpdate(fields, input));
381
+ const id = record[pk] as string | undefined;
382
+ if (id === undefined) throw new Error(`${name}.upsert(): no \`${pk}\` — an upsert needs the key it writes at.`);
383
+ const held = store.get(keyOf(id));
384
+ if (held) {
385
+ for (const [key, field] of Object.entries(fields)) {
386
+ if (key === pk || Lifecycle.of(field).stampedOnce) record[key] = held[key];
387
+ }
388
+ }
389
+ store.set(keyOf(id), record);
390
+ return record;
391
+ },
392
+ async upsertAll(inputs: readonly Partial<Record<string, unknown>>[]) {
393
+ for (const input of inputs) await (this as any).upsert(input);
394
+ return inputs.length;
395
+ },
396
+ async create(input: Partial<Record<string, unknown>>) {
397
+ const record = applyCreate(fields, input);
398
+ const id = record[pk] as string | undefined;
399
+ // `primary(text())` declares no generator, so nothing fills the hole and the
400
+ // caller has to. Keying on `undefined` would let the second create overwrite the
401
+ // first, in silence — the old version hid this by inventing an `id` field the
402
+ // entity never declared.
403
+ if (id === undefined) {
404
+ throw new Error(`${name}.create: '${pk}' is the primary key and nothing supplied it — this entity declares no generator for it.`);
405
+ }
406
+ // A create is not an upsert. `Map.set` overwrites, so a second create under the
407
+ // same key answered "created" while destroying the previous row — SQL answers a
408
+ // constraint violation, and a store that loses data silently is worse than one
409
+ // that fails.
410
+ if (store.has(keyOf(id))) {
411
+ throw new Error(`${name}.create: '${pk}' ${JSON.stringify(id)} already exists.`);
412
+ }
413
+ store.set(keyOf(id), record);
414
+ return record;
415
+ },
416
+ async update(id: string, input: Partial<Record<string, unknown>>) {
417
+ const existing = store.get(keyOf(id));
418
+ if (!existing) throw new Error(`Not found: ${id}`);
419
+ const updated = { ...existing, ...applyUpdate(fields, input), [pk]: existing[pk] };
420
+ store.set(keyOf(id), updated);
421
+ return updated;
422
+ },
423
+ async delete(id: string) { return store.delete(keyOf(id)); },
424
+ output(_schema: SchemaView) { return this; },
425
+ };
426
+ }
package/src/client.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The couple, minus the reactivity — everything `useQuery`/`useCommand` decide
3
+ * before a framework's state primitives get involved.
4
+ *
5
+ * Designation is class + verb: the imported entity class carries the metadata,
6
+ * its name carries the registration key. That is true in Vue and in React, and so
7
+ * is the link — a successful command on an entity revalidates every mounted query
8
+ * on that entity, because the entity is designated on both sides and nothing has
9
+ * to be declared. What differs between hosts is only HOW a value becomes reactive
10
+ * and how a revalidation is triggered, which is ~50 lines each and belongs to them.
11
+ *
12
+ * Browser-safe by construction: this module reaches `@fougere/core/contract` and
13
+ * the transport's client subpath, never the boot. `@fougere/app/client` is the
14
+ * subpath that keeps it that way.
15
+ */
16
+ import {
17
+ FougereError,
18
+ ErrorCode,
19
+ lowerFirst,
20
+ type InvocationContext,
21
+ type FrondCall,
22
+ } from '@fougere/core/contract';
23
+ import { frameCall, unframeResponse, type RpcResponse } from '@fougere/transport-http/client';
24
+
25
+ /** An entity class is a designation: its name is the registration key. */
26
+ export type EntityClass = { name: string };
27
+
28
+ /** What a page provides of an invocation — the rest is stamped server-side. */
29
+ export type CallInput = Partial<Pick<InvocationContext, 'params' | 'query' | 'body'>>;
30
+
31
+ /** The one door the browser knows. A named surface adds `/{surface}` to it. */
32
+ export const CALL_ENDPOINT = '/_fougere/call';
33
+
34
+ export type Fetcher = <T>(url: string, options: { method: 'POST'; body: unknown }) => Promise<T>;
35
+
36
+ let nextId = 1;
37
+
38
+ /** The registration key an entity class designates. */
39
+ export function entityKeyOf(entity: EntityClass): string {
40
+ return lowerFirst(entity.name);
41
+ }
42
+
43
+ export function callOf(entity: EntityClass, op: string): FrondCall {
44
+ return { entity: entityKeyOf(entity), op };
45
+ }
46
+
47
+ export function invocationOf(input?: CallInput): InvocationContext {
48
+ return { params: {}, query: {}, body: undefined, state: {}, ...input };
49
+ }
50
+
51
+ /**
52
+ * The cache key of a read. Same designation and same input means the same key —
53
+ * which is what lets two components asking the same thing share one request, and
54
+ * what the command side matches against to revalidate.
55
+ */
56
+ export function queryKeyOf(entityKey: string, op: string, input?: CallInput): string {
57
+ return `fougere:${entityKey}.${op}:${JSON.stringify(input ?? {})}`;
58
+ }
59
+
60
+ export async function sendCall(
61
+ fetcher: Fetcher,
62
+ call: FrondCall,
63
+ invocation: InvocationContext,
64
+ endpoint: string = CALL_ENDPOINT,
65
+ ): Promise<unknown> {
66
+ const response = await fetcher<RpcResponse>(endpoint, {
67
+ method: 'POST',
68
+ body: frameCall(call, invocation, nextId++),
69
+ });
70
+ return unframeResponse(response, call);
71
+ }
72
+
73
+ // ── The link ─────────────────────────────────────
74
+
75
+ /** Mounted queries per entity — the command side of the link reads this. */
76
+ const mounted = new Map<string, Set<string>>();
77
+
78
+ /** Register a mounted read. Returns the unregistration, for the host's scope teardown. */
79
+ export function trackQuery(entityKey: string, key: string): () => void {
80
+ const keys = mounted.get(entityKey) ?? new Set<string>();
81
+ keys.add(key);
82
+ mounted.set(entityKey, keys);
83
+ return () => keys.delete(key);
84
+ }
85
+
86
+ /** The keys a successful command on this entity should revalidate. */
87
+ export function mountedKeys(entityKey: string): string[] {
88
+ return [...(mounted.get(entityKey) ?? [])];
89
+ }
90
+
91
+ /**
92
+ * `mountedKeys` says WHICH reads a command invalidates; these say how to make one
93
+ * happen. Both halves turned out to be host-independent — Nuxt is the exception,
94
+ * because `refreshNuxtData` already is this registry.
95
+ *
96
+ * They lived in `@fougere/react` until a second non-Nuxt client needed them, which
97
+ * is when it became visible that nothing in them is React.
98
+ */
99
+ const refetchers = new Map<string, Set<() => void>>();
100
+
101
+ /** Register a mounted read's refetch. Returns the unregistration. */
102
+ export function onRefetch(key: string, run: () => void): () => void {
103
+ const set = refetchers.get(key) ?? new Set<() => void>();
104
+ set.add(run);
105
+ refetchers.set(key, set);
106
+ return () => set.delete(run);
107
+ }
108
+
109
+ export function revalidate(keys: string[]): void {
110
+ for (const key of keys) for (const run of refetchers.get(key) ?? []) run();
111
+ }
112
+
113
+ /** The browser's way to reach the envelope. Same-origin, so no base URL to configure. */
114
+ export const fetcher: Fetcher = async <T,>(url: string, options: { method: 'POST'; body: unknown }): Promise<T> => {
115
+ const response = await fetch(url, {
116
+ method: options.method,
117
+ headers: { 'content-type': 'application/json' },
118
+ body: JSON.stringify(options.body),
119
+ });
120
+ return (await response.json()) as T;
121
+ };
122
+
123
+ // ── Reading a result ─────────────────────────────
124
+
125
+ /** A list result reads as items whatever the wire delivered — bare array or envelope. */
126
+ export function itemsOf<T>(data: unknown): T[] {
127
+ if (Array.isArray(data)) return data as T[];
128
+ if (data && typeof data === 'object' && Array.isArray((data as { items?: unknown }).items)) {
129
+ return (data as { items: T[] }).items;
130
+ }
131
+ return [];
132
+ }
133
+
134
+ export function pageOf(data: unknown): { total?: number; hasMore?: boolean; endCursor?: string } {
135
+ return (data ?? {}) as { total?: number; hasMore?: boolean; endCursor?: string };
136
+ }
137
+
138
+ /**
139
+ * Whatever failed, as the error the primitives promise. A transport failure is not
140
+ * a domain refusal, so it arrives under SERVICE_UNAVAILABLE rather than borrowing a
141
+ * code the server never sent.
142
+ */
143
+ export function asFougereError(err: unknown, entityKey: string, op: string): FougereError {
144
+ return err instanceof FougereError
145
+ ? err
146
+ : new FougereError({
147
+ code: ErrorCode.SERVICE_UNAVAILABLE,
148
+ message: (err as Error)?.message ?? String(err),
149
+ entity: entityKey,
150
+ operation: op,
151
+ cause: err,
152
+ });
153
+ }
154
+
155
+ // The form contract is host-independent too, and a form is client code — so it
156
+ // reaches the browser through this subpath rather than through the package root,
157
+ // which carries the boot.
158
+ export {
159
+ formFieldsOf,
160
+ tableColumnsOf,
161
+ payloadOf,
162
+ errorsByField,
163
+ type FormEntity,
164
+ type FormField,
165
+ type TableColumn,
166
+ } from './form.js';
167
+ export { sessionViewOf, type SessionView } from './session.js';