@cosmicdrift/kumiko-framework 0.234.0 → 0.235.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.
@@ -24,8 +24,8 @@ import {
24
24
  createTextField,
25
25
  } from "./factories";
26
26
  import { isKebabSegment } from "./qualified-name";
27
- import type { ConfigKeyDefinition } from "./types/config";
28
- import type { Registry } from "./types/feature";
27
+ import type { ConfigKeyDefinition, TranslationKeys } from "./types/config";
28
+ import type { Registry, SecretKeyDefinition } from "./types/feature";
29
29
  import type { FieldDefinition } from "./types/fields";
30
30
  import type { AccessRule } from "./types/handlers";
31
31
  import type { NavDefinition, NavIconKey } from "./types/nav";
@@ -33,6 +33,8 @@ import type {
33
33
  ConfigEditScreenDefinition,
34
34
  EditFieldsSection,
35
35
  ScreenDefinition,
36
+ SecretsEditScreenDefinition,
37
+ SecretsEditSection,
36
38
  } from "./types/screen";
37
39
 
38
40
  // Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
@@ -53,6 +55,9 @@ export type ConfigFeatureSchema = {
53
55
  // wenn die App bereits Workspaces nutzt (sonst kippt eine workspace-lose
54
56
  // App in den Filter-Modus und verliert alle übrigen Navs).
55
57
  readonly workspace?: WorkspaceSchema;
58
+ // Generated i18n keys for the secrets screen (label/hint text pulled from
59
+ // the `r.secret()` declaration itself, not authored via r.translations).
60
+ readonly translations?: TranslationKeys;
56
61
  };
57
62
 
58
63
  // Audience-Reihenfolge im Sidebar: Plattform vor Tenant vor Benutzer.
