@ultimat3/cli 27.10.3 → 27.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "27.10.3",
3
+ "version": "27.11.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "27.10.3",
43
- "@ultimat3/admin": "27.10.3",
44
- "@ultimat3/ai": "27.10.3",
45
- "@ultimat3/auth": "27.10.3",
46
- "@ultimat3/cache": "27.10.3",
47
- "@ultimat3/core": "27.10.3",
48
- "@ultimat3/db": "27.10.3",
49
- "@ultimat3/entity": "27.10.3",
50
- "@ultimat3/flags": "27.10.3",
51
- "@ultimat3/http": "27.10.3",
52
- "@ultimat3/i18n": "27.10.3",
53
- "@ultimat3/jobs": "27.10.3",
54
- "@ultimat3/mail": "27.10.3",
55
- "@ultimat3/manifest": "27.10.3",
56
- "@ultimat3/mcp": "27.10.3",
57
- "@ultimat3/money": "27.10.3",
58
- "@ultimat3/notify": "27.10.3",
59
- "@ultimat3/policy": "27.10.3",
60
- "@ultimat3/pwa": "27.10.3",
61
- "@ultimat3/query": "27.10.3",
62
- "@ultimat3/realtime": "27.10.3",
63
- "@ultimat3/render": "27.10.3",
64
- "@ultimat3/schema": "27.10.3",
65
- "@ultimat3/seo": "27.10.3",
66
- "@ultimat3/storage": "27.10.3",
67
- "@ultimat3/testing": "27.10.3",
68
- "@ultimat3/time": "27.10.3",
42
+ "@ultimat3/action": "27.11.0",
43
+ "@ultimat3/admin": "27.11.0",
44
+ "@ultimat3/ai": "27.11.0",
45
+ "@ultimat3/auth": "27.11.0",
46
+ "@ultimat3/cache": "27.11.0",
47
+ "@ultimat3/core": "27.11.0",
48
+ "@ultimat3/db": "27.11.0",
49
+ "@ultimat3/entity": "27.11.0",
50
+ "@ultimat3/flags": "27.11.0",
51
+ "@ultimat3/http": "27.11.0",
52
+ "@ultimat3/i18n": "27.11.0",
53
+ "@ultimat3/jobs": "27.11.0",
54
+ "@ultimat3/mail": "27.11.0",
55
+ "@ultimat3/manifest": "27.11.0",
56
+ "@ultimat3/mcp": "27.11.0",
57
+ "@ultimat3/money": "27.11.0",
58
+ "@ultimat3/notify": "27.11.0",
59
+ "@ultimat3/policy": "27.11.0",
60
+ "@ultimat3/pwa": "27.11.0",
61
+ "@ultimat3/query": "27.11.0",
62
+ "@ultimat3/realtime": "27.11.0",
63
+ "@ultimat3/render": "27.11.0",
64
+ "@ultimat3/schema": "27.11.0",
65
+ "@ultimat3/seo": "27.11.0",
66
+ "@ultimat3/storage": "27.11.0",
67
+ "@ultimat3/testing": "27.11.0",
68
+ "@ultimat3/time": "27.11.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -1,7 +1,8 @@
1
- // The two ways a boot meets the framework schema (plan 101, s1-con #5): `applyLockedSchema` runs the
2
- // DDL in ONE transaction behind the migration advisory lock with a bounded `lock_timeout`, then
1
+ // The three ways a boot meets the framework schema (plan 101, s1-con #5): `applyLockedSchema` runs
2
+ // the DDL in ONE transaction behind the migration advisory lock with a bounded `lock_timeout`, then
3
3
  // stamps the build; `verifySchema` runs no DDL and refuses a database this build was never applied
4
- // to. Which boot takes which is `runtime-queue.ts`'s `SchemaMode`.
4
+ // to; `ensureSchema` reads the stamp and applies only when this build is absent from it. Which boot
5
+ // takes which is `runtime-queue.ts`'s `SchemaMode`.
5
6
 
6
7
  import { frameworkVersion, logger } from '@ultimat3/core';
7
8
  import type { DbClient } from '@ultimat3/db';
