import { dtoFields, eventDefaultOf, eventFields, inferEventTable, knownValues, isDeleteEventName, querySchema, requestFieldsOf, requestReplyReplySchema, requestReplySourceSchema, resolveEventTarget, resolveSourceSchema, typedFields, withTypeOverrides, type EventTarget, type ResolvedField, } from '../db/schema.ts'; import type { Store } from '../db/store.ts'; import type { Connection } from '../protocol/connection.ts'; import { isNestedType, jsonDefs, nestedClasses, nestedProperty } from '../protocol/dtoTypes.ts'; import { isLegacyFidelity } from '../protocol/fidelity.ts'; import { BIGDECIMAL_PATTERN, enumClassOf, jsonTypeOf, jvmClassOf, rangeOf, titleCase, } from '../protocol/fieldTypes.ts'; import { dataLogonReplySchema, dataLogonSchema, eventInboundProperties, eventReplySchema, GENESIS_META_SCHEMA, REQUEST_DETAILS_SCHEMA, } from '../protocol/gsfSchemas.ts'; import { ERROR_CODE, MESSAGE_TYPE } from '../protocol/messageTypes.ts'; import { resourceNotMappedNack } from '../protocol/msgNack.ts'; import type { FieldMetadata, GenesisMessage, MockServerConfig, QueryDef, RequestReplyDef, Row, } from '../types.ts'; // Kept here for existing importers; the resolver lives in db/schema.ts. export { inferEventTable }; // JSON_SCHEMA_REQUEST shares its request envelope with META_REQUEST (same // DETAILS.FEATURE resource-name field) but its reply is a completely // different shape: Connect.getJSONSchema doesn't read response.DETAILS at // all — the response *is* the SchemaResponse ({ INBOUND, OUTBOUND }, each a // JSON Schema). Who reads what: // - foundation-forms' (and so entity-management's create // and update forms) renders INBOUND.properties.DETAILS of an EVENT: its // properties, required, enum, default, title, and genesisType / the // org.joda.time.DateTime description for date pickers. // - entity-management's delete copies Object.keys(INBOUND...DETAILS // .properties) from the selected row into the commit DETAILS. // - foundation-forms' renders a QUERY's or REQUEST // SERVER's rows: OUTBOUND.properties.REPLY.items, else // OUTBOUND.$defs[...QueryRow]. // Route this to handleMetaRequest by mistake and forms silently render with // zero fields, and delete can never build a payload — no error, just a dead // button. // --- events ----------------------------------------------------------------- // One DETAILS property of an event's INBOUND schema. Nullable fields are // oneOf [schema, null]; BigDecimal is a string with a pattern and no // genesisType; dates are integers (epoch millis) described as DateTime. Only // a table event's DAO class, and a DAO a DTO unwraps, have titles, lengths, // ranges and genesisTypes (see meta.ts eventMetaField). function eventProperty(field: ResolvedField, table: boolean, appName?: string): Row { const type = field.type; if (isNestedType(type)) return nestedProperty(field); const annotated = table || field.dao === true; const property: Row = {}; if (annotated && type === 'STRING') { if (field.minLength !== undefined) property.minLength = field.minLength; if (field.maxSize !== undefined) property.maxLength = field.maxSize; } if (type === 'BIGDECIMAL') property.pattern = BIGDECIMAL_PATTERN; const range = annotated ? rangeOf(field) : undefined; if (range) [property.minimum, property.maximum] = range; property.readOnly = false; if (type === 'ENUM' && knownValues(field)) property.enum = knownValues(field); property.description = field.description ?? jvmClassOf(type, () => field.enumClass ?? enumClassOf(field.table, field.name, appName)); const fieldDefault = eventDefaultOf(field); if (fieldDefault !== undefined) property.default = fieldDefault; if (annotated) property.title = field.title ?? titleCase(field.name); if (annotated && type !== 'BIGDECIMAL') property.genesisType = type; property.type = jsonTypeOf(type); return field.nullable ? { oneOf: [property, { type: 'null' }] } : property; } // required: the fields that aren't optional (JsonSchema.kt:478), as // eventFields resolved them. A sequence key is optional: the database fills // it in; a nullable DTO field with no default is still required. function isRequired(field: ResolvedField): boolean { return !field.optional; } // The nested classes an INBOUND schema's $refs name, as GSF writes them for // its validator and its JSON_SCHEMA reply alike // (SingleObjectDefinition.toJsonSchema, JsonSchema.kt:474-486 @v8.15.29): // each property as DETAILS' are, the required ones listed (an empty list // too), and no additionalProperties, so a nested item may carry keys its // class doesn't have, which the decoder drops. A reply's $defs are written // the other way (dtoTypes.ts jsonDefs). function inboundDefs(fields: ResolvedField[], table?: string, appName?: string): Row { return Object.fromEntries( [...nestedClasses(fields).values()].map((nested) => { const properties = dtoFields(nested.fields, table); return [ nested.className, { properties: Object.fromEntries( properties.map((field) => [field.name, eventProperty(field, false, appName)]), ), required: properties.filter(isRequired).map((field) => field.name), description: nested.className, type: 'object', }, ]; }), ); } // An event's INBOUND schema: the message, with DETAILS as the event's class. // It is also what an inbound event is validated against // (handlers/eventValidation.ts), as on the real server. export function eventInboundSchema( eventName: string, target: EventTarget, fields: ResolvedField[], appName: string | undefined, ): Row { const annotated = target.kind === 'table'; const details = { properties: Object.fromEntries( fields.map((field) => [field.name, eventProperty(field, annotated, appName)]), ), required: fields.filter(isRequired).map((field) => field.name), additionalProperties: false, type: 'object', }; return { properties: eventInboundProperties(eventName, details), required: ['MESSAGE_TYPE', 'SOURCE_REF', 'USER_NAME', 'DETAILS'], additionalProperties: true, description: target.className, ...(target.title ? { title: target.title } : {}), $defs: inboundDefs(fields, target.table, appName), $schema: GENESIS_META_SCHEMA, type: 'object', }; } function eventSchema( eventName: string, target: EventTarget, fields: ResolvedField[], appName: string | undefined, ): { INBOUND: Row; OUTBOUND: Row } { return { INBOUND: eventInboundSchema(eventName, target, fields, appName), OUTBOUND: eventReplySchema(), }; } // --- dataservers and request servers --------------------------------------- function typeSchema(field: ResolvedField): Row { return { type: jsonTypeOf(field.type), ...(field.type === 'ENUM' && knownValues(field) ? { enum: knownValues(field) } : {}), }; } // A row property in a reply schema: nullable as oneOf [null, schema], with // an empty description and the Genesis type (BigDecimal included); a typed // server's list, map or nested class as a DTO's is. function rowProperty(field: ResolvedField, nullable: boolean): Row { if (isNestedType(field.type)) return nestedProperty({ ...field, nullable }); return nullable ? { oneOf: [{ type: 'null' }, typeSchema(field)], description: '', genesisType: field.type } : { ...typeSchema(field), description: '', genesisType: field.type }; } // Every QueryRow property is nullable: a live MODIFY carries only the fields // that changed. function dataserverSchema( queryName: string, query: QueryDef, config: MockServerConfig, store: Store, ): { INBOUND: Row; OUTBOUND: Row } { const fields = querySchema(query, config, store).fields; return { INBOUND: dataLogonSchema(queryName), OUTBOUND: dataLogonReplySchema( Object.fromEntries(fields.map((field) => [field.name, rowProperty(field, true)])), ), }; } function requestServerSchema( name: string, requestReply: RequestReplyDef, config: MockServerConfig, store: Store, ): { INBOUND: Row; OUTBOUND: Row } { // A typed server's request and reply classes (not recorded: in the // envelope a GPAL request server's schema has), else the request block and // the source's fields. const typed = requestReply.typed; const entityFields = typed ? [] : requestReplySourceSchema(requestReply, config, store).fields; const requestFields = typed ? typedFields(typed.request.fields) : requestFieldsOf(requestReply, store).map( (fieldName) => entityFields.find((candidate) => candidate.name === fieldName) ?? ({ name: fieldName, type: 'STRING' } as ResolvedField), ); const requestProperties = Object.fromEntries( requestFields.map((field) => [ field.name, isNestedType(field.type) ? nestedProperty(field) : { readOnly: false, ...(field.type === 'ENUM' && knownValues(field) ? { enum: knownValues(field) } : {}), description: '', genesisType: field.type, type: jsonTypeOf(field.type), }, ]), ); const request = { properties: requestProperties, additionalProperties: false, type: 'object' }; // The reply block, else the source's fields then RECORD_ID and TIMESTAMP, // as REPLY_FIELD (see requestReplyReplySchema). const fields = typed ? typedFields(typed.reply.fields) : requestReplyReplySchema(requestReply, config, store).fields; // The nested classes a typed server's reply $refs name. const replyDefs = typed ? jsonDefs(nestedClasses(fields), (nested) => dtoFields(nested.fields)) : {}; return { INBOUND: { properties: { MESSAGE_TYPE: { const: `REQ_${name}`, description: 'Message type', type: 'string' }, SOURCE_REF: { description: 'Source reference', type: 'string' }, USER_NAME: { description: 'User name', type: 'string' }, REQUEST: { oneOf: [request, { items: request, type: 'array' }] }, DETAILS: REQUEST_DETAILS_SCHEMA, }, required: ['MESSAGE_TYPE', 'SOURCE_REF', 'USER_NAME', 'REQUEST'], additionalProperties: false, description: `GPAL Request definition for ${name}`, $defs: typed ? inboundDefs(requestFields) : {}, type: 'object', }, OUTBOUND: { description: `GPAL Reply definition for ${name}`, type: 'object', properties: { MESSAGE_TYPE: { const: `REP_${name}` }, SOURCE_REF: { type: 'string' }, REPLY: { type: 'array', items: { type: 'object', properties: Object.fromEntries( fields.map((field) => [field.name, rowProperty(field, field.nullable)]), ), additionalProperties: false, required: [], }, }, NEXT_VIEW: { type: 'number' }, }, required: ['MESSAGE_TYPE', 'SOURCE_REF'], additionalProperties: false, ...(Object.keys(replyDefs).length > 0 ? { $defs: replyDefs } : {}), }, }; } // A request server's reply row properties, as its OUTBOUND schema's // REPLY.items gives them, or undefined when the config has no such server. export function requestServerReplyProperties( name: string, config: MockServerConfig, store: Store, ): Row | undefined { const requestReply = config.requestReplies?.[name]; if (!requestReply) return undefined; return requestServerSchema(name, requestReply, config, store).OUTBOUND.properties.REPLY.items .properties; } // The FEATURE resolves, in order, as: an entry in config.eventSchemas, a // dataserver query, a request server (by resource name, without REQ_), an // EVENT__ event, and finally a bare table name — the last is // this engine's own fallback for clients that ask by table (the real server // answers 404 there). Anything else gets the router's 404. export function handleJsonSchemaRequest( message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store, ): void { const feature: string | undefined = message.DETAILS?.FEATURE; if (isLegacyFidelity(config)) { handleLegacyJsonSchemaRequest(message, connection, config, store, feature); return; } const overridden = feature !== undefined && !!config.eventSchemas?.[feature]; const query = !overridden && feature ? config.queries?.[feature] : undefined; const requestReply = !overridden && feature ? config.requestReplies?.[feature] : undefined; let schema: { INBOUND: Row; OUTBOUND: Row } | undefined; if (query) { schema = dataserverSchema(feature!, query, config, store); } else if (requestReply) { schema = requestServerSchema(feature!, requestReply, config, store); } else { const event = resolveEventTarget(feature, config, { bareTable: true }); if (event) { schema = eventSchema(feature!, event, eventFields(event, config, store), config.appName); } } if (!schema) { connection.reply(message, resourceNotMappedNack(feature)); return; } connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.JSON_SCHEMA_FEATURE_ACK, ...schema }); } // --- fidelity: 'legacy' ----------------------------------------------------- // Up to 15.47 every FEATURE resolved to a table (or view) and got the same // minimal schema as INBOUND and OUTBOUND: {type, properties: {DETAILS: // {type, properties: {: {type}}, required}}}, `required` being the key // on deletes and empty otherwise. A query or request-server name resolved // through its `source`. The reply echoed JSON_SCHEMA_REQUEST. function legacyJsonSchemaType(fieldType: string): string { switch (fieldType) { case 'LONG': return 'integer'; case 'DOUBLE': return 'number'; case 'BOOLEAN': return 'boolean'; default: return 'string'; } } function legacySchema(fields: FieldMetadata[], required: string[]): Row { const properties: Row = {}; for (const field of fields) { properties[field.NAME] = { type: legacyJsonSchemaType(field.TYPE) }; } return { type: 'object', properties: { DETAILS: { type: 'object', properties, required }, }, }; } function handleLegacyJsonSchemaRequest( message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store, feature: string | undefined, ): void { const override = feature ? config.eventSchemas?.[feature] : undefined; const resourceSource = feature ? (config.queries?.[feature]?.source ?? config.requestReplies?.[feature]?.source) : undefined; const table = override?.table ?? resourceSource ?? inferEventTable(feature, Object.keys(config.tables ?? {})); if (!table) { connection.reply(message, { MESSAGE_TYPE: message.MESSAGE_TYPE, ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${feature}` }], }); return; } const pkOnly = override?.pkOnly ?? isDeleteEventName(feature!); const pkFields = store.getPkFields(table); let fields: FieldMetadata[] = withTypeOverrides( resolveSourceSchema(table, config, store), override?.fieldTypes, ).fields.map((field) => ({ NAME: field.name, TYPE: field.type })); if (pkOnly) fields = fields.filter((field) => pkFields.includes(field.NAME)); const schema = legacySchema(fields, pkOnly ? pkFields : []); connection.reply(message, { MESSAGE_TYPE: message.MESSAGE_TYPE, INBOUND: schema, OUTBOUND: schema, }); }