@ultimat3/cli 27.10.4 → 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 +28 -28
- package/src/framework-schema-apply.ts +36 -5
- package/src/framework-schema.ts +2 -2
- package/src/hold.ts +27 -2
- package/src/runtime-cache.ts +36 -3
- package/src/runtime-jobs-retention.ts +69 -0
- package/src/runtime-purge.ts +19 -5
- package/src/runtime-queue.ts +16 -7
- package/src/runtime-services.ts +2 -0
- package/src/schema-errors.ts +7 -4
- package/src/serve.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
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.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
}
|
package/src/framework-schema.ts
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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;
|
package/src/runtime-cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runtime-purge.ts
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
// after the notify tables joined the boot's DDL, which is how
|
|
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
|
|
package/src/runtime-queue.ts
CHANGED
|
@@ -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
|
|
128
|
-
* lock with a bounded `lock_timeout` and stamps the build. `'verify'` — a serving role on
|
|
129
|
-
* external database — runs none: an `alter table` queued behind one open transaction stalled
|
|
130
|
-
* enqueue in the fleet on each pod start, and racing `create table`s were a first-deploy
|
|
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
|
-
|
|
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 {
|
package/src/runtime-services.ts
CHANGED
|
@@ -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
|
);
|
package/src/schema-errors.ts
CHANGED
|
@@ -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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
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);
|