@simtlix/simfinity-js-client 1.0.0 → 1.0.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.
@@ -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.