@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.
- package/README.md +1 -1
- package/dist/app.d.ts +43 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +50 -0
- package/dist/app.js.map +1 -0
- package/dist/auto-register.d.ts +22 -7
- package/dist/auto-register.d.ts.map +1 -1
- package/dist/auto-register.js +23 -45
- package/dist/auto-register.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pothos.d.ts +43 -11
- package/dist/pothos.d.ts.map +1 -1
- package/dist/pothos.js +127 -52
- package/dist/pothos.js.map +1 -1
- package/package.json +5 -4
- package/src/app.ts +73 -0
- package/src/auto-register.ts +474 -0
- package/src/index.ts +14 -0
- package/src/pothos-entry.ts +9 -0
- package/src/pothos.ts +1011 -0
- package/src/serve.ts +133 -0
|
@@ -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';
|