@swimmesberger/elarion-jsonrpc-client-generator 0.2.8-preview.158.1 → 0.2.8

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 CHANGED
@@ -96,7 +96,7 @@ events.$client.onConnected(() => refetchAll())
96
96
 
97
97
  ## Error handling
98
98
 
99
- A JSON-RPC error from the server is thrown as a typed `RpcError` (`code`, `message`, optional `data`). Elarion maps its
99
+ A JSON-RPC error from the server is thrown as a typed `RpcError`: `code` (the stable string error code), `data` (its typed payload), `message`, and `rpcCode` (the numeric JSON-RPC code). Methods list their declared errors in the schema, so `RpcMethodError<'method'>` is a union discriminated by `code` with typed `data`, and `isRpcMethodError('method', error)` narrows a caught error. Elarion maps its
100
100
  `AppError` kinds onto the JSON-RPC server-reserved range, so the generated `RpcError` exposes a getter per kind — branch
101
101
  on the kind directly instead of re-wrapping:
102
102
 
package/dist/generate.js CHANGED
@@ -35,6 +35,21 @@ export function generateRpcClientFiles(schema, options = {}) {
35
35
  typesLines.push(` ${JSON.stringify(method)}: {`);
36
36
  typesLines.push(` params: ${paramsType}`);
37
37
  typesLines.push(` result: ${resultType}`);
38
+ const errorCodes = Object.keys(definition.errors ?? {}).sort();
39
+ if (errorCodes.length === 0) {
40
+ typesLines.push(' errors: {}');
41
+ }
42
+ else {
43
+ typesLines.push(' errors: {');
44
+ for (const code of errorCodes) {
45
+ const error = definition.errors[code];
46
+ const dataType = error.data === undefined
47
+ ? 'undefined'
48
+ : jsonSchemaToTypeScript(stripNullable(error.data), createContext(error.data, `methods.${method}.errors.${code}.data`), 3);
49
+ typesLines.push(` ${JSON.stringify(code)}: { kind: ${JSON.stringify(error.kind)}; data: ${dataType} }`);
50
+ }
51
+ typesLines.push(' }');
52
+ }
38
53
  typesLines.push(' }');
39
54
  }
40
55
  typesLines.push('}');
@@ -66,6 +81,28 @@ export function generateRpcClientFiles(schema, options = {}) {
66
81
  schemasLines.push('');
67
82
  schemasLines.push('export type RpcResultSchemas = typeof rpcResultSchemas');
68
83
  schemasLines.push('');
84
+ // Typed error payload schemas (ADR-0080): per method, per declared code that carries a payload.
85
+ schemasLines.push('export const rpcErrorDataSchemas = {');
86
+ for (const method of methods) {
87
+ const errors = schema.methods[method].errors ?? {};
88
+ const entries = Object.keys(errors)
89
+ .sort()
90
+ .filter((code) => errors[code].data !== undefined)
91
+ .map((code) => {
92
+ const data = errors[code].data;
93
+ const zodSchema = jsonSchemaToZod(stripNullable(data), createContext(data, `methods.${method}.errors.${code}.data`), 2);
94
+ return ` ${JSON.stringify(code)}: ${zodSchema},`;
95
+ });
96
+ if (entries.length > 0) {
97
+ schemasLines.push(` ${JSON.stringify(method)}: {`);
98
+ schemasLines.push(...entries);
99
+ schemasLines.push(' },');
100
+ }
101
+ }
102
+ schemasLines.push('} as const');
103
+ schemasLines.push('');
104
+ schemasLines.push('export type RpcErrorDataSchemas = typeof rpcErrorDataSchemas');
105
+ schemasLines.push('');
69
106
  // Client-event payload schemas (ADR-0043), emitted only when the schema declares events so event-free
70
107
  // schemas keep producing byte-identical output.
71
108
  const eventTopics = Object.keys(schema.events ?? {}).sort();
@@ -81,6 +118,13 @@ export function generateRpcClientFiles(schema, options = {}) {
81
118
  schemasLines.push('export type RpcEventPayloadSchemas = typeof rpcEventPayloadSchemas');
82
119
  schemasLines.push('');
83
120
  }
121
+ const errorCodesByMethod = {};
122
+ for (const method of methods) {
123
+ const codes = Object.keys(schema.methods[method].errors ?? {}).sort();
124
+ if (codes.length > 0) {
125
+ errorCodesByMethod[method] = codes;
126
+ }
127
+ }
84
128
  const idempotentMethods = methods.filter((method) => schema.methods[method].idempotent === true);
85
129
  // Where each method carries file payloads (x-elarion-file nodes), so the client can convert between
86
130
  // native File values and the base64 wire envelope. Methods without files are omitted entirely.
@@ -104,6 +148,7 @@ export function generateRpcClientFiles(schema, options = {}) {
104
148
  schemasFileName,
105
149
  methods,
106
150
  idempotentMethods,
151
+ errorCodesByMethod,
107
152
  paramsFilePaths,
108
153
  resultFilePaths,
109
154
  });