@@ -105,64 +110,231 @@ type MaskedKey = {
105
110
 
106
111
  type ScopedKey = { readonly key: MaskedKey; readonly roles: readonly string[] };
107
112
 
108
- export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema {
109
- const masked = collectMaskedKeys(registry);
110
- if (masked.length === 0) return { screens: [], navs: [] };
113
+ type ScopeResult = {
114
+ readonly screens: readonly ScreenDefinition[];
115
+ readonly navs: readonly NavDefinition[];
116
+ readonly translations?: TranslationKeys;
117
+ };
111
118
 
112
- // Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
113
- // (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
119
+ // Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
120
+ // (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
121
+ function collectDeclaredTranslationKeys(registry: Registry): Set<string> {
114
122
  const declaredTranslationKeys = new Set<string>();
115
123
  for (const f of registry.features.values()) {
116
124
  for (const key of Object.keys(f.translations ?? {})) declaredTranslationKeys.add(key);
117
125
  }
126
+ return declaredTranslationKeys;
127
+ }
128
+
129
+ // Per-feature configEdit screen + child-nav, one pair per feature present in
130
+ // `visible` at this scope.
131
+ function buildFeatureScreensAndNavs(
132
+ scope: ConfigScope,
133
+ visible: readonly ScopedKey[],
134
+ declaredTranslationKeys: ReadonlySet<string>,
135
+ ): { screens: ScreenDefinition[]; navs: NavDefinition[] } {
136
+ const screens: ScreenDefinition[] = [];
137
+ const navs: NavDefinition[] = [];
138
+ for (const feature of featuresPresent(visible.map((v) => v.key))) {
139
+ const group = visible.filter((v) => v.key.feature === feature);
140
+ const ordered = sortByMaskOrder(group.map((v) => v.key));
141
+ const access = rolesToAccess(group.flatMap((v) => v.roles));
142
+ const shortId = `${feature}-${scope}`;
143
+
144
+ screens.push(buildScreen(shortId, scope, feature, ordered, access, declaredTranslationKeys));
145
+ // A tenant/user-home key with an elevated write role (SystemAdmin on a
146
+ // tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
147
+ // audience navs (cascade-default screen + home screen) — both would
148
+ // otherwise carry the identical `${feature}.settings` label. Opt-in
149
+ // scoped override (`${feature}.settings.${scope}`) disambiguates only
150
+ // where a feature actually declares one; every single-scope feature
151
+ // keeps the plain key unchanged.
152
+ const scopedLabel = `${feature}.settings.${scope}`;
153
+ navs.push({
154
+ id: shortId,
155
+ label: declaredTranslationKeys.has(scopedLabel) ? scopedLabel : `${feature}.settings`,
156
+ parent: audienceNavShortId(scope),
157
+ screen: shortId,
158
+ icon: ordered[0]?.def.mask?.icon ?? "settings",
159
+ order: minMaskOrder(ordered),
160
+ access,
161
+ });
162
+ }
163
+ return { screens, navs };
164
+ }
165
+
166
+ // Everything generated for one audience scope: the audience-parent nav, the
167
+ // per-feature configEdit screens+navs, and (tenant-scope only, when secrets
168
+ // are enabled) the secrets screen. Null when nothing is visible at this scope.
169
+ function buildScopeResult(
170
+ scope: ConfigScope,
171
+ masked: readonly MaskedKey[],
172
+ secretsEnabled: boolean,
173
+ secretsWriteHandler: ReturnType<Registry["getWriteHandler"]>,
174
+ declaredSecrets: readonly DeclaredSecret[],
175
+ declaredTranslationKeys: ReadonlySet<string>,
176
+ ): ScopeResult | null {
177
+ const visible = scopedKeysAt(masked, scope);
178
+ // Secrets attach to the tenant-audience nav regardless of whether any
179
+ // config key is visible there — they alone must be enough to open it.
180
+ const includeSecrets = secretsEnabled && scope === "tenant";
181
+ if (visible.length === 0 && !includeSecrets) return null;
182
+
183
+ const configAccess = rolesToAccess(visible.flatMap((v) => v.roles));
184
+ const secretsAccess = secretsWriteHandler?.access;
185
+ const screens: ScreenDefinition[] = [];
186
+ const navs: NavDefinition[] = [];
187
+ // Audience-Parent: Gruppierungs-Knoten ohne Screen.
188
+ navs.push({
189
+ id: audienceNavShortId(scope),
190
+ label: `config.settings.${scope}`,
191
+ icon: SCOPE_ICON[scope],
192
+ order: SCOPE_ORDER[scope],
193
+ access: includeSecrets ? unionAccessRules([configAccess, secretsAccess]) : configAccess,
194
+ });
195
+
196
+ const perFeature = buildFeatureScreensAndNavs(scope, visible, declaredTranslationKeys);
197
+ screens.push(...perFeature.screens);
198
+ navs.push(...perFeature.navs);
199
+
200
+ let translations: TranslationKeys | undefined;
201
+ if (includeSecrets) {
202
+ const generated = buildSecretsScreen(declaredSecrets, secretsAccess, declaredTranslationKeys);
203
+ screens.push(generated.screen);
204
+ navs.push(generated.nav);
205
+ translations = generated.translations;
206
+ }
207
+
208
+ return { screens, navs, ...(translations !== undefined && { translations }) };
209
+ }
210
+
211
+ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema {
212
+ const masked = collectMaskedKeys(registry);
213
+ const declaredSecrets = collectDeclaredSecrets(registry);
214
+ // The `secrets` feature must actually be mounted (its set-handler
215
+ // registered) for a declared `r.secret()` to have anywhere to write to.
216
+ const secretsWriteHandler = registry.getWriteHandler("secrets:write:set");
217
+ const secretsEnabled = secretsWriteHandler !== undefined && declaredSecrets.length > 0;
218
+ if (masked.length === 0 && !secretsEnabled) return { screens: [], navs: [] };
219
+
220
+ const declaredTranslationKeys = collectDeclaredTranslationKeys(registry);
118
221
 
119
222
  const screens: ScreenDefinition[] = [];
120
223
  const navs: NavDefinition[] = [];
224
+ let translations: TranslationKeys | undefined;
121
225
 
122
226
  for (const scope of SCOPES_BROAD_TO_DEEP) {
123
- const visible = scopedKeysAt(masked, scope);
124
- if (visible.length === 0) continue;
227
+ const result = buildScopeResult(
228
+ scope,
229
+ masked,
230
+ secretsEnabled,
231
+ secretsWriteHandler,
232
+ declaredSecrets,
233
+ declaredTranslationKeys,
234
+ );
235
+ if (result === null) continue;
236
+ screens.push(...result.screens);
237
+ navs.push(...result.navs);
238
+ if (result.translations !== undefined) translations = result.translations;
239
+ }
125
240
 
126
- // Audience-Parent: Gruppierungs-Knoten ohne Screen.
127
- navs.push({
128
- id: audienceNavShortId(scope),
129
- label: `config.settings.${scope}`,
130
- icon: SCOPE_ICON[scope],
131
- order: SCOPE_ORDER[scope],
132
- access: rolesToAccess(visible.flatMap((v) => v.roles)),
241
+ // Every masked key machine-only and no secrets → no human hub, and no
242
+ // (empty) settings switcher.
243
+ if (navs.length === 0) return { screens, navs };
244
+ return {
245
+ screens,
246
+ navs,
247
+ workspace: buildSettingsWorkspace(navs),
248
+ ...(translations !== undefined && { translations }),
249
+ };
250
+ }
251
+
252
+ type DeclaredSecret = {
253
+ readonly qn: string;
254
+ readonly feature: string;
255
+ readonly shortKey: string;
256
+ readonly def: SecretKeyDefinition;
257
+ };
258
+
259
+ function collectDeclaredSecrets(registry: Registry): DeclaredSecret[] {
260
+ const out: DeclaredSecret[] = [];
261
+ for (const [qn, def] of registry.getAllSecretKeys()) {
262
+ const sep = qn.indexOf(":secret:");
263
+ if (sep === -1) continue;
264
+ out.push({
265
+ qn,
266
+ feature: qn.slice(0, sep),
267
+ shortKey: qn.slice(sep + ":secret:".length),
268
+ def,
133
269
  });
270
+ }
271
+ return out.sort(
272
+ (a, b) => a.feature.localeCompare(b.feature) || a.shortKey.localeCompare(b.shortKey),
273
+ );
274
+ }
275
+
276
+ // One screen for ALL declared secrets (not one per feature — a per-feature
277
+ // nav would collide with the same feature's configEdit nav under
278
+ // audience-tenant). `access` mirrors the secrets `set`-handler's own access
279
+ // rule verbatim so a screen never permits more than the handler itself does.
280
+ function buildSecretsScreen(
281
+ secrets: readonly DeclaredSecret[],
282
+ access: AccessRule | undefined,
283
+ declaredTranslationKeys: ReadonlySet<string>,
284
+ ): { screen: SecretsEditScreenDefinition; nav: NavDefinition; translations: TranslationKeys } {
285
+ const secretKeys: Record<string, string> = {};
286
+ const fieldLabels: Record<string, string> = {};
287
+ const fieldHints: Record<string, string> = {};
288
+ const requiredFields: string[] = [];
289
+ // Mutable outer record — TranslationKeys' Readonly<Record<...>> index
290
+ // signature only permits reading, so the top-level assignments below
291
+ // need a writable local type; each entry is still a fresh, never-mutated object.
292
+ const translations: Record<string, Readonly<Record<string, string>>> = {};
293
+ const sections: SecretsEditSection[] = [];
134
294
 
135
- for (const feature of featuresPresent(visible.map((v) => v.key))) {
136
- const group = visible.filter((v) => v.key.feature === feature);
137
- const ordered = sortByMaskOrder(group.map((v) => v.key));
138
- const access = rolesToAccess(group.flatMap((v) => v.roles));
139
- const shortId = `${feature}-${scope}`;
140
-
141
- screens.push(buildScreen(shortId, scope, feature, ordered, access, declaredTranslationKeys));
142
- // A tenant/user-home key with an elevated write role (SystemAdmin on a
143
- // tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
144
- // audience navs (cascade-default screen + home screen) — both would
145
- // otherwise carry the identical `${feature}.settings` label. Opt-in
146
- // scoped override (`${feature}.settings.${scope}`) disambiguates only
147
- // where a feature actually declares one; every single-scope feature
148
- // keeps the plain key unchanged.
149
- const scopedLabel = `${feature}.settings.${scope}`;
150
- navs.push({
151
- id: shortId,
152
- label: declaredTranslationKeys.has(scopedLabel) ? scopedLabel : `${feature}.settings`,
153
- parent: audienceNavShortId(scope),
154
- screen: shortId,
155
- icon: ordered[0]?.def.mask?.icon ?? "settings",
156
- order: minMaskOrder(ordered),
157
- access,
158
- });
295
+ for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
296
+ const fieldIds: string[] = [];
297
+ for (const s of secrets.filter((v) => v.feature === feature)) {
298
+ const fieldId = `${s.feature}-${s.shortKey}`;
299
+ fieldIds.push(fieldId);
300
+ secretKeys[fieldId] = s.qn;
301
+ const labelKey = `config.secret.${s.feature}.${s.shortKey}.label`;
302
+ fieldLabels[fieldId] = labelKey;
303
+ translations[labelKey] = { ...s.def.label };
304
+ if (s.def.hint !== undefined) {
305
+ const hintKey = `config.secret.${s.feature}.${s.shortKey}.hint`;
306
+ fieldHints[fieldId] = hintKey;
307
+ translations[hintKey] = { ...s.def.hint };
308
+ }
309
+ if (s.def.required === true) requiredFields.push(fieldId);
159
310
  }
311
+ const titleKey = `${feature}.settings`;
312
+ sections.push({
313
+ ...(declaredTranslationKeys.has(titleKey) && { title: titleKey }),
314
+ fields: fieldIds,
315
+ });
160
316
  }
161
317
 
162
- // Alle masked Keys maschinen-only → kein menschlicher Hub, kein (leerer)
163
- // Settings-Switcher.
164
- if (navs.length === 0) return { screens, navs };
165
- return { screens, navs, workspace: buildSettingsWorkspace(navs) };
318
+ const screen: SecretsEditScreenDefinition = {
319
+ id: "secrets",
320
+ type: "secretsEdit",
321
+ secretKeys,
322
+ fieldLabels,
323
+ ...(Object.keys(fieldHints).length > 0 && { fieldHints }),
324
+ ...(requiredFields.length > 0 && { requiredFields }),
325
+ sections,
326
+ ...(access !== undefined && { access }),
327
+ };
328
+ const nav: NavDefinition = {
329
+ id: "secrets",
330
+ label: "config.secrets.title",
331
+ parent: audienceNavShortId("tenant"),
332
+ screen: "secrets",
333
+ icon: "key",
334
+ order: 900,
335
+ ...(access !== undefined && { access }),
336
+ };
337
+ return { screen, nav, translations };
166
338
  }
167
339
 
168
340
  // Keys visible at `scope`, paired with their effective write roles AT that
@@ -440,6 +440,8 @@ export type {
440
440
  ScreenNavSugar,
441
441
  ScreenSlots,
442
442
  SecretKeyHandle,
443
+ SecretsEditScreenDefinition,
444
+ SecretsEditSection,
443
445
  SelectFieldDef,
444
446
  SessionUser,
445
447
  StreamHandlerDef,
@@ -587,6 +587,22 @@ export function validateJobTriggers(state: RegistryState): void {
587
587
  }
588
588
  }
589
589
 
590
+ export function validateBootGates(state: RegistryState): void {
591
+ // Boot gates run inline in JobRunner.start(), which has neither the
592
+ // per-tenant fan-out (that re-enqueues) nor the sequential lock's
593
+ // re-enqueue-and-return path. Both would make the gate pass silently
594
+ // without ever running the handler.
595
+ for (const [jobName, jobDef] of state.jobMap) {
596
+ if (!jobDef.bootGate) continue;
597
+ if (jobDef.perTenant) {
598
+ throw new Error(`Job "${jobName}" cannot combine bootGate with perTenant`);
599
+ }
600
+ if (jobDef.concurrency === "sequential") {
601
+ throw new Error(`Job "${jobName}" cannot combine bootGate with concurrency "sequential"`);
602
+ }
603
+ }
604
+ }
605
+
590
606
  export function validateExtensionUsageTargets(state: RegistryState): void {
591
607
  // Validate: extension usages must reference existing extensions
592
608
  for (const usage of state.extensionUsages) {
@@ -27,6 +27,7 @@ import {
27
27
  populateHandlerEntityMappings,
28
28
  publishEventPiiCatalog,
29
29
  resolveNotificationTriggersAndRegisterHooks,
30
+ validateBootGates,
30
31
  validateEntityHookTargets,
31
32
  validateEventMigrationVersions,
32
33
  validateExtensionSelectors,
@@ -79,6 +80,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
79
80
  validateLifecycleHookTargets(state);
80
81
  validateEntityHookTargets(state, features);
81
82
  validateJobTriggers(state);
83
+ validateBootGates(state);
82
84
  validateExtensionUsageTargets(state);
83
85
  computeHasRateLimitedHandler(state);
84
86
  publishEventPiiCatalog(state);
@@ -297,6 +297,8 @@ export type {
297
297
  ScreenFilterOp,
298
298
  ScreenNavSugar,
299
299
  ScreenSlots,
300
+ SecretsEditScreenDefinition,
301
+ SecretsEditSection,
300
302
  ToolbarAction,
301
303
  UnitKey,
302
304
  } from "@cosmicdrift/kumiko-types/screen";
package/src/i18n/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  pickAcceptLanguage,
14
14
  resolveHeaderLocale,
15
15
  } from "./request-locale";
16
+ export { SETTINGS_HUB_I18N } from "./settings-hub-keys";
16
17
 
17
18
  export type I18nOptions = {
18
19
  defaultLocale: string;
@@ -13,6 +13,7 @@ import type {
13
13
  ProjectionListScreenDefinition,
14
14
  RowAction,
15
15
  ScreenDefinition,
16
+ SecretsEditScreenDefinition,
16
17
  ToolbarAction,
17
18
  WorkspaceDefinition,
18
19
  } from "../engine/types";
@@ -223,6 +224,16 @@ export function requiredKeysFromScreen(
223
224
  }
224
225
  break;
225
226
  }
227
+ case "secretsEdit": {
228
+ const secretsEdit = screen as SecretsEditScreenDefinition;
229
+ for (const key of Object.values(secretsEdit.fieldLabels))
230
+ pushKey(out, key, treatDotFormAsKey);
231
+ for (const key of Object.values(secretsEdit.fieldHints ?? {})) {
232
+ pushKey(out, key, treatDotFormAsKey);
233
+ }
234
+ for (const section of secretsEdit.sections) pushKey(out, section.title, treatDotFormAsKey);
235
+ break;
236
+ }
226
237
  case "projectionDetail": {
227
238
  const detail = screen as ProjectionDetailScreenDefinition;
228
239
  for (const section of detail.layout.sections) {
@@ -0,0 +1,28 @@
1
+ import type { TranslationKeys } from "../engine/types/config";
2
+
3
+ /**
4
+ * Settings-Hub chrome keys (parent-nav labels, secrets-screen title/strings)
5
+ * needed by ANY app that mounts a foundation feature producing a hub nav —
6
+ * `secrets` is auto-mounted (see FOUNDATION_FEATURES in
7
+ * @cosmicdrift/kumiko-dev-server scaffold-app.ts) while `config` stays
8
+ * optional, so these keys must be shippable without `config` present.
9
+ * Both `config` and `secrets` ship this bundle via r.translations — see
10
+ * @cosmicdrift/kumiko-bundled-features config/i18n.ts (CONFIG_FEATURE_I18N)
11
+ * and secrets/feature.ts. Owned here (not in bundled-features) because the
12
+ * Settings-Hub generator that requires these keys
13
+ * (build-config-feature-schema.ts) lives in this package.
14
+ */
15
+ export const SETTINGS_HUB_I18N: TranslationKeys = {
16
+ "config.settings.title": { en: "Settings" },
17
+ "config.settings.system": { en: "Platform" },
18
+ "config.settings.tenant": { en: "Tenant" },
19
+ "config.settings.user": { en: "Personal" },
20
+ "config.secrets.title": { en: "Secrets" },
21
+ "config.secrets.notSet": { en: "Not set" },
22
+ "config.secrets.set": { en: "Set" },
23
+ "config.secrets.required": { en: "Required" },
24
+ "config.secrets.placeholder": { en: "Enter a value" },
25
+ "config.secrets.replacePlaceholder": { en: "Enter a new value to replace" },
26
+ "config.secrets.delete": { en: "Delete" },
27
+ "screen:secrets.title": { en: "Secrets" },
28
+ };
@@ -15,6 +15,7 @@ import { createInMemoryFileProvider } from "../../files/in-memory-provider";
15
15
  import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
16
16
  import { sleep, waitFor } from "../../testing";
17
17
  import {
18
+ bootJobIdForJobName,
18
19
  createJobRunner,
19
20
  type JobLogEntry,
20
21
  type JobMeta,
@@ -1222,3 +1223,129 @@ describe("job registry", () => {
1222
1223
  expect(job?.runOnBoot).toBe(true);
1223
1224
  });
1224
1225
  });
1226
+
1227
+ // --- Boot gates: only an inline gate can abort a boot (fw#2590) ---
1228
+
1229
+ describe("boot gates", () => {
1230
+ const gateLog: string[] = [];
1231
+ const GATE_FAILURE = "required legal blocks are not seeded";
1232
+
1233
+ function uniquePrefix(): string {
1234
+ return `kumiko-gate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1235
+ }
1236
+
1237
+ function createGateRunner(
1238
+ feature: ReturnType<typeof defineFeature>,
1239
+ queueNamePrefix: string,
1240
+ ): JobRunner {
1241
+ const context: AppContext = {};
1242
+ return createJobRunner({
1243
+ registry: createRegistry([feature]),
1244
+ context,
1245
+ redisUrl,
1246
+ consumerLane: "worker",
1247
+ queueNamePrefix,
1248
+ });
1249
+ }
1250
+
1251
+ async function purge(queueNamePrefix: string): Promise<void> {
1252
+ const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
1253
+ if (keys.length > 0) await testRedis.redis.del(...keys);
1254
+ }
1255
+
1256
+ test("a throwing gate rejects start() and stops the remaining boot wiring", async () => {
1257
+ const feature = defineFeature("failgate", (r) => {
1258
+ r.job("check", { trigger: { manual: true }, bootGate: true }, async () => {
1259
+ throw new Error(GATE_FAILURE);
1260
+ });
1261
+ r.job("afterGate", { trigger: { manual: true }, runOnBoot: true }, async () => {
1262
+ gateLog.push("after-gate");
1263
+ });
1264
+ });
1265
+ const prefix = uniquePrefix();
1266
+ const runner = createGateRunner(feature, prefix);
1267
+ gateLog.length = 0;
1268
+
1269
+ // Pin the qualified name the boot-job-id assertion below is built on —
1270
+ // a typo there would make that assertion pass vacuously.
1271
+ const afterGateName = "failgate:job:after-gate";
1272
+ expect(createRegistry([feature]).getJob(afterGateName)).toBeDefined();
1273
+
1274
+ const queue = new Queue(`${prefix}-worker`, {
1275
+ connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
1276
+ });
1277
+ // A post-close 'error' here is otherwise unhandled and bun:test
1278
+ // attributes it to whichever test runs next (fw#1805).
1279
+ queue.on("error", () => {});
1280
+
1281
+ try {
1282
+ // The gate's own message must survive — which blocks are missing is the
1283
+ // entire operational value, and onJobFailed runs before the re-throw.
1284
+ await expect(runner.start()).rejects.toThrow(GATE_FAILURE);
1285
+ await sleep(300);
1286
+ // The gate aborted start() before any further boot wiring ran: the
1287
+ // runOnBoot job was never even enqueued, and never executed.
1288
+ const bootJob = await queue.getJob(bootJobIdForJobName(afterGateName));
1289
+ expect(bootJob).toBeUndefined();
1290
+ expect(gateLog).toEqual([]);
1291
+ } finally {
1292
+ await queue.close();
1293
+ await runner.stop();
1294
+ await purge(prefix);
1295
+ }
1296
+ });
1297
+
1298
+ test("a gate runs on every start, unlike a deduped runOnBoot job", async () => {
1299
+ const feature = defineFeature("everygate", (r) => {
1300
+ r.job("check", { trigger: { manual: true }, bootGate: true }, async () => {
1301
+ gateLog.push("gate");
1302
+ });
1303
+ r.job("once", { trigger: { manual: true }, runOnBoot: true }, async () => {
1304
+ gateLog.push("boot");
1305
+ });
1306
+ });
1307
+ const prefix = uniquePrefix();
1308
+ gateLog.length = 0;
1309
+
1310
+ const first = createGateRunner(feature, prefix);
1311
+ await first.start();
1312
+ await waitFor(() => {
1313
+ expect(gateLog.filter((e) => e === "boot").length).toBe(1);
1314
+ });
1315
+ await first.stop();
1316
+
1317
+ const second = createGateRunner(feature, prefix);
1318
+ try {
1319
+ await second.start();
1320
+ await sleep(300);
1321
+ expect(gateLog.filter((e) => e === "gate").length).toBe(2);
1322
+ // Same fixed boot job id, still present in Redis from the first start,
1323
+ // so BullMQ drops the second enqueue — that dedup is why runOnBoot
1324
+ // cannot gate a deploy.
1325
+ expect(gateLog.filter((e) => e === "boot").length).toBe(1);
1326
+ } finally {
1327
+ await second.stop();
1328
+ await purge(prefix);
1329
+ }
1330
+ });
1331
+
1332
+ test("bootGate is rejected for perTenant and sequential jobs", () => {
1333
+ const perTenantGate = defineFeature("pergate", (r) => {
1334
+ r.job("check", { trigger: { manual: true }, bootGate: true, perTenant: true }, async () => {
1335
+ gateLog.push("never");
1336
+ });
1337
+ });
1338
+ expect(() => createRegistry([perTenantGate])).toThrow(/bootGate with perTenant/);
1339
+
1340
+ const sequentialGate = defineFeature("seqgate", (r) => {
1341
+ r.job(
1342
+ "check",
1343
+ { trigger: { manual: true }, bootGate: true, concurrency: "sequential" },
1344
+ async () => {
1345
+ gateLog.push("never");
1346
+ },
1347
+ );
1348
+ });
1349
+ expect(() => createRegistry([sequentialGate])).toThrow(/bootGate with concurrency/);
1350
+ });
1351
+ });
@@ -1,4 +1,4 @@
1
- import { type Job, Queue, Worker } from "bullmq";
1
+ import { Queue, Worker } from "bullmq";
2
2
  import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
@@ -395,7 +395,17 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
395
395
  return false;
396
396
  }
397
397
 
398
- async function handleJob(bullJob: Job): Promise<void> {
398
+ // Structural instead of BullMQ's `Job` so boot gates can invoke the same
399
+ // execution path inline. A real `Job` stays assignable, so handleJob is
400
+ // still a valid BullMQ Processor.
401
+ type JobInvocation = {
402
+ readonly id?: string | undefined;
403
+ readonly name: string;
404
+ readonly data: Record<string, unknown>;
405
+ readonly attemptsMade: number;
406
+ };
407
+
408
+ async function handleJob(bullJob: JobInvocation): Promise<void> {
399
409
  const rawName = bullJob.name;
400
410
 
401
411
  // Handle perTenant dispatch jobs — fan out to one job per tenant. The
@@ -651,6 +661,27 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
651
661
  }
652
662
  }
653
663
 
664
+ // Boot gates run inline and are awaited, so a throwing gate rejects start()
665
+ // — and with it the entrypoint's start() and runProdApp's boot, before the
666
+ // process reports ready. Enqueuing them (as runOnBoot does) would only fail
667
+ // a queue job and let the deploy through (fw#2590).
668
+ async function runBootGates(lane: JobRunIn): Promise<void> {
669
+ for (const [name, jobDef] of allJobs) {
670
+ if (laneForJob(jobDef) !== lane) continue;
671
+ if (!jobDef.bootGate) continue;
672
+ try {
673
+ await handleJob({ id: bootJobIdForJobName(name), name, data: {}, attemptsMade: 0 });
674
+ } catch (err) {
675
+ // The worker is already consuming here; leaving its Redis connections
676
+ // open would keep the event loop alive and turn the aborted boot into
677
+ // a hanging process instead of a non-zero exit.
678
+ await worker?.close();
679
+ worker = null;
680
+ throw err;
681
+ }
682
+ }
683
+ }
684
+
654
685
  const runnerApi: JobRunner = {
655
686
  async start(): Promise<void> {
656
687
  // skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
@@ -689,6 +720,10 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
689
720
  bootTimeout.cancel();
690
721
  }
691
722
 
723
+ // Ahead of cron/runOnBoot wiring on purpose: a failed gate must not
724
+ // leave recurring schedulers behind.
725
+ await runBootGates(consumerLane);
726
+
692
727
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
693
728
  // assigned to the other lane get their cron/boot wiring from the
694
729
  // runner running on that lane. Running both here would double-fire.