@genesislcap/mock-server 15.51.1 → 15.53.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.
Files changed (70) hide show
  1. package/README.md +408 -39
  2. package/dist/db/criteria.d.ts +13 -0
  3. package/dist/db/criteria.js +60 -21
  4. package/dist/db/identity.d.ts +11 -0
  5. package/dist/db/identity.js +46 -0
  6. package/dist/db/ordering.d.ts +8 -0
  7. package/dist/db/ordering.js +49 -0
  8. package/dist/db/schema.d.ts +62 -0
  9. package/dist/db/schema.js +490 -0
  10. package/dist/db/store.d.ts +20 -3
  11. package/dist/db/store.js +300 -50
  12. package/dist/handlers/commitEvent.js +47 -9
  13. package/dist/handlers/crudEvents.d.ts +2 -0
  14. package/dist/handlers/crudEvents.js +138 -0
  15. package/dist/handlers/dataLogon.js +105 -6
  16. package/dist/handlers/eventValidation.d.ts +5 -0
  17. package/dist/handlers/eventValidation.js +29 -0
  18. package/dist/handlers/jsonSchema.d.ts +4 -2
  19. package/dist/handlers/jsonSchema.js +222 -61
  20. package/dist/handlers/meta.d.ts +6 -1
  21. package/dist/handlers/meta.js +168 -21
  22. package/dist/handlers/requestReply.js +519 -30
  23. package/dist/handlers/resourceAuth.d.ts +1 -0
  24. package/dist/handlers/resourceAuth.js +14 -0
  25. package/dist/handlers/resources.js +3 -0
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.js +2 -1
  28. package/dist/protocol/connection.d.ts +2 -0
  29. package/dist/protocol/errors.d.ts +100 -2
  30. package/dist/protocol/errors.js +300 -12
  31. package/dist/protocol/fieldTypes.d.ts +10 -0
  32. package/dist/protocol/fieldTypes.js +107 -0
  33. package/dist/protocol/gsfSchemas.d.ts +9 -0
  34. package/dist/protocol/gsfSchemas.js +423 -0
  35. package/dist/protocol/messageTypes.d.ts +1 -0
  36. package/dist/protocol/messageTypes.js +1 -0
  37. package/dist/protocol/msgNack.d.ts +2 -0
  38. package/dist/protocol/msgNack.js +10 -0
  39. package/dist/protocol/rowUpdate.d.ts +5 -3
  40. package/dist/protocol/rowUpdate.js +33 -15
  41. package/dist/protocol/schemaValidation.d.ts +11 -0
  42. package/dist/protocol/schemaValidation.js +186 -0
  43. package/dist/server.js +95 -29
  44. package/dist/types.d.ts +45 -0
  45. package/package.json +1 -1
  46. package/src/db/criteria.ts +72 -21
  47. package/src/db/identity.ts +53 -0
  48. package/src/db/ordering.ts +56 -0
  49. package/src/db/schema.ts +672 -0
  50. package/src/db/store.ts +343 -50
  51. package/src/handlers/commitEvent.ts +54 -10
  52. package/src/handlers/crudEvents.ts +181 -0
  53. package/src/handlers/dataLogon.ts +127 -7
  54. package/src/handlers/eventValidation.ts +43 -0
  55. package/src/handlers/jsonSchema.ts +302 -69
  56. package/src/handlers/meta.ts +227 -23
  57. package/src/handlers/requestReply.ts +626 -33
  58. package/src/handlers/resourceAuth.ts +18 -0
  59. package/src/handlers/resources.ts +3 -0
  60. package/src/index.ts +17 -1
  61. package/src/protocol/connection.ts +10 -2
  62. package/src/protocol/errors.ts +361 -13
  63. package/src/protocol/fieldTypes.ts +115 -0
  64. package/src/protocol/gsfSchemas.ts +505 -0
  65. package/src/protocol/messageTypes.ts +1 -0
  66. package/src/protocol/msgNack.ts +12 -0
  67. package/src/protocol/rowUpdate.ts +40 -20
  68. package/src/protocol/schemaValidation.ts +208 -0
  69. package/src/server.ts +106 -28
  70. package/src/types.ts +187 -5
@@ -1,3 +1,4 @@
1
+ import { projectRow } from '../db/schema.ts';
1
2
  import type { Connection } from '../protocol/connection.ts';
2
3
  import type { ResourceAuthDef, RequestingUser, Row } from '../types.ts';
3
4
 
@@ -35,3 +36,20 @@ export function applyResourceAuth(
35
36
  }
36
37
  return result;
37
38
  }
