@simtlix/simfinity-js-client 1.0.6 → 1.1.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/.claude/worktrees/flamboyant-cartwright/.claude/settings.local.json +18 -0
- package/.claude/worktrees/flamboyant-cartwright/IMPLEMENTATION_PROMPT.md +1137 -0
- package/.claude/worktrees/flamboyant-cartwright/LICENSE +201 -0
- package/.claude/worktrees/flamboyant-cartwright/README.md +491 -0
- package/.claude/worktrees/flamboyant-cartwright/examples/demo.js +402 -0
- package/.claude/worktrees/flamboyant-cartwright/package-lock.json +107 -0
- package/.claude/worktrees/flamboyant-cartwright/package.json +29 -0
- package/.claude/worktrees/flamboyant-cartwright/src/SimfinityClient.js +1398 -0
- package/README.md +100 -0
- package/examples/demo.js +52 -0
- package/package.json +1 -1
- package/src/SimfinityClient.js +56 -0
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
# Implementation Prompt: Missing Features in simfinity-js-client
|
|
2
|
+
|
|
3
|
+
This document is a detailed, actionable implementation prompt for adding the missing features to `@simtlix/simfinity-js-client` so it can fully replace the direct introspection, query building, mutation construction, and data transformation logic currently embedded in `simfinity-fe-package`.
|
|
4
|
+
|
|
5
|
+
All changes target `src/SimfinityClient.js`. The current file is 665 lines.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Feature 1: Simfinity Extensions in Introspection](#feature-1-simfinity-extensions-in-introspection)
|
|
12
|
+
2. [Feature 2: Validated Scalar Type Utilities](#feature-2-validated-scalar-type-utilities)
|
|
13
|
+
3. [Feature 3: Schema Metadata Access API](#feature-3-schema-metadata-access-api)
|
|
14
|
+
4. [Feature 4: Automatic Selection Set Building](#feature-4-automatic-selection-set-building)
|
|
15
|
+
5. [Feature 5: Entity & Query Name Resolution](#feature-5-entity--query-name-resolution)
|
|
16
|
+
6. [Feature 6: Mutation Input Transformation](#feature-6-mutation-input-transformation)
|
|
17
|
+
7. [Feature 7: Collection Delta Mutations](#feature-7-collection-delta-mutations)
|
|
18
|
+
8. [Feature 8: Collection Item Queries (findByParent)](#feature-8-collection-item-queries-findbyparent)
|
|
19
|
+
9. [Feature 9: FK/Relation Search Queries](#feature-9-fkrelation-search-queries)
|
|
20
|
+
10. [Feature 10: State Machine Metadata](#feature-10-state-machine-metadata)
|
|
21
|
+
11. [Feature 11: Response Extensions (Pagination Count)](#feature-11-response-extensions-pagination-count)
|
|
22
|
+
12. [Summary of New Public API Methods](#summary-of-new-public-api-methods)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Feature 1: Simfinity Extensions in Introspection
|
|
27
|
+
|
|
28
|
+
### What to change
|
|
29
|
+
|
|
30
|
+
The `INTROSPECTION_QUERY` constant (lines 3-41) must be updated to fetch Simfinity-specific field extensions and support deeper type nesting.
|
|
31
|
+
|
|
32
|
+
### Current introspection query (problematic parts)
|
|
33
|
+
|
|
34
|
+
```graphql
|
|
35
|
+
fields {
|
|
36
|
+
name
|
|
37
|
+
type {
|
|
38
|
+
name kind
|
|
39
|
+
ofType {
|
|
40
|
+
name kind
|
|
41
|
+
ofType {
|
|
42
|
+
name kind
|
|
43
|
+
ofType { name kind } # Only 3 levels deep
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
args { ... }
|
|
48
|
+
# NO extensions block
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Required introspection query
|
|
53
|
+
|
|
54
|
+
Replace the entire `INTROSPECTION_QUERY` with:
|
|
55
|
+
|
|
56
|
+
```graphql
|
|
57
|
+
query IntrospectionQuery {
|
|
58
|
+
__schema {
|
|
59
|
+
queryType { name }
|
|
60
|
+
mutationType { name }
|
|
61
|
+
types {
|
|
62
|
+
name
|
|
63
|
+
kind
|
|
64
|
+
fields {
|
|
65
|
+
name
|
|
66
|
+
type {
|
|
67
|
+
kind name
|
|
68
|
+
ofType {
|
|
69
|
+
kind name
|
|
70
|
+
ofType {
|
|
71
|
+
kind name
|
|
72
|
+
ofType {
|
|
73
|
+
kind name
|
|
74
|
+
ofType { # 4th level added
|
|
75
|
+
kind name
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
args {
|
|
82
|
+
name
|
|
83
|
+
type {
|
|
84
|
+
kind name
|
|
85
|
+
ofType {
|
|
86
|
+
kind name
|
|
87
|
+
ofType {
|
|
88
|
+
kind name
|
|
89
|
+
ofType {
|
|
90
|
+
kind name
|
|
91
|
+
ofType { # 4th level added for args too
|
|
92
|
+
kind name
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
extensions {
|
|
100
|
+
relation {
|
|
101
|
+
displayField
|
|
102
|
+
embedded
|
|
103
|
+
connectionField
|
|
104
|
+
}
|
|
105
|
+
stateMachine
|
|
106
|
+
readOnly
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
enumValues { name }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Changes to `_buildTypesRegistry` (lines 355-374)
|
|
116
|
+
|
|
117
|
+
Update the field mapping to include extension data:
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
_buildTypesRegistry(types) {
|
|
121
|
+
for (const type of types) {
|
|
122
|
+
if (type.name.startsWith('__')) continue;
|
|
123
|
+
const validKinds = ['OBJECT', 'ENUM', 'SCALAR', 'INPUT_OBJECT'];
|
|
124
|
+
if (!validKinds.includes(type.kind)) continue;
|
|
125
|
+
|
|
126
|
+
const typeInfo = {
|
|
127
|
+
name: type.name,
|
|
128
|
+
kind: type.kind,
|
|
129
|
+
fields: (type.fields || []).map(f => ({
|
|
130
|
+
name: f.name,
|
|
131
|
+
type: unwrapType(f.type),
|
|
132
|
+
rawType: f.type,
|
|
133
|
+
args: (f.args || []).map(a => ({ name: a.name, type: a.type })),
|
|
134
|
+
extensions: f.extensions || null, // NEW: store extensions
|
|
135
|
+
})),
|
|
136
|
+
enumValues: (type.enumValues || []).map(v => v.name),
|
|
137
|
+
};
|
|
138
|
+
this._types.set(type.name, typeInfo);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Each field's `extensions` object has this shape:
|
|
144
|
+
|
|
145
|
+
```javascript
|
|
146
|
+
{
|
|
147
|
+
relation: {
|
|
148
|
+
displayField: string | null, // e.g. "name", "title"
|
|
149
|
+
embedded: boolean | null, // true = inline object, false/null = FK reference
|
|
150
|
+
connectionField: string | null // e.g. "serie" (back-reference field in child)
|
|
151
|
+
} | null,
|
|
152
|
+
stateMachine: boolean | null, // true = field is state-machine managed
|
|
153
|
+
readOnly: boolean | null // true = field cannot be modified
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Feature 2: Validated Scalar Type Utilities
|
|
160
|
+
|
|
161
|
+
### Context
|
|
162
|
+
|
|
163
|
+
Simfinity uses validated scalar names with a convention like `SeasonNumber_Int`, `EpisodeDate_Date`, `StartDate_DateTime`. The suffix after the last `_` indicates the base GraphQL scalar type. All type-detection utilities must strip this prefix before checking.
|
|
164
|
+
|
|
165
|
+
### New utility functions (add at module level, near `unwrapType`)
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
function getActualScalarType(name) {
|
|
169
|
+
if (!name) return null;
|
|
170
|
+
return name.includes('_') ? name.split('_').pop() || name : name;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isNumericScalar(name) {
|
|
174
|
+
if (!name) return false;
|
|
175
|
+
const actual = getActualScalarType(name);
|
|
176
|
+
if (!actual) return false;
|
|
177
|
+
const n = actual.toLowerCase();
|
|
178
|
+
return n === 'int' || n === 'float' || n === 'idnumber';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isBooleanScalar(name) {
|
|
182
|
+
if (!name) return false;
|
|
183
|
+
const actual = getActualScalarType(name);
|
|
184
|
+
if (!actual) return false;
|
|
185
|
+
return actual.toLowerCase() === 'boolean';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isDateTimeScalar(name) {
|
|
189
|
+
if (!name) return false;
|
|
190
|
+
const actual = getActualScalarType(name);
|
|
191
|
+
if (!actual) return false;
|
|
192
|
+
const n = actual.toLowerCase();
|
|
193
|
+
return n === 'date' || n === 'datetime' || n === 'timestamp'
|
|
194
|
+
|| n === 'isodate' || n === 'graphqldate' || n === 'graphqldatetime';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isScalarOrEnum(kind) {
|
|
198
|
+
return kind === 'SCALAR' || kind === 'ENUM';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isListType(rawType) {
|
|
202
|
+
let current = rawType;
|
|
203
|
+
while (current) {
|
|
204
|
+
if (current.kind === 'LIST') return true;
|
|
205
|
+
current = current.ofType || null;
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Public API on SimfinityClient
|
|
212
|
+
|
|
213
|
+
Expose as instance methods that delegate to the module-level functions:
|
|
214
|
+
|
|
215
|
+
```javascript
|
|
216
|
+
getActualScalarType(scalarName) { return getActualScalarType(scalarName); }
|
|
217
|
+
isNumericScalar(scalarName) { return isNumericScalar(scalarName); }
|
|
218
|
+
isBooleanScalar(scalarName) { return isBooleanScalar(scalarName); }
|
|
219
|
+
isDateTimeScalar(scalarName) { return isDateTimeScalar(scalarName); }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Feature 3: Schema Metadata Access API
|
|
225
|
+
|
|
226
|
+
### New public methods on `SimfinityClient`
|
|
227
|
+
|
|
228
|
+
Each method uses `this._types` and `this._ensureInitialized()`.
|
|
229
|
+
|
|
230
|
+
```javascript
|
|
231
|
+
getFieldExtensions(typeName, fieldName) {
|
|
232
|
+
this._ensureInitialized();
|
|
233
|
+
const typeInfo = this._types.get(typeName);
|
|
234
|
+
if (!typeInfo) return null;
|
|
235
|
+
const field = typeInfo.fields.find(f => f.name === fieldName);
|
|
236
|
+
return field?.extensions || null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
getDisplayField(typeName, fieldName) {
|
|
240
|
+
this._ensureInitialized();
|
|
241
|
+
const ext = this.getFieldExtensions(typeName, fieldName);
|
|
242
|
+
const displayField = ext?.relation?.displayField;
|
|
243
|
+
if (displayField) return displayField;
|
|
244
|
+
|
|
245
|
+
// Fallback: get the field's object type, look for 'name', then first scalar
|
|
246
|
+
const typeInfo = this._types.get(typeName);
|
|
247
|
+
const field = typeInfo?.fields.find(f => f.name === fieldName);
|
|
248
|
+
if (!field || field.type.kind !== 'OBJECT') return null;
|
|
249
|
+
|
|
250
|
+
const objectType = this._types.get(field.type.name);
|
|
251
|
+
if (!objectType?.fields) return null;
|
|
252
|
+
|
|
253
|
+
const objFieldNames = objectType.fields.map(f => f.name);
|
|
254
|
+
if (objFieldNames.includes('name')) return 'name';
|
|
255
|
+
|
|
256
|
+
const firstScalar = objectType.fields.find(f =>
|
|
257
|
+
isScalarOrEnum(f.type.kind) && !f.type.isList
|
|
258
|
+
);
|
|
259
|
+
return firstScalar?.name || null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
isEmbeddedField(typeName, fieldName) {
|
|
263
|
+
this._ensureInitialized();
|
|
264
|
+
const ext = this.getFieldExtensions(typeName, fieldName);
|
|
265
|
+
return ext?.relation?.embedded === true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getConnectionField(typeName, fieldName) {
|
|
269
|
+
this._ensureInitialized();
|
|
270
|
+
const ext = this.getFieldExtensions(typeName, fieldName);
|
|
271
|
+
return ext?.relation?.connectionField || null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
isStateMachineField(typeName, fieldName) {
|
|
275
|
+
this._ensureInitialized();
|
|
276
|
+
const ext = this.getFieldExtensions(typeName, fieldName);
|
|
277
|
+
return ext?.stateMachine === true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
isReadOnlyField(typeName, fieldName) {
|
|
281
|
+
this._ensureInitialized();
|
|
282
|
+
const ext = this.getFieldExtensions(typeName, fieldName);
|
|
283
|
+
return ext?.readOnly === true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
getEnumValues(typeName) {
|
|
287
|
+
this._ensureInitialized();
|
|
288
|
+
const typeInfo = this._types.get(typeName);
|
|
289
|
+
if (!typeInfo || typeInfo.kind !== 'ENUM') return [];
|
|
290
|
+
return typeInfo.enumValues || [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
getFieldsOfType(typeName) {
|
|
294
|
+
this._ensureInitialized();
|
|
295
|
+
const typeInfo = this._types.get(typeName);
|
|
296
|
+
if (!typeInfo) return [];
|
|
297
|
+
return typeInfo.fields;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
getDescriptionFieldType(typeName, descriptionField) {
|
|
301
|
+
this._ensureInitialized();
|
|
302
|
+
const typeInfo = this._types.get(typeName);
|
|
303
|
+
if (!typeInfo?.fields) return 'String';
|
|
304
|
+
const field = typeInfo.fields.find(f => f.name === descriptionField);
|
|
305
|
+
if (!field) return 'String';
|
|
306
|
+
return field.type.name || 'String';
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Feature 4: Automatic Selection Set Building
|
|
313
|
+
|
|
314
|
+
### Context
|
|
315
|
+
|
|
316
|
+
The FE package's `buildSelectionSetForObjectType()` in `src/lib/introspection.ts` (lines 190-311) builds a complete GraphQL selection set string plus metadata (columns, sort mappings, field type info) by walking the schema fields of an object type. The client needs an equivalent.
|
|
317
|
+
|
|
318
|
+
### New public method: `buildSelectionSet(typeName)`
|
|
319
|
+
|
|
320
|
+
```javascript
|
|
321
|
+
buildSelectionSet(typeName) {
|
|
322
|
+
this._ensureInitialized();
|
|
323
|
+
const typeInfo = this._types.get(typeName);
|
|
324
|
+
if (!typeInfo?.fields) {
|
|
325
|
+
return { selection: 'id', columns: ['id'], sortFieldByColumn: {}, fieldTypeByColumn: {} };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const columns = [];
|
|
329
|
+
const sortFieldByColumn = {};
|
|
330
|
+
const fieldTypeByColumn = {};
|
|
331
|
+
const fieldSelections = [];
|
|
332
|
+
|
|
333
|
+
for (const field of typeInfo.fields) {
|
|
334
|
+
const { name, type: unwrapped, rawType, extensions } = field;
|
|
335
|
+
|
|
336
|
+
// Skip list fields (collections are handled separately)
|
|
337
|
+
if (isListType(rawType)) continue;
|
|
338
|
+
|
|
339
|
+
if (isScalarOrEnum(unwrapped.kind)) {
|
|
340
|
+
fieldSelections.push(name);
|
|
341
|
+
if (name !== 'id') columns.push(name);
|
|
342
|
+
sortFieldByColumn[name] = name;
|
|
343
|
+
fieldTypeByColumn[name] = unwrapped.name || unwrapped.kind || 'SCALAR';
|
|
344
|
+
} else if (unwrapped.kind === 'OBJECT' && unwrapped.name) {
|
|
345
|
+
const objType = this._types.get(unwrapped.name);
|
|
346
|
+
const objFields = objType?.fields || [];
|
|
347
|
+
const objFieldNames = new Set(objFields.map(f => f.name));
|
|
348
|
+
const isEmbedded = extensions?.relation?.embedded === true;
|
|
349
|
+
|
|
350
|
+
// Determine display field with fallback chain
|
|
351
|
+
let chosenDisplay = null;
|
|
352
|
+
const extDisplay = extensions?.relation?.displayField;
|
|
353
|
+
if (extDisplay && objFieldNames.has(extDisplay)) {
|
|
354
|
+
chosenDisplay = extDisplay;
|
|
355
|
+
} else if (objFieldNames.has('name')) {
|
|
356
|
+
chosenDisplay = 'name';
|
|
357
|
+
} else {
|
|
358
|
+
const firstScalar = objFields.find(f =>
|
|
359
|
+
isScalarOrEnum(f.type.kind) && !f.type.isList
|
|
360
|
+
);
|
|
361
|
+
chosenDisplay = firstScalar?.name || null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Build sub-selection
|
|
365
|
+
const subFields = new Set();
|
|
366
|
+
if (chosenDisplay) subFields.add(chosenDisplay);
|
|
367
|
+
if (!isEmbedded && objFieldNames.has('id')) subFields.add('id');
|
|
368
|
+
if (subFields.size === 0) {
|
|
369
|
+
if (objFieldNames.has('id') && !isEmbedded) subFields.add('id');
|
|
370
|
+
else if (objFieldNames.has('name')) subFields.add('name');
|
|
371
|
+
else if (objFields[0]) subFields.add(objFields[0].name);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const subSelection = [...subFields].join(' ');
|
|
375
|
+
fieldSelections.push(`${name} { ${subSelection} }`);
|
|
376
|
+
columns.push(name);
|
|
377
|
+
|
|
378
|
+
if (chosenDisplay) {
|
|
379
|
+
sortFieldByColumn[name] = `${name}.${chosenDisplay}`;
|
|
380
|
+
const displayFieldInfo = objFields.find(f => f.name === chosenDisplay);
|
|
381
|
+
fieldTypeByColumn[name] = displayFieldInfo?.type?.name || 'OBJECT';
|
|
382
|
+
} else {
|
|
383
|
+
sortFieldByColumn[name] = name;
|
|
384
|
+
fieldTypeByColumn[name] = 'OBJECT';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Ensure id is always in selection
|
|
390
|
+
const hasId = typeInfo.fields.some(f => f.name === 'id');
|
|
391
|
+
if (hasId && !fieldSelections.includes('id')) {
|
|
392
|
+
fieldSelections.unshift('id');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
selection: fieldSelections.join('\n'),
|
|
397
|
+
columns,
|
|
398
|
+
sortFieldByColumn,
|
|
399
|
+
fieldTypeByColumn,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Impact on existing auto-field selection
|
|
405
|
+
|
|
406
|
+
The existing `_getScalarFields()` method remains as-is for backward compatibility. The new `buildSelectionSet()` provides the full metadata-aware alternative.
|
|
407
|
+
|
|
408
|
+
When `QueryBuilder` has no explicit `.fields()` call, it could optionally use `buildSelectionSet()` instead of `_getScalarFields()`. Add an opt-in flag:
|
|
409
|
+
|
|
410
|
+
```javascript
|
|
411
|
+
// In QueryBuilder, add:
|
|
412
|
+
autoSelect() {
|
|
413
|
+
const result = this._client.buildSelectionSet(this._typeName);
|
|
414
|
+
this._selectionTree.addScalars(result.selection);
|
|
415
|
+
this._hasExplicitFields = true;
|
|
416
|
+
this._selectionMeta = result; // store metadata for callers
|
|
417
|
+
return this;
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## Feature 5: Entity & Query Name Resolution
|
|
424
|
+
|
|
425
|
+
### Context
|
|
426
|
+
|
|
427
|
+
The FE frequently resolves between list query names (`"series"`) and type names (`"Serie"`). The client stores `typeName -> queryName` but not the reverse. Also, some internal lookups need to become public.
|
|
428
|
+
|
|
429
|
+
### New public methods
|
|
430
|
+
|
|
431
|
+
```javascript
|
|
432
|
+
getTypeNameForQuery(queryName) {
|
|
433
|
+
this._ensureInitialized();
|
|
434
|
+
const queryInfo = this._queries.get(queryName);
|
|
435
|
+
return queryInfo?.returnType?.name || null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
getPluralQueryName(typeName) {
|
|
439
|
+
this._ensureInitialized();
|
|
440
|
+
return this._typeNameToPlural.get(typeName) || null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
getSingularQueryName(typeName) {
|
|
444
|
+
this._ensureInitialized();
|
|
445
|
+
return this._typeNameToSingular.get(typeName) || null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
getListEntityNames() {
|
|
449
|
+
this._ensureInitialized();
|
|
450
|
+
const names = [];
|
|
451
|
+
for (const [queryName, queryInfo] of this._queries) {
|
|
452
|
+
if (queryInfo.returnType.isList && !queryName.endsWith('_aggregate')) {
|
|
453
|
+
names.push(queryName);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return names.sort();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
getListEntityNamesOfType(typeName) {
|
|
460
|
+
this._ensureInitialized();
|
|
461
|
+
const names = [];
|
|
462
|
+
for (const [queryName, queryInfo] of this._queries) {
|
|
463
|
+
if (queryInfo.returnType.isList
|
|
464
|
+
&& queryInfo.returnType.name === typeName
|
|
465
|
+
&& !queryName.endsWith('_aggregate')) {
|
|
466
|
+
names.push(queryName);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return names.sort();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
getQueryNamesForType(typeName) {
|
|
473
|
+
this._ensureInitialized();
|
|
474
|
+
return {
|
|
475
|
+
pluralQueryName: this._typeNameToPlural.get(typeName) || null,
|
|
476
|
+
singularQueryName: this._typeNameToSingular.get(typeName) || null,
|
|
477
|
+
aggregateQueryName: this._typeNameToAggregate.get(typeName) || null,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### New reverse-lookup registry
|
|
483
|
+
|
|
484
|
+
Add to `_buildQueriesRegistry()`:
|
|
485
|
+
|
|
486
|
+
```javascript
|
|
487
|
+
// After the existing loop, build the reverse map
|
|
488
|
+
this._queryNameToType = new Map();
|
|
489
|
+
for (const [typeName, queryName] of this._typeNameToPlural) {
|
|
490
|
+
this._queryNameToType.set(queryName, typeName);
|
|
491
|
+
}
|
|
492
|
+
for (const [typeName, queryName] of this._typeNameToSingular) {
|
|
493
|
+
this._queryNameToType.set(queryName, typeName);
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Add `this._queryNameToType = new Map();` to the constructor.
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## Feature 6: Mutation Input Transformation
|
|
502
|
+
|
|
503
|
+
### Context
|
|
504
|
+
|
|
505
|
+
The FE's `transformFormDataForMutation` (EntityForm.tsx lines 1139-1256) performs schema-aware data cleaning before sending mutation input. The client should offer this as a utility so consumers don't replicate this logic.
|
|
506
|
+
|
|
507
|
+
### New public method: `transformInput(typeName, rawInput, options?)`
|
|
508
|
+
|
|
509
|
+
```javascript
|
|
510
|
+
transformInput(typeName, rawInput, options = {}) {
|
|
511
|
+
this._ensureInitialized();
|
|
512
|
+
const { skipFields = [], transientFields = [], mode = 'create' } = options;
|
|
513
|
+
const typeInfo = this._types.get(typeName);
|
|
514
|
+
if (!typeInfo) return { ...rawInput };
|
|
515
|
+
|
|
516
|
+
const transformed = {};
|
|
517
|
+
|
|
518
|
+
for (const field of typeInfo.fields) {
|
|
519
|
+
const { name, type: unwrapped, rawType, extensions } = field;
|
|
520
|
+
|
|
521
|
+
// Skip fields not in input
|
|
522
|
+
if (rawInput[name] === undefined) continue;
|
|
523
|
+
|
|
524
|
+
// Skip id field (handled separately by add/update)
|
|
525
|
+
if (name === 'id') continue;
|
|
526
|
+
|
|
527
|
+
// Skip explicitly excluded fields
|
|
528
|
+
if (skipFields.includes(name) || transientFields.includes(name)) continue;
|
|
529
|
+
|
|
530
|
+
// Skip state machine fields
|
|
531
|
+
if (extensions?.stateMachine === true) continue;
|
|
532
|
+
|
|
533
|
+
// Skip read-only fields
|
|
534
|
+
if (extensions?.readOnly === true) continue;
|
|
535
|
+
|
|
536
|
+
// Skip collection fields (handled by transformCollectionDelta)
|
|
537
|
+
if (isListType(rawType) && unwrapped.kind === 'OBJECT') continue;
|
|
538
|
+
|
|
539
|
+
let value = rawInput[name];
|
|
540
|
+
|
|
541
|
+
// Handle embedded objects
|
|
542
|
+
if (unwrapped.kind === 'OBJECT' && extensions?.relation?.embedded === true) {
|
|
543
|
+
value = this._transformEmbeddedInput(unwrapped.name, value, options);
|
|
544
|
+
}
|
|
545
|
+
// Handle FK reference objects (non-embedded)
|
|
546
|
+
else if (unwrapped.kind === 'OBJECT' && !extensions?.relation?.embedded) {
|
|
547
|
+
value = this._cleanObjectFieldForMutation(value);
|
|
548
|
+
}
|
|
549
|
+
// Type coercion for scalars
|
|
550
|
+
else {
|
|
551
|
+
value = this._coerceScalarValue(value, unwrapped.name);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
555
|
+
transformed[name] = value;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Deep-clean __typename from all levels
|
|
560
|
+
return this._deepRemoveTypename(transformed);
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Private helper: `_transformEmbeddedInput(objectTypeName, value, options)`
|
|
565
|
+
|
|
566
|
+
```javascript
|
|
567
|
+
_transformEmbeddedInput(objectTypeName, value, options = {}) {
|
|
568
|
+
if (!value || typeof value !== 'object') return value;
|
|
569
|
+
const typeInfo = this._types.get(objectTypeName);
|
|
570
|
+
if (!typeInfo?.fields) return value;
|
|
571
|
+
|
|
572
|
+
const cleaned = {};
|
|
573
|
+
for (const field of typeInfo.fields) {
|
|
574
|
+
const { name, type: unwrapped, extensions } = field;
|
|
575
|
+
if (value[name] === undefined) continue;
|
|
576
|
+
if (extensions?.readOnly === true) continue;
|
|
577
|
+
|
|
578
|
+
let fieldValue = value[name];
|
|
579
|
+
fieldValue = this._coerceScalarValue(fieldValue, unwrapped.name);
|
|
580
|
+
|
|
581
|
+
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
|
|
582
|
+
cleaned[name] = fieldValue;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### Private helper: `_cleanObjectFieldForMutation(value)`
|
|
590
|
+
|
|
591
|
+
```javascript
|
|
592
|
+
_cleanObjectFieldForMutation(value) {
|
|
593
|
+
if (!value) return value;
|
|
594
|
+
// String ID: wrap in { id }
|
|
595
|
+
if (typeof value === 'string') return { id: value };
|
|
596
|
+
// Object with id: extract only id
|
|
597
|
+
if (typeof value === 'object' && value.id) return { id: value.id };
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Private helper: `_coerceScalarValue(value, scalarTypeName)`
|
|
603
|
+
|
|
604
|
+
```javascript
|
|
605
|
+
_coerceScalarValue(value, scalarTypeName) {
|
|
606
|
+
if (value === null || value === undefined) return value;
|
|
607
|
+
|
|
608
|
+
// Numeric coercion
|
|
609
|
+
if (isNumericScalar(scalarTypeName) && typeof value === 'string') {
|
|
610
|
+
return Number(value);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Date coercion: YYYY-MM-DD -> ISO DateTime
|
|
614
|
+
if (isDateTimeScalar(scalarTypeName) && typeof value === 'string') {
|
|
615
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
616
|
+
return `${value}T00:00:00.000Z`;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return value;
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### Private helper: `_deepRemoveTypename(obj)`
|
|
625
|
+
|
|
626
|
+
```javascript
|
|
627
|
+
_deepRemoveTypename(obj) {
|
|
628
|
+
if (typeof obj !== 'object' || obj === null) return obj;
|
|
629
|
+
if (Array.isArray(obj)) return obj.map(item => this._deepRemoveTypename(item));
|
|
630
|
+
|
|
631
|
+
const cleaned = {};
|
|
632
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
633
|
+
if (key === '__typename') continue;
|
|
634
|
+
cleaned[key] = this._deepRemoveTypename(value);
|
|
635
|
+
}
|
|
636
|
+
return cleaned;
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### Integration with existing `add()` and `update()` methods
|
|
641
|
+
|
|
642
|
+
Add an optional `{ transform: true }` options parameter:
|
|
643
|
+
|
|
644
|
+
```javascript
|
|
645
|
+
async add(typeName, input, fields, options = {}) {
|
|
646
|
+
this._ensureInitialized();
|
|
647
|
+
const transformedInput = options.transform
|
|
648
|
+
? this.transformInput(typeName, input, { mode: 'create', ...options })
|
|
649
|
+
: input;
|
|
650
|
+
return this._executeMutation(`add${typeName}`, typeName, { input: transformedInput }, fields);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async update(typeName, id, input, fields, options = {}) {
|
|
654
|
+
this._ensureInitialized();
|
|
655
|
+
const transformedInput = options.transform
|
|
656
|
+
? this.transformInput(typeName, input, { mode: 'update', ...options })
|
|
657
|
+
: input;
|
|
658
|
+
return this._executeMutation(
|
|
659
|
+
`update${typeName}`, typeName,
|
|
660
|
+
{ input: { id, ...transformedInput } },
|
|
661
|
+
fields,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
## Feature 7: Collection Delta Mutations
|
|
669
|
+
|
|
670
|
+
### Context
|
|
671
|
+
|
|
672
|
+
The FE's `transformCollectionDataForMutation` (EntityForm.tsx lines 1027-1123) builds the `{ added: [...], updated: [...], deleted: [...] }` structure for collection field mutations. Each item goes through type coercion, object field cleanup, metadata stripping, and `__typename` removal.
|
|
673
|
+
|
|
674
|
+
### New public method: `transformCollectionDelta(collectionTypeName, delta, options?)`
|
|
675
|
+
|
|
676
|
+
```javascript
|
|
677
|
+
transformCollectionDelta(collectionTypeName, delta, options = {}) {
|
|
678
|
+
this._ensureInitialized();
|
|
679
|
+
const { connectionField = null } = options;
|
|
680
|
+
const result = {};
|
|
681
|
+
|
|
682
|
+
if (delta.added && delta.added.length > 0) {
|
|
683
|
+
result.added = delta.added.map(item => {
|
|
684
|
+
let clean = { ...item };
|
|
685
|
+
|
|
686
|
+
// Remove metadata
|
|
687
|
+
delete clean.__status;
|
|
688
|
+
delete clean.__originalData;
|
|
689
|
+
|
|
690
|
+
// Remove connection field
|
|
691
|
+
if (connectionField && clean[connectionField] !== undefined) {
|
|
692
|
+
delete clean[connectionField];
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Remove temporary IDs
|
|
696
|
+
if (clean.id && typeof clean.id === 'string' && clean.id.startsWith('temp_')) {
|
|
697
|
+
delete clean.id;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Type coercion and object field cleanup
|
|
701
|
+
clean = this._cleanCollectionItem(collectionTypeName, clean);
|
|
702
|
+
clean = this._deepRemoveTypename(clean);
|
|
703
|
+
return clean;
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (delta.updated && delta.updated.length > 0) {
|
|
708
|
+
result.updated = delta.updated.map(item => {
|
|
709
|
+
let clean = { ...item };
|
|
710
|
+
|
|
711
|
+
// Remove metadata
|
|
712
|
+
delete clean.__status;
|
|
713
|
+
delete clean.__originalData;
|
|
714
|
+
|
|
715
|
+
// Remove connection field
|
|
716
|
+
if (connectionField && clean[connectionField] !== undefined) {
|
|
717
|
+
delete clean[connectionField];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Type coercion and object field cleanup (keep real ID)
|
|
721
|
+
clean = this._cleanCollectionItem(collectionTypeName, clean);
|
|
722
|
+
clean = this._deepRemoveTypename(clean);
|
|
723
|
+
return clean;
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (delta.deleted && delta.deleted.length > 0) {
|
|
728
|
+
result.deleted = delta.deleted.map(item => {
|
|
729
|
+
if (typeof item === 'string') return item;
|
|
730
|
+
if (typeof item === 'object' && item.id) return item.id;
|
|
731
|
+
return item;
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### Private helper: `_cleanCollectionItem(collectionTypeName, item)`
|
|
740
|
+
|
|
741
|
+
```javascript
|
|
742
|
+
_cleanCollectionItem(collectionTypeName, item) {
|
|
743
|
+
const typeInfo = this._types.get(collectionTypeName);
|
|
744
|
+
if (!typeInfo?.fields) return item;
|
|
745
|
+
|
|
746
|
+
const cleaned = { ...item };
|
|
747
|
+
|
|
748
|
+
for (const field of typeInfo.fields) {
|
|
749
|
+
const { name, type: unwrapped, rawType, extensions } = field;
|
|
750
|
+
if (cleaned[name] === undefined) continue;
|
|
751
|
+
|
|
752
|
+
// Remove state machine fields
|
|
753
|
+
if (extensions?.stateMachine === true) {
|
|
754
|
+
delete cleaned[name];
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Clean non-embedded object fields: extract { id } only
|
|
759
|
+
if (unwrapped.kind === 'OBJECT' && !extensions?.relation?.embedded) {
|
|
760
|
+
const val = cleaned[name];
|
|
761
|
+
if (val && typeof val === 'object' && 'id' in val) {
|
|
762
|
+
cleaned[name] = { id: val.id };
|
|
763
|
+
}
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Type coercion for scalars
|
|
768
|
+
if (isNumericScalar(unwrapped.name) && typeof cleaned[name] === 'string') {
|
|
769
|
+
cleaned[name] = Number(cleaned[name]);
|
|
770
|
+
}
|
|
771
|
+
if (isDateTimeScalar(unwrapped.name) && typeof cleaned[name] === 'string') {
|
|
772
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(cleaned[name])) {
|
|
773
|
+
cleaned[name] = `${cleaned[name]}T00:00:00.000Z`;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return cleaned;
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
### Usage pattern (how the FE would use this)
|
|
783
|
+
|
|
784
|
+
```javascript
|
|
785
|
+
const collectionChanges = {
|
|
786
|
+
seasons: {
|
|
787
|
+
added: [{ number: '3', year: '2025', serie: { id: 'abc' } }],
|
|
788
|
+
updated: [{ id: 'real-id', number: '2', year: '2024' }],
|
|
789
|
+
deleted: [{ id: 'del-id' }]
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// For each collection field:
|
|
794
|
+
for (const [fieldName, delta] of Object.entries(collectionChanges)) {
|
|
795
|
+
const connectionField = client.getConnectionField('Serie', fieldName);
|
|
796
|
+
const collectionTypeName = /* resolve from schema */ 'Season';
|
|
797
|
+
updateInput[fieldName] = client.transformCollectionDelta(collectionTypeName, delta, {
|
|
798
|
+
connectionField,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
805
|
+
## Feature 8: Collection Item Queries (findByParent)
|
|
806
|
+
|
|
807
|
+
### Context
|
|
808
|
+
|
|
809
|
+
The FE's `CollectionFieldGrid` (lines 196-260 of `CollectionFieldGrid.tsx`) builds queries to fetch collection items filtered by their parent entity. The filter uses the `connectionField` with `terms: { path: "id", operator: EQ, value: parentId }` pattern.
|
|
810
|
+
|
|
811
|
+
### New public method: `findByParent(typeName, connectionField, parentId)`
|
|
812
|
+
|
|
813
|
+
```javascript
|
|
814
|
+
findByParent(typeName, connectionField, parentId) {
|
|
815
|
+
this._ensureInitialized();
|
|
816
|
+
const builder = new QueryBuilder(this, typeName);
|
|
817
|
+
// Apply the connection field filter using the terms pattern
|
|
818
|
+
builder.where(connectionField, [{ path: 'id', operator: 'EQ', value: parentId }]);
|
|
819
|
+
// Default sort by id ASC
|
|
820
|
+
builder.sort('id', 'ASC');
|
|
821
|
+
return builder;
|
|
822
|
+
}
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### Usage pattern
|
|
826
|
+
|
|
827
|
+
```javascript
|
|
828
|
+
const seasons = await client
|
|
829
|
+
.findByParent('Season', 'serie', serieId)
|
|
830
|
+
.page(1, 10, true)
|
|
831
|
+
.where('id', 'NIN', ['modified-id-1', 'deleted-id-2']) // exclude modified/deleted
|
|
832
|
+
.fields('id number year state')
|
|
833
|
+
.exec();
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
The existing `QueryBuilder.where()` already handles `NIN` and other operators, so the chain works naturally.
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
## Feature 9: FK/Relation Search Queries
|
|
841
|
+
|
|
842
|
+
### Context
|
|
843
|
+
|
|
844
|
+
The FE's `ObjectFieldSelector` (lines 102-130 of `ObjectFieldSelector.tsx`) builds search-as-you-type queries for FK reference fields. It uses the `displayField` to determine which field to search, selects `LIKE` for strings and `EQ` for other types, and casts the search term to the appropriate type.
|
|
845
|
+
|
|
846
|
+
### New public method: `search(typeName, searchTerm, options?)`
|
|
847
|
+
|
|
848
|
+
```javascript
|
|
849
|
+
async search(typeName, searchTerm, options = {}) {
|
|
850
|
+
this._ensureInitialized();
|
|
851
|
+
const { page = 1, size = 10, displayField = null } = options;
|
|
852
|
+
|
|
853
|
+
// Resolve the display field for this type
|
|
854
|
+
// The displayField here refers to the field on the target type to search by.
|
|
855
|
+
// The caller can provide it, or we try to resolve it from the type's fields.
|
|
856
|
+
let searchField = displayField;
|
|
857
|
+
if (!searchField) {
|
|
858
|
+
// Try to find a reasonable display field from the type itself
|
|
859
|
+
const typeInfo = this._types.get(typeName);
|
|
860
|
+
if (typeInfo?.fields) {
|
|
861
|
+
const nameField = typeInfo.fields.find(f => f.name === 'name');
|
|
862
|
+
if (nameField) {
|
|
863
|
+
searchField = 'name';
|
|
864
|
+
} else {
|
|
865
|
+
const firstScalar = typeInfo.fields.find(f =>
|
|
866
|
+
isScalarOrEnum(f.type.kind) && f.name !== 'id'
|
|
867
|
+
);
|
|
868
|
+
searchField = firstScalar?.name || 'id';
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Determine search field type for operator selection and term casting
|
|
874
|
+
const typeInfo = this._types.get(typeName);
|
|
875
|
+
const fieldInfo = typeInfo?.fields.find(f => f.name === searchField);
|
|
876
|
+
const fieldTypeName = fieldInfo?.type?.name || 'String';
|
|
877
|
+
|
|
878
|
+
// Select operator: LIKE for strings, EQ for others
|
|
879
|
+
const isString = !isNumericScalar(fieldTypeName) && !isBooleanScalar(fieldTypeName);
|
|
880
|
+
const operator = isString ? 'LIKE' : 'EQ';
|
|
881
|
+
|
|
882
|
+
// Cast search term to proper type
|
|
883
|
+
let castedTerm = searchTerm;
|
|
884
|
+
if (isNumericScalar(fieldTypeName)) {
|
|
885
|
+
const actualType = getActualScalarType(fieldTypeName);
|
|
886
|
+
if (actualType?.toLowerCase() === 'int') {
|
|
887
|
+
const parsed = parseInt(searchTerm, 10);
|
|
888
|
+
if (!isNaN(parsed)) castedTerm = parsed;
|
|
889
|
+
} else {
|
|
890
|
+
const parsed = parseFloat(searchTerm);
|
|
891
|
+
if (!isNaN(parsed)) castedTerm = parsed;
|
|
892
|
+
}
|
|
893
|
+
} else if (isBooleanScalar(fieldTypeName)) {
|
|
894
|
+
if (searchTerm.toLowerCase() === 'true') castedTerm = true;
|
|
895
|
+
else if (searchTerm.toLowerCase() === 'false') castedTerm = false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Build and execute the query
|
|
899
|
+
const builder = this.find(typeName)
|
|
900
|
+
.fields(`id ${searchField}`)
|
|
901
|
+
.page(page, size, false)
|
|
902
|
+
.where(searchField, operator, castedTerm);
|
|
903
|
+
|
|
904
|
+
return builder.exec();
|
|
905
|
+
}
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
### Usage pattern
|
|
909
|
+
|
|
910
|
+
```javascript
|
|
911
|
+
// Search for genres by name
|
|
912
|
+
const results = await client.search('Genre', 'com', {
|
|
913
|
+
displayField: 'name',
|
|
914
|
+
page: 1,
|
|
915
|
+
size: 10,
|
|
916
|
+
});
|
|
917
|
+
// returns: [{ id: '1', name: 'Comedy' }, { id: '2', name: 'Romantic Comedy' }]
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
---
|
|
921
|
+
|
|
922
|
+
## Feature 10: State Machine Metadata
|
|
923
|
+
|
|
924
|
+
### Context
|
|
925
|
+
|
|
926
|
+
The client already classifies mutations as `stateTransition` in `_classifyMutation()` (lines 428-456). However, it doesn't expose which fields are state machine managed (from extensions) or which transitions are available per type.
|
|
927
|
+
|
|
928
|
+
### New public methods
|
|
929
|
+
|
|
930
|
+
```javascript
|
|
931
|
+
getStateMachineFields(typeName) {
|
|
932
|
+
this._ensureInitialized();
|
|
933
|
+
const typeInfo = this._types.get(typeName);
|
|
934
|
+
if (!typeInfo?.fields) return [];
|
|
935
|
+
return typeInfo.fields
|
|
936
|
+
.filter(f => f.extensions?.stateMachine === true)
|
|
937
|
+
.map(f => f.name);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
getAvailableTransitions(typeName) {
|
|
941
|
+
this._ensureInitialized();
|
|
942
|
+
const transitions = [];
|
|
943
|
+
for (const info of this._mutations.values()) {
|
|
944
|
+
if (info.category === 'stateTransition' && info.typeName === typeName) {
|
|
945
|
+
transitions.push({
|
|
946
|
+
action: info.action,
|
|
947
|
+
mutationName: info.name,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return transitions;
|
|
952
|
+
}
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
### Usage pattern
|
|
956
|
+
|
|
957
|
+
```javascript
|
|
958
|
+
// Check which fields are state machine managed
|
|
959
|
+
const smFields = client.getStateMachineFields('Season');
|
|
960
|
+
// returns: ['state']
|
|
961
|
+
|
|
962
|
+
// Get available transitions
|
|
963
|
+
const transitions = client.getAvailableTransitions('Season');
|
|
964
|
+
// returns: [{ action: 'activate', mutationName: 'activateSeason' }, ...]
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
## Feature 11: Response Extensions (Pagination Count)
|
|
970
|
+
|
|
971
|
+
### Context
|
|
972
|
+
|
|
973
|
+
The FE extracts pagination count from `result.extensions.count`. The client currently discards response extensions. Both `QueryBuilder.exec()` and `_sendRequest()` need to preserve them.
|
|
974
|
+
|
|
975
|
+
### Changes to `QueryBuilder.exec()` (lines 179-219)
|
|
976
|
+
|
|
977
|
+
Change the return value to include extensions:
|
|
978
|
+
|
|
979
|
+
```javascript
|
|
980
|
+
async exec() {
|
|
981
|
+
// ... existing query building code ...
|
|
982
|
+
|
|
983
|
+
const response = await this._client._sendRequest(query, variables);
|
|
984
|
+
if (response.errors) {
|
|
985
|
+
const error = new Error(`GraphQL errors: ${JSON.stringify(response.errors)}`);
|
|
986
|
+
error.graphQLErrors = response.errors;
|
|
987
|
+
throw error;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const result = response.data[queryInfo.name];
|
|
991
|
+
|
|
992
|
+
// Attach extensions if present (especially pagination count)
|
|
993
|
+
if (response.extensions) {
|
|
994
|
+
result.__extensions = response.extensions;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return result;
|
|
998
|
+
}
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
### Alternative: return a wrapper object
|
|
1002
|
+
|
|
1003
|
+
For a cleaner API, the builder could have a `.execWithMeta()` variant:
|
|
1004
|
+
|
|
1005
|
+
```javascript
|
|
1006
|
+
async execWithMeta() {
|
|
1007
|
+
// ... same query building ...
|
|
1008
|
+
const response = await this._client._sendRequest(query, variables);
|
|
1009
|
+
if (response.errors) {
|
|
1010
|
+
const error = new Error(`GraphQL errors: ${JSON.stringify(response.errors)}`);
|
|
1011
|
+
error.graphQLErrors = response.errors;
|
|
1012
|
+
throw error;
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
data: response.data[queryInfo.name],
|
|
1016
|
+
extensions: response.extensions || null,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
### Usage pattern
|
|
1022
|
+
|
|
1023
|
+
```javascript
|
|
1024
|
+
const { data: series, extensions } = await client
|
|
1025
|
+
.find('Serie')
|
|
1026
|
+
.page(1, 10, true)
|
|
1027
|
+
.execWithMeta();
|
|
1028
|
+
|
|
1029
|
+
const totalCount = extensions?.count;
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
---
|
|
1033
|
+
|
|
1034
|
+
## Summary of New Public API Methods
|
|
1035
|
+
|
|
1036
|
+
### Schema Metadata (Feature 1 + 2 + 3)
|
|
1037
|
+
|
|
1038
|
+
| Method | Signature | Description |
|
|
1039
|
+
|--------|-----------|-------------|
|
|
1040
|
+
| `getFieldExtensions` | `(typeName, fieldName) => extensions \| null` | Returns `{ relation, stateMachine, readOnly }` |
|
|
1041
|
+
| `getDisplayField` | `(typeName, fieldName) => string \| null` | Display field with fallback chain |
|
|
1042
|
+
| `isEmbeddedField` | `(typeName, fieldName) => boolean` | Is field an embedded object? |
|
|
1043
|
+
| `getConnectionField` | `(typeName, fieldName) => string \| null` | Back-reference field for collections |
|
|
1044
|
+
| `isStateMachineField` | `(typeName, fieldName) => boolean` | Is field state-machine managed? |
|
|
1045
|
+
| `isReadOnlyField` | `(typeName, fieldName) => boolean` | Is field read-only? |
|
|
1046
|
+
| `getEnumValues` | `(typeName) => string[]` | Enum values for an enum type |
|
|
1047
|
+
| `getFieldsOfType` | `(typeName) => fieldInfo[]` | All fields with full metadata |
|
|
1048
|
+
| `getDescriptionFieldType` | `(typeName, descField) => string` | Scalar type of a display field |
|
|
1049
|
+
| `getActualScalarType` | `(scalarName) => string \| null` | Strip validated scalar prefix |
|
|
1050
|
+
| `isNumericScalar` | `(scalarName) => boolean` | Detect Int/Float |
|
|
1051
|
+
| `isBooleanScalar` | `(scalarName) => boolean` | Detect Boolean |
|
|
1052
|
+
| `isDateTimeScalar` | `(scalarName) => boolean` | Detect Date/DateTime/Timestamp |
|
|
1053
|
+
|
|
1054
|
+
### Selection Set Building (Feature 4)
|
|
1055
|
+
|
|
1056
|
+
| Method | Signature | Description |
|
|
1057
|
+
|--------|-----------|-------------|
|
|
1058
|
+
| `buildSelectionSet` | `(typeName) => { selection, columns, sortFieldByColumn, fieldTypeByColumn }` | Full auto selection set |
|
|
1059
|
+
|
|
1060
|
+
### Entity Resolution (Feature 5)
|
|
1061
|
+
|
|
1062
|
+
| Method | Signature | Description |
|
|
1063
|
+
|--------|-----------|-------------|
|
|
1064
|
+
| `getTypeNameForQuery` | `(queryName) => string \| null` | Reverse: query name -> type name |
|
|
1065
|
+
| `getPluralQueryName` | `(typeName) => string \| null` | Type -> plural query name |
|
|
1066
|
+
| `getSingularQueryName` | `(typeName) => string \| null` | Type -> singular query name |
|
|
1067
|
+
| `getListEntityNames` | `() => string[]` | All list entity query names |
|
|
1068
|
+
| `getListEntityNamesOfType` | `(typeName) => string[]` | List queries returning a type |
|
|
1069
|
+
| `getQueryNamesForType` | `(typeName) => { plural, singular, aggregate }` | All query names for a type |
|
|
1070
|
+
|
|
1071
|
+
### Data Transformation (Feature 6 + 7)
|
|
1072
|
+
|
|
1073
|
+
| Method | Signature | Description |
|
|
1074
|
+
|--------|-----------|-------------|
|
|
1075
|
+
| `transformInput` | `(typeName, rawInput, options?) => cleaned` | Schema-aware mutation input transform |
|
|
1076
|
+
| `transformCollectionDelta` | `(typeName, { added, updated, deleted }, options?) => delta` | Collection mutation delta builder |
|
|
1077
|
+
|
|
1078
|
+
### Querying (Feature 8 + 9)
|
|
1079
|
+
|
|
1080
|
+
| Method | Signature | Description |
|
|
1081
|
+
|--------|-----------|-------------|
|
|
1082
|
+
| `findByParent` | `(typeName, connectionField, parentId) => QueryBuilder` | Pre-filtered collection query |
|
|
1083
|
+
| `search` | `(typeName, searchTerm, options?) => results` | FK field search-as-you-type |
|
|
1084
|
+
|
|
1085
|
+
### State Machine (Feature 10)
|
|
1086
|
+
|
|
1087
|
+
| Method | Signature | Description |
|
|
1088
|
+
|--------|-----------|-------------|
|
|
1089
|
+
| `getStateMachineFields` | `(typeName) => string[]` | Fields managed by state machine |
|
|
1090
|
+
| `getAvailableTransitions` | `(typeName) => { action, mutationName }[]` | Available state transitions |
|
|
1091
|
+
|
|
1092
|
+
### Response (Feature 11)
|
|
1093
|
+
|
|
1094
|
+
| Method | Signature | Description |
|
|
1095
|
+
|--------|-----------|-------------|
|
|
1096
|
+
| `execWithMeta` | `(on QueryBuilder) => { data, extensions }` | Query execution with response metadata |
|
|
1097
|
+
|
|
1098
|
+
---
|
|
1099
|
+
|
|
1100
|
+
## Implementation Order
|
|
1101
|
+
|
|
1102
|
+
Implement in this order (each depends on the previous):
|
|
1103
|
+
|
|
1104
|
+
1. **Feature 1** - Introspection extensions (foundational)
|
|
1105
|
+
2. **Feature 2** - Scalar type utilities (used by everything below)
|
|
1106
|
+
3. **Feature 3** - Schema metadata API (used by Features 4-10)
|
|
1107
|
+
4. **Feature 4** - Auto selection set building
|
|
1108
|
+
5. **Feature 5** - Entity/query name resolution
|
|
1109
|
+
6. **Feature 6** - Mutation input transformation
|
|
1110
|
+
7. **Feature 7** - Collection delta mutations
|
|
1111
|
+
8. **Feature 8** - Collection item queries
|
|
1112
|
+
9. **Feature 9** - FK search queries
|
|
1113
|
+
10. **Feature 10** - State machine metadata
|
|
1114
|
+
11. **Feature 11** - Response extensions
|
|
1115
|
+
|
|
1116
|
+
---
|
|
1117
|
+
|
|
1118
|
+
## Testing Guidance
|
|
1119
|
+
|
|
1120
|
+
For each feature, test against a running Simfinity backend. Key scenarios:
|
|
1121
|
+
|
|
1122
|
+
1. **Introspection**: Verify `extensions` data is populated for fields that have relations, state machines, or read-only flags.
|
|
1123
|
+
2. **Scalar utilities**: Test `SeasonNumber_Int` -> `Int`, `StartDate_Date` -> `Date`, etc.
|
|
1124
|
+
3. **Selection set**: Compare generated selection strings against what the FE currently builds in `buildSelectionSetForObjectType()`.
|
|
1125
|
+
4. **Transform**: Submit `add`/`update` mutations with `transform: true` and verify the backend accepts the cleaned input.
|
|
1126
|
+
5. **Collection delta**: Create parent entity update with collection changes and verify `added`/`updated`/`deleted` structure.
|
|
1127
|
+
6. **findByParent**: Query collection items filtered by parent ID and verify connection field filtering works.
|
|
1128
|
+
7. **search**: Search FK reference entities and verify `LIKE`/`EQ` operator selection and term casting.
|
|
1129
|
+
|
|
1130
|
+
---
|
|
1131
|
+
|
|
1132
|
+
## Backward Compatibility Notes
|
|
1133
|
+
|
|
1134
|
+
- All new methods are additive; no existing methods are removed or have their signatures changed (except optional trailing `options` params on `add`/`update`).
|
|
1135
|
+
- The `transform` option on `add()`/`update()` defaults to `false`, so existing callers are unaffected.
|
|
1136
|
+
- The `execWithMeta()` method is new and separate from `exec()`, preserving the existing return format.
|
|
1137
|
+
- The `QueryBuilder.autoSelect()` method is opt-in; existing `.fields()` behavior is unchanged.
|