@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.
- package/README.md +408 -39
- package/dist/db/criteria.d.ts +13 -0
- package/dist/db/criteria.js +60 -21
- package/dist/db/identity.d.ts +11 -0
- package/dist/db/identity.js +46 -0
- package/dist/db/ordering.d.ts +8 -0
- package/dist/db/ordering.js +49 -0
- package/dist/db/schema.d.ts +62 -0
- package/dist/db/schema.js +490 -0
- package/dist/db/store.d.ts +20 -3
- package/dist/db/store.js +300 -50
- package/dist/handlers/commitEvent.js +47 -9
- package/dist/handlers/crudEvents.d.ts +2 -0
- package/dist/handlers/crudEvents.js +138 -0
- package/dist/handlers/dataLogon.js +105 -6
- package/dist/handlers/eventValidation.d.ts +5 -0
- package/dist/handlers/eventValidation.js +29 -0
- package/dist/handlers/jsonSchema.d.ts +4 -2
- package/dist/handlers/jsonSchema.js +222 -61
- package/dist/handlers/meta.d.ts +6 -1
- package/dist/handlers/meta.js +168 -21
- package/dist/handlers/requestReply.js +519 -30
- package/dist/handlers/resourceAuth.d.ts +1 -0
- package/dist/handlers/resourceAuth.js +14 -0
- package/dist/handlers/resources.js +3 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/protocol/connection.d.ts +2 -0
- package/dist/protocol/errors.d.ts +100 -2
- package/dist/protocol/errors.js +300 -12
- package/dist/protocol/fieldTypes.d.ts +10 -0
- package/dist/protocol/fieldTypes.js +107 -0
- package/dist/protocol/gsfSchemas.d.ts +9 -0
- package/dist/protocol/gsfSchemas.js +423 -0
- package/dist/protocol/messageTypes.d.ts +1 -0
- package/dist/protocol/messageTypes.js +1 -0
- package/dist/protocol/msgNack.d.ts +2 -0
- package/dist/protocol/msgNack.js +10 -0
- package/dist/protocol/rowUpdate.d.ts +5 -3
- package/dist/protocol/rowUpdate.js +33 -15
- package/dist/protocol/schemaValidation.d.ts +11 -0
- package/dist/protocol/schemaValidation.js +186 -0
- package/dist/server.js +95 -29
- package/dist/types.d.ts +45 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +72 -21
- package/src/db/identity.ts +53 -0
- package/src/db/ordering.ts +56 -0
- package/src/db/schema.ts +672 -0
- package/src/db/store.ts +343 -50
- package/src/handlers/commitEvent.ts +54 -10
- package/src/handlers/crudEvents.ts +181 -0
- package/src/handlers/dataLogon.ts +127 -7
- package/src/handlers/eventValidation.ts +43 -0
- package/src/handlers/jsonSchema.ts +302 -69
- package/src/handlers/meta.ts +227 -23
- package/src/handlers/requestReply.ts +626 -33
- package/src/handlers/resourceAuth.ts +18 -0
- package/src/handlers/resources.ts +3 -0
- package/src/index.ts +17 -1
- package/src/protocol/connection.ts +10 -2
- package/src/protocol/errors.ts +361 -13
- package/src/protocol/fieldTypes.ts +115 -0
- package/src/protocol/gsfSchemas.ts +505 -0
- package/src/protocol/messageTypes.ts +1 -0
- package/src/protocol/msgNack.ts +12 -0
- package/src/protocol/rowUpdate.ts +40 -20
- package/src/protocol/schemaValidation.ts +208 -0
- package/src/server.ts +106 -28
- 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 {
|
|
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 (
|
|
10
|
-
// the joined composite key) —
|
|
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
|
package/src/protocol/errors.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|