cabloy 5.1.206 → 5.1.208

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 (33) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +1 -0
  4. package/package.json +1 -1
  5. package/repo-agent-governance/managed-assets.json +3 -3
  6. package/repo-agent-governance/policies/repository.md +1 -1
  7. package/repo-docs/backend/migration-and-changes.md +27 -8
  8. package/repo-docs/fullstack/quickstart.md +11 -1
  9. package/repo-docs/reference/repo-scripts.md +20 -1
  10. package/scripts/init.ts +15 -0
  11. package/vona/packages-cli/cli/package.json +1 -1
  12. package/vona/packages-cli/cli-set-api/package.json +1 -1
  13. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +17 -2
  14. package/vona/packages-cli/cli-set-api/test/binDevLifecycle.test.ts +100 -0
  15. package/vona/packages-vona/vona/package.json +1 -1
  16. package/vona/packages-vona/vona-core/package.json +1 -1
  17. package/vona/packages-vona/vona-core/src/lib/framework/cluster.ts +1 -1
  18. package/vona/packages-vona/vona-core/src/lib/framework/processMaster.test.ts +185 -0
  19. package/vona/packages-vona/vona-core/src/lib/framework/processMaster.ts +18 -7
  20. package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +127 -0
  21. package/vona/packages-vona/vona-mock/package.json +1 -1
  22. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  23. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  24. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +77 -42
  25. package/vona/src/suite-vendor/a-vona/modules/a-redis/test/redisCleanup.test.ts +378 -0
  26. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  27. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +56 -9
  28. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/types/startup.ts +1 -0
  29. package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +104 -0
  30. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  31. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +183 -51
  32. package/vona/src/suite-vendor/a-vona/modules/a-version/test/devDatabasePreparation.test.ts +221 -0
  33. package/vona/src/suite-vendor/a-vona/package.json +1 -1
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.206
1
+ 5.1.208
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.208
4
+
5
+ ### Improvements
6
+
7
+ - Update the README and quickstart documentation.
8
+ - Update the changelog for version 5.1.207.
9
+
10
+ ## 5.1.207
11
+
12
+ ### Features
13
+
14
+ - Add update functionality.
15
+
16
+ ### Bug Fixes
17
+
18
+ - Fix database reset behavior.
19
+
20
+ ### Improvements
21
+
22
+ - Enhance and refactor database reset functionality.
23
+
3
24
  ## 5.1.206
4
25
 
5
26
  ### Features
