@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 CHANGED
@@ -1,3 +1,23 @@
1
+ ## 0.12.57
2
+
3
+ ### Patch Changes
4
+
5
+ - 60ad8cb: fix dev-server hot reload so edited AND new functions/routes apply without a restart
6
+ - `@pikku/core`: the hot reloader fed raw zod `input`/`output` schemas into the JSON-schema map, so `compileAllSchemas` threw `Failed to compile schema` on every reload and the reload aborted (only the function body sometimes swapped, half-updated). It now registers function implementations only and leaves schemas to the codegen JSON output. New function exports are registered too (previously only already-registered names were replaced). Reloads write into the startup functions map directly to avoid a race with the dev watcher's codegen-scoped state swap, and re-import via a uniquely-named sibling copy since neither Bun nor tsx bust the module cache on a `?t=` query.
7
+ - New `reloadGeneratedMeta` (exported from `@pikku/core/dev`) re-reads the regenerated wiring meta + JSON schemas into the running process so new/changed routes, RPCs, queues and agents resolve without a restart.
8
+ - `@pikku/cli`: `pikku dev` now calls `reloadGeneratedMeta` after each watch-triggered codegen pass and re-imports the changed files once fresh meta is in state, so a NEW route in a changed wiring file registers (its `wireHTTP` no longer no-ops on missing meta).
9
+ - `@pikku/schema-cfworker`: `compileSchema` recompiles when a schema's value changes (not only on first sight), so hot-reloaded schemas take effect.
10
+
11
+ - 8f5c998: Fix dev hot-reload dropping runtime-registered function/queue meta. `reloadGeneratedMeta` replaced the whole `function`/`queue` meta maps with the generated JSON, wiping entries the framework registers at service-init (the workflow orchestrator, per-workflow queue workers, and other `addFunction`'d internals that never appear in the generated files). Workflow jobs then failed with `Function meta not found: pikkuWorkflowOrchestrator`. The reload now merges over the existing maps so those internals survive.
12
+
13
+ ## 0.12.56
14
+
15
+ ### Patch Changes
16
+
17
+ - 6c30861: fix workflow step retry backoff firing immediately
18
+ - `@pikku/queue-pg-boss`: `backoff: 'exponential'` mapped to `retryBackoff: true` without a base `retryDelay`; pg-boss computes exponential backoff as `retry_delay * 2^n` with a queue default of 0, so every retry fired immediately. Exponential backoff now gets a 1s base delay, and sub-second fixed delays round up to 1s instead of flooring to 0 (= immediate).
19
+ - `@pikku/core`: a duration-string `retryDelay` (e.g. `'15s'`) on a workflow step was silently dropped (only numbers were honored) and fell back to exponential. It now resolves to a fixed backoff via `getDurationInMilliseconds`.
20
+
1
21
  ## 0.12.55
2
22
 
3
23
  ### Patch Changes
@@ -1,10 +1,14 @@
1
1
  import type { Logger } from '../services/logger.js';
2
+ export * from './reload-meta.js';
2
3
  interface PikkuDevReloaderOptions {
3
4
  srcDirectories: string[];
4
5
  logger: Logger;
5
6
  pikkuDir?: string;
6
7
  }
7
- export declare function pikkuDevReloader(options: PikkuDevReloaderOptions): Promise<{
8
+ export interface PikkuDevReloaderHandle {
8
9
  close: () => void;
9
- }>;
10
- export {};
10
+ /** Re-import every file changed since the last drain (post-codegen, once
11
+ * fresh meta is in state). */
12
+ reimportPending: () => Promise<void>;
13
+ }
14
+ export declare function pikkuDevReloader(options: PikkuDevReloaderOptions): Promise<PikkuDevReloaderHandle>;
@@ -1,15 +1,14 @@
1
1
  import { watch } 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
  import { register } from 'tsx/esm/api';
6
6
  import { pikkuState } from '../pikku-state.js';
7
- import { addFunction } from '../function/function-runner.js';
8
7
  import { clearMiddlewareCache } from '../middleware-runner.js';
9
8
  import { clearPermissionsCache } from '../permissions.js';
10
9
  import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
11
10
  import { httpRouter } from '../wirings/http/routers/http-router.js';
12
- import { addSchema, compileAllSchemas } from '../schema.js';
11
+ export * from './reload-meta.js';
13
12
  const isFunctionConfig = (value) => {
14
13
  return (typeof value === 'object' &&
15
14
  value !== null &&
@@ -44,11 +43,31 @@ const ensureTsxRegistered = () => {
44
43
  register();
45
44
  tsxRegistered = true;
46
45
  };
46
+ let tempCounter = 0;
47
47
  const reimportModule = async (filePath, useTsx = false) => {
48
48
  try {
49
49
  if (useTsx) {
50
- ensureTsxRegistered();
51
- return await import(`${pathToFileURL(resolve(filePath)).href}?t=${Date.now()}`);
50
+ // Import a uniquely-named sibling copy: a `?t=` query does NOT bust
51
+ // the cache on either runtime (Bun keys module identity on the bare
52
+ // path; tsx's transform cache keys on the file path too), so a fresh
53
+ // path is the only reliable re-import. Same directory → identical
54
+ // resolution for relative and package-`imports` (#…) specifiers. The
55
+ // dot-prefix keeps it out of the watcher.
56
+ if (!process.versions.bun) {
57
+ // Node needs tsx's loader to import raw .ts; Bun imports it natively.
58
+ ensureTsxRegistered();
59
+ }
60
+ const abs = resolve(filePath);
61
+ const tempPath = join(dirname(abs), `.pikku-hot-${++tempCounter}-${basename(abs)}`);
62
+ await copyFile(abs, tempPath);
63
+ try {
64
+ return await import(pathToFileURL(tempPath).href);
65
+ }
66
+ finally {
67
+ await rm(tempPath, { force: true }).catch(() => {
68
+ // Best-effort temp cleanup; a leftover dotfile is watcher-ignored.
69
+ });
70
+ }
52
71
  }
53
72
  const content = await readFile(resolve(filePath), 'utf-8');
54
73
  const dataUrl = 'data:text/javascript;base64,' + Buffer.from(content).toString('base64');
@@ -62,7 +81,10 @@ const isWatchedTsFile = (filename) => {
62
81
  return (filename.endsWith('.ts') &&
63
82
  !filename.endsWith('.test.ts') &&
64
83
  !filename.endsWith('.d.ts') &&
65
- !filename.endsWith('.gen.ts'));
84
+ !filename.endsWith('.gen.ts') &&
85
+ // Hidden files: editor/sed atomic-write temps and our own hot-reload
86
+ // sibling copies must never trigger a reload of themselves.
87
+ !basename(filename).startsWith('.'));
66
88
  };
67
89
  export async function pikkuDevReloader(options) {
68
90
  const { srcDirectories, logger, pikkuDir = '.pikku' } = options;
@@ -73,6 +95,7 @@ export async function pikkuDevReloader(options) {
73
95
  const handleFileChange = async (changedTsFile) => {
74
96
  const start = Date.now();
75
97
  const reloadedNames = [];
98
+ const addedNames = [];
76
99
  const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d));
77
100
  if (!srcDir)
78
101
  return;
@@ -87,48 +110,59 @@ export async function pikkuDevReloader(options) {
87
110
  logger.error(`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`);
88
111
  return;
89
112
  }
90
- let schemasChanged = false;
113
+ // Register every function-config export — replacing known functions AND
114
+ // adding new ones (a brand-new function becomes callable as soon as its
115
+ // meta lands via reloadGeneratedMeta after the next codegen pass).
116
+ // Write into the map captured at startup, NOT pikkuState's current one:
117
+ // a dev-server watcher may have temporarily swapped in a codegen-scoped
118
+ // map for the same file event (runAllWithCommandState), and a write to
119
+ // that map is silently discarded when it restores the original.
120
+ // Schemas are NOT touched here: `input`/`output` hold raw zod schemas,
121
+ // while the schema map carries codegen-generated JSON schemas — mixing the
122
+ // two crashed every reload. Fresh JSON schemas arrive via
123
+ // reloadGeneratedMeta once codegen has re-emitted them.
91
124
  for (const [exportName, exportValue] of Object.entries(mod)) {
92
- if (isFunctionConfig(exportValue) && functionsMap.has(exportName)) {
93
- addFunction(exportName, exportValue);
125
+ if (!isFunctionConfig(exportValue))
126
+ continue;
127
+ const isNew = !functionsMap.has(exportName);
128
+ functionsMap.set(exportName, exportValue);
129
+ if (isNew)
130
+ addedNames.push(exportName);
131
+ else
94
132
  reloadedNames.push(exportName);
95
- if (exportValue.input) {
96
- addSchema(exportName, exportValue.input);
97
- schemasChanged = true;
98
- }
99
- if (exportValue.output) {
100
- addSchema(`${exportName}Output`, exportValue.output);
101
- schemasChanged = true;
102
- }
103
- }
104
133
  }
105
- if (reloadedNames.length > 0) {
106
- clearMiddlewareCache();
107
- clearPermissionsCache();
108
- clearChannelMiddlewareCache();
109
- httpRouter.reset();
110
- if (schemasChanged) {
111
- try {
112
- compileAllSchemas(logger);
113
- }
114
- catch (err) {
115
- const msg = err instanceof Error ? err.message : String(err);
116
- if (msg.includes('SchemaService') ||
117
- (msg.includes('schema') && msg.includes('not'))) {
118
- logger.warn('Schema recompilation skipped (no SchemaService)');
119
- }
120
- else {
121
- logger.error(`Schema recompilation failed: ${msg}`);
122
- return;
123
- }
124
- }
125
- }
134
+ // Re-importing the module re-ran its wire* side effects (wireHTTP et al
135
+ // are keyed map-sets, so re-registration replaces/adds) — reset the
136
+ // router and caches even when no function export changed, so a
137
+ // wiring-only file edit rebuilds the route matchers too.
138
+ clearMiddlewareCache();
139
+ clearPermissionsCache();
140
+ clearChannelMiddlewareCache();
141
+ httpRouter.reset();
142
+ if (reloadedNames.length > 0 || addedNames.length > 0) {
126
143
  const elapsed = Date.now() - start;
127
- logger.info(`Hot-reloaded: ${reloadedNames.join(', ')} (${elapsed}ms)`);
144
+ const parts = [];
145
+ if (reloadedNames.length > 0)
146
+ parts.push(reloadedNames.join(', '));
147
+ if (addedNames.length > 0)
148
+ parts.push(`new: ${addedNames.join(', ')}`);
149
+ logger.info(`Hot-reloaded: ${parts.join('; ')} (${elapsed}ms)`);
128
150
  }
129
151
  };
130
152
  let debounceTimer;
131
153
  const pendingChanges = new Set();
154
+ // Files re-imported since the last reimportPending() drain. A dev-server
155
+ // watcher drains this after its codegen pass so wire* registrations that
156
+ // were skipped for missing meta (a NEW route) run again with fresh meta.
157
+ const postCodegenQueue = new Set();
158
+ const safeHandleFileChange = async (file) => {
159
+ try {
160
+ await handleFileChange(file);
161
+ }
162
+ catch (err) {
163
+ logger.error(`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
164
+ }
165
+ };
132
166
  const scheduleReload = (filePath) => {
133
167
  pendingChanges.add(filePath);
134
168
  if (debounceTimer)
@@ -137,12 +171,8 @@ export async function pikkuDevReloader(options) {
137
171
  const files = [...pendingChanges];
138
172
  pendingChanges.clear();
139
173
  for (const file of files) {
140
- try {
141
- await handleFileChange(file);
142
- }
143
- catch (err) {
144
- logger.error(`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
145
- }
174
+ postCodegenQueue.add(file);
175
+ await safeHandleFileChange(file);
146
176
  }
147
177
  }, 50);
148
178
  };
@@ -168,5 +198,18 @@ export async function pikkuDevReloader(options) {
168
198
  watcher.close();
169
199
  }
170
200
  },
201
+ // Drain the queue of recently re-imported files and import them again.
202
+ // A dev-server watcher calls this AFTER its codegen pass has refreshed
203
+ // the generated meta (reloadGeneratedMeta): wire* registrations skip
204
+ // routes whose meta doesn't exist yet, so a wiring file changed
205
+ // alongside a NEW function only registers its new route when
206
+ // re-imported after the fresh meta has landed.
207
+ reimportPending: async () => {
208
+ const files = [...postCodegenQueue];
209
+ postCodegenQueue.clear();
210
+ for (const file of files) {
211
+ await safeHandleFileChange(file);
212
+ }
213
+ },
171
214
  };
172
215
  }
