@ultimat3/cli 27.10.4 → 27.12.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.4",
3
+ "version": "27.12.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.4",
43
- "@ultimat3/admin": "27.10.4",
44
- "@ultimat3/ai": "27.10.4",
45
- "@ultimat3/auth": "27.10.4",
46
- "@ultimat3/cache": "27.10.4",
47
- "@ultimat3/core": "27.10.4",
48
- "@ultimat3/db": "27.10.4",
49
- "@ultimat3/entity": "27.10.4",
50
- "@ultimat3/flags": "27.10.4",
51
- "@ultimat3/http": "27.10.4",
52
- "@ultimat3/i18n": "27.10.4",
53
- "@ultimat3/jobs": "27.10.4",
54
- "@ultimat3/mail": "27.10.4",
55
- "@ultimat3/manifest": "27.10.4",
56
- "@ultimat3/mcp": "27.10.4",
57
- "@ultimat3/money": "27.10.4",
58
- "@ultimat3/notify": "27.10.4",
59
- "@ultimat3/policy": "27.10.4",
60
- "@ultimat3/pwa": "27.10.4",
61
- "@ultimat3/query": "27.10.4",
62
- "@ultimat3/realtime": "27.10.4",
63
- "@ultimat3/render": "27.10.4",
64
- "@ultimat3/schema": "27.10.4",
65
- "@ultimat3/seo": "27.10.4",
66
- "@ultimat3/storage": "27.10.4",
67
- "@ultimat3/testing": "27.10.4",
68
- "@ultimat3/time": "27.10.4",
42
+ "@ultimat3/action": "27.12.0",
43
+ "@ultimat3/admin": "27.12.0",
44
+ "@ultimat3/ai": "27.12.0",
45
+ "@ultimat3/auth": "27.12.0",
46
+ "@ultimat3/cache": "27.12.0",
47
+ "@ultimat3/core": "27.12.0",
48
+ "@ultimat3/db": "27.12.0",
49
+ "@ultimat3/entity": "27.12.0",
50
+ "@ultimat3/flags": "27.12.0",
51
+ "@ultimat3/http": "27.12.0",
52
+ "@ultimat3/i18n": "27.12.0",
53
+ "@ultimat3/jobs": "27.12.0",
54
+ "@ultimat3/mail": "27.12.0",
55
+ "@ultimat3/manifest": "27.12.0",
56
+ "@ultimat3/mcp": "27.12.0",
57
+ "@ultimat3/money": "27.12.0",
58
+ "@ultimat3/notify": "27.12.0",
59
+ "@ultimat3/policy": "27.12.0",
60
+ "@ultimat3/pwa": "27.12.0",
61
+ "@ultimat3/query": "27.12.0",
62
+ "@ultimat3/realtime": "27.12.0",
63
+ "@ultimat3/render": "27.12.0",
64
+ "@ultimat3/schema": "27.12.0",
65
+ "@ultimat3/seo": "27.12.0",
66
+ "@ultimat3/storage": "27.12.0",
67
+ "@ultimat3/testing": "27.12.0",
68
+ "@ultimat3/time": "27.12.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);
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { REQUIRED_BUN } from '../../app-root';
13
13
  import type { GeneratedFile, NameSet } from '../naming';
14
+ import { TEST_COMPOSE_PATH, TEST_ENV_PATH } from '../scaffold-test-services';
14
15
  import { IMAGE_WORKFLOW_PATH, imageWorkflow } from './image.yml';
15
16
  import { RUNS_ON } from './runner';
16
17
 
