@pikku/core 0.12.70 → 0.12.72
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/CHANGELOG.md +143 -0
- package/LICENSE +21 -0
- package/dist/function/function-runner.js +7 -2
- package/dist/handle-error.d.ts +2 -0
- package/dist/handle-error.js +8 -2
- package/dist/schema.d.ts +24 -0
- package/dist/schema.js +46 -0
- package/dist/services/in-memory-queue-service.d.ts +6 -0
- package/dist/services/in-memory-queue-service.js +8 -1
- package/dist/services/in-memory-workflow-service.d.ts +3 -5
- package/dist/services/in-memory-workflow-service.js +10 -19
- package/dist/services/workflow-service.d.ts +7 -5
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
- package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
- package/dist/wirings/http/http-runner.d.ts +1 -1
- package/dist/wirings/http/http-runner.js +4 -2
- package/dist/wirings/http/http.types.d.ts +2 -0
- package/dist/wirings/http/index.d.ts +2 -1
- package/dist/wirings/http/index.js +1 -1
- package/dist/wirings/http/pikku-fetch-http-request.d.ts +11 -1
- package/dist/wirings/http/pikku-fetch-http-request.js +67 -3
- package/dist/wirings/mcp/mcp-runner.d.ts +5 -0
- package/dist/wirings/mcp/mcp-runner.js +5 -2
- package/dist/wirings/workflow/graph/graph-runner.js +3 -2
- package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
- package/dist/wirings/workflow/graph/graph-validation.js +0 -142
- package/dist/wirings/workflow/graph/index.d.ts +1 -1
- package/dist/wirings/workflow/graph/index.js +1 -1
- package/dist/wirings/workflow/index.d.ts +0 -1
- package/dist/wirings/workflow/index.js +0 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
- package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
- package/dist/wirings/workflow/workflow.types.d.ts +1 -6
- package/package.json +1 -2
- package/src/function/function-runner.test.ts +75 -0
- package/src/function/function-runner.ts +15 -2
- package/src/gopass-secrets-removed.test.ts +51 -0
- package/src/handle-error.test.ts +108 -1
- package/src/handle-error.ts +10 -2
- package/src/schema.test.ts +103 -0
- package/src/schema.ts +51 -0
- package/src/services/in-memory-queue-service.test.ts +66 -1
- package/src/services/in-memory-queue-service.ts +13 -2
- package/src/services/in-memory-workflow-service.ts +12 -25
- package/src/services/workflow-service.ts +7 -4
- package/src/types/core.types.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
- package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
- package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
- package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
- package/src/wirings/http/http-runner.ts +4 -1
- package/src/wirings/http/http.types.ts +2 -0
- package/src/wirings/http/index.ts +5 -1
- package/src/wirings/http/pikku-fetch-http-request.test.ts +89 -0
- package/src/wirings/http/pikku-fetch-http-request.ts +85 -3
- package/src/wirings/mcp/mcp-runner.test.ts +40 -0
- package/src/wirings/mcp/mcp-runner.ts +11 -2
- package/src/wirings/workflow/graph/graph-runner.ts +3 -2
- package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
- package/src/wirings/workflow/graph/graph-validation.ts +0 -196
- package/src/wirings/workflow/graph/index.ts +1 -5
- package/src/wirings/workflow/index.ts +0 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
- package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
- package/src/wirings/workflow/scenario-step.test.ts +1 -1
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
- package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
- package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
- package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
- package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
- package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
- package/src/wirings/workflow/workflow.types.ts +1 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/src/services/gopass-secrets.ts +0 -78
|
@@ -182,11 +182,6 @@ export interface WorkflowRunService {
|
|
|
182
182
|
graph: any;
|
|
183
183
|
source: string;
|
|
184
184
|
} | null>;
|
|
185
|
-
getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
|
|
186
|
-
workflowName: string;
|
|
187
|
-
graphHash: string;
|
|
188
|
-
graph: any;
|
|
189
|
-
}>>;
|
|
190
185
|
deleteRun(id: string): Promise<boolean>;
|
|
191
186
|
}
|
|
192
187
|
/**
|
|
@@ -367,7 +362,7 @@ export interface WorkflowRuntimeMeta {
|
|
|
367
362
|
/** Pikku function name (for execution) */
|
|
368
363
|
pikkuFuncId: string;
|
|
369
364
|
/** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', 'scenario' (complex + actor steps) */
|
|
370
|
-
source: 'dsl' | 'complex' | 'graph' | '
|
|
365
|
+
source: 'dsl' | 'complex' | 'graph' | 'scenario';
|
|
371
366
|
/** Optional description */
|
|
372
367
|
description?: string;
|
|
373
368
|
/** Tags for organization */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.72",
|
|
4
4
|
"description": "The Pikku runtime — functions, wirings, services, middleware and types",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,6 @@
|
|
|
55
55
|
"./services/istanbul-coverage": "./dist/services/istanbul-coverage-service.js",
|
|
56
56
|
"./services/local-content": "./dist/services/local-content.js",
|
|
57
57
|
"./services/temporary-file-service": "./dist/services/temporary-file-service.js",
|
|
58
|
-
"./services/gopass-secrets": "./dist/services/gopass-secrets.js",
|
|
59
58
|
"./crypto-utils": "./dist/crypto-utils.js",
|
|
60
59
|
"./hmac": "./dist/utils/hmac.js",
|
|
61
60
|
"./internal": "./dist/internal.js",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
runPikkuFunc,
|
|
8
8
|
} from './function-runner.js'
|
|
9
9
|
import { addGlobalPermission, addTagMiddleware } from '../index.js'
|
|
10
|
+
import { addSchema } from '../schema.js'
|
|
10
11
|
import { resetPikkuState, pikkuState } from '../pikku-state.js'
|
|
11
12
|
import type { CoreServices, CorePikkuMiddleware } from '../types/core.types.js'
|
|
12
13
|
import type { CorePermissionGroup } from './functions.types.js'
|
|
@@ -1494,4 +1495,78 @@ describe('runPikkuFunc - scopes', () => {
|
|
|
1494
1495
|
MissingScopeError
|
|
1495
1496
|
)
|
|
1496
1497
|
})
|
|
1498
|
+
|
|
1499
|
+
describe('schema defaults', () => {
|
|
1500
|
+
const addPagedFunction = (funcName: string, seen: { data?: any }) => {
|
|
1501
|
+
addSchema('PagedInput', {
|
|
1502
|
+
properties: {
|
|
1503
|
+
page: { type: 'number', default: 1 },
|
|
1504
|
+
limit: { type: 'number', default: 50 },
|
|
1505
|
+
},
|
|
1506
|
+
})
|
|
1507
|
+
addTestFunction(funcName, {
|
|
1508
|
+
func: async (_services: any, data: any) => {
|
|
1509
|
+
seen.data = data
|
|
1510
|
+
return 'ok'
|
|
1511
|
+
},
|
|
1512
|
+
})
|
|
1513
|
+
pikkuState(null, 'function', 'meta')[funcName]!.inputSchemaName =
|
|
1514
|
+
'PagedInput'
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// The whole point of the fix: `{}` validates because a defaulted property
|
|
1518
|
+
// is not `required`, so without this the body reads `undefined` for a value
|
|
1519
|
+
// its generated type declares as a number.
|
|
1520
|
+
test('a function called with {} receives its schema defaults', async () => {
|
|
1521
|
+
const seen: { data?: any } = {}
|
|
1522
|
+
addPagedFunction('paged', seen)
|
|
1523
|
+
|
|
1524
|
+
await runPikkuFunc('rpc', Math.random().toString(), 'paged', {
|
|
1525
|
+
singletonServices: mockSingletonServices,
|
|
1526
|
+
getAllServices: () => mockServices,
|
|
1527
|
+
data: () => ({}),
|
|
1528
|
+
auth: false,
|
|
1529
|
+
wire: {},
|
|
1530
|
+
})
|
|
1531
|
+
|
|
1532
|
+
assert.equal(seen.data.page, 1)
|
|
1533
|
+
assert.equal(seen.data.limit, 50)
|
|
1534
|
+
})
|
|
1535
|
+
|
|
1536
|
+
// `coerceDataFromSchema` is not passed here, exactly as a direct RPC
|
|
1537
|
+
// invocation leaves it. Defaults belong to the schema rather than to the
|
|
1538
|
+
// transport, so they must not ride on that flag — and a call made with no
|
|
1539
|
+
// arguments at all still has to get them.
|
|
1540
|
+
test('defaults apply with coercion off and no data', async () => {
|
|
1541
|
+
const seen: { data?: any } = {}
|
|
1542
|
+
addPagedFunction('pagedNoCoerce', seen)
|
|
1543
|
+
|
|
1544
|
+
await runPikkuFunc('rpc', Math.random().toString(), 'pagedNoCoerce', {
|
|
1545
|
+
singletonServices: mockSingletonServices,
|
|
1546
|
+
getAllServices: () => mockServices,
|
|
1547
|
+
data: () => undefined,
|
|
1548
|
+
auth: false,
|
|
1549
|
+
wire: {},
|
|
1550
|
+
})
|
|
1551
|
+
|
|
1552
|
+
assert.equal(seen.data.page, 1)
|
|
1553
|
+
assert.equal(seen.data.limit, 50)
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
test('a supplied value survives', async () => {
|
|
1557
|
+
const seen: { data?: any } = {}
|
|
1558
|
+
addPagedFunction('pagedSupplied', seen)
|
|
1559
|
+
|
|
1560
|
+
await runPikkuFunc('rpc', Math.random().toString(), 'pagedSupplied', {
|
|
1561
|
+
singletonServices: mockSingletonServices,
|
|
1562
|
+
getAllServices: () => mockServices,
|
|
1563
|
+
data: () => ({ page: 3 }),
|
|
1564
|
+
auth: false,
|
|
1565
|
+
wire: {},
|
|
1566
|
+
})
|
|
1567
|
+
|
|
1568
|
+
assert.equal(seen.data.page, 3)
|
|
1569
|
+
assert.equal(seen.data.limit, 50)
|
|
1570
|
+
})
|
|
1571
|
+
})
|
|
1497
1572
|
})
|
|
@@ -5,7 +5,11 @@ import {
|
|
|
5
5
|
} from '../wirings/channel/channel-middleware-runner.js'
|
|
6
6
|
import { runPermissions } from '../permissions.js'
|
|
7
7
|
import { pikkuState } from '../pikku-state.js'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
applyDefaultsFromSchema,
|
|
10
|
+
coerceTopLevelDataFromSchema,
|
|
11
|
+
validateSchema,
|
|
12
|
+
} from '../schema.js'
|
|
9
13
|
import type {
|
|
10
14
|
CoreUserSession,
|
|
11
15
|
CorePikkuMiddleware,
|
|
@@ -293,11 +297,20 @@ export const runPikkuFunc = async <In = any, Out = any>(
|
|
|
293
297
|
verifyScopes(funcConfig.scopes ?? funcMeta.scopes, session)
|
|
294
298
|
|
|
295
299
|
// Evaluate the data from the lazy function
|
|
296
|
-
|
|
300
|
+
let actualData = await data()
|
|
297
301
|
|
|
298
302
|
// Validate and coerce data if schema is defined
|
|
299
303
|
const inputSchemaName = funcMeta.inputSchemaName
|
|
300
304
|
if (inputSchemaName) {
|
|
305
|
+
// Fill in schema defaults before anything reads the data. Unconditional:
|
|
306
|
+
// a default belongs to the schema, not to the transport the call arrived
|
|
307
|
+
// on. Runs before coercion so a defaulted value and a supplied one are
|
|
308
|
+
// treated identically from here on.
|
|
309
|
+
actualData = applyDefaultsFromSchema(
|
|
310
|
+
inputSchemaName,
|
|
311
|
+
actualData,
|
|
312
|
+
packageName
|
|
313
|
+
)
|
|
301
314
|
// Coerce (top level) data types before validation (e.g. string→array, string→date)
|
|
302
315
|
if (coerceDataFromSchema) {
|
|
303
316
|
coerceTopLevelDataFromSchema(inputSchemaName, actualData, packageName)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { join, dirname } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const srcRoot = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const packageRoot = join(srcRoot, '..')
|
|
9
|
+
|
|
10
|
+
const collectSourceFiles = (directory: string): string[] =>
|
|
11
|
+
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
12
|
+
const entryPath = join(directory, entry.name)
|
|
13
|
+
if (entry.isDirectory()) {
|
|
14
|
+
return entry.name === 'dist' ? [] : collectSourceFiles(entryPath)
|
|
15
|
+
}
|
|
16
|
+
return /\.(ts|js|mts|cts)$/.test(entry.name) ? [entryPath] : []
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('gopass secret service removal', () => {
|
|
20
|
+
test('the gopass-secrets module no longer exists', () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
existsSync(join(srcRoot, 'services/gopass-secrets.ts')),
|
|
23
|
+
false,
|
|
24
|
+
'services/gopass-secrets.ts was reintroduced'
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('package.json no longer exports ./services/gopass-secrets', () => {
|
|
29
|
+
const packageJson = JSON.parse(
|
|
30
|
+
readFileSync(join(packageRoot, 'package.json'), 'utf-8')
|
|
31
|
+
)
|
|
32
|
+
assert.equal(
|
|
33
|
+
packageJson.exports['./services/gopass-secrets'],
|
|
34
|
+
undefined,
|
|
35
|
+
'./services/gopass-secrets export was reintroduced'
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('no source file references gopass', () => {
|
|
40
|
+
const offenders = collectSourceFiles(srcRoot).filter(
|
|
41
|
+
(file) =>
|
|
42
|
+
file !== fileURLToPath(import.meta.url) &&
|
|
43
|
+
/gopass/i.test(readFileSync(file, 'utf-8'))
|
|
44
|
+
)
|
|
45
|
+
assert.deepEqual(
|
|
46
|
+
offenders,
|
|
47
|
+
[],
|
|
48
|
+
`gopass references found in:\n${offenders.join('\n')}`
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
})
|
package/src/handle-error.test.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, test, beforeEach } from 'node:test'
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
import { handleHTTPError } from './handle-error.js'
|
|
4
|
-
import { addError } from './errors/error-handler.js'
|
|
4
|
+
import { addError, PikkuError } from './errors/error-handler.js'
|
|
5
5
|
import {
|
|
6
6
|
NotFoundError,
|
|
7
7
|
BadRequestError,
|
|
8
8
|
ForbiddenError,
|
|
9
|
+
MissingScopeError,
|
|
9
10
|
} from './errors/errors.js'
|
|
10
11
|
import { resetPikkuState } from './pikku-state.js'
|
|
11
12
|
|
|
@@ -399,6 +400,112 @@ describe('handleHTTPError', () => {
|
|
|
399
400
|
assert.deepStrictEqual(http._state.jsonBody, { errorId: 'tracker-str' })
|
|
400
401
|
})
|
|
401
402
|
|
|
403
|
+
test('should not leak the payload of a registered server error', () => {
|
|
404
|
+
class LeakyInternalError extends PikkuError {}
|
|
405
|
+
addError(LeakyInternalError, {
|
|
406
|
+
status: 500,
|
|
407
|
+
message: 'Something went wrong',
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const logger = createMockLogger()
|
|
411
|
+
const http = createMockHTTP()
|
|
412
|
+
const error = new LeakyInternalError(
|
|
413
|
+
'connection to 10.0.0.1 refused'
|
|
414
|
+
) as any
|
|
415
|
+
error.payload = { connectionString: 'postgres://user:hunter2@10.0.0.1' }
|
|
416
|
+
|
|
417
|
+
handleHTTPError(
|
|
418
|
+
error,
|
|
419
|
+
http as any,
|
|
420
|
+
'tracker-leak',
|
|
421
|
+
logger as any,
|
|
422
|
+
[],
|
|
423
|
+
true,
|
|
424
|
+
false
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
assert.strictEqual(http._state.statusCode, 500)
|
|
428
|
+
assert.strictEqual(http._state.jsonBody.payload, undefined)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('should not leak the raw message of a registered server error', () => {
|
|
432
|
+
class InternalMessageError extends PikkuError {}
|
|
433
|
+
addError(InternalMessageError, {
|
|
434
|
+
status: 500,
|
|
435
|
+
message: 'Something went wrong',
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
const logger = createMockLogger()
|
|
439
|
+
const http = createMockHTTP()
|
|
440
|
+
const error = new InternalMessageError('secret internal detail')
|
|
441
|
+
|
|
442
|
+
handleHTTPError(
|
|
443
|
+
error,
|
|
444
|
+
http as any,
|
|
445
|
+
'tracker-msg',
|
|
446
|
+
logger as any,
|
|
447
|
+
[],
|
|
448
|
+
true,
|
|
449
|
+
false
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
assert.strictEqual(http._state.statusCode, 500)
|
|
453
|
+
assert.strictEqual(http._state.jsonBody.message, 'Something went wrong')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('should keep the payload of a client facing error', () => {
|
|
457
|
+
const logger = createMockLogger()
|
|
458
|
+
const http = createMockHTTP()
|
|
459
|
+
const error = new MissingScopeError('admin:write')
|
|
460
|
+
|
|
461
|
+
handleHTTPError(
|
|
462
|
+
error,
|
|
463
|
+
http as any,
|
|
464
|
+
'tracker-scope',
|
|
465
|
+
logger as any,
|
|
466
|
+
[],
|
|
467
|
+
true,
|
|
468
|
+
false
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
assert.strictEqual(http._state.statusCode, 403)
|
|
472
|
+
assert.deepStrictEqual(http._state.jsonBody.payload, {
|
|
473
|
+
error: 'missing_scope',
|
|
474
|
+
scope: 'admin:write',
|
|
475
|
+
})
|
|
476
|
+
assert.strictEqual(
|
|
477
|
+
http._state.jsonBody.message,
|
|
478
|
+
'Missing required scope: admin:write'
|
|
479
|
+
)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('should expose server error details when exposeErrors is true', () => {
|
|
483
|
+
class ExposableInternalError extends PikkuError {}
|
|
484
|
+
addError(ExposableInternalError, {
|
|
485
|
+
status: 500,
|
|
486
|
+
message: 'Something went wrong',
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
const logger = createMockLogger()
|
|
490
|
+
const http = createMockHTTP()
|
|
491
|
+
const error = new ExposableInternalError('internal detail') as any
|
|
492
|
+
error.payload = { debug: true }
|
|
493
|
+
|
|
494
|
+
handleHTTPError(
|
|
495
|
+
error,
|
|
496
|
+
http as any,
|
|
497
|
+
'tracker-expose-known',
|
|
498
|
+
logger as any,
|
|
499
|
+
[],
|
|
500
|
+
true,
|
|
501
|
+
false,
|
|
502
|
+
true
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
assert.strictEqual(http._state.jsonBody.message, 'internal detail')
|
|
506
|
+
assert.deepStrictEqual(http._state.jsonBody.payload, { debug: true })
|
|
507
|
+
})
|
|
508
|
+
|
|
402
509
|
test('should handle warning log without trackerId', () => {
|
|
403
510
|
const logger = createMockLogger()
|
|
404
511
|
const http = createMockHTTP()
|
package/src/handle-error.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type { PikkuHTTP } from './wirings/http/http.types.js'
|
|
|
14
14
|
* @param {number[]} logWarningsForStatusCodes - HTTP status codes to log as warnings
|
|
15
15
|
* @param {boolean} respondWith404 - Whether to respond with 404 for NotFoundError
|
|
16
16
|
* @param {boolean} bubbleError - Whether to throw the error after handling
|
|
17
|
+
* @param {boolean} exposeErrors - Whether to include internal error details (message, stack and
|
|
18
|
+
* payload of 5xx errors) in the response body. Ignored in production.
|
|
17
19
|
*/
|
|
18
20
|
export const handleHTTPError = (
|
|
19
21
|
e: any,
|
|
@@ -33,15 +35,21 @@ export const handleHTTPError = (
|
|
|
33
35
|
// Get appropriate error response
|
|
34
36
|
const errorResponse = getErrorResponse(e)
|
|
35
37
|
if (errorResponse != null) {
|
|
38
|
+
const clientFacing =
|
|
39
|
+
errorResponse.status < 500 || (exposeErrors && !isProduction())
|
|
40
|
+
|
|
36
41
|
// Set status and response body
|
|
37
42
|
http?.response?.status(errorResponse.status)
|
|
38
43
|
http?.response?.json({
|
|
39
44
|
name: e instanceof Error ? e.name : undefined,
|
|
40
45
|
message:
|
|
41
|
-
|
|
46
|
+
clientFacing &&
|
|
47
|
+
e instanceof Error &&
|
|
48
|
+
e.message &&
|
|
49
|
+
e.message !== 'An error occurred'
|
|
42
50
|
? e.message
|
|
43
51
|
: errorResponse.message,
|
|
44
|
-
payload: (e as any).payload,
|
|
52
|
+
payload: clientFacing ? (e as any).payload : undefined,
|
|
45
53
|
errorId: traceId,
|
|
46
54
|
})
|
|
47
55
|
|
package/src/schema.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as assert from 'assert'
|
|
|
3
3
|
import {
|
|
4
4
|
addSchema,
|
|
5
5
|
getSchema,
|
|
6
|
+
applyDefaultsFromSchema,
|
|
6
7
|
coerceTopLevelDataFromSchema,
|
|
7
8
|
validateSchema,
|
|
8
9
|
compileAllSchemas,
|
|
@@ -111,6 +112,108 @@ describe('Schema', () => {
|
|
|
111
112
|
})
|
|
112
113
|
})
|
|
113
114
|
|
|
115
|
+
describe('applyDefaultsFromSchema', () => {
|
|
116
|
+
before(() => {
|
|
117
|
+
addSchema('defaultsSchema', {
|
|
118
|
+
properties: {
|
|
119
|
+
page: { type: 'number', default: 1 },
|
|
120
|
+
limit: { type: 'number', default: 50 },
|
|
121
|
+
addons: { type: 'array', default: [] },
|
|
122
|
+
mode: { type: 'string', default: 'manual' },
|
|
123
|
+
enabled: { type: 'boolean', default: true },
|
|
124
|
+
name: { type: 'string' },
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
addSchema('noDefaultsSchema', {
|
|
129
|
+
properties: { name: { type: 'string' } },
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
addSchema('booleanPropSchema', {
|
|
133
|
+
properties: { isActive: true, page: { type: 'number', default: 1 } },
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('should fill in absent properties from their default', () => {
|
|
138
|
+
const data = applyDefaultsFromSchema('defaultsSchema', {})
|
|
139
|
+
assert.strictEqual(data.page, 1)
|
|
140
|
+
assert.strictEqual(data.limit, 50)
|
|
141
|
+
assert.strictEqual(data.mode, 'manual')
|
|
142
|
+
assert.strictEqual(data.enabled, true)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('should not overwrite a supplied value', () => {
|
|
146
|
+
const data = applyDefaultsFromSchema('defaultsSchema', {
|
|
147
|
+
page: 7,
|
|
148
|
+
limit: 200,
|
|
149
|
+
})
|
|
150
|
+
assert.strictEqual(data.page, 7)
|
|
151
|
+
assert.strictEqual(data.limit, 200)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// `false` and `0` are the values a truthiness check silently replaces, and
|
|
155
|
+
// the reason this tests presence rather than falsiness.
|
|
156
|
+
test('should not overwrite a supplied falsy value', () => {
|
|
157
|
+
const data = applyDefaultsFromSchema('defaultsSchema', {
|
|
158
|
+
page: 0,
|
|
159
|
+
enabled: false,
|
|
160
|
+
})
|
|
161
|
+
assert.strictEqual(data.page, 0)
|
|
162
|
+
assert.strictEqual(data.enabled, false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('should fill defaults into nullish data', () => {
|
|
166
|
+
assert.strictEqual(
|
|
167
|
+
applyDefaultsFromSchema('defaultsSchema', undefined).page,
|
|
168
|
+
1
|
|
169
|
+
)
|
|
170
|
+
assert.strictEqual(
|
|
171
|
+
applyDefaultsFromSchema('defaultsSchema', null).limit,
|
|
172
|
+
50
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('should leave properties without a default absent', () => {
|
|
177
|
+
const data = applyDefaultsFromSchema('defaultsSchema', {})
|
|
178
|
+
assert.ok(!('name' in data))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Two requests sharing one array default would let the first request's
|
|
182
|
+
// pushes show up in the second.
|
|
183
|
+
test('should clone object and array defaults per call', () => {
|
|
184
|
+
const first = applyDefaultsFromSchema('defaultsSchema', {})
|
|
185
|
+
const second = applyDefaultsFromSchema('defaultsSchema', {})
|
|
186
|
+
first.addons.push('leaked')
|
|
187
|
+
assert.deepStrictEqual(second.addons, [])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('should return the same data when the schema has no defaults', () => {
|
|
191
|
+
const data = { name: 'x' }
|
|
192
|
+
assert.strictEqual(
|
|
193
|
+
applyDefaultsFromSchema('noDefaultsSchema', data),
|
|
194
|
+
data
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('should return data untouched for an unknown schema', () => {
|
|
199
|
+
const data = { name: 'x' }
|
|
200
|
+
assert.strictEqual(applyDefaultsFromSchema('nonExistent', data), data)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('should leave a primitive body for the validator to reject', () => {
|
|
204
|
+
assert.strictEqual(
|
|
205
|
+
applyDefaultsFromSchema('defaultsSchema', 'nope'),
|
|
206
|
+
'nope'
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('should skip boolean schema properties', () => {
|
|
211
|
+
const data = applyDefaultsFromSchema('booleanPropSchema', {})
|
|
212
|
+
assert.strictEqual(data.page, 1)
|
|
213
|
+
assert.ok(!('isActive' in data))
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
114
217
|
describe('validateSchema', () => {
|
|
115
218
|
beforeEach(() => {
|
|
116
219
|
resetPikkuState()
|
package/src/schema.ts
CHANGED
|
@@ -94,6 +94,57 @@ const validateAllSchemasLoaded = (
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Fill in absent top-level properties from their schema `default`.
|
|
99
|
+
*
|
|
100
|
+
* A `default` reaches the generated JSON Schema and keeps the property out of
|
|
101
|
+
* `required`, so omitting it validates — but nothing was ever filling it in.
|
|
102
|
+
* JSON Schema validators are pure by specification and none of the ones Pikku
|
|
103
|
+
* ships with (`@cfworker/json-schema`, and Ajv unless `useDefaults` is set)
|
|
104
|
+
* annotate the instance, so the function received `undefined` for a property
|
|
105
|
+
* its generated type declares as present. That is the worst shape a mismatch
|
|
106
|
+
* can take: validation permits the omission, the type says the value is there,
|
|
107
|
+
* and the body reads `undefined`.
|
|
108
|
+
*
|
|
109
|
+
* Applied unconditionally rather than alongside `coerceTopLevelDataFromSchema`,
|
|
110
|
+
* whose `coerceDataFromSchema` flag is about decoding transport-encoded values
|
|
111
|
+
* (a query string's `"1,2"` into an array). Defaults are a property of the
|
|
112
|
+
* schema, not of how the call arrived, so gating them on that flag would apply
|
|
113
|
+
* them over HTTP and skip them on a direct RPC invocation.
|
|
114
|
+
*
|
|
115
|
+
* Returns the data to use, which is a new object only when defaults had to be
|
|
116
|
+
* added to a nullish input — a call made with no arguments at all still gets
|
|
117
|
+
* them. Values are cloned so an object or array default (`[]`, `{}`) is never
|
|
118
|
+
* shared as one mutable instance across every request.
|
|
119
|
+
*/
|
|
120
|
+
export const applyDefaultsFromSchema = (
|
|
121
|
+
schemaName: string,
|
|
122
|
+
data: any,
|
|
123
|
+
packageName: string | null = null
|
|
124
|
+
) => {
|
|
125
|
+
const schema = pikkuState(packageName, 'misc', 'schemas').get(schemaName)
|
|
126
|
+
if (!schema?.properties) return data
|
|
127
|
+
|
|
128
|
+
// A primitive body cannot carry named properties; leave it for the validator
|
|
129
|
+
// to reject rather than reshaping it into something that would pass.
|
|
130
|
+
if (data != null && typeof data !== 'object') return data
|
|
131
|
+
|
|
132
|
+
let result = data
|
|
133
|
+
for (const key in schema.properties) {
|
|
134
|
+
const property = schema.properties[key]
|
|
135
|
+
if (typeof property === 'boolean' || !('default' in property)) {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
// Allocated only once a default is actually found, so a schema without any
|
|
139
|
+
// leaves the caller's data (and its absence) exactly as it was.
|
|
140
|
+
result ??= {}
|
|
141
|
+
if (result[key] === undefined) {
|
|
142
|
+
result[key] = structuredClone(property.default)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result
|
|
146
|
+
}
|
|
147
|
+
|
|
97
148
|
export const coerceTopLevelDataFromSchema = (
|
|
98
149
|
schemaName: string,
|
|
99
150
|
data: any,
|
|
@@ -23,7 +23,10 @@ const registerWorker = (
|
|
|
23
23
|
) => {
|
|
24
24
|
const calls = { count: 0 }
|
|
25
25
|
const funcId = `queue_${queueName}`
|
|
26
|
-
pikkuState(null, 'queue', 'meta')[queueName] = {
|
|
26
|
+
pikkuState(null, 'queue', 'meta')[queueName] = {
|
|
27
|
+
pikkuFuncId: funcId,
|
|
28
|
+
name: queueName,
|
|
29
|
+
}
|
|
27
30
|
pikkuState(null, 'function', 'meta')[funcId] = {
|
|
28
31
|
pikkuFuncId: funcId,
|
|
29
32
|
inputSchemaName: null,
|
|
@@ -55,6 +58,68 @@ const waitUntil = async (
|
|
|
55
58
|
return predicate()
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
// Register a queue worker that records the payload each delivery carried.
|
|
62
|
+
const registerRecordingWorker = (queueName: string) => {
|
|
63
|
+
const received: any[] = []
|
|
64
|
+
const funcId = `queue_${queueName}`
|
|
65
|
+
pikkuState(null, 'queue', 'meta')[queueName] = {
|
|
66
|
+
pikkuFuncId: funcId,
|
|
67
|
+
name: queueName,
|
|
68
|
+
}
|
|
69
|
+
pikkuState(null, 'function', 'meta')[funcId] = {
|
|
70
|
+
pikkuFuncId: funcId,
|
|
71
|
+
inputSchemaName: null,
|
|
72
|
+
outputSchemaName: null,
|
|
73
|
+
sessionless: true,
|
|
74
|
+
} as any
|
|
75
|
+
wireQueueWorker({
|
|
76
|
+
name: queueName,
|
|
77
|
+
func: {
|
|
78
|
+
auth: false,
|
|
79
|
+
func: async (_services: any, data: any) => {
|
|
80
|
+
received.push(data)
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
} as any)
|
|
84
|
+
return received
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('InMemoryQueueService payload isolation', () => {
|
|
88
|
+
test('a job carries the payload as it was when added', async () => {
|
|
89
|
+
const received = registerRecordingWorker('mutated')
|
|
90
|
+
const queue = new InMemoryQueueService()
|
|
91
|
+
const payload = { items: ['a'] }
|
|
92
|
+
|
|
93
|
+
await queue.add('mutated', payload, { delay: 0 })
|
|
94
|
+
payload.items.push('b')
|
|
95
|
+
|
|
96
|
+
assert.equal(await waitUntil(() => received.length === 1), true)
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
received[0].items,
|
|
99
|
+
['a'],
|
|
100
|
+
'the worker saw a mutation made after the job was queued — a real queue would have serialised it away'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a payload arrives shaped the way a real queue would deliver it', async () => {
|
|
105
|
+
const received = registerRecordingWorker('serialised')
|
|
106
|
+
const queue = new InMemoryQueueService()
|
|
107
|
+
|
|
108
|
+
await queue.add(
|
|
109
|
+
'serialised',
|
|
110
|
+
{ when: new Date('2020-01-01T00:00:00.000Z') },
|
|
111
|
+
{ delay: 0 }
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
assert.equal(await waitUntil(() => received.length === 1), true)
|
|
115
|
+
assert.equal(
|
|
116
|
+
received[0].when,
|
|
117
|
+
'2020-01-01T00:00:00.000Z',
|
|
118
|
+
'a Date survived as a Date, so dev behaviour diverges from every real queue backend'
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
58
123
|
describe('InMemoryQueueService retry', () => {
|
|
59
124
|
test('redelivers a transiently-failing job until it succeeds', async () => {
|
|
60
125
|
const calls = registerWorker('flaky', (count) => {
|
|
@@ -11,6 +11,12 @@ import { runQueueJob } from '../wirings/queue/queue-runner.js'
|
|
|
11
11
|
* a real queue — and redelivers a failed job up to `options.attempts` times with
|
|
12
12
|
* backoff, so a transiently-failing workflow step recovers exactly as it would
|
|
13
13
|
* on pg-boss/bullmq instead of being silently dropped on its first error.
|
|
14
|
+
*
|
|
15
|
+
* Payloads are JSON round-tripped on the way in, because every real backend
|
|
16
|
+
* puts the job on a wire (SQS body, Redis value, jsonb column) and the worker
|
|
17
|
+
* therefore never sees the caller's live object. Doing it here keeps dev
|
|
18
|
+
* behaviour honest, and keeps the callers — who cannot know which backend they
|
|
19
|
+
* are talking to — from having to serialise defensively.
|
|
14
20
|
*/
|
|
15
21
|
export class InMemoryQueueService implements QueueService {
|
|
16
22
|
readonly supportsResults = false
|
|
@@ -25,13 +31,15 @@ export class InMemoryQueueService implements QueueService {
|
|
|
25
31
|
const maxAttempts = Math.max(1, options?.attempts ?? 1)
|
|
26
32
|
let attemptsMade = 0
|
|
27
33
|
const createdAt = new Date()
|
|
34
|
+
const payload: T =
|
|
35
|
+
data === undefined ? data : (JSON.parse(JSON.stringify(data)) as T)
|
|
28
36
|
|
|
29
37
|
const runAttempt = async () => {
|
|
30
38
|
attemptsMade++
|
|
31
39
|
const job: QueueJob<T> = {
|
|
32
40
|
id: jobId,
|
|
33
41
|
queueName,
|
|
34
|
-
data,
|
|
42
|
+
data: payload,
|
|
35
43
|
status: () => 'active',
|
|
36
44
|
metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
|
|
37
45
|
pikkuUserId: options?.pikkuUserId,
|
|
@@ -41,7 +49,10 @@ export class InMemoryQueueService implements QueueService {
|
|
|
41
49
|
} catch (e: any) {
|
|
42
50
|
if (attemptsMade < maxAttempts) {
|
|
43
51
|
// Transient failure — redeliver with backoff, mirroring a real queue.
|
|
44
|
-
setTimeout(
|
|
52
|
+
setTimeout(
|
|
53
|
+
runAttempt,
|
|
54
|
+
this.backoffDelay(options?.backoff, attemptsMade)
|
|
55
|
+
)
|
|
45
56
|
} else {
|
|
46
57
|
console.error(
|
|
47
58
|
`[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`,
|