@ultimat3/cli 8.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 +9 -3
- package/package.json +26 -25
- package/src/app-load.ts +7 -0
- package/src/cmd-deploy.ts +6 -3
- package/src/dev-assets.ts +4 -7
- package/src/dev-cache.ts +129 -32
- package/src/dev-purge.ts +120 -0
- package/src/dev-queue.ts +31 -6
- package/src/dev-render.ts +11 -14
- package/src/dev-runtime.ts +51 -12
- package/src/document-styles.ts +2 -1
- package/src/island-bundle.ts +2 -6
- package/src/island-styles.ts +1 -1
- package/src/prerender.ts +2 -1
- package/src/runtime-overrides.ts +1 -1
- package/src/serve.ts +1 -1
- package/src/solid-loader.ts +1 -1
- package/src/style-csp.ts +2 -1
- package/src/templates/scaffold-docs.ts +10 -3
- package/src/templates/scaffold-repo.ts +3 -1
package/CLAUDE.md
CHANGED
|
@@ -535,6 +535,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
535
535
|
| `dev-queue.ts` | the db + queue pair alone, and the one place that takes every ambient accessor back |
|
|
536
536
|
| `dev-runtime.ts` | start the rest on top of it and install the remaining accessors (storage, mail, transport) |
|
|
537
537
|
| `dev-cache.ts` | which cache tiers this process reads through, and the cross-instance invalidation hop |
|
|
538
|
+
| `dev-purge.ts` | the hourly retention sweep: which framework tables this boot owns, the `purge()` job over them and the `task` that fires it |
|
|
538
539
|
| `dev-sync.ts` | the `sync` role: its live-query registry, who is dialling it, and the socket it owns |
|
|
539
540
|
| `runtime-overrides.ts` | the one field a host hands the framework a driver through |
|
|
540
541
|
| `sync-authenticator.ts` | the app's HTTP authenticator, seen as the sync node's |
|
|
@@ -602,6 +603,8 @@ relay draining it.
|
|
|
602
603
|
| the durable scheduler | `pgSchedulerState` + `createPgLeaseLeader` in `startRoles` | a watermark forgotten on restart, and every replica its own leader |
|
|
603
604
|
| the Postgres event bus | `dev-queue.ts` | `step.waitForEvent` forgot every correlation on restart |
|
|
604
605
|
| the shared idempotency store | `dev-queue.ts` | a retry on another replica charged the card twice |
|
|
606
|
+
| the shared auth limiter | `configureAuthLimiters` in `startServices` | account lockouts counted per POD, so N replicas granted `maxAttempts × N` guesses |
|
|
607
|
+
| the retention sweep | `dev-purge.ts`, declared in `startServices` | three `purgeExpired()` with no caller — every row `x_idempotency`, `x_rate_limit` and `x_auth_*` ever took was kept |
|
|
605
608
|
| the cache tiers | `dev-cache.ts` | only the CDN tier was registered; memo, LRU and Redis had zero callers |
|
|
606
609
|
| WebSocket authentication | `dev-sync.ts` | `actorId: null` on every socket — realtime was single-tenant by wiring |
|
|
607
610
|
| OTLP export | `otlp-export.ts` | the chart set the variable and no code read it |
|
|
@@ -769,9 +772,12 @@ Promoting it to `x verify`'s `boundaries` host check is one line in `scripts/ver
|
|
|
769
772
|
rule over names. Two stronger rules were measured and rejected: "the read must not be a property
|
|
770
773
|
initializer" reports six flags, five of which work (`x db --allow-destructive`, `x jobs --queue`);
|
|
771
774
|
"the summary must match the behaviour" is undecidable. So the flag's summary now says what it does,
|
|
772
|
-
and forcing a reload
|
|
773
|
-
|
|
774
|
-
|
|
775
|
+
and forcing a reload is **not a thing this framework does**, `As of 2026-08`. `updateSignal`
|
|
776
|
+
had no runtime caller for four majors and 9.0.0 deleted it rather than wiring it: `pwa` is tier 4
|
|
777
|
+
and the two runtimes holding both build ids — `http` (2) and `sync` (3) — are below it, so no
|
|
778
|
+
legal import could ever have reached the function. A deploy command has no channel to a running
|
|
779
|
+
client regardless; the plan is `docker compose up` or `helm upgrade`. What ships is notification:
|
|
780
|
+
`useConnection().updateAvailable` from `@ultimat3/realtime`.
|
|
775
781
|
|
|
776
782
|
## Planned commands are commands
|
|
777
783
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,30 +37,31 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/
|
|
44
|
-
"@ultimat3/
|
|
45
|
-
"@ultimat3/
|
|
46
|
-
"@ultimat3/
|
|
47
|
-
"@ultimat3/
|
|
48
|
-
"@ultimat3/
|
|
49
|
-
"@ultimat3/
|
|
50
|
-
"@ultimat3/
|
|
51
|
-
"@ultimat3/
|
|
52
|
-
"@ultimat3/
|
|
53
|
-
"@ultimat3/
|
|
54
|
-
"@ultimat3/
|
|
55
|
-
"@ultimat3/
|
|
56
|
-
"@ultimat3/
|
|
57
|
-
"@ultimat3/
|
|
58
|
-
"@ultimat3/
|
|
59
|
-
"@ultimat3/
|
|
60
|
-
"@ultimat3/
|
|
61
|
-
"@ultimat3/
|
|
62
|
-
"@ultimat3/
|
|
63
|
-
"@ultimat3/
|
|
40
|
+
"@ultimat3/action": "9.0.0",
|
|
41
|
+
"@ultimat3/admin": "9.0.0",
|
|
42
|
+
"@ultimat3/ai": "9.0.0",
|
|
43
|
+
"@ultimat3/auth": "9.0.0",
|
|
44
|
+
"@ultimat3/cache": "9.0.0",
|
|
45
|
+
"@ultimat3/core": "9.0.0",
|
|
46
|
+
"@ultimat3/db": "9.0.0",
|
|
47
|
+
"@ultimat3/entity": "9.0.0",
|
|
48
|
+
"@ultimat3/http": "9.0.0",
|
|
49
|
+
"@ultimat3/i18n": "9.0.0",
|
|
50
|
+
"@ultimat3/jobs": "9.0.0",
|
|
51
|
+
"@ultimat3/mail": "9.0.0",
|
|
52
|
+
"@ultimat3/manifest": "9.0.0",
|
|
53
|
+
"@ultimat3/mcp": "9.0.0",
|
|
54
|
+
"@ultimat3/policy": "9.0.0",
|
|
55
|
+
"@ultimat3/pwa": "9.0.0",
|
|
56
|
+
"@ultimat3/query": "9.0.0",
|
|
57
|
+
"@ultimat3/realtime": "9.0.0",
|
|
58
|
+
"@ultimat3/render": "9.0.0",
|
|
59
|
+
"@ultimat3/schema": "9.0.0",
|
|
60
|
+
"@ultimat3/scraping": "9.0.0",
|
|
61
|
+
"@ultimat3/seo": "9.0.0",
|
|
62
|
+
"@ultimat3/storage": "9.0.0",
|
|
63
|
+
"@ultimat3/testing": "9.0.0",
|
|
64
|
+
"@ultimat3/time": "9.0.0",
|
|
64
65
|
"babel-preset-solid": "^1.9.15"
|
|
65
66
|
}
|
|
66
67
|
}
|
package/src/app-load.ts
CHANGED
|
@@ -11,6 +11,13 @@ import { localeConfig } from '@ultimat3/i18n';
|
|
|
11
11
|
import type { ErrorCodeFact } from '@ultimat3/manifest';
|
|
12
12
|
import { registerQueries } from '@ultimat3/query';
|
|
13
13
|
import { isRouteConfig, pageComponentOf, registerRoute } from '@ultimat3/render';
|
|
14
|
+
// For the SIDE EFFECT, and it is this module's to hold: importing `@ultimat3/render/server`
|
|
15
|
+
// installs the `.tsx`/`.scss` Bun plugin, a plugin only transforms modules loaded AFTER it, and
|
|
16
|
+
// every app module below is loaded by the dynamic `import()` in this file. Before the render
|
|
17
|
+
// barrel split it came free with the line above; after it, the only other path to `/server` from
|
|
18
|
+
// here is six hops through `error-contract` → `fix-command` → the command registry, which is an
|
|
19
|
+
// accident one refactor away from compiling every app's `.tsx` to `React.createElement`.
|
|
20
|
+
import '@ultimat3/render/server';
|
|
14
21
|
import { collectDeclaredCodes } from './error-contract';
|
|
15
22
|
import type { Finding } from './output';
|
|
16
23
|
import { findingFrom } from './output';
|
package/src/cmd-deploy.ts
CHANGED
|
@@ -175,9 +175,12 @@ export const deployCommand: CliCommand = {
|
|
|
175
175
|
// `critical: <bool>`, and no file in `packages/` read that field — so the flag changed
|
|
176
176
|
// nothing about what `x deploy` did, on either method. `flag-reads.ts`'s
|
|
177
177
|
// `X_CLI_FLAG_UNREAD` passed it, because that gate proves a flag is READ and this one was:
|
|
178
|
-
// into a field with no reader.
|
|
179
|
-
// `updateSignal({ reason: 'security' })`,
|
|
180
|
-
//
|
|
178
|
+
// into a field with no reader. It is not coming back: `@ultimat3/pwa`'s
|
|
179
|
+
// `updateSignal({ reason: 'security' })`, the call it was to have triggered, is **deleted**
|
|
180
|
+
// as of 9.0.0 for having had no runtime caller of its own, and nothing in the framework
|
|
181
|
+
// force-navigates a client. A deploy also has no channel to one — the plan is
|
|
182
|
+
// `docker compose up` / `helm upgrade`, and the client's build id is read by `http` (tier 2)
|
|
183
|
+
// and `sync` (tier 3), neither of which may import a tier-4 package to act on it.
|
|
181
184
|
],
|
|
182
185
|
},
|
|
183
186
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
package/src/dev-assets.ts
CHANGED
|
@@ -15,8 +15,8 @@ import type { IconPlan } from '@ultimat3/pwa';
|
|
|
15
15
|
import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
|
|
16
16
|
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
17
17
|
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
|
|
18
|
-
import type {
|
|
19
|
-
import {
|
|
18
|
+
import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
|
|
19
|
+
import { isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
|
|
20
20
|
import {
|
|
21
21
|
AUTHORIZED_OBJECT_CACHE,
|
|
22
22
|
assertReadableKey,
|
|
@@ -87,14 +87,11 @@ const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint)
|
|
|
87
87
|
const mediaCache = (key: string): CacheHint =>
|
|
88
88
|
isTenantScoped(key) ? AUTHORIZED_OBJECT_CACHE : IMMUTABLE_IMAGE;
|
|
89
89
|
|
|
90
|
-
const isImageFormat = (value: string): value is ImageFormat =>
|
|
91
|
-
(IMAGE_FORMATS as readonly string[]).includes(value);
|
|
92
|
-
|
|
93
90
|
/**
|
|
94
91
|
* `exactOptionalPropertyTypes` makes an explicit `undefined` a different answer from an absent
|
|
95
92
|
* key, and `variantKey` reads presence — so a spread, never an assignment.
|
|
96
93
|
*/
|
|
97
|
-
function storageTransform(query: ImageQuery, format:
|
|
94
|
+
function storageTransform(query: ImageQuery, format: VariantFormat | undefined): ImageTransform {
|
|
98
95
|
return {
|
|
99
96
|
...(query.width === undefined ? {} : { width: query.width }),
|
|
100
97
|
...(format === undefined ? {} : { format }),
|
|
@@ -117,7 +114,7 @@ async function transformedVariant(
|
|
|
117
114
|
// A format storage cannot name has no variant key, so it cannot be cached. The driver refuses
|
|
118
115
|
// it with core's `X_IMAGE_UNSUPPORTED`; refusing it here too would give one bad URL two codes.
|
|
119
116
|
const format =
|
|
120
|
-
query.format !== undefined &&
|
|
117
|
+
query.format !== undefined && isVariantFormat(query.format) ? query.format : undefined;
|
|
121
118
|
const cacheable = query.format === undefined || format !== undefined;
|
|
122
119
|
const cached = cacheable ? variantKey(key, storageTransform(query, format)) : undefined;
|
|
123
120
|
// The SOURCE key decides the posture, not the variant's: `variantKey` keeps the source's prefix,
|
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 {
|
|
22
|
+
import type { CacheTierName } from '@ultimat3/core';
|
|
23
|
+
import { CACHE_TIERS, defineConfig, logger } from '@ultimat3/core';
|
|
20
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-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,
|
|
@@ -50,6 +52,13 @@ export interface RunningQueue {
|
|
|
50
52
|
readonly outbox: OutboxStore;
|
|
51
53
|
/** The `x_job_events` bus a `step.waitForEvent` resumes from. Durable, not per-process. */
|
|
52
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;
|
|
53
62
|
stop(): Promise<void>;
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -86,17 +95,25 @@ export function pgExecutorFor(client: DbClient): PgExecutor {
|
|
|
86
95
|
* applied statement by statement. Safe to split on `;`: every constant is fixed, with no semicolon
|
|
87
96
|
* inside a literal, and each package's own SQL test is where that stays true.
|
|
88
97
|
*
|
|
89
|
-
* `SQL_IDEMPOTENCY_TABLE` and `
|
|
90
|
-
* `@ultimat3/http` because a package that holds no
|
|
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
|
|
91
101
|
* — the same reason `SQL_JOBS_TABLE` is applied here. Each one absent is the same failure at a
|
|
92
102
|
* different door: a retried `POST /api/payments/charge` charges the card twice, and the FIRST
|
|
93
103
|
* request a `rateLimitStore` deployment serves dies on a missing `x_rate_limit` relation. The
|
|
94
104
|
* table is installed whether or not this boot passes `runtime.rateLimitStore` — `create table if
|
|
95
105
|
* not exists` on an unused table costs one round trip at boot, and a store installed later must
|
|
96
|
-
* not be the thing that discovers the schema was never applied.
|
|
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.
|
|
97
109
|
*/
|
|
98
110
|
async function applySchema(client: DevDbClient): Promise<void> {
|
|
99
|
-
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
|
+
]) {
|
|
100
117
|
for (const statement of ddl.split(';')) {
|
|
101
118
|
if (statement.trim().length > 0) await client.execute(raw(statement));
|
|
102
119
|
}
|
|
@@ -146,8 +163,16 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
|
|
|
146
163
|
);
|
|
147
164
|
const events = createPgEventBus({ executor });
|
|
148
165
|
setEventBus(events);
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
};
|
|
151
176
|
}
|
|
152
177
|
|
|
153
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.
|
package/src/dev-runtime.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// boot installs, only backed by embedded drivers.
|
|
5
5
|
|
|
6
6
|
import { mkdirSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import { configureAuthLimiters, postgresAuthLimiter, resetAuthLimiters } from '@ultimat3/auth';
|
|
7
9
|
import type { PurgeDriver } from '@ultimat3/cache';
|
|
8
10
|
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
9
11
|
import {
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
registerReadinessCheck,
|
|
12
14
|
renderThrowable,
|
|
13
15
|
resolveEnvironment,
|
|
16
|
+
systemClock,
|
|
14
17
|
} from '@ultimat3/core';
|
|
15
18
|
import type { RateLimitStore } from '@ultimat3/http';
|
|
16
19
|
import { postgresRateLimitStore } from '@ultimat3/http';
|
|
@@ -27,7 +30,8 @@ import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
|
|
|
27
30
|
import { selectTransport } from '@ultimat3/realtime/server';
|
|
28
31
|
import type { Storage } from '@ultimat3/storage';
|
|
29
32
|
import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
|
|
30
|
-
import { startCacheTiers } from './dev-cache';
|
|
33
|
+
import { loadCacheTiers, startCacheTiers } from './dev-cache';
|
|
34
|
+
import { installRetentionSweep } from './dev-purge';
|
|
31
35
|
import type { DevDbClient } from './dev-queue';
|
|
32
36
|
import { pgExecutorFor, startQueue } from './dev-queue';
|
|
33
37
|
import type { DevServices, Env } from './dev-services';
|
|
@@ -288,16 +292,45 @@ export async function startServices(
|
|
|
288
292
|
const bus: TransportSelection = selectTransport(env);
|
|
289
293
|
const queue = await startQueue(services, overrides);
|
|
290
294
|
const { db, jobs, outbox, events } = queue;
|
|
295
|
+
// The same executor the jobs driver, the outbox, the event bus and the idempotency store run
|
|
296
|
+
// on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
|
297
|
+
// `undefined`), one wrapper.
|
|
298
|
+
const executor = pgExecutorFor(db);
|
|
299
|
+
const rateLimitStore = postgresRateLimitStore({ executor });
|
|
291
300
|
// Boot is a sequence of external resources, and every step after the first can reject — the
|
|
292
301
|
// queue is already up, so from here an unwind must release it exactly like everything after it.
|
|
302
|
+
// Which is why the `try` opens on the NEXT line and not eight steps further down: it did, and
|
|
303
|
+
// the steps above it registered process-wide state (`configureAuthLimiters`, the `purge()` job
|
|
304
|
+
// and its `task`) that throws on a name a previous boot in this process left in the registry —
|
|
305
|
+
// `X_JOB_NAME_TAKEN`, outside the unwind, so `x dev` exited holding the PGlite lock, the pool
|
|
306
|
+
// and the ambient accessors. Nothing may be pushed onto `started` from outside this block.
|
|
293
307
|
const started: (() => void | Promise<void>)[] = [() => queue.stop()];
|
|
294
|
-
// One readiness check per resource this boot OWNS, released with it. Nothing in the tree
|
|
295
|
-
// registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
|
|
296
|
-
// `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `dev-roles.ts` calls it
|
|
297
|
-
// before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
|
|
298
|
-
// healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
|
|
299
|
-
started.push(probedReadinessCheck('database', () => db.ping()));
|
|
300
308
|
try {
|
|
309
|
+
// Where failed sign-ins are counted, installed BEFORE `loadApp` for the reason the idempotency
|
|
310
|
+
// store is: `defineAuth` is the app's call and it runs when the app's modules import, so a seam
|
|
311
|
+
// filled afterwards is one every app would have to fill itself. A FACTORY and not a limiter —
|
|
312
|
+
// the app declares `maxAttempts`/`windowMs`/`lockoutMs` and this boot has not read them yet, so
|
|
313
|
+
// a limiter built here would be refused by `assertAuthLimiterPolicy` on any app that tuned one.
|
|
314
|
+
//
|
|
315
|
+
// Until this line every deployment the framework produces counted lockouts per POD, while
|
|
316
|
+
// `x new` scaffolds `replicas: 2` and `docker/helm` runs three — `maxAttempts × N` guesses per
|
|
317
|
+
// account, and a lockout one replica established invisible to the rest.
|
|
318
|
+
configureAuthLimiters((policy) =>
|
|
319
|
+
postgresAuthLimiter({ executor, clock: systemClock, policy }),
|
|
320
|
+
);
|
|
321
|
+
started.push(() => resetAuthLimiters());
|
|
322
|
+
// The hourly sweep over the three framework tables this boot is responsible for. Every one of
|
|
323
|
+
// them ships a `purgeExpired()` that nothing called, so every row written was a row kept —
|
|
324
|
+
// `x_rate_limit` takes one upsert per request the web role serves, assets included.
|
|
325
|
+
started.push(
|
|
326
|
+
installRetentionSweep({ idempotency: queue.idempotency, rateLimit: rateLimitStore }),
|
|
327
|
+
);
|
|
328
|
+
// One readiness check per resource this boot OWNS, released with it. Nothing in the tree
|
|
329
|
+
// registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
|
|
330
|
+
// `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `dev-roles.ts` calls it
|
|
331
|
+
// before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
|
|
332
|
+
// healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
|
|
333
|
+
started.push(probedReadinessCheck('database', () => db.ping()));
|
|
301
334
|
// Dialled here rather than at selection: an unreachable bus must fail at `x dev`, not on the
|
|
302
335
|
// first change nobody receives, and the socket is a resource the unwind below has to release.
|
|
303
336
|
// A supplied transport is already connected and is NOT closed here: whoever built it owns its
|
|
@@ -331,7 +364,13 @@ export async function startServices(
|
|
|
331
364
|
// recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
|
|
332
365
|
// this boot is the only thing that registers one, and a tier left behind would purge for a
|
|
333
366
|
// process that has stopped.
|
|
334
|
-
|
|
367
|
+
// The ladder is the app's declaration, never the environment's. `cache.tiers` was declared,
|
|
368
|
+
// validated and documented while NOTHING read it — so an app asking for one rung got four,
|
|
369
|
+
// and one asking for a shared tier got whatever `REDIS_URL` happened to say. `.x` is
|
|
370
|
+
// `resolveServices`' own join, so its parent is the app root: the one fact this function needs
|
|
371
|
+
// and does not already carry.
|
|
372
|
+
const tiers = await loadCacheTiers(dirname(services.stateDir));
|
|
373
|
+
started.push(startCacheTiers({ env, purge, transport, tiers }));
|
|
335
374
|
|
|
336
375
|
return {
|
|
337
376
|
services,
|
|
@@ -343,12 +382,12 @@ export async function startServices(
|
|
|
343
382
|
storage,
|
|
344
383
|
mail,
|
|
345
384
|
mailDetail: selection.detail,
|
|
385
|
+
// Built above, beside the auth limiter factory and the retention sweep that purges its
|
|
386
|
+
// table: three readers of one executor, resolved once. `startWeb` is what an override
|
|
387
|
+
// replaces it at.
|
|
388
|
+
rateLimitStore,
|
|
346
389
|
// The env key that selected the bus — or the honest answer that no env key did, because a
|
|
347
390
|
// boot line reading `NATS_URL` over a transport the host handed in is a lie a script parses.
|
|
348
|
-
// The same executor the jobs driver, the outbox, the event bus and the idempotency store
|
|
349
|
-
// run on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
|
350
|
-
// `undefined`), one wrapper. `startWeb` is what an override replaces it at.
|
|
351
|
-
rateLimitStore: postgresRateLimitStore({ executor: pgExecutorFor(db) }),
|
|
352
391
|
transportDetail: overrides?.transport === undefined ? bus.detail : 'runtime override',
|
|
353
392
|
presenceTtlMs: bus.presenceTtlMs,
|
|
354
393
|
purge,
|
package/src/document-styles.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// apart from the styling nobody sees missing. A silent failure is exactly what axiom 3 exists for.
|
|
6
6
|
|
|
7
7
|
import type { Surface } from '@ultimat3/render';
|
|
8
|
-
import { routeEntries
|
|
8
|
+
import { routeEntries } from '@ultimat3/render';
|
|
9
|
+
import { stylesFor } from '@ultimat3/render/server';
|
|
9
10
|
import type { Finding } from './output';
|
|
10
11
|
|
|
11
12
|
/** The stylesheet an app is expected to own, named in the fix so it is one edit, not a hunt. */
|
package/src/island-bundle.ts
CHANGED
|
@@ -6,12 +6,8 @@
|
|
|
6
6
|
// Bun ships no path API. `posix` does the specifier arithmetic (an app-relative route file is
|
|
7
7
|
// POSIX by construction), `join`/`basename` the filesystem side.
|
|
8
8
|
import { basename, join, posix, relative, sep } from 'node:path';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
ISLAND_EXTENSION,
|
|
12
|
-
IslandInvalidError,
|
|
13
|
-
islandModuleId,
|
|
14
|
-
} from '@ultimat3/render';
|
|
9
|
+
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
10
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
15
11
|
import { IslandBuildFailedError } from './errors';
|
|
16
12
|
import { solidProductionPlugin } from './island-solid-production';
|
|
17
13
|
import { islandStylesPlugin } from './island-styles';
|
package/src/island-styles.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// element renders unclassed, and `Bun.build` reports `success: true` with no log.
|
|
5
5
|
|
|
6
6
|
import { renderThrowable, UltimateError } from '@ultimat3/core';
|
|
7
|
-
import { loadStylesheet } from '@ultimat3/render';
|
|
7
|
+
import { loadStylesheet } from '@ultimat3/render/server';
|
|
8
8
|
import type { BunPlugin } from 'bun';
|
|
9
9
|
import { IslandBuildFailedError } from './errors';
|
|
10
10
|
|
package/src/prerender.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import type { RouteEntry } from '@ultimat3/render';
|
|
9
|
-
import {
|
|
9
|
+
import { routeEntries } from '@ultimat3/render';
|
|
10
|
+
import { renderStatic } from '@ultimat3/render/server';
|
|
10
11
|
import { loadApp } from './app-load';
|
|
11
12
|
import { appManifest } from './app-manifest';
|
|
12
13
|
import type { RouteStats } from './budgets';
|
package/src/runtime-overrides.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Middleware, RateLimitStore } from '@ultimat3/http';
|
|
|
9
9
|
import type { JobDriver } from '@ultimat3/jobs';
|
|
10
10
|
import type { MailDriver } from '@ultimat3/mail';
|
|
11
11
|
import type { SyncAuthenticator, Transport } from '@ultimat3/realtime/server';
|
|
12
|
-
import type { IsrStore } from '@ultimat3/render';
|
|
12
|
+
import type { IsrStore } from '@ultimat3/render/server';
|
|
13
13
|
import type { ImageTransformDriver } from '@ultimat3/seo';
|
|
14
14
|
import type { Storage } from '@ultimat3/storage';
|
|
15
15
|
|
package/src/serve.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
migrate,
|
|
21
21
|
} from '@ultimat3/db';
|
|
22
22
|
import type { Route } from '@ultimat3/http';
|
|
23
|
-
import { createIsrController } from '@ultimat3/render';
|
|
23
|
+
import { createIsrController } from '@ultimat3/render/server';
|
|
24
24
|
import { apiRoutes } from './api-routes';
|
|
25
25
|
import { loadSignInPath } from './app-auth';
|
|
26
26
|
import { loadApp } from './app-load';
|
package/src/solid-loader.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// `tsc` compiles this file inside ITS program, where a `.d.ts` sitting in this directory is
|
|
8
8
|
// not included and its `declare module` never applies. `tsc -b` proves it in this repo.
|
|
9
9
|
import { transformAsync } from '@babel/core';
|
|
10
|
-
import { contentHash } from '@ultimat3/render';
|
|
10
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
11
11
|
import solidPreset from 'babel-preset-solid';
|
|
12
12
|
import type { BunPlugin } from 'bun';
|
|
13
13
|
import { IslandBuildFailedError } from './errors';
|
package/src/style-csp.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// stylesheet the document no longer carries — and the CSP would block the framework's own CSS.
|
|
5
5
|
|
|
6
6
|
import { cspHashSource } from '@ultimat3/http';
|
|
7
|
-
import { SURFACES
|
|
7
|
+
import { SURFACES } from '@ultimat3/render';
|
|
8
|
+
import { stylesFor } from '@ultimat3/render/server';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Call AFTER `loadApp`. One hash per distinct body: `stylesFor` is what `dev-render.ts` puts in
|
|
@@ -137,27 +137,34 @@ exec bunx x verify "$@"
|
|
|
137
137
|
const composeDev = (
|
|
138
138
|
app: NameSet,
|
|
139
139
|
): string => `# Optional: x dev needs none of this. Use it when you want the real Postgres/NATS/MinIO locally.
|
|
140
|
+
#
|
|
141
|
+
# Every published port binds 127.0.0.1, not 0.0.0.0. This stack ships its credentials in the file,
|
|
142
|
+
# as a dev stack reasonably does — so the short form \`'5432:5432'\` would put an authenticated
|
|
143
|
+
# database and an open object store on every interface this machine has, including the café wifi.
|
|
144
|
+
# Docker publishes a port by writing DNAT rules, so a host firewall does not stop it. To reach this
|
|
145
|
+
# stack from another machine, put a tunnel in front of it (\`ssh -L\`) rather than widening the bind;
|
|
146
|
+
# production topology is docker-compose.prod.yml, and it is a different file for a reason.
|
|
140
147
|
services:
|
|
141
148
|
db:
|
|
142
149
|
image: postgres:17-alpine
|
|
143
150
|
environment:
|
|
144
151
|
POSTGRES_PASSWORD: ${app.kebab}
|
|
145
152
|
POSTGRES_DB: ${app.kebab}
|
|
146
|
-
ports: ['5432:5432']
|
|
153
|
+
ports: ['127.0.0.1:5432:5432']
|
|
147
154
|
healthcheck:
|
|
148
155
|
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
|
149
156
|
interval: 5s
|
|
150
157
|
nats:
|
|
151
158
|
image: nats:2-alpine
|
|
152
159
|
command: ['-js']
|
|
153
|
-
ports: ['4222:4222']
|
|
160
|
+
ports: ['127.0.0.1:4222:4222']
|
|
154
161
|
s3:
|
|
155
162
|
image: minio/minio
|
|
156
163
|
command: ['server', '/data']
|
|
157
164
|
environment:
|
|
158
165
|
MINIO_ROOT_USER: ${app.kebab}
|
|
159
166
|
MINIO_ROOT_PASSWORD: ${app.kebab}-dev
|
|
160
|
-
ports: ['9000:9000']
|
|
167
|
+
ports: ['127.0.0.1:9000:9000']
|
|
161
168
|
`;
|
|
162
169
|
|
|
163
170
|
/** Docs, shims and container files for a new app, in the order a reader meets them. */
|
|
@@ -149,7 +149,9 @@ export const config = defineConfig({
|
|
|
149
149
|
defaultCurrency: 'USD',
|
|
150
150
|
// Env KEYS, never the value: the same image deploys to every environment. The database is
|
|
151
151
|
// configured entirely from the environment — \`DATABASE_URL\` and \`DATABASE_POOL_MAX\`.
|
|
152
|
-
cache:
|
|
152
|
+
// The tiers ARE the cache selection: add 'redis' to build the shared rung, which reads
|
|
153
|
+
// \`REDIS_URL\` and refuses the boot when it is unset.
|
|
154
|
+
cache: { tiers: ['request-memo', 'lru'] },
|
|
153
155
|
jobs: { queues: ['${app.kebab}-default'], concurrency: 4 },
|
|
154
156
|
// In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
|
|
155
157
|
realtime: { enabled: true, tier: 'live-queries', transport: 'memory' },
|