@ultimat3/cli 23.0.0 → 25.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/role-realtime.ts
CHANGED
|
@@ -14,10 +14,14 @@ const REALTIME_ROLES: readonly Role[] = ['sync', 'replicator'];
|
|
|
14
14
|
* what `x dev`'s default set needs on an app that opted out (`enabled: false`; the default is on).
|
|
15
15
|
* A selection that was NOTHING but realtime roles is refused instead: that is `ROLE=sync` on an app
|
|
16
16
|
* that turned realtime off, a container that would bind nothing and restart behind its probe.
|
|
17
|
+
*
|
|
18
|
+
* `dev` is the boot's `WebBinding.dev`, and picks the invocation the fix names: `x dev --role` for
|
|
19
|
+
* `x dev`, a `ROLE=` for a container — neither reads the other's selector.
|
|
17
20
|
*/
|
|
18
21
|
export function rolesUnderRealtime(
|
|
19
22
|
selected: readonly Role[],
|
|
20
23
|
realtime: Pick<RealtimeConfig, 'enabled'>,
|
|
24
|
+
dev: boolean,
|
|
21
25
|
): readonly Role[] {
|
|
22
26
|
// The key is read only when a realtime role was asked for, so a selection without one never
|
|
23
27
|
// depends on it — a hand-built runtime in a test that starts `web` alone included.
|
|
@@ -27,7 +31,9 @@ export function rolesUnderRealtime(
|
|
|
27
31
|
if (kept.length === 0) {
|
|
28
32
|
throw new ConfigInvalidError({
|
|
29
33
|
cause: `role ${dropped.join(',')} was asked for and realtime.enabled is false in app.config.ts, so this process would serve nothing`,
|
|
30
|
-
fix:
|
|
34
|
+
fix: dev
|
|
35
|
+
? 'set realtime: { enabled: true } in app.config.ts, or run a role that is not realtime: x dev --role web,worker,scheduler'
|
|
36
|
+
: 'ROLE=web bun apps/web/server.ts — a role that is not realtime; or set realtime: { enabled: true } in app.config.ts and rebuild the image, or drop the role from the deploy (the chart: roles.<role>.enabled: false in values.yaml)',
|
|
31
37
|
meta: { key: 'realtime.enabled', roles: dropped },
|
|
32
38
|
});
|
|
33
39
|
}
|
package/src/role-replicator.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
// Single responsibility: the `replicator` role
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Single responsibility: the `replicator` role — the one place the Postgres logical replication
|
|
2
|
+
// feed is selected, locked and pumped into the transport, for `x dev --role replicator` and a
|
|
3
|
+
// `ROLE=replicator` container alike. The two differ in what losing the lock means: terminal under
|
|
4
|
+
// `x dev`, a standby that keeps asking in a container.
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
ConfigInvalidError,
|
|
8
|
+
logger,
|
|
9
|
+
registerReadinessCheck,
|
|
10
|
+
renderThrowable,
|
|
11
|
+
} from '@ultimat3/core';
|
|
6
12
|
import { describeEntities } from '@ultimat3/entity';
|
|
7
13
|
import { ReplicatorSlotHeldError } from '@ultimat3/realtime';
|
|
8
|
-
import type { Replicator, Transport } from '@ultimat3/realtime/server';
|
|
9
|
-
import {
|
|
14
|
+
import type { Replicator, ReplicatorOptions, Transport } from '@ultimat3/realtime/server';
|
|
15
|
+
import {
|
|
16
|
+
changeFeedReplicator,
|
|
17
|
+
replicatorLockKey,
|
|
18
|
+
selectChangeFeed,
|
|
19
|
+
} from '@ultimat3/realtime/server';
|
|
10
20
|
import { BadFlagError } from './errors';
|
|
11
21
|
import type { DevServices, Env } from './runtime-bindings';
|
|
12
22
|
|
|
@@ -14,6 +24,14 @@ export interface StartReplicatorOptions {
|
|
|
14
24
|
readonly services: DevServices;
|
|
15
25
|
readonly env: Env;
|
|
16
26
|
readonly transport: Transport;
|
|
27
|
+
/**
|
|
28
|
+
* `x dev` (true) or a container (false) — the boot's `WebBinding.dev`. It decides what a lost
|
|
29
|
+
* lock race means and which invocation a refusal names: `x dev --role` is not a command a pod
|
|
30
|
+
* can run, and `ROLE=` is not a flag `x dev` reads.
|
|
31
|
+
*/
|
|
32
|
+
readonly dev: boolean;
|
|
33
|
+
/** The replicator's constructor. A seam for the role's own tests; the boot never passes it. */
|
|
34
|
+
readonly create?: (options: ReplicatorOptions) => Replicator;
|
|
17
35
|
}
|
|
18
36
|
|
|
19
37
|
export interface RunningReplicator {
|
|
@@ -31,15 +49,23 @@ export interface RunningReplicator {
|
|
|
31
49
|
* "not a dev role", which is what this used to say and sent an agent looking for a flag instead of
|
|
32
50
|
* a database. Same code as before (`X_CLI_BAD_FLAG`) because it is the same invocation.
|
|
33
51
|
*/
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
const NO_WALSENDER =
|
|
53
|
+
'the replicator decodes a write-ahead log, and the embedded database is PGlite — it has no ' +
|
|
54
|
+
'walsender to decode';
|
|
55
|
+
|
|
56
|
+
const embeddedRefusal = (dev: boolean): BadFlagError | ConfigInvalidError =>
|
|
57
|
+
dev
|
|
58
|
+
? new BadFlagError({
|
|
59
|
+
flag: 'role',
|
|
60
|
+
command: 'dev',
|
|
61
|
+
reason: NO_WALSENDER,
|
|
62
|
+
fix: 'DATABASE_URL=postgres://user:password@localhost:5432/app x dev --role replicator',
|
|
63
|
+
})
|
|
64
|
+
: new ConfigInvalidError({
|
|
65
|
+
cause: `ROLE=replicator booted with no DATABASE_URL: ${NO_WALSENDER}`,
|
|
66
|
+
fix: 'DATABASE_URL=postgres://user:password@host:5432/app ROLE=replicator bun apps/web/server.ts — in the chart, DATABASE_URL comes from the Secret named by existingSecret in docker/helm/values.yaml',
|
|
67
|
+
meta: { key: 'DATABASE_URL', role: 'replicator' },
|
|
68
|
+
});
|
|
43
69
|
|
|
44
70
|
/**
|
|
45
71
|
* The relations the feed decodes: every registered entity's PHYSICAL TABLE, never its name.
|
|
@@ -64,13 +90,70 @@ export function replicatedRelations(): readonly string[] {
|
|
|
64
90
|
* and forwards none. Refused here rather than inside the feed: this is the layer that knows the
|
|
65
91
|
* list came from the app's own registry, so it can name the command that adds to it.
|
|
66
92
|
*/
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
93
|
+
const NO_ENTITIES =
|
|
94
|
+
'no entities are registered, so the replicator would decode changes nothing matches';
|
|
95
|
+
|
|
96
|
+
const noEntitiesRefusal = (dev: boolean): BadFlagError | ConfigInvalidError =>
|
|
97
|
+
dev
|
|
98
|
+
? new BadFlagError({
|
|
99
|
+
flag: 'role',
|
|
100
|
+
command: 'dev',
|
|
101
|
+
reason: NO_ENTITIES,
|
|
102
|
+
fix: 'x g entity Post title:text — then x dev --role replicator',
|
|
103
|
+
})
|
|
104
|
+
: new ConfigInvalidError({
|
|
105
|
+
cause: `ROLE=replicator: ${NO_ENTITIES}`,
|
|
106
|
+
fix: 'x g entity Post title:text — then rebuild the image and roll the ROLE=replicator Deployment',
|
|
107
|
+
meta: { role: 'replicator' },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** The name `/readyz` reports the replicator under. */
|
|
111
|
+
export const REPLICATOR_READINESS_CHECK = 'replicator';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Ready means REPLICATING. A stream that ends is restarted by the replicator itself, and until it
|
|
115
|
+
* is back `running` is `false`: this process holds no slot and publishes nothing, so it must not be
|
|
116
|
+
* counted as the database's replicator. Returns the unregister.
|
|
117
|
+
*/
|
|
118
|
+
export function watchReplicatorReadiness(replicator: Pick<Replicator, 'running'>): () => void {
|
|
119
|
+
return registerReadinessCheck(REPLICATOR_READINESS_CHECK, () => replicator.running);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The standby: ask for the slot again on the replicator's own jittered backoff until it is granted
|
|
124
|
+
* or the role is stopped. `start()` answering `false` arms no retry of its own — the replicator's
|
|
125
|
+
* takeover loop covers a run it LOST, not one it never had — so a container that lost the race at
|
|
126
|
+
* boot would otherwise stand by forever, and a holder that died would leave the slot unreplicated.
|
|
127
|
+
*
|
|
128
|
+
* Unref'd: a pending ask must never be what keeps a draining process alive. Returns the cancel.
|
|
129
|
+
*/
|
|
130
|
+
function standBy(replicator: Replicator, key: string): () => void {
|
|
131
|
+
let cancelled = false;
|
|
132
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
133
|
+
const ask = (attempt: number): void => {
|
|
134
|
+
timer = setTimeout(() => {
|
|
135
|
+
timer = undefined;
|
|
136
|
+
if (cancelled) return;
|
|
137
|
+
// Voided: both outcomes are handled here, and a timer has nobody to hand a promise to.
|
|
138
|
+
void replicator.start().then(
|
|
139
|
+
(held) => {
|
|
140
|
+
if (held) logger.info('replicator.slot_taken_over', { key });
|
|
141
|
+
else if (!cancelled) ask(attempt + 1);
|
|
142
|
+
},
|
|
143
|
+
(thrown: unknown) => {
|
|
144
|
+
logger.warn('replicator.standby_failed', { key, error: renderThrowable(thrown) });
|
|
145
|
+
if (!cancelled) ask(attempt + 1);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
}, replicator.retryDelayMs(attempt));
|
|
149
|
+
timer.unref?.();
|
|
150
|
+
};
|
|
151
|
+
ask(0);
|
|
152
|
+
return () => {
|
|
153
|
+
cancelled = true;
|
|
154
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
74
157
|
|
|
75
158
|
/**
|
|
76
159
|
* Lock first, feed second. A replicator that opened its slot before losing the lock race would
|
|
@@ -78,28 +161,40 @@ const noEntitiesRefusal = (): BadFlagError =>
|
|
|
78
161
|
* thing standing between two containers and every change delivered twice.
|
|
79
162
|
*/
|
|
80
163
|
export async function startReplicator(options: StartReplicatorOptions): Promise<RunningReplicator> {
|
|
81
|
-
if (options.services.db.mode === 'embedded') throw embeddedRefusal();
|
|
164
|
+
if (options.services.db.mode === 'embedded') throw embeddedRefusal(options.dev);
|
|
82
165
|
const entities = replicatedRelations();
|
|
83
|
-
if (entities.length === 0) throw noEntitiesRefusal();
|
|
166
|
+
if (entities.length === 0) throw noEntitiesRefusal(options.dev);
|
|
84
167
|
|
|
85
168
|
const selection = selectChangeFeed(options.env, { entities });
|
|
86
169
|
const slot = selection.slot ?? '';
|
|
87
|
-
const
|
|
170
|
+
const key = replicatorLockKey(slot);
|
|
171
|
+
const replicator = (options.create ?? changeFeedReplicator)({
|
|
88
172
|
feed: selection.feed,
|
|
89
173
|
transport: options.transport,
|
|
90
174
|
lock: selection.lock,
|
|
91
175
|
});
|
|
92
|
-
// `start()` answers `false` for the one condition that is not this process's fault
|
|
93
|
-
// has nothing to fail over to
|
|
94
|
-
//
|
|
176
|
+
// `start()` answers `false` for the one condition that is not this process's fault: another
|
|
177
|
+
// process holds the lock. A dev boot has nothing to fail over to, so it is terminal there, with
|
|
178
|
+
// the code the topology docs promised. A container stays up, `/readyz` false, and stands by —
|
|
179
|
+
// a pod that threw here crash-looped, and a rolling update waiting on its readiness never
|
|
180
|
+
// terminated the old holder, so every rollout stalled (`replicator.ts`'s header: the loser stays
|
|
181
|
+
// up, unready).
|
|
182
|
+
let cancelStandby = (): void => undefined;
|
|
95
183
|
if (!(await replicator.start())) {
|
|
96
|
-
throw new ReplicatorSlotHeldError({ key
|
|
184
|
+
if (options.dev) throw new ReplicatorSlotHeldError({ key });
|
|
185
|
+
logger.warn('replicator.standby', { key, detail: 'another process holds the slot' });
|
|
186
|
+
cancelStandby = standBy(replicator, key);
|
|
97
187
|
}
|
|
188
|
+
const unregister = watchReplicatorReadiness(replicator);
|
|
98
189
|
return {
|
|
99
190
|
replicator,
|
|
100
191
|
slot,
|
|
101
192
|
detail: selection.detail,
|
|
102
193
|
async stop() {
|
|
194
|
+
// First: a replicator being stopped on purpose is not a failing one, and a standby must not
|
|
195
|
+
// ask for a slot after the role that wanted it has gone.
|
|
196
|
+
cancelStandby();
|
|
197
|
+
unregister();
|
|
103
198
|
await replicator.stop();
|
|
104
199
|
},
|
|
105
200
|
};
|
package/src/role-start.ts
CHANGED
|
@@ -7,28 +7,28 @@
|
|
|
7
7
|
// something every `x dev` in a team should do to the same server by simply starting.
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
|
-
import {
|
|
10
|
+
import { ctxOf, isRole, logger, markReady, ROLES } from '@ultimat3/core';
|
|
11
11
|
import type { RateLimitStore, Route, ServerHandle, WebSocketMount } from '@ultimat3/http';
|
|
12
12
|
import {
|
|
13
|
+
adoptRateLimitStore,
|
|
13
14
|
configuredAuthenticator,
|
|
14
15
|
configuredHttp,
|
|
15
|
-
createServer,
|
|
16
16
|
defineHttpConfig,
|
|
17
|
-
|
|
17
|
+
httpServer,
|
|
18
18
|
mergeHttpConfig,
|
|
19
19
|
} from '@ultimat3/http';
|
|
20
20
|
import type { OutboxRelay } from '@ultimat3/jobs';
|
|
21
21
|
import {
|
|
22
|
-
createOutboxRelay,
|
|
23
|
-
createPgLeaseLeader,
|
|
24
|
-
createScheduler,
|
|
25
|
-
createWorker,
|
|
26
22
|
jobDriver,
|
|
27
|
-
|
|
23
|
+
jobScheduler,
|
|
24
|
+
jobWorker,
|
|
25
|
+
outboxRelay,
|
|
26
|
+
postgresLeaseLeader,
|
|
27
|
+
postgresSchedulerState,
|
|
28
28
|
} from '@ultimat3/jobs';
|
|
29
29
|
import type { SyncWs } from '@ultimat3/realtime/server';
|
|
30
30
|
import { errorPageHook } from './error-pages';
|
|
31
|
-
import { BadFlagError,
|
|
31
|
+
import { BadFlagError, RuntimeDriverSplitError } from './errors';
|
|
32
32
|
import {
|
|
33
33
|
DEFAULT_METRICS_PORT,
|
|
34
34
|
startMetricsEndpoint,
|
|
@@ -40,15 +40,15 @@ import type { RunningRoles, StartRolesOptions } from './role-start-types';
|
|
|
40
40
|
import { DEV_ROLES } from './role-start-types';
|
|
41
41
|
import { prepareSync, type RunningSync } from './role-sync';
|
|
42
42
|
import { startWorkerWake } from './role-wake';
|
|
43
|
-
import type { Env } from './runtime-bindings';
|
|
44
43
|
import { devHooks } from './runtime-hooks';
|
|
45
44
|
import { workerOptionsFor } from './runtime-jobs';
|
|
46
45
|
import { startLiveFeed } from './runtime-live-feed';
|
|
47
46
|
import { pgExecutorFor } from './runtime-queue';
|
|
48
|
-
import type
|
|
47
|
+
import { type RunningServices, releaseOrThrow } from './runtime-services';
|
|
49
48
|
import { inlineScriptSources } from './script-csp';
|
|
50
49
|
import { inlineStyleSources } from './style-csp';
|
|
51
50
|
import { syncConnectSources } from './sync-url';
|
|
51
|
+
import { trustedHopsFromEnv } from './trusted-hops';
|
|
52
52
|
import { DEV_BINDING } from './web-binding';
|
|
53
53
|
|
|
54
54
|
// Declared beside the option types so `x dev`'s spec reads it without loading this module.
|
|
@@ -124,47 +124,8 @@ function warnIfUnauthenticatable(routes: readonly Route[]): void {
|
|
|
124
124
|
);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
*
|
|
130
|
-
* `defineHttpConfig` screens the same setting — `assertFiniteCount('trustedProxyHops', …,
|
|
131
|
-
* MAX_PROXY_HOPS)` — and this screen used to say 16 where that one says 64, so one setting had two
|
|
132
|
-
* ceilings and a deployment behind 20 hops was accepted by the library and refused by the boot.
|
|
133
|
-
* Widened rather than narrowed: tightening the library would break a shipped public API, while
|
|
134
|
-
* this end only ever refused topologies http already supports.
|
|
135
|
-
*
|
|
136
|
-
* It was a duplicated literal until 2026-08-26, because `@ultimat3/http` did not export the
|
|
137
|
-
* constant. It does now, so this file IMPORTS it — a downward edge, tier 5 to tier 2 — and there is
|
|
138
|
-
* no second number left to drift. `runtime-overrides.test.ts` still probes both screens for the
|
|
139
|
-
* highest count each accepts, and it is kept rather than deleted: it compares BEHAVIOUR, so it also
|
|
140
|
-
* catches the two disagreeing for a reason a shared constant cannot fix, such as one side gaining a
|
|
141
|
-
* range check the other does not have.
|
|
142
|
-
*/
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* How many proxies append to `x-forwarded-for` between the client and this process, or `null`
|
|
146
|
-
* when nothing in front of it is trusted.
|
|
147
|
-
*
|
|
148
|
-
* Read from the environment and not from `app.config.ts`, for the reason `PORT` and `ROLE` are: it
|
|
149
|
-
* is a fact about the DEPLOYMENT — one image runs behind an ingress in one cluster and behind
|
|
150
|
-
* nothing on a laptop — and an app that hardcoded it would be wrong in one of the two. `x dev`
|
|
151
|
-
* sets neither and gets `trustProxy: false`, which is correct: there is no proxy.
|
|
152
|
-
*
|
|
153
|
-
* Without this seam a container behind an ingress reads `ctx.ip` as the ingress's own socket
|
|
154
|
-
* address on every request, so the rate limiter keys the entire fleet's anonymous traffic into ONE
|
|
155
|
-
* bucket and a single scanner 429s every real signup.
|
|
156
|
-
*/
|
|
157
|
-
export function trustedHopsFromEnv(env: Env): number | null {
|
|
158
|
-
const raw = env['TRUSTED_PROXY_HOPS']?.trim();
|
|
159
|
-
if (raw === undefined || raw === '') return null;
|
|
160
|
-
const hops = Number(raw);
|
|
161
|
-
// A malformed count is refused rather than defaulted: reading the header at the wrong index is
|
|
162
|
-
// trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
|
|
163
|
-
if (!Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
|
|
164
|
-
throw new PortInvalidError({ value: raw, name: 'TRUSTED_PROXY_HOPS' });
|
|
165
|
-
}
|
|
166
|
-
return hops;
|
|
167
|
-
}
|
|
127
|
+
// `trusted-hops.ts` owns the read; re-exported because callers and tests already name it here.
|
|
128
|
+
export { trustedHopsFromEnv } from './trusted-hops';
|
|
168
129
|
|
|
169
130
|
/**
|
|
170
131
|
* A deployment that substituted a PER-PROCESS store is enforcing every declared number once per
|
|
@@ -189,7 +150,7 @@ function warnIfProcessScoped(store: RateLimitStore): void {
|
|
|
189
150
|
*
|
|
190
151
|
* One expression, one answer, in the order `RuntimeOverrides` documents — an override REPLACES the
|
|
191
152
|
* resolved default rather than sitting beside it. `undefined` is reachable only from a hand-built
|
|
192
|
-
* runtime, which is `
|
|
153
|
+
* runtime, which is `httpServer`'s per-process memory store and `scope: 'process'` below.
|
|
193
154
|
*/
|
|
194
155
|
function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefined {
|
|
195
156
|
const supplied = options.overrides?.rateLimitStore;
|
|
@@ -205,7 +166,7 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
|
|
|
205
166
|
const binding = options.http ?? DEV_BINDING;
|
|
206
167
|
const hops = trustedHopsFromEnv(options.env);
|
|
207
168
|
const store = rateLimitStoreFor(options);
|
|
208
|
-
return
|
|
169
|
+
return httpServer({
|
|
209
170
|
routes: options.routes,
|
|
210
171
|
role: 'web',
|
|
211
172
|
...(mount === undefined ? {} : { websocket: mount }),
|
|
@@ -216,7 +177,7 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
|
|
|
216
177
|
? {}
|
|
217
178
|
: { errorPage: errorPageHook(options.root, { perRequest: binding.dev }) }),
|
|
218
179
|
}),
|
|
219
|
-
// Both seams `
|
|
180
|
+
// Both seams `httpServer` already had and `startRoles` passed neither of, so an app's own
|
|
220
181
|
// middleware could not reach the pipeline any process the framework boots actually runs.
|
|
221
182
|
...(options.overrides?.middleware === undefined
|
|
222
183
|
? {}
|
|
@@ -271,7 +232,7 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
|
|
|
271
232
|
* `startServices` captures the drivers it built; `loadApp` imports the app's modules after it, and
|
|
272
233
|
* a module calling `setJobDriver()` at import time moves the ambient slot and leaves the capture
|
|
273
234
|
* alone. From here on the two are indistinguishable at every call site: `handle.enqueue()` reads
|
|
274
|
-
* the ambient one, `
|
|
235
|
+
* the ambient one, `jobWorker` claims from the captured one, and `/_x` reads the ambient one —
|
|
275
236
|
* so the dashboard agrees with the enqueue side and disagrees with reality.
|
|
276
237
|
*
|
|
277
238
|
* Refused, not reconciled. Reading through the accessor would make the split invisible instead of
|
|
@@ -290,12 +251,26 @@ function assertOneJobDriver(runtime: RunningServices): void {
|
|
|
290
251
|
|
|
291
252
|
export async function startRoles(options: StartRolesOptions): Promise<RunningRoles> {
|
|
292
253
|
// `realtime.enabled` read here, before anything binds: a refusal leaves nothing to unwind.
|
|
293
|
-
const selected = rolesUnderRealtime(
|
|
254
|
+
const selected = rolesUnderRealtime(
|
|
255
|
+
options.roles,
|
|
256
|
+
options.runtime.realtime,
|
|
257
|
+
(options.http ?? DEV_BINDING).dev,
|
|
258
|
+
);
|
|
294
259
|
assertOneJobDriver(options.runtime);
|
|
295
260
|
// Roles bind sockets in order, so a role that fails to start has to release the ones before it.
|
|
296
261
|
// Without this a failed `sync` leaves the web server bound and unreachable by any caller.
|
|
297
262
|
const started: (() => Promise<void>)[] = [];
|
|
298
263
|
try {
|
|
264
|
+
// Every role, before any can serve: an action's or a query's declared `rateLimit:` is spent
|
|
265
|
+
// inside the primitive (`invoke`, the read path) on every surface — a `worker` running
|
|
266
|
+
// `.job()`s as much as `web` — and counted in the installed store. The SAME instance the web
|
|
267
|
+
// role's limiter gets below (`rateLimitStoreFor` is the one decision), so one replica's MCP
|
|
268
|
+
// call and another's HTTP call spend one bucket in one table.
|
|
269
|
+
// A frame of its own over what `startServices` adopted, popped by its own undo — so stopping
|
|
270
|
+
// these roles takes back exactly this install, whatever else stacked over or under it.
|
|
271
|
+
const limits = rateLimitStoreFor(options);
|
|
272
|
+
const restoreLimits = limits === undefined ? () => undefined : adoptRateLimitStore(limits);
|
|
273
|
+
started.push(async () => restoreLimits());
|
|
299
274
|
// First, and for every role rather than only the two that open an HTTP socket: `worker` and
|
|
300
275
|
// `sync` are precisely the roles whose HPAs read a series the process itself has to publish,
|
|
301
276
|
// and a `worker` container with no listener is an HPA pinned at `<unknown>` forever.
|
|
@@ -319,21 +294,32 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
319
294
|
// own port and a listening server cannot be handed one, while the neighbouring-port refusals
|
|
320
295
|
// are only the right answer once the web port's own has been given.
|
|
321
296
|
const prepared = selected.includes('sync') ? await prepareSync(options) : null;
|
|
322
|
-
// One rollback entry, kept current — two would stop the node twice out of a failed boot.
|
|
297
|
+
// One rollback entry, kept current — two would stop the node twice out of a failed boot. It
|
|
298
|
+
// goes on the list AFTER the server's, because the list is also the stop order (newest
|
|
299
|
+
// first): the node's listener closes before the web server drains, as it always has. The
|
|
300
|
+
// window between — `startWeb` refusing — releases the prepared node itself.
|
|
323
301
|
let releaseSync = prepared?.stop ?? null;
|
|
302
|
+
let server: ServerHandle | null;
|
|
303
|
+
try {
|
|
304
|
+
server = selected.includes('web') ? startWeb(options, prepared?.mount) : null;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
await releaseSync?.().catch(() => undefined);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (server !== null) {
|
|
310
|
+
const web = server;
|
|
311
|
+
started.push(() => web.stop());
|
|
312
|
+
}
|
|
324
313
|
if (prepared !== null) started.push(async () => await releaseSync?.());
|
|
325
314
|
|
|
326
|
-
const server = selected.includes('web') ? startWeb(options, prepared?.mount) : null;
|
|
327
|
-
if (server !== null) started.push(() => server.stop());
|
|
328
|
-
|
|
329
315
|
const sync: RunningSync | null =
|
|
330
316
|
prepared === null ? null : await prepared.listen(server === null ? null : server.url());
|
|
331
317
|
if (sync !== null) releaseSync = sync.stop;
|
|
332
318
|
|
|
333
319
|
const worker = selected.includes('worker')
|
|
334
|
-
?
|
|
320
|
+
? jobWorker({
|
|
335
321
|
driver: options.runtime.jobs,
|
|
336
|
-
context: () =>
|
|
322
|
+
context: () => ctxOf({ role: 'worker', buildId: options.buildId }),
|
|
337
323
|
...workerOptionsFor(options.runtime.workerConfig),
|
|
338
324
|
})
|
|
339
325
|
: null;
|
|
@@ -351,7 +337,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
351
337
|
// collapses a repeat only while the first job is still live. A deployment with no `worker` has
|
|
352
338
|
// no one to run the jobs either way.
|
|
353
339
|
const relay: OutboxRelay | null = selected.includes('worker')
|
|
354
|
-
?
|
|
340
|
+
? outboxRelay({ store: options.runtime.outbox, driver: options.runtime.jobs })
|
|
355
341
|
: null;
|
|
356
342
|
relay?.start();
|
|
357
343
|
// Returned, not called-and-discarded: `stop()` waits out the pass in flight, and an unawaited
|
|
@@ -365,20 +351,20 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
365
351
|
const wake = selected.includes('worker') ? startWorkerWake(options.runtime.db) : null;
|
|
366
352
|
if (wake !== null) started.push(() => wake.stop());
|
|
367
353
|
|
|
368
|
-
// `state` and `leader`, not the defaults. `
|
|
354
|
+
// `state` and `leader`, not the defaults. `memorySchedulerState` forgets every watermark
|
|
369
355
|
// on restart, so a rolling deploy re-fires or skips whatever was due across it, and
|
|
370
356
|
// `soleLeader()` makes every replica the leader — three `scheduler` pods, three of every task.
|
|
371
357
|
//
|
|
372
|
-
// `
|
|
358
|
+
// `postgresLeaseLeader` and NOT `postgresLeader`: the latter's `pg_try_advisory_lock` is
|
|
373
359
|
// SESSION-scoped, and the session ends the moment the connection goes back to the pool, so
|
|
374
360
|
// every node reads itself as leader anyway. An expiring row is correct on the executor this
|
|
375
361
|
// package is actually handed.
|
|
376
362
|
const executor = pgExecutorFor(options.runtime.db);
|
|
377
363
|
const scheduler = selected.includes('scheduler')
|
|
378
|
-
?
|
|
364
|
+
? jobScheduler({
|
|
379
365
|
driver: options.runtime.jobs,
|
|
380
|
-
state:
|
|
381
|
-
leader:
|
|
366
|
+
state: postgresSchedulerState(executor),
|
|
367
|
+
leader: postgresLeaseLeader({ executor }),
|
|
382
368
|
})
|
|
383
369
|
: null;
|
|
384
370
|
scheduler?.start();
|
|
@@ -392,6 +378,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
392
378
|
services: options.runtime.services,
|
|
393
379
|
env: options.env,
|
|
394
380
|
transport: options.runtime.transport,
|
|
381
|
+
dev: binding.dev,
|
|
395
382
|
})
|
|
396
383
|
: null;
|
|
397
384
|
if (replicator !== null) started.push(() => replicator.stop());
|
|
@@ -406,6 +393,10 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
406
393
|
replicatorHere: replicator !== null,
|
|
407
394
|
});
|
|
408
395
|
started.push(async () => live.stop());
|
|
396
|
+
// The web server and the sync node mark ready when they bind; a process with neither — a
|
|
397
|
+
// worker, scheduler or replicator pod — is ready once its roles started, which is what the
|
|
398
|
+
// scrape port's `/readyz` answers (`metrics-endpoint.ts`). The checks still decide after this.
|
|
399
|
+
if (server === null && sync === null) markReady();
|
|
409
400
|
|
|
410
401
|
return {
|
|
411
402
|
roles: selected,
|
|
@@ -420,22 +411,15 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
420
411
|
liveBridge: live.bridge,
|
|
421
412
|
liveRegistry: sync?.registry ?? null,
|
|
422
413
|
async stop() {
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
//
|
|
428
|
-
// and
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
await wake?.stop();
|
|
433
|
-
await relay?.stop();
|
|
434
|
-
await worker?.stop('x dev stopped');
|
|
435
|
-
await sync?.stop();
|
|
436
|
-
await server?.stop();
|
|
437
|
-
// Last: a scrape taken while the roles above drain is the one that explains the drain.
|
|
438
|
-
metrics.stop();
|
|
414
|
+
// `started` IS the stop list, in boot order: released newest first — the live feed, the
|
|
415
|
+
// replicator (so the slot is released before the bus it published to closes), the
|
|
416
|
+
// scheduler, the wake session, the relay (awaited: a pass is an enqueue then a
|
|
417
|
+
// `markPublished`, and a pool closed between the two re-publishes next boot), the worker,
|
|
418
|
+
// the sync node, the server, the metrics listener (a scrape taken mid-drain explains the
|
|
419
|
+
// drain) and, after every role that could spend from it, the rate-limit store. EVERY step
|
|
420
|
+
// runs: one rejection used to skip the rest. One list, so the rollback and the stop cannot
|
|
421
|
+
// drift apart.
|
|
422
|
+
await releaseOrThrow(started);
|
|
439
423
|
},
|
|
440
424
|
};
|
|
441
425
|
} catch (error) {
|