@ultimat3/cli 1.1.0 → 2.0.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/CLAUDE.md +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The one `RunningServices` every `dev-roles` test file boots roles against, and the one reset
|
|
2
|
+
// between them. Shared rather than copied, for the reason `policy-fixture.ts` gives: three files
|
|
3
|
+
// start the same roles, and a second copy of the runtime drifts while each file keeps passing.
|
|
4
|
+
//
|
|
5
|
+
// Its own module rather than a `.test.ts` neighbours import, because `tsconfig.json` excludes
|
|
6
|
+
// `*.test.ts` — a fixture written there is one `tsc` never reads.
|
|
7
|
+
|
|
8
|
+
import { noopPurgeDriver } from '@ultimat3/cache';
|
|
9
|
+
import { resetLifecycle } from '@ultimat3/core';
|
|
10
|
+
import {
|
|
11
|
+
createMemoryDriver,
|
|
12
|
+
createMemoryEventBus,
|
|
13
|
+
createMemoryOutboxStore,
|
|
14
|
+
resetJobs,
|
|
15
|
+
resetJobsFacade,
|
|
16
|
+
resetTasks,
|
|
17
|
+
} from '@ultimat3/jobs';
|
|
18
|
+
import { createMemoryDriver as createMemoryMailDriver } from '@ultimat3/mail';
|
|
19
|
+
import { DEFAULT_PRESENCE_TTL_MS, InProcessTransport } from '@ultimat3/realtime';
|
|
20
|
+
import { defineStorage, localDriver } from '@ultimat3/storage';
|
|
21
|
+
import type { RunningServices } from './dev-runtime';
|
|
22
|
+
import { resolveServices } from './dev-services';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every service a role touches, embedded but real — no PGlite boot for a role-wiring test.
|
|
26
|
+
*
|
|
27
|
+
* `root` is a parameter and not a constant: each test file owns its own directory and deletes it,
|
|
28
|
+
* so two files sharing one on-disk storage root cannot leave the other's fixture half-removed.
|
|
29
|
+
*/
|
|
30
|
+
export function fixtureRuntime(root: string): RunningServices {
|
|
31
|
+
const services = resolveServices(root, {});
|
|
32
|
+
const transport = new InProcessTransport();
|
|
33
|
+
return {
|
|
34
|
+
services,
|
|
35
|
+
db: { async ping() {}, async close() {} } as unknown as RunningServices['db'],
|
|
36
|
+
jobs: createMemoryDriver(),
|
|
37
|
+
// A real store, not a stub: the `worker` role starts the outbox relay against it, and a relay
|
|
38
|
+
// whose `claim()` rejects on the first 200ms tick is an unhandled rejection in whichever test
|
|
39
|
+
// happens to still be running.
|
|
40
|
+
outbox: createMemoryOutboxStore(),
|
|
41
|
+
events: createMemoryEventBus(),
|
|
42
|
+
transport,
|
|
43
|
+
transportDetail: 'in-process fanout',
|
|
44
|
+
// The sync role reads this to build its `PresenceRegistry`; the default is what a boot with no
|
|
45
|
+
// `NATS_URL` resolves to, so the fixture is the real number rather than a rounder one.
|
|
46
|
+
presenceTtlMs: DEFAULT_PRESENCE_TTL_MS,
|
|
47
|
+
storage: defineStorage({ disks: { local: localDriver({ root: `${root}/storage` }) } }),
|
|
48
|
+
mail: createMemoryMailDriver(),
|
|
49
|
+
mailDetail: 'embedded',
|
|
50
|
+
purge: noopPurgeDriver(),
|
|
51
|
+
purgeDetail: 'none',
|
|
52
|
+
stop: async () => transport.close(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The process-global state one started role leaves behind. `resetLifecycle` is the load-bearing
|
|
58
|
+
* one: core's lifecycle is process-wide and a stopped server leaves it drained, so without it the
|
|
59
|
+
* SECOND web role in a file answers every request `X_DRAINING` — a suite that only passes when its
|
|
60
|
+
* tests are run one at a time. `@ultimat3/http`'s own server suite resets it for the same reason.
|
|
61
|
+
*/
|
|
62
|
+
export function resetDevRolesState(): void {
|
|
63
|
+
resetJobs();
|
|
64
|
+
resetJobsFacade();
|
|
65
|
+
resetTasks();
|
|
66
|
+
resetLifecycle();
|
|
67
|
+
}
|
package/src/dev-roles.ts
CHANGED
|
@@ -8,27 +8,28 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
10
|
import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
|
|
11
|
-
import type { Route, ServerHandle } from '@ultimat3/http';
|
|
12
|
-
import { createServer, defineHttpConfig } from '@ultimat3/http';
|
|
13
|
-
import type { Scheduler, Worker } from '@ultimat3/jobs';
|
|
14
|
-
import { createScheduler, createWorker } from '@ultimat3/jobs';
|
|
15
|
-
import { listQueries } from '@ultimat3/query';
|
|
11
|
+
import type { Route, ServerHandle, ServerHooks } from '@ultimat3/http';
|
|
12
|
+
import { configuredAuthenticator, createServer, defineHttpConfig } from '@ultimat3/http';
|
|
13
|
+
import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
|
|
16
14
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
SocketRegistry,
|
|
25
|
-
} from '@ultimat3/realtime';
|
|
15
|
+
createOutboxRelay,
|
|
16
|
+
createPgLeaseLeader,
|
|
17
|
+
createScheduler,
|
|
18
|
+
createWorker,
|
|
19
|
+
jobDriver,
|
|
20
|
+
pgSchedulerState,
|
|
21
|
+
} from '@ultimat3/jobs';
|
|
26
22
|
import { devHooks } from './dev-hooks';
|
|
23
|
+
import { pgExecutorFor } from './dev-queue';
|
|
27
24
|
import type { RunningReplicator } from './dev-replicator';
|
|
28
25
|
import { startReplicator } from './dev-replicator';
|
|
29
26
|
import type { RunningServices } from './dev-runtime';
|
|
30
27
|
import type { Env } from './dev-services';
|
|
31
|
-
import {
|
|
28
|
+
import { startSync } from './dev-sync';
|
|
29
|
+
import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
|
|
30
|
+
import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
31
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
32
|
+
import { inlineStyleSources } from './style-csp';
|
|
32
33
|
|
|
33
34
|
/** The roles `x dev` starts when `--role` names none, in boot order. */
|
|
34
35
|
export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
|
|
@@ -57,6 +58,36 @@ export interface StartRolesOptions {
|
|
|
57
58
|
* probe, which is the same failure in four costumes.
|
|
58
59
|
*/
|
|
59
60
|
readonly http?: WebBinding;
|
|
61
|
+
/**
|
|
62
|
+
* The app's `auth.signInPath`. Threaded rather than read from the config here because
|
|
63
|
+
* `startRoles` takes plain values — a test starts a web role with no `app.config.ts` at all.
|
|
64
|
+
*/
|
|
65
|
+
readonly signInPath?: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* Inline `<style>` bodies this process serves that the app's own surfaces do not account for —
|
|
68
|
+
* `/_x`'s shell. The surfaces themselves are read from the stylesheet registry here rather than
|
|
69
|
+
* passed, so no caller of `startRoles` can ship a web server whose CSP blocks the pages it
|
|
70
|
+
* serves: that policy is what rendered every deployed app completely unstyled.
|
|
71
|
+
*/
|
|
72
|
+
readonly inlineStyles?: readonly string[];
|
|
73
|
+
/**
|
|
74
|
+
* Non-fatal findings the browser overlay shows next to an error, for the request being answered.
|
|
75
|
+
* Only `x dev` supplies one — `serve.ts` boots through this same function and omits it, so a
|
|
76
|
+
* production process never has a diagnostic to call (axiom 6).
|
|
77
|
+
*/
|
|
78
|
+
readonly devNotices?: ServerHooks['devNotices'];
|
|
79
|
+
/**
|
|
80
|
+
* Where the scrape listener binds. Defaults to `DEFAULT_METRICS_PORT`, except when `port` is 0
|
|
81
|
+
* — a caller asking the kernel for an ephemeral HTTP port is a test, and a test that grabbed
|
|
82
|
+
* 9090 would fail the next one to run beside it.
|
|
83
|
+
*/
|
|
84
|
+
readonly metricsPort?: number;
|
|
85
|
+
/**
|
|
86
|
+
* What the host substituted for a boot decision. Read here for the three seams that are not
|
|
87
|
+
* services — the rate-limit store, the middleware chain and the sync authenticator — while the
|
|
88
|
+
* drivers themselves arrive already resolved on `runtime`.
|
|
89
|
+
*/
|
|
90
|
+
readonly overrides?: RuntimeOverrides;
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
export interface WebBinding {
|
|
@@ -73,6 +104,8 @@ export interface RunningRoles {
|
|
|
73
104
|
readonly url: string | null;
|
|
74
105
|
/** Where the sync role accepts websockets; null when it was not selected. */
|
|
75
106
|
readonly syncUrl: string | null;
|
|
107
|
+
/** `http://…` — the scrape base. Never null: every role publishes a signal worth scaling on. */
|
|
108
|
+
readonly metricsUrl: string;
|
|
76
109
|
readonly server: ServerHandle | null;
|
|
77
110
|
readonly worker: Worker | null;
|
|
78
111
|
readonly scheduler: Scheduler | null;
|
|
@@ -114,95 +147,133 @@ export function selectRoles(flag: string | undefined): readonly Role[] {
|
|
|
114
147
|
return SELECTABLE_ROLES.filter((role) => selected.includes(role));
|
|
115
148
|
}
|
|
116
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A server whose route table demands an identity it has no way to resolve.
|
|
152
|
+
*
|
|
153
|
+
* `hooks.authenticate` is the only place an actor can come from, and `devHooks()` spreads nothing
|
|
154
|
+
* when the app never called `configureAuthenticator()`. So a process in that state boots clean,
|
|
155
|
+
* reports healthy, and refuses every valid session on every `auth: 'required'` route — which is
|
|
156
|
+
* exactly what the demo app did: sign-in issued a real cookie and the next page still said 401,
|
|
157
|
+
* while four unit tests over the app's own resolver stayed green because each installed a viewer
|
|
158
|
+
* by hand.
|
|
159
|
+
*
|
|
160
|
+
* A warning and not a throw, deliberately: `x new` scaffolds guarded routes before it scaffolds an
|
|
161
|
+
* authenticator, so an app in the minutes between the two is incomplete, not broken. It is loud,
|
|
162
|
+
* it names the code, and it prints the call that fixes it.
|
|
163
|
+
*/
|
|
164
|
+
function warnIfUnauthenticatable(routes: readonly Route[]): void {
|
|
165
|
+
if (configuredAuthenticator() !== undefined) return;
|
|
166
|
+
const guarded = routes.filter((route) => route.meta.auth === 'required');
|
|
167
|
+
if (guarded.length === 0) return;
|
|
168
|
+
logger.warn(
|
|
169
|
+
`X_CONFIG_INVALID: ${guarded.length} route(s) declare auth: 'required' and no authenticator is configured, so every request is anonymous and each of them refuses every session — fix: call configureAuthenticator() at module scope in a file under apps/*/, e.g. configureAuthenticator((request) => viewerFor(request.header('cookie')))`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* How many proxies append to `x-forwarded-for` between the client and this process, or `null`
|
|
175
|
+
* when nothing in front of it is trusted.
|
|
176
|
+
*
|
|
177
|
+
* Read from the environment and not from `app.config.ts`, for the reason `PORT` and `ROLE` are: it
|
|
178
|
+
* is a fact about the DEPLOYMENT — one image runs behind an ingress in one cluster and behind
|
|
179
|
+
* nothing on a laptop — and an app that hardcoded it would be wrong in one of the two. `x dev`
|
|
180
|
+
* sets neither and gets `trustProxy: false`, which is correct: there is no proxy.
|
|
181
|
+
*
|
|
182
|
+
* Without this seam a container behind an ingress reads `ctx.ip` as the ingress's own socket
|
|
183
|
+
* address on every request, so the rate limiter keys the entire fleet's anonymous traffic into ONE
|
|
184
|
+
* bucket and a single scanner 429s every real signup.
|
|
185
|
+
*/
|
|
186
|
+
export function trustedHopsFromEnv(env: Env): number | null {
|
|
187
|
+
const raw = env['TRUSTED_PROXY_HOPS']?.trim();
|
|
188
|
+
if (raw === undefined || raw === '') return null;
|
|
189
|
+
const hops = Number(raw);
|
|
190
|
+
// A malformed count is refused rather than defaulted: reading the header at the wrong index is
|
|
191
|
+
// trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
|
|
192
|
+
if (!Number.isInteger(hops) || hops < 1 || hops > 16) {
|
|
193
|
+
throw new PortInvalidError({ value: raw, name: 'TRUSTED_PROXY_HOPS' });
|
|
194
|
+
}
|
|
195
|
+
return hops;
|
|
196
|
+
}
|
|
197
|
+
|
|
117
198
|
function startWeb(options: StartRolesOptions): ServerHandle {
|
|
199
|
+
warnIfUnauthenticatable(options.routes);
|
|
118
200
|
const binding = options.http ?? DEV_BINDING;
|
|
201
|
+
const hops = trustedHopsFromEnv(options.env);
|
|
202
|
+
const store = options.overrides?.rateLimitStore;
|
|
119
203
|
return createServer({
|
|
120
204
|
routes: options.routes,
|
|
121
205
|
role: 'web',
|
|
122
|
-
hooks: devHooks(),
|
|
206
|
+
hooks: devHooks(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
|
|
207
|
+
// Both seams `createServer` already had and `startRoles` passed neither of, so an app's own
|
|
208
|
+
// middleware could not reach the pipeline any process the framework boots actually runs.
|
|
209
|
+
...(options.overrides?.middleware === undefined
|
|
210
|
+
? {}
|
|
211
|
+
: { middleware: options.overrides.middleware }),
|
|
212
|
+
...(store === undefined ? {} : { rateLimitStore: store }),
|
|
123
213
|
config: defineHttpConfig({
|
|
124
214
|
port: options.port,
|
|
125
215
|
dev: binding.dev,
|
|
126
216
|
buildId: options.buildId,
|
|
127
217
|
hostname: binding.hostname,
|
|
218
|
+
signInPath: options.signInPath ?? null,
|
|
219
|
+
// One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
|
|
220
|
+
...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
|
|
221
|
+
// `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error: the
|
|
222
|
+
// old `'process'` default meant the shipped chart's three `web` replicas enforced
|
|
223
|
+
// `login: { limit: 5 }` as fifteen attempts, with `x verify` green. It is DERIVED from the
|
|
224
|
+
// store rather than hardcoded — a deployment that hands `runtime.rateLimitStore` a shared
|
|
225
|
+
// store is declaring the fleet-wide numbers, and `assertRateLimitScope` then holds the two
|
|
226
|
+
// halves together instead of a literal here quietly contradicting the store beside it.
|
|
227
|
+
rateLimit: { scope: store?.scope ?? 'process' },
|
|
228
|
+
// Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
|
|
229
|
+
// nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
|
|
230
|
+
// function of that body. Read after `loadApp` — importing the app IS what registered them.
|
|
231
|
+
security: {
|
|
232
|
+
csp: { extend: { 'style-src': inlineStyleSources(options.inlineStyles ?? []) } },
|
|
233
|
+
},
|
|
128
234
|
}),
|
|
129
235
|
}).start();
|
|
130
236
|
}
|
|
131
237
|
|
|
132
238
|
/**
|
|
133
|
-
*
|
|
134
|
-
* `@ultimat3/realtime`'s own bridge. A registry with nothing in it answers every live `subscribe`
|
|
135
|
-
* with "no live query registered", which is a working socket serving no reads — and it is what
|
|
136
|
-
* kept the row gate that decides per subscriber from ever running outside a unit test.
|
|
239
|
+
* The one moment the boot can still see both answers.
|
|
137
240
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const registry = new LiveQueryRegistry({
|
|
144
|
-
source: new RingChangeBuffer(),
|
|
145
|
-
// A withheld row is a metric, never a frame and never an error: telling a client "there is a
|
|
146
|
-
// row you may not see" is the leak the gate exists to prevent.
|
|
147
|
-
onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
|
|
148
|
-
});
|
|
149
|
-
const ctx = createContext({ role: 'sync', buildId: options.buildId });
|
|
150
|
-
for (const target of listQueries()) {
|
|
151
|
-
if (target.isLive) registry.register(liveQueryDefinition(target, { ctx }));
|
|
152
|
-
}
|
|
153
|
-
return registry;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* The sync role owns its own socket: websockets and the request pipeline drain differently.
|
|
241
|
+
* `startServices` captures the drivers it built; `loadApp` imports the app's modules after it, and
|
|
242
|
+
* a module calling `setJobDriver()` at import time moves the ambient slot and leaves the capture
|
|
243
|
+
* alone. From here on the two are indistinguishable at every call site: `handle.enqueue()` reads
|
|
244
|
+
* the ambient one, `createWorker` claims from the captured one, and `/_x` reads the ambient one —
|
|
245
|
+
* so the dashboard agrees with the enqueue side and disagrees with reality.
|
|
158
246
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
247
|
+
* Refused, not reconciled. Reading through the accessor would make the split invisible instead of
|
|
248
|
+
* impossible, and the app would still have installed a driver the boot never saw — no outbox store
|
|
249
|
+
* bound to it, no relay draining it. The fix line names the field that does work.
|
|
162
250
|
*/
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
registry: registerLiveQueries(options),
|
|
171
|
-
transport: options.runtime.transport,
|
|
172
|
-
buildId: options.buildId,
|
|
173
|
-
sockets,
|
|
174
|
-
// Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
|
|
175
|
-
// list at all — the KV bucket the transport just created would hold nothing and every `sync`
|
|
176
|
-
// container would run a presence-less protocol. It reads and writes `transport.shared`, so it
|
|
177
|
-
// is exactly as multi-node as the transport behind it: in-process here, the bucket under NATS.
|
|
178
|
-
presence: new PresenceRegistry({
|
|
179
|
-
transport: options.runtime.transport,
|
|
180
|
-
hub,
|
|
181
|
-
ttlMs: options.runtime.presenceTtlMs,
|
|
182
|
-
}),
|
|
251
|
+
function assertOneJobDriver(runtime: RunningServices): void {
|
|
252
|
+
const ambient = jobDriver();
|
|
253
|
+
if (ambient === undefined || ambient === runtime.jobs) return;
|
|
254
|
+
throw new RuntimeDriverSplitError({
|
|
255
|
+
driver: 'jobs',
|
|
256
|
+
ambient: ambient.name,
|
|
257
|
+
captured: runtime.jobs.name,
|
|
183
258
|
});
|
|
184
|
-
await node.start();
|
|
185
|
-
try {
|
|
186
|
-
const listener = listenSyncNode(node, { port: options.port === 0 ? 0 : options.port + 1 });
|
|
187
|
-
return {
|
|
188
|
-
url: listener.url,
|
|
189
|
-
stop: async () => {
|
|
190
|
-
listener.stop();
|
|
191
|
-
await node.stop();
|
|
192
|
-
},
|
|
193
|
-
};
|
|
194
|
-
} catch (error) {
|
|
195
|
-
await node.stop();
|
|
196
|
-
throw error;
|
|
197
|
-
}
|
|
198
259
|
}
|
|
199
260
|
|
|
200
261
|
export async function startRoles(options: StartRolesOptions): Promise<RunningRoles> {
|
|
201
262
|
const selected = options.roles;
|
|
263
|
+
assertOneJobDriver(options.runtime);
|
|
202
264
|
// Roles bind sockets in order, so a role that fails to start has to release the ones before it.
|
|
203
265
|
// Without this a failed `sync` leaves the web server bound and unreachable by any caller.
|
|
204
266
|
const started: (() => Promise<void>)[] = [];
|
|
205
267
|
try {
|
|
268
|
+
// First, and for every role rather than only the two that open an HTTP socket: `worker` and
|
|
269
|
+
// `sync` are precisely the roles whose HPAs read a series the process itself has to publish,
|
|
270
|
+
// and a `worker` container with no listener is an HPA pinned at `<unknown>` forever.
|
|
271
|
+
const metrics = startMetricsEndpoint({
|
|
272
|
+
port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
|
|
273
|
+
...(options.http === undefined ? {} : { hostname: options.http.hostname }),
|
|
274
|
+
});
|
|
275
|
+
started.push(async () => metrics.stop());
|
|
276
|
+
|
|
206
277
|
const server = selected.includes('web') ? startWeb(options) : null;
|
|
207
278
|
if (server !== null) started.push(() => server.stop());
|
|
208
279
|
|
|
@@ -218,8 +289,37 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
218
289
|
worker?.start();
|
|
219
290
|
if (worker !== null) started.push(() => worker.stop('x dev stopped'));
|
|
220
291
|
|
|
292
|
+
// The half of the transactional outbox that makes a staged row a running job. Without it
|
|
293
|
+
// `handle.enqueue()` inside a transaction writes to `x_outbox` and nothing ever reads it back
|
|
294
|
+
// — every enqueue in a request handler silently becomes a job that never runs.
|
|
295
|
+
//
|
|
296
|
+
// On `worker`, and on `worker` alone: it is the role that exists wherever jobs run at all, and
|
|
297
|
+
// a relay is safe to duplicate (publish-then-mark is at-least-once and the idempotency key
|
|
298
|
+
// collapses the repeat) but pointless to spread. A deployment with no `worker` has no one to
|
|
299
|
+
// run the jobs either way.
|
|
300
|
+
const relay: OutboxRelay | null = selected.includes('worker')
|
|
301
|
+
? createOutboxRelay({ store: options.runtime.outbox, driver: options.runtime.jobs })
|
|
302
|
+
: null;
|
|
303
|
+
relay?.start();
|
|
304
|
+
// Returned, not called-and-discarded: `stop()` waits out the pass in flight, and an unawaited
|
|
305
|
+
// one hands the failure rollback the same window a dropped `await` gives the teardown below.
|
|
306
|
+
if (relay !== null) started.push(() => relay.stop());
|
|
307
|
+
|
|
308
|
+
// `state` and `leader`, not the defaults. `createMemorySchedulerState` forgets every watermark
|
|
309
|
+
// on restart, so a rolling deploy re-fires or skips whatever was due across it, and
|
|
310
|
+
// `soleLeader()` makes every replica the leader — three `scheduler` pods, three of every task.
|
|
311
|
+
//
|
|
312
|
+
// `createPgLeaseLeader` and NOT `createPgLeader`: the latter's `pg_try_advisory_lock` is
|
|
313
|
+
// SESSION-scoped, and the session ends the moment the connection goes back to the pool, so
|
|
314
|
+
// every node reads itself as leader anyway. An expiring row is correct on the executor this
|
|
315
|
+
// package is actually handed.
|
|
316
|
+
const executor = pgExecutorFor(options.runtime.db);
|
|
221
317
|
const scheduler = selected.includes('scheduler')
|
|
222
|
-
? createScheduler({
|
|
318
|
+
? createScheduler({
|
|
319
|
+
driver: options.runtime.jobs,
|
|
320
|
+
state: pgSchedulerState(executor),
|
|
321
|
+
leader: createPgLeaseLeader({ executor }),
|
|
322
|
+
})
|
|
223
323
|
: null;
|
|
224
324
|
scheduler?.start();
|
|
225
325
|
if (scheduler !== null) started.push(() => scheduler.stop());
|
|
@@ -240,6 +340,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
240
340
|
roles: selected,
|
|
241
341
|
url: server === null ? null : server.url(),
|
|
242
342
|
syncUrl: sync?.url ?? null,
|
|
343
|
+
metricsUrl: metrics.url,
|
|
243
344
|
server,
|
|
244
345
|
worker,
|
|
245
346
|
scheduler,
|
|
@@ -248,9 +349,16 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
248
349
|
// Reverse boot order, so the slot is released before the bus it published to closes.
|
|
249
350
|
await replicator?.stop();
|
|
250
351
|
await scheduler?.stop();
|
|
352
|
+
// Before the worker, so nothing publishes into a queue whose consumer has already gone —
|
|
353
|
+
// and AWAITED, because a pass is a `driver.enqueue` followed by a `markPublished`. Dropped,
|
|
354
|
+
// this returns between the two and the lines below close the pool under the row it was
|
|
355
|
+
// about to mark: re-published next boot at best, a rejection against a closed pool at worst.
|
|
356
|
+
await relay?.stop();
|
|
251
357
|
await worker?.stop('x dev stopped');
|
|
252
358
|
await sync?.stop();
|
|
253
359
|
await server?.stop();
|
|
360
|
+
// Last: a scrape taken while the roles above drain is the one that explains the drain.
|
|
361
|
+
metrics.stop();
|
|
254
362
|
},
|
|
255
363
|
};
|
|
256
364
|
} catch (error) {
|
package/src/dev-runtime.ts
CHANGED
|
@@ -4,32 +4,36 @@
|
|
|
4
4
|
// boot installs, only backed by embedded drivers.
|
|
5
5
|
|
|
6
6
|
import { mkdirSync } from 'node:fs';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
7
|
import type { PurgeDriver } from '@ultimat3/cache';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
registerTier,
|
|
13
|
-
resetTiers,
|
|
14
|
-
selectPurgeDriver,
|
|
15
|
-
} from '@ultimat3/cache';
|
|
16
|
-
import type { EventBus, JobDriver } from '@ultimat3/jobs';
|
|
17
|
-
import { createMemoryEventBus, setEventBus } from '@ultimat3/jobs';
|
|
8
|
+
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
9
|
+
import { isLocal, resolveEnvironment } from '@ultimat3/core';
|
|
10
|
+
import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
|
|
18
11
|
import type { MailDriver } from '@ultimat3/mail';
|
|
19
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isMemoryDriver,
|
|
14
|
+
isUnconfiguredDriver,
|
|
15
|
+
resetMailDriver,
|
|
16
|
+
selectMailDriver,
|
|
17
|
+
setMailDriver,
|
|
18
|
+
} from '@ultimat3/mail';
|
|
20
19
|
import type { Transport, TransportSelection } from '@ultimat3/realtime';
|
|
21
20
|
import { selectTransport } from '@ultimat3/realtime';
|
|
22
21
|
import type { Storage } from '@ultimat3/storage';
|
|
23
|
-
import { defineStorage, localDriver } from '@ultimat3/storage';
|
|
22
|
+
import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
|
|
23
|
+
import { startCacheTiers } from './dev-cache';
|
|
24
24
|
import type { DevDbClient } from './dev-queue';
|
|
25
25
|
import { startQueue } from './dev-queue';
|
|
26
26
|
import type { DevServices, Env } from './dev-services';
|
|
27
|
+
import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
|
|
27
28
|
import { msg } from './messages';
|
|
29
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
28
30
|
|
|
29
31
|
export interface RunningServices {
|
|
30
32
|
readonly services: DevServices;
|
|
31
33
|
readonly db: DevDbClient;
|
|
32
34
|
readonly jobs: JobDriver;
|
|
35
|
+
/** The `x_outbox` store behind `handle.enqueue()`. A role starts the relay that drains it. */
|
|
36
|
+
readonly outbox: OutboxStore;
|
|
33
37
|
readonly events: EventBus;
|
|
34
38
|
readonly transport: Transport;
|
|
35
39
|
readonly storage: Storage;
|
|
@@ -62,6 +66,10 @@ export interface RunningServices {
|
|
|
62
66
|
* the human half.
|
|
63
67
|
*/
|
|
64
68
|
export function describeMail(runtime: RunningServices): string {
|
|
69
|
+
// Three arms, not two. A deployment with no credential outside development installs a driver that
|
|
70
|
+
// REFUSES every send, and reporting that as `external` would name it as a transport that delivers
|
|
71
|
+
// — the same lie the memory driver told when it answered `accepted` for mail nobody received.
|
|
72
|
+
if (isUnconfiguredDriver(runtime.mail)) return `mail=refused(${runtime.mailDetail})`;
|
|
65
73
|
return isMemoryDriver(runtime.mail)
|
|
66
74
|
? 'mail=embedded'
|
|
67
75
|
: `mail=external(${runtime.mail.name} via ${runtime.mailDetail})`;
|
|
@@ -84,6 +92,8 @@ export function describeCdn(runtime: RunningServices): string {
|
|
|
84
92
|
* value stays where `--json` can depend on it.
|
|
85
93
|
*/
|
|
86
94
|
export function mailLabel(runtime: RunningServices): string {
|
|
95
|
+
if (isUnconfiguredDriver(runtime.mail))
|
|
96
|
+
return msg('cli.dev.mail.refused', { detail: runtime.mailDetail });
|
|
87
97
|
return isMemoryDriver(runtime.mail)
|
|
88
98
|
? msg('cli.dev.mail.embedded')
|
|
89
99
|
: msg('cli.dev.mail.external', { driver: runtime.mail.name, detail: runtime.mailDetail });
|
|
@@ -98,13 +108,71 @@ export function cdnLabel(runtime: RunningServices): string {
|
|
|
98
108
|
|
|
99
109
|
const FILE_SCHEME = 'file://';
|
|
100
110
|
|
|
101
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The storage disk this process will use.
|
|
113
|
+
*
|
|
114
|
+
* An `external` binding now reaches `s3Driver`. It used to fall through to a LOCAL directory —
|
|
115
|
+
* `S3_ENDPOINT` selected a different root and nothing else, so every deployment that configured
|
|
116
|
+
* object storage silently wrote to a container-local disk, and every upload was destroyed by the
|
|
117
|
+
* next restart while the configured bucket stayed empty. Nothing failed; the files just went
|
|
118
|
+
* nowhere.
|
|
119
|
+
*
|
|
120
|
+
* The embedded branch is also where a hardened container died. `mkdirSync` on a
|
|
121
|
+
* `readOnlyRootFilesystem` throws a bare `EROFS` from inside Bun's fs, with no code, no fix and no
|
|
122
|
+
* mention of storage — the demo CrashLooped 22 times on it. A read-only filesystem is a normal way
|
|
123
|
+
* to run a container, so this reports what to do instead of what went wrong.
|
|
124
|
+
*/
|
|
125
|
+
export function startStorage(services: DevServices, env: Env, override?: Storage): Storage {
|
|
126
|
+
// First arm, not a branch beside the two below: a host that handed the boot a `Storage` has
|
|
127
|
+
// already answered "which disk", and re-deriving one from `S3_ENDPOINT` would be a second answer.
|
|
128
|
+
if (override !== undefined) return override;
|
|
102
129
|
const binding = services.storage;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
130
|
+
|
|
131
|
+
if (binding.mode === 'external') {
|
|
132
|
+
const bucket = env['S3_BUCKET'] ?? '';
|
|
133
|
+
if (bucket === '') {
|
|
134
|
+
throw new StorageUnwritableError(
|
|
135
|
+
`S3_ENDPOINT is set to ${binding.url} but S3_BUCKET is empty, so there is no disk to write to`,
|
|
136
|
+
'set S3_BUCKET to the bucket name, or unset S3_ENDPOINT to use the embedded disk',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return defineStorage({
|
|
140
|
+
disks: {
|
|
141
|
+
object: s3Driver({
|
|
142
|
+
bucket,
|
|
143
|
+
endpoint: binding.url,
|
|
144
|
+
...(env['S3_REGION'] === undefined ? {} : { region: env['S3_REGION'] }),
|
|
145
|
+
// MinIO needs path style; R2 and AWS do not. Declared, never sniffed from the endpoint.
|
|
146
|
+
forcePathStyle: env['S3_FORCE_PATH_STYLE'] === '1',
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
default: 'object',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const root = binding.url.slice(FILE_SCHEME.length);
|
|
154
|
+
// The embedded disk is what this branch falls back to whenever `S3_ENDPOINT`/`S3_BUCKET` are
|
|
155
|
+
// unset — including in `production` and `staging`, where an unset `STORAGE_SIGNING_SECRET` means
|
|
156
|
+
// every signed upload grant is minted with the string published in this repo, and
|
|
157
|
+
// `acceptSignedUpload` trusts a signed constraint over the app's own `uploadPolicy`.
|
|
158
|
+
//
|
|
159
|
+
// `localDriver` refuses that at construction on its own (`X_ENV_MISSING`). Pre-empted here so
|
|
160
|
+
// the message names the STORAGE CHOICE and both ways out, rather than arriving as "a secret is
|
|
161
|
+
// missing" from a helper the operator never configured. Not an outright ban on the local disk in
|
|
162
|
+
// production: a single-node Compose deploy on a mounted volume WITH a real secret is a rung on
|
|
163
|
+
// the scale ladder, and refusing it would be a deploy-shape decision, not a security fix.
|
|
164
|
+
if (!isLocal() && usesDevStorageSecret()) {
|
|
165
|
+
throw new LocalDiskUnsafeError({ environment: resolveEnvironment(), root });
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
mkdirSync(root, { recursive: true });
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
171
|
+
throw new StorageUnwritableError(
|
|
172
|
+
`the embedded storage disk needs ${root} and it could not be created: ${detail}`,
|
|
173
|
+
`mount a writable volume at ${root}, or set S3_ENDPOINT and S3_BUCKET to use object storage instead`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
108
176
|
return defineStorage({ disks: { local: localDriver({ root }) }, default: 'local' });
|
|
109
177
|
}
|
|
110
178
|
|
|
@@ -126,7 +194,11 @@ async function release(steps: readonly (() => void | Promise<void>)[]): Promise<
|
|
|
126
194
|
return failures;
|
|
127
195
|
}
|
|
128
196
|
|
|
129
|
-
export async function startServices(
|
|
197
|
+
export async function startServices(
|
|
198
|
+
services: DevServices,
|
|
199
|
+
env: Env,
|
|
200
|
+
overrides?: RuntimeOverrides,
|
|
201
|
+
): Promise<RunningServices> {
|
|
130
202
|
// Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does
|
|
131
203
|
// not dial. A typo'd credential must fail on the spot rather than after PGlite has started and
|
|
132
204
|
// been unwound again, and it must fail at boot rather than on the first mail nobody receives.
|
|
@@ -140,49 +212,54 @@ export async function startServices(services: DevServices, env: Env): Promise<Ru
|
|
|
140
212
|
// `@ultimat3/realtime`'s decision, and it is the same call a `ROLE=sync` container makes, so this
|
|
141
213
|
// process cannot resolve the bus differently from the container it stands in for.
|
|
142
214
|
const bus: TransportSelection = selectTransport(env);
|
|
143
|
-
const queue = await startQueue(services);
|
|
144
|
-
const { db, jobs } = queue;
|
|
215
|
+
const queue = await startQueue(services, overrides);
|
|
216
|
+
const { db, jobs, outbox, events } = queue;
|
|
145
217
|
// Boot is a sequence of external resources, and every step after the first can reject — the
|
|
146
218
|
// queue is already up, so from here an unwind must release it exactly like everything after it.
|
|
147
219
|
const started: (() => void | Promise<void>)[] = [() => queue.stop()];
|
|
148
220
|
try {
|
|
149
|
-
const events = createMemoryEventBus();
|
|
150
|
-
setEventBus(events);
|
|
151
221
|
// Dialled here rather than at selection: an unreachable bus must fail at `x dev`, not on the
|
|
152
222
|
// first change nobody receives, and the socket is a resource the unwind below has to release.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
223
|
+
// A supplied transport is already connected and is NOT closed here: whoever built it owns its
|
|
224
|
+
// socket, which is why the override skips both halves rather than only the dial.
|
|
225
|
+
let transport = overrides?.transport;
|
|
226
|
+
if (transport === undefined) {
|
|
227
|
+
await bus.connect();
|
|
228
|
+
started.push(() => bus.transport.close());
|
|
229
|
+
transport = bus.transport;
|
|
230
|
+
}
|
|
231
|
+
const storage = startStorage(services, env, overrides?.storage);
|
|
156
232
|
// With no credential this is the memory driver: caught, not sent, so the `/_x` mail panel can
|
|
157
233
|
// show what a template renders in every locale without a mailbox or a message escaping to a
|
|
158
234
|
// real address. `SMTP_URL` or `RESEND_API_KEY` makes it a real transport instead — the same
|
|
159
235
|
// "an unset variable means the embedded default" law the other three bindings follow.
|
|
160
|
-
const mail = selection.driver;
|
|
236
|
+
const mail = overrides?.mail ?? selection.driver;
|
|
161
237
|
setMailDriver(mail);
|
|
162
238
|
started.push(() => resetMailDriver());
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
started.push(() => resetTiers());
|
|
172
|
-
}
|
|
239
|
+
const purge = overrides?.purge ?? cdn.driver;
|
|
240
|
+
// Every tier this process reads through, plus the cross-instance invalidation hop, in one
|
|
241
|
+
// call. The CDN tier used to be the only one registered here — so `createRedisTier`,
|
|
242
|
+
// `createLruTier` and `createMemoTier` shipped with no caller at all and every replica
|
|
243
|
+
// recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
|
|
244
|
+
// this boot is the only thing that registers one, and a tier left behind would purge for a
|
|
245
|
+
// process that has stopped.
|
|
246
|
+
started.push(startCacheTiers({ env, purge, transport }));
|
|
173
247
|
|
|
174
248
|
return {
|
|
175
249
|
services,
|
|
176
250
|
db,
|
|
177
251
|
jobs,
|
|
252
|
+
outbox,
|
|
178
253
|
events,
|
|
179
|
-
transport
|
|
254
|
+
transport,
|
|
180
255
|
storage,
|
|
181
256
|
mail,
|
|
182
257
|
mailDetail: selection.detail,
|
|
183
|
-
|
|
258
|
+
// The env key that selected the bus — or the honest answer that no env key did, because a
|
|
259
|
+
// boot line reading `NATS_URL` over a transport the host handed in is a lie a script parses.
|
|
260
|
+
transportDetail: overrides?.transport === undefined ? bus.detail : 'runtime override',
|
|
184
261
|
presenceTtlMs: bus.presenceTtlMs,
|
|
185
|
-
purge
|
|
262
|
+
purge,
|
|
186
263
|
purgeDetail: cdn.detail,
|
|
187
264
|
// The same list the boot unwind uses, in the same reverse order, so a service added to the
|
|
188
265
|
// boot is released by both paths — a second copy of these steps is how one of them came to
|