package/README.md CHANGED
@@ -134,6 +134,7 @@ Use the root [package.json](https://github.com/cabloy/cabloy/blob/main/package.j
134
134
 
135
135
  ```bash
136
136
  npm run init
137
+ npm run db:reset
137
138
  npm run dev
138
139
  npm run tsc
139
140
  npm run test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.206",
3
+ "version": "5.1.208",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -683,7 +683,7 @@
683
683
  {
684
684
  "adapter": "cursor",
685
685
  "category": "policy",
686
- "sha256": "539fa24fc49fa8402ea0d9ab89c058762c441d5b3ed87af34a16235d1f8251a0",
686
+ "sha256": "e2784a08d355455da0da0cfb7c83ff1df4db69566012a1b4a33a5d591764f730",
687
687
  "source": "policies/repository.md",
688
688
  "target": ".cursor/rules/cabloy-governance.mdc"
689
689
  },
@@ -1005,14 +1005,14 @@
1005
1005
  {
1006
1006
  "adapter": "codex",
1007
1007
  "category": "policy",
1008
- "sha256": "f8172d4e80dc8b84bf99a0e926184eb78af5bd1fdfcdeebe63b02160f459afce",
1008
+ "sha256": "d5f4d8fb331147baa92aa68d8579ac604d2a34bf872efc594393ff1e52e8cd79",
1009
1009
  "source": "policies/repository.md",
1010
1010
  "target": "AGENTS.md"
1011
1011
  },
1012
1012
  {
1013
1013
  "adapter": "claude",
1014
1014
  "category": "policy",
1015
- "sha256": "f8172d4e80dc8b84bf99a0e926184eb78af5bd1fdfcdeebe63b02160f459afce",
1015
+ "sha256": "d5f4d8fb331147baa92aa68d8579ac604d2a34bf872efc594393ff1e52e8cd79",
1016
1016
  "source": "policies/repository.md",
1017
1017
  "target": "CLAUDE.md"
1018
1018
  }
@@ -96,7 +96,7 @@ Before inventing a custom implementation path:
96
96
  - Do not set `TEST_CONCURRENCY=false` for normal `npm run test` or CI runs: an unset value defaults to concurrent execution. For stateful `node:test` suites, declare `{ concurrency: false }` explicitly. Prove business contention by explicitly launching competing operations and asserting their combined durable outcome, never through runner parallelism or scheduling.
97
97
  - Unit tests must not temporarily mutate process-global origin or proxy configuration, including `app.config.server.serve.protocol`, `app.config.server.serve.host`, and `app.proxy`. Model request-origin cases with an explicit `req` fixture, and use the established `AppUtil` loopback fallback for requestless local URLs. Do not introduce a broad origin lock to serialize such tests; retain narrowly scoped locks only for genuinely shared domain state.
98
98
  - Unit tests must delete every test-owned persisted resource in `finally`, using precise owned identities and reverse dependency order.
99
- - Shared durable test or local-development fixtures must be created through the owning module's `meta.version.ts` `seed()` hook and treated as read-only by tests; seeds run on isolated test-mode startup or after an explicit dev-mode `npm run db:reset` against a newly recreated disposable database, not on ordinary dev startup or repeatedly against one database.
99
+ - Shared durable test or local-development fixtures must be created through the owning module's `meta.version.ts` `seed()` hook and treated as read-only by tests; seeds run on isolated test-mode startup or after an explicit dev-mode `npm run db:reset` against newly recreated disposable databases. In Cabloy Basic and Cabloy Start, `npm run dev` and `npm run dev:one` only reuse existing managed disposable databases; any missing required shared or static isolate target must fail promptly with an instruction to run `npm run db:reset`, without database creation, creation-time Redis cleanup, or seeding. Reset is explicitly destructive: one process clears validated application-owned Redis prefixes, drops/recreates managed databases, migrates, initializes, and seeds. Failures remain visible; investigate and explicitly rerun reset, with no preparation journal or automatic recovery. There is no file-based preparation lock; concurrent dev/dev or dev/reset against the same database and Redis namespace is unsupported. Do not run reset without explicit user approval; never seed repeatedly against one database.
100
100
 
101
101
  ## Verification expectations
102
102
 
@@ -94,11 +94,11 @@ export class MetaVersion extends BeanBase {}
94
94
 
95
95
  Three migration scenarios are defined:
96
96
 
97
- | Scenario | Purpose |
98
- | -------- | --------------------------------------------------- |
99
- | `update` | schema evolution such as tables and fields |
100
- | `init` | instance- or tenant-specific initialization data |
101
- | `seed` | baseline data for test runs and explicit dev resets |
97
+ | Scenario | Purpose |
98
+ | -------- | ------------------------------------------------------------ |
99
+ | `update` | schema evolution such as tables and fields |
100
+ | `init` | instance- or tenant-specific initialization data |
101
+ | `seed` | baseline data for test runs and newly prepared dev databases |
102
102
 
103
103
  This split is one of the most important Vona migration ideas because it separates:
104
104
 
@@ -110,7 +110,7 @@ A practical generated-thread interpretation is:
110
110
 
111
111
  - `update` follows schema and entity/model evolution
112
112
  - `init` follows instance-aware initialization needs introduced by the backend feature
113
- - `seed` provides shared baseline data during test-mode startup or an explicit development database reset
113
+ - `seed` provides shared baseline data during test-mode startup or an explicit development database reset; ordinary dev startup never seeds (see the shared lifecycle below)
114
114
 
115
115
  ## Update: schema migration
116
116
 
@@ -153,7 +153,7 @@ export class MetaVersion extends BeanBase implements IMetaVersionInit {
153
153
 
154
154
  The important point is that initialization can run per instance or tenant, which keeps tenant data isolated.
155
155
 
156
- ## Seed: test and reset baseline data
156
+ ## Seed: test and development baseline data
157
157
 
158
158
  Representative pattern:
159
159
 
@@ -171,7 +171,7 @@ export class MetaVersion extends BeanBase implements IMetaVersionSeed {
171
171
 
172
172
  This is valuable because shared baseline data becomes part of the structured version lifecycle instead of being scattered across unrelated setup code.
173
173
 
174
- Vona invokes `seed()` while the application starts in test mode (`npm run test` or managed E2E), and during an explicit `npm run db:reset` in dev mode. Ordinary development startup and framework upgrades do not seed or reset development data automatically. Use `seed()` for durable baseline fixtures shared by tests or for freshly prepared local development data. Both paths seed after instance initialization on a newly recreated disposable database; `seed()` is not a repeatable, same-database data-import mechanism.
174
+ Vona invokes `seed()` while the application starts in test mode (`npm run test` or managed E2E), and during an explicit `npm run db:reset` in dev mode. In **Cabloy Basic and Cabloy Start**, ordinary `npm run dev` and `npm run dev:one` only reuse existing managed disposable databases and never seed; a missing required database makes startup fail with an instruction to run `npm run db:reset`. Framework upgrades do not reset existing development data or repeat its seeds. Use `seed()` for durable baseline fixtures shared by tests or for freshly prepared local development data. Seeding follows instance initialization on a newly created or recreated disposable database; `seed()` is not a repeatable, same-database data-import mechanism. See [Managed development database preparation](#managed-development-database-preparation) for the explicit reset lifecycle.
175
175
 
176
176
  Treat shared `seed()` records as read-only in ordinary tests. A scenario that needs different state must create independent, test-owned data and clean it up in `finally`; it must not mutate or delete the shared baseline. `seed()` remains distinct from `init()`: use `init()` for instance-aware initialization data and reserve `seed()` for baseline data in test runs or explicit development resets.
177
177
 
@@ -208,6 +208,25 @@ A practical decision rule is:
208
208
  - `db:reset` requires `database.testDatabase: true` in dev config and a connection outside the disposable database; it refuses to reset a configured non-disposable database
209
209
  - use `test` when you need **test-mode** verification of migration, controller behavior, and the broader generated contract thread; E2E also remains test-mode
210
210
 
211
+ ### Managed development database preparation
212
+
213
+ **Cabloy Basic and Cabloy Start** share this explicit preparation lifecycle for framework-managed disposable development databases.
214
+
215
+ When `database.testDatabase: true` enables framework-managed disposable dev databases, `npm run dev` and `npm run dev:one` only reuse existing databases:
216
+
217
+ 1. **Check every required target:** both shared and statically configured isolate database targets must exist. If any required target is missing, startup fails promptly with an instruction to run `npm run db:reset`; it does not create the target, perform creation-time Redis cleanup, or seed as a fallback.
218
+ 2. **Reuse existing databases:** keep their data without database creation, creation-time Redis cleanup, or module seeding. Ordinary startup is not a fixture refresh, an implicit reset, or recovery for a partial reset. Non-disposable databases are outside this managed reset path.
219
+
220
+ An explicit `npm run db:reset` owns preparation in a single process. It requires `database.testDatabase: true` and a connection outside the disposable database. It clears validated application-owned Redis state with `clearAllData()`, drops and recreates the managed disposable dev databases, runs migrations and instance initialization, then invokes the owning modules' `seed()` hooks.
221
+
222
+ **Stop the development server and any other processes using the same database and Redis namespace first.** There is no file-based preparation lock; concurrent dev/dev and dev/reset against the same database and Redis namespace are unsupported. The reset command disables its own listener, but that does not protect a concurrently running server or queue worker. Never reset development data you need to keep.
223
+
224
+ If reset fails, the failure remains visible. There is no preparation journal or automatic recovery: investigate and resolve the cause, then explicitly rerun `npm run db:reset`. Database existence alone does not prove migrations, initialization, or seeding completed after an interruption. A subsequent dev startup must not be used to repair or complete the failed preparation.
225
+
226
+ The Redis cleanup is prefix-scoped, not a Redis-wide flush: `clearAllData()` validates application-owned prefixes and deletes matching Bull queue, redlock, worker, and cache keys. It does not use `FLUSHDB` or `FLUSHALL`, delete unrelated namespaces, or clear broadcast subscriptions. Only explicit reset performs this destructive preparation cleanup; ordinary dev startup does not. Prefix scoping is not permission to clear a live shared namespace manually.
227
+
228
+ See [Development database preparation](/reference/repo-scripts#development-database-preparation) for the root-command summary.
229
+
211
230
  ## Implementation checks for migration-sensitive changes
212
231
 
213
232
  When changing backend schema or module initialization behavior, do not only edit entities and models.
@@ -63,12 +63,22 @@ pnpm_config_minimum_release_age=0 npm create cabloy
63
63
 
64
64
  ## 3. Start Vona integrated SSR
65
65
 
66
- Run the root CLI command to start the Vona server:
66
+ Before the first start, run the root command below to prepare the development databases and seed data:
67
+
68
+ ```bash
69
+ npm run db:reset
70
+ ```
71
+
72
+ `npm run db:reset` is destructive: it clears application-owned Redis prefixes and drops and recreates the managed development databases, then runs migrations, initialization, and seeds. Use it only when you intend to reset those resources. Do not run it while a development server is using the same databases and Redis namespace.
73
+
74
+ Then start the Vona server:
67
75
 
68
76
  ```bash
69
77
  npm run dev
70
78
  ```
71
79
 
80
+ For subsequent starts, run `npm run dev` directly. It reuses existing databases and does not create or seed missing databases; if a required database is missing, run `npm run db:reset` explicitly before starting again.
81
+
72
82
  In the Cabloy Basic default environment, Vona listens on `7102` and serves the following SSR sites:
73
83
 
74
84
  | SSR Site | URL |
@@ -67,7 +67,7 @@ Builds, generated artifacts, dependency installation/generation, and other share
67
67
 
68
68
  For Vona integrated SSR, the allocated E2E origin is supplied to Zova at runtime as server `SSR_API_BASE_URL` and browser `API_BASE_URL`. Do not edit Zova `.env` files or rebuild solely to match the test port. The current root `npm run test:e2e` consumes prebuilt frontend artifacts; refresh affected SSR/REST output and synchronize dependencies when their inputs change. See [Runtime API origins in Vona integrated SSR](/fullstack/ssr-entry-modes#runtime-api-origins-in-vona-integrated-ssr) for configuration overrides and scope.
69
69
 
70
- For a deliberate **dev-mode** database recreation, `npm run db:reset` is a separate, destructive command: it drops matching disposable development databases, reruns migrations and instance initialization, then runs module `seed()` hooks. It requires `database.testDatabase: true` in the dev configuration and a connection outside the disposable database. Ordinary `npm run dev` does not seed or reset; never run `db:reset` against development data you need to retain. Upgrades no longer initialize development test data automatically. See [Migration and Changes](/backend/migration-and-changes#local-development-workflow).
70
+ For deliberate **dev-mode** database preparation, `npm run db:reset` is a separate, destructive command: in a single process, it clears validated application-owned Redis keys, drops and recreates managed disposable development databases, reruns migrations and instance initialization, then runs module `seed()` hooks. It requires `database.testDatabase: true` in the dev configuration and a connection outside the disposable database. Ordinary `npm run dev` and `npm run dev:one` only reuse existing managed databases; if any required target is missing, startup fails promptly with an instruction to run `npm run db:reset`. They do not create databases, perform creation-time Redis cleanup, or seed. Never run `db:reset` against development data you need to retain. Upgrades do not reset existing development data or repeat its seeds. See [Development database preparation](#development-database-preparation) and [Migration and Changes](/backend/migration-and-changes#local-development-workflow).
71
71
 
72
72
  ## Specification planning and derived charts
73
73
 
@@ -140,6 +140,25 @@ Cabloy Start exposes the equivalent Start repository surface:
140
140
 
141
141
  Cabloy Start does not expose Basic Commerce or root documentation wrappers.
142
142
 
143
+ ## Development database preparation
144
+
145
+ **Cabloy Basic and Cabloy Start** share this explicit development-preparation lifecycle. Use the active repository's commands and configuration; sharing the lifecycle does not make the editions' flavors or frontend outputs interchangeable.
146
+
147
+ With `database.testDatabase: true`, `npm run dev` and `npm run dev:one` only reuse existing framework-managed disposable development databases:
148
+
149
+ - **All required databases exist:** startup reuses them without database creation, creation-time Redis cleanup, or module seeding. Ordinary startup is not a reset or a repeatable fixture-import path.
150
+ - **Any required database is missing:** startup fails promptly with an instruction to run `npm run db:reset`. This check covers both shared and statically configured isolate database targets; the presence of the shared database alone is not sufficient. Startup does not create the missing target or clear Redis or seed as a fallback.
151
+
152
+ `npm run db:reset` is the explicit, destructive preparation command. It requires `database.testDatabase: true` and a connection outside the disposable database. In a single process, it clears validated application-owned Redis state with `clearAllData()`, drops and recreates the managed disposable dev databases, then runs migrations, instance initialization, and module `seed()` hooks.
153
+
154
+ **Stop the development server and any other processes using the same database and Redis namespace before running reset.** There is no file-based preparation lock: concurrent dev/dev and dev/reset processes against the same database and Redis namespace are unsupported. Disabling the reset command's own listener does not make reset safe alongside a live server or queue worker. Do not reset data you need to retain.
155
+
156
+ Reset failures remain visible. There is no preparation journal or automatic recovery; investigate and resolve the failure, then explicitly rerun `npm run db:reset`. Do not use ordinary dev startup to repair or finish a partial reset.
157
+
158
+ Here, `clearAllData()` deletes only keys in the validated application-owned Redis prefixes, covering Bull queue, redlock, worker, and cache keys. It does not use `FLUSHDB` or `FLUSHALL`, clear unrelated namespaces, or treat broadcast subscriptions as cleanup keys. Prefix scoping still makes reset destructive to the application's own Redis state; it is not permission to clean a shared live namespace manually.
159
+
160
+ See [Managed development database preparation](/backend/migration-and-changes#managed-development-database-preparation) for the seed lifecycle and safety boundary.
161
+
143
162
  ## Upgrade
144
163
 
145
164
  Run `npm run upgrade:dry-run` before `npm run upgrade` to inspect framework files and root manifest entries that an upgrade would synchronize. The upgrader replaces the framework-owned `repo-agent-governance/` source, reconciles only its named package scripts, and uses ownership-aware adapter adoption. It preserves locally modified or legacy adapter outputs as conflicts for explicit review instead of blindly overwriting root Claude, Codex, or Cursor files.
package/scripts/init.ts CHANGED
@@ -310,3 +310,18 @@ initRepoDocs();
310
310
  writeVersionMarker();
311
311
  // eslint-disable-next-line
312
312
  console.log('[init] Done!');
313
+ // eslint-disable-next-line
314
+ console.log(
315
+ [
316
+ '',
317
+ '=== Before your first development startup ===',
318
+ 'Prepare the development database before running npm run dev:',
319
+ '',
320
+ ' npm run db:reset',
321
+ '',
322
+ 'Warning: db:reset deletes managed development data and application-owned Redis state.',
323
+ 'Stop processes using the same database and Redis namespace before running it.',
324
+ 'If your development database is already prepared, skip reset to keep existing data.',
325
+ '',
326
+ ].join('\n'),
327
+ );
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli",
3
- "version": "1.1.160",
3
+ "version": "1.1.161",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli-set-api",
3
- "version": "1.1.158",
3
+ "version": "1.1.159",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli-set-api",
6
6
  "keywords": [
@@ -97,7 +97,8 @@ export class CliBinDev extends BeanCliBase {
97
97
 
98
98
  async _run(projectPath: string, _modulesMeta: Awaited<ReturnType<typeof glob>>) {
99
99
  let closed = false;
100
- return new Promise((resolve, _reject) => {
100
+ let crashError: Error | undefined;
101
+ return new Promise((resolve, reject) => {
101
102
  (nodemon as any)({
102
103
  script: '.vona/bootstrap.ts',
103
104
  cwd: projectPath,
@@ -127,9 +128,23 @@ export class CliBinDev extends BeanCliBase {
127
128
  ],
128
129
  });
129
130
  nodemon
131
+ .on('crash', () => {
132
+ if (closed) return;
133
+ closed = true;
134
+ // nodemon emits crash without an error payload; the child already logged the cause.
135
+ crashError = new Error('Development server crashed; see the startup error above');
136
+ // Let nodemon finish processing the child exit before stopping its watchers.
137
+ setImmediate(() => nodemon.emit('quit'));
138
+ })
130
139
  .on('quit', () => {
131
140
  closed = true;
132
- resolve(undefined);
141
+ if (crashError) {
142
+ // The programmatic quit event does not close watchers; reset before propagating
143
+ // the failure to the CLI's error handler, which exits the process immediately.
144
+ nodemon.reset(() => reject(crashError));
145
+ } else {
146
+ resolve(undefined);
147
+ }
133
148
  })
134
149
  .on('restart', files => {
135
150
  if (closed) {
@@ -0,0 +1,100 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { test } from 'node:test';
4
+
5
+ const devUrl = new URL('../src/lib/bean/cli.bin.dev.ts', import.meta.url).href;
6
+ const source = `
7
+ import assert from 'node:assert/strict';
8
+ import { EventEmitter } from 'node:events';
9
+ import { registerHooks } from 'node:module';
10
+ import { setImmediate as nextTurn } from 'node:timers/promises';
11
+ const devUrl = ${JSON.stringify(devUrl)};
12
+ const events = new EventEmitter();
13
+ let options;
14
+ let resetCount = 0;
15
+ let watchersActive = true;
16
+ const nodemon = globalThis.nodemonMock = opts => { options = opts; };
17
+ nodemon.reset = done => {
18
+ resetCount++;
19
+ watchersActive = false;
20
+ events.removeAllListeners();
21
+ done();
22
+ };
23
+ nodemon.on = (event, handler) => { events.on(event, handler); return nodemon; };
24
+ nodemon.emit = (event, ...args) => { events.emit(event, ...args); return nodemon; };
25
+ const mocks = {
26
+ '@cabloy/cli': 'export class BeanCliBase {}',
27
+ 'nodemon': 'export default globalThis.nodemonMock;',
28
+ 'rimraf': 'export function rimraf() { throw new Error("Unexpected cleanup"); }',
29
+ 'vona-core': 'export function resolveVonaLocalPaths() { throw new Error("Unexpected app access"); }',
30
+ '../utils.ts': 'export function getImportEsm() { return "--import=./.vona/register.js"; }',
31
+ './toolsBin/generateVonaMeta.ts': 'export function generateVonaMeta() { throw new Error("Unexpected generation"); }',
32
+ };
33
+ registerHooks({
34
+ resolve(specifier, context, nextResolve) {
35
+ if (context.parentURL === devUrl && mocks[specifier]) {
36
+ return { url: 'data:text/javascript,' + encodeURIComponent(mocks[specifier]), shortCircuit: true };
37
+ }
38
+ return nextResolve(specifier, context);
39
+ },
40
+ });
41
+ const { CliBinDev } = await import(devUrl);
42
+ const cli = Object.create(CliBinDev.prototype);
43
+ let settled = false;
44
+ const result = cli._run('/mock-project', {});
45
+ result.then(() => { settled = true; }, () => {
46
+ assert.equal(watchersActive, false, 'close watchers before propagating failure');
47
+ settled = true;
48
+ });
49
+ assert.equal(options.cwd, '/mock-project');
50
+ assert.equal(options.script, '.vona/bootstrap.ts');
51
+ // Leave nodemon's default stdout/stderr forwarding intact, including the original failure log.
52
+ assert.equal(options.stdout, undefined);
53
+ assert.equal(options.stderr, undefined);
54
+ const scenario = process.argv[1];
55
+ let quits = 0;
56
+ events.on('quit', () => { quits++; });
57
+ if (scenario === 'crash') {
58
+ console.error('Database cabloy_dev is missing. Create the disposable database first.');
59
+ nodemon.emit('crash'); // nodemon provides no crash payload
60
+ // Duplicate crashes must not request duplicate shutdowns.
61
+ nodemon.emit('crash');
62
+ await assert.rejects(result, /Development server crashed; see the startup error above/);
63
+ await nextTurn();
64
+ assert.equal(quits, 1);
65
+ assert.equal(settled, true);
66
+ assert.equal(resetCount, 1);
67
+ assert.equal(watchersActive, false);
68
+ } else {
69
+ nodemon.emit('start');
70
+ nodemon.emit('restart', ['src/example.ts']);
71
+ nodemon.emit('exit');
72
+ await nextTurn();
73
+ assert.equal(settled, false, 'ordinary restart/clean exit must keep supervision running');
74
+ assert.equal(quits, 0);
75
+ assert.equal(resetCount, 0);
76
+ assert.equal(watchersActive, true);
77
+ nodemon.emit('quit');
78
+ assert.equal(await result, undefined);
79
+ assert.equal(quits, 1);
80
+ }
81
+ console.log('verified');
82
+ `;
83
+
84
+ for (const scenario of ['crash', 'restart-and-quit']) {
85
+ test(`dev nodemon lifecycle: ${scenario}`, () => {
86
+ const result = spawnSync(
87
+ process.execPath,
88
+ ['--input-type=module', '--eval', source, scenario],
89
+ {
90
+ encoding: 'utf8',
91
+ timeout: 3_000,
92
+ },
93
+ );
94
+ assert.ifError(result.error);
95
+ assert.equal(result.signal, null, result.stderr);
96
+ assert.equal(result.status, 0, result.stderr);
97
+ assert.match(result.stdout, /verified/);
98
+ if (scenario === 'crash') assert.match(result.stderr, /Database cabloy_dev is missing/);
99
+ });
100
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona",
3
- "version": "5.1.105",
3
+ "version": "5.1.106",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-core",
3
- "version": "5.1.44",
3
+ "version": "5.1.45",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona",
6
6
  "keywords": [
@@ -8,8 +8,8 @@ import { handleProcessWork } from './processWorker.ts';
8
8
 
9
9
  export async function startCluster(workers: number, bootstrapOptions: BootstrapOptions) {
10
10
  if (cluster.isPrimary) {
11
+ await createAppMaster(bootstrapOptions);
11
12
  handleProcessMaster(workers);
12
- createAppMaster(bootstrapOptions);
13
13
  } else {
14
14
  await startWorker(bootstrapOptions);
15
15
  }
@@ -0,0 +1,185 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { test } from 'node:test';
4
+
5
+ const masterUrl = new URL('./processMaster.ts', import.meta.url).href;
6
+ const clusterUrl = new URL('./cluster.ts', import.meta.url).href;
7
+
8
+ // Import only the real master handler into a subprocess. Cluster workers and useApp are mocks;
9
+ // process.exit and the shutdown timeout remain real, without loading an app or opening listeners.
10
+ const masterSource = `
11
+ import assert from 'node:assert/strict';
12
+ import { EventEmitter } from 'node:events';
13
+ import { registerHooks } from 'node:module';
14
+ const masterUrl = ${JSON.stringify(masterUrl)};
15
+ const cluster = globalThis.clusterMock = new EventEmitter();
16
+ cluster.workers = {};
17
+ let forks = 0;
18
+ const kills = [];
19
+ const exited = [];
20
+ function exit(worker, code, signal = null) {
21
+ delete cluster.workers[worker.id];
22
+ exited.push({ id: worker.id, code, signal });
23
+ cluster.emit('exit', worker, code, signal);
24
+ }
25
+ cluster.fork = () => {
26
+ assert.equal(cluster.listenerCount('message'), 1, 'message handler must precede fork');
27
+ assert.equal(cluster.listenerCount('exit'), 1, 'exit handler must precede fork');
28
+ assert.ok(process.listenerCount('SIGINT'));
29
+ assert.ok(process.listenerCount('SIGUSR2'));
30
+ const worker = { id: ++forks, process: { kill(signal) {
31
+ kills.push({ id: worker.id, signal });
32
+ // Synchronous exit also proves reload intent is recorded before kill.
33
+ exit(worker, globalThis.reloadFailure ? 1 : null, globalThis.reloadFailure ? null : signal);
34
+ } } };
35
+ cluster.workers[worker.id] = worker;
36
+ return worker;
37
+ };
38
+ registerHooks({
39
+ resolve(specifier, context, nextResolve) {
40
+ if (context.parentURL === masterUrl) {
41
+ const source = {
42
+ 'node:cluster': 'export default globalThis.clusterMock;',
43
+ 'chalk': 'export default { cyan: message => message };',
44
+ './useApp.ts': 'export function useApp() { return { meta: { env: { LOGGER_DUMMY: "true" } } }; }',
45
+ }[specifier];
46
+ if (source) return { url: 'data:text/javascript,' + encodeURIComponent(source), shortCircuit: true };
47
+ }
48
+ return nextResolve(specifier, context);
49
+ },
50
+ });
51
+ process.on('exit', code => console.log(JSON.stringify({ code, forks, kills, exited })));
52
+ const { handleProcessMaster } = await import(masterUrl);
53
+ handleProcessMaster(2);
54
+ const [first, second] = Object.values(cluster.workers);
55
+ const scenario = process.argv[1];
56
+ switch (scenario) {
57
+ case 'all-clean':
58
+ exit(first, 0);
59
+ assert.equal(Object.keys(cluster.workers).length, 1);
60
+ exit(second, 0);
61
+ break;
62
+ case 'failure-then-clean':
63
+ exit(first, 7);
64
+ assert.equal(Object.keys(cluster.workers).length, 1);
65
+ exit(second, 0);
66
+ break;
67
+ case 'unexpected-signal':
68
+ exit(first, null, 'SIGTERM');
69
+ exit(second, 0);
70
+ break;
71
+ case 'shutdown-SIGINT':
72
+ case 'shutdown-SIGUSR2': {
73
+ const signal = scenario.slice('shutdown-'.length);
74
+ process.emit(signal);
75
+ exit(first, null, signal);
76
+ exit(second, 0);
77
+ break;
78
+ }
79
+ case 'reload':
80
+ case 'reload-failure': {
81
+ globalThis.reloadFailure = scenario === 'reload-failure';
82
+ cluster.emit('message', first, 'reload-worker');
83
+ assert.equal(forks, 3);
84
+ const replacement = cluster.workers[3];
85
+ assert.ok(replacement);
86
+ exit(second, 0);
87
+ exit(replacement, 0);
88
+ break;
89
+ }
90
+ case 'reload-during-shutdown':
91
+ process.emit('SIGUSR2');
92
+ cluster.emit('message', first, 'reload-worker');
93
+ assert.equal(forks, 2, 'shutdown must not create a replacement');
94
+ exit(second, 0);
95
+ break;
96
+ case 'failure-then-shutdown':
97
+ exit(first, 1);
98
+ process.emit('SIGINT');
99
+ exit(second, null, 'SIGINT');
100
+ break;
101
+ case 'failure-during-shutdown':
102
+ process.emit('SIGINT');
103
+ exit(first, 1);
104
+ exit(second, 0);
105
+ break;
106
+ case 'unexpected-signal-during-shutdown':
107
+ process.emit('SIGINT');
108
+ exit(first, null, 'SIGKILL');
109
+ exit(second, 0);
110
+ break;
111
+ default:
112
+ throw new Error('Unknown scenario: ' + scenario);
113
+ }
114
+ throw new Error('Final worker exit must terminate the master');
115
+ `;
116
+
117
+ test('cluster creates the master app before installing handlers and forking workers', () => {
118
+ const source = `
119
+ import assert from 'node:assert/strict';
120
+ import { registerHooks } from 'node:module';
121
+ const clusterUrl = ${JSON.stringify(clusterUrl)};
122
+ const calls = globalThis.calls = [];
123
+ const mocks = {
124
+ 'node:cluster': 'export default { isPrimary: true };',
125
+ './createApp.ts': 'export async function createAppMaster(options) { globalThis.calls.push("create"); await Promise.resolve(); globalThis.masterOptions = options; } export function createApp() { throw new Error("Unexpected worker"); }',
126
+ './processMaster.ts': 'export function handleProcessMaster(workers) { if (!globalThis.masterOptions) throw new Error("Master app not ready"); globalThis.calls.push(["handle", workers]); }',
127
+ './processWorker.ts': 'export function handleProcessWork() { throw new Error("Unexpected worker"); }',
128
+ };
129
+ registerHooks({
130
+ resolve(specifier, context, nextResolve) {
131
+ if (context.parentURL === clusterUrl && mocks[specifier]) {
132
+ return { url: 'data:text/javascript,' + encodeURIComponent(mocks[specifier]), shortCircuit: true };
133
+ }
134
+ return nextResolve(specifier, context);
135
+ },
136
+ });
137
+ const { startCluster } = await import(clusterUrl);
138
+ const options = {};
139
+ await startCluster(2, options);
140
+ assert.equal(globalThis.masterOptions, options);
141
+ assert.deepEqual(calls, ['create', ['handle', 2]]);
142
+ `;
143
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
144
+ encoding: 'utf8',
145
+ timeout: 3_000,
146
+ });
147
+ assert.ifError(result.error);
148
+ assert.equal(result.signal, null, result.stderr);
149
+ assert.equal(result.status, 0, result.stderr);
150
+ });
151
+
152
+ const scenarios = [
153
+ { name: 'all-clean', code: 0 },
154
+ { name: 'failure-then-clean', code: 1 },
155
+ { name: 'unexpected-signal', code: 1 },
156
+ { name: 'shutdown-SIGINT', code: 0 },
157
+ { name: 'shutdown-SIGUSR2', code: 0 },
158
+ { name: 'reload', code: 0, reload: true },
159
+ { name: 'reload-failure', code: 1, reload: true },
160
+ { name: 'reload-during-shutdown', code: 0 },
161
+ { name: 'failure-then-shutdown', code: 1 },
162
+ { name: 'failure-during-shutdown', code: 1 },
163
+ { name: 'unexpected-signal-during-shutdown', code: 1 },
164
+ ];
165
+
166
+ for (const scenario of scenarios) {
167
+ test(`process master: ${scenario.name}`, () => {
168
+ const result = spawnSync(
169
+ process.execPath,
170
+ ['--input-type=module', '--eval', masterSource, scenario.name],
171
+ { encoding: 'utf8', timeout: 3_000 },
172
+ );
173
+ assert.ifError(result.error);
174
+ assert.equal(result.signal, null, result.stderr);
175
+ assert.equal(result.status, scenario.code, result.stderr);
176
+ const summary = JSON.parse(result.stdout);
177
+ assert.equal(summary.code, scenario.code);
178
+ assert.equal(summary.forks, scenario.reload ? 3 : 2);
179
+ assert.equal(summary.exited.length, summary.forks);
180
+ assert.deepEqual(
181
+ summary.kills,
182
+ scenario.name.startsWith('reload') ? [{ id: 1, signal: 'SIGTERM' }] : [],
183
+ );
184
+ });
185
+ }