@@ -5,6 +5,8 @@ interface GenerateRpcClientSourceOptions {
5
5
  schemasFileName: string;
6
6
  methods: readonly string[];
7
7
  idempotentMethods: readonly string[];
8
+ /** The declared error codes per method (ADR-0080); methods without declared errors are omitted. */
9
+ errorCodesByMethod: Readonly<Record<string, readonly string[]>>;
8
10
  /** Property paths of file payloads per method ('*' = array items, [] = the value itself); omitted methods carry none. */
9
11
  paramsFilePaths: Readonly<Record<string, string[][]>>;
10
12
  resultFilePaths: Readonly<Record<string, string[][]>>;
@@ -13,7 +13,7 @@ export function generateRpcClientSource(options) {
13
13
  `// Source: ${options.sourceLabel}`,
14
14
  '',
15
15
  `import type { RpcMethods } from '${typesImport}'`,
16
- `import { rpcParamsSchemas, rpcResultSchemas } from '${schemasImport}'`,
16
+ `import { rpcErrorDataSchemas, rpcParamsSchemas, rpcResultSchemas } from '${schemasImport}'`,
17
17
  '',
18
18
  'export type RpcMethod = keyof RpcMethods & string',
19
19
  "export type RpcParams<M extends RpcMethod> = RpcMethods[M]['params']",
@@ -92,6 +92,13 @@ export function generateRpcClientSource(options) {
92
92
  ' | { readonly ok: true; readonly result: RpcResult<M> }',
93
93
  ' | { readonly ok: false; readonly error: RpcError }',
94
94
  '',
95
+ '// The declared error contract of a method (ADR-0080): a union of RpcError variants discriminated by the',
96
+ '// stable `code`, each with its typed `data`. Narrow with `error.code === ...` or `isRpcMethodError`.',
97
+ "export type RpcErrorCode<M extends RpcMethod> = keyof RpcMethods[M]['errors'] & string",
98
+ 'export type RpcMethodError<M extends RpcMethod> = {',
99
+ ' [C in RpcErrorCode<M>]: RpcError<C, RpcMethods[M][\'errors\'][C] extends { data: infer D } ? D : never>',
100
+ '}[RpcErrorCode<M>]',
101
+ '',
95
102
  'export type BatchResult<T extends readonly BatchItem[]> = {',
96
103
  ' readonly [K in keyof T]: T[K] extends BatchItem<infer M> ? BatchItemResult<M> : never',
97
104
  '}',
@@ -113,6 +120,13 @@ export function generateRpcClientSource(options) {
113
120
  ...generateMethodNameLines(options.methods),
114
121
  `const rpcIdempotentMethods: ReadonlySet<RpcMethod> = new Set(${JSON.stringify(options.idempotentMethods)})`,
115
122
  '',
123
+ `const rpcErrorCodes: Partial<Record<RpcMethod, readonly string[]>> = ${JSON.stringify(options.errorCodesByMethod)}`,
124
+ '',
125
+ '/** True when `error` is an RpcError carrying one of the error codes `method` declares. */',
126
+ 'export function isRpcMethodError<M extends RpcMethod>(method: M, error: unknown): error is RpcMethodError<M> {',
127
+ ' return error instanceof RpcError && (rpcErrorCodes[method]?.includes(error.code) ?? false)',
128
+ '}',
129
+ '',
116
130
  ...(hasFiles
117
131
  ? [
118
132
  '// Where each method carries file payloads: property paths into params/results ("*" descends into',
@@ -157,57 +171,65 @@ export function generateRpcClientSource(options) {
157
171
  ' unauthorized: -32005,',
158
172
  '} as const',
159
173
  '',
160
- 'export class RpcError extends Error {',
161
- ' readonly code: number',
162
- ' readonly data?: unknown',
163
- '',
164
- ' constructor(code: number, message: string, data?: unknown) {',
174
+ '// Every error carries a stable string `code` (the contract clients branch on) and `data` (its typed',
175
+ '// payload). `rpcCode` is the numeric JSON-RPC code, which only encodes the coarse error kind.',
176
+ 'export class RpcError<TCode extends string = string, TData = unknown> extends Error {',
177
+ ' /** The stable machine-readable error code, for example "token.malformed" or "not_found". */',
178
+ ' readonly code: TCode',
179
+ ' /** The typed error payload declared for this code; undefined when the error carries none. */',
180
+ ' readonly data: TData',
181
+ ' /** The numeric JSON-RPC error code (the coarse error kind, see ElarionErrorCodes). */',
182
+ ' readonly rpcCode: number',
183
+ '',
184
+ ' constructor(rpcCode: number, message: string, wireData?: unknown) {',
165
185
  ' super(message)',
166
186
  " this.name = 'RpcError'",
167
- ' this.code = code',
168
- ' this.data = data',
187
+ ' this.rpcCode = rpcCode',
188
+ ' const envelope = readErrorData(wireData)',
189
+ ' this.code = (envelope?.code ?? defaultErrorCode(rpcCode)) as TCode',
190
+ ' this.data = envelope?.data as TData',
169
191
  ' }',
170
192
  '',
171
193
  ' get isParseError() {',
172
- ' return this.code === -32700',
194
+ ' return this.rpcCode === -32700',
173
195
  ' }',
174
196
  '',
175
197
  ' get isInvalidRequest() {',
176
- ' return this.code === -32600',
198
+ ' return this.rpcCode === -32600',
177
199
  ' }',
178
200
  '',
179
201
  ' get isMethodNotFound() {',
180
- ' return this.code === -32601',
202
+ ' return this.rpcCode === -32601',
181
203
  ' }',
182
204
  '',
183
205
  ' get isInvalidParams() {',
184
- ' return this.code === -32602',
206
+ ' return this.rpcCode === -32602',
185
207
  ' }',
186
208
  '',
187
209
  ' get isInternalError() {',
188
- ' return this.code === -32603',
210
+ ' return this.rpcCode === -32603',
189
211
  ' }',
190
212
  '',
191
213
  ' // Elarion application-error kinds (see ElarionErrorCodes). isInvalidParams also covers Validation;',
192
214
  ' // isInternalError also covers Internal.',
193
215
  ' get isNotFound() {',
194
- ' return this.code === ElarionErrorCodes.notFound',
216
+ ' return this.rpcCode === ElarionErrorCodes.notFound',
195
217
  ' }',
196
218
  '',
197
219
  ' get isConflict() {',
198
- ' return this.code === ElarionErrorCodes.conflict',
220
+ ' return this.rpcCode === ElarionErrorCodes.conflict',
199
221
  ' }',
200
222
  '',
201
223
  ' get isForbidden() {',
202
- ' return this.code === ElarionErrorCodes.forbidden',
224
+ ' return this.rpcCode === ElarionErrorCodes.forbidden',
203
225
  ' }',
204
226
  '',
205
227
  ' get isBusinessRule() {',
206
- ' return this.code === ElarionErrorCodes.businessRule',
228
+ ' return this.rpcCode === ElarionErrorCodes.businessRule',
207
229
  ' }',
208
230
  '',
209
231
  ' get isUnauthorized() {',
210
- ' return this.code === ElarionErrorCodes.unauthorized',
232
+ ' return this.rpcCode === ElarionErrorCodes.unauthorized',
211
233
  ' }',
212
234
  '}',
213
235
  '',
@@ -355,7 +377,7 @@ export function generateRpcClientSource(options) {
355
377
  ' // A spec-compliant server reports request-level failures (e.g. parse errors) with "id": null —',
356
378
  ' // surface the server error instead of a generic id-mismatch protocol error.',
357
379
  ' if (response.error && (response.id === null || response.id === undefined)) {',
358
- ' throw toRpcError(response.error)',
380
+ ' throw toRpcError(response.error, method, validateResults)',
359
381
  ' }',
360
382
  '',
361
383
  ' if (!idsEqual(response.id, request.id)) {',
@@ -363,7 +385,7 @@ export function generateRpcClientSource(options) {
363
385
  ' }',
364
386
  '',
365
387
  ' if (response.error) {',
366
- ' throw toRpcError(response.error)',
388
+ ' throw toRpcError(response.error, method, validateResults)',
367
389
  ' }',
368
390
  '',
369
391
  hasFiles
@@ -448,7 +470,11 @@ export function generateRpcClientSource(options) {
448
470
  ' }',
449
471
  '',
450
472
  ' if (response.error) {',
451
- ' return { ok: false, error: toRpcError(response.error) }',
473
+ ' try {',
474
+ ' return { ok: false, error: toRpcError(response.error, method, validateResults) }',
475
+ ' } catch (error) {',
476
+ " return { ok: false, error: new RpcError(-32603, 'Invalid RPC error payload.', { code: 'internal', data: error }) }",
477
+ ' }',
452
478
  ' }',
453
479
  '',
454
480
  ' try {',
@@ -456,7 +482,7 @@ export function generateRpcClientSource(options) {
456
482
  ? ' return { ok: true, result: parseResult(method, decodeFileResult(method, response.result)) }'
457
483
  : ' return { ok: true, result: parseResult(method, response.result) }',
458
484
  ' } catch (error) {',
459
- " return { ok: false, error: new RpcError(-32603, 'Invalid RPC result.', error) }",
485
+ " return { ok: false, error: new RpcError(-32603, 'Invalid RPC result.', { code: 'internal', data: error }) }",
460
486
  ' }',
461
487
  ' }) as BatchResult<T>',
462
488
  ' } catch (error) {',
@@ -598,8 +624,48 @@ export function generateRpcClientSource(options) {
598
624
  ' )',
599
625
  '}',
600
626
  '',
601
- 'function toRpcError(error: JsonRpcErrorObject): RpcError {',
602
- ' return new RpcError(error.code, error.message, error.data)',
627
+ 'interface RpcErrorDataSchema {',
628
+ ' safeParse(value: unknown): { success: true } | { success: false; error: { message: string } }',
629
+ '}',
630
+ '',
631
+ 'function toRpcError(error: JsonRpcErrorObject, method?: RpcMethod, validate = false): RpcError {',
632
+ ' const rpcError = new RpcError(error.code, error.message, error.data)',
633
+ ' // A declared payload is part of the contract: validate it like a result so clients can trust the typing.',
634
+ ' if (validate && method !== undefined) {',
635
+ ' const schemas = rpcErrorDataSchemas as Partial<Record<string, Partial<Record<string, RpcErrorDataSchema>>>>',
636
+ ' const schema = schemas[method]?.[rpcError.code]',
637
+ ' if (schema !== undefined) {',
638
+ ' const parsed = schema.safeParse(rpcError.data)',
639
+ ' if (!parsed.success) {',
640
+ ' throw new RpcProtocolError(',
641
+ ' `Error payload for code ${JSON.stringify(rpcError.code)} of ${JSON.stringify(method)} does not match its declared contract: ${parsed.error.message}`',
642
+ ' )',
643
+ ' }',
644
+ ' }',
645
+ ' }',
646
+ ' return rpcError',
647
+ '}',
648
+ '',
649
+ '// The wire shape of error.data is always { code, data? } (ADR-0080).',
650
+ 'function readErrorData(value: unknown): { code: string; data?: unknown } | undefined {',
651
+ ' if (!isRecord(value) || typeof value.code !== ' + "'string'" + ') return undefined',
652
+ ' return { code: value.code, data: value.data }',
653
+ '}',
654
+ '',
655
+ '// Fallback for a server that sends no error.data: the default code of the numeric kind.',
656
+ 'function defaultErrorCode(rpcCode: number): string {',
657
+ ' switch (rpcCode) {',
658
+ " case -32700: return 'parse_error'",
659
+ " case -32600: return 'invalid_request'",
660
+ " case -32601: return 'method_not_found'",
661
+ " case -32602: return 'invalid_params'",
662
+ " case ElarionErrorCodes.notFound: return 'not_found'",
663
+ " case ElarionErrorCodes.conflict: return 'conflict'",
664
+ " case ElarionErrorCodes.forbidden: return 'forbidden'",
665
+ " case ElarionErrorCodes.businessRule: return 'business_rule'",
666
+ " case ElarionErrorCodes.unauthorized: return 'unauthorized'",
667
+ " default: return 'internal'",
668
+ ' }',
603
669
  '}',
604
670
  '',
605
671
  'function isRecord(value: unknown): value is Record<string, unknown> {',
package/dist/schema.d.ts CHANGED
@@ -26,9 +26,19 @@ export interface JsonSchema {
26
26
  minItems?: number;
27
27
  maxItems?: number;
28
28
  }
29
+ /**
30
+ * One declared error of a method (ADR-0080): the kind it fails with (the kind's default code, for example
31
+ * `not_found`) and, when the error carries a typed payload, the schema of `error.data.data`.
32
+ */
33
+ export interface RpcErrorSchema {
34
+ kind: string;
35
+ data?: JsonSchema;
36
+ }
29
37
  export interface RpcMethodSchema {
30
38
  params: JsonSchema;
31
39
  result: JsonSchema;
40
+ /** The declared error contract, keyed by stable error code; omitted when the method declares none. */
41
+ errors?: Record<string, RpcErrorSchema>;
32
42
  idempotent?: boolean;
33
43
  }
34
44
  /** One structured permission from the schema's capability vocabulary ({resource}.{verb} plus its parts). */
@@ -39,7 +49,7 @@ export interface RpcCapabilityPermission {
39
49
  }
40
50
  /**
41
51
  * The capability vocabulary block emitted by the server exporter (ADR-0032): module names with the
42
- * `[ClientFeatures]` each exposes, the structured permission catalog, and role names. All optional — older
52
+ * client-exposed flag each declares, the structured permission catalog, and role names. All optional — older
43
53
  * schemas (or hosts without the session/authorization registrations) simply omit it.
44
54
  */
45
55
  export interface RpcSchemaCapabilities {
@@ -26,7 +26,7 @@ function vocabularySection(capabilities) {
26
26
  }
27
27
  lines.push('');
28
28
  if (flagNames.length > 0) {
29
- lines.push('/** Flag/variant names the backend exposes to the client (the union of every [ClientFeatures] list). */');
29
+ lines.push('/** Flag/variant names the backend exposes to the client (the union of every client-exposed flag declaration). */');
30
30
  lines.push('export const Flags = {');
31
31
  for (const name of flagNames)
32
32
  lines.push(` ${q(name)}: ${q(name)},`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmesberger/elarion-jsonrpc-client-generator",
3
- "version": "0.2.8-preview.158.1",
3
+ "version": "0.2.8",
4
4
  "description": "Generate TypeScript RPC contracts, Zod schemas, and a fetch client from Elarion JSON-RPC schema exports.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",