@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/LICENSE +21 -0
  3. package/dist/function/function-runner.js +7 -2
  4. package/dist/handle-error.d.ts +2 -0
  5. package/dist/handle-error.js +8 -2
  6. package/dist/schema.d.ts +24 -0
  7. package/dist/schema.js +46 -0
  8. package/dist/services/in-memory-queue-service.d.ts +6 -0
  9. package/dist/services/in-memory-queue-service.js +8 -1
  10. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  11. package/dist/services/in-memory-workflow-service.js +10 -19
  12. package/dist/services/workflow-service.d.ts +7 -5
  13. package/dist/types/core.types.d.ts +7 -0
  14. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  15. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  16. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  17. package/dist/wirings/http/http-runner.d.ts +1 -1
  18. package/dist/wirings/http/http-runner.js +4 -2
  19. package/dist/wirings/http/http.types.d.ts +2 -0
  20. package/dist/wirings/http/index.d.ts +2 -1
  21. package/dist/wirings/http/index.js +1 -1
  22. package/dist/wirings/http/pikku-fetch-http-request.d.ts +11 -1
  23. package/dist/wirings/http/pikku-fetch-http-request.js +67 -3
  24. package/dist/wirings/mcp/mcp-runner.d.ts +5 -0
  25. package/dist/wirings/mcp/mcp-runner.js +5 -2
  26. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  27. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  28. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  29. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  30. package/dist/wirings/workflow/graph/index.js +1 -1
  31. package/dist/wirings/workflow/index.d.ts +0 -1
  32. package/dist/wirings/workflow/index.js +0 -2
  33. package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
  34. package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
  35. package/dist/wirings/workflow/workflow.types.d.ts +1 -6
  36. package/package.json +1 -2
  37. package/src/function/function-runner.test.ts +75 -0
  38. package/src/function/function-runner.ts +15 -2
  39. package/src/gopass-secrets-removed.test.ts +51 -0
  40. package/src/handle-error.test.ts +108 -1
  41. package/src/handle-error.ts +10 -2
  42. package/src/schema.test.ts +103 -0
  43. package/src/schema.ts +51 -0
  44. package/src/services/in-memory-queue-service.test.ts +66 -1
  45. package/src/services/in-memory-queue-service.ts +13 -2
  46. package/src/services/in-memory-workflow-service.ts +12 -25
  47. package/src/services/workflow-service.ts +7 -4
  48. package/src/types/core.types.ts +7 -0
  49. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  50. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  51. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  52. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  53. package/src/wirings/http/http-runner.ts +4 -1
  54. package/src/wirings/http/http.types.ts +2 -0
  55. package/src/wirings/http/index.ts +5 -1
  56. package/src/wirings/http/pikku-fetch-http-request.test.ts +89 -0
  57. package/src/wirings/http/pikku-fetch-http-request.ts +85 -3
  58. package/src/wirings/mcp/mcp-runner.test.ts +40 -0
  59. package/src/wirings/mcp/mcp-runner.ts +11 -2
  60. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  61. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  62. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  63. package/src/wirings/workflow/graph/index.ts +1 -5
  64. package/src/wirings/workflow/index.ts +0 -6
  65. package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
  66. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  67. package/src/wirings/workflow/scenario-step.test.ts +1 -1
  68. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  69. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  70. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  71. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  72. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  73. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  74. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  75. package/src/wirings/workflow/workflow.types.ts +1 -4
  76. package/tsconfig.tsbuildinfo +1 -1
  77. 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' | 'dynamic-workflow' | 'scenario';
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.70",
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 { coerceTopLevelDataFromSchema, validateSchema } from '../schema.js'
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
- const actualData = await data()
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
+ })
@@ -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()
@@ -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
- e instanceof Error && e.message && e.message !== 'An error occurred'
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
 
@@ -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] = { pikkuFuncId: funcId, name: 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(runAttempt, this.backoffDelay(options?.backoff, attemptsMade))
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):`,