@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.
- package/package.json +3 -3
- package/src/__tests__/unprocessable-error-details-reason-codemod.test.ts +205 -0
- package/src/api/server.ts +3 -0
- package/src/changes.json +8 -0
- package/src/db/queries/event-consumer.ts +20 -5
- package/src/engine/__tests__/build-app-schema.test.ts +48 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +140 -0
- package/src/engine/boot-validator/i18n-keys.ts +13 -5
- package/src/engine/boot-validator/screens.ts +5 -0
- package/src/engine/build-app-schema.ts +14 -0
- package/src/engine/build-config-feature-schema.ts +216 -44
- package/src/engine/index.ts +2 -0
- package/src/engine/registry-validate.ts +16 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/index.ts +2 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/required-surface-keys.ts +11 -0
- package/src/i18n/settings-hub-keys.ts +28 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +127 -0
- package/src/jobs/job-runner.ts +37 -2
- package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +176 -0
- package/src/pipeline/event-dispatcher-delivery.ts +1 -1
- package/src/pipeline/event-dispatcher.ts +6 -0
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +273 -0
- package/src/testing/e2e-generator.ts +3 -0
- package/src/ui-types/index.ts +2 -0
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
type ScopeResult = {
|
|
114
|
+
readonly screens: readonly ScreenDefinition[];
|
|
115
|
+
readonly navs: readonly NavDefinition[];
|
|
116
|
+
readonly translations?: TranslationKeys;
|
|
117
|
+
};
|
|
111
118
|
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
package/src/engine/index.ts
CHANGED
|
@@ -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) {
|
package/src/engine/registry.ts
CHANGED
|
@@ -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);
|
package/src/i18n/index.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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.
|