@@ -39,9 +40,9 @@ const ci = (app: NameSet): string => `name: ${CI_WORKFLOW_NAME}
39
40
  # \`.x/build-stats.json\`, so a run with no build reports X_BUDGET_UNMEASURED. Run \`x verify\` here
40
41
  # instead and this workflow is red for a reason that has nothing to do with the commit.
41
42
  #
42
- # No \`services:\` block, deliberately — \`bun run setup\` brings up embedded Postgres in-process,
43
- # so there is nothing to provision and nothing to wait on. Add one the day this app needs a real
44
- # server, beside the \`DATABASE_URL\` that selects it.
43
+ # \`bun run setup\` brings up embedded Postgres in-process; the TEST services come from
44
+ # \`docker/docker-compose.test.yml\` — Postgres 18, Redis, NATS and S3 in RAM, durability and logs
45
+ # off — started by the same two lines a laptop runs, never a \`services:\` block that restates them.
45
46
 
46
47
  # EVERY push, not just the default branch's. \`x new\` runs a plain \`git init\` and takes whatever
47
48
  # \`init.defaultBranch\` this machine already agreed on, so a \`branches: [main]\` filter silently
@@ -90,6 +91,9 @@ jobs:
90
91
  restore-keys: bun-\${{ runner.os }}-
91
92
  # Two steps rather than one \`&&\`, so the log names which half failed and times each.
92
93
  - run: bun run setup
94
+ # \`--wait\` returns once every healthcheck passes; the env file points the suites at them.
95
+ - run: docker compose -f ${TEST_COMPOSE_PATH} up -d --wait
96
+ - run: cat ${TEST_ENV_PATH} >> "$GITHUB_ENV"
93
97
  - run: bun run check
94
98
  `;
95
99
 
@@ -81,11 +81,13 @@ x-run-once-probe: &run-once-probe
81
81
 
82
82
  services:
83
83
  db:
84
- image: postgres:17-alpine
84
+ # Postgres 18 images keep the cluster under /var/lib/postgresql/18/docker, so the volume is the
85
+ # parent — mounted at .../data, as for 17, the image refuses to start.
86
+ image: postgres:18-alpine
85
87
  environment:
86
88
  POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
87
89
  POSTGRES_DB: ${app.kebab}
88
- volumes: ['pgdata:/var/lib/postgresql/data']
90
+ volumes: ['pgdata:/var/lib/postgresql']
89
91
  healthcheck:
90
92
  test: ['CMD-SHELL', 'pg_isready -U postgres']
91
93
  interval: 5s
@@ -11,6 +11,7 @@ import type { GeneratedFile, NameSet } from './naming';
11
11
  import { binFiles } from './scaffold-bin';
12
12
  import { claudeFiles } from './scaffold-claude';
13
13
  import { containerFiles } from './scaffold-container';
14
+ import { testServicesFiles } from './scaffold-test-services';
14
15
 
