@fougere/adapter-graphql 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.
@@ -0,0 +1,474 @@
1
+ import { upperFirst, FieldSet, Role } from '@fougere/schema';
2
+ /**
3
+ * Auto-register GraphQL types and operations from a fougere App.
4
+ *
5
+ * Reads scanned entities + handler facades and registers
6
+ * types, inputs, queries and mutations automatically.
7
+ * Respects handler method-based contracts and surfaces config.
8
+ *
9
+ * Relations (ref/many) are auto-wired between registered types.
10
+ */
11
+ import type SchemaBuilder from '@pothos/core';
12
+ import type { Fields, SchemaView, SchemaOrCard } from '@fougere/schema';
13
+ import { Anatomy, fieldsOf, } from '@fougere/schema';
14
+ import { registerType, registerOperations } from './pothos.js';
15
+
16
+ type HandlerFacade = Record<string, Function>;
17
+
18
+ /**
19
+ * The relation a foreign key points at — `authorId → author`, `user_id → user`.
20
+ * Returns undefined when the field carries no id suffix at all: there is nothing to
21
+ * derive, and taking the scalar's own name would collide with it.
22
+ */
23
+ function relationNameFor(fieldName: string): string | undefined {
24
+ const stripped = fieldName.replace(/(_id|Id|ID)$/, '');
25
+ return stripped && stripped !== fieldName ? stripped : undefined;
26
+ }
27
+
28
+ /**
29
+ * The field a target is keyed by — what a batch read indexes its answer on. The shape
30
+ * answers the absence and this door defaults it: `id` is what a node id falls back to.
31
+ */
32
+ function primaryNameOf(fields: Fields): string {
33
+ return FieldSet.of(fields).primary ?? 'id';
34
+ }
35
+
36
+ interface Batch {
37
+ keys: Set<string>;
38
+ rows: Promise<Map<string, any>>;
39
+ }
40
+
41
+ /** The batch of ONE direction — the two sides of a relation must not share a read. */
42
+ const directionKey = (entity: string, field: string) => `${entity}#${field}`;
43
+
44
+ /**
45
+ * How many keys go into one `list` call.
46
+ *
47
+ * A page has no ceiling, and `list` is the one read the ORM refuses to split (a limit
48
+ * and an order do not recompose across statements). So the slicing happens HERE, where
49
+ * the answer is a map being assembled and slices merge for free. Below SQL Server's
50
+ * 2100 bindings, the lowest of the four engines — this side does not know the dialect,
51
+ * so it takes the floor rather than guessing.
52
+ */
53
+ const KEYS_PER_READ = 1000;
54
+
55
+ /** Read a key set in slices, merging what each answers. */
56
+ async function readInSlices<R>(
57
+ keys: string[],
58
+ read: (slice: string[]) => Promise<Map<string, R>>,
59
+ merge: (into: Map<string, R>, from: Map<string, R>) => void,
60
+ ): Promise<Map<string, R>> {
61
+ if (keys.length <= KEYS_PER_READ) return read(keys);
62
+ const all = new Map<string, R>();
63
+ for (let i = 0; i < keys.length; i += KEYS_PER_READ) {
64
+ merge(all, await read(keys.slice(i, i + KEYS_PER_READ)));
65
+ }
66
+ return all;
67
+ }
68
+
69
+ /** A key answers one row: a later slice never contradicts an earlier one. */
70
+ const keepEach = <R>(into: Map<string, R>, from: Map<string, R>) => {
71
+ for (const [key, value] of from) into.set(key, value);
72
+ };
73
+
74
+ /** A key answers a group: slices of the SAME key concatenate. */
75
+ const concatEach = (into: Map<string, any[]>, from: Map<string, any[]>) => {
76
+ for (const [key, rows] of from) {
77
+ const held = into.get(key);
78
+ if (held) held.push(...rows); else into.set(key, rows);
79
+ }
80
+ };
81
+
82
+ /**
83
+ * The keys asked for during one tick, answered by one read.
84
+ *
85
+ * graphql-js calls a field resolver once per parent, so a page of 50 rows asked for
86
+ * its relation 50 times — measured, with 5 distinct keys behind those 50 calls. The
87
+ * keys of a tick are collected and answered together, which is the shape the framework
88
+ * already imposes one level up: a presenter receives the PAGE (`egress.ts`).
89
+ *
90
+ * Scoped by the request's context object, which graphql-js hands identically to every
91
+ * resolver of one request and never shares with another — two callers must never be
92
+ * answered out of one read. When there is no context (a resolver called directly, as
93
+ * the tests do), the scope is a shared object and the tick alone bounds the batch.
94
+ */
95
+ const batches = new WeakMap<object, Map<string, Batch>>();
96
+ const NO_CONTEXT: object = {};
97
+
98
+ function loadByKey<R>(
99
+ ctx: unknown,
100
+ entityKey: string,
101
+ id: string,
102
+ read: (ids: string[]) => Promise<Map<string, R>>,
103
+ absent: () => R,
104
+ ): Promise<R> {
105
+ const scope = ctx && typeof ctx === 'object' ? (ctx as object) : NO_CONTEXT;
106
+ let open = batches.get(scope);
107
+ if (!open) { open = new Map(); batches.set(scope, open); }
108
+
109
+ let batch = open.get(entityKey);
110
+ if (!batch) {
111
+ const keys = new Set<string>();
112
+ // The next microtask: every sibling resolver of the page has run by then, so
113
+ // their keys travel together. Closed first, so the following tick opens a new one.
114
+ const rows = Promise.resolve().then(() => {
115
+ open!.delete(entityKey);
116
+ return read([...keys]);
117
+ });
118
+ batch = { keys, rows };
119
+ open.set(entityKey, batch);
120
+ }
121
+ batch.keys.add(id);
122
+ return batch.rows.then((found) => found.get(id) ?? absent());
123
+ }
124
+
125
+ // ─── Types ──────────────────────────────────────
126
+
127
+ interface OperationMeta {
128
+ input?: SchemaView;
129
+ output?: SchemaView;
130
+ kind: 'query' | 'command';
131
+ binding?: {
132
+ name: string;
133
+ optional: boolean;
134
+ source:
135
+ | { kind: 'collector' | 'context' | 'fact' }
136
+ | { kind: 'param'; name: string }
137
+ | { kind: 'body' | 'query' };
138
+ }[];
139
+ signature?: {
140
+ name: string;
141
+ params: { name: string; type: { raw: string; name: string; array?: boolean; nullable?: boolean; undefined?: boolean; generics?: any[] }; optional?: boolean }[];
142
+ returnType?: { raw: string; name: string; array?: boolean; nullable?: boolean; undefined?: boolean; generics?: any[] };
143
+ };
144
+ }
145
+
146
+ interface EntityEntry {
147
+ name: string;
148
+ /** A live class in-process, a card from a frond whose class never crossed. */
149
+ entityClass: SchemaOrCard;
150
+ exposed?: boolean;
151
+ }
152
+
153
+ interface HandlerEntry {
154
+ /** The name the door answers to — `PostHandler` → `post`. NOT an entity name: a handler may carry none. */
155
+ address: string;
156
+ operations: Map<string, OperationMeta>;
157
+ surface?: string;
158
+ outputOverride?: SchemaView;
159
+ /** `name` is the class's own — it names the handler when two ops claim one root field. */
160
+ ctor?: { __output?: SchemaView; name?: string };
161
+ }
162
+
163
+ interface PresenterFieldMeta {
164
+ name: string;
165
+ returnType?: string;
166
+ /** The field emits a list per row, the page level of its return type removed. */
167
+ list?: boolean;
168
+ nullable?: boolean;
169
+ }
170
+
171
+ interface PresenterEntry {
172
+ entityName: string;
173
+ fields: string[];
174
+ fieldMeta: PresenterFieldMeta[];
175
+ /** The view each computed field emits, when the presenter declares one. */
176
+ views?: Record<string, unknown>;
177
+ }
178
+
179
+ interface FrondLike {
180
+ name: string;
181
+ entities: EntityEntry[];
182
+ handlers: HandlerEntry[];
183
+ presenters: PresenterEntry[];
184
+ surfaces?: Record<string, string[]>;
185
+ operationsOverrides?: Record<string, {
186
+ graphql?: string;
187
+ }>;
188
+ }
189
+
190
+ interface AppLike {
191
+ fronds: FrondLike[];
192
+ /** The façade an entity exposes to one audience — `undefined` when none. */
193
+ facadeFor(entity: string, surface?: string): Record<string, Function> | undefined;
194
+ /** Canonical operation table produced by core. */
195
+ operationsFor(entity: string, surface?: string): Map<string, OperationMeta> | undefined;
196
+ /**
197
+ * The presenter of an entity — `undefined` when none. Asked for rather than
198
+ * resolved by a key spelled here: this adapter used to build `${Name}Presenter`
199
+ * itself, and a convention respelled in two places drifts silently on the day it
200
+ * changes, exactly as `facadeFor` exists to prevent for doors.
201
+ */
202
+ presenterFor(entity: string): unknown | undefined;
203
+ }
204
+
205
+ // ─── Helpers ────────────────────────────────────
206
+
207
+ /**
208
+ * The key an entity is filed under — case-folded, because the same entity is spelled
209
+ * differently depending on where its name came from: the scan yields the registration name
210
+ * (`authorUser`), while a card's relation target is fully lowercased by `describe`
211
+ * (`authoruser`). Folding both is what lets one registry serve both sources.
212
+ */
213
+ function registryKey(entityName: string): string {
214
+ return entityName.toLowerCase();
215
+ }
216
+
217
+ /**
218
+ * The key a relation points at. A live entity class answers with its class name; a target
219
+ * rebuilt from a lone card is a `{ name }` stand-in and answers with the name `describe`
220
+ * wrote. Both are names, which is the whole reason this resolves by name.
221
+ */
222
+ function targetKey(target: unknown): string {
223
+ return registryKey(String((target as { name?: string } | undefined)?.name ?? ''));
224
+ }
225
+
226
+ // ─── Public API ─────────────────────────────────
227
+
228
+ export interface RegisterAllOptions {
229
+ /** Override which entities to expose. Default: all scanned entities with a handler. */
230
+ filter?: (entity: EntityEntry, frondName: string) => boolean;
231
+ /** Surface name for filtering (e.g. 'graphql', 'rest'). Uses frond.config.ts surfaces if set. */
232
+ surface?: string;
233
+ }
234
+
235
+ /**
236
+ * Auto-register GraphQL types and operations for all entities
237
+ * in the app that have a matching handler facade.
238
+ *
239
+ * Operations are driven by parsed handler signatures (from the scanner).
240
+ * Relations (ref/many) are auto-wired between registered entity types.
241
+ */
242
+ /**
243
+ * The GraphQL type of a declared presenter view, built once per view class.
244
+ *
245
+ * Named after the field that emits it (`OrderItems`, `OrderUser`) rather than after the view
246
+ * class, so two fields sharing one view still land on the same type and a view used twice is
247
+ * registered once — Pothos refuses a duplicate type name and would take the schema down.
248
+ */
249
+ const viewTypes = new WeakMap<object, any>();
250
+ function viewTypeOf(
251
+ builder: InstanceType<typeof SchemaBuilder>,
252
+ view: any,
253
+ name: string,
254
+ ): any {
255
+ const known = viewTypes.get(view);
256
+ if (known) return known;
257
+ const type = registerType(builder, { name, entity: view });
258
+ viewTypes.set(view, type);
259
+ return type;
260
+ }
261
+
262
+ export function registerAll(
263
+ builder: InstanceType<typeof SchemaBuilder>,
264
+ app: AppLike,
265
+ options?: RegisterAllOptions,
266
+ ): void {
267
+ // Collect registered types across all fronds for relation wiring, keyed by entity NAME.
268
+ //
269
+ // The name is the identity everywhere else in the system — `facadeFor(entity)`,
270
+ // `ormFor(entity)`, `schemaFor(entity)` all take one, and the table, the GraphQL type
271
+ // and the DI match are all derived from it. This registry keyed by class OBJECT was the
272
+ // lone dissent, and it cost a silent failure: a relation target that is not the very
273
+ // object registered (an entity rebuilt from a card, whose `to()` leaves a `{ name }`
274
+ // stand-in) missed the lookup, hit `if (!targetEntry) continue`, and the relation
275
+ // vanished from the schema without a word. `schema-sql` already resolved by name.
276
+ const typeRegistry = new Map<
277
+ string,
278
+ {
279
+ name: string;
280
+ type: any;
281
+ facade: HandlerFacade;
282
+ presenterFields: Set<string>;
283
+ /** The entity's OWN fields — pass 2 wires relations from these, not from an output view. */
284
+ fields: Fields;
285
+ }
286
+ >();
287
+
288
+ // ── Pass 1: register types + operations ────────
289
+
290
+ for (const frond of app.fronds) {
291
+ const handlerMap = new Map(frond.handlers.filter((h) => !h.surface).map((h) => [h.address, h]));
292
+ const presenterMap = new Map((frond.presenters ?? []).map((p) => [p.entityName, p]));
293
+
294
+ const surfaceName = options?.surface;
295
+
296
+ for (const entity of frond.entities) {
297
+ // Membership is core's answer, not ours — one rule, read here (see App.facadeFor).
298
+ const facade = app.facadeFor(entity.name, surfaceName) as HandlerFacade | undefined;
299
+ if (!facade) continue;
300
+
301
+ if (options?.filter && !options.filter(entity, frond.name)) continue;
302
+ if (!surfaceName && entity.exposed === false) continue;
303
+
304
+ const handler = (surfaceName
305
+ ? frond.handlers.find((h) => h.address === entity.name && h.surface === surfaceName)
306
+ : undefined) ?? handlerMap.get(entity.name);
307
+ const typeName = upperFirst(entity.name);
308
+
309
+ const presenterMeta = presenterMap.get(entity.name);
310
+ let presenter: Record<string, Function> | undefined;
311
+ if (presenterMeta) {
312
+ presenter = app.presenterFor(entity.name) as Record<string, Function> | undefined;
313
+ }
314
+
315
+ // Use handler's output schema if declared, otherwise entity
316
+ const outputSchema = handler?.outputOverride
317
+ ?? (handler?.ctor as any)?.__output
318
+ ?? entity.entityClass;
319
+
320
+ const type = registerType(builder, {
321
+ name: typeName,
322
+ entity: outputSchema as any,
323
+ presenter: presenter as any,
324
+ presenterFields: presenterMeta?.fields,
325
+ presenterFieldMeta: presenterMeta?.fieldMeta,
326
+ presenterViews: presenterMeta?.views as any,
327
+ viewType: (view, fieldName) => viewTypeOf(builder, view, `${typeName}${upperFirst(fieldName)}`),
328
+ });
329
+
330
+ // Track for relation wiring. The presenter's computed field names travel too: pass 2
331
+ // must not derive a relation over a name the author wrote.
332
+ typeRegistry.set(registryKey(entity.name), {
333
+ name: typeName, type, facade,
334
+ presenterFields: new Set(presenterMeta?.fields ?? []),
335
+ fields: fieldsOf(entity.entityClass),
336
+ });
337
+
338
+ const opOverrides = frond.operationsOverrides;
339
+
340
+ const operations = app.operationsFor(entity.name, surfaceName);
341
+ if (!operations) {
342
+ throw new Error(
343
+ `GraphQL cannot project '${entity.name}' without its EffectiveOperation table.`,
344
+ );
345
+ }
346
+
347
+ registerOperations(builder, {
348
+ name: typeName,
349
+ type,
350
+ facade,
351
+ operations,
352
+ operationsOverrides: opOverrides,
353
+ // Named so a root-field clash can say WHICH two handlers, in which fronds.
354
+ origin: `${frond.name}/${handler?.ctor?.name ?? `${typeName}Handler`}`,
355
+ // What an op declares as its return becomes its GraphQL type — unless that IS
356
+ // the entity's own schema, which already has one.
357
+ viewType: (view, opName) =>
358
+ view === outputSchema || view === entity.entityClass
359
+ ? type
360
+ : viewTypeOf(builder, view, `${typeName}${upperFirst(opName)}`),
361
+ });
362
+ }
363
+ }
364
+
365
+ // ── Pass 2: auto-wire relations (ref → N:1, many → 1:N) ──
366
+
367
+ for (const [entityName, { type, fields, presenterFields }] of typeRegistry) {
368
+ const relationFields: Record<string, (t: any) => any> = {};
369
+
370
+ for (const [fieldName, field] of Object.entries(fields)) {
371
+ if (Role.of(field).isReference) {
372
+ const target = Role.of(field).target;
373
+ if (!target) continue;
374
+ const targetEntry = typeRegistry.get(targetKey(target));
375
+ if (!targetEntry) continue;
376
+
377
+ // authorId → author, user_id → user. Both spellings, because a foreign key
378
+ // is named by its author and `/Id$/` alone left `user_id` untouched — the
379
+ // relation then took the scalar's own name and Pothos refused the duplicate,
380
+ // taking the WHOLE schema down with it.
381
+ const relationName = relationNameFor(fieldName);
382
+ // Nothing to strip, or the name is already taken by a field of the entity or by
383
+ // a presenter's computed field: the author named it, the author wins. Deriving
384
+ // over it would either crash the build or shadow what they wrote.
385
+ if (!relationName || relationName in fields || presenterFields.has(relationName)) continue;
386
+ const nullable = Anatomy.isNullable(field.shape);
387
+
388
+ const targetKeyName = primaryNameOf(targetEntry.fields);
389
+ const targetList = targetEntry.facade.list;
390
+ relationFields[relationName] = (t: any) => t.field({
391
+ type: targetEntry.type,
392
+ nullable,
393
+ resolve: (parent: any, _args: unknown, ctx: unknown) => {
394
+ const fk = parent[fieldName];
395
+ if (fk == null) return null;
396
+ // A door that serves no list — a handler narrowed to `findById` — keeps the
397
+ // row-at-a-time path rather than losing the relation entirely.
398
+ if (typeof targetList !== 'function') {
399
+ return targetEntry.facade.findById({ params: { id: fk }, query: {}, body: undefined, state: {} });
400
+ }
401
+ return loadByKey(ctx, directionKey(targetKey(target), targetKeyName), String(fk), (ids) =>
402
+ // The door the `many` dual already uses, with a SET where it names one
403
+ // value. Nothing new is published: a criterion learned to name several.
404
+ readInSlices(ids, async (slice) => {
405
+ const result = await targetList.call(targetEntry.facade, {
406
+ params: {}, query: { where: { [targetKeyName]: slice } }, body: undefined, state: {},
407
+ }) as any;
408
+ const rows = Array.isArray(result) ? result : result?.items ?? result?.data ?? [];
409
+ return new Map<string, any>(rows.map((row: any) => [String(row?.[targetKeyName]), row]));
410
+ }, keepEach),
411
+ () => null);
412
+ },
413
+ });
414
+ }
415
+
416
+ if (Role.of(field).isCollection) {
417
+ const target = Role.of(field).target;
418
+ if (!target) continue;
419
+ const targetEntry = typeRegistry.get(targetKey(target));
420
+ if (!targetEntry) continue;
421
+
422
+ // Trouver la FK inverse sur l'entité cible (la relation « one » qui pointe ici).
423
+ // Read off the registry, not off the target object: a target rebuilt from a card is
424
+ // a `{ name }` stand-in with no fields to walk, and the registry already holds them.
425
+ const reverseFk = Object.entries(targetEntry.fields).find(
426
+ ([, f]) => Role.of(f).isReference
427
+ && targetKey(Role.of(f).target) === entityName,
428
+ );
429
+ if (!reverseFk) continue;
430
+
431
+ const [reverseFkName] = reverseFk;
432
+
433
+ relationFields[fieldName] = (t: any) => t.field({
434
+ type: [targetEntry.type],
435
+ resolve: (parent: any, _args: unknown, ctx: unknown) => {
436
+ const id = parent.id;
437
+ if (id == null) return [];
438
+ // The same batch as its `one` dual, one query for the whole page — the two
439
+ // directions differ only in what a key answers: one row there, a group here.
440
+ //
441
+ // `where` — un critère se nomme. Passé à la racine de la query, il retombait
442
+ // dans les options de `list()`, qui ignore ce qu'elle ne connaît pas : la
443
+ // relation rendait alors TOUTE la table cible, sans un mot.
444
+ return loadByKey(ctx, directionKey(targetKey(target), reverseFkName), String(id), (ids) =>
445
+ readInSlices(ids, async (slice) => {
446
+ const result = await targetEntry.facade.list({
447
+ params: {}, query: { where: { [reverseFkName]: slice } }, body: undefined, state: {},
448
+ }) as any;
449
+ const rows = Array.isArray(result) ? result : result?.items ?? result?.data ?? [];
450
+ const grouped = new Map<string, any[]>();
451
+ for (const row of rows) {
452
+ const key = String(row?.[reverseFkName]);
453
+ const held = grouped.get(key);
454
+ if (held) held.push(row); else grouped.set(key, [row]);
455
+ }
456
+ return grouped;
457
+ }, concatEach),
458
+ () => []);
459
+ },
460
+ });
461
+ }
462
+ }
463
+
464
+ if (Object.keys(relationFields).length > 0) {
465
+ (builder as any).objectFields(type, (t: any) => {
466
+ const result: Record<string, any> = {};
467
+ for (const [name, factory] of Object.entries(relationFields)) {
468
+ result[name] = factory(t);
469
+ }
470
+ return result;
471
+ });
472
+ }
473
+ }
474
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The GraphQL surface, in two calls: derive the schema, then mount it.
3
+ *
4
+ * The Pothos primitives `registerAll` stands on live one import away, under
5
+ * `@fougere/adapter-graphql/pothos`. They are a complement — a field the projection cannot
6
+ * derive — never a second way to build what it already gives. Offering them at the same
7
+ * rank made that hierarchy invisible: an agent looking for "how do I declare a type" found
8
+ * three doors and rebuilt two hundred lines by hand (measured 2026-08-02).
9
+ */
10
+ export { registerAll } from './auto-register.js';
11
+ export type { RegisterAllOptions } from './auto-register.js';
12
+ export { registerGraphQL } from './serve.js';
13
+ export type { GraphQLServeOptions } from './serve.js';
14
+ export { schemaOf, executeOn, type AppQuery } from './app.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The Pothos primitives — for what `registerAll` cannot derive, never to replace it.
3
+ *
4
+ * Reach for these to add a field the projection has no way to know about. Rebuilding
5
+ * types, inputs and operations with them reimplements `registerAll` by hand and drops
6
+ * what it wires for free: relations, and a presenter's computed fields.
7
+ */
8
+ export { registerType, registerInput, registerOperations, registerObjectType } from './pothos.js';
9
+ export type { TypeConfig, InputConfig, OperationsConfig, ObjectFieldDef } from './pothos.js';