@ultimat3/cli 7.0.0 → 9.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 +24 -4
- package/README.md +8 -3
- package/package.json +26 -25
- package/src/app-boundaries.ts +55 -5
- package/src/app-load.ts +7 -0
- package/src/bin.ts +6 -3
- package/src/ci-log.ts +0 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +43 -6
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +55 -4
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +68 -6
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-verify.ts +47 -6
- package/src/dev-assets.ts +4 -7
- package/src/dev-cache.ts +130 -33
- package/src/dev-lock.ts +124 -12
- package/src/dev-purge.ts +120 -0
- package/src/dev-queue.ts +39 -9
- package/src/dev-render.ts +11 -14
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +137 -6
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +5 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -6
- package/src/island-styles.ts +1 -1
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +12 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/prerender.ts +2 -1
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +12 -4
- package/src/serve.ts +1 -1
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- package/src/solid-loader.ts +1 -1
- package/src/style-csp.ts +2 -1
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/island.ts +2 -1
- package/src/templates/route.ts +1 -1
- package/src/templates/scaffold-app.ts +3 -82
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +14 -6
- package/src/templates/scaffold-docs.ts +34 -16
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +40 -7
- package/src/test-select.ts +4 -3
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/write-line.ts +23 -5
package/src/dev-cache.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Which cache tiers this process reads through, and the hop that tells the other replicas what it
|
|
2
|
-
// just dropped.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// just dropped. The ladder is exactly what `cache.tiers` names: a rung the config lists is built or
|
|
3
|
+
// the boot refuses, and a rung it does not list is never built — this file used to register memo
|
|
4
|
+
// and lru unconditionally, redis on `REDIS_URL` and cdn on any real purge driver, so the key was
|
|
5
|
+
// declared, validated at boot, documented, and read by nothing.
|
|
6
6
|
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
7
9
|
import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
|
|
8
10
|
import {
|
|
11
|
+
CacheDriverUnavailableError,
|
|
9
12
|
createCdnTier,
|
|
10
13
|
createLruTier,
|
|
11
14
|
createMemoTier,
|
|
@@ -16,8 +19,11 @@ import {
|
|
|
16
19
|
registerTier,
|
|
17
20
|
resetTiers,
|
|
18
21
|
} from '@ultimat3/cache';
|
|
19
|
-
import {
|
|
20
|
-
import
|
|
22
|
+
import type { CacheTierName } from '@ultimat3/core';
|
|
23
|
+
import { CACHE_TIERS, defineConfig, logger } from '@ultimat3/core';
|
|
24
|
+
import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
|
|
25
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
26
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
21
27
|
import type { Env } from './dev-services';
|
|
22
28
|
|
|
23
29
|
/**
|
|
@@ -29,24 +35,125 @@ export const CACHE_INVALIDATE_SUBJECT = 'x.cache.invalidate';
|
|
|
29
35
|
|
|
30
36
|
export interface CacheTiersOptions {
|
|
31
37
|
readonly env: Env;
|
|
32
|
-
/** Already resolved by the boot —
|
|
38
|
+
/** Already resolved by the boot — a `cdn` rung is built against a real edge or not at all. */
|
|
33
39
|
readonly purge: PurgeDriver;
|
|
34
40
|
readonly transport: Transport;
|
|
41
|
+
/**
|
|
42
|
+
* `config.cache.tiers`, verbatim. REQUIRED, and that is the enforcement (axiom 3): a boot that
|
|
43
|
+
* has not read the app's declaration cannot call this function at all — it is a type error, not
|
|
44
|
+
* a convention to remember. `loadCacheTiers` is what a boot holding only a root calls for it.
|
|
45
|
+
*/
|
|
46
|
+
readonly tiers: readonly CacheTierName[];
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
/**
|
|
38
|
-
* The
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
50
|
+
* The rungs an app that declares none gets — ASKED of `defineConfig` rather than written out, for
|
|
51
|
+
* two reasons that point the same way: `defaults()` is private to `@ultimat3/core`, and a second
|
|
52
|
+
* literal list of rung names is a second vocabulary that `bun run render-modes` refuses (it caught
|
|
53
|
+
* exactly that here). One source, so the default cannot drift into a second ladder.
|
|
42
54
|
*/
|
|
43
|
-
|
|
55
|
+
export const DEFAULT_CACHE_TIERS: readonly CacheTierName[] = defineConfig({
|
|
56
|
+
name: 'cache-defaults',
|
|
57
|
+
}).cache.tiers;
|
|
58
|
+
|
|
59
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
60
|
+
typeof value === 'object' && value !== null;
|
|
61
|
+
|
|
62
|
+
const isTierList = (value: unknown): value is readonly CacheTierName[] =>
|
|
63
|
+
Array.isArray(value) && value.every((entry) => CACHE_TIERS.some((name) => name === entry));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `cache.tiers` out of the app's own `app.config.ts` — the sibling of `app-auth.ts`'s
|
|
67
|
+
* `loadSignInPath`, and structural for the same reason: `defineConfig` returns a plain object, and
|
|
68
|
+
* a config that resolved through an older core simply has no `cache` section.
|
|
69
|
+
*
|
|
70
|
+
* A list this refuses cannot come from `defineConfig` — `validate()` rejects an unknown rung one
|
|
71
|
+
* `await import` above this line — so the fallback is for a hand-written config object, and the
|
|
72
|
+
* two rungs every app starts from are the honest answer for one.
|
|
73
|
+
*/
|
|
74
|
+
export async function loadCacheTiers(root: string): Promise<readonly CacheTierName[]> {
|
|
75
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
76
|
+
if (!existsSync(configPath)) return DEFAULT_CACHE_TIERS;
|
|
77
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
78
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
79
|
+
if (!isRecord(config)) return DEFAULT_CACHE_TIERS;
|
|
80
|
+
const cache = config['cache'];
|
|
81
|
+
if (!isRecord(cache)) return DEFAULT_CACHE_TIERS;
|
|
82
|
+
const { tiers } = cache;
|
|
83
|
+
return isTierList(tiers) ? tiers : DEFAULT_CACHE_TIERS;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `REDIS_URL` is the same "an unset variable means the embedded default" law the db, events,
|
|
88
|
+
* storage, mail and CDN bindings follow — and it is the variable Bun's own `Bun.redis` reads, so a
|
|
89
|
+
* tier selected here and a client built there cannot point at two servers.
|
|
90
|
+
*/
|
|
91
|
+
function redisUrl(env: Env): string | undefined {
|
|
44
92
|
const url = env['REDIS_URL']?.trim();
|
|
45
|
-
return url === undefined || url === '' ? undefined :
|
|
93
|
+
return url === undefined || url === '' ? undefined : url;
|
|
46
94
|
}
|
|
47
95
|
|
|
48
96
|
/**
|
|
49
|
-
*
|
|
97
|
+
* One rung, or a refusal. A process that cannot build what it was configured to build must not
|
|
98
|
+
* start — `assertRateLimitScope`'s rule (`@ultimat3/http`), applied to the ladder: a fleet reading
|
|
99
|
+
* a per-process cache while `cache.tiers` declares a shared one is a stale-and-slow deployment
|
|
100
|
+
* that looks like a performance problem for a week.
|
|
101
|
+
*
|
|
102
|
+
* `X_CACHE_DRIVER_UNAVAILABLE` is BORROWED from `@ultimat3/cache` rather than twinned: "this tier
|
|
103
|
+
* cannot be built here" is what that code already means, and its shipped `fix:` is this one. The
|
|
104
|
+
* precedent is `dev-assets.ts` throwing `@ultimat3/pwa`'s `PwaIconMissingError`.
|
|
105
|
+
*/
|
|
106
|
+
function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
107
|
+
switch (name) {
|
|
108
|
+
case 'request-memo':
|
|
109
|
+
return createMemoTier();
|
|
110
|
+
case 'lru':
|
|
111
|
+
return createLruTier();
|
|
112
|
+
case 'redis':
|
|
113
|
+
if (redisUrl(options.env) === undefined) {
|
|
114
|
+
throw new CacheDriverUnavailableError({
|
|
115
|
+
driver: 'redis',
|
|
116
|
+
cause:
|
|
117
|
+
'cache.tiers names it and REDIS_URL is unset, so this process would read a per-process ladder while the config declares a shared one',
|
|
118
|
+
fix: 'set REDIS_URL in .env, or drop the redis tier from cache.tiers in app.config.ts',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return createRedisTier();
|
|
122
|
+
case 'cdn':
|
|
123
|
+
// A noop tier would put a `cdn` line in every invalidation report claiming keys an edge that
|
|
124
|
+
// does not exist had accepted — and the `/_x` cache panel renders those reports, so the lie
|
|
125
|
+
// would be the thing an agent reads.
|
|
126
|
+
if (isNoopPurgeDriver(options.purge)) {
|
|
127
|
+
throw new CacheDriverUnavailableError({
|
|
128
|
+
driver: 'cdn',
|
|
129
|
+
cause:
|
|
130
|
+
'cache.tiers names it and no CDN credential is set, so every invalidation report would claim keys an edge that does not exist had accepted',
|
|
131
|
+
fix: 'set FASTLY_API_TOKEN and FASTLY_SERVICE_ID in .env, or CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID, or drop the cdn tier from cache.tiers in app.config.ts',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return createCdnTier({ purge: options.purge });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The one case where the declaration and the environment disagree and the boot still proceeds: a
|
|
140
|
+
* credential is set for a rung `cache.tiers` does not name. The config wins — it is the
|
|
141
|
+
* declaration, an env var is deployment detail — but silently paying for a Redis or an edge
|
|
142
|
+
* nothing reads is the same class of surprise the refusals above exist for, so it is said out loud.
|
|
143
|
+
*/
|
|
144
|
+
function warnUnnamed(options: CacheTiersOptions): void {
|
|
145
|
+
const named = new Set(options.tiers);
|
|
146
|
+
if (!named.has('redis') && redisUrl(options.env) !== undefined) {
|
|
147
|
+
logger.warn('cache.tier.unnamed', { tier: 'redis', source: 'REDIS_URL' });
|
|
148
|
+
}
|
|
149
|
+
if (!named.has('cdn') && !isNoopPurgeDriver(options.purge)) {
|
|
150
|
+
logger.warn('cache.tier.unnamed', { tier: 'cdn', source: options.purge.name });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Register the tiers the app declared, wire both halves of cross-instance invalidation, and return
|
|
156
|
+
* the release.
|
|
50
157
|
*
|
|
51
158
|
* The outbound half publishes the wire tags this process just dropped; the inbound half applies
|
|
52
159
|
* another instance's. A message this process published is delivered back to it on every real bus
|
|
@@ -56,25 +163,15 @@ function sharedTier(env: Env): CacheTier | undefined {
|
|
|
56
163
|
* suppresses it, and `emit` is not a public parameter.
|
|
57
164
|
*/
|
|
58
165
|
export function startCacheTiers(options: CacheTiersOptions): () => Promise<void> {
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// not decide read order — `sortTiers` does
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// `invalidateTags` fans out to registered tiers and to nothing else — so a read cache holding
|
|
69
|
-
// entries of its own was a `cache:` query an action's `invalidates` could never bust. The seam
|
|
70
|
-
// is gone: a `cache:` read fills the ladder registered here, so there is one registry and one
|
|
71
|
-
// fan-out and no wiring to get wrong.
|
|
72
|
-
// Registered only when a credential named a real edge. A noop tier would put a `cdn` line in
|
|
73
|
-
// every invalidation report claiming keys an edge that does not exist had accepted — and the
|
|
74
|
-
// `/_x` cache panel renders those reports, so the lie would be the thing an agent reads.
|
|
75
|
-
if (!isNoopPurgeDriver(options.purge)) {
|
|
76
|
-
registerTier(createCdnTier({ purge: options.purge }));
|
|
77
|
-
}
|
|
166
|
+
// Built before ANY of them is registered: a refusal halfway through a list would leave the
|
|
167
|
+
// process-global registry holding the rungs that came first, with no release returned to drop
|
|
168
|
+
// them. Registration order does not decide read order — `sortTiers` does, by `CACHE_TIERS`.
|
|
169
|
+
// Nothing installs a read tier beyond these, and that is the point: `invalidateTags` fans out to
|
|
170
|
+
// registered tiers and to nothing else, so a read cache holding entries of its own was a
|
|
171
|
+
// `cache:` query an action's `invalidates` could never bust.
|
|
172
|
+
const built = options.tiers.map((name) => buildTier(name, options));
|
|
173
|
+
for (const tier of built) registerTier(tier);
|
|
174
|
+
warnUnnamed(options);
|
|
78
175
|
|
|
79
176
|
registerInvalidationBroadcast(async (wireTags) => {
|
|
80
177
|
await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
|
package/src/dev-lock.ts
CHANGED
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
// The lock file is what makes the second one nameable at all: nothing else in the process can tell
|
|
15
15
|
// "another dev server owns this directory" from "the database is broken".
|
|
16
16
|
|
|
17
|
-
import { unlinkSync } from 'node:fs';
|
|
17
|
+
import { closeSync, mkdirSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { UltimateError } from '@ultimat3/core';
|
|
20
20
|
import { docsFor } from './error-codes';
|
|
21
21
|
import { exec, type Runner } from './exec';
|
|
22
|
+
import { quoteArg } from './shell-quote';
|
|
22
23
|
|
|
23
24
|
/** Where the running dev server records itself, inside the state directory it already owns. */
|
|
24
25
|
export const DEV_LOCK_FILE = 'dev.lock';
|
|
@@ -101,6 +102,28 @@ export class DevAlreadyRunningError extends UltimateError {
|
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The lock file exists, this process could not read it and could not remove it — which is not
|
|
107
|
+
* "another x dev is running", and was reported as exactly that.
|
|
108
|
+
*
|
|
109
|
+
* `DevAlreadyRunningError` needs a `DevLock`, and the only one in hand on that path was `mine`:
|
|
110
|
+
* the refusal then read `pid <this process> is already running x dev` with `fix: … kill <this
|
|
111
|
+
* process>`, a remedy that kills the reader and a cause naming the wrong holder. Axiom 4 wants a
|
|
112
|
+
* runnable fix and an honest cause, so the honest answer is its own code — the file is the
|
|
113
|
+
* problem, and removing it is what a reader can actually do.
|
|
114
|
+
*/
|
|
115
|
+
export class DevLockUnreadableError extends UltimateError {
|
|
116
|
+
constructor(input: { readonly path: string; readonly stateDir: string }) {
|
|
117
|
+
super({
|
|
118
|
+
code: 'X_DEV_LOCK_UNREADABLE',
|
|
119
|
+
cause: `${input.path} could not be parsed as a dev lock and could not be removed, so x dev cannot tell whether another process owns ${input.stateDir}`,
|
|
120
|
+
fix: `rm ${quoteArg(input.path)} # then re-run x dev`,
|
|
121
|
+
docs: docsFor('X_DEV_LOCK_UNREADABLE'),
|
|
122
|
+
meta: { path: input.path, stateDir: input.stateDir },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
104
127
|
/** Whatever is listening, as far as the OS will say. Both fields absent when it will not say. */
|
|
105
128
|
export interface PortHolder {
|
|
106
129
|
readonly pid?: number;
|
|
@@ -207,6 +230,41 @@ export const isPortBound = (port: number, hostname: string): boolean => {
|
|
|
207
230
|
}
|
|
208
231
|
};
|
|
209
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Take the lock, or answer `false` because someone else holds it.
|
|
235
|
+
*
|
|
236
|
+
* `wx` is the whole mechanism: the create and the exclusivity are ONE syscall, so two boots racing
|
|
237
|
+
* this cannot both come back `true`. A check followed by a write is what this replaces, and the
|
|
238
|
+
* window between those two was seconds wide — `startDev` boots embedded Postgres, the queue, the
|
|
239
|
+
* transport and the app's modules before anything was written down.
|
|
240
|
+
*
|
|
241
|
+
* Only `EEXIST` is "someone else has it". Anything else — a read-only checkout, a `.x/` nobody may
|
|
242
|
+
* write — is rethrown as it arrives: it is the same failure `writeLock` would have raised seconds
|
|
243
|
+
* later, and inventing a code for it here would be a second answer to one condition.
|
|
244
|
+
*/
|
|
245
|
+
function claimExclusive(path: string, lock: DevLock): boolean {
|
|
246
|
+
let fd: number;
|
|
247
|
+
try {
|
|
248
|
+
fd = openSync(path, 'wx');
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if ((error as { code?: string }).code === 'EEXIST') return false;
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
writeFileSync(fd, `${JSON.stringify(lock, null, 2)}\n`);
|
|
255
|
+
} finally {
|
|
256
|
+
closeSync(fd);
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Whatever is on disk right now, or `null` if it is absent or half-written. */
|
|
262
|
+
const readLock = async (path: string): Promise<DevLock | null> => {
|
|
263
|
+
const file = Bun.file(path);
|
|
264
|
+
if (!(await file.exists())) return null;
|
|
265
|
+
return parseLock(await file.text());
|
|
266
|
+
};
|
|
267
|
+
|
|
210
268
|
export interface PreflightInput {
|
|
211
269
|
readonly stateDir: string;
|
|
212
270
|
readonly port: number;
|
|
@@ -220,22 +278,53 @@ export interface PreflightInput {
|
|
|
220
278
|
readonly holder?: (port: number) => Promise<PortHolder>;
|
|
221
279
|
}
|
|
222
280
|
|
|
281
|
+
export interface PreflightResult {
|
|
282
|
+
readonly clearedStale: boolean;
|
|
283
|
+
/**
|
|
284
|
+
* Give the directory back. The caller runs it when the boot it was preflighting FAILED —
|
|
285
|
+
* `serve.ts`'s `releaseBoot` shape — because a claim held by a process that gave up refuses
|
|
286
|
+
* every later boot in that shell for a pid that is gone.
|
|
287
|
+
*/
|
|
288
|
+
release(): void;
|
|
289
|
+
}
|
|
290
|
+
|
|
223
291
|
/**
|
|
224
|
-
* Run before anything boots
|
|
225
|
-
*
|
|
292
|
+
* Run before anything boots, and it CLAIMS: it returns holding the directory, never having merely
|
|
293
|
+
* looked at it.
|
|
294
|
+
*
|
|
295
|
+
* That is the fix, not a detail. `preflight` read the lock, `startDev` booted for seconds, and
|
|
296
|
+
* `writeLock` ran last — so two `x dev` in one checkout both passed the check and both opened
|
|
297
|
+
* `.x/pgdata`, `X_DEV_ALREADY_RUNNING` was unreachable, and what the operator actually got was
|
|
298
|
+
* `X_DB_UNAVAILABLE` whose `fix:` reads "run `x dev`" — the incident this file's own header
|
|
299
|
+
* records. The claim closes the window it names.
|
|
300
|
+
*
|
|
301
|
+
* The stale path still exists and is still normal (a hard kill leaves a lock behind): the file is
|
|
302
|
+
* removed and the claim retried once. A retry that ALSO loses is another boot that claimed the
|
|
303
|
+
* cleared slot in that instant, which is a refusal and not a third boot.
|
|
226
304
|
*/
|
|
227
|
-
export const preflight = async (input: PreflightInput): Promise<
|
|
305
|
+
export const preflight = async (input: PreflightInput): Promise<PreflightResult> => {
|
|
228
306
|
const alive = input.alive ?? isProcessAlive;
|
|
229
307
|
const bound = input.portBound ?? isPortBound;
|
|
230
308
|
const path = lockPath(input.stateDir);
|
|
231
|
-
const
|
|
309
|
+
const release = (): void => clearLock(input.stateDir);
|
|
310
|
+
// The lock lives inside the state directory, which the database has not created yet — this runs
|
|
311
|
+
// before anything boots, which is the whole point of it.
|
|
312
|
+
mkdirSync(input.stateDir, { recursive: true });
|
|
313
|
+
const mine: DevLock = {
|
|
314
|
+
pid: process.pid,
|
|
315
|
+
port: input.port,
|
|
316
|
+
// Refined by `writeLock` once the server reports the address it really bound; until then this
|
|
317
|
+
// is what the refusal prints, and it is the address the boot is about to ask for.
|
|
318
|
+
url: `http://${input.hostname}:${input.port}`,
|
|
319
|
+
startedAt: new Date().toISOString(),
|
|
320
|
+
};
|
|
232
321
|
let clearedStale = false;
|
|
233
322
|
|
|
234
|
-
if (
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
323
|
+
if (!claimExclusive(path, mine)) {
|
|
324
|
+
const held = await readLock(path);
|
|
325
|
+
if (held !== null && alive(held.pid)) {
|
|
237
326
|
throw new DevAlreadyRunningError({
|
|
238
|
-
lock,
|
|
327
|
+
lock: held,
|
|
239
328
|
stateDir: input.stateDir,
|
|
240
329
|
...(input.embeddedDb === undefined ? {} : { embeddedDb: input.embeddedDb }),
|
|
241
330
|
});
|
|
@@ -245,22 +334,45 @@ export const preflight = async (input: PreflightInput): Promise<{ clearedStale:
|
|
|
245
334
|
try {
|
|
246
335
|
unlinkSync(path);
|
|
247
336
|
} catch {
|
|
248
|
-
// Already gone, or not ours to remove.
|
|
337
|
+
// Already gone, or not ours to remove. The claim below is what decides.
|
|
249
338
|
}
|
|
250
339
|
clearedStale = true;
|
|
340
|
+
if (!claimExclusive(path, mine)) {
|
|
341
|
+
// Lost the race for the slot we just cleared. `held` is the best identity available — a
|
|
342
|
+
// half-written file parses as `null` for microseconds — and refusing on a stale pid beats
|
|
343
|
+
// the alternative, which is two processes writing one single-writer data directory.
|
|
344
|
+
//
|
|
345
|
+
// NEVER `mine`, which is what it fell back to: with an unparseable lock and a failed
|
|
346
|
+
// `unlinkSync`, both reads answer `null` and the refusal named THIS pid as the holder —
|
|
347
|
+
// `kill <self>` as the remedy for a file nobody could read.
|
|
348
|
+
const holder = (await readLock(path)) ?? held;
|
|
349
|
+
if (holder === null) throw new DevLockUnreadableError({ path, stateDir: input.stateDir });
|
|
350
|
+
throw new DevAlreadyRunningError({
|
|
351
|
+
lock: holder,
|
|
352
|
+
stateDir: input.stateDir,
|
|
353
|
+
...(input.embeddedDb === undefined ? {} : { embeddedDb: input.embeddedDb }),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
251
356
|
}
|
|
252
357
|
|
|
253
358
|
if (bound(input.port, input.hostname)) {
|
|
359
|
+
// Released before the throw: the remedy this refusal prints is `x dev --port <n>`, and a claim
|
|
360
|
+
// left behind would answer that command with X_DEV_ALREADY_RUNNING naming the process that
|
|
361
|
+
// just exited on it.
|
|
362
|
+
release();
|
|
254
363
|
throw new DevPortInUseError({
|
|
255
364
|
port: input.port,
|
|
256
365
|
suggestion: suggestPort(input.port),
|
|
257
366
|
holder: await (input.holder ?? portHolder)(input.port),
|
|
258
367
|
});
|
|
259
368
|
}
|
|
260
|
-
return { clearedStale };
|
|
369
|
+
return { clearedStale, release };
|
|
261
370
|
};
|
|
262
371
|
|
|
263
|
-
/**
|
|
372
|
+
/**
|
|
373
|
+
* Record this process. The claim is already on disk — `preflight` took it — so this REFINES it
|
|
374
|
+
* with the address the server actually bound, which is the one field the preflight could not know.
|
|
375
|
+
*/
|
|
264
376
|
export const writeLock = async (stateDir: string, lock: DevLock): Promise<void> => {
|
|
265
377
|
await Bun.write(lockPath(stateDir), `${JSON.stringify(lock, null, 2)}\n`);
|
|
266
378
|
};
|
package/src/dev-purge.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The retention sweep this boot owns: the three framework tables that grow with traffic, the
|
|
2
|
+
// `purge()` job that empties them and the `task` that fires it hourly.
|
|
3
|
+
//
|
|
4
|
+
// WHY here and not in the packages that own the tables: `postgresIdempotencyStore` (tier 3),
|
|
5
|
+
// `postgresRateLimitStore` (tier 2) and `postgresAuthLimiter` (tier 2) cannot see each other and
|
|
6
|
+
// none of them may import `@ultimat3/jobs`. Boot is the one place that holds all three, which is
|
|
7
|
+
// the same reason it is boot that applies their DDL and installs them.
|
|
8
|
+
//
|
|
9
|
+
// WHY they had no caller at all until now: each store shipped a `purgeExpired()` documented as
|
|
10
|
+
// "an app runs this from a `task`" — and a task only ENQUEUES, so there was no job for one to
|
|
11
|
+
// enqueue and no app wrote either half. `x_rate_limit` takes one upsert per HTTP request the web
|
|
12
|
+
// role serves, assets included, so every deployment the framework produces was accumulating a row
|
|
13
|
+
// per source address forever.
|
|
14
|
+
|
|
15
|
+
import type { PostgresIdempotencyStore } from '@ultimat3/action';
|
|
16
|
+
import { purgeAuthLimits } from '@ultimat3/auth';
|
|
17
|
+
import type { PostgresRateLimitStore } from '@ultimat3/http';
|
|
18
|
+
import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
|
|
19
|
+
import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
|
|
20
|
+
|
|
21
|
+
/** The durable queue key. Pinned, like every framework-owned job name — rows carry it. */
|
|
22
|
+
export const PURGE_JOB_NAME = 'x.purge';
|
|
23
|
+
/** The scheduler's key for the same sweep: `lastFiredAt` and the occurrence lock read it. */
|
|
24
|
+
export const PURGE_TASK_NAME = 'x.purge.hourly';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The two stores this boot BUILT, handed over rather than rebuilt here. A second
|
|
28
|
+
* `postgresIdempotencyStore({ executor })` would sweep on the default window even where the boot
|
|
29
|
+
* had configured another — two answers to "how long is a record kept", and the shorter one
|
|
30
|
+
* deletes reservations a retry is still entitled to.
|
|
31
|
+
*
|
|
32
|
+
* The auth limiter is absent on purpose: it does not exist yet at boot. `defineAuth` builds it
|
|
33
|
+
* through the factory `configureAuthLimiters` installed, and that happens when the app's modules
|
|
34
|
+
* import — after this. `purgeAuthLimits()` is the seam that reads whatever was built.
|
|
35
|
+
*/
|
|
36
|
+
export interface RetentionStores {
|
|
37
|
+
readonly idempotency: PostgresIdempotencyStore;
|
|
38
|
+
readonly rateLimit: PostgresRateLimitStore;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read per attempt by the job, and emptied by the disposer `installRetentionSweep` returns: a
|
|
43
|
+
* sweep left declared after its boot has stopped must not reach through a closed pool. Module
|
|
44
|
+
* scope for the reason `setJobDriver` and `configureKdfGate` are — a process has one boot at a
|
|
45
|
+
* time, and the registry the job lives in is process-wide too.
|
|
46
|
+
*/
|
|
47
|
+
let installed: readonly PurgeTarget[] = [];
|
|
48
|
+
|
|
49
|
+
/** Declared once per process and re-declared only if a `resetJobs()` took it out of the registry. */
|
|
50
|
+
let sweep: JobHandle<PurgeInput> | undefined;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `x_auth_failures` and `x_auth_lockouts` together, under the prefix they share: one target,
|
|
54
|
+
* because `purgeAuthLimits()` clears both in one statement and two targets under one pair of
|
|
55
|
+
* tables would be a second delete that removes nothing.
|
|
56
|
+
*/
|
|
57
|
+
const authTarget: PurgeTarget = {
|
|
58
|
+
name: 'x_auth',
|
|
59
|
+
// No `nowMs`: a limiter built through the seam holds the clock its host handed it, which is the
|
|
60
|
+
// clock every `at_ms` in those tables was written from.
|
|
61
|
+
purgeExpired: () => purgeAuthLimits(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
name: 'x_idempotency',
|
|
68
|
+
purgeExpired: (): Promise<number> => stores.idempotency.purgeExpired(),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'x_rate_limit',
|
|
72
|
+
// The job's clock, not the server's. `last_ms` is written by whichever process took the
|
|
73
|
+
// token, so a purge measured against `now()` in Postgres computes a refill from the offset
|
|
74
|
+
// between two clocks — and against a frozen one it read 20,000,000 seconds of refill and
|
|
75
|
+
// deleted a bucket holding 0 of 4 tokens, which is a free limit reset from the cleanup.
|
|
76
|
+
purgeExpired: (nowMs: number): Promise<number> => stores.rateLimit.purgeExpired(nowMs),
|
|
77
|
+
},
|
|
78
|
+
authTarget,
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Declared lazily and guarded on the registry rather than on a module flag alone: `resetJobs()` /
|
|
84
|
+
* `resetTasks()` empty the registries a test shares with the next boot, and a memoised handle that
|
|
85
|
+
* is no longer seated is one the worker's `getJob` can never find.
|
|
86
|
+
*/
|
|
87
|
+
function declareSweep(): void {
|
|
88
|
+
if (getJob(PURGE_JOB_NAME) === undefined) {
|
|
89
|
+
sweep = purge({ name: PURGE_JOB_NAME, targets: () => installed });
|
|
90
|
+
}
|
|
91
|
+
const handle = sweep;
|
|
92
|
+
if (handle !== undefined && getTask(PURGE_TASK_NAME) === undefined) {
|
|
93
|
+
task({
|
|
94
|
+
name: PURGE_TASK_NAME,
|
|
95
|
+
cron: DEFAULT_PURGE_CRON,
|
|
96
|
+
// The one zone a framework-shipped schedule may name: an app's business hours are the app's,
|
|
97
|
+
// and a retention sweep has none. `tz` is required by `task()` and never inferred.
|
|
98
|
+
tz: 'UTC',
|
|
99
|
+
// `skip`, the default: a scheduler that was down across four occurrences has four sweeps'
|
|
100
|
+
// worth of expired rows in one table, and one pass removes all of them. Replaying the missed
|
|
101
|
+
// occurrences would be three passes that each delete nothing.
|
|
102
|
+
enqueue: () => [[handle, {}]],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Put the sweep on the queue's registry and its schedule, and answer the release.
|
|
109
|
+
*
|
|
110
|
+
* IF NOTHING EVER RUNS IT the tables grow exactly as they did before — the sweep is a `job`, so it
|
|
111
|
+
* needs a `worker` to claim it and a `scheduler` to enqueue it. A deployment with neither has no
|
|
112
|
+
* background work at all, and this is one more thing it does not do.
|
|
113
|
+
*/
|
|
114
|
+
export function installRetentionSweep(stores: RetentionStores): () => void {
|
|
115
|
+
installed = retentionTargets(stores);
|
|
116
|
+
declareSweep();
|
|
117
|
+
return () => {
|
|
118
|
+
installed = [];
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/dev-queue.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
// a driver over a closed socket.
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
type PostgresIdempotencyStore,
|
|
8
9
|
postgresIdempotencyStore,
|
|
9
10
|
resetIdempotency,
|
|
10
11
|
SQL_IDEMPOTENCY_TABLE,
|
|
11
12
|
setIdempotencyStore,
|
|
12
13
|
} from '@ultimat3/action';
|
|
14
|
+
import { SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
|
|
13
15
|
import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
|
|
14
16
|
import {
|
|
15
17
|
createPgliteClient,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
setDbClient,
|
|
21
23
|
} from '@ultimat3/db';
|
|
22
24
|
import type { Tx } from '@ultimat3/entity';
|
|
25
|
+
import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
|
|
23
26
|
import type { EventBus, JobDriver, OutboxStore, PgExecutor } from '@ultimat3/jobs';
|
|
24
27
|
import {
|
|
25
28
|
createJobsFacade,
|
|
@@ -49,6 +52,13 @@ export interface RunningQueue {
|
|
|
49
52
|
readonly outbox: OutboxStore;
|
|
50
53
|
/** The `x_job_events` bus a `step.waitForEvent` resumes from. Durable, not per-process. */
|
|
51
54
|
readonly events: EventBus;
|
|
55
|
+
/**
|
|
56
|
+
* The `x_idempotency` store this boot installed behind `idempotent: true`. Returned for the
|
|
57
|
+
* reason `outbox` is: the retention sweep over that table is a ROLE's work, not the queue's,
|
|
58
|
+
* and it has to sweep the store that was INSTALLED — a second one built beside it would purge
|
|
59
|
+
* on the default window even where this boot had configured another.
|
|
60
|
+
*/
|
|
61
|
+
readonly idempotency: PostgresIdempotencyStore;
|
|
52
62
|
stop(): Promise<void>;
|
|
53
63
|
}
|
|
54
64
|
|
|
@@ -82,16 +92,28 @@ export function pgExecutorFor(client: DbClient): PgExecutor {
|
|
|
82
92
|
* Every table this process's framework packages own, applied before anything reads one.
|
|
83
93
|
*
|
|
84
94
|
* PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL is
|
|
85
|
-
* applied statement by statement. Safe to split on `;`:
|
|
86
|
-
*
|
|
95
|
+
* applied statement by statement. Safe to split on `;`: every constant is fixed, with no semicolon
|
|
96
|
+
* inside a literal, and each package's own SQL test is where that stays true.
|
|
87
97
|
*
|
|
88
|
-
* `SQL_IDEMPOTENCY_TABLE`
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
98
|
+
* `SQL_IDEMPOTENCY_TABLE`, `SQL_RATE_LIMIT_TABLE` and `SQL_AUTH_LIMIT_TABLES` are here and not in
|
|
99
|
+
* `@ultimat3/action`, `@ultimat3/http` or `@ultimat3/auth` because a package that holds no
|
|
100
|
+
* database dependency cannot apply its own schema
|
|
101
|
+
* — the same reason `SQL_JOBS_TABLE` is applied here. Each one absent is the same failure at a
|
|
102
|
+
* different door: a retried `POST /api/payments/charge` charges the card twice, and the FIRST
|
|
103
|
+
* request a `rateLimitStore` deployment serves dies on a missing `x_rate_limit` relation. The
|
|
104
|
+
* table is installed whether or not this boot passes `runtime.rateLimitStore` — `create table if
|
|
105
|
+
* not exists` on an unused table costs one round trip at boot, and a store installed later must
|
|
106
|
+
* not be the thing that discovers the schema was never applied. The auth pair is the strongest
|
|
107
|
+
* case for that rule: `defineAuth` builds its limiter when the APP's modules import, which is
|
|
108
|
+
* after this, so the first failed sign-in would otherwise be what discovers the missing relation.
|
|
92
109
|
*/
|
|
93
110
|
async function applySchema(client: DevDbClient): Promise<void> {
|
|
94
|
-
for (const ddl of [
|
|
111
|
+
for (const ddl of [
|
|
112
|
+
SQL_JOBS_TABLE,
|
|
113
|
+
SQL_IDEMPOTENCY_TABLE,
|
|
114
|
+
SQL_RATE_LIMIT_TABLE,
|
|
115
|
+
SQL_AUTH_LIMIT_TABLES,
|
|
116
|
+
]) {
|
|
95
117
|
for (const statement of ddl.split(';')) {
|
|
96
118
|
if (statement.trim().length > 0) await client.execute(raw(statement));
|
|
97
119
|
}
|
|
@@ -141,8 +163,16 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
|
|
|
141
163
|
);
|
|
142
164
|
const events = createPgEventBus({ executor });
|
|
143
165
|
setEventBus(events);
|
|
144
|
-
|
|
145
|
-
|
|
166
|
+
const idempotency = postgresIdempotencyStore({ executor });
|
|
167
|
+
setIdempotencyStore(idempotency);
|
|
168
|
+
return {
|
|
169
|
+
db: client,
|
|
170
|
+
jobs: driver,
|
|
171
|
+
outbox,
|
|
172
|
+
events,
|
|
173
|
+
idempotency,
|
|
174
|
+
stop: () => releaseQueue(client, driver),
|
|
175
|
+
};
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
/**
|
package/src/dev-render.ts
CHANGED
|
@@ -11,32 +11,29 @@ import type { Ctx } from '@ultimat3/core';
|
|
|
11
11
|
import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
|
|
12
12
|
import { asCtx, html, stream } from '@ultimat3/http';
|
|
13
13
|
import { currentLocale } from '@ultimat3/i18n';
|
|
14
|
-
import type {
|
|
15
|
-
IslandCollector,
|
|
16
|
-
IsrController,
|
|
17
|
-
RenderResult,
|
|
18
|
-
RouteData,
|
|
19
|
-
RouteEntry,
|
|
20
|
-
} from '@ultimat3/render';
|
|
14
|
+
import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
21
15
|
import {
|
|
22
|
-
contentHash,
|
|
23
16
|
createIslandCollector,
|
|
24
|
-
createIsrController,
|
|
25
17
|
headFromMeta,
|
|
26
18
|
hydrateRuntime,
|
|
27
|
-
isrKey,
|
|
28
19
|
metaContextFor,
|
|
29
|
-
ROOT_ELEMENT_ID,
|
|
30
|
-
renderComponent,
|
|
31
20
|
renderHead,
|
|
32
|
-
renderSsr,
|
|
33
21
|
routeDataFor,
|
|
34
22
|
routeEntries,
|
|
35
23
|
seoRenderers,
|
|
24
|
+
} from '@ultimat3/render';
|
|
25
|
+
import type { IsrController } from '@ultimat3/render/server';
|
|
26
|
+
import {
|
|
27
|
+
contentHash,
|
|
28
|
+
createIsrController,
|
|
29
|
+
isrKey,
|
|
30
|
+
ROOT_ELEMENT_ID,
|
|
31
|
+
renderComponent,
|
|
32
|
+
renderSsr,
|
|
36
33
|
staticHeaders,
|
|
37
34
|
streamResult,
|
|
38
35
|
stylesFor,
|
|
39
|
-
} from '@ultimat3/render';
|
|
36
|
+
} from '@ultimat3/render/server';
|
|
40
37
|
|
|
41
38
|
/**
|
|
42
39
|
* Specifier → built chunk URL, bound to the route file the specifier is written relative to.
|