@pikku/core 0.12.55 → 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.
- package/CHANGELOG.md +20 -0
- package/dist/dev/hot-reload.d.ts +7 -3
- package/dist/dev/hot-reload.js +90 -47
- package/dist/dev/reload-meta.d.ts +24 -0
- package/dist/dev/reload-meta.js +99 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +6 -6
- package/package.json +1 -1
- package/src/dev/hot-reload.test.ts +15 -8
- package/src/dev/hot-reload.ts +99 -54
- package/src/dev/reload-meta.test.ts +154 -0
- package/src/dev/reload-meta.ts +138 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +6 -6
- package/src/wirings/workflow/workflow-retry-policy.test.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/dev/hot-reload.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { watch, type FSWatcher } from 'node:fs'
|
|
2
|
-
import { stat, readFile } from 'node:fs/promises'
|
|
3
|
-
import { join, resolve, relative } from 'node:path'
|
|
2
|
+
import { stat, readFile, copyFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, join, resolve, relative } from 'node:path'
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
5
5
|
|
|
6
6
|
import { register } from 'tsx/esm/api'
|
|
7
7
|
|
|
8
8
|
import { pikkuState } from '../pikku-state.js'
|
|
9
|
-
import { addFunction } from '../function/function-runner.js'
|
|
10
9
|
import { clearMiddlewareCache } from '../middleware-runner.js'
|
|
11
10
|
import { clearPermissionsCache } from '../permissions.js'
|
|
12
11
|
import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js'
|
|
13
12
|
import { httpRouter } from '../wirings/http/routers/http-router.js'
|
|
14
|
-
import { addSchema, compileAllSchemas } from '../schema.js'
|
|
15
13
|
import type { Logger } from '../services/logger.js'
|
|
16
14
|
import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
|
|
17
15
|
|
|
16
|
+
export * from './reload-meta.js'
|
|
17
|
+
|
|
18
18
|
interface PikkuDevReloaderOptions {
|
|
19
19
|
srcDirectories: string[]
|
|
20
20
|
logger: Logger
|
|
@@ -65,16 +65,37 @@ const ensureTsxRegistered = () => {
|
|
|
65
65
|
tsxRegistered = true
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
let tempCounter = 0
|
|
69
|
+
|
|
68
70
|
const reimportModule = async (
|
|
69
71
|
filePath: string,
|
|
70
72
|
useTsx = false
|
|
71
73
|
): Promise<Record<string, unknown> | null> => {
|
|
72
74
|
try {
|
|
73
75
|
if (useTsx) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
// Import a uniquely-named sibling copy: a `?t=` query does NOT bust
|
|
77
|
+
// the cache on either runtime (Bun keys module identity on the bare
|
|
78
|
+
// path; tsx's transform cache keys on the file path too), so a fresh
|
|
79
|
+
// path is the only reliable re-import. Same directory → identical
|
|
80
|
+
// resolution for relative and package-`imports` (#…) specifiers. The
|
|
81
|
+
// dot-prefix keeps it out of the watcher.
|
|
82
|
+
if (!process.versions.bun) {
|
|
83
|
+
// Node needs tsx's loader to import raw .ts; Bun imports it natively.
|
|
84
|
+
ensureTsxRegistered()
|
|
85
|
+
}
|
|
86
|
+
const abs = resolve(filePath)
|
|
87
|
+
const tempPath = join(
|
|
88
|
+
dirname(abs),
|
|
89
|
+
`.pikku-hot-${++tempCounter}-${basename(abs)}`
|
|
77
90
|
)
|
|
91
|
+
await copyFile(abs, tempPath)
|
|
92
|
+
try {
|
|
93
|
+
return await import(pathToFileURL(tempPath).href)
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(tempPath, { force: true }).catch(() => {
|
|
96
|
+
// Best-effort temp cleanup; a leftover dotfile is watcher-ignored.
|
|
97
|
+
})
|
|
98
|
+
}
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
const content = await readFile(resolve(filePath), 'utf-8')
|
|
@@ -91,13 +112,23 @@ const isWatchedTsFile = (filename: string): boolean => {
|
|
|
91
112
|
filename.endsWith('.ts') &&
|
|
92
113
|
!filename.endsWith('.test.ts') &&
|
|
93
114
|
!filename.endsWith('.d.ts') &&
|
|
94
|
-
!filename.endsWith('.gen.ts')
|
|
115
|
+
!filename.endsWith('.gen.ts') &&
|
|
116
|
+
// Hidden files: editor/sed atomic-write temps and our own hot-reload
|
|
117
|
+
// sibling copies must never trigger a reload of themselves.
|
|
118
|
+
!basename(filename).startsWith('.')
|
|
95
119
|
)
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
export interface PikkuDevReloaderHandle {
|
|
123
|
+
close: () => void
|
|
124
|
+
/** Re-import every file changed since the last drain (post-codegen, once
|
|
125
|
+
* fresh meta is in state). */
|
|
126
|
+
reimportPending: () => Promise<void>
|
|
127
|
+
}
|
|
128
|
+
|
|
98
129
|
export async function pikkuDevReloader(
|
|
99
130
|
options: PikkuDevReloaderOptions
|
|
100
|
-
): Promise<
|
|
131
|
+
): Promise<PikkuDevReloaderHandle> {
|
|
101
132
|
const { srcDirectories, logger, pikkuDir = '.pikku' } = options
|
|
102
133
|
const absSrcDirs = srcDirectories.map((d) => resolve(d))
|
|
103
134
|
const absPikkuDir = resolve(pikkuDir)
|
|
@@ -108,6 +139,7 @@ export async function pikkuDevReloader(
|
|
|
108
139
|
const handleFileChange = async (changedTsFile: string) => {
|
|
109
140
|
const start = Date.now()
|
|
110
141
|
const reloadedNames: string[] = []
|
|
142
|
+
const addedNames: string[] = []
|
|
111
143
|
|
|
112
144
|
const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d))
|
|
113
145
|
if (!srcDir) return
|
|
@@ -134,54 +166,59 @@ export async function pikkuDevReloader(
|
|
|
134
166
|
return
|
|
135
167
|
}
|
|
136
168
|
|
|
137
|
-
|
|
138
|
-
|
|
169
|
+
// Register every function-config export — replacing known functions AND
|
|
170
|
+
// adding new ones (a brand-new function becomes callable as soon as its
|
|
171
|
+
// meta lands via reloadGeneratedMeta after the next codegen pass).
|
|
172
|
+
// Write into the map captured at startup, NOT pikkuState's current one:
|
|
173
|
+
// a dev-server watcher may have temporarily swapped in a codegen-scoped
|
|
174
|
+
// map for the same file event (runAllWithCommandState), and a write to
|
|
175
|
+
// that map is silently discarded when it restores the original.
|
|
176
|
+
// Schemas are NOT touched here: `input`/`output` hold raw zod schemas,
|
|
177
|
+
// while the schema map carries codegen-generated JSON schemas — mixing the
|
|
178
|
+
// two crashed every reload. Fresh JSON schemas arrive via
|
|
179
|
+
// reloadGeneratedMeta once codegen has re-emitted them.
|
|
139
180
|
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
140
|
-
if (isFunctionConfig(exportValue)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
addSchema(exportName, exportValue.input)
|
|
146
|
-
schemasChanged = true
|
|
147
|
-
}
|
|
148
|
-
if (exportValue.output) {
|
|
149
|
-
addSchema(`${exportName}Output`, exportValue.output)
|
|
150
|
-
schemasChanged = true
|
|
151
|
-
}
|
|
152
|
-
}
|
|
181
|
+
if (!isFunctionConfig(exportValue)) continue
|
|
182
|
+
const isNew = !functionsMap.has(exportName)
|
|
183
|
+
functionsMap.set(exportName, exportValue)
|
|
184
|
+
if (isNew) addedNames.push(exportName)
|
|
185
|
+
else reloadedNames.push(exportName)
|
|
153
186
|
}
|
|
154
187
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
compileAllSchemas(logger)
|
|
164
|
-
} catch (err) {
|
|
165
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
166
|
-
if (
|
|
167
|
-
msg.includes('SchemaService') ||
|
|
168
|
-
(msg.includes('schema') && msg.includes('not'))
|
|
169
|
-
) {
|
|
170
|
-
logger.warn('Schema recompilation skipped (no SchemaService)')
|
|
171
|
-
} else {
|
|
172
|
-
logger.error(`Schema recompilation failed: ${msg}`)
|
|
173
|
-
return
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
188
|
+
// Re-importing the module re-ran its wire* side effects (wireHTTP et al
|
|
189
|
+
// are keyed map-sets, so re-registration replaces/adds) — reset the
|
|
190
|
+
// router and caches even when no function export changed, so a
|
|
191
|
+
// wiring-only file edit rebuilds the route matchers too.
|
|
192
|
+
clearMiddlewareCache()
|
|
193
|
+
clearPermissionsCache()
|
|
194
|
+
clearChannelMiddlewareCache()
|
|
195
|
+
httpRouter.reset()
|
|
177
196
|
|
|
197
|
+
if (reloadedNames.length > 0 || addedNames.length > 0) {
|
|
178
198
|
const elapsed = Date.now() - start
|
|
179
|
-
|
|
199
|
+
const parts: string[] = []
|
|
200
|
+
if (reloadedNames.length > 0) parts.push(reloadedNames.join(', '))
|
|
201
|
+
if (addedNames.length > 0) parts.push(`new: ${addedNames.join(', ')}`)
|
|
202
|
+
logger.info(`Hot-reloaded: ${parts.join('; ')} (${elapsed}ms)`)
|
|
180
203
|
}
|
|
181
204
|
}
|
|
182
205
|
|
|
183
206
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
184
207
|
const pendingChanges = new Set<string>()
|
|
208
|
+
// Files re-imported since the last reimportPending() drain. A dev-server
|
|
209
|
+
// watcher drains this after its codegen pass so wire* registrations that
|
|
210
|
+
// were skipped for missing meta (a NEW route) run again with fresh meta.
|
|
211
|
+
const postCodegenQueue = new Set<string>()
|
|
212
|
+
|
|
213
|
+
const safeHandleFileChange = async (file: string) => {
|
|
214
|
+
try {
|
|
215
|
+
await handleFileChange(file)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
logger.error(
|
|
218
|
+
`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
185
222
|
|
|
186
223
|
const scheduleReload = (filePath: string) => {
|
|
187
224
|
pendingChanges.add(filePath)
|
|
@@ -190,13 +227,8 @@ export async function pikkuDevReloader(
|
|
|
190
227
|
const files = [...pendingChanges]
|
|
191
228
|
pendingChanges.clear()
|
|
192
229
|
for (const file of files) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
} catch (err) {
|
|
196
|
-
logger.error(
|
|
197
|
-
`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`
|
|
198
|
-
)
|
|
199
|
-
}
|
|
230
|
+
postCodegenQueue.add(file)
|
|
231
|
+
await safeHandleFileChange(file)
|
|
200
232
|
}
|
|
201
233
|
}, 50)
|
|
202
234
|
}
|
|
@@ -229,5 +261,18 @@ export async function pikkuDevReloader(
|
|
|
229
261
|
watcher.close()
|
|
230
262
|
}
|
|
231
263
|
},
|
|
264
|
+
// Drain the queue of recently re-imported files and import them again.
|
|
265
|
+
// A dev-server watcher calls this AFTER its codegen pass has refreshed
|
|
266
|
+
// the generated meta (reloadGeneratedMeta): wire* registrations skip
|
|
267
|
+
// routes whose meta doesn't exist yet, so a wiring file changed
|
|
268
|
+
// alongside a NEW function only registers its new route when
|
|
269
|
+
// re-imported after the fresh meta has landed.
|
|
270
|
+
reimportPending: async () => {
|
|
271
|
+
const files = [...postCodegenQueue]
|
|
272
|
+
postCodegenQueue.clear()
|
|
273
|
+
for (const file of files) {
|
|
274
|
+
await safeHandleFileChange(file)
|
|
275
|
+
}
|
|
276
|
+
},
|
|
232
277
|
}
|
|
233
278
|
}
|
|
@@ -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
|
+
}
|
|
@@ -921,14 +921,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
921
921
|
): JobOptions {
|
|
922
922
|
const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES
|
|
923
923
|
const retryDelay = stepOptions?.retryDelay
|
|
924
|
+
// A concrete retryDelay (15000, '15s') is a fixed backoff; only the literal
|
|
925
|
+
// 'exponential' — or no delay at all — selects exponential.
|
|
924
926
|
const backoff =
|
|
925
|
-
|
|
926
|
-
? { type: 'fixed', delay: retryDelay }
|
|
927
|
-
: retryDelay === 'exponential'
|
|
927
|
+
retryDelay !== undefined && retryDelay !== 'exponential'
|
|
928
|
+
? { type: 'fixed', delay: getDurationInMilliseconds(retryDelay) }
|
|
929
|
+
: retries > 0 || retryDelay === 'exponential'
|
|
928
930
|
? 'exponential'
|
|
929
|
-
:
|
|
930
|
-
? 'exponential'
|
|
931
|
-
: undefined
|
|
931
|
+
: undefined
|
|
932
932
|
return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
|
|
933
933
|
}
|
|
934
934
|
|
|
@@ -48,6 +48,13 @@ describe('resolveStepJobOptions — workflow owns retry policy', () => {
|
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
+
test("duration-string retryDelay ('15s') → fixed backoff in ms", () => {
|
|
52
|
+
assert.deepEqual(ws.resolve({ retries: 3, retryDelay: '15s' }), {
|
|
53
|
+
attempts: 4,
|
|
54
|
+
backoff: { type: 'fixed', delay: 15000 },
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
51
58
|
test("retryDelay: 'exponential' → exponential backoff", () => {
|
|
52
59
|
assert.deepEqual(ws.resolve({ retries: 2, retryDelay: 'exponential' }), {
|
|
53
60
|
attempts: 3,
|