@ultimat3/cli 23.0.0 → 24.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 +15 -7
- package/README.md +3 -3
- package/package.json +28 -28
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-manifest.ts +2 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +7 -17
- package/src/budgets.ts +4 -35
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build.ts +64 -15
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +6 -5
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +41 -14
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +79 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs.ts +18 -6
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +12 -2
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +4 -4
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +23 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +33 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/i18n-audit.ts +4 -1
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +3 -15
- package/src/jobs-drain.ts +82 -15
- package/src/jobs-report.ts +4 -4
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +12 -3
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +2 -0
- package/src/metrics-endpoint.ts +25 -1
- package/src/output.ts +6 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +9 -4
- package/src/role-sync.ts +55 -20
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +31 -10
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +62 -35
- package/src/runtime-services.ts +20 -17
- package/src/runtime-storage.ts +10 -5
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +34 -6
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-web.ts +12 -7
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +3 -0
- package/src/templates/naming.ts +28 -0
- package/src/templates/route.ts +3 -6
- package/src/templates/scaffold-app.ts +6 -2
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +4 -2
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-repo.ts +9 -1
- package/src/templates/scaffold-shell.ts +39 -36
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/trusted-hops-error.ts +17 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +17 -46
- package/src/verify-deadline.ts +130 -33
- package/src/verify-merge.ts +60 -27
- package/src/verify-part-step.ts +90 -0
- package/src/verify-run.ts +29 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-tests.ts +5 -2
- package/src/build-errors.ts +0 -54
- package/src/verify-errors.ts +0 -58
package/src/runtime-realtime.ts
CHANGED
|
@@ -1,55 +1,19 @@
|
|
|
1
|
-
// `realtime.enabled`, `realtime.transport` and `realtime.urlEnv
|
|
2
|
-
// `app
|
|
3
|
-
//
|
|
4
|
-
// plain object. Until 22.0.0 no boot read any of the three and `NATS_URL` alone decided the bus.
|
|
1
|
+
// `realtime.enabled`, `realtime.transport` and `realtime.urlEnv` for the boot, out of the config
|
|
2
|
+
// `startServices` loads once (`app-config-load.ts`). Until 22.0.0 no boot read any of the three
|
|
3
|
+
// and `NATS_URL` alone decided the bus.
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
// `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import { ConfigInvalidError, type RealtimeConfig, type RealtimeTransport } from '@ultimat3/core';
|
|
10
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
11
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
5
|
+
import type { AppConfig, RealtimeConfig } from '@ultimat3/core';
|
|
12
6
|
|
|
13
|
-
/** Core's `defaults()` for the section
|
|
7
|
+
/** Core's `defaults()` for the section — the answer for a root with no `app.config.ts`. */
|
|
14
8
|
export const REALTIME_DEFAULTS: RealtimeConfig = Object.freeze({
|
|
15
9
|
enabled: true,
|
|
16
10
|
transport: 'memory',
|
|
17
11
|
urlEnv: undefined,
|
|
18
12
|
});
|
|
19
13
|
|
|
20
|
-
const TRANSPORTS: readonly RealtimeTransport[] = ['memory', 'nats'];
|
|
21
|
-
|
|
22
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
23
|
-
typeof value === 'object' && value !== null;
|
|
24
|
-
|
|
25
|
-
/** A transport this boot can build, or a refusal — never a guess at a bus. */
|
|
26
|
-
function transportOf(section: Record<string, unknown>): RealtimeTransport {
|
|
27
|
-
const value = section['transport'];
|
|
28
|
-
if (value === undefined) return REALTIME_DEFAULTS.transport;
|
|
29
|
-
const known = TRANSPORTS.find((transport) => transport === value);
|
|
30
|
-
if (known !== undefined) return known;
|
|
31
|
-
throw new ConfigInvalidError({
|
|
32
|
-
cause: `realtime.transport in ${APP_CONFIG_FILE} is not one of ${TRANSPORTS.join(', ')}`,
|
|
33
|
-
fix: `set realtime: { transport: 'memory' } in ${APP_CONFIG_FILE}, or { transport: 'nats', urlEnv: 'NATS_URL' } for more than one node`,
|
|
34
|
-
meta: { key: 'realtime.transport' },
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
14
|
/**
|
|
39
|
-
* The section as the boot obeys it
|
|
40
|
-
*
|
|
15
|
+
* The section as the boot obeys it — `defineConfig`'s per-key merge, so the boot and the app's own
|
|
16
|
+
* config object agree, and a transport nothing builds is core's `X_CONFIG_INVALID`.
|
|
41
17
|
*/
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
if (!(await Bun.file(path).exists())) return REALTIME_DEFAULTS;
|
|
45
|
-
const module = (await import(path)) as Record<string, unknown>;
|
|
46
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
47
|
-
const section = isRecord(config) ? config['realtime'] : undefined;
|
|
48
|
-
if (!isRecord(section)) return REALTIME_DEFAULTS;
|
|
49
|
-
const { enabled, urlEnv } = section;
|
|
50
|
-
return {
|
|
51
|
-
enabled: typeof enabled === 'boolean' ? enabled : REALTIME_DEFAULTS.enabled,
|
|
52
|
-
transport: transportOf(section),
|
|
53
|
-
urlEnv: typeof urlEnv === 'string' && urlEnv.trim() !== '' ? urlEnv.trim() : undefined,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
18
|
+
export const realtimeConfigOf = (config: AppConfig | undefined): RealtimeConfig =>
|
|
19
|
+
config?.realtime ?? REALTIME_DEFAULTS;
|
package/src/runtime-render.ts
CHANGED
|
@@ -1,30 +1,21 @@
|
|
|
1
|
-
// Projecting the route table onto HTTP routes
|
|
1
|
+
// Projecting the route table onto HTTP routes both boots serve. Every mode goes through
|
|
2
2
|
// `@ultimat3/render`'s own function for that mode — the CLI picks the mode and supplies the
|
|
3
|
-
// document
|
|
4
|
-
//
|
|
5
|
-
// The document is head + the route's own component, rendered by `@ultimat3/render`'s server JSX
|
|
6
|
-
// writer, with the surface's compiled CSS LINKED — one content-hashed file per surface
|
|
7
|
-
// (`style-bundle.ts`), served `immutable`.
|
|
8
|
-
//
|
|
9
|
-
// It was inlined until 2026-09-06, on the argument that a `site/` page is a 0kb-JS artifact a CDN
|
|
10
|
-
// serves as one file and a link would add a round trip. The round trip is real and it is paid
|
|
11
|
-
// once: measured against ai-maxxing, every `app/` document carried the SAME 156,738-byte `<style>`
|
|
12
|
-
// block — 92% of the dashboard document — inside a response the pipeline sends
|
|
13
|
-
// `Cache-Control: private, no-store`, so the trip that argument saved was re-paid in full on every
|
|
14
|
-
// navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
|
|
15
|
-
// "second file" cost is one `Bun.write`.
|
|
3
|
+
// document (head + the route's component, the surface's CSS LINKED, `style-bundle.ts`), it never
|
|
4
|
+
// decides what a mode means or what headers it earns.
|
|
16
5
|
|
|
17
6
|
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
18
7
|
import { posix } from 'node:path';
|
|
19
8
|
import { actionPathStyle } from '@ultimat3/action';
|
|
20
9
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
21
10
|
import type { Ctx } from '@ultimat3/core';
|
|
22
|
-
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
11
|
+
import { CLIENT_SCOPE_HEADER, createSingleFlight } from '@ultimat3/core';
|
|
23
12
|
import type {
|
|
24
13
|
RouteMeta as HttpRouteMeta,
|
|
25
14
|
RedirectIntent,
|
|
15
|
+
RequestContext,
|
|
26
16
|
Route,
|
|
27
17
|
RouteParams,
|
|
18
|
+
UltimateRequest,
|
|
28
19
|
} from '@ultimat3/http';
|
|
29
20
|
import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
|
|
30
21
|
import { currentLocale, localeConfig } from '@ultimat3/i18n';
|
|
@@ -52,7 +43,6 @@ import {
|
|
|
52
43
|
import type { IsrController } from '@ultimat3/render/server';
|
|
53
44
|
import {
|
|
54
45
|
contentHash,
|
|
55
|
-
createIsrController,
|
|
56
46
|
isrKey,
|
|
57
47
|
ROOT_ELEMENT_ID,
|
|
58
48
|
renderComponent,
|
|
@@ -69,6 +59,7 @@ import {
|
|
|
69
59
|
navigationTagsOf,
|
|
70
60
|
principalRelocation,
|
|
71
61
|
} from './page-navigation';
|
|
62
|
+
import { attachedIsr } from './runtime-isr';
|
|
72
63
|
import type { StaticResult } from './static-document';
|
|
73
64
|
import { createStaticMemo, staticMemoKey, staticResponse } from './static-document';
|
|
74
65
|
import { styleBundle } from './style-bundle';
|
|
@@ -77,7 +68,11 @@ export type { DocumentOptions, IslandResolver } from './document-options';
|
|
|
77
68
|
|
|
78
69
|
export interface DevRenderOptions extends DocumentOptions {
|
|
79
70
|
readonly buildId: string;
|
|
80
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* The boot's own controller, attached and released by it (`runtime-isr.ts`). Omitted, one is
|
|
73
|
+
* built AND attached here, never released — a caller with no stop list (a contract test) still
|
|
74
|
+
* gets pages a tag bust reaches; the revalidator slot is the latest attach's.
|
|
75
|
+
*/
|
|
81
76
|
readonly isr?: IsrController;
|
|
82
77
|
/**
|
|
83
78
|
* Keep each `static` document after its first render (`static-document.ts`). The container sets
|
|
@@ -156,7 +151,8 @@ const headFor = async (
|
|
|
156
151
|
|
|
157
152
|
/**
|
|
158
153
|
* `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
|
|
159
|
-
* imports none.
|
|
154
|
+
* imports none. Inlined until 2026-09-06: every `app/` document of ai-maxxing then carried the same
|
|
155
|
+
* 156,738-byte `<style>` (92% of it) under `no-store`, re-paid on every navigation.
|
|
160
156
|
*
|
|
161
157
|
* In `<head>`, which is what keeps this a swap and not a regression: a `<link rel="stylesheet">`
|
|
162
158
|
* there is render-blocking in every browser, exactly as the inline block was, so there is no
|
|
@@ -429,8 +425,11 @@ const metaOf = (entry: RouteEntry, head?: NavigationDocumentHead): HttpRouteMeta
|
|
|
429
425
|
* only a test does — and then guard and page fall back together.
|
|
430
426
|
*/
|
|
431
427
|
export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
432
|
-
const isr = options.isr ??
|
|
428
|
+
const isr = options.isr ?? attachedIsr({ buildId: options.buildId }).isr;
|
|
433
429
|
const memo = createStaticMemo();
|
|
430
|
+
// A cold static key is ONE render however many requests arrive during it: the memo alone was
|
|
431
|
+
// check-then-act, so a burst at boot ran `load` and the render once per request.
|
|
432
|
+
const firstRender = createSingleFlight();
|
|
434
433
|
return routeEntries().map((registered) => ({
|
|
435
434
|
method: 'GET' as const,
|
|
436
435
|
path: registered.path,
|
|
@@ -450,23 +449,51 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
|
450
449
|
: undefined;
|
|
451
450
|
const kept = key === undefined ? undefined : memo.get(key);
|
|
452
451
|
if (kept !== undefined) return staticResponse(request, kept);
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
return responseOf(result);
|
|
465
|
-
}
|
|
466
|
-
const document: StaticResult = { ...result, body: result.body };
|
|
467
|
-
if (key !== undefined) memo.set(key, document);
|
|
452
|
+
let led = false;
|
|
453
|
+
const render = async (): Promise<Rendered> => {
|
|
454
|
+
led = true;
|
|
455
|
+
const rendered = await renderEntry(entry, request, ctx, options, isr);
|
|
456
|
+
if (key !== undefined && rendered.document !== undefined) memo.set(key, rendered.document);
|
|
457
|
+
return rendered;
|
|
458
|
+
};
|
|
459
|
+
const shared = key === undefined ? await render() : await firstRender.run(key, render);
|
|
460
|
+
// Only a DOCUMENT is shared. A redirect `load` decided was its request's own, so a request
|
|
461
|
+
// that joined one renders for itself rather than answer with another request's `Location`.
|
|
462
|
+
const rendered = shared.document !== undefined || led ? shared : await render();
|
|
468
463
|
// The ETag was only ever a header: a matching `If-None-Match` is a 304, not the page again.
|
|
469
|
-
return
|
|
464
|
+
return rendered.document === undefined
|
|
465
|
+
? rendered.respond()
|
|
466
|
+
: staticResponse(request, rendered.document);
|
|
470
467
|
},
|
|
471
468
|
}));
|
|
472
469
|
}
|
|
470
|
+
|
|
471
|
+
/** A static document a joined request can share, or how to answer when there is none. */
|
|
472
|
+
interface Rendered {
|
|
473
|
+
readonly document?: StaticResult;
|
|
474
|
+
readonly respond: () => Response;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function renderEntry(
|
|
478
|
+
entry: RouteEntry,
|
|
479
|
+
request: UltimateRequest,
|
|
480
|
+
ctx: RequestContext,
|
|
481
|
+
options: DevRenderOptions,
|
|
482
|
+
isr: IsrController,
|
|
483
|
+
): Promise<Rendered> {
|
|
484
|
+
const data: DevRouteData = { url: request.url.href, params: ctx.params };
|
|
485
|
+
// ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
|
|
486
|
+
// object, so a route's `load` runs exactly once however its mode splits head from body.
|
|
487
|
+
const loaded = await routeDataFor(entry.config, data);
|
|
488
|
+
// `setRedirect()` inside `load` — the same slot an action's handler fills — answers here,
|
|
489
|
+
// before a document is rendered for a page the visitor is leaving. `withStatus` refuses a
|
|
490
|
+
// 3xx because a rendered document has no `Location`; this is the path that has one.
|
|
491
|
+
const to = takeRedirect(ctx);
|
|
492
|
+
if (to !== undefined) return { respond: () => loadRedirect(entry, to) };
|
|
493
|
+
const result = await resultFor(entry, data, loaded, options, isr, asCtx(ctx));
|
|
494
|
+
if (entry.config.render !== 'static' || typeof result.body !== 'string') {
|
|
495
|
+
return { respond: () => responseOf(result) };
|
|
496
|
+
}
|
|
497
|
+
const document: StaticResult = { ...result, body: result.body };
|
|
498
|
+
return { document, respond: () => responseOf(document) };
|
|
499
|
+
}
|
package/src/runtime-services.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
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
7
|
import { configureAuthLimiters, postgresAuthLimiter, resetAuthLimiters } from '@ultimat3/auth';
|
|
9
8
|
import type { PurgeDriver } from '@ultimat3/cache';
|
|
10
9
|
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
@@ -31,17 +30,18 @@ import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
|
|
|
31
30
|
import { selectTransport } from '@ultimat3/realtime/server';
|
|
32
31
|
import type { Storage } from '@ultimat3/storage';
|
|
33
32
|
import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
|
|
33
|
+
import { loadAppConfig } from './app-config-load';
|
|
34
34
|
import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
|
|
35
35
|
import { msg } from './messages';
|
|
36
36
|
import type { DevServices, Env } from './runtime-bindings';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
37
|
+
import { cacheTiersOf, startCacheTiers } from './runtime-cache';
|
|
38
|
+
import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
|
|
39
|
+
import { inboxRetentionOf } from './runtime-notify-retention';
|
|
40
40
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
41
41
|
import { installRetentionSweep } from './runtime-purge';
|
|
42
|
-
import type { DevDbClient } from './runtime-queue';
|
|
42
|
+
import type { DevDbClient, SchemaMode } from './runtime-queue';
|
|
43
43
|
import { pgExecutorFor, startQueue } from './runtime-queue';
|
|
44
|
-
import {
|
|
44
|
+
import { realtimeConfigOf } from './runtime-realtime';
|
|
45
45
|
|
|
46
46
|
export interface RunningServices {
|
|
47
47
|
readonly services: DevServices;
|
|
@@ -305,6 +305,8 @@ export async function startServices(
|
|
|
305
305
|
services: DevServices,
|
|
306
306
|
env: Env,
|
|
307
307
|
overrides?: RuntimeOverrides,
|
|
308
|
+
/** `'verify'` for a serving role on an external database — `runtime-queue.ts`'s `SchemaMode`. */
|
|
309
|
+
schema: SchemaMode = 'apply',
|
|
308
310
|
): Promise<RunningServices> {
|
|
309
311
|
// Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does
|
|
310
312
|
// not dial. A typo'd credential must fail on the spot rather than after PGlite has started and
|
|
@@ -318,14 +320,16 @@ export async function startServices(
|
|
|
318
320
|
// service starts, rather than quietly keeping the in-process bus. Which transport, which KV
|
|
319
321
|
// bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
|
|
320
322
|
// `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
|
|
321
|
-
// container it stands in for. The
|
|
322
|
-
|
|
323
|
-
const
|
|
323
|
+
// container it stands in for. The app's config is loaded ONCE, here, and every section below is
|
|
324
|
+
// read out of that one validated object.
|
|
325
|
+
const config = await loadAppConfig(services.root);
|
|
326
|
+
const realtime = realtimeConfigOf(config);
|
|
327
|
+
const workerConfig = workerConfigOf(config);
|
|
324
328
|
const bus: TransportSelection = selectTransport(env, realtime);
|
|
325
329
|
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
326
330
|
// reader here already uses it, so a queue that asked `process.env` would decide the standby from
|
|
327
331
|
// a different answer than the middleware that routes to it.
|
|
328
|
-
const queue = await startQueue(services, overrides, env);
|
|
332
|
+
const queue = await startQueue(services, overrides, env, schema);
|
|
329
333
|
const { db, jobs, outbox, events } = queue;
|
|
330
334
|
// The same executor the jobs driver, the outbox, the event bus and the idempotency store run
|
|
331
335
|
// on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
|
@@ -359,13 +363,14 @@ export async function startServices(
|
|
|
359
363
|
// `x_rate_limit` takes one upsert per request the web role serves, assets included.
|
|
360
364
|
//
|
|
361
365
|
// `inboxRetention` is READ HERE and not defaulted in `runtime-purge.ts`: the two windows are the
|
|
362
|
-
// app's,
|
|
363
|
-
//
|
|
366
|
+
// app's, and a loader that silently answered "never sweep" from inside the sweep would be
|
|
367
|
+
// indistinguishable from an app that chose it.
|
|
364
368
|
started.push(
|
|
365
369
|
installRetentionSweep({
|
|
366
370
|
idempotency: queue.idempotency,
|
|
367
371
|
rateLimit: rateLimitStore,
|
|
368
|
-
|
|
372
|
+
events,
|
|
373
|
+
inboxRetention: inboxRetentionOf(config),
|
|
369
374
|
}),
|
|
370
375
|
);
|
|
371
376
|
// One readiness check per resource this boot OWNS, released with it. Nothing in the tree
|
|
@@ -409,10 +414,8 @@ export async function startServices(
|
|
|
409
414
|
// process that has stopped.
|
|
410
415
|
// The ladder is the app's declaration, never the environment's. `cache.tiers` was declared,
|
|
411
416
|
// validated and documented while NOTHING read it — so an app asking for one rung got four,
|
|
412
|
-
// and one asking for a shared tier got whatever `REDIS_URL` happened to say.
|
|
413
|
-
|
|
414
|
-
// and does not already carry.
|
|
415
|
-
const tiers = await loadCacheTiers(dirname(services.stateDir));
|
|
417
|
+
// and one asking for a shared tier got whatever `REDIS_URL` happened to say.
|
|
418
|
+
const tiers = cacheTiersOf(config);
|
|
416
419
|
started.push(startCacheTiers({ env, purge, transport, tiers }));
|
|
417
420
|
|
|
418
421
|
return {
|
package/src/runtime-storage.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
orgMismatch,
|
|
25
25
|
} from '@ultimat3/storage';
|
|
26
26
|
import { storageUploadRoute } from './runtime-storage-upload';
|
|
27
|
+
import { storedObjectHeaders } from './stored-object-headers';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* The one capability that gates reading a stored object, on every disk. A permission and not a
|
|
@@ -103,11 +104,10 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
|
|
|
103
104
|
*/
|
|
104
105
|
export function assertReadableKey(key: string, actor: Actor): string {
|
|
105
106
|
const safe = assertSafeKey(key);
|
|
106
|
-
// An actor with no org is inside no org, so every tenant-scoped key is somebody else's
|
|
107
|
-
//
|
|
108
|
-
// URL for the actor's missing claim.
|
|
107
|
+
// An actor with no org is inside no org, so every tenant-scoped key is somebody else's —
|
|
108
|
+
// `isWithinOrg` answers exactly that for an empty org, without throwing.
|
|
109
109
|
const orgId = actor.orgId ?? '';
|
|
110
|
-
if (isTenantScoped(safe) &&
|
|
110
|
+
if (isTenantScoped(safe) && !isWithinOrg(safe, orgId)) {
|
|
111
111
|
throw orgMismatch(safe, orgId);
|
|
112
112
|
}
|
|
113
113
|
return safe;
|
|
@@ -185,9 +185,14 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
|
|
|
185
185
|
const headers = new Headers({
|
|
186
186
|
'content-type': read.object.contentType,
|
|
187
187
|
etag,
|
|
188
|
-
'last-modified': read.object.lastModified.toUTCString(),
|
|
189
188
|
'accept-ranges': 'bytes',
|
|
189
|
+
// A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
|
|
190
|
+
...storedObjectHeaders(read.object.contentType),
|
|
190
191
|
});
|
|
192
|
+
// Omitted, never invented: a disk whose provider reported no date has none, and a `Last-Modified`
|
|
193
|
+
// made up for it is a validator a cache would go on to trust.
|
|
194
|
+
const modified = read.object.lastModified;
|
|
195
|
+
if (modified !== undefined) headers.set('last-modified', modified.toUTCString());
|
|
191
196
|
// Revalidation costs a request and no bytes, which is the trade an authorized response wants:
|
|
192
197
|
// the bytes never change under a key, but the actor's permission to read them can be revoked.
|
|
193
198
|
if (etagMatches(request.header('if-none-match'), etag)) {
|
package/src/schema-diff.ts
CHANGED
|
@@ -40,6 +40,11 @@ export interface SchemaDifference {
|
|
|
40
40
|
|
|
41
41
|
const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
|
|
42
42
|
const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
|
|
43
|
+
/** The difference's name — what `x db gen "record …"` is told to write down. */
|
|
44
|
+
const REPLICA_IDENTITY_FULL = 'replica identity full';
|
|
45
|
+
const REPLICA_IDENTITY_UNRECORDED =
|
|
46
|
+
'needs REPLICA IDENTITY FULL — a channel with params or a live query subscribes to it — and no ' +
|
|
47
|
+
'migration recorded it';
|
|
43
48
|
|
|
44
49
|
const columns = (names: readonly string[]): string => names.join(', ');
|
|
45
50
|
|
|
@@ -187,6 +192,17 @@ function compareTable(declared: TableDescription, recorded: TableDescription): S
|
|
|
187
192
|
detail: `declares primary key (${columns(declared.primaryKey)}) and the migration recorded (${columns(recorded.primaryKey)})`,
|
|
188
193
|
});
|
|
189
194
|
}
|
|
195
|
+
// One direction: `replica-identity.ts` never reverts the identity, so a recorded FULL nothing
|
|
196
|
+
// needs any more is what `x db gen` leaves behind on purpose, not a difference.
|
|
197
|
+
if (declared.replicaIdentityFull === true && recorded.replicaIdentityFull !== true) {
|
|
198
|
+
out.push({
|
|
199
|
+
direction: 'unmigrated',
|
|
200
|
+
part: 'table',
|
|
201
|
+
table,
|
|
202
|
+
name: REPLICA_IDENTITY_FULL,
|
|
203
|
+
detail: REPLICA_IDENTITY_UNRECORDED,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
190
206
|
out.push(
|
|
191
207
|
...compareList({
|
|
192
208
|
part: 'column',
|
package/src/schema-drift.ts
CHANGED
|
@@ -15,10 +15,15 @@ import { existsSync } from 'node:fs';
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
17
17
|
import type { EntityDescriptionLike, UnrenderedDeclaration } from '@ultimat3/db';
|
|
18
|
-
import { declaredSchema, snapshotOf, unrenderedOf } from '@ultimat3/db';
|
|
18
|
+
import { declaredSchema, shellInertIdentifier, snapshotOf, unrenderedOf } from '@ultimat3/db';
|
|
19
19
|
import { describeEntities } from '@ultimat3/entity';
|
|
20
20
|
import { loadApp } from './app-load';
|
|
21
21
|
import { checkSourceDrift, DB_PACKAGE } from './drift';
|
|
22
|
+
import {
|
|
23
|
+
appReplicaIdentity,
|
|
24
|
+
type ReplicaIdentityWanted,
|
|
25
|
+
wantedReplicaIdentity,
|
|
26
|
+
} from './drift-replica-identity';
|
|
22
27
|
import { MIGRATIONS_DIR, readMigrations } from './migrations';
|
|
23
28
|
import type { Finding } from './output';
|
|
24
29
|
import { diffDeclaredSchema, type SchemaDifference } from './schema-diff';
|
|
@@ -50,10 +55,17 @@ const appEntities: DeclaredEntities = async (root) => {
|
|
|
50
55
|
* silently and the result is green. In that state the unrendered entry's own `fix:` is the
|
|
51
56
|
* instruction: `@ultimat3/db` owns that wording, and a second one here would drift from it.
|
|
52
57
|
*/
|
|
58
|
+
interface Repair {
|
|
59
|
+
/** One runnable command or one edit — never a second option behind a shell comment. */
|
|
60
|
+
readonly fix: string;
|
|
61
|
+
/** Why this fix and not the obvious one, appended to the cause where the reader sees it. */
|
|
62
|
+
readonly why?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
function repairFix(
|
|
54
66
|
unrendered: readonly UnrenderedDeclaration[],
|
|
55
67
|
difference: SchemaDifference,
|
|
56
|
-
):
|
|
68
|
+
): Repair {
|
|
57
69
|
// The entry ABOUT this difference first, then any entry at all. Reading `unrendered[0]`
|
|
58
70
|
// unconditionally meant that in any app carrying an unrendered DEFAULT, every difference —
|
|
59
71
|
// a dropped CHECK included — was answered with the default's edit, which is an instruction for
|
|
@@ -62,7 +74,7 @@ function repairFix(
|
|
|
62
74
|
(entry) => entry.table === difference.table && entry.name === difference.name,
|
|
63
75
|
);
|
|
64
76
|
// The entry ABOUT this difference carries the edit that repairs it, so it is the whole answer.
|
|
65
|
-
if (named !== undefined) return named.fix;
|
|
77
|
+
if (named !== undefined) return { fix: named.fix };
|
|
66
78
|
// Otherwise `x db gen` is still unsafe — it would drop what the other entries name — but the
|
|
67
79
|
// reader must NOT be handed a different declaration's edit as the instruction for this one.
|
|
68
80
|
// Reading `unrendered[0]` did exactly that: a column default and an index each got
|
|
@@ -72,16 +84,26 @@ function repairFix(
|
|
|
72
84
|
// app — not only for the one the entry names.
|
|
73
85
|
const blocker = unrendered[0];
|
|
74
86
|
if (blocker !== undefined) {
|
|
75
|
-
return
|
|
87
|
+
return {
|
|
88
|
+
fix: blocker.fix,
|
|
89
|
+
why: `${blocker.table}.${blocker.name} reaches no SQL, so regenerating would drop it — repair that before recording ${difference.name}`,
|
|
90
|
+
};
|
|
76
91
|
}
|
|
77
92
|
// "record", not "add": one finding covers a declaration the migrations never carried AND one they
|
|
78
|
-
// carry differently, so `add` would be a wrong migration name for half of them. `undeclared`
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
// carry differently, so `add` would be a wrong migration name for half of them. `undeclared` runs
|
|
94
|
+
// the DROP, and the other branch — the declaration may have been LOST rather than removed, which
|
|
95
|
+
// is the whole reason this direction is its own finding — rides in the cause: a `fix:` is one
|
|
96
|
+
// command, and a `# or …` after it was a shell comment nobody's paste would run.
|
|
97
|
+
if (difference.direction === 'unmigrated') return { fix: `x db gen "record ${difference.name}"` };
|
|
98
|
+
// The name was read off a sidecar on disk and lands inside double quotes, where `$(…)` and a
|
|
99
|
+
// backtick still run: one the shell would act on is named in the cause only, never the command.
|
|
100
|
+
const inert = shellInertIdentifier(difference.name) !== null;
|
|
101
|
+
return {
|
|
102
|
+
fix: inert
|
|
103
|
+
? `x db gen "drop ${difference.name}"`
|
|
104
|
+
: 'x db gen "drop what the entities no longer declare"',
|
|
105
|
+
why: `if ${difference.name} was lost rather than removed, re-declare it on the entity instead — this command drops it`,
|
|
106
|
+
};
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
/**
|
|
@@ -95,8 +117,10 @@ export const schemaDifferenceCause = (difference: SchemaDifference): string =>
|
|
|
95
117
|
? `table "${difference.table}" ${difference.detail}`
|
|
96
118
|
: `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
|
|
97
119
|
|
|
98
|
-
function findingFor(difference: SchemaDifference,
|
|
99
|
-
const
|
|
120
|
+
function findingFor(difference: SchemaDifference, repair: Repair): Finding {
|
|
121
|
+
const { fix } = repair;
|
|
122
|
+
const stated = schemaDifferenceCause(difference);
|
|
123
|
+
const cause = repair.why === undefined ? stated : `${stated} — ${repair.why}`;
|
|
100
124
|
return difference.direction === 'unmigrated'
|
|
101
125
|
? { code: 'X_DB_SCHEMA_UNMIGRATED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR }
|
|
102
126
|
: { code: 'X_DB_SCHEMA_UNDECLARED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
|
|
@@ -114,6 +138,7 @@ function findingFor(difference: SchemaDifference, fix: string): Finding {
|
|
|
114
138
|
export async function checkSnapshotDrift(
|
|
115
139
|
root: string,
|
|
116
140
|
declared: DeclaredEntities = appEntities,
|
|
141
|
+
identity: ReplicaIdentityWanted = appReplicaIdentity,
|
|
117
142
|
): Promise<readonly Finding[]> {
|
|
118
143
|
if (!existsSync(join(root, DB_PACKAGE))) return [];
|
|
119
144
|
const entities = await declared(root);
|
|
@@ -122,7 +147,11 @@ export async function checkSnapshotDrift(
|
|
|
122
147
|
if (migrations.length === 0) return [];
|
|
123
148
|
const recorded = declaredSchema(migrations);
|
|
124
149
|
if (recorded === undefined) return [];
|
|
125
|
-
|
|
150
|
+
// Read only over a WHOLE registry (`entities` is `undefined` otherwise), the rule `x db gen`
|
|
151
|
+
// states for the same call: a short one makes every subscribed name in the missing module a typo.
|
|
152
|
+
const wanted = wantedReplicaIdentity(identity, new Set(entities.map((entity) => entity.table)));
|
|
153
|
+
if ('finding' in wanted) return [wanted.finding];
|
|
154
|
+
const differences = diffDeclaredSchema(snapshotOf(entities, wanted.tables), recorded);
|
|
126
155
|
if (differences.length === 0) return [];
|
|
127
156
|
// `recorded` and not the entities alone: whether an `assert` is a LOSS depends on whether a
|
|
128
157
|
// migration recorded a CHECK for it, which only the sidecar knows.
|
|
@@ -145,8 +174,9 @@ export async function checkMigrationDrift(
|
|
|
145
174
|
root: string,
|
|
146
175
|
declared: DeclaredEntities = appEntities,
|
|
147
176
|
hashDrift: (root: string) => Promise<readonly Finding[]> = checkSourceDrift,
|
|
177
|
+
identity: ReplicaIdentityWanted = appReplicaIdentity,
|
|
148
178
|
): Promise<readonly Finding[]> {
|
|
149
|
-
const snapshot = await checkSnapshotDrift(root, declared);
|
|
179
|
+
const snapshot = await checkSnapshotDrift(root, declared, identity);
|
|
150
180
|
if (snapshot.length > 0) return snapshot;
|
|
151
181
|
return hashDrift(root);
|
|
152
182
|
}
|
package/src/seo-routes.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
// `GET /robots.txt
|
|
2
|
-
//
|
|
3
|
-
// `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
|
|
1
|
+
// `GET /robots.txt`, `GET /sitemap.xml` and its parts (`/sitemaps/<n>.xml`) from a running web
|
|
2
|
+
// role — the files the static export writes, for a deploy that serves its pages from a container.
|
|
3
|
+
// Mounted by `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
|
|
4
4
|
|
|
5
5
|
import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
|
|
6
6
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
7
|
-
import { applyCacheHeaders } from '@ultimat3/http';
|
|
7
|
+
import { applyCacheHeaders, json, NO_STORE } from '@ultimat3/http';
|
|
8
|
+
import { SITEMAP_PARTS_DIR } from '@ultimat3/seo';
|
|
9
|
+
import { quoteArg } from './shell-quote';
|
|
8
10
|
import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
|
|
11
|
+
import type { SiteSeo } from './site-seo';
|
|
9
12
|
import { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
|
|
10
13
|
|
|
11
14
|
export interface SeoRoutesOptions {
|
|
@@ -26,22 +29,57 @@ function originOf(options: SeoRoutesOptions, request: UltimateRequest): string {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
32
|
+
* An hour of shared cache is what a CDN keeps of either file, and what this process keeps of the
|
|
33
|
+
* answer behind both. `prerender()` may enumerate rows that change, so the memo is never longer
|
|
34
|
+
* than the response's own `max-age` — a crawler that bypasses the CDN sees what one behind it
|
|
35
|
+
* would. Before it, every `/robots.txt` hit recomputed the whole sitemap, each dynamic route's
|
|
36
|
+
* `prerender()` included.
|
|
31
37
|
*/
|
|
32
38
|
const SEO_CACHE = { mode: 'public', maxAgeSeconds: 3600 } as const;
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Origins remembered at once. With no declared origin the request's own is the key, and a `Host`
|
|
42
|
+
* header is the caller's to choose — so the memo is bounded, oldest out first.
|
|
43
|
+
*/
|
|
44
|
+
const SEO_MEMO_ORIGINS = 8;
|
|
45
|
+
|
|
46
|
+
interface Memo {
|
|
47
|
+
readonly at: number;
|
|
48
|
+
readonly answer: Promise<SiteSeo>;
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
35
52
|
const environment: Environment =
|
|
36
53
|
tryResolveEnvironment({ env: options.env }) ?? DEFAULT_ENVIRONMENT;
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
baseUrl
|
|
54
|
+
const compute = (baseUrl: string): Promise<SiteSeo> =>
|
|
55
|
+
siteSeo({
|
|
56
|
+
baseUrl,
|
|
40
57
|
environment,
|
|
41
58
|
disallow: options.site?.disallow ?? [],
|
|
42
59
|
...(options.site === undefined ? {} : { sitemap: options.site.sitemap }),
|
|
43
60
|
...(options.root === undefined ? {} : { root: options.root }),
|
|
44
61
|
});
|
|
62
|
+
// Single-flight per origin: concurrent requests share the one computation in progress. A failed
|
|
63
|
+
// one is dropped, so the next request asks again rather than serving the failure for an hour.
|
|
64
|
+
const memo = new Map<string, Memo>();
|
|
65
|
+
const answer = (request: UltimateRequest): Promise<SiteSeo> => {
|
|
66
|
+
const origin = originOf(options, request);
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const kept = memo.get(origin);
|
|
69
|
+
if (kept !== undefined && now - kept.at < SEO_CACHE.maxAgeSeconds * 1000) return kept.answer;
|
|
70
|
+
memo.delete(origin);
|
|
71
|
+
if (memo.size >= SEO_MEMO_ORIGINS) {
|
|
72
|
+
const oldest = memo.keys().next().value;
|
|
73
|
+
if (oldest !== undefined) memo.delete(oldest);
|
|
74
|
+
}
|
|
75
|
+
const computed = compute(origin);
|
|
76
|
+
memo.set(origin, { at: now, answer: computed });
|
|
77
|
+
// Voided: the request awaits `computed` itself and reports its failure; this branch only forgets it.
|
|
78
|
+
void computed.catch(() => {
|
|
79
|
+
if (memo.get(origin)?.answer === computed) memo.delete(origin);
|
|
80
|
+
});
|
|
81
|
+
return computed;
|
|
82
|
+
};
|
|
45
83
|
|
|
46
84
|
return [
|
|
47
85
|
{
|
|
@@ -69,5 +107,38 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
|
69
107
|
SEO_CACHE,
|
|
70
108
|
),
|
|
71
109
|
},
|
|
110
|
+
{
|
|
111
|
+
method: 'GET',
|
|
112
|
+
path: `${SITEMAP_PARTS_DIR}/:file`,
|
|
113
|
+
meta: { name: 'seo.sitemapPart', auth: 'public', tags: ['seo'] },
|
|
114
|
+
// A part of a split sitemap, from the same answer the index came from — so a part the index
|
|
115
|
+
// names always exists here, and the static export writes the same paths.
|
|
116
|
+
handler: async (request: UltimateRequest): Promise<Response> => {
|
|
117
|
+
const seo = await answer(request);
|
|
118
|
+
const part = seo.sitemaps.find((file) => file.path === request.pathname);
|
|
119
|
+
if (part !== undefined && part.path !== SITEMAP_PATH) {
|
|
120
|
+
return applyCacheHeaders(
|
|
121
|
+
new Response(part.xml, {
|
|
122
|
+
headers: { 'content-type': 'application/xml; charset=utf-8' },
|
|
123
|
+
}),
|
|
124
|
+
SEO_CACHE,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const index = new URL(SITEMAP_PATH, originOf(options, request)).href;
|
|
128
|
+
const missing = json(
|
|
129
|
+
{
|
|
130
|
+
ok: false,
|
|
131
|
+
error: {
|
|
132
|
+
code: 'X_ROUTE_NOT_FOUND',
|
|
133
|
+
cause: `${request.pathname} is no part of this site's sitemap: the index names ${String(Math.max(seo.sitemaps.length - 1, 0))} part(s)`,
|
|
134
|
+
fix: `curl -sS ${quoteArg(index)}`,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{ status: 404 },
|
|
138
|
+
);
|
|
139
|
+
// `no-store`: the part may exist once the site grows, and a cached miss would hide it.
|
|
140
|
+
return applyCacheHeaders(missing, NO_STORE);
|
|
141
|
+
},
|
|
142
|
+
},
|
|
72
143
|
];
|
|
73
144
|
}
|