@fougere/adapter-graphql 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/app.d.ts +3 -26
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +2 -20
- package/dist/app.js.map +1 -1
- package/dist/auto-register.d.ts +8 -18
- package/dist/auto-register.d.ts.map +1 -1
- package/dist/auto-register.js +19 -60
- package/dist/auto-register.js.map +1 -1
- package/dist/index.d.ts +1 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -9
- package/dist/index.js.map +1 -1
- package/dist/pothos-entry.d.ts +1 -7
- package/dist/pothos-entry.d.ts.map +1 -1
- package/dist/pothos-entry.js +1 -7
- package/dist/pothos-entry.js.map +1 -1
- package/dist/pothos.d.ts +10 -76
- package/dist/pothos.d.ts.map +1 -1
- package/dist/pothos.js +42 -143
- package/dist/pothos.js.map +1 -1
- package/dist/serve.d.ts +2 -19
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +2 -20
- package/dist/serve.js.map +1 -1
- package/package.json +3 -3
- package/src/app.ts +3 -26
- package/src/auto-register.ts +25 -76
- package/src/index.ts +1 -9
- package/src/pothos-entry.ts +1 -7
- package/src/pothos.ts +48 -154
- package/src/serve.ts +3 -26
package/src/auto-register.ts
CHANGED
|
@@ -1,25 +1,13 @@
|
|
|
1
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
|
-
*/
|
|
2
|
+
/** Auto-register GraphQL types and operations from a fougere App. */
|
|
11
3
|
import type SchemaBuilder from '@pothos/core';
|
|
12
|
-
import type { Fields, SchemaView
|
|
13
|
-
import {
|
|
4
|
+
import type { Fields, SchemaView } from '@fougere/schema';
|
|
5
|
+
import { Shapes } from '@fougere/schema';
|
|
14
6
|
import { registerType, registerOperations, type OperationMeta } from './pothos.js';
|
|
15
7
|
|
|
16
8
|
type HandlerFacade = Record<string, Function>;
|
|
17
9
|
|
|
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
|
-
*/
|
|
10
|
+
/** The relation a foreign key points at — `authorId → author`, `user_id → user`. */
|
|
23
11
|
function relationNameFor(fieldName: string): string | undefined {
|
|
24
12
|
const stripped = fieldName.replace(/(_id|Id|ID)$/, '');
|
|
25
13
|
return stripped && stripped !== fieldName ? stripped : undefined;
|
|
@@ -41,15 +29,7 @@ interface Batch {
|
|
|
41
29
|
/** The batch of ONE direction — the two sides of a relation must not share a read. */
|
|
42
30
|
const directionKey = (entity: string, field: string) => `${entity}#${field}`;
|
|
43
31
|
|
|
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
|
-
*/
|
|
32
|
+
/** How many keys go into one `list` call. */
|
|
53
33
|
const KEYS_PER_READ = 1000;
|
|
54
34
|
|
|
55
35
|
/** Read a key set in slices, merging what each answers. */
|
|
@@ -74,24 +54,12 @@ const keepEach = <R>(into: Map<string, R>, from: Map<string, R>) => {
|
|
|
74
54
|
/** A key answers a group: slices of the SAME key concatenate. */
|
|
75
55
|
const concatEach = (into: Map<string, any[]>, from: Map<string, any[]>) => {
|
|
76
56
|
for (const [key, rows] of from) {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
57
|
+
const bucket = into.get(key);
|
|
58
|
+
if (bucket) bucket.push(...rows); else into.set(key, rows);
|
|
79
59
|
}
|
|
80
60
|
};
|
|
81
61
|
|
|
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
|
-
*/
|
|
62
|
+
/** The keys asked for during one tick, answered by one read. */
|
|
95
63
|
const batches = new WeakMap<object, Map<string, Batch>>();
|
|
96
64
|
const NO_CONTEXT: object = {};
|
|
97
65
|
|
|
@@ -127,12 +95,15 @@ function loadByKey<R>(
|
|
|
127
95
|
interface EntityEntry {
|
|
128
96
|
name: string;
|
|
129
97
|
/** A live class in-process, a card from a frond whose class never crossed. */
|
|
130
|
-
entityClass:
|
|
98
|
+
entityClass: SchemaView;
|
|
131
99
|
exposed?: boolean;
|
|
132
100
|
}
|
|
133
101
|
|
|
134
102
|
interface HandlerEntry {
|
|
135
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* The name the door answers to — `PostHandler` → `post`. NOT an entity name: a handler may carry
|
|
105
|
+
* none.
|
|
106
|
+
*/
|
|
136
107
|
address: string;
|
|
137
108
|
operations: Map<string, OperationMeta>;
|
|
138
109
|
surface?: string;
|
|
@@ -174,32 +145,22 @@ interface AppLike {
|
|
|
174
145
|
facadeFor(entity: string, surface?: string): Record<string, Function> | undefined;
|
|
175
146
|
/** Canonical operation table produced by core. */
|
|
176
147
|
operationsFor(entity: string, surface?: string): Map<string, OperationMeta> | undefined;
|
|
177
|
-
/**
|
|
178
|
-
* The presenter of an entity — `undefined` when none. Asked for rather than
|
|
179
|
-
* resolved by a key spelled here: this adapter used to build `${Name}Presenter`
|
|
180
|
-
* itself, and a convention respelled in two places drifts silently on the day it
|
|
181
|
-
* changes, exactly as `facadeFor` exists to prevent for doors.
|
|
182
|
-
*/
|
|
148
|
+
/** The presenter of an entity — `undefined` when none. */
|
|
183
149
|
presenterFor(entity: string): unknown | undefined;
|
|
184
150
|
}
|
|
185
151
|
|
|
186
152
|
// ─── Helpers ────────────────────────────────────
|
|
187
153
|
|
|
188
154
|
/**
|
|
189
|
-
* The key an entity is filed under — case-folded, because the same entity is spelled
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* (`authoruser`). Folding both is what lets one registry serve both sources.
|
|
155
|
+
* The key an entity is filed under — case-folded, because the same entity is spelled differently
|
|
156
|
+
* depending on where its name came from: the scan yields the registration name (`authorUser`),
|
|
157
|
+
* while a card's relation target is fully lowercased by `describe` (`authoruser`).
|
|
193
158
|
*/
|
|
194
159
|
function registryKey(entityName: string): string {
|
|
195
160
|
return entityName.toLowerCase();
|
|
196
161
|
}
|
|
197
162
|
|
|
198
|
-
/**
|
|
199
|
-
* The key a relation points at. A live entity class answers with its class name; a target
|
|
200
|
-
* rebuilt from a lone card is a `{ name }` stand-in and answers with the name `describe`
|
|
201
|
-
* wrote. Both are names, which is the whole reason this resolves by name.
|
|
202
|
-
*/
|
|
163
|
+
/** The key a relation points at. */
|
|
203
164
|
function targetKey(target: unknown): string {
|
|
204
165
|
return registryKey(String((target as { name?: string } | undefined)?.name ?? ''));
|
|
205
166
|
}
|
|
@@ -213,20 +174,8 @@ export interface RegisterAllOptions {
|
|
|
213
174
|
surface?: string;
|
|
214
175
|
}
|
|
215
176
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
218
|
-
* in the app that have a matching handler facade.
|
|
219
|
-
*
|
|
220
|
-
* Operations are driven by parsed handler signatures (from the scanner).
|
|
221
|
-
* Relations (ref/many) are auto-wired between registered entity types.
|
|
222
|
-
*/
|
|
223
|
-
/**
|
|
224
|
-
* The GraphQL type of a declared presenter view, built once per view class.
|
|
225
|
-
*
|
|
226
|
-
* Named after the field that emits it (`OrderItems`, `OrderUser`) rather than after the view
|
|
227
|
-
* class, so two fields sharing one view still land on the same type and a view used twice is
|
|
228
|
-
* registered once — Pothos refuses a duplicate type name and would take the schema down.
|
|
229
|
-
*/
|
|
177
|
+
/** Auto-register GraphQL types and operations from a fougere App. */
|
|
178
|
+
/** The GraphQL type of a declared presenter view, built once per view class. */
|
|
230
179
|
const viewTypes = new WeakMap<object, any>();
|
|
231
180
|
function viewTypeOf(
|
|
232
181
|
builder: InstanceType<typeof SchemaBuilder>,
|
|
@@ -248,7 +197,7 @@ export function registerAll(
|
|
|
248
197
|
// Collect registered types across all fronds for relation wiring, keyed by entity NAME.
|
|
249
198
|
//
|
|
250
199
|
// The name is the identity everywhere else in the system — `facadeFor(entity)`,
|
|
251
|
-
// `
|
|
200
|
+
// `storageFor(entity)`, `schemaFor(entity)` all take one, and the table, the GraphQL type
|
|
252
201
|
// and the DI match are all derived from it. This registry keyed by class OBJECT was the
|
|
253
202
|
// lone dissent, and it cost a silent failure: a relation target that is not the very
|
|
254
203
|
// object registered (an entity rebuilt from a card, whose `to()` leaves a `{ name }`
|
|
@@ -313,7 +262,7 @@ export function registerAll(
|
|
|
313
262
|
typeRegistry.set(registryKey(entity.name), {
|
|
314
263
|
name: typeName, type, facade,
|
|
315
264
|
presenterFields: new Set(presenterMeta?.fields ?? []),
|
|
316
|
-
fields:
|
|
265
|
+
fields: entity.entityClass.getFields(),
|
|
317
266
|
});
|
|
318
267
|
|
|
319
268
|
const opOverrides = frond.operationsOverrides;
|
|
@@ -364,7 +313,7 @@ export function registerAll(
|
|
|
364
313
|
// a presenter's computed field: the author named it, the author wins. Deriving
|
|
365
314
|
// over it would either crash the build or shadow what they wrote.
|
|
366
315
|
if (!relationName || relationName in fields || presenterFields.has(relationName)) continue;
|
|
367
|
-
const nullable =
|
|
316
|
+
const nullable = Shapes.isNullable(field.shape);
|
|
368
317
|
|
|
369
318
|
const targetKeyName = primaryNameOf(targetEntry.fields);
|
|
370
319
|
const targetList = targetEntry.facade.list;
|
|
@@ -431,8 +380,8 @@ export function registerAll(
|
|
|
431
380
|
const grouped = new Map<string, any[]>();
|
|
432
381
|
for (const row of rows) {
|
|
433
382
|
const key = String(row?.[reverseFkName]);
|
|
434
|
-
const
|
|
435
|
-
if (
|
|
383
|
+
const bucket = grouped.get(key);
|
|
384
|
+
if (bucket) bucket.push(row); else grouped.set(key, [row]);
|
|
436
385
|
}
|
|
437
386
|
return grouped;
|
|
438
387
|
}, concatEach),
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** The GraphQL surface, in two calls. */
|
|
10
2
|
export { registerAll } from './auto-register.js';
|
|
11
3
|
export type { RegisterAllOptions } from './auto-register.js';
|
|
12
4
|
export { registerGraphQL } from './serve.js';
|
package/src/pothos-entry.ts
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** The Pothos primitives — for what `registerAll` cannot derive, never to replace it. */
|
|
8
2
|
export { registerType, registerInput, registerOperations, registerObjectType } from './pothos.js';
|
|
9
3
|
export type { TypeConfig, InputConfig, OperationsConfig, ObjectFieldDef } from './pothos.js';
|
package/src/pothos.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { upperFirst, Role } from '@fougere/schema';
|
|
|
3
3
|
* @fougere/adapter-graphql — Pothos types derived from Fougere entities
|
|
4
4
|
*/
|
|
5
5
|
import type SchemaBuilder from '@pothos/core';
|
|
6
|
-
import {
|
|
7
|
-
import type { Field, Fields, SchemaView
|
|
8
|
-
import { Boundary, Card, Lifecycle,
|
|
6
|
+
import { Shapes, Schema, type Shape } from '@fougere/schema';
|
|
7
|
+
import type { Field, Fields, SchemaView } from '@fougere/schema';
|
|
8
|
+
import { Boundary, Card, Lifecycle, Visibility } from '@fougere/schema';
|
|
9
9
|
|
|
10
10
|
// ─── Types ─────────────────────────────────────────
|
|
11
11
|
|
|
@@ -19,7 +19,7 @@ export interface TypeConfig {
|
|
|
19
19
|
name: string;
|
|
20
20
|
/** Entity source */
|
|
21
21
|
/** The schema whose fields become the type — a live class, or a card that travelled. */
|
|
22
|
-
entity:
|
|
22
|
+
entity: SchemaView;
|
|
23
23
|
/** Champs à exclure du type GraphQL */
|
|
24
24
|
exclude?: string[];
|
|
25
25
|
/** Relations à résoudre */
|
|
@@ -31,9 +31,8 @@ export interface TypeConfig {
|
|
|
31
31
|
/** Per-field type metadata from source parsing. */
|
|
32
32
|
presenterFieldMeta?: { name: string; returnType?: string; list?: boolean; nullable?: boolean }[];
|
|
33
33
|
/**
|
|
34
|
-
* The view a computed field emits, when the presenter declared one — the object type
|
|
35
|
-
*
|
|
36
|
-
* object-valued field can only be serialized.
|
|
34
|
+
* The view a computed field emits, when the presenter declared one — the object type to build
|
|
35
|
+
* for it.
|
|
37
36
|
*/
|
|
38
37
|
presenterViews?: Record<string, EntityClass | [EntityClass]>;
|
|
39
38
|
/** Builds (or reuses) the GraphQL object type for a declared view. */
|
|
@@ -69,7 +68,7 @@ export interface OperationBinding {
|
|
|
69
68
|
source:
|
|
70
69
|
| { kind: 'collector' | 'context' | 'fact' }
|
|
71
70
|
| { kind: 'param'; name: string }
|
|
72
|
-
| { kind: '
|
|
71
|
+
| { kind: 'input' | 'query' };
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
/** The projection-facing subset of core's EffectiveOperation. */
|
|
@@ -104,11 +103,7 @@ export interface OperationsConfig {
|
|
|
104
103
|
* one root field. Absent when a caller builds a type by hand.
|
|
105
104
|
*/
|
|
106
105
|
origin?: string;
|
|
107
|
-
/**
|
|
108
|
-
* The GraphQL type for a schema an operation declares as its return. The caller owns
|
|
109
|
-
* this because it alone knows whether the schema IS the entity's (then: the type
|
|
110
|
-
* already registered) or something else (then: a new named type).
|
|
111
|
-
*/
|
|
106
|
+
/** The GraphQL type for a schema an operation declares as its return. */
|
|
112
107
|
viewType?: (view: SchemaView, opName: string) => any;
|
|
113
108
|
}
|
|
114
109
|
|
|
@@ -135,13 +130,7 @@ const PRIMITIVES: Record<string, (t: any, required: boolean) => any> = {
|
|
|
135
130
|
boolean: (t, r) => t.arg.boolean({ required: r }),
|
|
136
131
|
};
|
|
137
132
|
|
|
138
|
-
/**
|
|
139
|
-
* Parameter types no GraphQL argument stands for. `ListOptions` is NOT one of
|
|
140
|
-
* them: it has its own branch below that turns it into the six pagination
|
|
141
|
-
* arguments. Listing it here classified it first — the skip branch runs before
|
|
142
|
-
* the pagination one — so `kind: 'pagination'` was never assigned and every
|
|
143
|
-
* `list(options?: ListOptions)` op reached GraphQL with no arguments at all.
|
|
144
|
-
*/
|
|
133
|
+
/** Parameter types no GraphQL argument stands for. */
|
|
145
134
|
const SKIP_TYPES = new Set(['InvocationContext']);
|
|
146
135
|
|
|
147
136
|
// ─── Helpers ───────────────────────────────────────
|
|
@@ -154,7 +143,7 @@ function fieldToGraphQL(
|
|
|
154
143
|
): any {
|
|
155
144
|
// Dispatch on the BASE type via anatomy — `shape.type` may be the nullable
|
|
156
145
|
// `[T,'null']` union, a direct comparison would fail silently on it.
|
|
157
|
-
const { base: shape, nullable } =
|
|
146
|
+
const { base: shape, nullable } = Shapes.of(field.shape);
|
|
158
147
|
|
|
159
148
|
// Before the type switch: a bounded set is its own GraphQL type whatever its base type
|
|
160
149
|
// carries. `oneOf` fed the form's `select` and the DDL's `CHECK` from the day it was
|
|
@@ -199,7 +188,7 @@ function fieldToGraphQL(
|
|
|
199
188
|
case 'array': {
|
|
200
189
|
// A value list (`list(text())`) becomes a GraphQL list of the item scalar; a list
|
|
201
190
|
// of objects becomes a list of JSON strings — the same rule 'object' follows.
|
|
202
|
-
const items =
|
|
191
|
+
const items = Shapes.of(shape.items).base;
|
|
203
192
|
const resolve = (parent: any) => parent[fieldName] ?? (nullable ? null : []);
|
|
204
193
|
switch (items?.type) {
|
|
205
194
|
case 'integer': return t.intList({ nullable, resolve });
|
|
@@ -240,14 +229,7 @@ function fieldToGraphQL(
|
|
|
240
229
|
}
|
|
241
230
|
}
|
|
242
231
|
|
|
243
|
-
/**
|
|
244
|
-
* A JSON-Schema object shape, turned into a GraphQL input type.
|
|
245
|
-
*
|
|
246
|
-
* `list(json(OrderLine))` inlines the line's shape as nested `properties` — good enough for
|
|
247
|
-
* the judge, invisible to GraphQL until now: the `array` case fell through to `stringList`,
|
|
248
|
-
* so `items` reached the schema as `[String!]!` and a client had to hand-encode every line
|
|
249
|
-
* as JSON. The mutation was unusable (measured 2026-08-02).
|
|
250
|
-
*/
|
|
232
|
+
/** A JSON-Schema object shape, turned into a GraphQL input type. */
|
|
251
233
|
function nestedInputType(
|
|
252
234
|
builder: InstanceType<typeof SchemaBuilder>,
|
|
253
235
|
shape: Shape,
|
|
@@ -266,7 +248,7 @@ function nestedInputType(
|
|
|
266
248
|
const out: Record<string, any> = {};
|
|
267
249
|
for (const [key, prop] of Object.entries(properties)) {
|
|
268
250
|
const isRequired = required.has(key);
|
|
269
|
-
switch (
|
|
251
|
+
switch (Shapes.of(prop).base?.type) {
|
|
270
252
|
case 'integer': out[key] = t.int({ required: isRequired }); break;
|
|
271
253
|
case 'number': out[key] = t.float({ required: isRequired }); break;
|
|
272
254
|
case 'boolean': out[key] = t.boolean({ required: isRequired }); break;
|
|
@@ -281,9 +263,9 @@ function nestedInputType(
|
|
|
281
263
|
}
|
|
282
264
|
|
|
283
265
|
/**
|
|
284
|
-
* One input type per name, PER BUILDER — Pothos refuses a duplicate name, and a ref built on
|
|
285
|
-
*
|
|
286
|
-
* ("InputObjectRef has not been implemented").
|
|
266
|
+
* One input type per name, PER BUILDER — Pothos refuses a duplicate name, and a ref built on one
|
|
267
|
+
* builder is unknown to the next: a global cache handed a stale ref to the second schema
|
|
268
|
+
* ("InputObjectRef has not been implemented").
|
|
287
269
|
*/
|
|
288
270
|
const nestedInputs = new WeakMap<object, Map<string, any>>();
|
|
289
271
|
|
|
@@ -291,9 +273,8 @@ const nestedInputs = new WeakMap<object, Map<string, any>>();
|
|
|
291
273
|
const enumTypes = new WeakMap<object, Map<string, { ref: any; values: string[] }>>();
|
|
292
274
|
|
|
293
275
|
/**
|
|
294
|
-
* A GraphQL enum value is an IDENTIFIER, not a string: `in-progress` or `à valider` cannot
|
|
295
|
-
*
|
|
296
|
-
* that will not fit stays a `String` — the judge still refuses what is not in it.
|
|
276
|
+
* A GraphQL enum value is an IDENTIFIER, not a string: `in-progress` or `à valider` cannot be
|
|
277
|
+
* spelled in a query.
|
|
297
278
|
*/
|
|
298
279
|
const GRAPHQL_NAME = /^[_A-Za-z][_0-9A-Za-z]*$/;
|
|
299
280
|
|
|
@@ -307,10 +288,6 @@ function enumValuesOf(shape: Shape | undefined): string[] | undefined {
|
|
|
307
288
|
/**
|
|
308
289
|
* The enum type for one field's value set — one per NAME per builder, so `Post.status` and
|
|
309
290
|
* `CreatePostInput.status` are the same `PostStatus` and a value read can be written back.
|
|
310
|
-
*
|
|
311
|
-
* A second field claiming the name with a different set falls back to `String` rather than
|
|
312
|
-
* being served the first one: two different sets under one name would let a client send a
|
|
313
|
-
* value this field never declared, which is the opposite of what the enum is for.
|
|
314
291
|
*/
|
|
315
292
|
function enumTypeFor(
|
|
316
293
|
builder: InstanceType<typeof SchemaBuilder>,
|
|
@@ -346,7 +323,7 @@ function fieldToInput(
|
|
|
346
323
|
// Required = the presence axis, projected onto GraphQL's single knob: the
|
|
347
324
|
// caller must supply it (no `lifecycle.create` rule answers absence), null is
|
|
348
325
|
// not legal, and the view is not in patch mode (a patch omits freely).
|
|
349
|
-
const { base: shape, nullable } =
|
|
326
|
+
const { base: shape, nullable } = Shapes.of(field.shape);
|
|
350
327
|
const required = !patch && !nullable && Lifecycle.of(field).requiredAtCreate;
|
|
351
328
|
|
|
352
329
|
// The dual of the output side, and it must be the SAME type: an input left as `String`
|
|
@@ -369,7 +346,7 @@ function fieldToInput(
|
|
|
369
346
|
return t.boolean({ required });
|
|
370
347
|
|
|
371
348
|
case 'array': {
|
|
372
|
-
const items =
|
|
349
|
+
const items = Shapes.of(shape.items).base;
|
|
373
350
|
switch (items?.type) {
|
|
374
351
|
case 'integer': return t.intList({ required });
|
|
375
352
|
case 'number': return t.floatList({ required });
|
|
@@ -416,21 +393,7 @@ export interface ObjectFieldDef {
|
|
|
416
393
|
resolve?: (parent: any) => any;
|
|
417
394
|
}
|
|
418
395
|
|
|
419
|
-
/**
|
|
420
|
-
* Register a GraphQL object type from a declarative field map.
|
|
421
|
-
*
|
|
422
|
-
* Each field auto-resolves from `parent[key]` unless a custom `resolve` is provided.
|
|
423
|
-
* Works for wrapper types, result types, or any structural type.
|
|
424
|
-
*
|
|
425
|
-
* ```ts
|
|
426
|
-
* const PostList = registerObjectType(builder, 'PostList', {
|
|
427
|
-
* items: { type: [PostType] },
|
|
428
|
-
* total: { type: 'int', nullable: true, resolve: async (p) => lazyCount(p) },
|
|
429
|
-
* hasMore: { type: 'boolean', nullable: true },
|
|
430
|
-
* endCursor: { type: 'string', nullable: true },
|
|
431
|
-
* });
|
|
432
|
-
* ```
|
|
433
|
-
*/
|
|
396
|
+
/** Register a GraphQL object type from a declarative field map. */
|
|
434
397
|
export function registerObjectType(
|
|
435
398
|
builder: InstanceType<typeof SchemaBuilder>,
|
|
436
399
|
name: string,
|
|
@@ -476,26 +439,10 @@ export function registerObjectType(
|
|
|
476
439
|
|
|
477
440
|
// ─── Public API ────────────────────────────────────
|
|
478
441
|
|
|
479
|
-
/**
|
|
480
|
-
* Enregistre un type GraphQL (lecture) depuis une entité fougere.
|
|
481
|
-
*
|
|
482
|
-
* ```ts
|
|
483
|
-
* const ProductType = registerType(builder, {
|
|
484
|
-
* name: 'Product',
|
|
485
|
-
* entity: Product,
|
|
486
|
-
* exclude: ['categoryId'],
|
|
487
|
-
* relations: {
|
|
488
|
-
* category: {
|
|
489
|
-
* type: CategoryType,
|
|
490
|
-
* resolve: (parent) => db.select()...
|
|
491
|
-
* },
|
|
492
|
-
* },
|
|
493
|
-
* });
|
|
494
|
-
* ```
|
|
495
|
-
*/
|
|
442
|
+
/** Enregistre un type GraphQL (lecture) depuis une entité fougere. */
|
|
496
443
|
export function registerType(builder: InstanceType<typeof SchemaBuilder>, config: TypeConfig): any {
|
|
497
444
|
// A live class or a card — an adapter needs the fields, never the constructor.
|
|
498
|
-
const schema =
|
|
445
|
+
const schema = config.entity;
|
|
499
446
|
const fields = schema.getFields();
|
|
500
447
|
const exclude = new Set(config.exclude ?? []);
|
|
501
448
|
// Who owns the enum names: the schema a view came from, so `PostCard.status` and
|
|
@@ -621,26 +568,7 @@ export function registerType(builder: InstanceType<typeof SchemaBuilder>, config
|
|
|
621
568
|
});
|
|
622
569
|
}
|
|
623
570
|
|
|
624
|
-
/**
|
|
625
|
-
* The GraphQL input for EXACTLY this view — or none, when the view asks for nothing.
|
|
626
|
-
*
|
|
627
|
-
* The caller derives the view and this projects it; it holds no policy of its own. What
|
|
628
|
-
* a client may supply at CREATION is `Visibility.input`, which the op path applies for
|
|
629
|
-
* create/update alone — `publish(input: Post)` must still name the post.
|
|
630
|
-
*
|
|
631
|
-
* Two things are dropped here because GraphQL cannot carry them, not because of any
|
|
632
|
-
* rule: a collection has no column to send, and `boundary in: 'closed'` is refused from
|
|
633
|
-
* every client. A view left with nothing after that gets `undefined` rather than an
|
|
634
|
-
* input object with zero fields, which is invalid GraphQL and takes the WHOLE schema
|
|
635
|
-
* down — every other type included.
|
|
636
|
-
*
|
|
637
|
-
* ```ts
|
|
638
|
-
* const CreateProductInput = registerInput(builder, {
|
|
639
|
-
* name: 'CreateProductInput',
|
|
640
|
-
* schema: CreateProduct,
|
|
641
|
-
* });
|
|
642
|
-
* ```
|
|
643
|
-
*/
|
|
571
|
+
/** The GraphQL input for EXACTLY this view — or none, when the view asks for nothing. */
|
|
644
572
|
export function registerInput(builder: InstanceType<typeof SchemaBuilder>, config: InputConfig): any {
|
|
645
573
|
const fields = Object.fromEntries(
|
|
646
574
|
Object.entries(config.schema.getFields())
|
|
@@ -676,25 +604,13 @@ export function registerInput(builder: InstanceType<typeof SchemaBuilder>, confi
|
|
|
676
604
|
|
|
677
605
|
// ─── GraphQL field naming ────────────────────────
|
|
678
606
|
|
|
679
|
-
/**
|
|
680
|
-
* Who holds each root field — a GraphQL root is FLAT, and two ops can want one name.
|
|
681
|
-
*
|
|
682
|
-
* Refused here rather than left to Pothos: it answers `Duplicate field ofBook on
|
|
683
|
-
* Mutation` with no file, no handler and no remedy, and it takes the whole schema down
|
|
684
|
-
* — every other type included. The five CRUD names weave the entity in (`createBook`),
|
|
685
|
-
* so they never meet this; a custom op keeps its method name, which is the author's and
|
|
686
|
-
* says nothing about its subject. Four handlers named `ofBook` in one measured app.
|
|
687
|
-
*
|
|
688
|
-
* Nothing is renamed automatically: `chapterOfBook` would be this package's choice of
|
|
689
|
-
* the app's public vocabulary, and adding an entity in some other frond would silently
|
|
690
|
-
* rename a field already published.
|
|
691
|
-
*/
|
|
607
|
+
/** Who holds each root field — a GraphQL root is FLAT, and two ops can want one name. */
|
|
692
608
|
const claimed = new WeakMap<object, Map<string, string>>();
|
|
693
609
|
function claimRootField(builder: object, fieldName: string, origin: string): void {
|
|
694
|
-
let
|
|
695
|
-
if (!
|
|
610
|
+
let perField = claimed.get(builder);
|
|
611
|
+
if (!perField) { perField = new Map(); claimed.set(builder, perField); }
|
|
696
612
|
|
|
697
|
-
const first =
|
|
613
|
+
const first = perField.get(fieldName);
|
|
698
614
|
if (first !== undefined && first !== origin) {
|
|
699
615
|
const opName = origin.split('.').pop();
|
|
700
616
|
// `operations:` is keyed by op name PER FROND, so it cannot tell two handlers of the
|
|
@@ -710,7 +626,7 @@ function claimRootField(builder: object, fieldName: string, origin: string): voi
|
|
|
710
626
|
+ `A root field is global, so one of them has to give. ${remedy}`,
|
|
711
627
|
);
|
|
712
628
|
}
|
|
713
|
-
|
|
629
|
+
perField.set(fieldName, origin);
|
|
714
630
|
}
|
|
715
631
|
|
|
716
632
|
function graphqlFieldName(opName: string, entityName: string): string {
|
|
@@ -733,7 +649,7 @@ function graphqlFieldName(opName: string, entityName: string): string {
|
|
|
733
649
|
|
|
734
650
|
interface ArgsResult {
|
|
735
651
|
argsDef: (t: any) => Record<string, any>;
|
|
736
|
-
buildInvocation: (args: any, gqlCtx: any) => { params: Record<string, any>; query: Record<string, any>;
|
|
652
|
+
buildInvocation: (args: any, gqlCtx: any) => { params: Record<string, any>; query: Record<string, any>; input: unknown; state: Record<string, any> };
|
|
737
653
|
}
|
|
738
654
|
|
|
739
655
|
function buildArgsFromSignature(
|
|
@@ -748,7 +664,7 @@ function buildArgsFromSignature(
|
|
|
748
664
|
// invocation context, and publishing it would let callers impersonate that value.
|
|
749
665
|
const paramPlan: {
|
|
750
666
|
name: string;
|
|
751
|
-
kind: 'primitive' | '
|
|
667
|
+
kind: 'primitive' | 'input' | 'skip' | 'pagination';
|
|
752
668
|
typeName: string;
|
|
753
669
|
optional: boolean;
|
|
754
670
|
nullable: boolean;
|
|
@@ -772,8 +688,8 @@ function buildArgsFromSignature(
|
|
|
772
688
|
case 'param':
|
|
773
689
|
paramPlan.push({ name: param.name, kind: 'primitive', typeName, optional: binding.optional, nullable: param.type.nullable === true });
|
|
774
690
|
continue;
|
|
775
|
-
case '
|
|
776
|
-
paramPlan.push({ name: param.name, kind: '
|
|
691
|
+
case 'input':
|
|
692
|
+
paramPlan.push({ name: param.name, kind: 'input', typeName, optional: binding.optional, nullable: param.type.nullable === true });
|
|
777
693
|
continue;
|
|
778
694
|
}
|
|
779
695
|
}
|
|
@@ -794,14 +710,14 @@ function buildArgsFromSignature(
|
|
|
794
710
|
continue;
|
|
795
711
|
}
|
|
796
712
|
|
|
797
|
-
// Object/entity param →
|
|
798
|
-
paramPlan.push({ name: param.name, kind: '
|
|
713
|
+
// Object/entity param → input
|
|
714
|
+
paramPlan.push({ name: param.name, kind: 'input', typeName, optional: param.optional ?? false, nullable: param.type.nullable === true });
|
|
799
715
|
}
|
|
800
716
|
|
|
801
717
|
// Register input type if needed
|
|
802
718
|
let inputRef: any;
|
|
803
|
-
const
|
|
804
|
-
if (
|
|
719
|
+
const inputParam = paramPlan.find((p) => p.kind === 'input');
|
|
720
|
+
if (inputParam && meta.input) {
|
|
805
721
|
// Only strip non-client fields for create/update — other ops may legitimately use them (e.g. publish(id))
|
|
806
722
|
const isMutation = opName === 'create' || opName === 'update';
|
|
807
723
|
const opInputFields = isMutation ? Visibility.of(meta.input.getFields()).input : meta.input.getFields();
|
|
@@ -831,8 +747,8 @@ function buildArgsFromSignature(
|
|
|
831
747
|
}
|
|
832
748
|
}
|
|
833
749
|
|
|
834
|
-
if (
|
|
835
|
-
args.input = t.arg({ type: inputRef, required: !
|
|
750
|
+
if (inputParam && inputRef) {
|
|
751
|
+
args.input = t.arg({ type: inputRef, required: !inputParam.optional && !inputParam.nullable });
|
|
836
752
|
}
|
|
837
753
|
|
|
838
754
|
if (hasPagination) {
|
|
@@ -849,7 +765,7 @@ function buildArgsFromSignature(
|
|
|
849
765
|
|
|
850
766
|
const buildInvocation = (args: any, gqlCtx: any) => {
|
|
851
767
|
const params: Record<string, any> = {};
|
|
852
|
-
let
|
|
768
|
+
let input: unknown = undefined;
|
|
853
769
|
|
|
854
770
|
for (const p of paramPlan) {
|
|
855
771
|
if (p.kind === 'primitive') {
|
|
@@ -857,19 +773,19 @@ function buildArgsFromSignature(
|
|
|
857
773
|
// null. Test undefined alone: `!= null` erased the second case and made
|
|
858
774
|
// `foo?: T | null` indistinguishable from `foo?: T`.
|
|
859
775
|
if (args[p.name] !== undefined) params[p.name] = args[p.name];
|
|
860
|
-
} else if (p.kind === '
|
|
861
|
-
|
|
776
|
+
} else if (p.kind === 'input') {
|
|
777
|
+
input = args.input;
|
|
862
778
|
} else if (p.kind === 'pagination') {
|
|
863
|
-
// Collect pagination args into
|
|
779
|
+
// Collect pagination args into input (ListOptions)
|
|
864
780
|
const options: Record<string, any> = {};
|
|
865
781
|
for (const key of ['limit', 'offset', 'page', 'after', 'orderBy', 'order']) {
|
|
866
782
|
if (args[key] !== undefined) options[key] = args[key];
|
|
867
783
|
}
|
|
868
|
-
|
|
784
|
+
input = options;
|
|
869
785
|
}
|
|
870
786
|
}
|
|
871
787
|
|
|
872
|
-
return { params, query: {},
|
|
788
|
+
return { params, query: {}, input, state: gqlCtx?.state ?? {} };
|
|
873
789
|
};
|
|
874
790
|
|
|
875
791
|
return { argsDef, buildInvocation };
|
|
@@ -895,17 +811,7 @@ function resolveOutputType(
|
|
|
895
811
|
return { type: 'list-wrapper', isList: true, nullable: false };
|
|
896
812
|
}
|
|
897
813
|
|
|
898
|
-
/**
|
|
899
|
-
* The type the operation SAYS it returns.
|
|
900
|
-
*
|
|
901
|
-
* `async stats(): Promise<StatsOutput[]>` is a declaration, and the scan already
|
|
902
|
-
* resolved it into a live schema class. Until now this threw it away and announced the
|
|
903
|
-
* entity's type instead, so a schema built from a handler with such an op was simply
|
|
904
|
-
* wrong: its own fields were not queryable, and the entity's came back null.
|
|
905
|
-
*
|
|
906
|
-
* Falls back to the entity when nothing is declared, or when what is declared IS the
|
|
907
|
-
* entity — `publish(): Promise<Post>` must not mint a second Post type.
|
|
908
|
-
*/
|
|
814
|
+
/** The type the operation SAYS it returns. */
|
|
909
815
|
const declared = meta?.output && opName && config.viewType
|
|
910
816
|
? config.viewType(meta.output, opName)
|
|
911
817
|
: undefined;
|
|
@@ -919,19 +825,7 @@ function resolveOutputType(
|
|
|
919
825
|
|
|
920
826
|
// ─── registerOperations ──────────────────────────
|
|
921
827
|
|
|
922
|
-
/**
|
|
923
|
-
* Register all GraphQL operations for an entity from parsed handler signatures.
|
|
924
|
-
*
|
|
925
|
-
* Each operation in the map is registered as a Query or Mutation field
|
|
926
|
-
* based on naming convention (list*, find*, get*, search* → Query, else → Mutation).
|
|
927
|
-
*
|
|
928
|
-
* Args are generated from the parsed method signature:
|
|
929
|
-
* - Primitives (string, number) → scalar args
|
|
930
|
-
* - Entity/object params → input types (derived from meta.input)
|
|
931
|
-
* - Partial<T> wrapper → all input fields nullable
|
|
932
|
-
* - ListOptions → standard pagination args
|
|
933
|
-
* - InvocationContext → skipped (injected by resolver)
|
|
934
|
-
*/
|
|
828
|
+
/** Register all GraphQL operations for an entity from parsed handler signatures. */
|
|
935
829
|
export function registerOperations(builder: InstanceType<typeof SchemaBuilder>, config: OperationsConfig): void {
|
|
936
830
|
// Pre-register list wrapper type if list op exists
|
|
937
831
|
let listWrapperType: any;
|
|
@@ -992,7 +886,7 @@ export function registerOperations(builder: InstanceType<typeof SchemaBuilder>,
|
|
|
992
886
|
hasMore: result?.hasMore,
|
|
993
887
|
_count: () => config.facade[opName]({
|
|
994
888
|
...invocation,
|
|
995
|
-
|
|
889
|
+
input: { ...(invocation.input as any ?? {}), count: true, limit: 1 },
|
|
996
890
|
}),
|
|
997
891
|
};
|
|
998
892
|
}
|