@pikku/core 0.12.56 → 0.12.57

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,154 @@
1
+ import { describe, test, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+
7
+ import { pikkuState, resetPikkuState } from '../pikku-state.js'
8
+ import { getSchema } from '../schema.js'
9
+ import { reloadGeneratedMeta } from './reload-meta.js'
10
+
11
+ const createMockLogger = () => {
12
+ const logs: Array<{ level: string; message: string }> = []
13
+ return {
14
+ info: (msg: string) => logs.push({ level: 'info', message: String(msg) }),
15
+ warn: (msg: string) => logs.push({ level: 'warn', message: String(msg) }),
16
+ error: (msg: string | Error) =>
17
+ logs.push({
18
+ level: 'error',
19
+ message: msg instanceof Error ? msg.message : String(msg),
20
+ }),
21
+ debug: (msg: string) => logs.push({ level: 'debug', message: String(msg) }),
22
+ getLogs: () => logs,
23
+ setLevel: () => {},
24
+ }
25
+ }
26
+
27
+ const compiled: string[] = []
28
+ const mockSchemaService = {
29
+ compileSchema: (name: string, _value: any) => {
30
+ compiled.push(name)
31
+ },
32
+ validateSchema: () => {},
33
+ getSchemaNames: () => new Set(compiled),
34
+ getSchemaKeys: () => [],
35
+ } as any
36
+
37
+ describe('reloadGeneratedMeta', { concurrency: false }, () => {
38
+ let tmpDir: string
39
+ let mockLogger: ReturnType<typeof createMockLogger>
40
+
41
+ beforeEach(async () => {
42
+ resetPikkuState()
43
+ compiled.length = 0
44
+ tmpDir = await mkdtemp(join(tmpdir(), 'pikku-reload-meta-test-'))
45
+ mockLogger = createMockLogger()
46
+ })
47
+
48
+ afterEach(async () => {
49
+ await rm(tmpDir, { recursive: true, force: true })
50
+ })
51
+
52
+ test('reads generated meta json into pikku state', async () => {
53
+ await mkdir(join(tmpDir, 'function'), { recursive: true })
54
+ await mkdir(join(tmpDir, 'rpc'), { recursive: true })
55
+ await writeFile(
56
+ join(tmpDir, 'function/pikku-functions-meta.gen.json'),
57
+ JSON.stringify({
58
+ newFunc: { pikkuFuncId: 'newFunc', inputSchemaName: 'NewFuncInput' },
59
+ })
60
+ )
61
+ await writeFile(
62
+ join(tmpDir, 'rpc/pikku-rpc-wirings-meta.internal.gen.json'),
63
+ JSON.stringify({ newFunc: 'newFunc' })
64
+ )
65
+
66
+ await reloadGeneratedMeta({
67
+ pikkuDir: tmpDir,
68
+ logger: mockLogger,
69
+ schemaService: mockSchemaService,
70
+ })
71
+
72
+ const functionsMeta = pikkuState(null, 'function', 'meta') as any
73
+ assert.equal(functionsMeta.newFunc.pikkuFuncId, 'newFunc')
74
+ const rpcMeta = pikkuState(null, 'rpc', 'meta') as any
75
+ assert.equal(rpcMeta.newFunc, 'newFunc')
76
+ })
77
+
78
+ test('preserves runtime-registered internals (workflow orchestrator) across reload', async () => {
79
+ // The workflow service registers these at init; they are never in the
80
+ // generated JSON, so the reload must merge — not replace — the meta maps.
81
+ pikkuState(null, 'function', 'meta', {
82
+ pikkuWorkflowOrchestrator: { pikkuFuncId: 'pikkuWorkflowOrchestrator' },
83
+ })
84
+ pikkuState(null, 'queue', 'meta', {
85
+ 'pikku-workflow-orchestrator': {
86
+ pikkuFuncId: 'pikkuWorkflowOrchestrator',
87
+ name: 'pikku-workflow-orchestrator',
88
+ },
89
+ })
90
+
91
+ await mkdir(join(tmpDir, 'function'), { recursive: true })
92
+ await mkdir(join(tmpDir, 'queue'), { recursive: true })
93
+ await writeFile(
94
+ join(tmpDir, 'function/pikku-functions-meta.gen.json'),
95
+ JSON.stringify({ userFunc: { pikkuFuncId: 'userFunc' } })
96
+ )
97
+ await writeFile(
98
+ join(tmpDir, 'queue/pikku-queue-workers-wirings-meta.gen.json'),
99
+ JSON.stringify({ userQueue: { pikkuFuncId: 'userFunc', name: 'userQueue' } })
100
+ )
101
+
102
+ await reloadGeneratedMeta({
103
+ pikkuDir: tmpDir,
104
+ logger: mockLogger,
105
+ schemaService: mockSchemaService,
106
+ })
107
+
108
+ const functionsMeta = pikkuState(null, 'function', 'meta') as any
109
+ assert.equal(
110
+ functionsMeta.pikkuWorkflowOrchestrator?.pikkuFuncId,
111
+ 'pikkuWorkflowOrchestrator',
112
+ 'runtime-registered internal must survive the reload'
113
+ )
114
+ assert.equal(functionsMeta.userFunc?.pikkuFuncId, 'userFunc')
115
+ const queueMeta = pikkuState(null, 'queue', 'meta') as any
116
+ assert.equal(
117
+ queueMeta['pikku-workflow-orchestrator']?.pikkuFuncId,
118
+ 'pikkuWorkflowOrchestrator'
119
+ )
120
+ assert.equal(queueMeta.userQueue?.name, 'userQueue')
121
+ })
122
+
123
+ test('re-adds generated json schemas and recompiles them', async () => {
124
+ const schemasDir = join(tmpDir, 'schemas', 'schemas')
125
+ await mkdir(schemasDir, { recursive: true })
126
+ await writeFile(
127
+ join(schemasDir, 'NewFuncInput.schema.json'),
128
+ JSON.stringify({ type: 'object', properties: { id: { type: 'string' } } })
129
+ )
130
+
131
+ await reloadGeneratedMeta({
132
+ pikkuDir: tmpDir,
133
+ logger: mockLogger,
134
+ schemaService: mockSchemaService,
135
+ })
136
+
137
+ const schema = getSchema('NewFuncInput') as any
138
+ assert.equal(schema.type, 'object')
139
+ assert.ok(
140
+ compiled.includes('NewFuncInput'),
141
+ 'Schema should be recompiled after reload'
142
+ )
143
+ })
144
+
145
+ test('missing meta files and schemas dir are not an error', async () => {
146
+ await reloadGeneratedMeta({
147
+ pikkuDir: tmpDir,
148
+ logger: mockLogger,
149
+ schemaService: mockSchemaService,
150
+ })
151
+ const errors = mockLogger.getLogs().filter((l) => l.level === 'error')
152
+ assert.deepEqual(errors, [])
153
+ })
154
+ })
@@ -0,0 +1,138 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import { pikkuState } from '../pikku-state.js'
5
+ import { addSchema, compileAllSchemas } from '../schema.js'
6
+ import { clearMiddlewareCache } from '../middleware-runner.js'
7
+ import { clearPermissionsCache } from '../permissions.js'
8
+ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js'
9
+ import { httpRouter } from '../wirings/http/routers/http-router.js'
10
+ import type { Logger } from '../services/logger.js'
11
+ import type { SchemaService } from '../services/schema-service.js'
12
+
13
+ export interface ReloadGeneratedMetaOptions {
14
+ /** The project's generated output directory (the CLI's resolved outDir). */
15
+ pikkuDir: string
16
+ logger: Logger
17
+ /** Used to recompile validators for changed schemas; falls back to the
18
+ * schema service on the registered singleton services. */
19
+ schemaService?: SchemaService
20
+ }
21
+
22
+ const readJson = async (
23
+ logger: Logger,
24
+ file: string
25
+ ): Promise<any | undefined> => {
26
+ let raw: string
27
+ try {
28
+ raw = await readFile(file, 'utf-8')
29
+ } catch {
30
+ // The project doesn't use this wiring type — nothing generated.
31
+ return undefined
32
+ }
33
+ try {
34
+ return JSON.parse(raw)
35
+ } catch (err) {
36
+ logger.error(
37
+ `Hot-reload could not parse ${file}: ${err instanceof Error ? err.message : String(err)}`
38
+ )
39
+ return undefined
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Re-reads the codegen output (wiring meta + JSON schemas) into the running
45
+ * process so new and changed functions become callable without a server
46
+ * restart. The generated `*-meta.gen.ts` files are plain
47
+ * `pikkuState(area, key, <json>)` side effects, but they cannot be
48
+ * re-imported after a dev-time codegen — the ESM cache pins both the wrapper
49
+ * and its JSON import — so this reads the JSON sources directly and applies
50
+ * the same state.
51
+ *
52
+ * Meant to be called by a dev-server watcher after each codegen pass. Routes
53
+ * registered by NEW `wireHTTP` files are not picked up (their modules were
54
+ * never imported); those still need a restart.
55
+ */
56
+ export async function reloadGeneratedMeta(
57
+ options: ReloadGeneratedMetaOptions
58
+ ): Promise<void> {
59
+ const { pikkuDir, logger, schemaService } = options
60
+ const dir = resolve(pikkuDir)
61
+
62
+ const functionsMeta = await readJson(
63
+ logger,
64
+ join(dir, 'function/pikku-functions-meta.gen.json')
65
+ )
66
+ // Merge over the existing map, don't replace it: framework internals like
67
+ // pikkuWorkflowOrchestrator / the per-workflow queue workers are registered
68
+ // at service-init (pikku-workflow-service.ts), never in the generated JSON —
69
+ // a wholesale replace drops them and workflow jobs then fail with
70
+ // "Function meta not found: pikkuWorkflowOrchestrator".
71
+ if (functionsMeta) {
72
+ const existing = pikkuState(null, 'function', 'meta') ?? {}
73
+ pikkuState(null, 'function', 'meta', { ...existing, ...functionsMeta })
74
+ }
75
+
76
+ const httpMeta = await readJson(
77
+ logger,
78
+ join(dir, 'http/pikku-http-wirings-meta.gen.json')
79
+ )
80
+ if (httpMeta) pikkuState(null, 'http', 'meta', httpMeta)
81
+
82
+ const rpcMeta = await readJson(
83
+ logger,
84
+ join(dir, 'rpc/pikku-rpc-wirings-meta.internal.gen.json')
85
+ )
86
+ if (rpcMeta) pikkuState(null, 'rpc', 'meta', rpcMeta)
87
+
88
+ const queueMeta = await readJson(
89
+ logger,
90
+ join(dir, 'queue/pikku-queue-workers-wirings-meta.gen.json')
91
+ )
92
+ // Same reason as function meta: the workflow service adds its orchestrator /
93
+ // step queues (and wf-orchestrator-* / wf-step-* per-workflow queues) here at
94
+ // init, absent from the generated JSON — merge so they survive the reload.
95
+ if (queueMeta) {
96
+ const existing = pikkuState(null, 'queue', 'meta') ?? {}
97
+ pikkuState(null, 'queue', 'meta', { ...existing, ...queueMeta })
98
+ }
99
+
100
+ const agentMeta = await readJson(
101
+ logger,
102
+ join(dir, 'agent/pikku-agent-wirings-meta.gen.json')
103
+ )
104
+ if (agentMeta?.agentsMeta) {
105
+ pikkuState(null, 'agent', 'agentsMeta', agentMeta.agentsMeta)
106
+ }
107
+
108
+ // Generated JSON schemas: <outDir>/schemas/schemas/<Name>.schema.json.
109
+ // Re-adding replaces the map entry; the schema service recompiles any
110
+ // validator whose stored schema value no longer matches.
111
+ const schemasDir = join(dir, 'schemas', 'schemas')
112
+ let schemaFiles: string[] = []
113
+ try {
114
+ schemaFiles = await readdir(schemasDir)
115
+ } catch {
116
+ // No generated schemas — a schema-less project.
117
+ }
118
+ for (const file of schemaFiles) {
119
+ if (!file.endsWith('.schema.json')) continue
120
+ const schema = await readJson(logger, join(schemasDir, file))
121
+ if (schema) {
122
+ addSchema(file.slice(0, -'.schema.json'.length), schema)
123
+ }
124
+ }
125
+
126
+ clearMiddlewareCache()
127
+ clearPermissionsCache()
128
+ clearChannelMiddlewareCache()
129
+ httpRouter.reset()
130
+
131
+ try {
132
+ compileAllSchemas(logger, schemaService)
133
+ } catch (err) {
134
+ logger.error(
135
+ `Hot-reload schema recompilation failed: ${err instanceof Error ? err.message : String(err)}`
136
+ )
137
+ }
138
+ }