@fougere/app 0.5.0-alpha.1 → 0.7.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/auth.d.ts +1 -7
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/boot.d.ts +14 -96
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +19 -217
- package/dist/boot.js.map +1 -1
- package/dist/client.d.ts +5 -24
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -32
- package/dist/client.js.map +1 -1
- package/dist/express.d.ts +2 -12
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +4 -52
- package/dist/express.js.map +1 -1
- package/dist/form.d.ts +11 -56
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +15 -33
- package/dist/form.js.map +1 -1
- package/dist/graphql.d.ts +2 -28
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +1 -6
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +3 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -12
- package/dist/index.js.map +1 -1
- package/dist/rest.d.ts +2 -16
- package/dist/rest.d.ts.map +1 -1
- package/dist/rest.js +4 -34
- package/dist/rest.js.map +1 -1
- package/dist/serve.d.ts +10 -57
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +12 -55
- package/dist/serve.js.map +1 -1
- package/dist/session.d.ts +2 -7
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +2 -7
- package/dist/session.js.map +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +1 -11
- package/dist/state.js.map +1 -1
- package/dist/web.d.ts +3 -19
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +4 -32
- package/dist/web.js.map +1 -1
- package/package.json +11 -10
- package/src/auth.ts +1 -7
- package/src/boot.ts +31 -260
- package/src/client.ts +7 -33
- package/src/express.ts +4 -52
- package/src/form.ts +20 -73
- package/src/graphql.ts +2 -28
- package/src/index.ts +4 -12
- package/src/rest.ts +4 -34
- package/src/serve.ts +13 -60
- package/src/session.ts +2 -7
- package/src/state.ts +1 -11
- package/src/web.ts +4 -32
package/src/boot.ts
CHANGED
|
@@ -1,98 +1,35 @@
|
|
|
1
1
|
import { Role } from '@fougere/schema';
|
|
2
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
|
-
*/
|
|
3
|
+
/** Fougere server bootstrap — single entry point for an app's lifecycle, whatever hosts it. */
|
|
21
4
|
import { createApp, identityFromEnv, Logger, migrating, seeding } from '@fougere/core';
|
|
22
5
|
import { scanProject, loadCascadedConfig, setModuleLoader, frondAliases, resolveConventions } from '@fougere/core/node';
|
|
23
6
|
import type { Extension } from '@fougere/core';
|
|
24
7
|
import { createContainer } from '@fougere/container';
|
|
25
|
-
import
|
|
8
|
+
import { createMemoryStorage } from '@fougere/adapter-memory';
|
|
9
|
+
import type { App, CreateAppOptions, Storage, FougereConfig, Transport } from '@fougere/core';
|
|
10
|
+
import type { ResolvedStorage } from '@fougere/defaults';
|
|
26
11
|
import { applyCreate, applyUpdate, type SchemaView } from '@fougere/schema';
|
|
27
12
|
|
|
28
13
|
// ── Public types ─────────────────────────────────
|
|
29
14
|
|
|
30
15
|
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
16
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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.
|
|
17
|
+
* The whole data layer, as `resolveStorage` composed it — where the rows are, which source
|
|
18
|
+
* transacts, what to close. Handed over as ONE subject: a host that passed the factory alone
|
|
19
|
+
* lost the transaction and the connection's owner, and nothing said so.
|
|
42
20
|
*/
|
|
21
|
+
storage?: ResolvedStorage;
|
|
22
|
+
/** What this app takes on beyond its fronds, each stating what it does and what it undoes. */
|
|
43
23
|
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
|
-
*/
|
|
24
|
+
/** What the boot line names as the host — 'Nuxt/Nitro', 'Next'. */
|
|
49
25
|
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
|
-
*/
|
|
26
|
+
/** What this app is built from, when the host already knows. */
|
|
62
27
|
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
|
-
*/
|
|
28
|
+
/** What this app STATES it hosts — `frond('blog', { entities: [Post] })`. */
|
|
71
29
|
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
|
-
*/
|
|
30
|
+
/** What `fougere.config.ts` says, when the host already read it. */
|
|
83
31
|
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
|
-
*/
|
|
32
|
+
/** Who performs an outgoing call, when the default cannot. */
|
|
96
33
|
remoteTransport?: (url: string) => Transport;
|
|
97
34
|
}
|
|
98
35
|
|
|
@@ -112,15 +49,7 @@ export function configureFougere(config: FougereServerConfig) {
|
|
|
112
49
|
_appPromise = null;
|
|
113
50
|
}
|
|
114
51
|
|
|
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
|
-
*/
|
|
52
|
+
/** Add to what is already stated, instead of replacing it. */
|
|
124
53
|
export function extendFougere(config: Partial<FougereServerConfig>) {
|
|
125
54
|
_config = { ..._config, ...config };
|
|
126
55
|
_appPromise = null;
|
|
@@ -134,22 +63,7 @@ export function useFougereApp(): Promise<App> {
|
|
|
134
63
|
return _appPromise;
|
|
135
64
|
}
|
|
136
65
|
|
|
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
|
-
*/
|
|
66
|
+
/** Turn the ring. */
|
|
153
67
|
export async function reloadFougere(timeoutMs?: number): Promise<App> {
|
|
154
68
|
const previous = _appPromise;
|
|
155
69
|
_appPromise = null;
|
|
@@ -208,32 +122,16 @@ async function boot(): Promise<App> {
|
|
|
208
122
|
// Auto-resolve the data layer from config.db when the user didn't provide a
|
|
209
123
|
// custom one via configureFougere. The resolution itself lives in @fougere/defaults
|
|
210
124
|
// — this host must not know which storage package backs `db:`.
|
|
211
|
-
let
|
|
212
|
-
|
|
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) {
|
|
125
|
+
let storage = _config.storage;
|
|
126
|
+
if (!storage) {
|
|
222
127
|
const { resolveStorage } = await import('@fougere/defaults');
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
}
|
|
128
|
+
storage = resolveStorage(fileConfig.db as never, (fileConfig as { sources?: unknown }).sources as never);
|
|
129
|
+
log.debug(storage.storageFactory ? 'auto-resolving storage from config.db' : 'no db declared — falling back to in-memory storage');
|
|
236
130
|
}
|
|
131
|
+
// The storage's two halves, kept together: its ascent is an extension, its connection
|
|
132
|
+
// is not — it is opened here, before the container, so it closes after the container.
|
|
133
|
+
const storageFactory = storage.storageFactory ?? createMemoryStorage;
|
|
134
|
+
const storageMigrate: Extension['up'] | undefined = storage.migrate;
|
|
237
135
|
|
|
238
136
|
// Layer-2 wiring: `remotes: { catalog: 'http://...' }` in fougere.config.ts
|
|
239
137
|
// is all the user writes — the default transport comes from here.
|
|
@@ -265,19 +163,16 @@ async function boot(): Promise<App> {
|
|
|
265
163
|
? { scan: await scanProject(root, undefined, conventions) }
|
|
266
164
|
: {}),
|
|
267
165
|
createContainer,
|
|
268
|
-
|
|
269
|
-
sourceOf,
|
|
270
|
-
|
|
271
|
-
|
|
166
|
+
storageFactory,
|
|
167
|
+
sourceOf: storage.sourceOf,
|
|
168
|
+
transacts: storage.transacts,
|
|
169
|
+
transacted: storage.transacted as never,
|
|
170
|
+
db: storage.db,
|
|
272
171
|
auth: fileConfig.auth,
|
|
273
172
|
adapters: fileConfig.adapters,
|
|
274
173
|
remotes: fileConfig.remotes,
|
|
275
174
|
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
|
-
*/
|
|
175
|
+
/** The whole ascent, one ordered list. */
|
|
281
176
|
extensions: [
|
|
282
177
|
// The slot is declared even when this host resolved no storage — a host that resolved
|
|
283
178
|
// its own (the Nitro plugin does, for its bundler) then REPLACES this member in place
|
|
@@ -289,7 +184,7 @@ async function boot(): Promise<App> {
|
|
|
289
184
|
// Opened before the container, so released after it. Never wired here until now:
|
|
290
185
|
// this host boots the storage and no host closed one, which is what made a reload
|
|
291
186
|
// leak the pool of every app it discarded.
|
|
292
|
-
onDispose:
|
|
187
|
+
onDispose: storage.close,
|
|
293
188
|
});
|
|
294
189
|
|
|
295
190
|
log.info(`ascent: ${app.extensions().join(' → ') || 'nothing declared'}`);
|
|
@@ -300,127 +195,3 @@ async function boot(): Promise<App> {
|
|
|
300
195
|
return app;
|
|
301
196
|
}
|
|
302
197
|
|
|
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
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The couple, minus the reactivity — everything `useQuery`/`useCommand` decide
|
|
3
|
-
*
|
|
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.
|
|
2
|
+
* The couple, minus the reactivity — everything `useQuery`/`useCommand` decide before a
|
|
3
|
+
* framework's state primitives get involved.
|
|
15
4
|
*/
|
|
16
5
|
import {
|
|
17
6
|
FougereError,
|
|
@@ -26,7 +15,7 @@ import { frameCall, unframeResponse, type RpcResponse } from '@fougere/transport
|
|
|
26
15
|
export type EntityClass = { name: string };
|
|
27
16
|
|
|
28
17
|
/** What a page provides of an invocation — the rest is stamped server-side. */
|
|
29
|
-
export type CallInput = Partial<Pick<InvocationContext, 'params' | 'query' | '
|
|
18
|
+
export type CallInput = Partial<Pick<InvocationContext, 'params' | 'query' | 'input'>>;
|
|
30
19
|
|
|
31
20
|
/** The one door the browser knows. A named surface adds `/{surface}` to it. */
|
|
32
21
|
export const CALL_ENDPOINT = '/_fougere/call';
|
|
@@ -45,14 +34,10 @@ export function callOf(entity: EntityClass, op: string): FrondCall {
|
|
|
45
34
|
}
|
|
46
35
|
|
|
47
36
|
export function invocationOf(input?: CallInput): InvocationContext {
|
|
48
|
-
return { params: {}, query: {},
|
|
37
|
+
return { params: {}, query: {}, input: undefined, state: {}, ...input };
|
|
49
38
|
}
|
|
50
39
|
|
|
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
|
-
*/
|
|
40
|
+
/** The cache key of a read. */
|
|
56
41
|
export function queryKeyOf(entityKey: string, op: string, input?: CallInput): string {
|
|
57
42
|
return `fougere:${entityKey}.${op}:${JSON.stringify(input ?? {})}`;
|
|
58
43
|
}
|
|
@@ -88,14 +73,7 @@ export function mountedKeys(entityKey: string): string[] {
|
|
|
88
73
|
return [...(mounted.get(entityKey) ?? [])];
|
|
89
74
|
}
|
|
90
75
|
|
|
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
|
-
*/
|
|
76
|
+
/** `mountedKeys` says WHICH reads a command invalidates; these say how to make one happen. */
|
|
99
77
|
const refetchers = new Map<string, Set<() => void>>();
|
|
100
78
|
|
|
101
79
|
/** Register a mounted read's refetch. Returns the unregistration. */
|
|
@@ -135,11 +113,7 @@ export function pageOf(data: unknown): { total?: number; hasMore?: boolean; endC
|
|
|
135
113
|
return (data ?? {}) as { total?: number; hasMore?: boolean; endCursor?: string };
|
|
136
114
|
}
|
|
137
115
|
|
|
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
|
-
*/
|
|
116
|
+
/** Whatever failed, as the error the primitives promise. */
|
|
143
117
|
export function asFougereError(err: unknown, entityKey: string, op: string): FougereError {
|
|
144
118
|
return err instanceof FougereError
|
|
145
119
|
? err
|
package/src/express.ts
CHANGED
|
@@ -1,35 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The doors as Express middlewares — the form an Express app expects.
|
|
3
|
-
*
|
|
4
|
-
* ```ts
|
|
5
|
-
* const app = express();
|
|
6
|
-
* app.use(express.json());
|
|
7
|
-
* app.use(fougere()); // ← one line, like cors() or express.json()
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* This replaces an earlier `mountDoors(createExpressRouter(app))`, which was wrong in
|
|
11
|
-
* two ways at once. It handed the app INTO a function instead of adding something to
|
|
12
|
-
* the app, and it made the caller name Express 5's wildcard syntax (`/api/*splat`) —
|
|
13
|
-
* a detail that belongs to the framework, not to its user. Worse, it mounted all
|
|
14
|
-
* three doors together with no way to take two of them.
|
|
15
|
-
*
|
|
16
|
-
* A middleware fixes both by construction, because Express already says WHERE:
|
|
17
|
-
*
|
|
18
|
-
* ```ts
|
|
19
|
-
* app.use(fougere()); // the three doors, at the paths the client knows
|
|
20
|
-
* app.use('/admin', fougereRest()); // REST only, wherever you want it
|
|
21
|
-
* ```
|
|
22
|
-
*
|
|
23
|
-
* And `next()` is the passthrough these doors were already imitating: `serveRest`
|
|
24
|
-
* answers `{ kind: 'pass' }` for a path it does not serve, which is exactly what
|
|
25
|
-
* Express means by calling the next handler. The middleware stops inventing it.
|
|
26
|
-
*
|
|
27
|
-
* The names mirror `@fougere/app/web` on purpose — same doors, host-shaped. `/web`
|
|
28
|
-
* gives you `Request` → `Response` handlers; this gives you middlewares.
|
|
29
|
-
*
|
|
30
|
-
* Nothing here imports express: the shapes are structural, so the package keeps its
|
|
31
|
-
* dependency list and a test can hand it a plain object.
|
|
32
|
-
*/
|
|
1
|
+
/** The doors as Express middlewares — the form an Express app expects. */
|
|
33
2
|
import { readExpressBody } from '@fougere/http';
|
|
34
3
|
import { serveRest, serveRpc, rpcParseError } from './serve.js';
|
|
35
4
|
import { serveGraphQL } from './graphql.js';
|
|
@@ -59,14 +28,7 @@ interface ExpressResponse {
|
|
|
59
28
|
type Next = (err?: unknown) => void;
|
|
60
29
|
export type ExpressMiddleware = (req: any, res: any, next: Next) => void;
|
|
61
30
|
|
|
62
|
-
/**
|
|
63
|
-
* Who the caller is, from what ran before.
|
|
64
|
-
*
|
|
65
|
-
* Express has no ambient request, so an app that resolves its own session says so by
|
|
66
|
-
* putting it on the request — `req.fougereState`, or a bare `req.user`, which is what
|
|
67
|
-
* passport and most middlewares already set. Nothing is taken from the payload: the
|
|
68
|
-
* browser sits outside the topology.
|
|
69
|
-
*/
|
|
31
|
+
/** Who the caller is, from what ran before. */
|
|
70
32
|
function stateOf(req: ExpressRequest): Record<string, unknown> {
|
|
71
33
|
if (req.fougereState) return req.fougereState;
|
|
72
34
|
return req.user ? { user: req.user } : {};
|
|
@@ -95,12 +57,7 @@ function fail(res: ExpressResponse, next: Next, err: unknown): void {
|
|
|
95
57
|
next(err);
|
|
96
58
|
}
|
|
97
59
|
|
|
98
|
-
/**
|
|
99
|
-
* The call envelope, at `/_fougere/call` — the door the browser primitives use.
|
|
100
|
-
*
|
|
101
|
-
* Mounted with `app.use()`, Express strips nothing, so the path still carries the
|
|
102
|
-
* audience segment (`/_fougere/call/public`) that `surfaceOf` reads.
|
|
103
|
-
*/
|
|
60
|
+
/** The call envelope, at `/_fougere/call` — the door the browser primitives use. */
|
|
104
61
|
export function fougereCall(mountPath = '/_fougere/call'): ExpressMiddleware {
|
|
105
62
|
return (req, res, next) => {
|
|
106
63
|
const path = pathOf(req);
|
|
@@ -132,12 +89,7 @@ export function fougereSession(mountPath = '/_fougere/session'): ExpressMiddlewa
|
|
|
132
89
|
};
|
|
133
90
|
}
|
|
134
91
|
|
|
135
|
-
/**
|
|
136
|
-
* The REST projection, under `/api` by default.
|
|
137
|
-
*
|
|
138
|
-
* A path this app does not serve calls `next()` — so an app's own `/api/health` keeps
|
|
139
|
-
* answering whether it was registered before or after this middleware.
|
|
140
|
-
*/
|
|
92
|
+
/** The REST projection, under `/api` by default. */
|
|
141
93
|
export function fougereRest(mountPath = '/api'): ExpressMiddleware {
|
|
142
94
|
return (req, res, next) => {
|
|
143
95
|
const path = pathOf(req);
|