@@ -70,9 +71,39 @@ export async function verifySchema(client: DbClient): Promise<void> {
70
71
  const mine = thisBuild();
71
72
  const verdict = stampVerdict(parseStamp(await readStamp(client)), mine);
72
73
  if (!verdict.applied) throw new FrameworkSchemaUnappliedError({ mine, newest: verdict.newest });
73
- if (verdict.skew === undefined) return;
74
+ reportSkew(mine, verdict.skew);
75
+ }
76
+
77
+ /**
78
+ * A CLI command's boot — `x db backfill`, `x jobs`, `x auth`: no DDL when this build's stamp is
79
+ * already there, the locked apply when it is not.
80
+ *
81
+ * The stamp is read FIRST because re-applying idempotent DDL is not idempotent for every role.
82
+ * Postgres checks ownership before existence, so `create index if not exists` on a table the role
83
+ * does not own is `42501 must be owner` even when the index is there. A production runtime role
84
+ * holds DML only, and the PostSync backfill Job ran as it: every deploy's `x db backfill --all
85
+ * --write` died in the jobs entry and no sweep was ever enqueued. When the stamp is absent the
86
+ * apply still runs, so a fresh dev database needs no separate step — and a role that cannot apply
87
+ * it is refused with `X_FRAMEWORK_SCHEMA_FAILED`, whose fix is the migrate step.
88
+ */
89
+ export async function ensureSchema(client: DbClient): Promise<void> {
90
+ const mine = thisBuild();
91
+ const verdict = stampVerdict(parseStamp(await readStamp(client)), mine);
92
+ if (!verdict.applied) {
93
+ await applyLockedSchema(client);
94
+ return;
95
+ }
96
+ reportSkew(mine, verdict.skew);
97
+ }
98
+
99
+ /**
100
+ * A fleet straddling a major is reported once and served — refusing it would crash-loop the old
101
+ * pods of every rolling deploy across a breaking release.
102
+ */
103
+ function reportSkew(mine: StampEntry, skew: StampEntry | undefined): void {
104
+ if (skew === undefined) return;
74
105
  logger.warn('ultimate framework major skew', {
75
- cause: `this process runs ${mine.version} and the database's framework schema was last applied by ${verdict.skew.version}: a fleet across a major boundary is mid-rollout or half rolled back`,
106
+ cause: `this process runs ${mine.version} and the database's framework schema was last applied by ${skew.version}: a fleet across a major boundary is mid-rollout or half rolled back`,
76
107
  fix: 'kubectl rollout status deployment --selector app.kubernetes.io/component=web',
77
108
  });
78
109
  }
@@ -1,7 +1,7 @@
1
1
  // The framework's OWN tables, as one table: which package declares each, which relations it
2
2
  // creates, and the DDL. One list, read by the one applier, so "installed in dev and not in
3
- // production" is not a state this framework can be in — `startQueue` is on every boot path there
4
- // is, `ROLE=migrate` included.
3
+ // production" is not a state this framework can be in: `ROLE=migrate` applies it and stamps the
4
+ // build, and every other boot path meets it through `startQueue`'s `SchemaMode`.
5
5
  //
6
6
  // A framework table an app has to install by hand is a table that will be missing in production on
7
7
  // the one code path that needs it, and it surfaces as a Postgres `42P01` from inside a worker.
package/src/hold.ts CHANGED
@@ -3,7 +3,15 @@
3
3
  // before the exit code. Ctrl-C then takes core's own three-phase drain (stop accepting, finish
4
4
  // in-flight, close) instead of killing a query mid-round-trip.
5
5
 
6
- import { drain, installSignalHandlers, logger, onShutdown, systemClock } from '@ultimat3/core';
6
+ import {
7
+ drain,
8
+ installSignalHandlers,
9
+ isDraining,
10
+ isUltimateError,
11
+ logger,
12
+ onShutdown,
13
+ systemClock,
14
+ } from '@ultimat3/core';
7
15
 
8
16
  export interface HoldOptions {
9
17
  /**
@@ -129,7 +137,24 @@ export async function holdWhileBooting<T extends { stop(): Promise<void> }>(
129
137
  try {
130
138
  app = await boot();
131
139
  } catch (error) {
132
- armed.disarm();
140
+ // A boot that failed BECAUSE a drain began under it — a rollout superseding a pod still
141
+ // booting, whose server then met `markReady()`'s `X_LIFECYCLE_DRAINED` — is a pod doing what
142
+ // it was told: finish the drain, say so once at info, exit 0. It printed a stack trace and
143
+ // exited non-zero, twice a week in production, paging on a normal rollout. A failure with no
144
+ // drain under way is still the boot's own refusal, rethrown untouched.
145
+ if (isDraining() && options.exit !== undefined) {
146
+ await drain();
147
+ armed.disarm();
148
+ logger.info('ultimate boot interrupted by shutdown', {
149
+ name,
150
+ code: isUltimateError(error) ? error.code : 'uncoded',
151
+ });
152
+ options.exit(0);
153
+ } else {
154
+ armed.disarm();
155
+ }
156
+ // Unreachable past a real `process.exit`; a test's `exit` spy returns, and the caller still
157
+ // sees the boot's error rather than an app that never booted.
133
158
  throw error;
134
159
  }
135
160
  booted = app;
@@ -23,6 +23,7 @@ import type { AppConfig, CacheTierName } from '@ultimat3/core';
23
23
  import {
24
24
  backoffDelay,
25
25
  defineConfig,
26
+ finiteCount,
26
27
  isOutageMilestone,
27
28
  logger,
28
29
  renderThrowable,
@@ -63,8 +64,23 @@ export interface CacheTiersOptions {
63
64
  readonly tiers: readonly CacheTierName[];
64
65
  /** The wait before re-subscribing after `attempt` failures (1-based). Injected by a test. */
65
66
  readonly subscribeRetryMs?: ((attempt: number) => number) | undefined;
67
+ /**
68
+ * How long the boot subscribe may be refused before a refusal is worth a warning. Default
69
+ * `FIRST_DIAL_GRACE_MS`; a test of an OUTAGE passes 0.
70
+ */
71
+ readonly firstDialGraceMs?: number | undefined;
66
72
  }