15
16
  const agents = (app: NameSet): string => `# AGENTS.md
16
17
 
@@ -67,7 +68,14 @@ branch whose test cannot fail is worse than an uncovered one, because it reads a
67
68
  browser-only mount, a container entry point — carries its \`why\`, and is printed by \`x doctor\`.
68
69
 
69
70
  Every test the scaffold and the generators write is the idiom to copy. All of them run on the
70
- \`unit\` step — in process, on the in-memory driver, with no database, browser or server:
71
+ \`unit\` step — in process, on the in-memory driver, with no database, browser or server.
72
+
73
+ A suite that needs a real server reads it from \`docker/test-services.env\`: Postgres 18, Redis, NATS
74
+ and S3 from \`docker/docker-compose.test.yml\`, in RAM, with durability and logging off — CI starts
75
+ it before \`bun run check\`, a laptop with \`docker compose -f docker/docker-compose.test.yml up -d
76
+ --wait\` then \`set -a; . docker/test-services.env; set +a\`. A resource a test opens is held with
77
+ \`await using\` — \`await using db = await acquireWorkerDatabase()\` drops its database however
78
+ the test ends:
71
79
 
72
80
  | What | How its test runs it | Copy from |
73
81
  |---|---|---|
@@ -136,6 +144,14 @@ bun run dev # all roles in one process, embedded Postgres, /_x mounted
136
144
  bun run check # the gate: a static build, then typecheck, lint, boundaries, tests, drift, budgets
137
145
  \`\`\`
138
146
 
147
+ Tests against real servers — Postgres 18, Redis, NATS, S3, in RAM and silent, as CI runs them:
148
+
149
+ \`\`\`sh
150
+ docker compose -f docker/docker-compose.test.yml up -d --wait
151
+ set -a; . docker/test-services.env; set +a
152
+ bun run check
153
+ \`\`\`
154
+
139
155
  \`packages/db/migrations\` starts empty and \`x db gen\` is its only writer — \`bun run setup\` runs
140
156
  \`x db gen "initial"\` for you on a fresh clone. Until it has, \`x verify\`'s \`drift\` step is red
141
157
  with \`X_DB_DRIFT\`, and that is the fix it names.
@@ -173,7 +189,7 @@ const composeDev = (
173
189
  # production topology is docker-compose.prod.yml, and it is a different file for a reason.
174
190
  services:
175
191
  db:
176
- image: postgres:17-alpine
192
+ image: postgres:18-alpine
177
193
  environment:
178
194
  POSTGRES_PASSWORD: ${app.kebab}
179
195
  POSTGRES_DB: ${app.kebab}
@@ -214,6 +230,8 @@ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
214
230
  // The scripts `bun run setup` and `bun run check` run — TypeScript, so one file per OS.
215
231
  ...binFiles(),
216
232
  { path: 'docker/docker-compose.dev.yml', contents: composeDev(app) },
233
+ // What the suites run against — in RAM, silent; ci.yml starts it before the gate.
234
+ ...testServicesFiles(app),
217
235
  // The harness half of the same job AGENTS.md does. It lands in the app's own repo rather than
218
236
  // in a global config, so it is visible in the scaffold's diff and deletable in one line.
219
237
  ...claudeFiles(app),
@@ -0,0 +1,137 @@
1
+ // The backing services a new app's suites run against: `docker/docker-compose.test.yml` and the
2
+ // one env file that points the suites at it. In RAM, durability off, silent — the framework's own
3
+ // `docker/docker-compose.test.yml`, cut to what an app has. Ports sit apart from the dev stack's
4
+ // so both can run at once; `ci.yml` starts this file before `bun run check`.
5
+
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+
8
+ export const TEST_COMPOSE_PATH = 'docker/docker-compose.test.yml';
9
+ export const TEST_ENV_PATH = 'docker/test-services.env';
10
+
11
+ /** Host ports, one place: the compose file publishes them and the env file names them. */
12
+ export const TEST_PORTS = { postgres: 55432, redis: 56379, nats: 54222, s3: 59000 } as const;
13
+
14
+ const composeTest = (
15
+ app: NameSet,
16
+ ): string => `# The services the test suites run against — in RAM, and silent.
17
+ #
18
+ # docker compose -f ${TEST_COMPOSE_PATH} up -d --wait
19
+ # set -a; . ${TEST_ENV_PATH}; set +a
20
+ # bun run check
21
+ #
22
+ # \`--wait\` returns once every healthcheck passes. CI runs the same lines (.github/workflows/ci.yml).
23
+ # None of it is required: with no TEST_* variable set the unit suite runs on the in-memory driver,
24
+ # and a suite that needs a real server skips. Ports are the dev stack's plus 50000, so both run.
25
+ #
26
+ # Three rules, on every service:
27
+ #
28
+ # * RAM. Data lives on a tmpfs: no block device written, nothing left behind by \`down\`.
29
+ # * NO DURABILITY. fsync, synchronous_commit and full_page_writes off, WAL minimal and no WAL
30
+ # senders; Redis keeps no RDB snapshot and no AOF. A test database that survives a crash is a
31
+ # database nobody asked for.
32
+ # * SILENT. Each server logs as little as it can be told to, and the log driver is \`none\`. To
33
+ # watch one that will not start, run it attached: \`docker compose -f ${TEST_COMPOSE_PATH} up postgres\`.
34
+ #
35
+ # Every port binds 127.0.0.1: the credentials are in this file.
36
+ name: ${app.kebab}-test
37
+
38
+ x-silent: &silent
39
+ logging: { driver: none }
40
+
41
+ services:
42
+ # Postgres 18, the production major. \`initdb -c\` writes each setting into the new cluster;
43
+ # \`--no-sync\` is initdb's own fsync of a cluster on a tmpfs. Postgres 18 images keep the cluster
44
+ # under /var/lib/postgresql/18, so the tmpfs is the parent.
45
+ postgres:
46
+ <<: *silent
47
+ image: postgres:18-alpine
48
+ environment:
49
+ POSTGRES_USER: ${app.kebab}
50
+ POSTGRES_PASSWORD: ${app.kebab}-test
51
+ POSTGRES_DB: postgres
52
+ POSTGRES_INITDB_ARGS: >-
53
+ --no-sync
54
+ -c fsync=off
55
+ -c synchronous_commit=off
56
+ -c full_page_writes=off
57
+ -c wal_level=minimal
58
+ -c max_wal_senders=0
59
+ -c logging_collector=off
60
+ -c log_min_messages=fatal
61
+ -c log_statement=none
62
+ -c log_checkpoints=off
63
+ -c log_connections=off
64
+ -c log_disconnections=off
65
+ tmpfs: [/var/lib/postgresql]
66
+ ports: ['127.0.0.1:${TEST_PORTS.postgres}:5432']
67
+ healthcheck:
68
+ # TCP, never the socket: the entrypoint's temporary init server listens on the socket only.
69
+ test: ['CMD-SHELL', 'pg_isready -U ${app.kebab} -h 127.0.0.1 -p 5432']
70
+ interval: 1s
71
+ timeout: 3s
72
+ retries: 60
73
+
74
+ redis:
75
+ <<: *silent
76
+ image: redis:8-alpine
77
+ command: ['redis-server', '--save', '', '--appendonly', 'no', '--loglevel', 'warning']
78
+ tmpfs: [/data]
79
+ ports: ['127.0.0.1:${TEST_PORTS.redis}:6379']
80
+ healthcheck:
81
+ test: ['CMD', 'redis-cli', 'ping']
82
+ interval: 1s
83
+ timeout: 3s
84
+ retries: 30
85
+
86
+ # JetStream's store on the tmpfs; \`-l /dev/null\` is the only quiet a nats-server has.
87
+ nats:
88
+ <<: *silent
89
+ image: nats:2-alpine
90
+ command: ['-js', '-sd', '/data', '-m', '8222', '-l', '/dev/null']
91
+ tmpfs: [/data]
92
+ ports: ['127.0.0.1:${TEST_PORTS.nats}:4222']
93
+ healthcheck:
94
+ test: ['CMD', 'wget', '-q', '--spider', 'http://127.0.0.1:8222/healthz']
95
+ interval: 1s
96
+ timeout: 3s
97
+ retries: 30
98
+
99
+ # A directory under /data is a bucket, so the tmpfs mounted AT the bucket's path is the bootstrap.
100
+ s3:
101
+ <<: *silent
102
+ image: versity/versitygw:v1.8.0@sha256:30292fc2eeacc67a36993b01f7a7a5e3361a19cced0e80c1d71cfa2a4b0a2499
103
+ environment:
104
+ VGW_BACKEND: posix
105
+ VGW_BACKEND_ARG: /data
106
+ VGW_PORT: ':9000'
107
+ VGW_QUIET: 'true'
108
+ VGW_REGION: auto
109
+ ROOT_ACCESS_KEY: ${app.kebab}
110
+ ROOT_SECRET_KEY: ${app.kebab}-test
111
+ tmpfs: [/data/${app.kebab}-test]
112
+ ports: ['127.0.0.1:${TEST_PORTS.s3}:9000']
113
+ healthcheck:
114
+ test: ['CMD', 'nc', '-z', '127.0.0.1', '9000']
115
+ interval: 1s
116
+ timeout: 3s
117
+ retries: 30
118
+ `;
119
+
120
+ const testEnv = (
121
+ app: NameSet,
122
+ ): string => `# Where the suites find the services ${TEST_COMPOSE_PATH} starts — the one place these URLs are
123
+ # written. A laptop sources it (\`set -a; . ${TEST_ENV_PATH}; set +a\`); CI appends it to $GITHUB_ENV.
124
+ # TEST_DATABASE_URL is what @ultimat3/testing clones one database per test worker from.
125
+ TEST_DATABASE_URL=postgres://${app.kebab}:${app.kebab}-test@127.0.0.1:${TEST_PORTS.postgres}/postgres
126
+ TEST_REDIS_URL=redis://127.0.0.1:${TEST_PORTS.redis}
127
+ TEST_NATS_URL=nats://127.0.0.1:${TEST_PORTS.nats}
128
+ TEST_S3_URL=http://${app.kebab}:${app.kebab}-test@127.0.0.1:${TEST_PORTS.s3}/${app.kebab}-test
129
+ `;
130
+
131
+ /** The test stack and its env file. */
132
+ export function testServicesFiles(app: NameSet): readonly GeneratedFile[] {
133
+ return [
134
+ { path: TEST_COMPOSE_PATH, contents: composeTest(app) },
135
+ { path: TEST_ENV_PATH, contents: testEnv(app) },
136
+ ];
137
+ }