@fougere/app 0.2.0-alpha.2 → 0.4.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/dist/boot.d.ts +75 -3
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +120 -39
- package/dist/boot.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3 -3
- package/dist/client.js.map +1 -1
- package/dist/form.d.ts +26 -1
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +49 -10
- package/dist/form.js.map +1 -1
- package/dist/graphql.d.ts +12 -5
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +13 -33
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/rest.js.map +1 -1
- package/package.json +12 -25
- package/src/auth.ts +20 -0
- package/src/boot.ts +408 -0
- package/src/client.ts +167 -0
- package/src/express.ts +208 -0
- package/src/form.ts +237 -0
- package/src/graphql.ts +85 -0
- package/src/index.ts +55 -0
- package/src/rest.ts +118 -0
- package/src/serve.ts +191 -0
- package/src/session.ts +20 -0
- package/src/state.ts +33 -0
- package/src/web.ts +108 -0
package/src/boot.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
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 `fougere.config.ts` says, when the host already read it.
|
|
65
|
+
*
|
|
66
|
+
* The same rule as `scan`, and found the same way: `boot()` re-reads the file at
|
|
67
|
+
* runtime, which a Worker cannot do — measured, a consumer's `remotes:` never reached
|
|
68
|
+
* the boot and its pages rendered empty with nothing said. A host that read the config
|
|
69
|
+
* at BUILD time states it here instead.
|
|
70
|
+
*
|
|
71
|
+
* `auth` is deliberately not part of what a codegen'd host can carry: it holds a live
|
|
72
|
+
* provider, not a value. An app that authenticates reads its own config.
|
|
73
|
+
*/
|
|
74
|
+
config?: Partial<FougereConfig>;
|
|
75
|
+
/**
|
|
76
|
+
* Who performs an outgoing call, when the default cannot.
|
|
77
|
+
*
|
|
78
|
+
* `boot()` builds an HTTP transport from `remotes:` and that is right nearly
|
|
79
|
+
* everywhere. It is not right on Cloudflare: a Worker calling a sibling's public URL
|
|
80
|
+
* is refused by the edge with error 1042, so two Workers of one account reach each
|
|
81
|
+
* other through a SERVICE BINDING and through nothing else. A binding is a value only
|
|
82
|
+
* the host holds, so only the host can state this.
|
|
83
|
+
*
|
|
84
|
+
* It replaces the default entirely — signing included, since a host that builds its
|
|
85
|
+
* own transport is the one that knows what to put on the wire.
|
|
86
|
+
*/
|
|
87
|
+
remoteTransport?: (url: string) => Transport;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── State ────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
let _config: FougereServerConfig = {};
|
|
93
|
+
let _appPromise: Promise<App> | null = null;
|
|
94
|
+
|
|
95
|
+
// ── Public API ───────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Override the data layer — only needed if you don't want the convention-driven
|
|
99
|
+
* setup based on `config.db` in fougere.config.ts.
|
|
100
|
+
*/
|
|
101
|
+
export function configureFougere(config: FougereServerConfig) {
|
|
102
|
+
_config = config;
|
|
103
|
+
_appPromise = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Add to what is already stated, instead of replacing it.
|
|
108
|
+
*
|
|
109
|
+
* A host states its app in PIECES when the pieces are known at different moments: a
|
|
110
|
+
* build writes the scan and the config into a generated plugin, and a value only the
|
|
111
|
+
* running process holds — a Cloudflare service binding — cannot be written there at all.
|
|
112
|
+
* Its dual is `configureFougere`, which replaces; `reloadFougere` depends on that
|
|
113
|
+
* replacement, so merging silently would have broken the turn of the ring.
|
|
114
|
+
*/
|
|
115
|
+
export function extendFougere(config: Partial<FougereServerConfig>) {
|
|
116
|
+
_config = { ..._config, ...config };
|
|
117
|
+
_appPromise = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get the booted Fougere app. Lazy — boots on first call, then caches. */
|
|
121
|
+
export function useFougereApp(): Promise<App> {
|
|
122
|
+
if (!_appPromise) {
|
|
123
|
+
_appPromise = boot();
|
|
124
|
+
}
|
|
125
|
+
return _appPromise;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Turn the ring: instantiate the app again, then let the previous one go.
|
|
130
|
+
*
|
|
131
|
+
* This is what "reload" means for anything the config CONSUMED — a value that built
|
|
132
|
+
* something cannot move under what it built, so the thing is built again. Its dual is
|
|
133
|
+
* `applyConfig`, for values that are merely consulted and need no turn at all.
|
|
134
|
+
*
|
|
135
|
+
* Every door reaches the app through `useFougereApp()` inside the request it serves and
|
|
136
|
+
* none holds it across two, which is what makes the swap invisible: the next request
|
|
137
|
+
* lands on the new app whether or not the old one has finished being released.
|
|
138
|
+
*
|
|
139
|
+
* A call already running finishes on the OLD app: it is drained before being released,
|
|
140
|
+
* so nothing has its storage closed underneath it. `timeoutMs` bounds that wait, and a
|
|
141
|
+
* drain that runs out REJECTS — the app is left alone rather than released under work,
|
|
142
|
+
* because a caller who cannot wait must choose that on purpose.
|
|
143
|
+
*/
|
|
144
|
+
export async function reloadFougere(timeoutMs?: number): Promise<App> {
|
|
145
|
+
const previous = _appPromise;
|
|
146
|
+
_appPromise = null;
|
|
147
|
+
// The new one first: a boot that fails leaves the previous app serving, still whole.
|
|
148
|
+
const next = await useFougereApp();
|
|
149
|
+
if (previous) {
|
|
150
|
+
const old = await previous;
|
|
151
|
+
await old.drain(timeoutMs);
|
|
152
|
+
await old.dispose();
|
|
153
|
+
}
|
|
154
|
+
return next;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Boot ─────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
async function boot(): Promise<App> {
|
|
160
|
+
const bootStart = performance.now();
|
|
161
|
+
const log = new Logger('boot');
|
|
162
|
+
|
|
163
|
+
log.info(`booting (${_config.host ?? 'app'})`);
|
|
164
|
+
|
|
165
|
+
const { createJiti } = await import('jiti');
|
|
166
|
+
// Nitro serves from a bundle, but the scan still reads frond sources from disk — so the
|
|
167
|
+
// named form a frond uses for its neighbour has to resolve here too.
|
|
168
|
+
//
|
|
169
|
+
// Installed twice: the config names the scope the aliases are built from, so reading it
|
|
170
|
+
// must not need them. Nothing in `fougere.config.ts` may import `@fronds/*`.
|
|
171
|
+
//
|
|
172
|
+
// And installed only when something is going to READ a source. A host that handed in
|
|
173
|
+
// both its scan and its config has nothing left to load, and jiti cannot run where
|
|
174
|
+
// there is no module resolver: measured on workerd, `createJiti` threw
|
|
175
|
+
// `Cannot read properties of undefined (reading 'paths')` and every request answered
|
|
176
|
+
// 500 — the loader was being built for files that no longer needed opening.
|
|
177
|
+
const reads = _config.scan === undefined || _config.config === undefined;
|
|
178
|
+
const installLoader = (alias?: Record<string, string>): void => {
|
|
179
|
+
if (!reads) return;
|
|
180
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true, ...(alias ? { alias } : {}) });
|
|
181
|
+
setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
|
|
182
|
+
};
|
|
183
|
+
installLoader();
|
|
184
|
+
|
|
185
|
+
// Config cascades along the workspace→app frontier: the workspace root (via
|
|
186
|
+
// FOUGERE_ROOT, where `remotes`/shared db live) is the base, the app (cwd)
|
|
187
|
+
// overrides. Same boundary the fronds cascade along. No `root` → both equal.
|
|
188
|
+
const configRoot = process.cwd();
|
|
189
|
+
const root = process.env.FOUGERE_ROOT ?? configRoot;
|
|
190
|
+
// The host's word wins, for the reason it wins on `scan`: it read the file already,
|
|
191
|
+
// and where there is no file a second read finds nothing and says nothing.
|
|
192
|
+
const fileConfig: FougereConfig = (_config.config as FougereConfig | undefined)
|
|
193
|
+
?? (await loadCascadedConfig(root, configRoot));
|
|
194
|
+
const conventions = resolveConventions(fileConfig.conventions);
|
|
195
|
+
// `frondAliases` reads a directory listing, so it is asked only when the loader it
|
|
196
|
+
// feeds is going to exist at all.
|
|
197
|
+
if (reads) installLoader(await frondAliases(root, conventions));
|
|
198
|
+
|
|
199
|
+
// Auto-resolve the data layer from config.db when the user didn't provide a
|
|
200
|
+
// custom one via configureFougere. The resolution itself lives in @fougere/defaults
|
|
201
|
+
// — this host must not know which storage package backs `db:`.
|
|
202
|
+
let db = _config.db;
|
|
203
|
+
let ormFactory = _config.ormFactory;
|
|
204
|
+
// The storage's two halves, kept together: its ascent is an extension, its connection
|
|
205
|
+
// is not — it is opened here, before the container, so it closes after the container.
|
|
206
|
+
let storageMigrate: Extension['up'] | undefined;
|
|
207
|
+
let closeStorage: (() => Promise<void>) | undefined;
|
|
208
|
+
// Where the rows are, and how to open a transaction there — read from the same storage
|
|
209
|
+
// resolution, because a frame's realization is decided by `sources:` and nothing else.
|
|
210
|
+
let sourceOf: ((entityName: string) => string) | undefined;
|
|
211
|
+
let transacted: CreateAppOptions['transacted'];
|
|
212
|
+
if (!ormFactory) {
|
|
213
|
+
const { resolveStorage } = await import('@fougere/defaults');
|
|
214
|
+
const storage = resolveStorage(fileConfig.db as never, (fileConfig as { sources?: unknown }).sources as never);
|
|
215
|
+
if (storage.ormFactory) {
|
|
216
|
+
log.debug('auto-resolving storage from config.db');
|
|
217
|
+
db = storage.db;
|
|
218
|
+
ormFactory = storage.ormFactory;
|
|
219
|
+
sourceOf = storage.sourceOf;
|
|
220
|
+
transacted = storage.transacted as never;
|
|
221
|
+
storageMigrate = storage.migrate;
|
|
222
|
+
closeStorage = storage.close;
|
|
223
|
+
} else {
|
|
224
|
+
log.debug('no db declared — falling back to in-memory ORM');
|
|
225
|
+
ormFactory = createMemoryOrm;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Layer-2 wiring: `remotes: { catalog: 'http://...' }` in fougere.config.ts
|
|
230
|
+
// is all the user writes — the default transport comes from here.
|
|
231
|
+
// The host's word wins here too — and where it speaks, nothing below runs: building
|
|
232
|
+
// the default would import the transport and read the environment for a key, both
|
|
233
|
+
// pointless once the caller has said who carries the call.
|
|
234
|
+
let remoteTransport: ((url: string) => Transport) | undefined = _config.remoteTransport;
|
|
235
|
+
if (!remoteTransport && Object.keys(fileConfig.remotes ?? {}).length > 0) {
|
|
236
|
+
log.debug(`remotes declared (${Object.keys(fileConfig.remotes!).join(', ')}) — wiring HTTP transport`);
|
|
237
|
+
const { createHttpTransport } = await import('@fougere/transport-http');
|
|
238
|
+
// A call that leaves this process carries a proof of who sent it, when the
|
|
239
|
+
// deployment gave one. Without a key it travels as a bare claim, which only a
|
|
240
|
+
// receiver that trusts no root will take.
|
|
241
|
+
const { sign } = await identityFromEnv();
|
|
242
|
+
remoteTransport = (url) => createHttpTransport(url, (sign ? { sign } : {}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const app = await createApp({
|
|
246
|
+
// The host's word wins: it scanned at build, and a second scan here would either
|
|
247
|
+
// repeat that work or — where there is no disk — find nothing and say so quietly.
|
|
248
|
+
scan: _config.scan ?? (await scanProject(root, undefined, conventions)),
|
|
249
|
+
createContainer,
|
|
250
|
+
ormFactory,
|
|
251
|
+
sourceOf,
|
|
252
|
+
transacted,
|
|
253
|
+
db,
|
|
254
|
+
auth: fileConfig.auth,
|
|
255
|
+
adapters: fileConfig.adapters,
|
|
256
|
+
remotes: fileConfig.remotes,
|
|
257
|
+
remoteTransport,
|
|
258
|
+
/**
|
|
259
|
+
* The whole ascent, one ordered list: tables, then rows, then what the host adds.
|
|
260
|
+
* A host wanting its OWN seeding declares `{ name: 'seeds', … }` and replaces that
|
|
261
|
+
* member — it no longer has to claim everything after the boot to get it.
|
|
262
|
+
*/
|
|
263
|
+
extensions: [
|
|
264
|
+
// The slot is declared even when this host resolved no storage — a host that resolved
|
|
265
|
+
// its own (the Nitro plugin does, for its bundler) then REPLACES this member in place
|
|
266
|
+
// instead of adding one after the seeds, which is rows before tables.
|
|
267
|
+
migrating(storageMigrate),
|
|
268
|
+
seeding((message) => log.info(`[seed]${message}`)),
|
|
269
|
+
...(_config.extensions ?? []),
|
|
270
|
+
],
|
|
271
|
+
// Opened before the container, so released after it. Never wired here until now:
|
|
272
|
+
// this host boots the storage and no host closed one, which is what made a reload
|
|
273
|
+
// leak the pool of every app it discarded.
|
|
274
|
+
onDispose: closeStorage,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
log.info(`ascent: ${app.extensions().join(' → ') || 'nothing declared'}`);
|
|
278
|
+
|
|
279
|
+
const ms = (performance.now() - bootStart).toFixed(0);
|
|
280
|
+
log.info(`ready in ${ms}ms — ${app.fronds.length} frond(s)${app.auth ? ` + auth (${app.auth.basePath})` : ''}`);
|
|
281
|
+
|
|
282
|
+
return app;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Fallback ORM ─────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The store an app with no `db` runs on.
|
|
289
|
+
*
|
|
290
|
+
* It used to ignore both its arguments — `(_entity, _name)` — so it forced the field
|
|
291
|
+
* name `id`, minted a uuid whatever the entity declared, and realized none of the
|
|
292
|
+
* lifecycle rules: `created()` stamped nothing, a declared default stayed absent. The
|
|
293
|
+
* same page therefore behaved one way here and another way on SQLite.
|
|
294
|
+
*
|
|
295
|
+
* It reads the axes now, through the one realization every storage shares.
|
|
296
|
+
*/
|
|
297
|
+
export function createMemoryOrm(entity: SchemaView, name: string): EntityOrm {
|
|
298
|
+
const fields = entity.getFields();
|
|
299
|
+
const pk = Object.entries(fields).find(([, field]) => Role.of(field).isPrimary)?.[0] ?? 'id';
|
|
300
|
+
const store = new Map<string, Record<string, unknown>>();
|
|
301
|
+
// `EntityOrm.findById(id: string)` — but a key can hold a number, and a Map keyed on
|
|
302
|
+
// `1` does not answer `'1'`. SQL never had the question; here the divergence was
|
|
303
|
+
// silent and only on this storage.
|
|
304
|
+
const keyOf = (value: unknown) => String(value);
|
|
305
|
+
// Same contract as SQL: a criterion may name a SET, and an empty set matches nothing.
|
|
306
|
+
const matches = (row: Record<string, unknown>, criteria: Record<string, unknown>) =>
|
|
307
|
+
Object.entries(criteria).every(([key, value]) => Array.isArray(value)
|
|
308
|
+
? value.some((v) => Object.is(row[key], v))
|
|
309
|
+
: Object.is(row[key], value));
|
|
310
|
+
return {
|
|
311
|
+
client: store,
|
|
312
|
+
async list(options?: any) {
|
|
313
|
+
let items = [...store.values()];
|
|
314
|
+
if (options?.where) items = items.filter((row) => matches(row, options.where));
|
|
315
|
+
// Held before the page is cut, and after the filter: `total` answers "how many
|
|
316
|
+
// match", which is what a paginator divides. Reading `store.size` at the end
|
|
317
|
+
// answered a different question — every row the store holds, including the ones
|
|
318
|
+
// the filter exists to keep out of this caller's sight.
|
|
319
|
+
const matching = items.length;
|
|
320
|
+
const limit = options?.limit;
|
|
321
|
+
const offset = options?.page && limit ? (options.page - 1) * limit : options?.offset ?? 0;
|
|
322
|
+
if (offset > 0) items = items.slice(offset);
|
|
323
|
+
const hasMore = limit ? items.length > limit : false;
|
|
324
|
+
if (limit) items = items.slice(0, limit);
|
|
325
|
+
const result = items as any;
|
|
326
|
+
result.hasMore = hasMore;
|
|
327
|
+
result.endCursor = items.length > 0 ? String((items[items.length - 1] as any)[pk] ?? '') : undefined;
|
|
328
|
+
if (options?.count) result.total = matching;
|
|
329
|
+
return result;
|
|
330
|
+
},
|
|
331
|
+
async findById(id: string) { return store.get(keyOf(id)); },
|
|
332
|
+
async findBy(criteria: Record<string, unknown>) {
|
|
333
|
+
return [...store.values()].find((row) => matches(row, criteria));
|
|
334
|
+
},
|
|
335
|
+
async findAllBy(criteria: Record<string, unknown>) {
|
|
336
|
+
return [...store.values()].filter((row) => matches(row, criteria));
|
|
337
|
+
},
|
|
338
|
+
// Same contract as SQL: a map keyed by the primary key, a miss being an absent key.
|
|
339
|
+
async findByKeys(ids: readonly string[]) {
|
|
340
|
+
const found = new Map<string, Record<string, unknown>>();
|
|
341
|
+
for (const id of ids) {
|
|
342
|
+
const row = store.get(keyOf(id));
|
|
343
|
+
if (row) found.set(String(id), row);
|
|
344
|
+
}
|
|
345
|
+
return found;
|
|
346
|
+
},
|
|
347
|
+
// The dual, same contract as SQL: grouped by the value read off the ROW.
|
|
348
|
+
async findAllByKeys(field: string, keys: readonly string[]) {
|
|
349
|
+
const grouped = new Map<string, Record<string, unknown>[]>();
|
|
350
|
+
if (keys.length === 0) return grouped;
|
|
351
|
+
const wanted = new Set(keys.map(String));
|
|
352
|
+
for (const row of store.values()) {
|
|
353
|
+
const key = String(row[field]);
|
|
354
|
+
if (!wanted.has(key)) continue;
|
|
355
|
+
const held = grouped.get(key);
|
|
356
|
+
if (held) held.push(row); else grouped.set(key, [row]);
|
|
357
|
+
}
|
|
358
|
+
return grouped;
|
|
359
|
+
},
|
|
360
|
+
// Same contract as SQL: the key and the creation stamps survive an overwrite.
|
|
361
|
+
async upsert(input: Partial<Record<string, unknown>>) {
|
|
362
|
+
const record = applyCreate(fields, applyUpdate(fields, input));
|
|
363
|
+
const id = record[pk] as string | undefined;
|
|
364
|
+
if (id === undefined) throw new Error(`${name}.upsert(): no \`${pk}\` — an upsert needs the key it writes at.`);
|
|
365
|
+
const held = store.get(keyOf(id));
|
|
366
|
+
if (held) {
|
|
367
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
368
|
+
if (key === pk || Lifecycle.of(field).stampedOnce) record[key] = held[key];
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
store.set(keyOf(id), record);
|
|
372
|
+
return record;
|
|
373
|
+
},
|
|
374
|
+
async upsertAll(inputs: readonly Partial<Record<string, unknown>>[]) {
|
|
375
|
+
for (const input of inputs) await (this as any).upsert(input);
|
|
376
|
+
return inputs.length;
|
|
377
|
+
},
|
|
378
|
+
async create(input: Partial<Record<string, unknown>>) {
|
|
379
|
+
const record = applyCreate(fields, input);
|
|
380
|
+
const id = record[pk] as string | undefined;
|
|
381
|
+
// `primary(text())` declares no generator, so nothing fills the hole and the
|
|
382
|
+
// caller has to. Keying on `undefined` would let the second create overwrite the
|
|
383
|
+
// first, in silence — the old version hid this by inventing an `id` field the
|
|
384
|
+
// entity never declared.
|
|
385
|
+
if (id === undefined) {
|
|
386
|
+
throw new Error(`${name}.create: '${pk}' is the primary key and nothing supplied it — this entity declares no generator for it.`);
|
|
387
|
+
}
|
|
388
|
+
// A create is not an upsert. `Map.set` overwrites, so a second create under the
|
|
389
|
+
// same key answered "created" while destroying the previous row — SQL answers a
|
|
390
|
+
// constraint violation, and a store that loses data silently is worse than one
|
|
391
|
+
// that fails.
|
|
392
|
+
if (store.has(keyOf(id))) {
|
|
393
|
+
throw new Error(`${name}.create: '${pk}' ${JSON.stringify(id)} already exists.`);
|
|
394
|
+
}
|
|
395
|
+
store.set(keyOf(id), record);
|
|
396
|
+
return record;
|
|
397
|
+
},
|
|
398
|
+
async update(id: string, input: Partial<Record<string, unknown>>) {
|
|
399
|
+
const existing = store.get(keyOf(id));
|
|
400
|
+
if (!existing) throw new Error(`Not found: ${id}`);
|
|
401
|
+
const updated = { ...existing, ...applyUpdate(fields, input), [pk]: existing[pk] };
|
|
402
|
+
store.set(keyOf(id), updated);
|
|
403
|
+
return updated;
|
|
404
|
+
},
|
|
405
|
+
async delete(id: string) { return store.delete(keyOf(id)); },
|
|
406
|
+
output(_schema: SchemaView) { return this; },
|
|
407
|
+
};
|
|
408
|
+
}
|
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';
|