39
+
40
+ // One dataserver row as this user sees it: undefined when rowFilter rejects
41
+ // it, else only the query's columns (projectRow: fieldNames from
42
+ // queryFieldNames) minus the hidden ones. rowFilter sees the whole row, so a
43
+ // filter may read a column the query doesn't expose.
44
+ export function permittedQueryRow(
45
+ row: Row,
46
+ auth: ResourceAuthDef | undefined,
47
+ user: RequestingUser,
48
+ fieldNames: string[] | undefined,
49
+ ): Row | undefined {
50
+ if (auth?.rowFilter && !auth.rowFilter(row, user)) return undefined;
51
+ const projected = projectRow(row, fieldNames);
52
+ const hidden = hiddenFields(auth, user);
53
+ if (hidden.size === 0) return projected;
54
+ return Object.fromEntries(Object.entries(projected).filter(([field]) => !hidden.has(field)));
55
+ }
@@ -1,3 +1,4 @@
1
+ import { crudEventNames } from '../db/schema.ts';
1
2
  import type { Connection } from '../protocol/connection.ts';
2
3
  import { MESSAGE_TYPE } from '../protocol/messageTypes.ts';
3
4
  import type { GenesisMessage, MockServerConfig, ResourceItem } from '../types.ts';
@@ -19,6 +20,8 @@ function deriveResources(config: MockServerConfig): ResourceItem[] {
19
20
  }