@@ -0,0 +1,24 @@
1
+ import type { Logger } from '../services/logger.js';
2
+ import type { SchemaService } from '../services/schema-service.js';
3
+ export interface ReloadGeneratedMetaOptions {
4
+ /** The project's generated output directory (the CLI's resolved outDir). */
5
+ pikkuDir: string;
6
+ logger: Logger;
7
+ /** Used to recompile validators for changed schemas; falls back to the
8
+ * schema service on the registered singleton services. */
9
+ schemaService?: SchemaService;
10
+ }
11
+ /**
12
+ * Re-reads the codegen output (wiring meta + JSON schemas) into the running
13
+ * process so new and changed functions become callable without a server
14
+ * restart. The generated `*-meta.gen.ts` files are plain
15
+ * `pikkuState(area, key, <json>)` side effects, but they cannot be
16
+ * re-imported after a dev-time codegen — the ESM cache pins both the wrapper
17
+ * and its JSON import — so this reads the JSON sources directly and applies
18
+ * the same state.
19
+ *
20
+ * Meant to be called by a dev-server watcher after each codegen pass. Routes
21
+ * registered by NEW `wireHTTP` files are not picked up (their modules were
22
+ * never imported); those still need a restart.
23
+ */
24
+ export declare function reloadGeneratedMeta(options: ReloadGeneratedMetaOptions): Promise<void>;
@@ -0,0 +1,99 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { pikkuState } from '../pikku-state.js';
4
+ import { addSchema, compileAllSchemas } from '../schema.js';
5
+ import { clearMiddlewareCache } from '../middleware-runner.js';
6
+ import { clearPermissionsCache } from '../permissions.js';
7
+ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
8
+ import { httpRouter } from '../wirings/http/routers/http-router.js';
9
+ const readJson = async (logger, file) => {
10
+ let raw;
11
+ try {
12
+ raw = await readFile(file, 'utf-8');
13
+ }
14
+ catch {
15
+ // The project doesn't use this wiring type — nothing generated.
16
+ return undefined;
17
+ }
18
+ try {
19
+ return JSON.parse(raw);
20
+ }
21
+ catch (err) {
22
+ logger.error(`Hot-reload could not parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
23
+ return undefined;
24
+ }
25
+ };
26
+ /**
27
+ * Re-reads the codegen output (wiring meta + JSON schemas) into the running
28
+ * process so new and changed functions become callable without a server
29
+ * restart. The generated `*-meta.gen.ts` files are plain
30
+ * `pikkuState(area, key, <json>)` side effects, but they cannot be
31
+ * re-imported after a dev-time codegen — the ESM cache pins both the wrapper
32
+ * and its JSON import — so this reads the JSON sources directly and applies
33
+ * the same state.
34
+ *
35
+ * Meant to be called by a dev-server watcher after each codegen pass. Routes
36
+ * registered by NEW `wireHTTP` files are not picked up (their modules were
37
+ * never imported); those still need a restart.
38
+ */
39
+ export async function reloadGeneratedMeta(options) {
40
+ const { pikkuDir, logger, schemaService } = options;
41
+ const dir = resolve(pikkuDir);
42
+ const functionsMeta = await readJson(logger, join(dir, 'function/pikku-functions-meta.gen.json'));
43
+ // Merge over the existing map, don't replace it: framework internals like
44
+ // pikkuWorkflowOrchestrator / the per-workflow queue workers are registered
45
+ // at service-init (pikku-workflow-service.ts), never in the generated JSON —
46
+ // a wholesale replace drops them and workflow jobs then fail with
47
+ // "Function meta not found: pikkuWorkflowOrchestrator".
48
+ if (functionsMeta) {
49
+ const existing = pikkuState(null, 'function', 'meta') ?? {};
50
+ pikkuState(null, 'function', 'meta', { ...existing, ...functionsMeta });
51
+ }
52
+ const httpMeta = await readJson(logger, join(dir, 'http/pikku-http-wirings-meta.gen.json'));
53
+ if (httpMeta)
54
+ pikkuState(null, 'http', 'meta', httpMeta);
55
+ const rpcMeta = await readJson(logger, join(dir, 'rpc/pikku-rpc-wirings-meta.internal.gen.json'));
56
+ if (rpcMeta)
57
+ pikkuState(null, 'rpc', 'meta', rpcMeta);
58
+ const queueMeta = await readJson(logger, join(dir, 'queue/pikku-queue-workers-wirings-meta.gen.json'));
59
+ // Same reason as function meta: the workflow service adds its orchestrator /
60
+ // step queues (and wf-orchestrator-* / wf-step-* per-workflow queues) here at
61
+ // init, absent from the generated JSON — merge so they survive the reload.
62
+ if (queueMeta) {
63
+ const existing = pikkuState(null, 'queue', 'meta') ?? {};
64
+ pikkuState(null, 'queue', 'meta', { ...existing, ...queueMeta });
65
+ }
66
+ const agentMeta = await readJson(logger, join(dir, 'agent/pikku-agent-wirings-meta.gen.json'));
67
+ if (agentMeta?.agentsMeta) {
68
+ pikkuState(null, 'agent', 'agentsMeta', agentMeta.agentsMeta);
69
+ }
70
+ // Generated JSON schemas: <outDir>/schemas/schemas/<Name>.schema.json.
71
+ // Re-adding replaces the map entry; the schema service recompiles any
72
+ // validator whose stored schema value no longer matches.
73
+ const schemasDir = join(dir, 'schemas', 'schemas');
74
+ let schemaFiles = [];
75
+ try {
76
+ schemaFiles = await readdir(schemasDir);
77
+ }
78
+ catch {
79
+ // No generated schemas — a schema-less project.
80
+ }
81
+ for (const file of schemaFiles) {
82
+ if (!file.endsWith('.schema.json'))
83
+ continue;
84
+ const schema = await readJson(logger, join(schemasDir, file));
85
+ if (schema) {
86
+ addSchema(file.slice(0, -'.schema.json'.length), schema);
87
+ }
88
+ }
89
+ clearMiddlewareCache();
90
+ clearPermissionsCache();
91
+ clearChannelMiddlewareCache();
92
+ httpRouter.reset();
93
+ try {
94
+ compileAllSchemas(logger, schemaService);
95
+ }
96
+ catch (err) {
97
+ logger.error(`Hot-reload schema recompilation failed: ${err instanceof Error ? err.message : String(err)}`);
98
+ }
99
+ }
@@ -509,13 +509,13 @@ export class PikkuWorkflowService {
509
509
  resolveStepJobOptions(stepOptions) {
510
510
  const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
511
511
  const retryDelay = stepOptions?.retryDelay;
512
- const backoff = typeof retryDelay === 'number'
513
- ? { type: 'fixed', delay: retryDelay }
514
- : retryDelay === 'exponential'
512
+ // A concrete retryDelay (15000, '15s') is a fixed backoff; only the literal
513
+ // 'exponential' — or no delay at all — selects exponential.
514
+ const backoff = retryDelay !== undefined && retryDelay !== 'exponential'
515
+ ? { type: 'fixed', delay: getDurationInMilliseconds(retryDelay) }
516
+ : retries > 0 || retryDelay === 'exponential'
515
517
  ? 'exponential'
516
- : retries > 0
517
- ? 'exponential'
518
- : undefined;
518
+ : undefined;
519
519
  return { attempts: retries + 1, ...(backoff ? { backoff } : {}) };
520
520
  }
521
521
  async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.55",
3
+ "version": "0.12.57",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -152,29 +152,36 @@ describe('pikkuDevReloader', { concurrency: false }, () => {
152
152
  assert.ok(reloadLog, 'Should log hot-reload message')
153
153
  })
154
154
 
155
- test('should not replace a function that is not registered', async (t) => {
155
+ test('should register a brand-new function export', async (t) => {
156
156
  if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
157
157
 
158
158
  addFunction('registeredFunc', {
159
159
  func: async () => ({ name: 'registered' }),
160
160
  })
161
161
 
162
- await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "unknown" }')
163
-
164
162
  reloader = await pikkuDevReloader({
165
163
  srcDirectories: [tmpDir],
166
164
  logger: mockLogger,
167
165
  pikkuDir: tmpDir,
168
166
  })
169
167
 
170
- await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "updated" }')
168
+ await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "unknown" }')
171
169
 
172
170
  await wait(300)
173
171
 
174
- assert.equal(
175
- pikkuState(null, 'function', 'functions').has('unknownFunc'),
176
- false
177
- )
172
+ const func = pikkuState(null, 'function', 'functions').get('unknownFunc')!
173
+ assert.ok(func, 'New function export should be registered')
174
+ assert.deepEqual(await func.func({} as any, {}, {} as any), {
175
+ name: 'unknown',
176
+ })
177
+ const newLog = mockLogger
178
+ .getLogs()
179
+ .find(
180
+ (l) =>
181
+ l.message.includes('Hot-reloaded') &&
182
+ l.message.includes('new: unknownFunc')
183
+ )
184
+ assert.ok(newLog, 'Should log the newly registered function')
178
185
  })
179
186
 
180
187
  test('should keep old code when JS import fails', async (t) => {