@pikku/core 0.12.15 → 0.12.17

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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Type-level tests for list-function primitives.
3
+ *
4
+ * No runtime assertions — the point is that valid shapes compile and
5
+ * invalid shapes fail with `@ts-expect-error`. The file is excluded
6
+ * from `yarn build` via tsconfig's `**∕*.test.ts` pattern.
7
+ */
8
+
9
+ import type { ListInput, ListOutput, Filter } from './list.types.js'
10
+
11
+ // -- ListInput ---------------------------------------------------------------
12
+
13
+ type SessionFilter = {
14
+ status?: string[]
15
+ therapistId?: string
16
+ uploadedAt?: string
17
+ }
18
+ type SessionSort = 'user' | 'status' | 'uploaded_at'
19
+
20
+ const _basic: ListInput<SessionFilter, SessionSort> = {
21
+ cursor: 'abc',
22
+ limit: 50,
23
+ sort: [
24
+ { column: 'uploaded_at', direction: 'desc' },
25
+ { column: 'status', direction: 'asc' },
26
+ ],
27
+ filter: { status: ['pending'] },
28
+ search: 'sarah',
29
+ }
30
+ void _basic
31
+
32
+ const _empty: ListInput = {}
33
+ void _empty
34
+
35
+ // sort.column must be from the declared union
36
+ // @ts-expect-error — 'created_at' is not in SessionSort
37
+ const _badSort: ListInput<SessionFilter, SessionSort> = {
38
+ sort: [{ column: 'created_at', direction: 'asc' }],
39
+ }
40
+ void _badSort
41
+
42
+ // sort.direction must be 'asc' or 'desc'
43
+ // @ts-expect-error — 'descending' is not a valid direction
44
+ const _badDirection: ListInput<SessionFilter, SessionSort> = {
45
+ sort: [{ column: 'user', direction: 'descending' }],
46
+ }
47
+ void _badDirection
48
+
49
+ // -- ListOutput --------------------------------------------------------------
50
+
51
+ interface Session {
52
+ id: string
53
+ user: string
54
+ status: string
55
+ }
56
+
57
+ const _output: ListOutput<Session> = {
58
+ rows: [{ id: '1', user: 'sarah', status: 'pending' }],
59
+ nextCursor: null,
60
+ totalCount: 42,
61
+ }
62
+ void _output
63
+
64
+ // nextCursor is required (must be string or null, not undefined-only)
65
+ // @ts-expect-error — missing nextCursor
66
+ const _badOutput: ListOutput<Session> = {
67
+ rows: [],
68
+ }
69
+ void _badOutput
70
+
71
+ // -- Filter: leaves ----------------------------------------------------------
72
+
73
+ // Simple equality
74
+ const _leafEq: Filter<SessionFilter> = { therapistId: 'kim' }
75
+ void _leafEq
76
+
77
+ // Array = IN shorthand
78
+ const _leafIn: Filter<SessionFilter> = { status: ['pending', 'processed'] }
79
+ void _leafIn
80
+
81
+ // Operator object
82
+ const _leafOp: Filter<SessionFilter> = {
83
+ uploadedAt: { gt: '2026-04-10', lte: '2026-04-17' },
84
+ }
85
+ void _leafOp
86
+
87
+ // Null = IS NULL
88
+ const _leafNull: Filter<SessionFilter> = { therapistId: null }
89
+ void _leafNull
90
+
91
+ // NOT on a value
92
+ const _leafNot: Filter<SessionFilter> = { therapistId: { not: 'kim' } }
93
+ void _leafNot
94
+
95
+ // -- Filter: AND (array) -----------------------------------------------------
96
+
97
+ const _and: Filter<SessionFilter> = [
98
+ { status: ['pending'] },
99
+ { therapistId: 'kim' },
100
+ { uploadedAt: { gt: '2026-04-10' } },
101
+ ]
102
+ void _and
103
+
104
+ // -- Filter: OR (object with arbitrary label keys) --------------------------
105
+
106
+ const _or: Filter<SessionFilter> = {
107
+ viaKim: { therapistId: 'kim' },
108
+ viaPark: { therapistId: 'park' },
109
+ }
110
+ void _or
111
+
112
+ // -- Filter: nested AND/OR ---------------------------------------------------
113
+
114
+ const _nested: Filter<SessionFilter> = [
115
+ { status: ['pending'] },
116
+ {
117
+ kim: { therapistId: 'kim' },
118
+ park: { therapistId: 'park' },
119
+ },
120
+ { uploadedAt: { gt: '2026-04-10' } },
121
+ ]
122
+ void _nested
@@ -0,0 +1,121 @@
1
+ /**
2
+ * List-function primitives.
3
+ *
4
+ * A "list function" is any Pikku function that returns a paginated
5
+ * collection. Adopting this shape unlocks a shared vocabulary across
6
+ * MCP tools, AI agents, typed RPC clients, and widget libraries — they
7
+ * all reason about cursor, filter, sort, and search uniformly.
8
+ *
9
+ * Under the hood, a list function is still a normal `pikkuFunc`. These
10
+ * types are purely structural constraints; no runtime behaviour change.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { pikkuFunc } from '#pikku'
15
+ * import type { ListInput, ListOutput } from '@pikku/core'
16
+ *
17
+ * export const listSessions = pikkuFunc<
18
+ * ListInput<{ status?: SessionStatus[] }, 'user' | 'status' | 'uploaded_at'>,
19
+ * ListOutput<Session>
20
+ * >({
21
+ * func: async ({ kysely }, input) => {
22
+ * // input.sort / input.filter / input.cursor / input.search are typed
23
+ * return { rows, nextCursor, totalCount }
24
+ * },
25
+ * })
26
+ * ```
27
+ */
28
+
29
+ /**
30
+ * Shape every list function accepts as input.
31
+ *
32
+ * @template F - User-defined filter shape. Each key is a filterable field.
33
+ * @template S - String union of sortable column names.
34
+ */
35
+ export interface ListInput<
36
+ F extends Record<string, unknown> = Record<string, never>,
37
+ S extends string = never,
38
+ > {
39
+ /** Opaque cursor from the previous page's `nextCursor`. */
40
+ cursor?: string
41
+ /** Page size. Server may cap. */
42
+ limit?: number
43
+ /** Ordered sort criteria — first entry is primary. */
44
+ sort?: Array<{ column: S; direction: 'asc' | 'desc' }>
45
+ /** Structured filter tree. See {@link Filter}. */
46
+ filter?: Filter<F>
47
+ /** Unstructured text search across server-configured fields. */
48
+ search?: string
49
+ }
50
+
51
+ /**
52
+ * Shape every list function returns.
53
+ */
54
+ export interface ListOutput<Row> {
55
+ rows: Row[]
56
+ /** Null when no more pages. */
57
+ nextCursor: string | null
58
+ /** Optional — backend may skip when expensive. */
59
+ totalCount?: number
60
+ }
61
+
62
+ /**
63
+ * Leaf predicate value on a single field.
64
+ *
65
+ * Operator keywords (`equals`, `not`, `in`, `notIn`, `gt`, `gte`, `lt`,
66
+ * `lte`, `contains`, `startsWith`, `endsWith`, `mode`) mirror Prisma's
67
+ * vocabulary for dev familiarity. These keys are reserved and cannot be
68
+ * used as user field names in a Filter's `F` type.
69
+ */
70
+ export type LeafValue<T> =
71
+ | T
72
+ | null
73
+ | T[]
74
+ | {
75
+ equals?: T | null
76
+ not?: T | null | LeafValue<T>
77
+ in?: T[]
78
+ notIn?: T[]
79
+ gt?: T
80
+ gte?: T
81
+ lt?: T
82
+ lte?: T
83
+ contains?: string
84
+ startsWith?: string
85
+ endsWith?: string
86
+ mode?: 'sensitive' | 'insensitive'
87
+ }
88
+
89
+ /**
90
+ * A single-key object keyed by a field name from F, value is a leaf
91
+ * predicate. Single-key so the runtime discriminator is unambiguous:
92
+ * multi-key objects are OR groups.
93
+ */
94
+ export type LeafFilter<F extends Record<string, unknown>> = {
95
+ [K in keyof F]: { [Key in K]: LeafValue<F[K]> }
96
+ }[keyof F]
97
+
98
+ /**
99
+ * Recursive filter tree.
100
+ *
101
+ * - `Array<Filter<F>>` — AND of children.
102
+ * - `{ [label: string]: Filter<F> }` — OR of children. Keys are arbitrary
103
+ * labels (unique strings), ignored at evaluation time.
104
+ * - `LeafFilter<F>` — single-key predicate on a declared field.
105
+ *
106
+ * @example "status = pending AND (therapist = kim OR therapist = park) AND uploaded_at > 2026-04-10"
107
+ * ```ts
108
+ * const filter: Filter<{ status: string; therapistId: string; uploadedAt: string }> = [
109
+ * { status: 'pending' },
110
+ * {
111
+ * kim: { therapistId: 'kim' },
112
+ * park: { therapistId: 'park' },
113
+ * },
114
+ * { uploadedAt: { gt: '2026-04-10' } },
115
+ * ]
116
+ * ```
117
+ */
118
+ export type Filter<F extends Record<string, unknown>> =
119
+ | LeafFilter<F>
120
+ | Filter<F>[]
121
+ | { [label: string]: Filter<F> }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export type {
22
22
  JSONValue,
23
23
  MakeRequired,
24
24
  MiddlewareMetadata,
25
+ MiddlewarePriority,
25
26
  PermissionMetadata,
26
27
  PickOptional,
27
28
  PickRequired,
@@ -58,6 +59,13 @@ export {
58
59
  pikkuApprovalDescription,
59
60
  } from './function/functions.types.js'
60
61
  export { addFunction, getAllFunctionNames } from './function/index.js'
62
+ export type {
63
+ ListInput,
64
+ ListOutput,
65
+ Filter,
66
+ LeafFilter,
67
+ LeafValue,
68
+ } from './function/list.types.js'
61
69
  export { PikkuRequest } from './pikku-request.js'
62
70
  export {
63
71
  getRelativeTimeOffsetFromNow,
@@ -3,5 +3,6 @@ export { authCookie } from './auth-cookie.js'
3
3
  export { authBearer } from './auth-bearer.js'
4
4
  export { pikkuRemoteAuthMiddleware } from './remote-auth.js'
5
5
  export { cors } from './cors.js'
6
+ export { telemetryOuter, telemetryInner } from './telemetry.js'
6
7
  export { addMiddleware, runMiddleware } from '../middleware-runner.js'
7
8
  export { addPermission } from '../permissions.js'
@@ -0,0 +1,110 @@
1
+ import { pikkuMiddleware, pikkuMiddlewareFactory } from '../types/core.types.js'
2
+
3
+ /**
4
+ * Outer telemetry middleware that captures total request duration and outcome.
5
+ * Runs at `highest` priority (outermost in the middleware chain).
6
+ *
7
+ * Emits a structured JSON log entry with `__pikku_telemetry: 'end'` containing:
8
+ * - Total duration (including all middleware)
9
+ * - Outcome (ok/error)
10
+ * - Wire metadata (wireType, wireId, traceId)
11
+ * - HTTP details (method, path, status) if applicable
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { telemetryOuter } from '@pikku/core/middleware'
16
+ * addMiddleware('myTag', [telemetryOuter()])
17
+ * ```
18
+ */
19
+ export const telemetryOuter = pikkuMiddlewareFactory<{
20
+ environmentId?: string
21
+ orgId?: string
22
+ } | void>(({ environmentId, orgId } = {}) => {
23
+ return pikkuMiddleware({
24
+ name: 'telemetry-outer',
25
+ priority: 'highest',
26
+ func: async (services, wire, next) => {
27
+ const start = performance.now()
28
+ let outcome = 'ok'
29
+ let errorMessage: string | undefined
30
+ try {
31
+ await next()
32
+ } catch (e) {
33
+ outcome = 'error'
34
+ errorMessage = e instanceof Error ? e.message : String(e)
35
+ throw e
36
+ } finally {
37
+ services.logger.info({
38
+ __pikku_telemetry: 'end',
39
+ __pikku_layer: 'outer',
40
+ traceId: wire.traceId,
41
+ wireType: wire.wireType,
42
+ wireId: wire.wireId,
43
+ totalDuration: Math.round(performance.now() - start),
44
+ outcome,
45
+ ...(errorMessage ? { errorMessage } : {}),
46
+ ...(wire.http
47
+ ? {
48
+ httpStatus: wire.http.response?.statusCode,
49
+ httpMethod: wire.http.request?.method(),
50
+ httpPath: wire.http.request?.path(),
51
+ }
52
+ : {}),
53
+ ...(environmentId ? { environmentId } : {}),
54
+ ...(orgId ? { orgId } : {}),
55
+ })
56
+ }
57
+ },
58
+ })
59
+ })
60
+
61
+ /**
62
+ * Inner telemetry middleware that captures function execution duration and user context.
63
+ * Runs at `lowest` priority (innermost, closest to the function).
64
+ *
65
+ * Emits a structured JSON log entry with `__pikku_telemetry: 'end'` containing:
66
+ * - Function-only duration (excluding outer middleware like auth)
67
+ * - User identity (pikkuUserId) if authenticated
68
+ * - Wire metadata (wireType, wireId, traceId)
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * import { telemetryInner } from '@pikku/core/middleware'
73
+ * addMiddleware('myTag', [telemetryInner()])
74
+ * ```
75
+ */
76
+ export const telemetryInner = pikkuMiddlewareFactory<{
77
+ environmentId?: string
78
+ orgId?: string
79
+ } | void>(({ environmentId, orgId } = {}) => {
80
+ return pikkuMiddleware({
81
+ name: 'telemetry-inner',
82
+ priority: 'lowest',
83
+ func: async (services, wire, next) => {
84
+ const start = performance.now()
85
+ let outcome = 'ok'
86
+ let errorMessage: string | undefined
87
+ try {
88
+ await next()
89
+ } catch (e) {
90
+ outcome = 'error'
91
+ errorMessage = e instanceof Error ? e.message : String(e)
92
+ throw e
93
+ } finally {
94
+ services.logger.info({
95
+ __pikku_telemetry: 'end',
96
+ __pikku_layer: 'inner',
97
+ traceId: wire.traceId,
98
+ wireType: wire.wireType,
99
+ wireId: wire.wireId,
100
+ functionDuration: Math.round(performance.now() - start),
101
+ outcome,
102
+ pikkuUserId: wire.pikkuUserId,
103
+ ...(errorMessage ? { errorMessage } : {}),
104
+ ...(environmentId ? { environmentId } : {}),
105
+ ...(orgId ? { orgId } : {}),
106
+ })
107
+ }
108
+ },
109
+ })
110
+ })
@@ -6,7 +6,27 @@ import {
6
6
  runMiddleware,
7
7
  } from './middleware-runner.js'
8
8
  import { resetPikkuState } from './pikku-state.js'
9
- import type { CorePikkuMiddleware } from './types/core.types.js'
9
+ import type {
10
+ CorePikkuMiddleware,
11
+ MiddlewarePriority,
12
+ } from './types/core.types.js'
13
+ import { pikkuMiddleware } from './types/core.types.js'
14
+
15
+ const withPriority = (
16
+ name: string,
17
+ priority: MiddlewarePriority,
18
+ log: string[]
19
+ ): CorePikkuMiddleware => {
20
+ return pikkuMiddleware({
21
+ name,
22
+ priority,
23
+ func: async (_services, _wire, next) => {
24
+ log.push(`start:${name}`)
25
+ await next()
26
+ log.push(`end:${name}`)
27
+ },
28
+ })
29
+ }
10
30
 
11
31
  beforeEach(() => {
12
32
  resetPikkuState()
@@ -329,6 +349,56 @@ describe('combineMiddleware', () => {
329
349
  assert.equal(result.length, 1)
330
350
  assert.equal(result[0], middleware)
331
351
  })
352
+
353
+ test('should sort middleware by priority', () => {
354
+ const log: string[] = []
355
+ const low = withPriority('low', 'low', log)
356
+ const highest = withPriority('highest', 'highest', log)
357
+ const medium = withPriority('medium', 'medium', log)
358
+
359
+ const result = combineMiddleware('http', Math.random().toString(), {
360
+ wireMiddleware: [low, highest, medium],
361
+ })
362
+
363
+ assert.equal(result.length, 3)
364
+ assert.equal(result[0], highest)
365
+ assert.equal(result[1], medium)
366
+ assert.equal(result[2], low)
367
+ })
368
+
369
+ test('should default unprioritized middleware to medium', () => {
370
+ const log: string[] = []
371
+ const highest = withPriority('highest', 'highest', log)
372
+ const lowest = withPriority('lowest', 'lowest', log)
373
+ const noPriority: CorePikkuMiddleware = async (_services, _wire, next) => {
374
+ await next()
375
+ }
376
+
377
+ const result = combineMiddleware('http', Math.random().toString(), {
378
+ wireMiddleware: [lowest, noPriority, highest],
379
+ })
380
+
381
+ assert.equal(result.length, 3)
382
+ assert.equal(result[0], highest)
383
+ assert.equal(result[1], noPriority) // defaults to medium
384
+ assert.equal(result[2], lowest)
385
+ })
386
+
387
+ test('should preserve registration order within same priority', () => {
388
+ const log: string[] = []
389
+ const first = withPriority('first', 'medium', log)
390
+ const second = withPriority('second', 'medium', log)
391
+ const third = withPriority('third', 'medium', log)
392
+
393
+ const result = combineMiddleware('http', Math.random().toString(), {
394
+ wireMiddleware: [first, second, third],
395
+ })
396
+
397
+ assert.equal(result.length, 3)
398
+ assert.equal(result[0], first)
399
+ assert.equal(result[1], second)
400
+ assert.equal(result[2], third)
401
+ })
332
402
  })
333
403
 
334
404
  describe('runMiddleware', () => {
@@ -415,4 +485,82 @@ describe('runMiddleware', () => {
415
485
 
416
486
  assert.deepEqual(executionOrder, ['middleware'])
417
487
  })
488
+
489
+ test('should execute middleware in priority order (highest first, lowest last)', async () => {
490
+ const log: string[] = []
491
+ const low = withPriority('low', 'low', log)
492
+ const highest = withPriority('highest', 'highest', log)
493
+ const medium = withPriority('medium', 'medium', log)
494
+
495
+ await runMiddleware(
496
+ {} as any,
497
+ {} as any,
498
+ [low, highest, medium],
499
+ async () => {
500
+ log.push('main')
501
+ }
502
+ )
503
+
504
+ assert.deepEqual(log, [
505
+ 'start:highest',
506
+ 'start:medium',
507
+ 'start:low',
508
+ 'main',
509
+ 'end:low',
510
+ 'end:medium',
511
+ 'end:highest',
512
+ ])
513
+ })
514
+
515
+ test('should sort unsorted middleware passed directly to runMiddleware', async () => {
516
+ const log: string[] = []
517
+ const lowest = withPriority('lowest', 'lowest', log)
518
+ const highest = withPriority('highest', 'highest', log)
519
+
520
+ await runMiddleware({} as any, {} as any, [lowest, highest], async () => {
521
+ log.push('main')
522
+ })
523
+
524
+ // highest should run first (outermost), lowest last (innermost)
525
+ assert.deepEqual(log, [
526
+ 'start:highest',
527
+ 'start:lowest',
528
+ 'main',
529
+ 'end:lowest',
530
+ 'end:highest',
531
+ ])
532
+ })
533
+
534
+ test('should handle all five priority levels in correct order', async () => {
535
+ const log: string[] = []
536
+ const lowest = withPriority('lowest', 'lowest', log)
537
+ const low = withPriority('low', 'low', log)
538
+ const medium = withPriority('medium', 'medium', log)
539
+ const high = withPriority('high', 'high', log)
540
+ const highest = withPriority('highest', 'highest', log)
541
+
542
+ // Pass in reverse order to verify sorting
543
+ await runMiddleware(
544
+ {} as any,
545
+ {} as any,
546
+ [lowest, low, medium, high, highest],
547
+ async () => {
548
+ log.push('main')
549
+ }
550
+ )
551
+
552
+ assert.deepEqual(log, [
553
+ 'start:highest',
554
+ 'start:high',
555
+ 'start:medium',
556
+ 'start:low',
557
+ 'start:lowest',
558
+ 'main',
559
+ 'end:lowest',
560
+ 'end:low',
561
+ 'end:medium',
562
+ 'end:high',
563
+ 'end:highest',
564
+ ])
565
+ })
418
566
  })
@@ -5,10 +5,34 @@ import type {
5
5
  CorePikkuMiddlewareGroup,
6
6
  PikkuWiringTypes,
7
7
  MiddlewareMetadata,
8
+ MiddlewarePriority,
8
9
  } from './types/core.types.js'
9
10
  import { pikkuState } from './pikku-state.js'
10
11
  import { freezeDedupe, getTagGroups } from './utils.js'
11
12
 
13
+ const PRIORITY_ORDER: Record<MiddlewarePriority, number> = {
14
+ highest: 0,
15
+ high: 1,
16
+ medium: 2,
17
+ low: 3,
18
+ lowest: 4,
19
+ }
20
+
21
+ const getMiddlewarePriority = (fn: CorePikkuMiddleware): number => {
22
+ const priority = (
23
+ fn as CorePikkuMiddleware & { __priority?: MiddlewarePriority }
24
+ ).__priority
25
+ return PRIORITY_ORDER[priority ?? 'medium']
26
+ }
27
+
28
+ const sortByPriority = (
29
+ middlewares: CorePikkuMiddleware[]
30
+ ): CorePikkuMiddleware[] => {
31
+ return middlewares.sort(
32
+ (a, b) => getMiddlewarePriority(a) - getMiddlewarePriority(b)
33
+ )
34
+ }
35
+
12
36
  /**
13
37
  * Runs a chain of middleware functions in sequence before executing the main function.
14
38
  *
@@ -32,11 +56,16 @@ export const runMiddleware = async <Middleware extends CorePikkuMiddleware>(
32
56
  middlewares: readonly Middleware[],
33
57
  main?: () => Promise<unknown>
34
58
  ): Promise<unknown> => {
35
- // Deduplicate middleware using Set to avoid running the same middleware multiple times
59
+ // Sort by priority if not already sorted (cached middleware is pre-sorted)
60
+ const sorted = isSortedByPriority(middlewares)
61
+ ? middlewares
62
+ : ([...middlewares].sort(
63
+ (a, b) => getMiddlewarePriority(a) - getMiddlewarePriority(b)
64
+ ) as readonly Middleware[])
36
65
  let result: any
37
66
  const dispatch = async (index: number): Promise<any> => {
38
- if (middlewares && index < middlewares.length) {
39
- return await middlewares[index]!(services as any, wire, () =>
67
+ if (sorted && index < sorted.length) {
68
+ return await sorted[index]!(services as any, wire, () =>
40
69
  dispatch(index + 1)
41
70
  )
42
71
  } else if (main) {
@@ -47,6 +76,20 @@ export const runMiddleware = async <Middleware extends CorePikkuMiddleware>(
47
76
  return result
48
77
  }
49
78
 
79
+ const isSortedByPriority = (
80
+ middlewares: readonly CorePikkuMiddleware[]
81
+ ): boolean => {
82
+ for (let i = 1; i < middlewares.length; i++) {
83
+ if (
84
+ getMiddlewarePriority(middlewares[i]) <
85
+ getMiddlewarePriority(middlewares[i - 1])
86
+ ) {
87
+ return false
88
+ }
89
+ }
90
+ return true
91
+ }
92
+
50
93
  /**
51
94
  * Registers global middleware for a specific tag.
52
95
  *
@@ -231,7 +274,8 @@ export const combineMiddleware = (
231
274
  resolved.push(...funcMiddleware)
232
275
  }
233
276
 
234
- // Deduplicate and freeze
277
+ // Sort by priority, deduplicate, and freeze
278
+ sortByPriority(resolved)
235
279
  middlewareCache[wireType][uid] = freezeDedupe(
236
280
  resolved
237
281
  ) as readonly CorePikkuMiddleware[]
@@ -305,6 +305,23 @@ export type CorePikkuMiddleware<
305
305
  next: () => Promise<void>
306
306
  ) => Promise<void>
307
307
 
308
+ /**
309
+ * Priority levels for middleware execution order.
310
+ * Lower priority runs first (outermost in the onion model).
311
+ *
312
+ * - `highest` — Runs first (outermost). Use for telemetry, request tracing.
313
+ * - `high` — Runs early. Use for CORS, rate limiting.
314
+ * - `medium` — Default. Use for auth, most user middleware.
315
+ * - `low` — Runs late. Use for post-auth processing.
316
+ * - `lowest` — Runs last (innermost, closest to function). Use for inner telemetry.
317
+ */
318
+ export type MiddlewarePriority =
319
+ | 'highest'
320
+ | 'high'
321
+ | 'medium'
322
+ | 'low'
323
+ | 'lowest'
324
+
308
325
  /**
309
326
  * Configuration object for creating middleware with metadata
310
327
  *
@@ -321,6 +338,8 @@ export type CorePikkuMiddlewareConfig<
321
338
  name?: string
322
339
  /** Optional description of what the middleware does */
323
340
  description?: string
341
+ /** Execution priority. Lower runs first (outermost). Defaults to 'medium'. */
342
+ priority?: MiddlewarePriority
324
343
  }
325
344
 
326
345
  /**
@@ -378,7 +397,15 @@ export const pikkuMiddleware = <
378
397
  | CorePikkuMiddleware<SingletonServices, UserSession>
379
398
  | CorePikkuMiddlewareConfig<SingletonServices, UserSession>
380
399
  ): CorePikkuMiddleware<SingletonServices, UserSession> => {
381
- return typeof middleware === 'function' ? middleware : middleware.func
400
+ if (typeof middleware === 'function') return middleware
401
+ const func = middleware.func as CorePikkuMiddleware<
402
+ SingletonServices,
403
+ UserSession
404
+ > & { __priority?: MiddlewarePriority }
405
+ if (middleware.priority) {
406
+ func.__priority = middleware.priority
407
+ }
408
+ return func
382
409
  }
383
410
 
384
411
  /**
@@ -70,7 +70,11 @@ export class PikkuMockRequest implements PikkuHTTPRequest {
70
70
  }
71
71
 
72
72
  export class PikkuMockResponse implements PikkuHTTPResponse {
73
- public _status: number | undefined
73
+ public _status: number = 200
74
+
75
+ public get statusCode(): number {
76
+ return this._status
77
+ }
74
78
 
75
79
  status(code: number): this {
76
80
  this._status = code