20
21
  const eventNames = new Set([
21
22
  ...Object.keys(config.eventHandlers ?? {}),
23
+ // The generic CRUD events tables register through TableDef.events.
24
+ ...crudEventNames(config),
22
25
  // Events served only by eventSchemas + defaultEventHandler still need to
23
26
  // be discoverable, or the client-side resource gate blocks them before
24
27
  // the commit is ever sent.
package/src/index.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  export { createMockServer, type MockServer } from './server.ts';
2
2
  export { launchStack, type LaunchStackOptions, type LaunchedStack } from './launch.ts';
3
3
  export { Store } from './db/store.ts';
4
- export { NackError, type NackErrorInput, type NackErrorEntry } from './protocol/errors.ts';
4
+ export { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from './db/identity.ts';
5
+ export {
6
+ ErrorCode,
7
+ NackError,
8
+ statusCodeFor,
9
+ type ErrorCodeName,
10
+ type GenesisErrorType,
11
+ type NackErrorEntry,
12
+ type NackErrorInput,
13
+ type NackErrorOptions,
14
+ } from './protocol/errors.ts';
5
15
  export { compileCriteria, filterByCriteria, type Predicate } from './db/criteria.ts';
6
16
  export type {
7
17
  AntiJoinDef,
@@ -9,14 +19,19 @@ export type {
9
19
  AuthUser,
10
20
  BroadcastFn,
11
21
  BroadcastTableChangeFn,
22
+ CrudEventVerb,
12
23
  EventHandler,
13
24
  EventHandlerCtx,
14
25
  EventHandlerResult,
15
26
  EventSchemaOverride,
16
27
  FidelityMode,
28
+ FieldDef,
17
29
  FieldMetadata,
30
+ GeneratedFieldDef,
31
+ GenesisFieldType,
18
32
  GenesisMessage,
19
33
  HttpRouteDef,
34
+ IndexDef,
20
35
  JoinDef,
21
36
  MockServerConfig,
22
37
  OnMessageCtx,
@@ -28,6 +43,7 @@ export type {
28
43
  ResourceAuthDef,
29
44
  ResourceItem,
30
45
  Row,
46
+ SequenceFormat,
31
47
  SocketLike,
32
48
  SsoIdentityProvider,
33
49
  TableDef,
@@ -6,8 +6,9 @@ const WS_OPEN = 1;
6
6
 
7
7
  export interface Subscription {
8
8
  resourceName: string;
9
- // Computes the DETAILS.ROW_REF value for a pushed row (single pk value, or
10
- // the joined composite key) — bound to Store.getRowRef at subscribe time.
9
+ // Computes the DETAILS.ROW_REF value for a pushed row (its RECORD_ID as a
10
+ // string; in legacy mode the pk value or joined composite key) — see
11
+ // rowRefFor in protocol/rowUpdate.ts.
11
12
  getRowRef: (row: Row) => unknown;
12
13
  predicate: Predicate;
13
14
  // SEQUENCE_ID of the last QUERY_UPDATE sent on this subscription (the
@@ -27,6 +28,13 @@ export interface Subscription {
27
28
  held?: Set<string>;
28
29
  maxRows?: number;
29
30
  maxView?: number;
31
+ // The columns this subscription's rows carry (the query's fields block or
32
+ // declared columns, narrowed by DETAILS.FIELDS); undefined: rows as stored.
33
+ fieldNames?: string[];
34
+ // Set under ORDER_BY: offered every live INSERT (stamped, and the raw
35
+ // row). Returns true when the row sorts among the rows still waiting for
36
+ // MORE_ROWS, and so was queued there in order instead of being pushed.
37
+ queueInsert?: (key: string, stamped: Row, row: Row) => boolean;
30
38
  }
31
39
 
32
40
  // Wraps one WebSocket client connection: auth state and its live DATA_LOGON
@@ -1,32 +1,380 @@
1
+ // The error vocabulary of service NACKs (EVENT_NACK, REP_* errors), as GSF
2
+ // 8.15.x serializes it: genesis-messages' GenesisError family
3
+ // (global.genesis.message.core.error.GenesisError.kt) and the ErrorCode enum
4
+ // (ErrorCode.kt). Every item names its class in `@type`, carries a string
5
+ // CODE, TEXT and a "<code> <reason>" STATUS_CODE, plus the class's own
6
+ // fields; the envelope carries WARNING next to ERROR. Checked against frames
7
+ // recorded from a GSF 8.15.29 app (test/conformance/recordings).
8
+ //
9
+ // The router's MSG_NACK is a different family (no @type, an integer CODE,
10
+ // DETAILS {ERROR, STATUS_CODE}) — see msgNack.ts.
11
+
12
+ // HttpStatusCode.kt's status lines ("$code $description") for the statuses
13
+ // ErrorCode uses.
14
+ const BAD_REQUEST = '400 Bad Request';
15
+ const UNAUTHORIZED = '401 Unauthorized';
16
+ const FORBIDDEN = '403 Forbidden';
17
+ const NOT_FOUND = '404 Not Found';
18
+ const REQUEST_TIMEOUT = '408 Request Timeout';
19
+ const TOO_MANY_REQUESTS = '429 Too Many Requests';
20
+ const INTERNAL_SERVER_ERROR = '500 Internal Server Error';
21
+ const SERVICE_UNAVAILABLE = '503 Service Unavailable';
22
+
23
+ // [readable text, HTTP status line] per ErrorCode.kt entry, in the enum's order.
24
+ // The readable text is what StandardError(ErrorCode.X) sends as TEXT when the
25
+ // caller gives none.
26
+ const ERROR_CODES = {
27
+ GENERIC_ERROR: ['Something is wrong', INTERNAL_SERVER_ERROR],
28
+ MISSING_FIELD: ['Field is missing', BAD_REQUEST],
29
+ VALIDATION_ERROR: ['Message failed validation', BAD_REQUEST],
30
+ INVALID_MESSAGE: ['Invalid message or invalid message format', BAD_REQUEST],
31
+ NOT_SUPPORTED_ENUM_VALUE: ['Enum value not supported', BAD_REQUEST],
32
+ NOT_AUTHORISED: ['Not authorised', FORBIDDEN],
33
+ DUPLICATE_KEY: [
34
+ 'Record conflicts with unique index already stored in the database',
35
+ INTERNAL_SERVER_ERROR,
36
+ ],
37
+ INVALID_MESSAGE_TYPE: ['Invalid message type', BAD_REQUEST],
38
+ INVALID_DATASOURCE: ['Invalid DATASOURCE_NAME', BAD_REQUEST],
39
+ INVALID_PARAMETER: ["MOVING_VIEW can't be true if maxView is set to 0", BAD_REQUEST],
40
+ LOGIN_ERROR: ['There was a problem logging in to the SOAP WebService', UNAUTHORIZED],
41
+ MULTIPLE_TABLES: ['Only one table can be referred', INTERNAL_SERVER_ERROR],
42
+ MISSING_KEY: ['Key name must be provided', BAD_REQUEST],
43
+ UNKNOWN_TABLE: ['No such table', BAD_REQUEST],
44
+ UNKNOWN_FIELD: ['No such field on table', BAD_REQUEST],
45
+ UNKNOWN: ['Unknown error', INTERNAL_SERVER_ERROR],
46
+ NO_MESSAGE_TYPE: ['No message type provided', BAD_REQUEST],
47
+ NO_SOURCE_REF: ['No source ref provided', BAD_REQUEST],
48
+ NO_USER_NAME: ['No user name provided', BAD_REQUEST],
49
+ UNAVAILABLE: ['Process is currently unavailable', SERVICE_UNAVAILABLE],
50
+ UNKNOWN_MESSAGE_TYPE: ['Message type not supported', BAD_REQUEST],
51
+ FEATURE_NOT_PROVIDED: ['Feature not provided', BAD_REQUEST],
52
+ FEATURE_NOT_FOUND: ['Feature not found', NOT_FOUND],
53
+ JSON_SCHEMA_NOT_FOUND: ['JSON schema not found', NOT_FOUND],
54
+ REJECT_RULE_DOES_NOT_EXIST: ['Failed to find Dynamic Rule', NOT_FOUND],
55
+ ERROR_CHECKING_EXISTING_RULE: ['Error while checking for rule', BAD_REQUEST],
56
+ NO_DS_NAME: ['No data source provided', BAD_REQUEST],
57
+ INVALID_DS_NAME: ['Data source does not exist', NOT_FOUND],
58
+ INVALID_INDEX: ['Invalid index', BAD_REQUEST],
59
+ REJECT_RULE_MISSING: ['Rule does not exist', NOT_FOUND],
60
+ ERROR_MODIFYING_RULE: ['Error encountered trying to modify rule', INTERNAL_SERVER_ERROR],
61
+ INVALID_CRITERIA: ['Criteria has failed validation', BAD_REQUEST],
62
+ MAX_LOGON_LIMIT: ['Tried to connect too many times', TOO_MANY_REQUESTS],
63
+ RECORD_NOT_FOUND: ['No record found for given key data', NOT_FOUND],
64
+ SERVICE_NOT_FOUND: ['No service found', NOT_FOUND],
65
+ DATABASE_FAILURE: ['There was an internal database error', INTERNAL_SERVER_ERROR],
66
+ DATABASE_ERROR: ['Database error', INTERNAL_SERVER_ERROR],
67
+ OPERATION_TIMEOUT: ['Server timed out', REQUEST_TIMEOUT],
68
+ DEPENDENT_RECORD_FOUND: [
69
+ 'Dependency found on other records for changed value(s)',
70
+ INTERNAL_SERVER_ERROR,
71
+ ],
72
+ REQUIRES_APPROVAL: ['Transaction requires approval', FORBIDDEN],
73
+ APPROVAL_MESSAGE_MISSING: [
74
+ 'Transaction requires approval, but the approval message is missing',
75
+ BAD_REQUEST,
76
+ ],
77
+ INTERNAL_ERROR: ['Unexpected server error', INTERNAL_SERVER_ERROR],
78
+ GATEWAY_ERROR: ['Notify gateway error', BAD_REQUEST],
79
+ REQUEST_FAILED: ['Problem handling request', BAD_REQUEST],
80
+ UNABLE_TO_UPDATE_APPROVAL: [
81
+ 'Pending approval error, unable to update approval',
82
+ INTERNAL_SERVER_ERROR,
83
+ ],
84
+ APPROVAL_SAME_USER_CANNOT_ACCEPT: [
85
+ 'Pending approval error, same user cannot accept',
86
+ BAD_REQUEST,
87
+ ],
88
+ APPROVAL_RECORD_NOT_FOUND: ['Pending approval record not found', NOT_FOUND],
89
+ REJECTED_BY_SERVICE: ['Pending approval rejected by service', INTERNAL_SERVER_ERROR],
90
+ APPROVAL_DIFF_USER_CANNOT_CANCEL: ['Pending approval different user cannot cancel', BAD_REQUEST],
91
+ APPROVAL_WRONG_STATUS_CANNOT_CANCEL: [
92
+ 'Pending approval error, wrong status cannot cancel',
93
+ BAD_REQUEST,
94
+ ],
95
+ APPROVAL_WRONG_STATUS_CANNOT_ACCEPT: [
96
+ 'Pending approval error, wrong status cannot accept',
97
+ BAD_REQUEST,
98
+ ],
99
+ APPROVAL_SAME_USER_CANNOT_REJECT: [
100
+ 'Pending approval error, same user cannot reject',
101
+ BAD_REQUEST,
102
+ ],
103
+ APPROVAL_WRONG_STATUS_CANNOT_REJECT: [
104
+ 'Pending approval error, wrong status cannot reject',
105
+ BAD_REQUEST,
106
+ ],
107
+ MISSING_HOSTNAME: ['Missing hostname', BAD_REQUEST],
108
+ NUMBER_OF_RECORDS_DOES_NOT_MATCH: [
109
+ 'Event nack, Number of keys and records does not match',
110
+ BAD_REQUEST,
111
+ ],
112
+ OPTIMISTIC_CONCURRENCY_ERROR: ['Optimistic concurrency error', BAD_REQUEST],
113
+ } as const satisfies Record<string, readonly [string, string]>;
114
+
115
+ export type ErrorCodeName = keyof typeof ERROR_CODES;
116
+
117
+ // GSF's ErrorCode names, for CODE values: ErrorCode.DUPLICATE_KEY ===
118
+ // 'DUPLICATE_KEY'. CODE is a plain string on the wire, so a project's own
119
+ // codes work too (their STATUS_CODE defaults to 500, as StandardError's does).
120
+ export const ErrorCode = Object.fromEntries(
121
+ Object.keys(ERROR_CODES).map((name) => [name, name]),
122
+ ) as { readonly [Name in ErrorCodeName]: Name };
123
+
124
+ // Codes that aren't in ErrorCode.kt but do appear in platform NACKs.
125
+ const EXTRA_STATUSES: Record<string, string> = {
126
+ INCORRECT_CREDENTIALS: UNAUTHORIZED, // platform-auth's LoginError
127
+ };
128
+
129
+ function knownCode(code: string): readonly [string, string] | undefined {
130
+ return Object.hasOwn(ERROR_CODES, code) ? ERROR_CODES[code as ErrorCodeName] : undefined;
131
+ }
132
+
133
+ // The "<code> <reason>" STATUS_CODE for a CODE: the ErrorCode's own status,
134
+ // or 500 for codes GSF doesn't know (GenesisError's default statusCode).
135
+ export function statusCodeFor(code: string): string {
136
+ return knownCode(code)?.[1] ?? EXTRA_STATUSES[code] ?? INTERNAL_SERVER_ERROR;
137
+ }
138
+
139
+ // ErrorCode's readable text, used as TEXT when an error gives none.
140
+ export function defaultTextFor(code: string): string | undefined {
141
+ return knownCode(code)?.[0];
142
+ }
143
+
144
+ export type GenesisErrorType = 'StandardError' | 'FieldError' | 'TableStandardError' | 'LoginError';
145
+
146
+ // What a handler throws (inside a NackError) or returns as a warning. Keys
147
+ // are accepted in either case. Without '@type', the class is inferred: TABLE
148
+ // makes a TableStandardError, FIELD (or PATH) a FieldError, anything else a
149
+ // StandardError. Without CODE, a FieldError is VALIDATION_ERROR and anything
150
+ // else REQUEST_FAILED — what a failed GPAL require() sends (an
151
+ // IllegalArgumentException; EventHandlerUtils.handleError). GPAL's
152
+ // nack("text") is INTERNAL_ERROR instead: see NackError.nack.
1
153
  export interface NackErrorInput {
154
+ '@type'?: GenesisErrorType;
2
155
  CODE?: string;
3
156
  code?: string;
4
157
  TEXT?: string;
5
158
  text?: string;
159
+ STATUS_CODE?: string;
160
+ statusCode?: string;
6
161
  FIELD?: string;
7
162
  field?: string;
163
+ PATH?: string | null;
164
+ path?: string | null;
165
+ TABLE?: string;
166
+ table?: string;
8
167
  }
9
168
 
169
+ // One wire error item. '@type' and STATUS_CODE are always present by default;
170
+ // they are optional here only because fidelity: 'legacy' sends the pre-15.48
171
+ // {CODE, TEXT, FIELD?} items.
10
172
  export interface NackErrorEntry {
173
+ '@type'?: GenesisErrorType;
11
174
  CODE: string;
12
175
  TEXT: string;
13
- FIELD?: string;
176
+ STATUS_CODE?: string;
177
+ FIELD?: string | null;
178
+ PATH?: string | null;
179
+ TABLE?: string | null;
180
+ DETAILS?: null;
181
+ }
182
+
183
+ const LEGACY_DEFAULT_CODE = 'VALIDATION_ERROR';
184
+ const UNKNOWN_TEXT = 'Unknown error';
185
+
186
+ function inferType(input: NackErrorInput): GenesisErrorType {
187
+ if (input['@type']) return input['@type'];
188
+ if ((input.TABLE ?? input.table) !== undefined) return 'TableStandardError';
189
+ const hasField = (input.FIELD ?? input.field) !== undefined;
190
+ const hasPath = (input.PATH ?? input.path) !== undefined;
191
+ return hasField || hasPath ? 'FieldError' : 'StandardError';
192
+ }
193
+
194
+ // Renders one input as GSF serializes the matching GenesisError subclass,
195
+ // keys in the same order. Nullable class fields are sent as null, as
196
+ // FieldError's PATH is on the wire.
197
+ export function toNackEntry(input: NackErrorInput): NackErrorEntry {
198
+ const type = inferType(input);
199
+ const CODE =
200
+ input.CODE ??
201
+ input.code ??
202
+ (type === 'FieldError' ? ErrorCode.VALIDATION_ERROR : ErrorCode.REQUEST_FAILED);
203
+ const TEXT = input.TEXT ?? input.text ?? defaultTextFor(CODE) ?? UNKNOWN_TEXT;
204
+ const entry: NackErrorEntry = {
205
+ '@type': type,
206
+ CODE,
207
+ TEXT,
208
+ STATUS_CODE: input.STATUS_CODE ?? input.statusCode ?? statusCodeFor(CODE),
209
+ };
210
+ const field = input.FIELD ?? input.field;
211
+ switch (type) {
212
+ case 'FieldError':
213
+ entry.FIELD = field ?? '';
214
+ entry.PATH = input.PATH ?? input.path ?? null;
215
+ break;
216
+ case 'TableStandardError':
217
+ entry.TABLE = input.TABLE ?? input.table ?? null;
218
+ entry.FIELD = field ?? null;
219
+ break;
220
+ case 'LoginError':
221
+ entry.DETAILS = null;
222
+ break;
223
+ default:
224
+ if (field !== undefined) entry.FIELD = field;
225
+ }
226
+ return entry;
227
+ }
228
+
229
+ // The {CODE, TEXT, FIELD?} item this engine sent up to 15.47, for
230
+ // fidelity: 'legacy' (a helper's code still lends its readable text).
231
+ export function toLegacyNackEntry(input: NackErrorInput): NackErrorEntry {
232
+ const field = input.FIELD ?? input.field;
233
+ const code = input.CODE ?? input.code;
234
+ return {
235
+ CODE: code ?? LEGACY_DEFAULT_CODE,
236
+ TEXT: input.TEXT ?? input.text ?? (code && defaultTextFor(code)) ?? UNKNOWN_TEXT,
237
+ ...(field ? { FIELD: field } : {}),
238
+ };
14
239
  }
15
240
 
16
- // Throw this from an event handler / request-reply resolver to produce a
17
- // protocol-correct *_NACK with ERROR as an array of {CODE, TEXT, FIELD?} —
18
- // never {error, details} (lowercase, non-array), which crashes real clients
19
- // calling .forEach on the wrong shape.
241
+ function asList(inputs: string | NackErrorInput | NackErrorInput[] | undefined): NackErrorInput[] {
242
+ if (inputs === undefined) return [];
243
+ if (typeof inputs === 'string') return [{ TEXT: inputs }];
244
+ return Array.isArray(inputs) ? inputs : [inputs];
245
+ }
246
+
247
+ export interface NackErrorOptions {
248
+ // Sent in the NACK's WARNING list. (Acting on IGNORE_WARNINGS is a later
249
+ // item; for now a warning always fails the event.)
250
+ warnings?: NackErrorInput | NackErrorInput[];
251
+ }
252
+
253
+ // Throw this from an event handler or request-reply resolver for a
254
+ // protocol-correct NACK: ERROR is always an array of items, never
255
+ // {error, details} (lowercase, non-array), which crashes real clients calling
256
+ // .forEach on it. Pass a string for a failed GPAL require() (StandardError
257
+ // REQUEST_FAILED, 400), one input, or several to report every failing field
258
+ // at once, as a handler's EventNack(error = listOf(...)) does. The static
259
+ // helpers build the platform's common errors, nack() included.
20
260
  export class NackError extends Error {
21
261
  readonly errors: NackErrorEntry[];
262
+ readonly warnings: NackErrorEntry[];
263
+ readonly inputs: readonly NackErrorInput[];
264
+ readonly warningInputs: readonly NackErrorInput[];
265
+
266
+ constructor(errors: string | NackErrorInput | NackErrorInput[], options: NackErrorOptions = {}) {
267
+ const inputs = asList(errors);
268
+ const warningInputs = asList(options.warnings);
269
+ const entries = inputs.map(toNackEntry);
270
+ const warnings = warningInputs.map(toNackEntry);
271
+ super([...entries, ...warnings].map((entry) => entry.TEXT).join(', ') || UNKNOWN_TEXT);
272
+ this.name = 'NackError';
273
+ this.inputs = inputs;
274
+ this.warningInputs = warningInputs;
275
+ this.errors = entries;
276
+ this.warnings = warnings;
277
+ }
22
278
 
23
- constructor(errors: NackErrorInput | NackErrorInput[]) {
24
- const list = Array.isArray(errors) ? errors : [errors];
25
- super(list.map((e) => e.TEXT ?? e.text ?? 'Unknown error').join(', '));
26
- this.errors = list.map((e) => ({
27
- CODE: e.CODE ?? e.code ?? 'VALIDATION_ERROR',
28
- TEXT: e.TEXT ?? e.text ?? 'Unknown error',
29
- ...(e.FIELD || e.field ? { FIELD: e.FIELD ?? e.field } : {}),
30
- }));
279
+ // A FieldError: VALIDATION_ERROR (400) naming the field. PATH is the JSON
280
+ // path the error was found at — null (the default) for a handler's own
281
+ // check, '$.DETAILS...' for a schema failure. Forms highlight FIELD/PATH,
282
+ // and the AI assistant reads a null PATH as "the app said no".
283
+ static field(
284
+ field: string,
285
+ text: string,
286
+ path: string | null = null,
287
+ code: string = ErrorCode.VALIDATION_ERROR,
288
+ ): NackError {
289
+ return new NackError({
290
+ '@type': 'FieldError',
291
+ CODE: code,
292
+ TEXT: text,
293
+ FIELD: field,
294
+ PATH: path,
295
+ });
31
296
  }
297
+
298
+ // A StandardError with a GSF ErrorCode (or a project's own code), TEXT
299
+ // defaulting to the code's readable text: NackError.standard('NOT_AUTHORISED').
300
+ static standard(code: string, text?: string): NackError {
301
+ return new NackError({ '@type': 'StandardError', CODE: code, TEXT: text });
302
+ }
303
+
304
+ // The insert that hits an existing key: TableStandardError DUPLICATE_KEY
305
+ // (500), FIELD '<INDEX_NAME> -> <value>', e.g.
306
+ // NackError.duplicateKey('COUNTERPARTY', 'COUNTERPARTY_BY_ID', 'CP001').
307
+ static duplicateKey(table: string, index: string, value: unknown, text?: string): NackError {
308
+ return new NackError({
309
+ '@type': 'TableStandardError',
310
+ CODE: ErrorCode.DUPLICATE_KEY,
311
+ TEXT: text,
312
+ TABLE: table,
313
+ FIELD: `${index} -> ${String(value)}`,
314
+ });
315
+ }
316
+
317
+ // A modify or delete of a record that isn't there: TableStandardError
318
+ // RECORD_NOT_FOUND (404). GSF names the unique index in FIELD for a modify
319
+ // and sends '' for a delete, so `index` defaults to ''.
320
+ static recordNotFound(table: string, index = '', text?: string): NackError {
321
+ return new NackError({
322
+ '@type': 'TableStandardError',
323
+ CODE: ErrorCode.RECORD_NOT_FOUND,
324
+ TEXT: text,
325
+ TABLE: table,
326
+ FIELD: index,
327
+ });
328
+ }
329
+
330
+ // GPAL's nack("text"): GSF wraps the text in an Exception, which
331
+ // EventHandlerUtils.handleError answers with StandardError INTERNAL_ERROR
332
+ // (500) carrying the text. (A failed require() is `new NackError(text)`.)
333
+ static nack(text: string): NackError {
334
+ return new NackError({ '@type': 'StandardError', CODE: ErrorCode.INTERNAL_ERROR, TEXT: text });
335
+ }
336
+
337
+ // A NACK that only warns: ERROR stays empty, WARNING carries these.
338
+ static warning(warnings: string | NackErrorInput | NackErrorInput[]): NackError {
339
+ return new NackError([], { warnings: asList(warnings) });
340
+ }
341
+ }
342
+
343
+ // The ERROR/WARNING part of a service NACK for anything a handler threw. A
344
+ // NackError gives its own items; anything else is StandardError
345
+ // INTERNAL_ERROR with the error's message, as EventHandlerUtils.handleError
346
+ // answers an unexpected exception. Legacy mode sends the pre-15.48 items
347
+ // (no @type/STATUS_CODE, VALIDATION_ERROR by default) and no WARNING: it had
348
+ // no warnings, so they go in ERROR, after the errors.
349
+ export function nackPayload(
350
+ error: unknown,
351
+ legacy: boolean,
352
+ ): { ERROR: NackErrorEntry[]; WARNING?: NackErrorEntry[] } {
353
+ if (error instanceof NackError) {
354
+ return legacy
355
+ ? { ERROR: [...error.inputs, ...error.warningInputs].map(toLegacyNackEntry) }
356
+ : { ERROR: error.errors, WARNING: error.warnings };
357
+ }
358
+ const message = error instanceof Error ? error.message : String(error);
359
+ if (legacy) return { ERROR: [{ CODE: LEGACY_DEFAULT_CODE, TEXT: message }] };
360
+ return {
361
+ ERROR: [
362
+ toNackEntry({
363
+ '@type': 'StandardError',
364
+ CODE: ErrorCode.INTERNAL_ERROR,
365
+ TEXT: message || undefined,
366
+ }),
367
+ ],
368
+ WARNING: [],
369
+ };
370
+ }
371
+
372
+ // The payload for warnings a handler returned instead of throwing. Legacy
373
+ // mode had no WARNING list, so they are sent as its {CODE, TEXT} errors.
374
+ export function warningPayload(
375
+ warnings: NackErrorInput[],
376
+ legacy: boolean,
377
+ ): { ERROR: NackErrorEntry[]; WARNING?: NackErrorEntry[] } {
378
+ if (legacy) return { ERROR: warnings.map(toLegacyNackEntry) };
379
+ return { ERROR: [], WARNING: warnings.map(toNackEntry) };
32
380
  }
@@ -0,0 +1,115 @@
1
+ // How the real server describes a Genesis field type on the wire: the JSON
2
+ // type a value travels as, the JVM class its generated DAO property has, and
3
+ // the names it derives from table and field names. Checked against GSF
4
+ // 8.15.29 recordings (test/conformance/recordings/showcase-real/meta-*.json,
5
+ // json-schema-*.json) and genesis-process's PALMetadataUtils.kt.
6
+
7
+ // The JSON type each Genesis type travels as. Dates are epoch millis and
8
+ // BigDecimals are strings, so a client must not read the Genesis type as the
9
+ // JSON one.
10
+ const JSON_TYPES: Record<string, string> = {
11
+ STRING: 'string',
12
+ ENUM: 'string',
13
+ INT: 'integer',
14
+ SHORT: 'integer',
15
+ LONG: 'integer',
16
+ DOUBLE: 'number',
17
+ BIGDECIMAL: 'string',
18
+ BOOLEAN: 'boolean',
19
+ DATE: 'integer',
20
+ DATETIME: 'integer',
21
+ NANO_TIMESTAMP: 'integer',
22
+ RAW: 'string',
23
+ };
24
+
25
+ // The JVM class of the generated DAO property, which the real server puts in
26
+ // DESCRIPTION / description. foundation-forms recognises a date field by
27
+ // 'org.joda.time.DateTime' (jsonforms/testers/isDate.ts).
28
+ const JVM_CLASSES: Record<string, string> = {
29
+ STRING: 'kotlin.String',
30
+ INT: 'kotlin.Int',
31
+ SHORT: 'kotlin.Short',
32
+ LONG: 'kotlin.Long',
33
+ DOUBLE: 'kotlin.Double',
34
+ BIGDECIMAL: 'java.math.BigDecimal',
35
+ BOOLEAN: 'kotlin.Boolean',
36
+ DATE: 'org.joda.time.DateTime',
37
+ DATETIME: 'org.joda.time.DateTime',
38
+ NANO_TIMESTAMP: 'kotlin.Long',
39
+ RAW: 'kotlin.ByteArray',
40
+ };
41
+
42
+ // Value ranges the DAO's generated annotations carry, reported as MIN/MAX in
43
+ // event META and minimum/maximum in event JSON schemas. LONG is bounded by
44
+ // JavaScript's safe integers, not by Long.MAX_VALUE.
45
+ const INT_MIN = -2147483648;
46
+ const INT_MAX = 2147483647;
47
+ const SHORT_MIN = -32768;
48
+ const SHORT_MAX = 32767;
49
+ const RANGES: Record<string, [number, number]> = {
50
+ INT: [INT_MIN, INT_MAX],
51
+ SHORT: [SHORT_MIN, SHORT_MAX],
52
+ LONG: [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
53
+ NANO_TIMESTAMP: [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
54
+ DOUBLE: [-Number.MAX_VALUE, Number.MAX_VALUE],
55
+ };
56
+
57
+ // What the real event schema puts on a BigDecimal property instead of a
58
+ // genesisType.
59
+ export const BIGDECIMAL_PATTERN = '[0-9]+(\\.[0-9]+)?';
60
+
61
+ export function jsonTypeOf(type: string): string {
62
+ return JSON_TYPES[type] ?? 'string';
63
+ }
64
+
65
+ export function rangeOf(type: string): [number, number] | undefined {
66
+ return RANGES[type];
67
+ }
68
+
69
+ // TYPE in event META follows the JVM class, not the dictionary: a DATE column
70
+ // is a DateTime property, so it is TYPE 'DATETIME' (GENESIS_TYPE says 'DATE').
71
+ export function eventMetaTypeOf(type: string): string {
72
+ if (type === 'DATE') return 'DATETIME';
73
+ if (type === 'NANO_TIMESTAMP') return 'LONG';
74
+ return type;
75
+ }
76
+
77
+ // 'COUNTERPARTY_ID' -> 'Counterparty Id', the TITLE the DAO generator gives.
78
+ export function titleCase(name: string): string {
79
+ return name
80
+ .split('_')
81
+ .filter(Boolean)
82
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
83
+ .join(' ');
84
+ }
85
+
86
+ // 'ALL_TYPES' -> 'AllTypes', the generated class name.
87
+ export function pascalCase(name: string): string {
88
+ return titleCase(name).replace(/ /g, '');
89
+ }
90
+
91
+ // The generated DAO class an event's DETAILS deserialize into.
92
+ export function daoClassOf(table: string): string {
93
+ return `global.genesis.gen.dao.${pascalCase(table)}`;
94
+ }
95
+
96
+ // The generated entity class of a view (ViewEntityGenerator.kt PACKAGE_NAME,
97
+ // GSF v8.15.29): what a request server over the view names in
98
+ // PARAMETRIC_TYPE.
99
+ export function viewEntityClassOf(view: string): string {
100
+ return `global.genesis.gen.view.entity.${pascalCase(view)}`;
101
+ }
102
+
103
+ // The generated enum class of an ENUM column.
104
+ export function enumClassOf(table: string | undefined, field: string, appName?: string): string {
105
+ const segments = ['global.genesis.gen.dao.enums'];
106
+ if (appName) segments.push(appName);
107
+ if (table) segments.push(table.toLowerCase());
108
+ segments.push(pascalCase(field));
109
+ return segments.join('.');
110
+ }
111
+
112
+ export function jvmClassOf(type: string, enumClass: () => string): string {
113
+ if (type === 'ENUM') return enumClass();
114
+ return JVM_CLASSES[type] ?? 'kotlin.String';
115
+ }