@fougere/adapter-graphql 0.2.0-alpha.2
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/LICENSE +21 -0
- package/README.md +15 -0
- package/dist/auto-register.d.ts +101 -0
- package/dist/auto-register.d.ts.map +1 -0
- package/dist/auto-register.js +339 -0
- package/dist/auto-register.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/pothos-entry.d.ts +10 -0
- package/dist/pothos-entry.d.ts.map +1 -0
- package/dist/pothos-entry.js +9 -0
- package/dist/pothos-entry.js.map +1 -0
- package/dist/pothos.d.ts +177 -0
- package/dist/pothos.d.ts.map +1 -0
- package/dist/pothos.js +696 -0
- package/dist/pothos.js.map +1 -0
- package/dist/serve.d.ts +29 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +101 -0
- package/dist/serve.js.map +1 -0
- package/package.json +56 -0
package/dist/pothos.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { Anatomy, Schema } from '@fougere/schema';
|
|
2
|
+
import { boundaryOf, fieldsOf, inputFields, resolveBoundary, sourceNameOf } from '@fougere/schema';
|
|
3
|
+
// Mirrors core's resolveIsReadOp (this package stays core-free, same as OperationMeta
|
|
4
|
+
// above). Scheduled to die with the handler-kind plan (docs/notes/handler-kind.md).
|
|
5
|
+
const READ_PREFIXES = ['list', 'find', 'get', 'search', 'count', 'exists', 'stats'];
|
|
6
|
+
function resolveIsReadOp(name, overrides) {
|
|
7
|
+
const kind = overrides?.[name]?.kind;
|
|
8
|
+
if (kind)
|
|
9
|
+
return kind === 'query';
|
|
10
|
+
return READ_PREFIXES.some((p) => name.startsWith(p));
|
|
11
|
+
}
|
|
12
|
+
function capitalize(s) {
|
|
13
|
+
return s[0].toUpperCase() + s.slice(1);
|
|
14
|
+
}
|
|
15
|
+
function pluralize(name) {
|
|
16
|
+
return name.endsWith('y') ? name.slice(0, -1) + 'ies' : name + 's';
|
|
17
|
+
}
|
|
18
|
+
const PRIMITIVES = {
|
|
19
|
+
string: (t, r) => t.arg.string({ required: r }),
|
|
20
|
+
number: (t, r) => t.arg.int({ required: r }),
|
|
21
|
+
boolean: (t, r) => t.arg.boolean({ required: r }),
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Parameter types no GraphQL argument stands for. `ListOptions` is NOT one of
|
|
25
|
+
* them: it has its own branch below that turns it into the six pagination
|
|
26
|
+
* arguments. Listing it here classified it first — the skip branch runs before
|
|
27
|
+
* the pagination one — so `kind: 'pagination'` was never assigned and every
|
|
28
|
+
* `list(options?: ListOptions)` op reached GraphQL with no arguments at all.
|
|
29
|
+
*/
|
|
30
|
+
const SKIP_TYPES = new Set(['InvocationContext']);
|
|
31
|
+
// ─── Helpers ───────────────────────────────────────
|
|
32
|
+
function fieldToGraphQL(t, field, fieldName, enumFor) {
|
|
33
|
+
// Dispatch on the BASE type via anatomy — `shape.type` may be the nullable
|
|
34
|
+
// `[T,'null']` union, a direct comparison would fail silently on it.
|
|
35
|
+
const { base: shape, nullable } = Anatomy.of(field.shape);
|
|
36
|
+
// Before the type switch: a bounded set is its own GraphQL type whatever its base type
|
|
37
|
+
// carries. `oneOf` fed the form's `select` and the DDL's `CHECK` from the day it was
|
|
38
|
+
// written; here it fell through to `String`, so a schema explorer showed nothing of the
|
|
39
|
+
// set and a generated client could not narrow the union.
|
|
40
|
+
// Only the TYPE changes here. `nullable` is passed exactly where the `String` branch below
|
|
41
|
+
// passes it and omitted where that branch calls `exposeString` — spelling `nullable: false`
|
|
42
|
+
// instead would emit `PostStatus!` next to a `title: String` on the same type, so a bounded
|
|
43
|
+
// set would carry a stricter contract than a plain field for no reason of its own.
|
|
44
|
+
const values = enumFor && enumValuesOf(shape);
|
|
45
|
+
if (values) {
|
|
46
|
+
const ref = enumFor(values);
|
|
47
|
+
if (ref) {
|
|
48
|
+
const resolve = (parent) => parent[fieldName] ?? null;
|
|
49
|
+
return nullable ? t.field({ type: ref, nullable: true, resolve }) : t.field({ type: ref, resolve });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
switch (shape?.type) {
|
|
53
|
+
case 'integer':
|
|
54
|
+
return nullable ? t.int({ nullable: true, resolve: (parent) => parent[fieldName] ?? null })
|
|
55
|
+
: t.exposeInt(fieldName);
|
|
56
|
+
case 'number':
|
|
57
|
+
return nullable ? t.float({ nullable: true, resolve: (parent) => parent[fieldName] ?? null })
|
|
58
|
+
: t.exposeFloat(fieldName);
|
|
59
|
+
case 'boolean':
|
|
60
|
+
return nullable ? t.boolean({ nullable: true, resolve: (parent) => parent[fieldName] ?? null })
|
|
61
|
+
: t.exposeBoolean(fieldName);
|
|
62
|
+
case 'object':
|
|
63
|
+
// JSON → String sérialisé
|
|
64
|
+
return t.string({
|
|
65
|
+
nullable,
|
|
66
|
+
resolve: (parent) => {
|
|
67
|
+
const val = parent[fieldName];
|
|
68
|
+
return val != null ? JSON.stringify(val) : null;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
case 'array': {
|
|
72
|
+
// value list (`list(text())`) → liste GraphQL du scalaire des items;
|
|
73
|
+
// items objets → liste de Strings JSON (même règle que 'object')
|
|
74
|
+
const items = Anatomy.of(shape.items).base;
|
|
75
|
+
const resolve = (parent) => parent[fieldName] ?? (nullable ? null : []);
|
|
76
|
+
switch (items?.type) {
|
|
77
|
+
case 'integer': return t.intList({ nullable, resolve });
|
|
78
|
+
case 'number': return t.floatList({ nullable, resolve });
|
|
79
|
+
case 'boolean': return t.booleanList({ nullable, resolve });
|
|
80
|
+
case 'object':
|
|
81
|
+
return t.stringList({
|
|
82
|
+
nullable,
|
|
83
|
+
resolve: (parent) => {
|
|
84
|
+
const val = parent[fieldName];
|
|
85
|
+
return val != null ? val.map((v) => JSON.stringify(v)) : (nullable ? null : []);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
default: return t.stringList({ nullable, resolve });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
case 'string':
|
|
92
|
+
// date-time → String GraphQL, encoded on egress via the field's boundary (Date → ISO).
|
|
93
|
+
// Exposing the raw Date would let Pothos coerce it to a non-ISO `String(date)`.
|
|
94
|
+
if (shape.format === 'date-time') {
|
|
95
|
+
return t.string({
|
|
96
|
+
nullable,
|
|
97
|
+
resolve: (parent) => {
|
|
98
|
+
const val = parent[fieldName];
|
|
99
|
+
return val != null ? resolveBoundary(field).encode(val) : null;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// string (id, texte, enum, ref) → String GraphQL
|
|
104
|
+
return nullable ? t.string({ nullable: true, resolve: (parent) => parent[fieldName] ?? null })
|
|
105
|
+
: t.exposeString(fieldName);
|
|
106
|
+
default:
|
|
107
|
+
// pas de shape (relation many) → String GraphQL
|
|
108
|
+
return nullable ? t.string({ nullable: true, resolve: (parent) => parent[fieldName] ?? null })
|
|
109
|
+
: t.exposeString(fieldName);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A JSON-Schema object shape, turned into a GraphQL input type.
|
|
114
|
+
*
|
|
115
|
+
* `list(json(OrderLine))` inlines the line's shape as nested `properties` — good enough for
|
|
116
|
+
* the judge, invisible to GraphQL until now: the `array` case fell through to `stringList`,
|
|
117
|
+
* so `items` reached the schema as `[String!]!` and a client had to hand-encode every line
|
|
118
|
+
* as JSON. The mutation was unusable (measured 2026-08-02).
|
|
119
|
+
*/
|
|
120
|
+
function nestedInputType(builder, shape, name) {
|
|
121
|
+
let perBuilder = nestedInputs.get(builder);
|
|
122
|
+
if (!perBuilder)
|
|
123
|
+
nestedInputs.set(builder, (perBuilder = new Map()));
|
|
124
|
+
const known = perBuilder.get(name);
|
|
125
|
+
if (known)
|
|
126
|
+
return known;
|
|
127
|
+
const properties = (shape.properties ?? {});
|
|
128
|
+
const required = new Set((shape.required ?? []));
|
|
129
|
+
const type = builder.inputType(name, {
|
|
130
|
+
fields: (t) => {
|
|
131
|
+
const out = {};
|
|
132
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
133
|
+
const isRequired = required.has(key);
|
|
134
|
+
switch (Anatomy.of(prop).base?.type) {
|
|
135
|
+
case 'integer':
|
|
136
|
+
out[key] = t.int({ required: isRequired });
|
|
137
|
+
break;
|
|
138
|
+
case 'number':
|
|
139
|
+
out[key] = t.float({ required: isRequired });
|
|
140
|
+
break;
|
|
141
|
+
case 'boolean':
|
|
142
|
+
out[key] = t.boolean({ required: isRequired });
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
out[key] = t.string({ required: isRequired });
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
perBuilder.set(name, type);
|
|
153
|
+
return type;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* One input type per name, PER BUILDER — Pothos refuses a duplicate name, and a ref built on
|
|
157
|
+
* one builder is unknown to the next: a global cache handed a stale ref to the second schema
|
|
158
|
+
* ("InputObjectRef has not been implemented"). The builder owns its types, so it owns the map.
|
|
159
|
+
*/
|
|
160
|
+
const nestedInputs = new WeakMap();
|
|
161
|
+
/** Same rule as {@link nestedInputs}, for the enum types — with the values, see below. */
|
|
162
|
+
const enumTypes = new WeakMap();
|
|
163
|
+
/**
|
|
164
|
+
* A GraphQL enum value is an IDENTIFIER, not a string: `in-progress` or `à valider` cannot
|
|
165
|
+
* be spelled in a query. `oneOf` is a JSON Schema keyword and accepts any string, so a set
|
|
166
|
+
* that will not fit stays a `String` — the judge still refuses what is not in it.
|
|
167
|
+
*/
|
|
168
|
+
const GRAPHQL_NAME = /^[_A-Za-z][_0-9A-Za-z]*$/;
|
|
169
|
+
function enumValuesOf(shape) {
|
|
170
|
+
const values = shape?.enum;
|
|
171
|
+
if (!Array.isArray(values) || values.length === 0)
|
|
172
|
+
return undefined;
|
|
173
|
+
if (!values.every((v) => typeof v === 'string' && GRAPHQL_NAME.test(v)))
|
|
174
|
+
return undefined;
|
|
175
|
+
return values;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The enum type for one field's value set — one per NAME per builder, so `Post.status` and
|
|
179
|
+
* `CreatePostInput.status` are the same `PostStatus` and a value read can be written back.
|
|
180
|
+
*
|
|
181
|
+
* A second field claiming the name with a different set falls back to `String` rather than
|
|
182
|
+
* being served the first one: two different sets under one name would let a client send a
|
|
183
|
+
* value this field never declared, which is the opposite of what the enum is for.
|
|
184
|
+
*/
|
|
185
|
+
function enumTypeFor(builder, name, values) {
|
|
186
|
+
let perBuilder = enumTypes.get(builder);
|
|
187
|
+
if (!perBuilder)
|
|
188
|
+
enumTypes.set(builder, (perBuilder = new Map()));
|
|
189
|
+
const known = perBuilder.get(name);
|
|
190
|
+
if (known) {
|
|
191
|
+
const same = known.values.length === values.length && known.values.every((v, i) => v === values[i]);
|
|
192
|
+
return same ? known.ref : undefined;
|
|
193
|
+
}
|
|
194
|
+
const ref = builder.enumType(name, { values });
|
|
195
|
+
perBuilder.set(name, { ref, values });
|
|
196
|
+
return ref;
|
|
197
|
+
}
|
|
198
|
+
/** `Post` + `status` → `PostStatus`. Undefined when the schema has no name to build on. */
|
|
199
|
+
function enumNameFor(owner, fieldName) {
|
|
200
|
+
return owner ? `${owner}${capitalize(fieldName)}` : undefined;
|
|
201
|
+
}
|
|
202
|
+
function fieldToInput(t, field, patch, nested, enumFor) {
|
|
203
|
+
// Required = the presence axis, projected onto GraphQL's single knob: the
|
|
204
|
+
// caller must supply it (no `lifecycle.create` rule answers absence), null is
|
|
205
|
+
// not legal, and the view is not in patch mode (a patch omits freely).
|
|
206
|
+
const { base: shape, nullable } = Anatomy.of(field.shape);
|
|
207
|
+
const required = !patch && !nullable && field.lifecycle?.create === undefined;
|
|
208
|
+
// The dual of the output side, and it must be the SAME type: an input left as `String`
|
|
209
|
+
// would refuse nothing the enum refuses, and a client could not hand back the value a
|
|
210
|
+
// query just gave it.
|
|
211
|
+
const values = enumFor && enumValuesOf(shape);
|
|
212
|
+
if (values) {
|
|
213
|
+
const ref = enumFor(values);
|
|
214
|
+
if (ref)
|
|
215
|
+
return t.field({ type: ref, required });
|
|
216
|
+
}
|
|
217
|
+
switch (shape?.type) {
|
|
218
|
+
case 'integer':
|
|
219
|
+
return t.int({ required });
|
|
220
|
+
case 'number':
|
|
221
|
+
return t.float({ required });
|
|
222
|
+
case 'boolean':
|
|
223
|
+
return t.boolean({ required });
|
|
224
|
+
case 'array': {
|
|
225
|
+
const items = Anatomy.of(shape.items).base;
|
|
226
|
+
switch (items?.type) {
|
|
227
|
+
case 'integer': return t.intList({ required });
|
|
228
|
+
case 'number': return t.floatList({ required });
|
|
229
|
+
case 'boolean': return t.booleanList({ required });
|
|
230
|
+
case 'object': {
|
|
231
|
+
// A nested shape IS a type — serializing it would make the caller encode JSON by hand.
|
|
232
|
+
const built = nested?.(items, 'Item');
|
|
233
|
+
return built ? t.field({ type: [built], required }) : t.stringList({ required });
|
|
234
|
+
}
|
|
235
|
+
default: return t.stringList({ required });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
case 'string':
|
|
239
|
+
case 'object':
|
|
240
|
+
default:
|
|
241
|
+
return t.string({ required });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const SCALARS = {
|
|
245
|
+
string: (t, o) => o.nullable ? t.string(o) : t.string(o),
|
|
246
|
+
int: (t, o) => o.nullable ? t.int(o) : t.int(o),
|
|
247
|
+
float: (t, o) => o.nullable ? t.float(o) : t.float(o),
|
|
248
|
+
boolean: (t, o) => o.nullable ? t.boolean(o) : t.boolean(o),
|
|
249
|
+
};
|
|
250
|
+
function isScalar(type) {
|
|
251
|
+
return typeof type === 'string' && type in SCALARS;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Register a GraphQL object type from a declarative field map.
|
|
255
|
+
*
|
|
256
|
+
* Each field auto-resolves from `parent[key]` unless a custom `resolve` is provided.
|
|
257
|
+
* Works for wrapper types, result types, or any structural type.
|
|
258
|
+
*
|
|
259
|
+
* ```ts
|
|
260
|
+
* const PostList = registerObjectType(builder, 'PostList', {
|
|
261
|
+
* items: { type: [PostType] },
|
|
262
|
+
* total: { type: 'int', nullable: true, resolve: async (p) => lazyCount(p) },
|
|
263
|
+
* hasMore: { type: 'boolean', nullable: true },
|
|
264
|
+
* endCursor: { type: 'string', nullable: true },
|
|
265
|
+
* });
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
export function registerObjectType(builder, name, fieldDefs) {
|
|
269
|
+
return builder.objectRef(name).implement({
|
|
270
|
+
fields: (t) => {
|
|
271
|
+
const result = {};
|
|
272
|
+
for (const [key, def] of Object.entries(fieldDefs)) {
|
|
273
|
+
const nullable = def.nullable ?? false;
|
|
274
|
+
// Custom resolve → field resolver (lazy/computed fields)
|
|
275
|
+
if (def.resolve) {
|
|
276
|
+
if (Array.isArray(def.type)) {
|
|
277
|
+
result[key] = t.field({ type: def.type, nullable, resolve: def.resolve });
|
|
278
|
+
}
|
|
279
|
+
else if (isScalar(def.type)) {
|
|
280
|
+
result[key] = SCALARS[def.type](t, { nullable, resolve: def.resolve });
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
result[key] = t.field({ type: def.type, nullable, resolve: def.resolve });
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// No custom resolve → expose directly from parent (no resolveField overhead)
|
|
288
|
+
if (isScalar(def.type)) {
|
|
289
|
+
const expose = {
|
|
290
|
+
string: () => t.exposeString(key, { nullable }),
|
|
291
|
+
int: () => t.exposeInt(key, { nullable }),
|
|
292
|
+
float: () => t.exposeFloat(key, { nullable }),
|
|
293
|
+
boolean: () => t.exposeBoolean(key, { nullable }),
|
|
294
|
+
};
|
|
295
|
+
result[key] = expose[def.type]();
|
|
296
|
+
}
|
|
297
|
+
else if (Array.isArray(def.type)) {
|
|
298
|
+
result[key] = t.field({ type: def.type, nullable, resolve: (parent) => parent[key] });
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
result[key] = t.field({ type: def.type, nullable, resolve: (parent) => parent[key] });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
// ─── Public API ────────────────────────────────────
|
|
309
|
+
/**
|
|
310
|
+
* Enregistre un type GraphQL (lecture) depuis une entité fougere.
|
|
311
|
+
*
|
|
312
|
+
* ```ts
|
|
313
|
+
* const ProductType = registerType(builder, {
|
|
314
|
+
* name: 'Product',
|
|
315
|
+
* entity: Product,
|
|
316
|
+
* exclude: ['categoryId'],
|
|
317
|
+
* relations: {
|
|
318
|
+
* category: {
|
|
319
|
+
* type: CategoryType,
|
|
320
|
+
* resolve: (parent) => db.select()...
|
|
321
|
+
* },
|
|
322
|
+
* },
|
|
323
|
+
* });
|
|
324
|
+
* ```
|
|
325
|
+
*/
|
|
326
|
+
export function registerType(builder, config) {
|
|
327
|
+
// A live class or a card — an adapter needs the fields, never the constructor.
|
|
328
|
+
const fields = fieldsOf(config.entity);
|
|
329
|
+
const exclude = new Set(config.exclude ?? []);
|
|
330
|
+
// Who owns the enum names: the schema a view came from, so `PostCard.status` and
|
|
331
|
+
// `CreatePostInput.status` land on the one `PostStatus`. A card that travelled carries no
|
|
332
|
+
// class name — the GraphQL type name is then the best owner available.
|
|
333
|
+
const enumOwner = sourceNameOf(config.entity) ?? config.name;
|
|
334
|
+
return builder.objectRef(config.name).implement({
|
|
335
|
+
fields: (t) => {
|
|
336
|
+
const result = {};
|
|
337
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
338
|
+
if (exclude.has(fieldName))
|
|
339
|
+
continue;
|
|
340
|
+
// Skip 'many' fields — handled by relations
|
|
341
|
+
if (field.role?.relation?.kind === 'many')
|
|
342
|
+
continue;
|
|
343
|
+
// Skip fields that have a relation override
|
|
344
|
+
if (config.relations?.[fieldName])
|
|
345
|
+
continue;
|
|
346
|
+
// Write-only (boundary out: 'closed', e.g. password): never emitted
|
|
347
|
+
if (boundaryOf(field).out === 'closed')
|
|
348
|
+
continue;
|
|
349
|
+
result[fieldName] = fieldToGraphQL(t, field, fieldName, (values) => {
|
|
350
|
+
const name = enumNameFor(enumOwner, fieldName);
|
|
351
|
+
return name ? enumTypeFor(builder, name, values) : undefined;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
// Add relations
|
|
355
|
+
if (config.relations) {
|
|
356
|
+
for (const [name, rel] of Object.entries(config.relations)) {
|
|
357
|
+
if (rel.list) {
|
|
358
|
+
result[name] = t.field({
|
|
359
|
+
type: [rel.type],
|
|
360
|
+
resolve: rel.resolve,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
result[name] = t.field({
|
|
365
|
+
type: rel.type,
|
|
366
|
+
resolve: rel.resolve,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Add presenter computed fields (resolveField — called only when requested)
|
|
372
|
+
if (config.presenter) {
|
|
373
|
+
const allowed = config.presenterFields
|
|
374
|
+
? new Set(config.presenterFields)
|
|
375
|
+
: null;
|
|
376
|
+
const metaMap = new Map((config.presenterFieldMeta ?? []).map((m) => [m.name, m]));
|
|
377
|
+
// The names the scan found, looked up on the instance — never `Object.entries`.
|
|
378
|
+
// A presenter is a class instance: its methods live on the prototype, so own
|
|
379
|
+
// enumerable properties are its INJECTED DEPENDENCIES and nothing else. Enumerating
|
|
380
|
+
// them added no computed field at all and would have exposed the ORMs if any name
|
|
381
|
+
// had matched — an Order reached GraphQL with neither user, items nor total, while
|
|
382
|
+
// REST carried all three from the same presenter.
|
|
383
|
+
const names = config.presenterFields ?? Object.getOwnPropertyNames(Object.getPrototypeOf(config.presenter));
|
|
384
|
+
for (const name of names) {
|
|
385
|
+
if (name === 'constructor')
|
|
386
|
+
continue;
|
|
387
|
+
if (allowed && !allowed.has(name))
|
|
388
|
+
continue;
|
|
389
|
+
if (result[name])
|
|
390
|
+
continue; // entity field takes precedence
|
|
391
|
+
const fn = config.presenter[name];
|
|
392
|
+
if (typeof fn !== 'function')
|
|
393
|
+
continue;
|
|
394
|
+
const meta = metaMap.get(name);
|
|
395
|
+
const nullable = meta?.nullable ?? true;
|
|
396
|
+
// READ the value, never recompute it. The façade applies the presenter on every
|
|
397
|
+
// door (`presentEgress`), so the row arrives carrying its computed fields; calling
|
|
398
|
+
// the method again ran the work twice — and once the method started receiving the
|
|
399
|
+
// PAGE rather than one row, the second call was handed a single object and threw
|
|
400
|
+
// `posts.map is not a function`. What GraphQL owes the field is its declaration.
|
|
401
|
+
const resolve = (parent) => parent?.[name] ?? null;
|
|
402
|
+
// The presenter STATED what this field emits — build its type instead of guessing.
|
|
403
|
+
const declared = config.presenterViews?.[name];
|
|
404
|
+
if (declared && config.viewType) {
|
|
405
|
+
const isList = Array.isArray(declared);
|
|
406
|
+
const view = (isList ? declared[0] : declared);
|
|
407
|
+
const viewRef = config.viewType(view, name);
|
|
408
|
+
result[name] = t.field({ type: isList ? [viewRef] : viewRef, nullable, resolve });
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
// Map inferred return type → GraphQL scalar, one per row or a list of them.
|
|
412
|
+
// `list` is the arity the scan measured after removing the page level of the
|
|
413
|
+
// method's return type; without it a computed list announced its item type and
|
|
414
|
+
// a client selecting the field got one value where the row carried several.
|
|
415
|
+
const many = meta?.list === true;
|
|
416
|
+
switch (meta?.returnType) {
|
|
417
|
+
case 'number':
|
|
418
|
+
result[name] = many ? t.floatList({ nullable, resolve }) : t.float({ nullable, resolve });
|
|
419
|
+
break;
|
|
420
|
+
case 'boolean':
|
|
421
|
+
result[name] = many ? t.booleanList({ nullable, resolve }) : t.boolean({ nullable, resolve });
|
|
422
|
+
break;
|
|
423
|
+
case 'string':
|
|
424
|
+
result[name] = many ? t.stringList({ nullable, resolve }) : t.string({ nullable, resolve });
|
|
425
|
+
break;
|
|
426
|
+
default:
|
|
427
|
+
// The scan could not name a scalar: the method returns an object, a list, or
|
|
428
|
+
// nothing it can read. Serialize, exactly as an `object`-shaped entity field
|
|
429
|
+
// does — a String typing without serialization made GraphQL coerce the value
|
|
430
|
+
// to "[object Object]", and any client selecting subfields got a schema error
|
|
431
|
+
// on a field that REST served whole.
|
|
432
|
+
result[name] = t.string({
|
|
433
|
+
nullable,
|
|
434
|
+
resolve: async (parent) => {
|
|
435
|
+
const value = await resolve(parent);
|
|
436
|
+
if (value == null)
|
|
437
|
+
return null;
|
|
438
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return result;
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Enregistre un input GraphQL (écriture) depuis un SchemaView.
|
|
451
|
+
*
|
|
452
|
+
* ```ts
|
|
453
|
+
* const CreateProductInput = registerInput(builder, {
|
|
454
|
+
* name: 'CreateProductInput',
|
|
455
|
+
* schema: CreateProduct,
|
|
456
|
+
* });
|
|
457
|
+
* ```
|
|
458
|
+
*/
|
|
459
|
+
export function registerInput(builder, config) {
|
|
460
|
+
const fields = config.schema.getFields();
|
|
461
|
+
// The view's SOURCE, not the input's name: `CreatePostInput` derives from `Post`, and its
|
|
462
|
+
// `status` must be the same `PostStatus` the query emits.
|
|
463
|
+
const enumOwner = sourceNameOf(config.schema);
|
|
464
|
+
// Input-field omissibility is a projection of the view's MODE (partial() → patch),
|
|
465
|
+
// never of forged per-field flags — the fields themselves stay untouched.
|
|
466
|
+
const patch = config.schema.getOpts().patch ?? false;
|
|
467
|
+
return builder.inputType(config.name, {
|
|
468
|
+
fields: (t) => {
|
|
469
|
+
const result = {};
|
|
470
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
471
|
+
// Skip virtual fields
|
|
472
|
+
if (field.role?.relation?.kind === 'many')
|
|
473
|
+
continue;
|
|
474
|
+
// Read-only (boundary in: 'closed'): never accepted from a client
|
|
475
|
+
if (boundaryOf(field).in === 'closed')
|
|
476
|
+
continue;
|
|
477
|
+
result[fieldName] = fieldToInput(t, field, patch, (shape, suffix) => nestedInputType(builder, shape, `${config.name}${capitalize(fieldName)}${suffix}`), (values) => {
|
|
478
|
+
const name = enumNameFor(enumOwner, fieldName);
|
|
479
|
+
return name ? enumTypeFor(builder, name, values) : undefined;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return result;
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
// ─── GraphQL field naming ────────────────────────
|
|
487
|
+
function graphqlFieldName(opName, entityName) {
|
|
488
|
+
const nameLower = entityName.charAt(0).toLowerCase() + entityName.slice(1);
|
|
489
|
+
const namePlural = pluralize(nameLower);
|
|
490
|
+
switch (opName) {
|
|
491
|
+
case 'list': return namePlural;
|
|
492
|
+
case 'findById': return nameLower;
|
|
493
|
+
case 'create':
|
|
494
|
+
case 'update':
|
|
495
|
+
case 'delete':
|
|
496
|
+
return `${opName}${entityName}`;
|
|
497
|
+
default:
|
|
498
|
+
return opName;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function buildArgsFromSignature(sig, meta, builder, opName, entityName) {
|
|
502
|
+
// Classify each param: primitive, body (object/input), or skip
|
|
503
|
+
const paramPlan = [];
|
|
504
|
+
for (const param of sig.params) {
|
|
505
|
+
const typeName = param.type.name;
|
|
506
|
+
if (SKIP_TYPES.has(typeName)) {
|
|
507
|
+
paramPlan.push({ name: param.name, kind: 'skip', typeName, optional: true });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (typeName === 'ListOptions') {
|
|
511
|
+
paramPlan.push({ name: param.name, kind: 'pagination', typeName, optional: true });
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (typeName in PRIMITIVES) {
|
|
515
|
+
paramPlan.push({ name: param.name, kind: 'primitive', typeName, optional: param.optional ?? false });
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
// Object/entity param → body
|
|
519
|
+
paramPlan.push({ name: param.name, kind: 'body', typeName, optional: param.optional ?? false });
|
|
520
|
+
}
|
|
521
|
+
// Register input type if needed
|
|
522
|
+
let inputRef;
|
|
523
|
+
const bodyParam = paramPlan.find((p) => p.kind === 'body');
|
|
524
|
+
if (bodyParam && meta.input) {
|
|
525
|
+
// Only strip non-client fields for create/update — other ops may legitimately use them (e.g. publish(id))
|
|
526
|
+
const isMutation = opName === 'create' || opName === 'update';
|
|
527
|
+
const opInputFields = isMutation ? inputFields(meta.input.getFields()) : meta.input.getFields();
|
|
528
|
+
const inputName = `${capitalize(opName)}${entityName}Input`;
|
|
529
|
+
inputRef = registerInput(builder, {
|
|
530
|
+
name: inputName,
|
|
531
|
+
// A real schema over those fields, not a forged stand-in: an update input is the
|
|
532
|
+
// same fields seen through the patch mode.
|
|
533
|
+
schema: Schema.of(opInputFields, undefined, undefined, { patch: opName === 'update' }),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
const hasPagination = paramPlan.some((p) => p.kind === 'pagination');
|
|
537
|
+
const argsDef = (t) => {
|
|
538
|
+
const args = {};
|
|
539
|
+
for (const p of paramPlan) {
|
|
540
|
+
if (p.kind === 'primitive') {
|
|
541
|
+
args[p.name] = PRIMITIVES[p.typeName](t, !p.optional);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (bodyParam && inputRef) {
|
|
545
|
+
args.input = t.arg({ type: inputRef, required: !bodyParam.optional });
|
|
546
|
+
}
|
|
547
|
+
if (hasPagination) {
|
|
548
|
+
args.limit = t.arg.int();
|
|
549
|
+
args.offset = t.arg.int();
|
|
550
|
+
args.page = t.arg.int();
|
|
551
|
+
args.after = t.arg.string();
|
|
552
|
+
args.orderBy = t.arg.string();
|
|
553
|
+
args.order = t.arg.string();
|
|
554
|
+
}
|
|
555
|
+
return args;
|
|
556
|
+
};
|
|
557
|
+
const buildInvocation = (args, gqlCtx) => {
|
|
558
|
+
const params = {};
|
|
559
|
+
let body = undefined;
|
|
560
|
+
for (const p of paramPlan) {
|
|
561
|
+
if (p.kind === 'primitive') {
|
|
562
|
+
if (args[p.name] != null)
|
|
563
|
+
params[p.name] = args[p.name];
|
|
564
|
+
}
|
|
565
|
+
else if (p.kind === 'body') {
|
|
566
|
+
body = args.input;
|
|
567
|
+
}
|
|
568
|
+
else if (p.kind === 'pagination') {
|
|
569
|
+
// Collect pagination args into body (ListOptions)
|
|
570
|
+
const options = {};
|
|
571
|
+
for (const key of ['limit', 'offset', 'page', 'after', 'orderBy', 'order']) {
|
|
572
|
+
if (args[key] != null)
|
|
573
|
+
options[key] = args[key];
|
|
574
|
+
}
|
|
575
|
+
body = options;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return { params, query: {}, body, state: gqlCtx?.state ?? {} };
|
|
579
|
+
};
|
|
580
|
+
return { argsDef, buildInvocation };
|
|
581
|
+
}
|
|
582
|
+
// ─── Output type resolution ──────────────────────
|
|
583
|
+
function resolveOutputType(sig, config, meta, opName) {
|
|
584
|
+
const rt = sig.returnType;
|
|
585
|
+
// boolean → Boolean scalar
|
|
586
|
+
if (rt?.name === 'boolean') {
|
|
587
|
+
return { type: 'Boolean', isList: false, nullable: false };
|
|
588
|
+
}
|
|
589
|
+
// ListResult<T> → paginated list wrapper
|
|
590
|
+
if (rt?.name === 'ListResult') {
|
|
591
|
+
return { type: 'list-wrapper', isList: true, nullable: false };
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The type the operation SAYS it returns.
|
|
595
|
+
*
|
|
596
|
+
* `async stats(): Promise<StatsOutput[]>` is a declaration, and the scan already
|
|
597
|
+
* resolved it into a live schema class. Until now this threw it away and announced the
|
|
598
|
+
* entity's type instead, so a schema built from a handler with such an op was simply
|
|
599
|
+
* wrong: its own fields were not queryable, and the entity's came back null.
|
|
600
|
+
*
|
|
601
|
+
* Falls back to the entity when nothing is declared, or when what is declared IS the
|
|
602
|
+
* entity — `publish(): Promise<Post>` must not mint a second Post type.
|
|
603
|
+
*/
|
|
604
|
+
const declared = meta?.output && opName && config.viewType
|
|
605
|
+
? config.viewType(meta.output, opName)
|
|
606
|
+
: undefined;
|
|
607
|
+
const type = declared ?? config.type;
|
|
608
|
+
if (rt?.array) {
|
|
609
|
+
return { type: [type], isList: false, nullable: false };
|
|
610
|
+
}
|
|
611
|
+
return { type, isList: false, nullable: rt?.nullable ?? false };
|
|
612
|
+
}
|
|
613
|
+
// ─── registerOperations ──────────────────────────
|
|
614
|
+
/**
|
|
615
|
+
* Register all GraphQL operations for an entity from parsed handler signatures.
|
|
616
|
+
*
|
|
617
|
+
* Each operation in the map is registered as a Query or Mutation field
|
|
618
|
+
* based on naming convention (list*, find*, get*, search* → Query, else → Mutation).
|
|
619
|
+
*
|
|
620
|
+
* Args are generated from the parsed method signature:
|
|
621
|
+
* - Primitives (string, number) → scalar args
|
|
622
|
+
* - Entity/object params → input types (derived from meta.input)
|
|
623
|
+
* - Partial<T> wrapper → all input fields nullable
|
|
624
|
+
* - ListOptions → standard pagination args
|
|
625
|
+
* - InvocationContext → skipped (injected by resolver)
|
|
626
|
+
*/
|
|
627
|
+
export function registerOperations(builder, config) {
|
|
628
|
+
// Pre-register list wrapper type if list op exists
|
|
629
|
+
let listWrapperType;
|
|
630
|
+
const listMeta = config.operations.get('list');
|
|
631
|
+
if (listMeta && typeof config.facade.list === 'function') {
|
|
632
|
+
listWrapperType = registerObjectType(builder, `${config.name}List`, {
|
|
633
|
+
items: { type: [config.type] },
|
|
634
|
+
total: {
|
|
635
|
+
type: 'int', nullable: true,
|
|
636
|
+
resolve: async (parent) => {
|
|
637
|
+
if (parent.total !== undefined)
|
|
638
|
+
return parent.total;
|
|
639
|
+
if (parent._count)
|
|
640
|
+
return (await parent._count()).total ?? null;
|
|
641
|
+
return null;
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
endCursor: { type: 'string', nullable: true },
|
|
645
|
+
hasMore: { type: 'boolean', nullable: true },
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
for (const [opName, meta] of config.operations) {
|
|
649
|
+
if (typeof config.facade[opName] !== 'function')
|
|
650
|
+
continue;
|
|
651
|
+
const sig = meta.signature;
|
|
652
|
+
if (!sig)
|
|
653
|
+
continue;
|
|
654
|
+
const fieldName = graphqlFieldName(opName, config.name);
|
|
655
|
+
const { argsDef, buildInvocation } = buildArgsFromSignature(sig, meta, builder, opName, config.name);
|
|
656
|
+
// Output type — what the op declares, else the entity's.
|
|
657
|
+
const output = resolveOutputType(sig, config, meta, opName);
|
|
658
|
+
const isListWrapper = output.type === 'list-wrapper';
|
|
659
|
+
const outputType = isListWrapper ? listWrapperType : output.type;
|
|
660
|
+
if (!outputType)
|
|
661
|
+
continue;
|
|
662
|
+
const fieldDef = (t) => ({
|
|
663
|
+
[fieldName]: t.field({
|
|
664
|
+
type: outputType,
|
|
665
|
+
nullable: output.nullable,
|
|
666
|
+
args: argsDef(t),
|
|
667
|
+
...(meta.description && { description: meta.description }),
|
|
668
|
+
resolve: async (_, args, gqlCtx) => {
|
|
669
|
+
const invocation = buildInvocation(args, gqlCtx);
|
|
670
|
+
const result = await config.facade[opName](invocation);
|
|
671
|
+
// List wrapper: shape the result for the paginated type
|
|
672
|
+
if (isListWrapper) {
|
|
673
|
+
const items = Array.isArray(result) ? [...result] : result;
|
|
674
|
+
return {
|
|
675
|
+
items,
|
|
676
|
+
endCursor: result?.endCursor,
|
|
677
|
+
hasMore: result?.hasMore,
|
|
678
|
+
_count: () => config.facade[opName]({
|
|
679
|
+
...invocation,
|
|
680
|
+
body: { ...(invocation.body ?? {}), count: true, limit: 1 },
|
|
681
|
+
}),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
return result;
|
|
685
|
+
},
|
|
686
|
+
}),
|
|
687
|
+
});
|
|
688
|
+
if (resolveIsReadOp(opName, config.operationsOverrides)) {
|
|
689
|
+
builder.queryFields(fieldDef);
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
builder.mutationFields(fieldDef);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
//# sourceMappingURL=pothos.js.map
|