67
73
 
74
+ /**
75
+ * The boot subscribe races the bus's FIRST dial, and loses it on almost every pod: in production
76
+ * every pod warned on attempts 1–2 and was subscribed within 1.3 s — 345 lines in three weeks of
77
+ * alerting noise. Refusals inside this window are not reported; a bus still refusing after it is
78
+ * an outage and warns as before (attempt milestones, then `recovered` at info). 10 s is several
79
+ * times the slowest first dial measured, and far under any window in which a missed peer bust
80
+ * matters: the process is flushed (`flushDeaf`) when the subscribe lands either way.
81
+ */
82
+ export const FIRST_DIAL_GRACE_MS = 10_000;
83
+
68
84
  /** 1 s doubling to 30 s, full jitter: a bus that is down is retried without a fleet in lockstep. */
69
85
  const defaultSubscribeRetryMs = (attempt: number): number =>
70
86
  backoffDelay({ attempt, base: 1_000, max: 30_000, jitter: 'full' });
@@ -163,6 +179,13 @@ function warnUnnamed(options: CacheTiersOptions): void {
163
179
  * suppresses it, and `emit` is not a public parameter.
164
180
  */
165
181
  export function startCacheTiers(options: CacheTiersOptions): () => Promise<void> {
182
+ // Every option checked before anything is registered: a refusal after `registerTier` or
183
+ // `registerInvalidationBroadcast` would leave process-global state installed with no release.
184
+ const graceMs = finiteCount(
185
+ 'startCacheTiers',
186
+ 'firstDialGraceMs',
187
+ options.firstDialGraceMs ?? FIRST_DIAL_GRACE_MS,
188
+ );
166
189
  // Built before ANY of them is registered: a refusal halfway through a list would leave the
167
190
  // process-global registry holding the rungs that came first, with no release returned to drop
168
191
  // them. Registration order does not decide read order — `sortTiers` does, by `CACHE_TIERS`.
@@ -276,6 +299,9 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
276
299
  const flushDeaf = (source: string): void => {
277
300
  void flushProcessTiers(source);
278
301
  };
302
+ const bootedAt = performance.now();
303
+ /** Whether an operator has been told of this outage — what makes `recovered` worth a line. */
304
+ let warned = false;
279
305
  const subscribing = (async (): Promise<TransportSubscription | undefined> => {
280
306
  for (let attempt = 1; !stopped; attempt += 1) {
281
307
  try {
@@ -287,15 +313,22 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
287
313
  );
288
314
  subscribed = true;
289
315
  if (attempt > 1) {
290
- // The outage's one closing line, beside the thinned warnings it ends.
291
- logger.info('cache.broadcast.subscribe-failed recovered', { after: attempt - 1 });
316
+ // The outage's one closing line, beside the thinned warnings it ends — and only then: a
317
+ // first dial that landed inside its window had no outage to close.
318
+ if (warned) {
319
+ logger.info('cache.broadcast.subscribe-failed recovered', { after: attempt - 1 });
320
+ }
292
321
  flushDeaf('cache.bus-subscribed');
293
322
  }
294
323
  return subscription;
295
324
  } catch (error) {
296
325
  // Attempts 1, 2, 4, 8, …: the dial's own thinning (`NatsTransport`), so an outage is not
297
326
  // one line per retry per pod for as long as it lasts.
298
- if (isOutageMilestone(attempt)) {
327
+ // Silent inside the first dial's window. Past it, the FIRST refusal warns whatever its
328
+ // attempt number, then the milestones thin the rest.
329
+ const inGrace = performance.now() - bootedAt < graceMs;
330
+ if (!inGrace && (!warned || isOutageMilestone(attempt))) {
331
+ warned = true;
299
332
  logger.warn('cache.broadcast.subscribe-failed', {
300
333
  error: broadcastErrorText(error),
301
334
  attempt,
@@ -0,0 +1,69 @@
1
+ // `jobs.doneRetentionMs`, `jobs.deadRetentionMs` and `jobs.outboxRetentionMs`, out of the config
2
+ // `startServices` loads once (`app-config-load.ts`), as the windows the `x_jobs` and `x_outbox`
3
+ // purge targets are built with. The `notify` windows' sibling (`runtime-notify-retention.ts`), with
4
+ // one difference: these DEFAULT, because a finished job is bookkeeping and an unread message is not.
5
+
6
+ import type { AppConfig, PgExecutor } from '@ultimat3/core';
7
+ import {
8
+ finiteCount,
9
+ JOBS_DEAD_RETENTION_MS_DEFAULT,
10
+ JOBS_DONE_RETENTION_MS_DEFAULT,
11
+ JOBS_OUTBOX_RETENTION_MS_DEFAULT,
12
+ } from '@ultimat3/core';
13
+ import type { PurgeTarget } from '@ultimat3/jobs';
14
+ import { jobsPurgeTarget, outboxPurgeTarget } from '@ultimat3/jobs';
15
+
16
+ export interface JobsRetention {
17
+ readonly doneRetentionMs: number;
18
+ readonly deadRetentionMs: number;
19
+ readonly outboxRetentionMs: number;
20
+ }
21
+
22
+ const SUBJECT = 'jobsRetentionOf';
23
+
24
+ /**
25
+ * The app's windows, or core's defaults for a boot with no config file or a hand-built
26
+ * `AppConfig` that left a key out (`JobsConfig` declares the three optional) — the same constants
27
+ * `defineConfig` fills in, so an app without `app.config.ts` keeps finished jobs exactly as long as
28
+ * one that never wrote the keys. Core's validator refuses a bad window in a loaded file; this
29
+ * screen is the layered form for the config that never met it.
30
+ */
31
+ export function jobsRetentionOf(config: AppConfig | undefined): JobsRetention {
32
+ const jobs = config?.jobs;
33
+ return {
34
+ doneRetentionMs: finiteCount(
35
+ SUBJECT,
36
+ 'jobs.doneRetentionMs',
37
+ jobs?.doneRetentionMs ?? JOBS_DONE_RETENTION_MS_DEFAULT,
38
+ 1,
39
+ ),
40
+ deadRetentionMs: finiteCount(
41
+ SUBJECT,
42
+ 'jobs.deadRetentionMs',
43
+ jobs?.deadRetentionMs ?? JOBS_DEAD_RETENTION_MS_DEFAULT,
44
+ 1,
45
+ ),
46
+ outboxRetentionMs: finiteCount(
47
+ SUBJECT,
48
+ 'jobs.outboxRetentionMs',
49
+ jobs?.outboxRetentionMs ?? JOBS_OUTBOX_RETENTION_MS_DEFAULT,
50
+ 1,
51
+ ),
52
+ };
53
+ }
54
+
55
+ /** The two queue-table targets `installRetentionSweep` takes, over the boot's own executor. */
56
+ export function jobsRetentionTargets(
57
+ executor: PgExecutor,
58
+ config: AppConfig | undefined,
59
+ ): { readonly jobs: PurgeTarget; readonly outbox: PurgeTarget } {
60
+ const windows = jobsRetentionOf(config);
61
+ return {
62
+ jobs: jobsPurgeTarget({
63
+ executor,
64
+ doneRetentionMs: windows.doneRetentionMs,
65
+ deadRetentionMs: windows.deadRetentionMs,
66
+ }),
67
+ outbox: outboxPurgeTarget({ executor, publishedRetentionMs: windows.outboxRetentionMs }),
68
+ };
69
+ }
@@ -1,11 +1,11 @@
1
1
  // The retention sweep this boot owns: the framework tables that grow with traffic, the `purge()`
2
2
  // job that empties them and the `task` that fires it hourly.
3
3
  //
4
- // SEVEN TARGETS over seven tables, `As of 2026-10-02` — `x_idempotency`, `x_rate_limit`, the
5
- // `x_auth` limiter's table, `x_notify_deliveries`, `x_notify_inbox`, `x_notify_digests` and
6
- // `x_job_events`. Counted nowhere in prose but here: this header said "three" for two releases
7
- // after the notify tables joined the boot's DDL, which is how `x_notify_inbox` became the one
8
- // framework table nothing swept.
4
+ // NINE TARGETS, `As of 2026-10-17` — `x_idempotency`, `x_rate_limit`, the `x_auth` limiter's
5
+ // table, `x_notify_deliveries`, `x_notify_inbox`, `x_notify_digests`, `x_job_events`, `x_outbox`
6
+ // (published rows) and `x_jobs` (finished rows, with their `x_job_steps`). Counted nowhere in prose but here: this header said
7
+ // "three" for two releases after the notify tables joined the boot's DDL, which is how
8
+ // `x_notify_inbox` became the one framework table nothing swept — and `x_jobs` was the next one.
9
9
  //
10
10
  // WHY here and not in the packages that own the tables: `postgresIdempotencyStore` (tier 3),
11
11
  // `postgresRateLimitStore` (tier 2) and `postgresAuthLimiter` (tier 2) cannot see each other and
@@ -58,6 +58,15 @@ export interface RetentionStores {
58
58
  * growing for the life of the deployment — which it did until this member existed.
59
59
  */
60
60
  readonly events: EventBus;
61
+ /**
62
+ * Finished jobs — `jobsPurgeTarget` over this boot's executor, with the app's
63
+ * `jobs.doneRetentionMs` / `jobs.deadRetentionMs`. REQUIRED for `events`' reason: until it
64
+ * existed `x_jobs` and `x_job_steps` kept every job ever run, `mail.send`'s recipient and body
65
+ * among them.
66
+ */
67
+ readonly jobs: PurgeTarget;
68
+ /** Published outbox rows — `outboxPurgeTarget` at `jobs.outboxRetentionMs`. REQUIRED, as `jobs`. */
69
+ readonly outbox: PurgeTarget;
61
70
  /**
62
71
  * The app's own answer to "how long is an unread message kept", read off the config
63
72
  * `startServices` loads (`inboxRetentionOf`). Absent windows mean the inbox is never swept,
@@ -143,6 +152,11 @@ function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
143
152
  ...notifyTargets(stores.inboxRetention ?? NO_INBOX_RETENTION),
144
153
  // Its own clock, and so no `nowMs`: every instant in that table is the database's.
145
154
  eventsPurgeTarget(stores.events),
155
+ // Last: the two widest tables, batched, so every small sweep above has run before they spend
156
+ // the pass. Either order is safe — the outbox sweep takes only PUBLISHED rows, and the `x_jobs`
157
+ // sweep vetoes only a job whose outbox row is still unpublished.
158
+ stores.outbox,
159
+ stores.jobs,
146
160
  ];
147
161
  }
148
162
 
@@ -35,7 +35,7 @@ import {
35
35
  setJobDriver,
36
36
  setJobsFacade,
37
37
  } from '@ultimat3/jobs';
38
- import { applyLockedSchema, verifySchema } from './framework-schema-apply';
38
+ import { applyLockedSchema, ensureSchema, verifySchema } from './framework-schema-apply';
39
39
  import { appExtensions } from './migration-extensions';
40
40
  import type { DevServices } from './runtime-bindings';
41
41
  import type { RuntimeOverrides } from './runtime-overrides';
@@ -124,15 +124,20 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
124
124
  * schema — the same reason `SQL_JOBS_TABLE` is applied by the boot.
125
125
  */
126
126
  /**
127
- * `'apply'` — `ROLE=migrate`, `x dev` and every CLI command — runs the DDL under the migration
128
- * lock with a bounded `lock_timeout` and stamps the build. `'verify'` — a serving role on an
129
- * external database — runs none: an `alter table` queued behind one open transaction stalled every
130
- * enqueue in the fleet on each pod start, and racing `create table`s were a first-deploy `23505`.
127
+ * `'apply'` — `ROLE=migrate`, `x db migrate`, `x dev` and `x mcp serve` — runs the DDL under the
128
+ * migration lock with a bounded `lock_timeout` and stamps the build. `'verify'` — a serving role on
129
+ * an external database — runs none: an `alter table` queued behind one open transaction stalled
130
+ * every enqueue in the fleet on each pod start, and racing `create table`s were a first-deploy
131
+ * `23505`. `'ensure'` — every other CLI command, `x db backfill` and `x jobs` among them — runs
132
+ * none when this build's stamp is present and applies otherwise: those commands run as the
133
+ * runtime role in production, which owns no framework table and so cannot re-run even
134
+ * `if not exists` DDL (`framework-schema-apply.ts`).
131
135
  */
132
- export type SchemaMode = 'apply' | 'verify';
136
+ export type SchemaMode = 'apply' | 'ensure' | 'verify';
133
137
 
134
138
  async function applySchema(client: DevDbClient, mode: SchemaMode): Promise<void> {
135
139
  if (mode === 'verify') await verifySchema(client);
140
+ else if (mode === 'ensure') await ensureSchema(client);
136
141
  else await applyLockedSchema(client);
137
142
  }
138
143
  /**
@@ -248,7 +253,11 @@ export async function startQueue(
248
253
  * this file reads it: see `startDb` for what two readers of one question cost.
249
254
  */
250
255
  env: ReplicaEnv = process.env,
251
- schema: SchemaMode = 'apply',
256
+ /**
257
+ * `'ensure'` unless the caller IS the step that applies: a command that only reads or enqueues
258
+ * must not need the privileges of the migrate role (`SchemaMode`).
259
+ */
260
+ schema: SchemaMode = 'ensure',
252
261
  ): Promise<RunningQueue> {
253
262
  const { client: db, replica } = startDb(services, env);
254
263
  try {
@@ -37,6 +37,7 @@ import type { DevServices, Env } from './runtime-bindings';
37
37
  import { selectBus } from './runtime-bus';
38
38
  import { cacheTiersOf, startCacheTiers } from './runtime-cache';
39
39
  import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
40
+ import { jobsRetentionTargets } from './runtime-jobs-retention';
40
41
  import { selectAppMailDriver } from './runtime-mail';
41
42
  import { inboxRetentionOf } from './runtime-notify-retention';
42
43
  import type { RuntimeOverrides } from './runtime-overrides';
@@ -403,6 +404,7 @@ export async function startServices(
403
404
  idempotency: queue.idempotency,
404
405
  rateLimit: rateLimitStore,
405
406
  events,
407
+ ...jobsRetentionTargets(executor, config),
406
408
  inboxRetention: inboxRetentionOf(config),
407
409
  }),
408
410
  );
@@ -18,10 +18,13 @@ export class FrameworkSchemaFailedError extends UltimateError {
18
18
  super({
19
19
  code: 'X_FRAMEWORK_SCHEMA_FAILED',
20
20
  cause: `${input.pkg} could not create ${input.tables.join(', ')}: ${renderThrowable(input.cause)}`,
21
- // An EDIT plus the command that CONFIRMS it, which is the house shape for a repair the gate
22
- // cannot perform: the two real causes are a role without `create`, and a relation of that
23
- // name already present with an incompatible shape.
24
- fix: `grant create on schema public to the role in DATABASE_URL, then re-run: x db migrate`,
21
+ // The migrate step, as the role that owns the schema. The common cause is `42501`: a runtime
22
+ // role with DML only — no `create` on `public`, and not the owner of an existing framework
23
+ // table, which Postgres demands even of `create index if not exists`. Granting that role
24
+ // `create` (this line's old advice) cures the first and never the second, and widens the
25
+ // runtime role besides. The rarer cause, a relation of that name with another shape, is
26
+ // named in `cause` and reported again by the same command.
27
+ fix: 'x db migrate # with DATABASE_URL naming the role that owns schema public, as ROLE=migrate runs',
25
28
  meta: { pkg: input.pkg, tables: [...input.tables] },
26
29
  });
27
30
  }
package/src/serve.ts CHANGED
@@ -48,10 +48,13 @@ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-t
48
48
  * is what fails on it.
49
49
  */
50
50
  export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
51
+ // `'apply'`, never the stamp-first default: this IS the step that applies, and an operator
52
+ // re-running it is how a dropped framework table comes back.
51
53
  const queue = await startQueue(
52
54
  resolveServices(options.root, options.env),
53
55
  options.runtime,
54
56
  options.env,
57
+ 'apply',
55
58
  );
56
59
  try {
57
60
  const migrations = await readMigrations(options.root);
@@ -87,12 +87,28 @@ export interface InFlightRun {
87
87
  readonly command: readonly string[];
88
88
  /** Everything it had printed before the kill; absent when it did not settle after it. */
89
89
  readonly output?: string;
90
+ /** How long it had been running when the deadline passed. */
91
+ readonly runningMs?: number;
90
92
  }
91
93
 
94
+ /**
95
+ * The runs a step had already seen settle when its deadline passed. A batched step (a coverage
96
+ * run's slices) that finished ninety of them did not hang — it outgrew its deadline — and a
97
+ * finding that cannot tell the two apart sends its reader hunting a stuck file that is not there.
98
+ * A `type`: it rides in `Finding.meta`, which an interface is not assignable to.
99
+ */
100
+ export type StepProgress = {
101
+ readonly runs: number;
102
+ /** The longest any of them took, as its runner reported it. */
103
+ readonly slowestMs: number;
104
+ };
105
+
92
106
  /** What a step was doing when its deadline passed — `verify-stalled.ts` turns it into the finding. */
93
107
  export interface StepExpiry {
94
108
  readonly killed: readonly KilledProcess[];
95
109
  readonly inFlight: readonly InFlightRun[];
110
+ /** Absent is none: a step that never started a child, or a fixture that does not say. */
111
+ readonly finished?: StepProgress;
96
112
  }
97
113
 
98
114
  /** A killed child's pipes close with it; this is only the bound on a runner that is not `exec`. */
@@ -138,7 +154,10 @@ export function guardStep(
138
154
  ): StepGuard {
139
155
  let expired = false;
140
156
  // Keyed by the promise itself: two batches of one step may run the very same argv.
141
- const inFlight = new Map<Promise<ExecResult>, readonly string[]>();
157
+ const inFlight = new Map<Promise<ExecResult>, Waiting>();
158
+ // Settled runs, counted only before the deadline: a kill settles the rest, and those are not
159
+ // progress.
160
+ let finished = { runs: 0, slowestMs: 0 };
142
161
  // Every child's pid, recorded the moment it exists and dropped when its run settles (a settled
143
162
  // pid may be reused). Windows reads no process's environment, so this is the step's only handle.
144
163
  const spawned = new Map<number, readonly string[]>();
@@ -169,9 +188,16 @@ export function guardStep(
169
188
  options.onSpawn?.(spawnedPid);
170
189
  },
171
190
  });
172
- inFlight.set(run, command);
191
+ inFlight.set(run, { command, startedAt: performance.now() });
173
192
  try {
174
- return await run;
193
+ const result = await run;
194
+ if (!expired) {
195
+ finished = {
196
+ runs: finished.runs + 1,
197
+ slowestMs: Math.max(finished.slowestMs, result.durationMs),
198
+ };
199
+ }
200
+ return result;
175
201
  } finally {
176
202
  inFlight.delete(run);
177
203
  if (pid !== undefined) spawned.delete(pid);
@@ -179,11 +205,16 @@ export function guardStep(
179
205
  },
180
206
  async expire(): Promise<StepExpiry> {
181
207
  expired = true;
208
+ const progress = finished.runs === 0 ? {} : { finished };
182
209
  // Before the kill: a killed child settles its runner call, which takes it off the map.
183
- const waiting = [...inFlight];
210
+ const at = performance.now();
211
+ const waiting = [...inFlight].map(
212
+ ([run, { command, startedAt }]) =>
213
+ [run, command, Math.round(at - startedAt)] as const satisfies WaitingRun,
214
+ );
184
215
  if (windows) {
185
216
  const killed = await killTrees(spawned, platform.killTree ?? taskkillTree);
186
- return { killed, inFlight: await settleAll(waiting) };
217
+ return { killed, inFlight: await settleAll(waiting), ...progress };
187
218
  }
188
219
  const workers = await killTagged(tag, undefined, isTestWorker);
189
220
  if (workers.length > 0) {
@@ -193,19 +224,27 @@ export function guardStep(
193
224
  const killed = [...workers, ...rest.filter((one) => workers.every((w) => w.pid !== one.pid))];
194
225
  // After it: the kill closed the child's pipes, so its call now resolves with everything it
195
226
  // had printed — for `bun test`, the crash line of each file a worker was holding.
196
- return { killed, inFlight: await settleAll(waiting) };
227
+ return { killed, inFlight: await settleAll(waiting), ...progress };
197
228
  },
198
229
  };
199
230
  }
200
231
 
232
+ interface Waiting {
233
+ readonly command: readonly string[];
234
+ readonly startedAt: number;
235
+ }
236
+
237
+ /** A run the deadline found in flight: its call, its argv, and how long it had been running. */
238
+ type WaitingRun = readonly [Promise<ExecResult>, readonly string[], number];
239
+
201
240
  /** What each child had printed, once the kill closed its pipes — or just its argv past `SETTLE_MS`. */
202
- function settleAll(
203
- waiting: readonly (readonly [Promise<ExecResult>, readonly string[]])[],
204
- ): Promise<readonly InFlightRun[]> {
241
+ function settleAll(waiting: readonly WaitingRun[]): Promise<readonly InFlightRun[]> {
205
242
  return Promise.all(
206
- waiting.map(async ([run, command]): Promise<InFlightRun> => {
243
+ waiting.map(async ([run, command, runningMs]): Promise<InFlightRun> => {
207
244
  const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
208
- return raced === undefined || raced.timedOut ? { command } : { command, output: raced.value };
245
+ return raced === undefined || raced.timedOut
246
+ ? { command, runningMs }
247
+ : { command, output: raced.value, runningMs };
209
248
  }),
210
249
  );
211
250
  }
@@ -5,7 +5,7 @@
5
5
  import { ERROR_DOCS_URL } from '@ultimat3/core';
6
6
  import type { Finding } from './output';
7
7
  import { quoteArg } from './shell-quote';
8
- import type { InFlightRun, StepExpiry } from './verify-deadline';
8
+ import type { InFlightRun, StepExpiry, StepProgress } from './verify-deadline';
9
9
  import { STEP_TIMEOUT_FIELD } from './verify-deadline';
10
10
  import type { VerifyStepName } from './verify-step';
11
11
 
@@ -16,12 +16,14 @@ import type { VerifyStepName } from './verify-step';
16
16
  export type StalledRun = {
17
17
  /** The argv without its file list: `bun test --parallel=3`. */
18
18
  readonly command: string;
19
- /** Files the run was handed; 0 for a serial run, which selects by filter. */
19
+ /** Files the run was handed by name; 0 for a serial run that selects by filter. */
20
20
  readonly files: number;
21
21
  /** `--parallel`'s width, and 1 for a serial run. */
22
22
  readonly workers: number;
23
23
  /** The files that were still running, as bun named them. Empty when it named none. */
24
24
  readonly stuck: readonly string[];
25
+ /** How long it had been running when it was stopped. */
26
+ readonly runningMs?: number;
25
27
  };
26
28
 
27
29
  /** `X_VERIFY_STEP_TIMEOUT`'s `meta`. A `type`: an interface is not assignable to `Finding.meta`. */
@@ -30,6 +32,8 @@ export type StepTimeoutMeta = {
30
32
  readonly deadlineMs: number;
31
33
  readonly killed: readonly { readonly pid: number; readonly command: string }[];
32
34
  readonly inFlight: readonly StalledRun[];
35
+ /** The runs that had finished before the deadline — the step's progress. */
36
+ readonly finished: StepProgress;
33
37
  };
34
38
 
35
39
  /**
@@ -59,15 +63,18 @@ export function stalledRun(run: InFlightRun): StalledRun | undefined {
59
63
  const width = rest.find((arg) => arg.startsWith(PARALLEL));
60
64
  const flags = rest.filter((arg) => arg.startsWith('--') && !arg.startsWith('--timings'));
61
65
  const command = ['bun', 'test', ...flags].join(' ');
66
+ const age = run.runningMs === undefined ? {} : { runningMs: run.runningMs };
62
67
  if (width === undefined) {
63
68
  const last = lines.findLastIndex((line) => FILE_LINE.test(line));
64
69
  const started = last < 0 ? undefined : FILE_LINE.exec(lines[last] ?? '')?.[1];
65
70
  const failed = lines.slice(last + 1).some((line) => line.startsWith('(fail)'));
66
71
  return {
67
72
  command,
68
- files: 0,
73
+ // A coverage slice names its files; a serial suite selects by a filter that is not a path.
74
+ files: rest.filter((arg) => TEST_FILE.test(arg)).length,
69
75
  workers: 1,
70
76
  stuck: started === undefined || failed ? [] : [started],
77
+ ...age,
71
78
  };
72
79
  }
73
80
  const files = rest.filter((arg) => !arg.startsWith('--'));
@@ -81,9 +88,32 @@ export function stalledRun(run: InFlightRun): StalledRun | undefined {
81
88
  files: files.length,
82
89
  workers: Math.max(1, Number(width.slice(PARALLEL.length)) || 1),
83
90
  stuck: [...new Set(crashed)],
91
+ ...age,
84
92
  };
85
93
  }
86
94
 
95
+ const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
96
+
97
+ /**
98
+ * A batched step that had finished runs, none of those still in flight older than twice the
99
+ * slowest that finished, was making progress when its deadline passed: it outgrew the deadline
100
+ * at this width, and no file is stuck. Anything older than that is read as the hang it may be.
101
+ */
102
+ const PROGRESS_SLACK = 2;
103
+
104
+ export function outgrewDeadline(runs: readonly StalledRun[], expiry: StepExpiry): boolean {
105
+ const finished = expiry.finished;
106
+ if (finished === undefined || finished.runs === 0) return false;
107
+ if (runs.some((run) => run.stuck.length > 0)) return false;
108
+ const bound = finished.slowestMs * PROGRESS_SLACK;
109
+ return expiry.inFlight.every((run) => run.runningMs !== undefined && run.runningMs <= bound);
110
+ }
111
+
112
+ const seconds = (ms: number): string => String(Math.round(ms / 1000));
113
+
114
+ const oldest = (expiry: StepExpiry): number =>
115
+ Math.max(0, ...expiry.inFlight.map((run) => run.runningMs ?? 0));
116
+
87
117
  /** How many paths a `cause:` spells out — the rest are in `meta`. */
88
118
  const NAMED_IN_CAUSE = 5;
89
119
 
@@ -99,6 +129,10 @@ export function stalledOutput(expiry: StepExpiry): string | undefined {
99
129
  }
100
130
 
101
131
  const inFlightClause = (runs: readonly StalledRun[], expiry: StepExpiry): string => {
132
+ if (outgrewDeadline(runs, expiry)) {
133
+ const finished = expiry.finished as StepProgress;
134
+ return `; it was not hung: ${String(finished.runs)} run(s) it started had finished (the slowest in ${seconds(finished.slowestMs)} s) and the longest still running had been going ${seconds(oldest(expiry))} s — the step needs longer than its deadline at this width`;
135
+ }
102
136
  const files = runs.flatMap((run) => run.stuck);
103
137
  const named = runs.find((run) => run.stuck.length > 0);
104
138
  if (named !== undefined) {
@@ -110,7 +144,7 @@ const inFlightClause = (runs: readonly StalledRun[], expiry: StepExpiry): string
110
144
  if (waiting === undefined) return '';
111
145
  return runs[0] === undefined
112
146
  ? `; it was waiting on: ${waiting.command.slice(0, 4).join(' ')}`
113
- : `; its running "${runs[0].command}" named no file still in flight, so the run itself did not exit`;
147
+ : `; its running "${runs[0].command}" named no file still in flight, so the run itself did not exit${waiting.runningMs === undefined ? '' : ` after ${seconds(waiting.runningMs)} s`}`;
114
148
  };
115
149
 
116
150
  /**
@@ -137,12 +171,15 @@ export const stepTimeoutFinding = (
137
171
  deadlineMs: ms,
138
172
  killed: expiry.killed.map((one) => ({ pid: one.pid, command: one.command })),
139
173
  inFlight: runs,
174
+ finished: expiry.finished ?? { runs: 0, slowestMs: 0 },
140
175
  };
176
+ const wider = `${command} --only ${step} --workers <n> --json`;
141
177
  return {
142
178
  code: 'X_VERIFY_STEP_TIMEOUT',
143
179
  cause: `step "${step}" did not finish within its ${String(ms)} ms deadline, so it was stopped and ${String(expiry.killed.length)} process(es) it had started were killed${inFlightClause(runs, expiry)}`,
144
- fix:
145
- stuck.length > 0
180
+ fix: outgrewDeadline(runs, expiry)
181
+ ? `${wider} # it outgrew its deadline at this width, so run it wider; or raise the deadline in x.verify.json — add "${STEP_TIMEOUT_FIELD}": { "${step}": <milliseconds> } above the ${String(ms)} it was given`
182
+ : stuck.length > 0
146
183
  ? `${alone} # what had not finished, alone; the whole step: ${rerun}; ${longer}`
147
184
  : `${rerun} # reproduce it alone; ${longer}`,
148
185
  docs: ERROR_DOCS_URL,