@genesislcap/event-type-codegen 15.17.0 → 15.18.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/README.md +15 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -1
- package/dist/index.js.map +1 -1
- package/dist/kotlin-parse-utils.d.ts +2 -1
- package/dist/kotlin-parse-utils.d.ts.map +1 -1
- package/dist/kotlin-parse-utils.js +10 -4
- package/dist/kotlin-parse-utils.js.map +1 -1
- package/dist/naming.d.ts +9 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +49 -2
- package/dist/naming.js.map +1 -1
- package/dist/parse-kotlin.d.ts.map +1 -1
- package/dist/parse-kotlin.js +5 -4
- package/dist/parse-kotlin.js.map +1 -1
- package/dist/parse-metadata.d.ts +58 -0
- package/dist/parse-metadata.d.ts.map +1 -0
- package/dist/parse-metadata.js +231 -0
- package/dist/parse-metadata.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +65 -1
- package/src/kotlin-parse-utils.ts +17 -7
- package/src/naming.ts +58 -2
- package/src/parse-kotlin.ts +5 -4
- package/src/parse-metadata.ts +327 -0
- package/test/fixtures/client-out/genesis-event-types.ts +1 -1
- package/test/fixtures/golden/genesis-event-types.ts +1 -1
- package/test/kotlin-parse-utils.test.ts +42 -0
- package/test/naming.test.ts +49 -0
- package/test/parse-metadata.test.ts +208 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { eventNameToEventClassName } from './naming';
|
|
2
|
+
|
|
3
|
+
/** Wire-named DETAILS property from live event metadata / JSON Schema. */
|
|
4
|
+
export type MetadataField = {
|
|
5
|
+
/** Already UPPER_SNAKE wire name (e.g. ADDRESS_LINE_1). */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Emitted TypeScript type (e.g. string, number, Side). */
|
|
8
|
+
tsType: string;
|
|
9
|
+
optional: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type MetadataEventContract = {
|
|
13
|
+
eventName: string;
|
|
14
|
+
detailsInterface: string;
|
|
15
|
+
fields: MetadataField[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type MetadataEnumDef = {
|
|
19
|
+
name: string;
|
|
20
|
+
values: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dump shape produced by foundation-header dev capture (FUI-2608).
|
|
25
|
+
* Prefer `schema.INBOUND` DETAILS; fall back to `metadata.FIELD`.
|
|
26
|
+
*/
|
|
27
|
+
export type EventMetadataDump = {
|
|
28
|
+
capturedAt?: string;
|
|
29
|
+
source?: string;
|
|
30
|
+
events: Record<
|
|
31
|
+
string,
|
|
32
|
+
{
|
|
33
|
+
metadata?: {
|
|
34
|
+
FIELD?: Array<{
|
|
35
|
+
NAME: string;
|
|
36
|
+
TYPE?: string;
|
|
37
|
+
JSON_TYPE?: string;
|
|
38
|
+
OPTIONAL?: boolean;
|
|
39
|
+
VALID_VALUES?: string;
|
|
40
|
+
}>;
|
|
41
|
+
NAME?: string;
|
|
42
|
+
};
|
|
43
|
+
schema?: {
|
|
44
|
+
INBOUND?: {
|
|
45
|
+
properties?: Record<string, unknown>;
|
|
46
|
+
required?: string[];
|
|
47
|
+
};
|
|
48
|
+
MESSAGE_TYPE?: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
55
|
+
typeof value === 'object' && value != null && !Array.isArray(value);
|
|
56
|
+
|
|
57
|
+
/** Quote a string for safe emission as a TS string literal / object key. */
|
|
58
|
+
export const quoteTsString = (s: string): string =>
|
|
59
|
+
`'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
60
|
+
|
|
61
|
+
/** Bare identifier when legal; otherwise a quoted string key. */
|
|
62
|
+
const tsPropertyKey = (name: string): string =>
|
|
63
|
+
/^[A-Za-z_$][\w$]*$/.test(name) ? name : quoteTsString(name);
|
|
64
|
+
|
|
65
|
+
/** EVENT_BROKER_INSERT → BrokerInsertDetails */
|
|
66
|
+
export const eventNameToDetailsInterfaceName = (eventName: string): string => {
|
|
67
|
+
const base = eventName.startsWith('EVENT_') ? eventName.slice('EVENT_'.length) : eventName;
|
|
68
|
+
const pascal = base
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.split('_')
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
73
|
+
.join('');
|
|
74
|
+
return `${pascal}Details`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Build a valid PascalCase TypeScript identifier from an enum name hint. */
|
|
78
|
+
const toSafeEnumIdentifier = (enumNameHint: string): string => {
|
|
79
|
+
const enumName = enumNameHint
|
|
80
|
+
.split(/[^A-Za-z0-9]+/)
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
|
83
|
+
.join('');
|
|
84
|
+
if (!enumName) return 'GeneratedEnum';
|
|
85
|
+
return /^[A-Za-z]/.test(enumName) ? enumName : `E${enumName}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const valuesKey = (values: string[]): string => values.join('\0');
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Register an enum under a collision-safe name. Same values → reuse; different values →
|
|
92
|
+
* disambiguate (`Foo`, `Foo2`, …) and report via `unmapped`.
|
|
93
|
+
*/
|
|
94
|
+
const registerEnum = (
|
|
95
|
+
enums: Map<string, string[]>,
|
|
96
|
+
preferredName: string,
|
|
97
|
+
values: string[],
|
|
98
|
+
unmapped: string[],
|
|
99
|
+
context: string,
|
|
100
|
+
): string => {
|
|
101
|
+
const existing = enums.get(preferredName);
|
|
102
|
+
if (!existing) {
|
|
103
|
+
enums.set(preferredName, values);
|
|
104
|
+
return preferredName;
|
|
105
|
+
}
|
|
106
|
+
if (valuesKey(existing) === valuesKey(values)) {
|
|
107
|
+
return preferredName;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let n = 2;
|
|
111
|
+
let candidate = `${preferredName}${n}`;
|
|
112
|
+
while (enums.has(candidate) && valuesKey(enums.get(candidate)!) !== valuesKey(values)) {
|
|
113
|
+
n += 1;
|
|
114
|
+
candidate = `${preferredName}${n}`;
|
|
115
|
+
}
|
|
116
|
+
if (!enums.has(candidate)) {
|
|
117
|
+
enums.set(candidate, values);
|
|
118
|
+
unmapped.push(`${context}: enum name collision for ${preferredName}; emitted as ${candidate}`);
|
|
119
|
+
}
|
|
120
|
+
return candidate;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const jsonSchemaTypeToTs = (
|
|
124
|
+
propSchema: Record<string, unknown>,
|
|
125
|
+
enums: Map<string, string[]>,
|
|
126
|
+
enumNameHint: string,
|
|
127
|
+
unmapped: string[],
|
|
128
|
+
context: string,
|
|
129
|
+
): string => {
|
|
130
|
+
if (Array.isArray(propSchema.enum) && propSchema.enum.every((v) => typeof v === 'string')) {
|
|
131
|
+
const values = propSchema.enum as string[];
|
|
132
|
+
const safeName = toSafeEnumIdentifier(enumNameHint);
|
|
133
|
+
return registerEnum(enums, safeName, values, unmapped, context);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (Array.isArray(propSchema.oneOf)) {
|
|
137
|
+
const nonNull = (propSchema.oneOf as unknown[]).find(
|
|
138
|
+
(branch) => isRecord(branch) && branch.type !== 'null',
|
|
139
|
+
);
|
|
140
|
+
if (isRecord(nonNull)) {
|
|
141
|
+
return jsonSchemaTypeToTs(nonNull, enums, enumNameHint, unmapped, context);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const type = propSchema.type;
|
|
146
|
+
if (type === 'integer' || type === 'number') return 'number';
|
|
147
|
+
if (type === 'boolean') return 'boolean';
|
|
148
|
+
if (type === 'array') {
|
|
149
|
+
const items = isRecord(propSchema.items) ? propSchema.items : {};
|
|
150
|
+
return `${jsonSchemaTypeToTs(items, enums, enumNameHint, unmapped, context)}[]`;
|
|
151
|
+
}
|
|
152
|
+
if (type === 'object') return 'Record<string, unknown>';
|
|
153
|
+
return 'string';
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const metadataJsonTypeToTs = (jsonType?: string, fieldType?: string): string => {
|
|
157
|
+
const jt = (jsonType ?? '').toLowerCase();
|
|
158
|
+
if (jt === 'integer' || jt === 'number') return 'number';
|
|
159
|
+
if (jt === 'boolean') return 'boolean';
|
|
160
|
+
if (
|
|
161
|
+
(fieldType ?? '').toUpperCase() === 'DATETIME' ||
|
|
162
|
+
(fieldType ?? '').toUpperCase() === 'DATE'
|
|
163
|
+
) {
|
|
164
|
+
return 'number';
|
|
165
|
+
}
|
|
166
|
+
return 'string';
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const extractDetailsSchema = (
|
|
170
|
+
inbound: Record<string, unknown> | undefined,
|
|
171
|
+
): { properties: Record<string, unknown>; required: Set<string> } | null => {
|
|
172
|
+
if (!inbound) return null;
|
|
173
|
+
const rootProps = isRecord(inbound.properties) ? inbound.properties : undefined;
|
|
174
|
+
if (!rootProps) return null;
|
|
175
|
+
|
|
176
|
+
// Event inbound is usually { DETAILS: { type: object, properties: {...}, required: [...] } }
|
|
177
|
+
const details = rootProps.DETAILS;
|
|
178
|
+
if (isRecord(details) && isRecord(details.properties)) {
|
|
179
|
+
const required = Array.isArray(details.required)
|
|
180
|
+
? new Set(details.required.filter((r): r is string => typeof r === 'string'))
|
|
181
|
+
: new Set<string>();
|
|
182
|
+
return { properties: details.properties, required };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Some schemas flatten DETAILS properties at the inbound root.
|
|
186
|
+
const required = Array.isArray(inbound.required)
|
|
187
|
+
? new Set(inbound.required.filter((r): r is string => typeof r === 'string'))
|
|
188
|
+
: new Set<string>();
|
|
189
|
+
return { properties: rootProps, required };
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse a header-captured event-metadata.json dump into per-event contracts.
|
|
194
|
+
*/
|
|
195
|
+
export const parseEventMetadataDump = (
|
|
196
|
+
dump: EventMetadataDump,
|
|
197
|
+
): { contracts: MetadataEventContract[]; enums: MetadataEnumDef[]; unmapped: string[] } => {
|
|
198
|
+
const contracts: MetadataEventContract[] = [];
|
|
199
|
+
const enums = new Map<string, string[]>();
|
|
200
|
+
const unmapped: string[] = [];
|
|
201
|
+
|
|
202
|
+
const eventNames = Object.keys(dump.events ?? {}).sort((a, b) => a.localeCompare(b));
|
|
203
|
+
|
|
204
|
+
for (const eventName of eventNames) {
|
|
205
|
+
if (!eventName.startsWith('EVENT_')) {
|
|
206
|
+
unmapped.push(`${eventName}: not an EVENT_* handler`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const entry = dump.events[eventName];
|
|
211
|
+
const inbound = isRecord(entry?.schema?.INBOUND)
|
|
212
|
+
? (entry.schema.INBOUND as Record<string, unknown>)
|
|
213
|
+
: undefined;
|
|
214
|
+
const detailsSchema = extractDetailsSchema(inbound);
|
|
215
|
+
|
|
216
|
+
const fields: MetadataField[] = [];
|
|
217
|
+
|
|
218
|
+
if (detailsSchema) {
|
|
219
|
+
for (const [fieldName, rawProp] of Object.entries(detailsSchema.properties)) {
|
|
220
|
+
if (fieldName === 'MESSAGE_TYPE' || fieldName === 'SOURCE_REF') continue;
|
|
221
|
+
if (!isRecord(rawProp)) continue;
|
|
222
|
+
const optional = !detailsSchema.required.has(fieldName);
|
|
223
|
+
const enumNameHint = eventName.startsWith('EVENT_')
|
|
224
|
+
? `${eventName.slice('EVENT_'.length)}_${fieldName}`
|
|
225
|
+
: `${eventName}_${fieldName}`;
|
|
226
|
+
fields.push({
|
|
227
|
+
name: fieldName,
|
|
228
|
+
tsType: jsonSchemaTypeToTs(
|
|
229
|
+
rawProp,
|
|
230
|
+
enums,
|
|
231
|
+
enumNameHint,
|
|
232
|
+
unmapped,
|
|
233
|
+
`${eventName}.${fieldName}`,
|
|
234
|
+
),
|
|
235
|
+
optional,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
} else if (entry?.metadata?.FIELD?.length) {
|
|
239
|
+
for (const field of entry.metadata.FIELD) {
|
|
240
|
+
fields.push({
|
|
241
|
+
name: field.NAME,
|
|
242
|
+
tsType: metadataJsonTypeToTs(field.JSON_TYPE, field.TYPE),
|
|
243
|
+
optional: field.OPTIONAL !== false,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
unmapped.push(`${eventName}: no INBOUND DETAILS schema or metadata.FIELD`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
contracts.push({
|
|
252
|
+
eventName,
|
|
253
|
+
detailsInterface: eventNameToDetailsInterfaceName(eventName),
|
|
254
|
+
fields,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const enumDefs: MetadataEnumDef[] = [...enums.entries()]
|
|
259
|
+
.map(([name, values]) => ({ name, values }))
|
|
260
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
261
|
+
|
|
262
|
+
return { contracts, enums: enumDefs, unmapped };
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export const renderEventTypesFromMetadataContracts = (
|
|
266
|
+
contracts: MetadataEventContract[],
|
|
267
|
+
enums: MetadataEnumDef[],
|
|
268
|
+
unmapped: string[],
|
|
269
|
+
): string => {
|
|
270
|
+
const unmappedComment =
|
|
271
|
+
unmapped.length > 0
|
|
272
|
+
? `/**\n * Unmapped handlers (not included in EventDetailsMap):\n${unmapped
|
|
273
|
+
.map((line) => ` * - ${line}`)
|
|
274
|
+
.join('\n')}\n */\n\n`
|
|
275
|
+
: '';
|
|
276
|
+
|
|
277
|
+
const enumExports = enums
|
|
278
|
+
.map((e) => {
|
|
279
|
+
const entries = e.values
|
|
280
|
+
.map((value) => ` ${quoteTsString(value)}: ${quoteTsString(value)},`)
|
|
281
|
+
.join('\n');
|
|
282
|
+
return `export const ${e.name} = {\n${entries}\n} as const;\n\nexport type ${e.name} = (typeof ${e.name})[keyof typeof ${e.name}];`;
|
|
283
|
+
})
|
|
284
|
+
.join('\n\n');
|
|
285
|
+
|
|
286
|
+
const interfaces = contracts
|
|
287
|
+
.map((c) => {
|
|
288
|
+
const lines = c.fields.map((f) => {
|
|
289
|
+
const optional = f.optional ? '?' : '';
|
|
290
|
+
return ` ${tsPropertyKey(f.name)}${optional}: ${f.tsType};`;
|
|
291
|
+
});
|
|
292
|
+
return `export interface ${c.detailsInterface} {\n${lines.join('\n')}\n}`;
|
|
293
|
+
})
|
|
294
|
+
.join('\n\n');
|
|
295
|
+
|
|
296
|
+
const mapEntries = contracts
|
|
297
|
+
.map((c) => ` ${tsPropertyKey(c.eventName)}: ${c.detailsInterface};`)
|
|
298
|
+
.join('\n');
|
|
299
|
+
|
|
300
|
+
const eventClasses = contracts
|
|
301
|
+
.map((c) => {
|
|
302
|
+
const className = eventNameToEventClassName(c.eventName);
|
|
303
|
+
return `export class ${className} extends GenesisEvent<'${c.eventName}', ${c.detailsInterface}> {
|
|
304
|
+
readonly MESSAGE_TYPE = '${c.eventName}' as const;
|
|
305
|
+
|
|
306
|
+
constructor(details: ${c.detailsInterface}) {
|
|
307
|
+
super(details);
|
|
308
|
+
}
|
|
309
|
+
}`;
|
|
310
|
+
})
|
|
311
|
+
.join('\n\n');
|
|
312
|
+
|
|
313
|
+
const foundationCommsImport =
|
|
314
|
+
contracts.length > 0 ? `import { GenesisEvent } from '@genesislcap/foundation-comms';\n\n` : '';
|
|
315
|
+
|
|
316
|
+
return `// AUTO-GENERATED — do not edit
|
|
317
|
+
// Source: event metadata dump (genx generate event-types --from-metadata)
|
|
318
|
+
|
|
319
|
+
${unmappedComment}${foundationCommsImport}${enumExports ? `${enumExports}\n\n` : ''}${interfaces}
|
|
320
|
+
|
|
321
|
+
export interface EventDetailsMap {
|
|
322
|
+
${mapEntries}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export type GenesisEventName = keyof EventDetailsMap;
|
|
326
|
+
${eventClasses ? `\n${eventClasses}\n` : ''}`;
|
|
327
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
+
|
|
3
|
+
import { parseKotlinProperty } from '../src/kotlin-parse-utils';
|
|
4
|
+
import { kotlinPropertyToWireField } from '../src/naming';
|
|
5
|
+
|
|
6
|
+
const suite = createLogicSuite('parseKotlinProperty');
|
|
7
|
+
|
|
8
|
+
suite('parses ordinary camelCase properties', () => {
|
|
9
|
+
const prop = parseKotlinProperty('public var instrumentId: String');
|
|
10
|
+
assert.ok(prop);
|
|
11
|
+
assert.equal(prop!.name, 'instrumentId');
|
|
12
|
+
assert.equal(prop!.kotlinType, 'String');
|
|
13
|
+
assert.equal(prop!.optional, false);
|
|
14
|
+
assert.equal(kotlinPropertyToWireField(prop!.name), 'INSTRUMENT_ID');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
suite('parses backtick-escaped reserved identifiers (Position.VALUE)', () => {
|
|
18
|
+
const prop = parseKotlinProperty(
|
|
19
|
+
'@Title("Value") @GenesisType(Field.Type.DOUBLE) public var `value`: Double? = null',
|
|
20
|
+
);
|
|
21
|
+
assert.ok(prop);
|
|
22
|
+
assert.equal(prop!.name, 'value');
|
|
23
|
+
assert.equal(prop!.kotlinType, 'Double');
|
|
24
|
+
assert.equal(prop!.optional, true);
|
|
25
|
+
assert.equal(kotlinPropertyToWireField(prop!.name), 'VALUE');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
suite('parses bare backtick params when allowBareParams is set', () => {
|
|
29
|
+
const prop = parseKotlinProperty('`object`: String? = null', { allowBareParams: true });
|
|
30
|
+
assert.ok(prop);
|
|
31
|
+
assert.equal(prop!.name, 'object');
|
|
32
|
+
assert.equal(kotlinPropertyToWireField(prop!.name), 'OBJECT');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
suite('passes through backticked UPPER_SNAKE properties unchanged on the wire', () => {
|
|
36
|
+
const prop = parseKotlinProperty('public var `ORDER_ID`: String');
|
|
37
|
+
assert.ok(prop);
|
|
38
|
+
assert.equal(prop!.name, 'ORDER_ID');
|
|
39
|
+
assert.equal(kotlinPropertyToWireField(prop!.name), 'ORDER_ID');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
suite.run();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
+
|
|
3
|
+
import { kotlinPropertyToWireField, toUpperUnderscoreWithAcronyms } from '../src/naming';
|
|
4
|
+
|
|
5
|
+
const suite = createLogicSuite('toUpperUnderscoreWithAcronyms');
|
|
6
|
+
|
|
7
|
+
/** Cases mirrored from genesis-server ToUpperUnderscoreWithAcronymsTest. */
|
|
8
|
+
suite('matches genesis-server simple cases', () => {
|
|
9
|
+
assert.equal(toUpperUnderscoreWithAcronyms('orderIds'), 'ORDER_IDS');
|
|
10
|
+
assert.equal(toUpperUnderscoreWithAcronyms('orderIdsTest'), 'ORDER_IDS_TEST');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
suite('matches genesis-server acronym cases', () => {
|
|
14
|
+
assert.equal(toUpperUnderscoreWithAcronyms('orderI'), 'ORDER_I');
|
|
15
|
+
assert.equal(toUpperUnderscoreWithAcronyms('orderID'), 'ORDER_ID');
|
|
16
|
+
assert.equal(toUpperUnderscoreWithAcronyms('orderIDS'), 'ORDER_IDS');
|
|
17
|
+
assert.equal(toUpperUnderscoreWithAcronyms('orderIa'), 'ORDER_IA');
|
|
18
|
+
assert.equal(toUpperUnderscoreWithAcronyms('orderIDNotional'), 'ORDER_ID_NOTIONAL');
|
|
19
|
+
assert.equal(toUpperUnderscoreWithAcronyms('orderIDSInstrument'), 'ORDER_IDS_INSTRUMENT');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
suite('matches genesis-server number cases', () => {
|
|
23
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order1'), 'ORDER_1');
|
|
24
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order12'), 'ORDER_12');
|
|
25
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order123'), 'ORDER_123');
|
|
26
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order1a'), 'ORDER_1A');
|
|
27
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order12a'), 'ORDER_12A');
|
|
28
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order123a'), 'ORDER_123A');
|
|
29
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order1A'), 'ORDER_1_A');
|
|
30
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order12A'), 'ORDER_12_A');
|
|
31
|
+
assert.equal(toUpperUnderscoreWithAcronyms('order123A'), 'ORDER_123_A');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
suite('fixes FUI-2606 field-name examples', () => {
|
|
35
|
+
assert.equal(kotlinPropertyToWireField('addressLine1'), 'ADDRESS_LINE_1');
|
|
36
|
+
assert.equal(kotlinPropertyToWireField('addressLine2'), 'ADDRESS_LINE_2');
|
|
37
|
+
assert.equal(kotlinPropertyToWireField('brokerCtmBicM2i'), 'BROKER_CTM_BIC_M_2I');
|
|
38
|
+
assert.equal(kotlinPropertyToWireField('dfaNotes1'), 'DFA_NOTES_1');
|
|
39
|
+
assert.equal(kotlinPropertyToWireField('tradeType1'), 'TRADE_TYPE_1');
|
|
40
|
+
assert.equal(kotlinPropertyToWireField('createdAt'), 'CREATED_AT');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
suite('passes through names that are already UPPER_SNAKE', () => {
|
|
44
|
+
assert.equal(toUpperUnderscoreWithAcronyms('ORDER_ID'), 'ORDER_ID');
|
|
45
|
+
assert.equal(toUpperUnderscoreWithAcronyms('ADDRESS_LINE_1'), 'ADDRESS_LINE_1');
|
|
46
|
+
assert.equal(toUpperUnderscoreWithAcronyms('ALREADY_UPPER'), 'ALREADY_UPPER');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
suite.run();
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
eventNameToDetailsInterfaceName,
|
|
5
|
+
parseEventMetadataDump,
|
|
6
|
+
renderEventTypesFromMetadataContracts,
|
|
7
|
+
type EventMetadataDump,
|
|
8
|
+
} from '../src/parse-metadata';
|
|
9
|
+
|
|
10
|
+
const suite = createLogicSuite('parse-metadata');
|
|
11
|
+
|
|
12
|
+
const sampleDump: EventMetadataDump = {
|
|
13
|
+
capturedAt: '2026-08-24T10:00:00.000Z',
|
|
14
|
+
events: {
|
|
15
|
+
EVENT_BROKER_INSERT: {
|
|
16
|
+
schema: {
|
|
17
|
+
MESSAGE_TYPE: 'EVENT_BROKER_INSERT',
|
|
18
|
+
INBOUND: {
|
|
19
|
+
properties: {
|
|
20
|
+
DETAILS: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
required: ['BROKER_NAME'],
|
|
23
|
+
properties: {
|
|
24
|
+
BROKER_ID: {
|
|
25
|
+
oneOf: [{ type: 'null' }, { type: 'string' }],
|
|
26
|
+
genesisType: 'STRING',
|
|
27
|
+
},
|
|
28
|
+
BROKER_NAME: { type: 'string', genesisType: 'STRING' },
|
|
29
|
+
ADDRESS_LINE_1: {
|
|
30
|
+
oneOf: [{ type: 'null' }, { type: 'string' }],
|
|
31
|
+
},
|
|
32
|
+
UPDATE_DATE: { type: 'integer', genesisType: 'DATETIME' },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
EVENT_BROKER_AMEND: {
|
|
40
|
+
schema: {
|
|
41
|
+
INBOUND: {
|
|
42
|
+
properties: {
|
|
43
|
+
DETAILS: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
required: ['BROKER_ID', 'BROKER_NAME'],
|
|
46
|
+
properties: {
|
|
47
|
+
BROKER_ID: { type: 'string' },
|
|
48
|
+
BROKER_NAME: { type: 'string' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
suite('derives per-event details interface names', () => {
|
|
59
|
+
assert.equal(eventNameToDetailsInterfaceName('EVENT_BROKER_INSERT'), 'BrokerInsertDetails');
|
|
60
|
+
assert.equal(eventNameToDetailsInterfaceName('EVENT_TRADE_MODIFY'), 'TradeModifyDetails');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
suite('parses schema DETAILS with correct optionality and integer dates', () => {
|
|
64
|
+
const { contracts, unmapped } = parseEventMetadataDump(sampleDump);
|
|
65
|
+
assert.equal(unmapped.length, 0);
|
|
66
|
+
assert.equal(contracts.length, 2);
|
|
67
|
+
|
|
68
|
+
const insert = contracts.find((c) => c.eventName === 'EVENT_BROKER_INSERT')!;
|
|
69
|
+
const brokerId = insert.fields.find((f) => f.name === 'BROKER_ID')!;
|
|
70
|
+
const brokerName = insert.fields.find((f) => f.name === 'BROKER_NAME')!;
|
|
71
|
+
const address = insert.fields.find((f) => f.name === 'ADDRESS_LINE_1')!;
|
|
72
|
+
const updateDate = insert.fields.find((f) => f.name === 'UPDATE_DATE')!;
|
|
73
|
+
|
|
74
|
+
assert.equal(brokerId.optional, true);
|
|
75
|
+
assert.equal(brokerName.optional, false);
|
|
76
|
+
assert.equal(address.name, 'ADDRESS_LINE_1');
|
|
77
|
+
assert.equal(updateDate.tsType, 'number');
|
|
78
|
+
|
|
79
|
+
const amend = contracts.find((c) => c.eventName === 'EVENT_BROKER_AMEND')!;
|
|
80
|
+
assert.equal(amend.fields.find((f) => f.name === 'BROKER_ID')!.optional, false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
suite('renders separate insert/amend interfaces from metadata', () => {
|
|
84
|
+
const { contracts, enums, unmapped } = parseEventMetadataDump(sampleDump);
|
|
85
|
+
const rendered = renderEventTypesFromMetadataContracts(contracts, enums, unmapped);
|
|
86
|
+
assert.is(rendered.includes('export interface BrokerInsertDetails'), true);
|
|
87
|
+
assert.is(rendered.includes('export interface BrokerAmendDetails'), true);
|
|
88
|
+
assert.is(rendered.includes('ADDRESS_LINE_1?: string'), true);
|
|
89
|
+
assert.is(rendered.includes('UPDATE_DATE?: number'), true);
|
|
90
|
+
assert.is(rendered.includes('EVENT_BROKER_INSERT: BrokerInsertDetails'), true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
suite('scopes enum names per event to avoid cross-event collisions', () => {
|
|
94
|
+
const enumDump: EventMetadataDump = {
|
|
95
|
+
events: {
|
|
96
|
+
EVENT_TRADE_INSERT: {
|
|
97
|
+
schema: {
|
|
98
|
+
INBOUND: {
|
|
99
|
+
properties: {
|
|
100
|
+
DETAILS: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
SIDE: { type: 'string', enum: ['BUY', 'SELL'] },
|
|
104
|
+
status: { type: 'string', enum: ['OPEN', 'CLOSED'] },
|
|
105
|
+
SETTLEMENT: { type: 'string', enum: ['T+1', 'N/A', "O'Brien"] },
|
|
106
|
+
'MY-FIELD': { type: 'string', enum: ['A', 'B'] },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
EVENT_ORDER_INSERT: {
|
|
114
|
+
schema: {
|
|
115
|
+
INBOUND: {
|
|
116
|
+
properties: {
|
|
117
|
+
DETAILS: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
SIDE: { type: 'string', enum: ['LONG', 'SHORT'] },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
// Hashes to the same PascalCase as EVENT_TRADE_INSERT.SIDE → TradeInsertSide
|
|
128
|
+
EVENT_TRADE: {
|
|
129
|
+
schema: {
|
|
130
|
+
INBOUND: {
|
|
131
|
+
properties: {
|
|
132
|
+
DETAILS: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
properties: {
|
|
135
|
+
INSERT_SIDE: { type: 'string', enum: ['IN', 'OUT'] },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const { contracts, enums, unmapped } = parseEventMetadataDump(enumDump);
|
|
146
|
+
|
|
147
|
+
const tradeSide = enums.find((e) => e.name === 'TradeInsertSide');
|
|
148
|
+
const orderSide = enums.find((e) => e.name === 'OrderInsertSide');
|
|
149
|
+
const tradeStatus = enums.find((e) => e.name === 'TradeInsertStatus');
|
|
150
|
+
const settlement = enums.find((e) => e.name === 'TradeInsertSettlement');
|
|
151
|
+
const myField = enums.find((e) => e.name === 'TradeInsertMyField');
|
|
152
|
+
const collision = enums.find((e) => e.name === 'TradeInsertSide2');
|
|
153
|
+
|
|
154
|
+
assert.ok(tradeSide);
|
|
155
|
+
assert.ok(orderSide);
|
|
156
|
+
assert.ok(tradeStatus);
|
|
157
|
+
assert.ok(settlement);
|
|
158
|
+
assert.ok(myField);
|
|
159
|
+
assert.ok(collision);
|
|
160
|
+
// EVENT_TRADE sorts before EVENT_TRADE_INSERT, so INSERT_SIDE wins TradeInsertSide
|
|
161
|
+
assert.equal(tradeSide!.values.join(','), 'IN,OUT');
|
|
162
|
+
assert.equal(orderSide!.values.join(','), 'LONG,SHORT');
|
|
163
|
+
assert.equal(tradeStatus!.values.join(','), 'OPEN,CLOSED');
|
|
164
|
+
assert.equal(settlement!.values.join(','), "T+1,N/A,O'Brien");
|
|
165
|
+
assert.equal(collision!.values.join(','), 'BUY,SELL');
|
|
166
|
+
assert.ok(unmapped.some((line) => line.includes('enum name collision for TradeInsertSide')));
|
|
167
|
+
|
|
168
|
+
const trade = contracts.find((c) => c.eventName === 'EVENT_TRADE_INSERT')!;
|
|
169
|
+
assert.equal(trade.fields.find((f) => f.name === 'SIDE')!.tsType, 'TradeInsertSide2');
|
|
170
|
+
assert.equal(trade.fields.find((f) => f.name === 'status')!.tsType, 'TradeInsertStatus');
|
|
171
|
+
assert.equal(trade.fields.find((f) => f.name === 'MY-FIELD')!.tsType, 'TradeInsertMyField');
|
|
172
|
+
|
|
173
|
+
const tradeOnly = contracts.find((c) => c.eventName === 'EVENT_TRADE')!;
|
|
174
|
+
assert.equal(tradeOnly.fields.find((f) => f.name === 'INSERT_SIDE')!.tsType, 'TradeInsertSide');
|
|
175
|
+
|
|
176
|
+
const rendered = renderEventTypesFromMetadataContracts(contracts, enums, unmapped);
|
|
177
|
+
assert.is(rendered.includes('export const TradeInsertSide'), true);
|
|
178
|
+
assert.is(rendered.includes('export const OrderInsertSide'), true);
|
|
179
|
+
assert.is(rendered.includes('SIDE?: TradeInsertSide2'), true);
|
|
180
|
+
assert.is(rendered.includes("'MY-FIELD'?: TradeInsertMyField"), true);
|
|
181
|
+
// Non-identifier / quote-bearing values must be quoted on both sides
|
|
182
|
+
assert.is(rendered.includes("'T+1': 'T+1'"), true);
|
|
183
|
+
assert.is(rendered.includes("'N/A': 'N/A'"), true);
|
|
184
|
+
assert.is(rendered.includes("'O\\'Brien': 'O\\'Brien'"), true);
|
|
185
|
+
// Emitted file must be syntactically valid JS for the enum object
|
|
186
|
+
const enumBlock = rendered.slice(
|
|
187
|
+
rendered.indexOf('export const TradeInsertSettlement'),
|
|
188
|
+
rendered.indexOf('export type TradeInsertSettlement'),
|
|
189
|
+
);
|
|
190
|
+
assert.ok(enumBlock.includes('as const'));
|
|
191
|
+
const runnable = enumBlock.replace(/^export\s+/m, '').replace(/\s+as const/, '');
|
|
192
|
+
// eslint-disable-next-line no-new-func -- assert generated enum object parses
|
|
193
|
+
const parsed = new Function(`${runnable}; return TradeInsertSettlement;`)();
|
|
194
|
+
assert.equal(parsed['T+1'], 'T+1');
|
|
195
|
+
assert.equal(parsed['N/A'], 'N/A');
|
|
196
|
+
assert.equal(parsed["O'Brien"], "O'Brien");
|
|
197
|
+
|
|
198
|
+
const ifaceStart = rendered.indexOf('export interface TradeInsertDetails {');
|
|
199
|
+
const ifaceEnd = rendered.indexOf('}', ifaceStart);
|
|
200
|
+
const ifaceBody = rendered.slice(rendered.indexOf('{', ifaceStart) + 1, ifaceEnd);
|
|
201
|
+
const objectLiteral = `{${ifaceBody.replace(/\?:/g, ':').replace(/: [^;]+;/g, ': 1,')}}`;
|
|
202
|
+
// eslint-disable-next-line no-new-func -- assert generated interface keys parse
|
|
203
|
+
const keys = new Function(`return ${objectLiteral};`)() as Record<string, number>;
|
|
204
|
+
assert.equal(keys['MY-FIELD'], 1);
|
|
205
|
+
assert.equal(keys.SIDE, 1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
suite.run();
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/config.ts","./src/index.ts","./src/kotlin-parse-utils.ts","./src/naming.ts","./src/parse-dao.ts","./src/parse-enums.ts","./src/parse-kotlin.ts","./src/render.ts","./src/types.ts"],"version":"5.9.2"}
|
|
1
|
+
{"root":["./src/config.ts","./src/index.ts","./src/kotlin-parse-utils.ts","./src/naming.ts","./src/parse-dao.ts","./src/parse-enums.ts","./src/parse-kotlin.ts","./src/parse-metadata.ts","./src/render.ts","./src/types.ts"],"version":"5.9.2"}
|