@auto-engineer/narrative 1.3.4 → 1.4.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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +6 -6
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/loader/ts-utils.d.ts +2 -2
- package/dist/src/loader/ts-utils.d.ts.map +1 -1
- package/dist/src/loader/ts-utils.js +7 -1
- package/dist/src/loader/ts-utils.js.map +1 -1
- package/dist/src/schema.d.ts +226 -20
- package/dist/src/schema.d.ts.map +1 -1
- package/dist/src/schema.js +6 -3
- package/dist/src/schema.js.map +1 -1
- package/dist/src/transformers/model-to-narrative/generators/imports.d.ts.map +1 -1
- package/dist/src/transformers/model-to-narrative/generators/imports.js +1 -0
- package/dist/src/transformers/model-to-narrative/generators/imports.js.map +1 -1
- package/dist/src/transformers/model-to-narrative/generators/types.d.ts +1 -1
- package/dist/src/transformers/model-to-narrative/generators/types.d.ts.map +1 -1
- package/dist/src/transformers/model-to-narrative/generators/types.js +2 -1
- package/dist/src/transformers/model-to-narrative/generators/types.js.map +1 -1
- package/dist/src/transformers/narrative-to-model/index.js.map +1 -1
- package/dist/src/transformers/narrative-to-model/messages.d.ts +1 -1
- package/dist/src/transformers/narrative-to-model/messages.d.ts.map +1 -1
- package/dist/src/transformers/narrative-to-model/messages.js +9 -1
- package/dist/src/transformers/narrative-to-model/messages.js.map +1 -1
- package/dist/src/transformers/narrative-to-model/spec-processors.d.ts +22 -1
- package/dist/src/transformers/narrative-to-model/spec-processors.d.ts.map +1 -1
- package/dist/src/transformers/narrative-to-model/spec-processors.js +81 -0
- package/dist/src/transformers/narrative-to-model/spec-processors.js.map +1 -1
- package/dist/src/transformers/narrative-to-model/type-inference.d.ts +1 -1
- package/dist/src/transformers/narrative-to-model/type-inference.d.ts.map +1 -1
- package/dist/src/transformers/narrative-to-model/type-inference.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +4 -0
- package/src/loader/ts-utils.ts +16 -10
- package/src/model-to-narrative.specs.ts +53 -0
- package/src/schema.ts +7 -2
- package/src/transformers/model-to-narrative/generators/imports.ts +1 -0
- package/src/transformers/model-to-narrative/generators/types.ts +4 -5
- package/src/transformers/narrative-to-model/index.ts +5 -5
- package/src/transformers/narrative-to-model/messages.ts +12 -3
- package/src/transformers/narrative-to-model/spec-processors.specs.ts +241 -0
- package/src/transformers/narrative-to-model/spec-processors.ts +91 -4
- package/src/transformers/narrative-to-model/type-inference.ts +4 -4
- package/src/types.ts +5 -0
package/package.json
CHANGED
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"typescript": "^5.9.2",
|
|
24
24
|
"zod": "^3.22.4",
|
|
25
25
|
"zod-to-json-schema": "^3.22.3",
|
|
26
|
-
"@auto-engineer/
|
|
27
|
-
"@auto-engineer/
|
|
28
|
-
"@auto-engineer/message-bus": "1.
|
|
26
|
+
"@auto-engineer/file-store": "1.4.0",
|
|
27
|
+
"@auto-engineer/id": "1.4.0",
|
|
28
|
+
"@auto-engineer/message-bus": "1.4.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^20.0.0",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
|
-
"version": "1.
|
|
38
|
+
"version": "1.4.0",
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "tsx scripts/build.ts",
|
|
41
41
|
"test": "vitest run --reporter=dot",
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type {
|
|
|
19
19
|
Integration,
|
|
20
20
|
MessageTarget,
|
|
21
21
|
Origin,
|
|
22
|
+
Query,
|
|
22
23
|
State,
|
|
23
24
|
} from './types';
|
|
24
25
|
export { createIntegration } from './types';
|
|
@@ -81,6 +82,7 @@ export {
|
|
|
81
82
|
modelSchema,
|
|
82
83
|
NarrativeNamesSchema as NarrativeNamesSystemSchema,
|
|
83
84
|
NarrativeSchema,
|
|
85
|
+
QuerySchema,
|
|
84
86
|
QuerySliceSchema,
|
|
85
87
|
ReactSliceSchema,
|
|
86
88
|
RuleSchema,
|
|
@@ -135,3 +137,5 @@ export type MessageRef = z.infer<typeof MessageRefSchema>;
|
|
|
135
137
|
// ID assignment utilities
|
|
136
138
|
export { addAutoIds, hasAllIds } from './id';
|
|
137
139
|
export type { ClientSpecNode } from './schema';
|
|
140
|
+
|
|
141
|
+
export { detectQueryAction, extractQueryNameFromRequest } from './transformers/narrative-to-model/spec-processors';
|
package/src/loader/ts-utils.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function parseImports(ts: typeof import('typescript'), fileName: string,
|
|
|
77
77
|
|
|
78
78
|
export interface TypeInfo {
|
|
79
79
|
stringLiteral: string;
|
|
80
|
-
classification?: 'command' | 'event' | 'state';
|
|
80
|
+
classification?: 'command' | 'event' | 'state' | 'query';
|
|
81
81
|
dataFields?: { name: string; type: string; required: boolean }[];
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -93,7 +93,7 @@ export interface GivenTypeInfo {
|
|
|
93
93
|
column: number;
|
|
94
94
|
ordinal: number; // Sequential position within the file
|
|
95
95
|
typeName: string;
|
|
96
|
-
classification: 'command' | 'event' | 'state';
|
|
96
|
+
classification: 'command' | 'event' | 'state' | 'query';
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
function extractDataFieldsFromTypeLiteral(
|
|
@@ -136,7 +136,7 @@ function processTypeAlias(
|
|
|
136
136
|
const baseName = getBaseName(typeRef.typeName);
|
|
137
137
|
if (typeof baseName !== 'string') return;
|
|
138
138
|
|
|
139
|
-
if (!['Command', 'Event', 'State'].includes(baseName)) return;
|
|
139
|
+
if (!['Command', 'Event', 'State', 'Query'].includes(baseName)) return;
|
|
140
140
|
|
|
141
141
|
const typeArgs = typeRef.typeArguments ?? [];
|
|
142
142
|
if (typeArgs.length === 0) return;
|
|
@@ -145,7 +145,7 @@ function processTypeAlias(
|
|
|
145
145
|
if (!ts.isLiteralTypeNode(firstArg) || !ts.isStringLiteral(firstArg.literal)) return;
|
|
146
146
|
|
|
147
147
|
const stringLiteral = firstArg.literal.text;
|
|
148
|
-
const classification = baseName.toLowerCase() as 'command' | 'event' | 'state';
|
|
148
|
+
const classification = baseName.toLowerCase() as 'command' | 'event' | 'state' | 'query';
|
|
149
149
|
|
|
150
150
|
// Try to extract the data fields from the 2nd generic arg (if present)
|
|
151
151
|
let dataFields: { name: string; type: string; required: boolean }[] | undefined;
|
|
@@ -271,7 +271,7 @@ function extractDataFields(
|
|
|
271
271
|
return dataFields;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
function inferClassificationFromName(stringLiteral: string): 'command' | 'event' | 'state' | undefined {
|
|
274
|
+
function inferClassificationFromName(stringLiteral: string): 'command' | 'event' | 'state' | 'query' | undefined {
|
|
275
275
|
const eventPatterns = ['ed', 'Created', 'Updated', 'Deleted', 'Placed', 'Added', 'Removed', 'Changed'];
|
|
276
276
|
if (eventPatterns.some((pattern) => stringLiteral.endsWith(pattern))) {
|
|
277
277
|
return 'event';
|
|
@@ -282,6 +282,11 @@ function inferClassificationFromName(stringLiteral: string): 'command' | 'event'
|
|
|
282
282
|
return 'command';
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// Query patterns: View*, Get*, List*, Find*, Search*, Fetch*
|
|
286
|
+
if (/^(View|Get|List|Find|Search|Fetch)[A-Z]/.test(stringLiteral)) {
|
|
287
|
+
return 'query';
|
|
288
|
+
}
|
|
289
|
+
|
|
285
290
|
const statePatterns = ['Summary', 'View', 'Items', 'List', 'Data', 'Info'];
|
|
286
291
|
if (statePatterns.some((pattern) => stringLiteral.endsWith(pattern))) {
|
|
287
292
|
return 'state';
|
|
@@ -463,7 +468,7 @@ function classifyBaseGeneric(
|
|
|
463
468
|
ts: typeof import('typescript'),
|
|
464
469
|
checker: import('typescript').TypeChecker,
|
|
465
470
|
typeRef: import('typescript').TypeReferenceNode,
|
|
466
|
-
): 'event' | 'command' | 'state' | null {
|
|
471
|
+
): 'event' | 'command' | 'state' | 'query' | null {
|
|
467
472
|
// Resolve base symbol (handles aliases and qualified names)
|
|
468
473
|
let sym: import('typescript').Symbol | undefined;
|
|
469
474
|
if (ts.isIdentifier(typeRef.typeName) || ts.isQualifiedName(typeRef.typeName)) {
|
|
@@ -476,6 +481,7 @@ function classifyBaseGeneric(
|
|
|
476
481
|
if (base.endsWith('Event')) return 'event';
|
|
477
482
|
if (base.endsWith('Command')) return 'command';
|
|
478
483
|
if (base.endsWith('State')) return 'state';
|
|
484
|
+
if (base.endsWith('Query')) return 'query';
|
|
479
485
|
return null;
|
|
480
486
|
}
|
|
481
487
|
|
|
@@ -483,7 +489,7 @@ function tryUnwrapDirectGeneric(
|
|
|
483
489
|
ts: typeof import('typescript'),
|
|
484
490
|
typeArg: import('typescript').TypeReferenceNode,
|
|
485
491
|
checker: import('typescript').TypeChecker,
|
|
486
|
-
): { typeName: string; classification: 'event' | 'command' | 'state' } | null {
|
|
492
|
+
): { typeName: string; classification: 'event' | 'command' | 'state' | 'query' } | null {
|
|
487
493
|
if (typeArg.typeArguments === undefined || typeArg.typeArguments.length === 0) return null;
|
|
488
494
|
|
|
489
495
|
const kind = classifyBaseGeneric(ts, checker, typeArg);
|
|
@@ -504,7 +510,7 @@ function tryUnwrapTypeAlias(
|
|
|
504
510
|
typeArg: import('typescript').TypeReferenceNode,
|
|
505
511
|
typeMap: Map<string, TypeInfo>,
|
|
506
512
|
typesByFile: Map<string, Map<string, TypeInfo>>,
|
|
507
|
-
): { typeName: string; classification: 'event' | 'command' | 'state' } | null {
|
|
513
|
+
): { typeName: string; classification: 'event' | 'command' | 'state' | 'query' } | null {
|
|
508
514
|
if (!ts.isIdentifier(typeArg.typeName)) return null;
|
|
509
515
|
|
|
510
516
|
const typeName = typeArg.typeName.text;
|
|
@@ -526,7 +532,7 @@ function tryUnwrapGeneric(
|
|
|
526
532
|
checker: import('typescript').TypeChecker,
|
|
527
533
|
typeMap: Map<string, TypeInfo>,
|
|
528
534
|
typesByFile: Map<string, Map<string, TypeInfo>>,
|
|
529
|
-
): { typeName: string; classification: 'event' | 'command' | 'state' } | null {
|
|
535
|
+
): { typeName: string; classification: 'event' | 'command' | 'state' | 'query' } | null {
|
|
530
536
|
if (!ts.isTypeReferenceNode(typeArg)) return null;
|
|
531
537
|
|
|
532
538
|
return tryUnwrapDirectGeneric(ts, typeArg, checker) ?? tryUnwrapTypeAlias(ts, typeArg, typeMap, typesByFile);
|
|
@@ -554,7 +560,7 @@ function createGivenTypeInfo(
|
|
|
554
560
|
node: import('typescript').CallExpression,
|
|
555
561
|
ordinal: number,
|
|
556
562
|
typeName: string,
|
|
557
|
-
classification: 'event' | 'command' | 'state',
|
|
563
|
+
classification: 'event' | 'command' | 'state' | 'query',
|
|
558
564
|
): GivenTypeInfo {
|
|
559
565
|
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
560
566
|
return {
|
|
@@ -658,6 +658,59 @@ narrative('Test Flow with Rule IDs', 'FLOW-456', () => {
|
|
|
658
658
|
`);
|
|
659
659
|
});
|
|
660
660
|
|
|
661
|
+
it('should correctly generate Query type alias for query messages', async () => {
|
|
662
|
+
const modelWithQueryMessage: Model = {
|
|
663
|
+
variant: 'specs',
|
|
664
|
+
narratives: [
|
|
665
|
+
{
|
|
666
|
+
name: 'Workout Flow',
|
|
667
|
+
id: 'FLOW-001',
|
|
668
|
+
slices: [],
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
messages: [
|
|
672
|
+
{
|
|
673
|
+
type: 'query',
|
|
674
|
+
name: 'GetWorkoutHistory',
|
|
675
|
+
fields: [
|
|
676
|
+
{ name: 'memberId', type: 'string', required: true },
|
|
677
|
+
{ name: 'limit', type: 'number', required: false },
|
|
678
|
+
],
|
|
679
|
+
metadata: { version: 1 },
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
type: 'event',
|
|
683
|
+
name: 'WorkoutRecorded',
|
|
684
|
+
fields: [{ name: 'workoutId', type: 'string', required: true }],
|
|
685
|
+
source: 'internal',
|
|
686
|
+
metadata: { version: 1 },
|
|
687
|
+
},
|
|
688
|
+
],
|
|
689
|
+
integrations: [],
|
|
690
|
+
modules: [],
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const code = getCode(await modelToNarrative(modelWithQueryMessage));
|
|
694
|
+
|
|
695
|
+
expect(code).toEqual(`import { narrative } from '@auto-engineer/narrative';
|
|
696
|
+
import type { Event, Query } from '@auto-engineer/narrative';
|
|
697
|
+
type GetWorkoutHistory = Query<
|
|
698
|
+
'GetWorkoutHistory',
|
|
699
|
+
{
|
|
700
|
+
memberId: string;
|
|
701
|
+
limit?: number;
|
|
702
|
+
}
|
|
703
|
+
>;
|
|
704
|
+
type WorkoutRecorded = Event<
|
|
705
|
+
'WorkoutRecorded',
|
|
706
|
+
{
|
|
707
|
+
workoutId: string;
|
|
708
|
+
}
|
|
709
|
+
>;
|
|
710
|
+
narrative('Workout Flow', 'FLOW-001', () => {});
|
|
711
|
+
`);
|
|
712
|
+
});
|
|
713
|
+
|
|
661
714
|
it('should correctly resolve Date types in messages', async () => {
|
|
662
715
|
const modelWithDateTypes: Model = {
|
|
663
716
|
variant: 'specs',
|
package/src/schema.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
// Message reference for module type ownership
|
|
4
4
|
export const MessageRefSchema = z
|
|
5
5
|
.object({
|
|
6
|
-
kind: z.enum(['command', 'event', 'state']).describe('Message kind'),
|
|
6
|
+
kind: z.enum(['command', 'event', 'state', 'query']).describe('Message kind'),
|
|
7
7
|
name: z.string().describe('Message name'),
|
|
8
8
|
})
|
|
9
9
|
.describe('Reference to a message type');
|
|
@@ -174,7 +174,11 @@ const StateSchema = BaseMessageSchema.extend({
|
|
|
174
174
|
type: z.literal('state'),
|
|
175
175
|
}).describe('State/Read Model representing a view of data');
|
|
176
176
|
|
|
177
|
-
const
|
|
177
|
+
const QuerySchema = BaseMessageSchema.extend({
|
|
178
|
+
type: z.literal('query'),
|
|
179
|
+
}).describe('Query representing a read operation');
|
|
180
|
+
|
|
181
|
+
const MessageSchema = z.discriminatedUnion('type', [CommandSchema, EventSchema, StateSchema, QuerySchema]);
|
|
178
182
|
|
|
179
183
|
const BaseSliceSchema = z
|
|
180
184
|
.object({
|
|
@@ -417,6 +421,7 @@ export {
|
|
|
417
421
|
CommandSchema,
|
|
418
422
|
EventSchema,
|
|
419
423
|
StateSchema,
|
|
424
|
+
QuerySchema,
|
|
420
425
|
IntegrationSchema,
|
|
421
426
|
CommandSliceSchema,
|
|
422
427
|
QuerySliceSchema,
|
|
@@ -2,7 +2,7 @@ import type tsNS from 'typescript';
|
|
|
2
2
|
import { typeFromString } from '../ast/emit-helpers';
|
|
3
3
|
|
|
4
4
|
type Message = {
|
|
5
|
-
type: 'command' | 'event' | 'state';
|
|
5
|
+
type: 'command' | 'event' | 'state' | 'query';
|
|
6
6
|
name: string;
|
|
7
7
|
fields: { name: string; type: string; required: boolean }[];
|
|
8
8
|
};
|
|
@@ -33,10 +33,9 @@ export function buildTypeAliases(ts: typeof tsNS, messages: Message[]): tsNS.Sta
|
|
|
33
33
|
|
|
34
34
|
const name = f.createIdentifier(m.name);
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
m.type === 'event' ? 'Event' : m.type === 'command' ? 'Command' : 'State'
|
|
38
|
-
|
|
39
|
-
);
|
|
36
|
+
const baseTypeName =
|
|
37
|
+
m.type === 'event' ? 'Event' : m.type === 'command' ? 'Command' : m.type === 'query' ? 'Query' : 'State';
|
|
38
|
+
const rhs = f.createTypeReferenceNode(baseTypeName, typeArgs);
|
|
40
39
|
|
|
41
40
|
return f.createTypeAliasDeclaration(
|
|
42
41
|
undefined, // No export keyword
|
|
@@ -12,7 +12,7 @@ import { resolveInferredType } from './type-inference';
|
|
|
12
12
|
|
|
13
13
|
type TypeResolver = (
|
|
14
14
|
t: string,
|
|
15
|
-
expected?: 'command' | 'event' | 'state',
|
|
15
|
+
expected?: 'command' | 'event' | 'state' | 'query',
|
|
16
16
|
exampleData?: unknown,
|
|
17
17
|
) => { resolvedName: string; typeInfo: TypeInfo | undefined };
|
|
18
18
|
|
|
@@ -47,7 +47,7 @@ function getTypesForNarrative(
|
|
|
47
47
|
function tryResolveFromNarrativeTypes(
|
|
48
48
|
t: string,
|
|
49
49
|
narrativeSpecificTypes: Map<string, TypeInfo>,
|
|
50
|
-
expected?: 'command' | 'event' | 'state',
|
|
50
|
+
expected?: 'command' | 'event' | 'state' | 'query',
|
|
51
51
|
exampleData?: unknown,
|
|
52
52
|
): { resolvedName: string; typeInfo: TypeInfo | undefined } {
|
|
53
53
|
if (t !== 'InferredType') {
|
|
@@ -68,7 +68,7 @@ function tryFallbackToUnionTypes(
|
|
|
68
68
|
resolvedName: string,
|
|
69
69
|
typeInfo: TypeInfo | undefined,
|
|
70
70
|
unionTypes: Map<string, TypeInfo>,
|
|
71
|
-
expected?: 'command' | 'event' | 'state',
|
|
71
|
+
expected?: 'command' | 'event' | 'state' | 'query',
|
|
72
72
|
exampleData?: unknown,
|
|
73
73
|
): { resolvedName: string; typeInfo: TypeInfo | undefined } {
|
|
74
74
|
if (resolvedName !== 'InferredType' && typeInfo) {
|
|
@@ -88,7 +88,7 @@ function tryFallbackToUnionTypes(
|
|
|
88
88
|
function tryResolveFromUnionTypes(
|
|
89
89
|
t: string,
|
|
90
90
|
unionTypes: Map<string, TypeInfo>,
|
|
91
|
-
expected?: 'command' | 'event' | 'state',
|
|
91
|
+
expected?: 'command' | 'event' | 'state' | 'query',
|
|
92
92
|
exampleData?: unknown,
|
|
93
93
|
): { resolvedName: string; typeInfo: TypeInfo | undefined } {
|
|
94
94
|
if (t !== 'InferredType') {
|
|
@@ -110,7 +110,7 @@ function createTypeResolver(
|
|
|
110
110
|
) {
|
|
111
111
|
return (
|
|
112
112
|
t: string,
|
|
113
|
-
expected?: 'command' | 'event' | 'state',
|
|
113
|
+
expected?: 'command' | 'event' | 'state' | 'query',
|
|
114
114
|
exampleData?: unknown,
|
|
115
115
|
): { resolvedName: string; typeInfo: TypeInfo | undefined } => {
|
|
116
116
|
if (narrativeSpecificTypes) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Message } from '../../index';
|
|
2
2
|
import type { TypeInfo } from '../../loader/ts-utils';
|
|
3
3
|
|
|
4
|
-
function mapKindToMessageType(k: 'command' | 'query' | 'reaction'): 'command' | 'event' | 'state' {
|
|
4
|
+
function mapKindToMessageType(k: 'command' | 'query' | 'reaction'): 'command' | 'event' | 'state' | 'query' {
|
|
5
5
|
if (k === 'command') return 'command';
|
|
6
|
-
if (k === 'query') return '
|
|
6
|
+
if (k === 'query') return 'query';
|
|
7
7
|
return 'event';
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -52,7 +52,7 @@ function processStateFields(
|
|
|
52
52
|
export function createMessage(
|
|
53
53
|
name: string,
|
|
54
54
|
typeInfo: TypeInfo | undefined,
|
|
55
|
-
messageType: 'command' | 'event' | 'state',
|
|
55
|
+
messageType: 'command' | 'event' | 'state' | 'query',
|
|
56
56
|
): Message {
|
|
57
57
|
let fields = buildInitialFields(typeInfo);
|
|
58
58
|
|
|
@@ -81,6 +81,15 @@ export function createMessage(
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
if (messageType === 'query') {
|
|
85
|
+
return {
|
|
86
|
+
type: 'query',
|
|
87
|
+
name,
|
|
88
|
+
fields,
|
|
89
|
+
metadata,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
return {
|
|
85
94
|
type: 'state',
|
|
86
95
|
name,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { Message } from '../../index';
|
|
3
|
+
import type { ExampleShapeHints } from './example-shapes';
|
|
4
|
+
import { detectQueryAction, extractQueryNameFromRequest, processWhen } from './spec-processors';
|
|
5
|
+
|
|
6
|
+
describe('spec-processors', () => {
|
|
7
|
+
describe('extractQueryNameFromRequest', () => {
|
|
8
|
+
it('should extract query name from simple GraphQL query', () => {
|
|
9
|
+
const request = 'query ViewWorkoutPlan { workoutPlan { id } }';
|
|
10
|
+
expect(extractQueryNameFromRequest(request)).toBe('ViewWorkoutPlan');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should extract query name from GraphQL query with variables', () => {
|
|
14
|
+
const request = 'query ViewWorkoutPlan($workoutId: ID!) { workoutPlan(id: $workoutId) { id name } }';
|
|
15
|
+
expect(extractQueryNameFromRequest(request)).toBe('ViewWorkoutPlan');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should extract query name case-insensitively', () => {
|
|
19
|
+
const request = 'QUERY GetUserProfile { user { id } }';
|
|
20
|
+
expect(extractQueryNameFromRequest(request)).toBe('GetUserProfile');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should return null for undefined request', () => {
|
|
24
|
+
expect(extractQueryNameFromRequest(undefined)).toBe(null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return null for empty string', () => {
|
|
28
|
+
expect(extractQueryNameFromRequest('')).toBe(null);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return null for mutation', () => {
|
|
32
|
+
const request = 'mutation CreateWorkout { createWorkout { id } }';
|
|
33
|
+
expect(extractQueryNameFromRequest(request)).toBe(null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should extract query name from JSON AST format', () => {
|
|
37
|
+
const ast = {
|
|
38
|
+
kind: 'Document',
|
|
39
|
+
definitions: [
|
|
40
|
+
{
|
|
41
|
+
kind: 'OperationDefinition',
|
|
42
|
+
operation: 'query',
|
|
43
|
+
name: { value: 'ListWorkouts' },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
expect(extractQueryNameFromRequest(JSON.stringify(ast))).toBe('ListWorkouts');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('detectQueryAction', () => {
|
|
52
|
+
it('should return true when whenText exactly matches query name from request', () => {
|
|
53
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan($id: ID!) { workoutPlan { id } }' };
|
|
54
|
+
expect(detectQueryAction('ViewWorkoutPlan', slice)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return true when whenText matches query name case-insensitively', () => {
|
|
58
|
+
const slice = { type: 'query', request: 'query viewWorkoutPlan { workoutPlan { id } }' };
|
|
59
|
+
expect(detectQueryAction('ViewWorkoutPlan', slice)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should return false for command slices', () => {
|
|
63
|
+
const slice = { type: 'command', request: 'mutation CreateWorkout { createWorkout { id } }' };
|
|
64
|
+
expect(detectQueryAction('ViewWorkoutPlan', slice)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return false when no request field is provided (no naming convention fallback)', () => {
|
|
68
|
+
const slice = { type: 'query' };
|
|
69
|
+
// Without request field, cannot determine if it's a query action
|
|
70
|
+
expect(detectQueryAction('ViewWorkoutPlan', slice)).toBe(false);
|
|
71
|
+
expect(detectQueryAction('GetUserProfile', slice)).toBe(false);
|
|
72
|
+
expect(detectQueryAction('ListWorkouts', slice)).toBe(false);
|
|
73
|
+
expect(detectQueryAction('FindUserById', slice)).toBe(false);
|
|
74
|
+
expect(detectQueryAction('SearchProducts', slice)).toBe(false);
|
|
75
|
+
expect(detectQueryAction('FetchOrders', slice)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return false for event names in query slices', () => {
|
|
79
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
|
|
80
|
+
expect(detectQueryAction('WorkoutPlanCreated', slice)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return true for case-insensitive match to query name from request', () => {
|
|
84
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
|
|
85
|
+
// Case-insensitive matching works when request field is present
|
|
86
|
+
expect(detectQueryAction('viewWorkoutPlan', slice)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should return false for empty whenText', () => {
|
|
90
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
|
|
91
|
+
expect(detectQueryAction('', slice)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('processWhen', () => {
|
|
96
|
+
const mockTypeResolver = (t: string) => ({
|
|
97
|
+
resolvedName: t,
|
|
98
|
+
typeInfo: undefined,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const createEmptyHints = (): ExampleShapeHints => new Map();
|
|
102
|
+
|
|
103
|
+
describe('query action detection', () => {
|
|
104
|
+
it('should create a query message when When is a query action matching request', () => {
|
|
105
|
+
const messages: Map<string, Message> = new Map();
|
|
106
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan($id: ID!) { workoutPlan { id } }' };
|
|
107
|
+
const when = { commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } };
|
|
108
|
+
|
|
109
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
110
|
+
|
|
111
|
+
// Query message should be created for query actions
|
|
112
|
+
expect(messages.size).toBe(1);
|
|
113
|
+
expect(messages.has('ViewWorkoutPlan')).toBe(true);
|
|
114
|
+
expect(messages.get('ViewWorkoutPlan')?.type).toBe('query');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should create a message when no request field (cannot detect query action)', () => {
|
|
118
|
+
const messages: Map<string, Message> = new Map();
|
|
119
|
+
const slice = { type: 'query' }; // No request field
|
|
120
|
+
const when = { commandRef: 'ViewWorkoutHistory', exampleData: { userId: 'user_456' } };
|
|
121
|
+
|
|
122
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
123
|
+
|
|
124
|
+
// Without request field, cannot detect query action - treats as event
|
|
125
|
+
expect(messages.size).toBe(1);
|
|
126
|
+
expect(messages.has('ViewWorkoutHistory')).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should create a query message when When matches query name from request', () => {
|
|
130
|
+
const messages: Map<string, Message> = new Map();
|
|
131
|
+
const slice = { type: 'query', request: 'query GetUserProfile { user { id } }' };
|
|
132
|
+
const when = { commandRef: 'GetUserProfile', exampleData: { userId: 'user_123' } };
|
|
133
|
+
|
|
134
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
135
|
+
|
|
136
|
+
// Query action detected via request field - query message created
|
|
137
|
+
expect(messages.size).toBe(1);
|
|
138
|
+
expect(messages.has('GetUserProfile')).toBe(true);
|
|
139
|
+
expect(messages.get('GetUserProfile')?.type).toBe('query');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should create a message when When is an event name in query slice', () => {
|
|
143
|
+
const messages: Map<string, Message> = new Map();
|
|
144
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
|
|
145
|
+
const when = { commandRef: 'WorkoutPlanUpdated', exampleData: { workoutId: 'wrk_123' } };
|
|
146
|
+
|
|
147
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
148
|
+
|
|
149
|
+
// Event messages SHOULD be created
|
|
150
|
+
expect(messages.size).toBe(1);
|
|
151
|
+
expect(messages.has('WorkoutPlanUpdated')).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should create a message for command slices regardless of naming', () => {
|
|
155
|
+
const messages: Map<string, Message> = new Map();
|
|
156
|
+
const slice = { type: 'command' };
|
|
157
|
+
const when = { commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } };
|
|
158
|
+
|
|
159
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
160
|
+
|
|
161
|
+
// Command messages SHOULD be created even if it looks like a query action name
|
|
162
|
+
expect(messages.size).toBe(1);
|
|
163
|
+
expect(messages.has('ViewWorkoutPlan')).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('array of When items', () => {
|
|
168
|
+
it('should create query messages for query action items matching request', () => {
|
|
169
|
+
const messages: Map<string, Message> = new Map();
|
|
170
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
|
|
171
|
+
const when = [{ commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } }];
|
|
172
|
+
|
|
173
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
174
|
+
|
|
175
|
+
// Query action matches request - query message created
|
|
176
|
+
expect(messages.size).toBe(1);
|
|
177
|
+
expect(messages.has('ViewWorkoutPlan')).toBe(true);
|
|
178
|
+
expect(messages.get('ViewWorkoutPlan')?.type).toBe('query');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should create messages when no request field (cannot detect query action)', () => {
|
|
182
|
+
const messages: Map<string, Message> = new Map();
|
|
183
|
+
const slice = { type: 'query' }; // No request field
|
|
184
|
+
const when = [{ commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } }];
|
|
185
|
+
|
|
186
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
187
|
+
|
|
188
|
+
// Without request field, cannot detect query action - message created
|
|
189
|
+
expect(messages.size).toBe(1);
|
|
190
|
+
expect(messages.has('ViewWorkoutPlan')).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should create messages for event items in array', () => {
|
|
194
|
+
const messages: Map<string, Message> = new Map();
|
|
195
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
|
|
196
|
+
const when = [
|
|
197
|
+
{ commandRef: 'WorkoutPlanCreated', exampleData: { workoutId: 'wrk_123' } },
|
|
198
|
+
{ commandRef: 'WorkoutPlanUpdated', exampleData: { workoutId: 'wrk_123' } },
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
202
|
+
|
|
203
|
+
expect(messages.size).toBe(2);
|
|
204
|
+
expect(messages.has('WorkoutPlanCreated')).toBe(true);
|
|
205
|
+
expect(messages.has('WorkoutPlanUpdated')).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should create query message for query action and event message in mixed array', () => {
|
|
209
|
+
const messages: Map<string, Message> = new Map();
|
|
210
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
|
|
211
|
+
const when = [
|
|
212
|
+
{ commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } }, // Query action - create query message
|
|
213
|
+
{ commandRef: 'WorkoutPlanUpdated', exampleData: { workoutId: 'wrk_123' } }, // Event - create event message
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
|
|
217
|
+
|
|
218
|
+
expect(messages.size).toBe(2);
|
|
219
|
+
expect(messages.has('ViewWorkoutPlan')).toBe(true);
|
|
220
|
+
expect(messages.get('ViewWorkoutPlan')?.type).toBe('query');
|
|
221
|
+
expect(messages.has('WorkoutPlanUpdated')).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('example shape hints collection', () => {
|
|
226
|
+
it('should collect example hints for query actions', () => {
|
|
227
|
+
const messages: Map<string, Message> = new Map();
|
|
228
|
+
const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
|
|
229
|
+
const when = { commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123', userId: 'user_456' } };
|
|
230
|
+
const hints = createEmptyHints();
|
|
231
|
+
|
|
232
|
+
processWhen(when, slice, mockTypeResolver, messages, hints);
|
|
233
|
+
|
|
234
|
+
// Query message created and hints should be collected
|
|
235
|
+
expect(messages.size).toBe(1);
|
|
236
|
+
expect(messages.get('ViewWorkoutPlan')?.type).toBe('query');
|
|
237
|
+
expect(hints.has('ViewWorkoutPlan')).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|