cabloy 5.1.205 → 5.1.207
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/.cabloy-version +1 -1
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/repo-agent-governance/managed-assets.json +3 -3
- package/repo-agent-governance/policies/repository.md +1 -1
- package/repo-docs/backend/migration-and-changes.md +27 -8
- package/repo-docs/reference/repo-scripts.md +20 -1
- package/scripts/init.ts +15 -0
- package/scripts/runIsolatedTest.test.ts +21 -0
- package/scripts/runIsolatedTest.ts +14 -6
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +17 -2
- package/vona/packages-cli/cli-set-api/test/binDevLifecycle.test.ts +100 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/framework/cluster.ts +1 -1
- package/vona/packages-vona/vona-core/src/lib/framework/processMaster.test.ts +185 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processMaster.ts +18 -7
- package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +127 -0
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +77 -42
- package/vona/src/suite-vendor/a-vona/modules/a-redis/test/redisCleanup.test.ts +378 -0
- package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +56 -9
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/types/startup.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +104 -0
- package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +183 -51
- package/vona/src/suite-vendor/a-vona/modules/a-version/test/devDatabasePreparation.test.ts +221 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.207
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.1.207
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add update functionality.
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- Fix database reset behavior.
|
|
12
|
+
|
|
13
|
+
### Improvements
|
|
14
|
+
|
|
15
|
+
- Enhance and refactor database reset functionality.
|
|
16
|
+
|
|
17
|
+
## 5.1.206
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
- Update functionality (details unspecified).
|
|
22
|
+
|
|
23
|
+
### Improvements
|
|
24
|
+
|
|
25
|
+
- Update the pnpm dependency lockfile.
|
|
26
|
+
|
|
3
27
|
## 5.1.205
|
|
4
28
|
|
|
5
29
|
### Features
|
package/package.json
CHANGED
|
@@ -683,7 +683,7 @@
|
|
|
683
683
|
{
|
|
684
684
|
"adapter": "cursor",
|
|
685
685
|
"category": "policy",
|
|
686
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
@@ -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
|
|
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
|
+
);
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
createTestRunId,
|
|
33
33
|
getUnitTestCommands,
|
|
34
34
|
reserveTestRun,
|
|
35
|
+
resolveCabloyEdition,
|
|
35
36
|
ROOT_DIR,
|
|
36
37
|
runIsolatedProcess,
|
|
37
38
|
runIsolatedTest,
|
|
@@ -79,6 +80,26 @@ test('public test scripts delegate to the isolated runner without recursion', ()
|
|
|
79
80
|
]);
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
test('accepts either Cabloy edition and rejects ambiguous or missing markers', () => {
|
|
84
|
+
const root = mkdtempSync(join(tmpdir(), 'cabloy-run-edition-'));
|
|
85
|
+
const basicMarker = join(root, '__CABLOY_BASIC__');
|
|
86
|
+
const startMarker = join(root, '__CABLOY_START__');
|
|
87
|
+
try {
|
|
88
|
+
assert.throws(() => resolveCabloyEdition(root), /Not a Cabloy project/);
|
|
89
|
+
|
|
90
|
+
writeFileSync(basicMarker, '');
|
|
91
|
+
assert.equal(resolveCabloyEdition(root), 'basic');
|
|
92
|
+
|
|
93
|
+
writeFileSync(startMarker, '');
|
|
94
|
+
assert.throws(() => resolveCabloyEdition(root), /Ambiguous Cabloy edition/);
|
|
95
|
+
|
|
96
|
+
rmSync(basicMarker);
|
|
97
|
+
assert.equal(resolveCabloyEdition(root), 'start');
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
82
103
|
test('creates a UTC timestamp and eight random hex digits for one run identity', () => {
|
|
83
104
|
const date = new Date('2026-10-14T01:02:03.456Z');
|
|
84
105
|
assert.match(createTestRunId(date), /^20261014010203-[a-f0-9]{8}$/);
|
|
@@ -313,13 +313,21 @@ export function getUnitTestCommands(args: string[]) {
|
|
|
313
313
|
];
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
export
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
316
|
+
export function resolveCabloyEdition(rootDir = ROOT_DIR): 'basic' | 'start' {
|
|
317
|
+
const hasBasic = existsSync(resolve(rootDir, '__CABLOY_BASIC__'));
|
|
318
|
+
const hasStart = existsSync(resolve(rootDir, '__CABLOY_START__'));
|
|
319
|
+
if (hasBasic === hasStart) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
hasBasic
|
|
322
|
+
? 'Ambiguous Cabloy edition: both __CABLOY_BASIC__ and __CABLOY_START__ markers are present'
|
|
323
|
+
: 'Not a Cabloy project: no __CABLOY_BASIC__ or __CABLOY_START__ marker found',
|
|
324
|
+
);
|
|
322
325
|
}
|
|
326
|
+
return hasBasic ? 'basic' : 'start';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function runIsolatedTest(kind: 'unit' | 'e2e', args: string[]) {
|
|
330
|
+
resolveCabloyEdition();
|
|
323
331
|
if (process.env.E2E_BASE_URL !== undefined) {
|
|
324
332
|
throw new Error(
|
|
325
333
|
'E2E_BASE_URL is unsupported; the E2E runner manages a local test-mode target.',
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|
|
@@ -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
|
+
}
|