@ultimat3/cli 19.2.0 → 19.3.1
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/CLAUDE.md +108 -6
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/budgets.ts +17 -6
- package/src/cmd-dev.ts +29 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +5 -1
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +6 -6
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/src/cmd-generate.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { appManifest, writeAppManifest } from './app-manifest';
|
|
|
8
8
|
import { requireAppRoot } from './app-root';
|
|
9
9
|
import type { CliCommand, CommandContext } from './command';
|
|
10
10
|
import { generate } from './generate-files';
|
|
11
|
-
import { GENERATORS, readKind, readName, readSurface } from './generate-kinds';
|
|
11
|
+
import { GENERATORS, readKind, readName, readPermission, readSurface } from './generate-kinds';
|
|
12
12
|
import { containedPath, writeFiles } from './generate-write';
|
|
13
13
|
import { resolveCatalogModule } from './i18n-audit';
|
|
14
14
|
import { syncI18nIndex } from './i18n-index';
|
|
@@ -63,7 +63,10 @@ export const generateCommand: CliCommand = {
|
|
|
63
63
|
const surface = readSurface(flagString(ctx.args, 'surface'), kind, name);
|
|
64
64
|
const locales = resolveLocales(flagList(ctx.args, 'locales'));
|
|
65
65
|
const at = flagString(ctx.args, 'at');
|
|
66
|
-
|
|
66
|
+
// Read with the two above, and refused here for their reason: the value is spliced into the
|
|
67
|
+
// emitted source three times, and a value that is not a `<resource>:<verb>` is a page the app
|
|
68
|
+
// cannot compile — found after the files are on disk, which is the worst place to find it.
|
|
69
|
+
const permission = readPermission(flagString(ctx.args, 'permission'), kind);
|
|
67
70
|
// Read before a file is planned, like the flags above: which module a generated component
|
|
68
71
|
// imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
|
|
69
72
|
const catalogModule = await resolveCatalogModule(root);
|
package/src/cmd-i18n.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// would, and `node:path` because Bun exposes no path API to build what either of them takes.
|
|
9
9
|
import { type FileHandle, mkdir, open } from 'node:fs/promises';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
|
+
import { stringField } from '@ultimat3/core';
|
|
11
12
|
import type { Catalog } from '@ultimat3/i18n';
|
|
12
13
|
import { auditCatalogs, catalogKeys } from '@ultimat3/i18n';
|
|
13
14
|
import { loadApp } from './app-load';
|
|
@@ -40,9 +41,15 @@ export const I18N_SUBCOMMANDS = ['check', 'add', 'sync'] as const;
|
|
|
40
41
|
/** `ExtractReport` is plain JSON by construction — same idiom as `cmd-registries.ts`'s `asJson`. */
|
|
41
42
|
const asJson = (value: object): Record<string, JsonValue> => value as Record<string, JsonValue>;
|
|
42
43
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* `open`'s failure when the file is already there — the one errno this command translates.
|
|
46
|
+
*
|
|
47
|
+
* `stringField`, never `'code' in error && error.code`: `in` narrows for the COMPILER and promises
|
|
48
|
+
* the runtime nothing, so the read still happens on a value this process did not build and a
|
|
49
|
+
* throwing getter takes the command down one line after the guard written to make it safe. The
|
|
50
|
+
* same repair `dev-lock.ts` took for the cast spelling of the identical read.
|
|
51
|
+
*/
|
|
52
|
+
const isAlreadyExists = (error: unknown): boolean => stringField(error, 'code') === 'EEXIST';
|
|
46
53
|
|
|
47
54
|
/**
|
|
48
55
|
* The exclusive half of the create: `wx` fails rather than truncates, so an existing catalog is
|
package/src/cmd-jobs.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
|
|
6
6
|
|
|
7
7
|
import type { JobDriver } from '@ultimat3/jobs';
|
|
8
|
-
import { cancelJob,
|
|
8
|
+
import { cancelJob, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
|
|
9
9
|
import { requireAppRoot } from './app-root';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
11
|
import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
|
|
12
|
+
import type { DrainOutcome } from './jobs-drain';
|
|
12
13
|
import { drainJobs } from './jobs-drain';
|
|
13
14
|
import { withJobDriver } from './jobs-driver';
|
|
14
15
|
import {
|
|
@@ -28,7 +29,32 @@ import { flagBool, flagString } from './parse';
|
|
|
28
29
|
|
|
29
30
|
export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'cancel', 'drain'] as const;
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
|
|
34
|
+
* Closed, and read three ways: the flag summary, the refusal, and the `memory` case below.
|
|
35
|
+
*/
|
|
36
|
+
export const DRAIN_TARGETS = ['redis', 'nats'] as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `memory` was on that list until 2026-09 and could not be: `createMemoryDriver()` is a `Map` in
|
|
40
|
+
* THIS process, so `x jobs drain --to memory` enqueued each job into it and then `ack`ed the
|
|
41
|
+
* durable row off the source. Reproduced against two real drivers — source ready 1 -> 0, target
|
|
42
|
+
* ready 1, `ok: true` — and the target dies with the command. `wiki/CLI-Reference.md` said the
|
|
43
|
+
* crash window "duplicates a job … instead of losing it"; this target lost every one of them.
|
|
44
|
+
*
|
|
45
|
+
* Refused by NAME rather than folded into the unknown-value message, for `cmd-deploy.ts`'s
|
|
46
|
+
* `readMethod` reason: `--to memory` is a spelling that used to work, so a reader who types it is
|
|
47
|
+
* owed the fact that it moved work into a process that is about to exit, not a list of words.
|
|
48
|
+
*/
|
|
49
|
+
function refuseMemoryTarget(): never {
|
|
50
|
+
throw new BadFlagError({
|
|
51
|
+
flag: 'to',
|
|
52
|
+
command: 'jobs',
|
|
53
|
+
reason:
|
|
54
|
+
'memory is a Map inside this process — the drain would ack every durable row and lose the copy when the command exits',
|
|
55
|
+
fix: 'x jobs drain --to redis --json # or --to nats; --dry-run reports the plan and moves nothing',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
32
58
|
|
|
33
59
|
function requireIdPositional(ctx: CommandContext, sub: string): string {
|
|
34
60
|
const id = ctx.args.positionals[0];
|
|
@@ -57,10 +83,13 @@ function requireEnvUrl(env: CommandContext['env'], name: string, target: string)
|
|
|
57
83
|
|
|
58
84
|
/**
|
|
59
85
|
* `redis`/`nats` are honest `X_NOT_IMPLEMENTED` stubs in `@ultimat3/jobs` — building one here is
|
|
60
|
-
* fine even though every `enqueue` on it will fail; `drainJobs` reports that per record.
|
|
86
|
+
* fine even though every `enqueue` on it will fail; `drainJobs` reports that per record. What is
|
|
87
|
+
* NOT fine is a target that accepts every enqueue and then vanishes, which is why `memory` is
|
|
88
|
+
* refused first and by name rather than falling into the closed-set message below.
|
|
61
89
|
* Exported so a test can drive the `--to`/env-var validation without a driver or a boot.
|
|
62
90
|
*/
|
|
63
91
|
export function buildDrainTarget(to: string | undefined, env: CommandContext['env']): JobDriver {
|
|
92
|
+
if (to === 'memory') refuseMemoryTarget();
|
|
64
93
|
if (to === undefined || !(DRAIN_TARGETS as readonly string[]).includes(to)) {
|
|
65
94
|
throw new BadFlagError({
|
|
66
95
|
flag: 'to',
|
|
@@ -68,7 +97,6 @@ export function buildDrainTarget(to: string | undefined, env: CommandContext['en
|
|
|
68
97
|
reason: `expects one of: ${DRAIN_TARGETS.join(', ')}`,
|
|
69
98
|
});
|
|
70
99
|
}
|
|
71
|
-
if (to === 'memory') return createMemoryDriver();
|
|
72
100
|
if (to === 'redis') return createRedisDriver({ url: requireEnvUrl(env, 'REDIS_URL', 'redis') });
|
|
73
101
|
return createNatsDriver({ servers: [requireEnvUrl(env, 'NATS_URL', 'nats')] });
|
|
74
102
|
}
|
|
@@ -174,11 +202,13 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
|
|
|
174
202
|
* A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
|
|
175
203
|
* design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
|
|
176
204
|
* empty a driver, and a partial move that exited 0 would read as "the queue is clear".
|
|
205
|
+
*
|
|
206
|
+
* Exported, and separate from the flag reading above it, because every target the flag now accepts
|
|
207
|
+
* needs a server: a test can produce a real outcome from two drivers and render THAT, where
|
|
208
|
+
* driving the whole command would need a redis or a nats to move anything at all.
|
|
177
209
|
*/
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const dryRun = flagBool(ctx.args, 'dry-run');
|
|
181
|
-
const outcome = await drainJobs(driver, target, dryRun);
|
|
210
|
+
export function drainResult(outcome: DrainOutcome): CommandResult {
|
|
211
|
+
const dryRun = outcome.dryRun;
|
|
182
212
|
const findings = outcome.failures.map((failure) => failure.finding);
|
|
183
213
|
const lines: string[] = [];
|
|
184
214
|
if (outcome.skipped.length > 0) {
|
|
@@ -212,6 +242,15 @@ async function runDrain(driver: JobDriver, ctx: CommandContext): Promise<Command
|
|
|
212
242
|
};
|
|
213
243
|
}
|
|
214
244
|
|
|
245
|
+
/** The move, then the render. The target is built ABOVE `withJobDriver` — see `run` below. */
|
|
246
|
+
async function runDrain(
|
|
247
|
+
driver: JobDriver,
|
|
248
|
+
target: JobDriver,
|
|
249
|
+
ctx: CommandContext,
|
|
250
|
+
): Promise<CommandResult> {
|
|
251
|
+
return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
|
|
252
|
+
}
|
|
253
|
+
|
|
215
254
|
export const jobsCommand: CliCommand = {
|
|
216
255
|
spec: {
|
|
217
256
|
name: 'jobs',
|
|
@@ -245,7 +284,7 @@ export const jobsCommand: CliCommand = {
|
|
|
245
284
|
{
|
|
246
285
|
name: 'to',
|
|
247
286
|
type: 'string',
|
|
248
|
-
summary:
|
|
287
|
+
summary: `drain: target driver — ${DRAIN_TARGETS.join(', ')}`,
|
|
249
288
|
subcommands: ['drain'],
|
|
250
289
|
},
|
|
251
290
|
{
|
|
@@ -259,11 +298,18 @@ export const jobsCommand: CliCommand = {
|
|
|
259
298
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
260
299
|
const root = requireAppRoot('jobs', ctx.cwd).dir;
|
|
261
300
|
const sub = ctx.args.subcommand ?? 'ls';
|
|
301
|
+
// BEFORE `withJobDriver`, which boots the SOURCE queue and pings it. `--to` is a flag, so
|
|
302
|
+
// whether it names a durable driver is answerable with no server at all — and reading it
|
|
303
|
+
// inside meant `x jobs drain --to memory` on a box whose database is down reported the boot
|
|
304
|
+
// failure instead of `X_CLI_BAD_FLAG`, i.e. the operator repaired Postgres to be told the
|
|
305
|
+
// word they typed was refused by name. It also opens a connection to a target the command
|
|
306
|
+
// then refuses, which is a socket nothing closes.
|
|
307
|
+
const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
|
|
262
308
|
return withJobDriver(root, ctx, (driver) => {
|
|
263
309
|
if (sub === 'show') return runShow(driver, ctx);
|
|
264
310
|
if (sub === 'retry') return runRetry(driver, ctx);
|
|
265
311
|
if (sub === 'cancel') return runCancel(driver, ctx);
|
|
266
|
-
if (
|
|
312
|
+
if (target !== null) return runDrain(driver, target, ctx);
|
|
267
313
|
return runLs(driver, ctx);
|
|
268
314
|
});
|
|
269
315
|
},
|
package/src/cmd-test.ts
CHANGED
|
@@ -104,8 +104,12 @@ export const testCommand: CliCommand = {
|
|
|
104
104
|
name: 'test',
|
|
105
105
|
summary:
|
|
106
106
|
'run one test type — or the whole suite — across N workers, one isolated database per worker',
|
|
107
|
-
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json]`,
|
|
107
|
+
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json] [-- <bun test flags>]`,
|
|
108
108
|
positionalChoices: TEST_TYPES,
|
|
109
|
+
// The one command that hands a tail to another tool — `bun test` — and the reason
|
|
110
|
+
// `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
|
|
111
|
+
// dropped both, so a run that measured no coverage reported exactly what a coverage run does.
|
|
112
|
+
passthrough: true,
|
|
109
113
|
flags: [
|
|
110
114
|
{
|
|
111
115
|
name: 'workers',
|
|
@@ -173,15 +177,13 @@ export const testCommand: CliCommand = {
|
|
|
173
177
|
}
|
|
174
178
|
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
175
179
|
const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// profile. Neither is visible without a real `TEST_DATABASE_URL`, which is why the split
|
|
184
|
-
// measured green for as long as it did.
|
|
180
|
+
// The width a `--worker` index is judged against, and nothing else: which files really run one
|
|
181
|
+
// at a time is `test-passes.ts`, off the FILES rather than off the positional. This line read
|
|
182
|
+
// the positional alone until 2026-09 and the comment here claimed it made a serial type serial
|
|
183
|
+
// — true for `x test live`, false for the bare `x test --workers 8` that selects every type,
|
|
184
|
+
// which ran the same `live` and `e2e` files eight at a time. It stays because a `--worker` run
|
|
185
|
+
// is one process per CI JOB: sharding a serial type across N of them is the cluster-wide race
|
|
186
|
+
// in a second disguise, and refusing the index is where that is caught.
|
|
185
187
|
const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
|
|
186
188
|
const workers = Math.max(1, Math.min(requested, ceiling));
|
|
187
189
|
const only = readIndex(ctx.args, 'worker', 0);
|
|
@@ -208,6 +210,9 @@ export const testCommand: CliCommand = {
|
|
|
208
210
|
// corpus, so its shard 2 is a different shard 2 — reproducing nothing, which is the one
|
|
209
211
|
// thing `reproduceFor` exists to prevent.
|
|
210
212
|
...(scope === undefined ? {} : { affected: scope.selection }),
|
|
213
|
+
// The fifth input to the split, and the one a rerun most obviously needs: `--coverage`
|
|
214
|
+
// changes what a run measures, and the reproduce line carries it back out.
|
|
215
|
+
...(ctx.args.passthrough.length === 0 ? {} : { passthrough: ctx.args.passthrough }),
|
|
211
216
|
});
|
|
212
217
|
return scope === undefined ? result : withScope(result, scope);
|
|
213
218
|
},
|
package/src/db-seed.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { isSeed, SEED_TIERS, seedTiersFor } from '@ultimat3/entity';
|
|
|
15
15
|
import { BadFlagError } from './errors';
|
|
16
16
|
import type { Finding, JsonValue } from './output';
|
|
17
17
|
import { findingFrom } from './output';
|
|
18
|
+
import { hasPathSegment } from './path-segments';
|
|
18
19
|
import { renderTable } from './table';
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -101,7 +102,7 @@ export async function discoverSeeds(root: string): Promise<SeedDiscovery> {
|
|
|
101
102
|
const seen = new Set<string>();
|
|
102
103
|
for (const pattern of SEED_GLOBS) {
|
|
103
104
|
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
104
|
-
if (absolute
|
|
105
|
+
if (hasPathSegment(absolute, 'node_modules') || absolute.includes('.test.')) continue;
|
|
105
106
|
if (seen.has(absolute)) continue;
|
|
106
107
|
seen.add(absolute);
|
|
107
108
|
const file = relative(root, absolute).split(sep).join('/');
|
package/src/dev-queue.ts
CHANGED
|
@@ -78,8 +78,16 @@ export interface RunningQueue {
|
|
|
78
78
|
* Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
|
|
79
79
|
* it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
|
|
80
80
|
* so `DATABASE_REPLICA_URL` was read by no booted process at all.
|
|
81
|
+
*
|
|
82
|
+
* `env` is REQUIRED, and that is the repair: it defaulted to `process.env` and `startQueue` passed
|
|
83
|
+
* nothing, so the standby was decided from the process while the middleware that opens the
|
|
84
|
+
* `withReplicaReads` scope was decided from the boot's own `options.env` (`cmd-dev.ts`,
|
|
85
|
+
* `serve.ts`). Two sources for one question answer differently the moment a boot is handed an
|
|
86
|
+
* environment it did not inherit — a routed client with no scope, or a scope with no standby, and
|
|
87
|
+
* neither reports anything. A default here is what let the caller forget; the type is what stops
|
|
88
|
+
* the next one. Exported for the test that proves which environment decides.
|
|
81
89
|
*/
|
|
82
|
-
function startDb(services: DevServices, env: ReplicaEnv
|
|
90
|
+
export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
83
91
|
const binding = services.db;
|
|
84
92
|
const client =
|
|
85
93
|
binding.mode === 'embedded'
|
|
@@ -218,8 +226,14 @@ async function releaseQueue(
|
|
|
218
226
|
export async function startQueue(
|
|
219
227
|
services: DevServices,
|
|
220
228
|
overrides?: RuntimeOverrides,
|
|
229
|
+
/**
|
|
230
|
+
* The boot's own environment — `x dev`'s, the container role's, the CLI command's `ctx.env`.
|
|
231
|
+
* `process.env` is the default for a caller that has no other answer, and it is the ONLY place
|
|
232
|
+
* this file reads it: see `startDb` for what two readers of one question cost.
|
|
233
|
+
*/
|
|
234
|
+
env: ReplicaEnv = process.env,
|
|
221
235
|
): Promise<RunningQueue> {
|
|
222
|
-
const { client: db, replica } = startDb(services);
|
|
236
|
+
const { client: db, replica } = startDb(services, env);
|
|
223
237
|
try {
|
|
224
238
|
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
225
239
|
// fails at boot rather than on some later query.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// One rebuild at a time. A reload is a full `appManifest()` plus a `buildIslands()` over every
|
|
2
|
+
// island, and the watcher had no in-flight guard: a 45ms drip of writes — a slow `git checkout`, a
|
|
3
|
+
// formatter walking the tree, `x db gen` — started one per file, 40 for 40 files, each assigning
|
|
4
|
+
// the same two state slots in COMPLETION order. So a slower earlier rebuild could land on top of a
|
|
5
|
+
// newer one, and the dev server then served a manifest built from source that had already changed.
|
|
6
|
+
|
|
7
|
+
/** What a tick does while a rebuild is already running: nothing, except become the next one. */
|
|
8
|
+
export type ReloadTrigger = (file: string) => void;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Serialise `run`, keeping only the LAST tick that arrived while it was busy. N ticks during one
|
|
12
|
+
* rebuild are exactly one more rebuild, for the newest file — never N queued ones, and never a
|
|
13
|
+
* dropped tick, which would leave the process serving the state before the author's last save.
|
|
14
|
+
*
|
|
15
|
+
* `onError` defaults to swallowing, because the caller that cares reports its own failure as a
|
|
16
|
+
* finding; a rejection escaping here would be an unhandled rejection that takes `x dev` down.
|
|
17
|
+
*/
|
|
18
|
+
export function coalesceReloads(
|
|
19
|
+
run: (file: string) => Promise<void> | void,
|
|
20
|
+
onError: (error: unknown, file: string) => void = () => undefined,
|
|
21
|
+
): ReloadTrigger {
|
|
22
|
+
let running = false;
|
|
23
|
+
let pending: string | undefined;
|
|
24
|
+
|
|
25
|
+
const start = (file: string): void => {
|
|
26
|
+
running = true;
|
|
27
|
+
// `Promise.resolve().then` rather than a bare call: a SYNCHRONOUS throw from `run` would
|
|
28
|
+
// otherwise escape the fs callback that triggered it, where nothing is listening.
|
|
29
|
+
void (async () => await run(file))()
|
|
30
|
+
.catch((error: unknown) => onError(error, file))
|
|
31
|
+
.finally(() => {
|
|
32
|
+
running = false;
|
|
33
|
+
const next = pending;
|
|
34
|
+
pending = undefined;
|
|
35
|
+
if (next !== undefined) start(next);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (file: string): void => {
|
|
40
|
+
if (running) {
|
|
41
|
+
pending = file;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
start(file);
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/dev-runtime.ts
CHANGED
|
@@ -299,7 +299,10 @@ export async function startServices(
|
|
|
299
299
|
// `@ultimat3/realtime`'s decision, and it is the same call a `ROLE=sync` container makes, so this
|
|
300
300
|
// process cannot resolve the bus differently from the container it stands in for.
|
|
301
301
|
const bus: TransportSelection = selectTransport(env);
|
|
302
|
-
|
|
302
|
+
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
303
|
+
// reader here already uses it, so a queue that asked `process.env` would decide the standby from
|
|
304
|
+
// a different answer than the middleware that routes to it.
|
|
305
|
+
const queue = await startQueue(services, overrides, env);
|
|
303
306
|
const { db, jobs, outbox, events } = queue;
|
|
304
307
|
// The same executor the jobs driver, the outbox, the event bus and the idempotency store run
|
|
305
308
|
// on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
package/src/dev-sync.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
SocketRegistry,
|
|
16
16
|
} from '@ultimat3/realtime/server';
|
|
17
17
|
import type { StartRolesOptions } from './dev-roles';
|
|
18
|
-
import { neighbouringPort, PORT_RANGE } from './flag-number';
|
|
18
|
+
import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
|
|
19
19
|
import { portFree } from './port-probe';
|
|
20
20
|
import { syncAuthenticator } from './sync-authenticator';
|
|
21
21
|
import { DEV_BINDING } from './web-binding';
|
|
@@ -53,7 +53,11 @@ class SyncPortInUseError extends UltimateError {
|
|
|
53
53
|
super({
|
|
54
54
|
code: 'X_PORT_IN_USE',
|
|
55
55
|
cause: `the sync role binds PORT + 1, so \`x dev --port ${input.webPort}\` needs port ${input.port} and something is already listening on it`,
|
|
56
|
-
|
|
56
|
+
// `portPairAfter`, never `neighbouringPort`: `x dev` binds a PAIR, so the neighbour of the
|
|
57
|
+
// web port IS the sync port this refusal is about — the fix said `x dev --port 4000` for a
|
|
58
|
+
// run that had just died on 4000, and a test named "its fix is a command that ends the
|
|
59
|
+
// failure" pinned it.
|
|
60
|
+
fix: `x dev --port ${portPairAfter(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
|
|
57
61
|
meta: { port: input.port, webPort: input.webPort },
|
|
58
62
|
});
|
|
59
63
|
}
|
|
@@ -183,7 +187,11 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
183
187
|
// so the one socket that streams live database patches was the one socket on every
|
|
184
188
|
// interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
|
|
185
189
|
const binding = options.http ?? DEV_BINDING;
|
|
186
|
-
|
|
190
|
+
// No drain grace: there is one node here and it is the one going away. Its clients reconnect
|
|
191
|
+
// to it when `x dev` is back, and a grace that kept their patches flowing meanwhile was five
|
|
192
|
+
// seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a reconnect frame
|
|
193
|
+
// whose target does not exist yet.
|
|
194
|
+
const listener = listenSyncNode(node, { port, hostname: binding.hostname, drainGraceMs: 0 });
|
|
187
195
|
return {
|
|
188
196
|
url: listener.url,
|
|
189
197
|
registry,
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The app root watched one directory at a time, because the watch SET is a registration decision
|
|
2
|
+
// and not a filter. `watch(root, { recursive: true })` takes an inotify descriptor per directory
|
|
3
|
+
// in the tree — including every directory the dev loop then discards events from — so `.git/`,
|
|
4
|
+
// `node_modules/` and `.x/` (which this very process writes to, continuously) each cost a
|
|
5
|
+
// descriptor, a kernel queue entry and a JS callback per write, against a per-user descriptor
|
|
6
|
+
// budget of 8192 on many distributions. Bun 1.4.0's `fs.watch` takes no ignore option, so the
|
|
7
|
+
// only place the answer can be given early is at registration.
|
|
8
|
+
|
|
9
|
+
// why: Bun exposes no filesystem watcher, no directory listing and no synchronous stat —
|
|
10
|
+
// `Bun.file().exists()` is async and answers false for a DIRECTORY, which is the one thing this
|
|
11
|
+
// module has to decide. Delete each when Bun ships an equivalent.
|
|
12
|
+
import type { Dirent } from 'node:fs';
|
|
13
|
+
import { readdirSync, statSync, watch } from 'node:fs';
|
|
14
|
+
// why: Bun exposes no path-join primitive.
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { finiteCount, logger } from '@ultimat3/core';
|
|
17
|
+
import type { DevIgnore } from './dev-watch';
|
|
18
|
+
import { devIgnore } from './dev-watch';
|
|
19
|
+
import { pathSegments } from './path-segments';
|
|
20
|
+
|
|
21
|
+
/** What `node:fs`'s watcher hands a listener — `filename` is optional in fact, not only in type. */
|
|
22
|
+
export type WatchListener = (event: string, filename: string | Buffer | null | undefined) => void;
|
|
23
|
+
|
|
24
|
+
/** The one thing this module needs of a watcher, so a test can stand one up in four lines. */
|
|
25
|
+
export interface DirectoryWatcher {
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type WatchDirectory = (directory: string, listener: WatchListener) => DirectoryWatcher;
|
|
30
|
+
|
|
31
|
+
export interface WatchTreeOptions {
|
|
32
|
+
readonly root: string;
|
|
33
|
+
/** A source change, app-root-relative and POSIX-separated. Debounced. */
|
|
34
|
+
readonly onChange: (file: string) => void;
|
|
35
|
+
/** Trailing debounce. A save touching five files is one reload, not five. Default 30ms. */
|
|
36
|
+
readonly debounceMs?: number;
|
|
37
|
+
/** Test seam: the default is `node:fs`'s `watch(dir, { recursive: false })`. */
|
|
38
|
+
readonly watchDirectory?: WatchDirectory;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WatchTree {
|
|
42
|
+
/** Directories holding a descriptor right now, app-root-relative; `''` is the app root. */
|
|
43
|
+
directories(): readonly string[];
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_DEBOUNCE_MS = 30;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every directory under `root` an event could be a source change in, `''` first. Ignored
|
|
51
|
+
* directories are not descended, so a `node_modules` tree costs one `readdir` and nothing else.
|
|
52
|
+
* A symlinked directory is deliberately not followed: `Dirent.isDirectory()` is false for one, and
|
|
53
|
+
* a workspace symlink pointing back into the checkout would otherwise be walked twice.
|
|
54
|
+
*/
|
|
55
|
+
export function admittedDirectories(root: string, ignore: DevIgnore): readonly string[] {
|
|
56
|
+
const admitted: string[] = [''];
|
|
57
|
+
const pending: string[] = [''];
|
|
58
|
+
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
|
59
|
+
for (const entry of childrenOf(join(root, next))) {
|
|
60
|
+
if (!entry.isDirectory()) continue;
|
|
61
|
+
const child = next === '' ? entry.name : `${next}/${entry.name}`;
|
|
62
|
+
if (ignore.ignores(child, true)) continue;
|
|
63
|
+
admitted.push(child);
|
|
64
|
+
pending.push(child);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return admitted.sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One directory's entries, or none. A directory removed between the listing above and this read, or
|
|
72
|
+
* one this user may not open, is neither a source change nor a finding anyone can act on.
|
|
73
|
+
*/
|
|
74
|
+
function childrenOf(path: string): readonly Dirent[] {
|
|
75
|
+
try {
|
|
76
|
+
return readdirSync(path, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nodeWatch: WatchDirectory = (directory, listener) =>
|
|
83
|
+
watch(directory, { recursive: false }, listener);
|
|
84
|
+
|
|
85
|
+
/** Whether the path is a directory right now — the question a `rename` event does not answer. */
|
|
86
|
+
function isDirectoryAt(path: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
return statSync(path).isDirectory();
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function watchTree(options: WatchTreeOptions): WatchTree {
|
|
95
|
+
const { root, onChange } = options;
|
|
96
|
+
// Screened before a single descriptor is taken. `??` guards NULLISH and `NaN` is not nullish, so
|
|
97
|
+
// an unparsed value walks past the default into `setTimeout(fn, NaN)`, which coerces to **0**:
|
|
98
|
+
// the debounce reads as installed and every keystroke runs a full `appManifest()` plus
|
|
99
|
+
// `buildIslands()`. `0` is admitted — "rebuild on the next tick" is a decision — and `Infinity`
|
|
100
|
+
// is not, because a reload that never fires is the same defect facing the other way.
|
|
101
|
+
const debounceMs = finiteCount(
|
|
102
|
+
'watchTree',
|
|
103
|
+
'debounceMs',
|
|
104
|
+
options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
105
|
+
);
|
|
106
|
+
const open = new WatchDirectoryMap(options.watchDirectory ?? nodeWatch, root);
|
|
107
|
+
let ignore = devIgnore(root);
|
|
108
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
109
|
+
let last = '';
|
|
110
|
+
let warned = false;
|
|
111
|
+
|
|
112
|
+
const schedule = (file: string): void => {
|
|
113
|
+
last = file;
|
|
114
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
115
|
+
timer = setTimeout(() => onChange(last), debounceMs);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const listen = (directory: string): void => {
|
|
119
|
+
open.add(directory, (event, filename) => {
|
|
120
|
+
if (typeof filename !== 'string' && !(filename instanceof Buffer)) {
|
|
121
|
+
// Bun's watcher delivers no filename when the WATCHED directory itself moves or is removed
|
|
122
|
+
// — `mv myapp myapp2`, a re-clone, a volume remount. Once, because the same rename can
|
|
123
|
+
// arrive on every descriptor at the same instant.
|
|
124
|
+
if (!warned) logger.warn('dev.watch.unnamed_event', { directory: directory || '.' });
|
|
125
|
+
warned = true;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const name = pathSegments(filename.toString()).join('/');
|
|
129
|
+
const file = directory === '' ? name : `${directory}/${name}`;
|
|
130
|
+
// Asked of the DISK, once, because every trailing-slash rule in a `.gitignore` turns on it
|
|
131
|
+
// and a `rename` says only that something moved. One stat against a whole rebuild.
|
|
132
|
+
const isDirectory = open.has(file) || isDirectoryAt(join(root, file));
|
|
133
|
+
if (event === 'rename') follow(file, isDirectory);
|
|
134
|
+
if (file === '.gitignore') {
|
|
135
|
+
// The ignore set is the app's own, so an edit to it changes which directories are watched
|
|
136
|
+
// at all. Not a source change: rebuilding the manifest for it would be a reload the author
|
|
137
|
+
// did not ask for on the one file whose whole job is saying what to leave alone.
|
|
138
|
+
ignore = devIgnore(root);
|
|
139
|
+
reconcile();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (ignore.ignores(file, isDirectory)) return;
|
|
143
|
+
schedule(file);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** A `rename` created or removed something: keep the descriptor set honest either way. */
|
|
148
|
+
const follow = (file: string, isDirectory: boolean): void => {
|
|
149
|
+
if (isDirectory && isDirectoryAt(join(root, file))) {
|
|
150
|
+
if (ignore.ignores(file, true)) return;
|
|
151
|
+
if (!open.has(file)) for (const found of subtree(file)) listen(found);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (open.has(file)) open.remove(file);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** The admitted directories at or under `directory`, discovered rather than assumed. */
|
|
158
|
+
const subtree = (directory: string): readonly string[] =>
|
|
159
|
+
admittedDirectories(join(root, directory), ignore).map((found) =>
|
|
160
|
+
found === '' ? directory : `${directory}/${found}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/** The whole set, re-derived: a directory the author just ignored gives its descriptor back. */
|
|
164
|
+
const reconcile = (): void => {
|
|
165
|
+
const admitted = new Set(admittedDirectories(root, ignore));
|
|
166
|
+
for (const held of open.directories()) if (!admitted.has(held)) open.remove(held);
|
|
167
|
+
for (const directory of admitted) if (!open.has(directory)) listen(directory);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const directory of admittedDirectories(root, ignore)) listen(directory);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
directories: () => open.directories(),
|
|
174
|
+
close(): void {
|
|
175
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
176
|
+
open.closeAll();
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The descriptors this watcher holds, keyed by app-root-relative directory. Its own type because
|
|
183
|
+
* removing one means removing everything under it: a deleted directory takes its children's
|
|
184
|
+
* descriptors with it, and a `Map` iterated by the caller would leak every one of them.
|
|
185
|
+
*/
|
|
186
|
+
class WatchDirectoryMap {
|
|
187
|
+
readonly #watchers = new Map<string, DirectoryWatcher>();
|
|
188
|
+
readonly #watch: WatchDirectory;
|
|
189
|
+
readonly #root: string;
|
|
190
|
+
|
|
191
|
+
constructor(watchDirectory: WatchDirectory, root: string) {
|
|
192
|
+
this.#watch = watchDirectory;
|
|
193
|
+
this.#root = root;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
add(directory: string, listener: WatchListener): void {
|
|
197
|
+
if (this.#watchers.has(directory)) return;
|
|
198
|
+
try {
|
|
199
|
+
this.#watchers.set(directory, this.#watch(join(this.#root, directory), listener));
|
|
200
|
+
} catch {
|
|
201
|
+
// A directory that vanished between the walk and the registration. The parent's own
|
|
202
|
+
// descriptor still reports anything that reappears there.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
has(directory: string): boolean {
|
|
207
|
+
return this.#watchers.has(directory);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
directories(): readonly string[] {
|
|
211
|
+
return [...this.#watchers.keys()].sort();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
remove(directory: string): void {
|
|
215
|
+
for (const held of this.#watchers.keys()) {
|
|
216
|
+
if (held !== directory && !held.startsWith(`${directory}/`)) continue;
|
|
217
|
+
this.#watchers.get(held)?.close();
|
|
218
|
+
this.#watchers.delete(held);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
closeAll(): void {
|
|
223
|
+
for (const watcher of this.#watchers.values()) watcher.close();
|
|
224
|
+
this.#watchers.clear();
|
|
225
|
+
}
|
|
226
|
+
}
|