@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 +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/src/templates/github/ci.yml.ts +7 -3
- package/src/templates/scaffold-container-compose.ts +4 -2
- package/src/templates/scaffold-docs.ts +20 -2
- package/src/templates/scaffold-test-services.ts +137 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
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.
|
|
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.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
|
|
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);
|
|
@@ -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
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|