@jskit-ai/kernel 0.1.185 → 0.1.186

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.185",
3
+ "version": "0.1.186",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "json-rest-schema": "^1.0.17"
@@ -1,5 +1,5 @@
1
1
  import { ActionRuntimeError } from "../../shared/actions/actionDefinitions.js";
2
- import { normalizeOpaqueId } from "../../shared/support/normalize.js";
2
+ import { normalizeOpaqueId, normalizeTransactionOutcome } from "../../shared/support/normalize.js";
3
3
  import { resolveDefaultSurfaceId } from "../support/appConfig.js";
4
4
 
5
5
  const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
@@ -255,7 +255,9 @@ function resolveRequestRouteTransport(request) {
255
255
 
256
256
  function applyRouteTransportErrorResponse(reply, request, error, {
257
257
  statusCode = 500,
258
- normalizedErrorCode = ""
258
+ normalizedErrorCode = "",
259
+ message = error?.message,
260
+ exposeDetails = true
259
261
  } = {}) {
260
262
  const transport = resolveRequestRouteTransport(request);
261
263
  const errorSerializer = transport && typeof transport.error === "function" ? transport.error : null;
@@ -267,7 +269,9 @@ function applyRouteTransportErrorResponse(reply, request, error, {
267
269
  request,
268
270
  reply,
269
271
  statusCode,
270
- code: normalizedErrorCode
272
+ code: normalizedErrorCode,
273
+ message,
274
+ exposeDetails
271
275
  });
272
276
 
273
277
  if (payload && typeof payload.then === "function") {
@@ -282,6 +286,36 @@ function applyRouteTransportErrorResponse(reply, request, error, {
282
286
  return true;
283
287
  }
284
288
 
289
+ function resolveApiErrorStatus(error, code) {
290
+ // JSON REST's public error codes need HTTP classification even without status hints.
291
+ switch (code) {
292
+ case "REST_API_VALIDATION":
293
+ return 422;
294
+ case "REST_API_RESOURCE":
295
+ switch (error?.subtype) {
296
+ case "not_found": return 404;
297
+ case "forbidden": return 403;
298
+ case "conflict": return 409;
299
+ default: return 400;
300
+ }
301
+ case "REST_API_VERSION_CONFLICT":
302
+ return 409;
303
+ case "REST_API_PRECONDITION_FAILED":
304
+ return 412;
305
+ case "REST_API_FIELDSET_INVALID":
306
+ case "REST_API_INCLUDE_INVALID":
307
+ return 400;
308
+ case "REST_API_PAYLOAD":
309
+ return error?.statusCode === 413 ? 413 : 400;
310
+ case "REST_API_TEMPORAL_DATA_INVALID":
311
+ return 500;
312
+ default: {
313
+ const status = Number(error?.statusCode || error?.status);
314
+ return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500;
315
+ }
316
+ }
317
+ }
318
+
285
319
  function registerApiErrorHandler(
286
320
  app,
287
321
  {
@@ -308,10 +342,10 @@ function registerApiErrorHandler(
308
342
 
309
343
  app.setErrorHandler((error, request, reply) => {
310
344
  const normalizedErrorCode = String(error?.code || "").trim();
345
+ const transactionOutcome = normalizeTransactionOutcome(error?.transactionOutcome);
346
+ const outcomeFields = transactionOutcome ? { transactionOutcome } : {};
311
347
  const isCsrfErrorCode = normalizedErrorCode.startsWith("FST_CSRF_");
312
- const statusFromError = Number(error?.statusCode || error?.status);
313
- const statusCode =
314
- Number.isInteger(statusFromError) && statusFromError >= 400 && statusFromError <= 599 ? statusFromError : 500;
348
+ const statusCode = resolveApiErrorStatus(error, normalizedErrorCode);
315
349
 
316
350
  if (Array.isArray(error?.validation)) {
317
351
  const fieldErrors = resolveValidationFieldErrors(error);
@@ -325,6 +359,7 @@ function registerApiErrorHandler(
325
359
  reply.code(400).send({
326
360
  error: "Validation failed.",
327
361
  code: validationErrorCode,
362
+ ...outcomeFields,
328
363
  fieldErrors,
329
364
  details: {
330
365
  fieldErrors
@@ -336,7 +371,8 @@ function registerApiErrorHandler(
336
371
  if (isAppError(error) || error instanceof ActionRuntimeError) {
337
372
  if (applyRouteTransportErrorResponse(reply, request, error, {
338
373
  statusCode: error.status,
339
- normalizedErrorCode: normalizedErrorCode || "app_error"
374
+ normalizedErrorCode: normalizedErrorCode || "app_error",
375
+ exposeDetails: shouldExposeAppErrorDetails(normalizedErrorCode)
340
376
  })) {
341
377
  return;
342
378
  }
@@ -349,7 +385,8 @@ function registerApiErrorHandler(
349
385
  const appErrorCode = normalizedErrorCode || "app_error";
350
386
  const payload = {
351
387
  error: error.message,
352
- code: appErrorCode
388
+ code: appErrorCode,
389
+ ...outcomeFields
353
390
  };
354
391
  if (error.details && shouldExposeAppErrorDetails(appErrorCode)) {
355
392
  payload.details = error.details;
@@ -385,7 +422,8 @@ function registerApiErrorHandler(
385
422
  normalizedErrorCode || (statusCode >= 500 ? "internal_server_error" : "request_failed");
386
423
  const payload = {
387
424
  error: message,
388
- code: fallbackErrorCode
425
+ code: fallbackErrorCode,
426
+ ...outcomeFields
389
427
  };
390
428
  if (isCsrfErrorCode) {
391
429
  payload.details = {
@@ -394,7 +432,9 @@ function registerApiErrorHandler(
394
432
  }
395
433
  if (applyRouteTransportErrorResponse(reply, request, error, {
396
434
  statusCode,
397
- normalizedErrorCode: fallbackErrorCode
435
+ normalizedErrorCode: fallbackErrorCode,
436
+ message,
437
+ exposeDetails: statusCode < 500
398
438
  })) {
399
439
  return;
400
440
  }
@@ -155,6 +155,44 @@ test("registerApiErrorHandler includes internal_server_error code for unhandled
155
155
  assert.equal(reply.payload.error, "Internal server error.");
156
156
  });
157
157
 
158
+ test("registerApiErrorHandler exposes only allowlisted transaction outcomes", () => {
159
+ const fastify = createFastifyStub();
160
+ registerApiErrorHandler(fastify, { isAppError });
161
+ for (const transactionOutcome of ["none", "pending", "committed", "rolledBack", "unknown"]) {
162
+ const error = Object.assign(new Error("Private SQL failure"), {
163
+ transactionOutcome,
164
+ cause: { password: "private" },
165
+ cleanupErrors: [{ message: "private cleanup" }]
166
+ });
167
+ const reply = createReplyStub();
168
+ fastify.errorHandler(error, {}, reply);
169
+ assert.equal(reply.statusCode, 500);
170
+ assert.deepEqual(reply.payload, {
171
+ error: "Internal server error.",
172
+ code: "internal_server_error",
173
+ transactionOutcome
174
+ });
175
+ }
176
+ for (const transactionOutcome of [undefined, "success", { secret: "private" }]) {
177
+ const reply = createReplyStub();
178
+ fastify.errorHandler(Object.assign(new Error("private"), { transactionOutcome }), {}, reply);
179
+ assert.equal(Object.hasOwn(reply.payload, "transactionOutcome"), false);
180
+ }
181
+ });
182
+
183
+ test("AppError and validation payloads retain transaction outcomes", () => {
184
+ const fastify = createFastifyStub();
185
+ registerApiErrorHandler(fastify, { isAppError });
186
+ for (const error of [
187
+ Object.assign(new AppError(409, "Conflict."), { transactionOutcome: "rolledBack" }),
188
+ { validation: [{ instancePath: "/title", message: "Required" }], transactionOutcome: "none" }
189
+ ]) {
190
+ const reply = createReplyStub();
191
+ fastify.errorHandler(error, {}, reply);
192
+ assert.equal(reply.payload.transactionOutcome, error.transactionOutcome);
193
+ }
194
+ });
195
+
158
196
  test("registerApiErrorHandler keeps known error code for non-app errors", () => {
159
197
  const fastify = createFastifyStub();
160
198
  registerApiErrorHandler(fastify, { isAppError });
@@ -1,3 +1,9 @@
1
+ const TRANSACTION_OUTCOMES = Object.freeze(["none", "pending", "committed", "rolledBack", "unknown"]);
2
+
3
+ function normalizeTransactionOutcome(value) {
4
+ return TRANSACTION_OUTCOMES.includes(value) ? value : "";
5
+ }
6
+
1
7
  function normalizeText(value, { fallback = "" } = {}) {
2
8
  const normalized = String(value || "").trim();
3
9
  return normalized || fallback;
@@ -351,6 +357,8 @@ function ensureNonEmptyText(value, label = "value") {
351
357
  }
352
358
 
353
359
  export {
360
+ TRANSACTION_OUTCOMES,
361
+ normalizeTransactionOutcome,
354
362
  normalizeText,
355
363
  hasValue,
356
364
  normalizeBoolean,
@@ -17,9 +17,19 @@ import {
17
17
  normalizeOneOf,
18
18
  normalizeQueryToken,
19
19
  normalizeText,
20
+ normalizeTransactionOutcome,
20
21
  normalizeUniqueTextList
21
22
  } from "./normalize.js";
22
23
 
24
+ test("normalizeTransactionOutcome accepts only the documented literal outcomes", () => {
25
+ for (const value of ["none", "pending", "committed", "rolledBack", "unknown"]) {
26
+ assert.equal(normalizeTransactionOutcome(value), value);
27
+ }
28
+ for (const value of [undefined, null, false, 0, "", "rolledback", " committed ", { toString: () => "committed" }]) {
29
+ assert.equal(normalizeTransactionOutcome(value), "");
30
+ }
31
+ });
32
+
23
33
  test("hasValue returns false for nullish and blank text, true otherwise", () => {
24
34
  assert.equal(hasValue(null), false);
25
35
  assert.equal(hasValue(undefined), false);