@cosmicdrift/kumiko-framework 0.50.0 → 0.51.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.45.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.50.0",
185
185
  "@types/uuid": "^11.0.0",
186
186
  "bun-types": "^1.3.13",
187
187
  "pino-pretty": "^13.1.3"
@@ -1521,6 +1521,26 @@ describe("boot-validator", () => {
1521
1521
  ).toThrow(/Config-Key "shop:config:typo-here" ist in keiner Feature-Registry deklariert/);
1522
1522
  });
1523
1523
 
1524
+ test("camelCase-Quelle (multi-word) → kanonische kebab-QN matched, kein Throw", () => {
1525
+ // Regression: r.config registriert den camelCase-Objekt-Key
1526
+ // (brandingTitle), die echte QN ist aber `shop:config:branding-title`
1527
+ // (define-feature toKebab't beides). Ein configEdit-Screen, der die
1528
+ // kanonische kebab-QN referenziert, darf NICHT als "unbekannt" failen —
1529
+ // ohne qualifyEntityName im Validator stand dort die rohe camelCase-QN.
1530
+ const feature = defineFeature("shop", (r) => {
1531
+ r.config({ keys: { brandingTitle: createTenantConfig("text", { default: "" }) } });
1532
+ r.screen({
1533
+ id: "settings",
1534
+ type: "configEdit",
1535
+ scope: "tenant",
1536
+ configKeys: { title: "shop:config:branding-title" },
1537
+ fields: { title: { type: "text" } } as never,
1538
+ layout: { sections: [{ title: "Basics", fields: ["title"] }] } as never,
1539
+ });
1540
+ });
1541
+ expect(() => validateBoot([feature])).not.toThrow();
1542
+ });
1543
+
1524
1544
  test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
1525
1545
  // synthesizeConfigEditScreen reicht die layout 1:1 an RenderEdit weiter —
1526
1546
  // eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
@@ -2124,11 +2144,11 @@ describe("boot-validator — config key backing × scope", () => {
2124
2144
  expect(() => validateBoot([feature])).toThrow(/backing="secrets".*requires scope="system"/i);
2125
2145
  });
2126
2146
 
2127
- test("rejects backing:secrets even system-scoped until the dispatch is wired (framework#333)", () => {
2147
+ test("allows backing:secrets when system-scoped (dispatch is wired)", () => {
2128
2148
  const feature = defineFeature("billing", (r) => {
2129
2149
  r.config({ keys: { apiKey: createSystemConfig("text", { backing: "secrets" }) } });
2130
2150
  });
2131
- expect(() => validateBoot([feature])).toThrow(/not yet wired.*333/i);
2151
+ expect(() => validateBoot([feature])).not.toThrow();
2132
2152
  });
2133
2153
 
2134
2154
  test("a config key without secrets backing boots fine", () => {
@@ -151,14 +151,12 @@ export function validateConfigKeyBacking(feature: FeatureDefinition): void {
151
151
  );
152
152
  }
153
153
 
154
- // The secrets read/write dispatch is not yet wired (framework#333). Until
155
- // it lands, the value would silently persist in the config-values
156
- // projection instead of the secrets store losing the envelope-encryption,
157
- // key-rotation and audit-on-read that backing="secrets" promises. Reject
158
- // loudly rather than degrade to config-encrypted behind the declaration.
159
- throw new Error(
160
- `[Feature ${feature.name}] Config key "${keyName}" declares backing="secrets", but the secrets read/write dispatch is not yet wired (framework#333). Until then remove backing (the value would store as config-encrypted) or gate the feature behind #333.`,
161
- );
154
+ // system-scoped backing="secrets" is wired end-to-end: reads dispatch
155
+ // through the resolver's secretsReader, writes through config:write:set/
156
+ // :reset into the secrets store (SYSTEM_TENANT_ID), masked in the query
157
+ // handlers. The runtime contract is that the app provides
158
+ // `extraContext.secrets` (+ a MasterKeyProvider) a backing="secrets"
159
+ // read/write without it throws loud at request time, not silently.
162
160
  }
163
161
  }
164
162
 
@@ -1,3 +1,4 @@
1
+ import { QnTypes, qualifyEntityName } from "../qualified-name";
1
2
  import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
2
3
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
3
4
  import {
@@ -58,14 +59,18 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
58
59
 
59
60
  // Collect all config keys across features (for cross-feature reference validation)
60
61
  const allConfigKeys = new Set<string>();
61
- // Qualified config-key set für ConfigEditScreen-Validation. Format
62
- // wie in registry.ts: `<feature>:config:<short>`. allConfigKeys oben
63
- // nutzt das ältere `feature.short`-Format für validateConfigReads.
62
+ // Qualified config-key set für ConfigEditScreen-Validation. MUSS via
63
+ // qualifyEntityName kanonisiert werden (toKebab auf Feature + Key) — exakt
64
+ // wie define-feature/registry den QN bildet. Der rohe f.configKeys-Key ist
65
+ // der camelCase-Objekt-Key (`brandingTitle`), die echte QN aber
66
+ // `…:config:branding-title`; ohne toKebab failt jeder configEdit-Screen mit
67
+ // multi-word Config-Key fälschlich. allConfigKeys oben nutzt das ältere
68
+ // `feature.short`-Format für validateConfigReads.
64
69
  const allConfigKeyQns = new Set<string>();
65
70
  for (const f of features) {
66
71
  for (const key of Object.keys(f.configKeys)) {
67
72
  allConfigKeys.add(`${f.name}.${key}`);
68
- allConfigKeyQns.add(`${f.name}:config:${key}`);
73
+ allConfigKeyQns.add(qualifyEntityName(f.name, QnTypes.config, key));
69
74
  }
70
75
  }
71
76
 
@@ -64,6 +64,11 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
64
64
  encrypted?: boolean;
65
65
  options?: readonly string[]; // for select type
66
66
  bounds?: T extends "number" ? ConfigBounds : never;
67
+ // Regex enforced at write (set.write) — only meaningful for text keys
68
+ // (never for the other type-tags). Use anchored + length-bounded patterns:
69
+ // the value is tenant-supplied, so an unbounded backtracking regex is a
70
+ // ReDoS vector. See ConfigKeyDefinition.pattern.
71
+ pattern?: T extends "text" ? { regex: string; flags?: string } : never;
67
72
  computed?: ConfigComputedFn<T>;
68
73
  allowPerRequest?: T extends "text" ? never : boolean;
69
74
  required?: boolean;
@@ -100,6 +105,7 @@ function createConfigKey<T extends ConfigKeyType>(
100
105
  ...(opts.encrypted ? { encrypted: true } : {}),
101
106
  ...(opts.options ? { options: opts.options } : {}),
102
107
  bounds: opts.bounds as ConfigBounds | undefined, // @cast-boundary schema-walk
108
+ ...(opts.pattern ? { pattern: opts.pattern } : {}),
103
109
  computed: opts.computed,
104
110
  ...(opts.allowPerRequest === true ? { allowPerRequest: true } : {}),
105
111
  ...(opts.required === true ? { required: true } : {}),
@@ -19,6 +19,10 @@ export type ManifestConfigKey = {
19
19
  readonly computed: boolean;
20
20
  readonly options: readonly string[] | null;
21
21
  readonly bounds: { readonly min?: number; readonly max?: number } | null;
22
+ // Serializable write-time validator for type="text" keys (hex/https/length).
23
+ // Carried into the manifest as ConfigKeyDefinition.pattern's JSDoc promises
24
+ // (feature-manifest, docgen) — was previously dropped by this serializer.
25
+ readonly pattern: { readonly regex: string; readonly flags?: string } | null;
22
26
  readonly writeRoles: readonly string[];
23
27
  readonly readRoles: readonly string[];
24
28
  };
@@ -96,6 +100,7 @@ export function buildManifestFromRegistry(
96
100
  computed: def.computed !== undefined,
97
101
  options: def.options ?? null,
98
102
  bounds: def.bounds ?? null,
103
+ pattern: def.pattern ?? null,
99
104
  writeRoles: def.access.write,
100
105
  readRoles: def.access.read,
101
106
  });
@@ -233,6 +233,7 @@ export type {
233
233
  ConfigMask,
234
234
  ConfigResolver,
235
235
  ConfigScope,
236
+ ConfigSecretsReader,
236
237
  ConfigSeedDef,
237
238
  ConfigStoredRow,
238
239
  ConfigStoredRowWithSource,
@@ -73,6 +73,16 @@ export type ConfigComputedFn<T extends ConfigKeyType = ConfigKeyType> = (
73
73
  // Tenant-Override. Die backing×scope-Matrix erzwingt der boot-validator.
74
74
  export type ConfigBacking = "config" | "secrets";
75
75
 
76
+ // Minimal read surface the config resolver needs to dispatch a
77
+ // backing="secrets" key to the secrets store, without coupling the engine
78
+ // types to the full SecretsContext. The app's `ctx.secrets` (a SecretsContext)
79
+ // is structurally assignable. Threaded per-call (not at resolver construction)
80
+ // because the resolver is framework-auto-created while `ctx.secrets` is
81
+ // app-provided — only the request context sees both.
82
+ export type ConfigSecretsReader = {
83
+ get(tenantId: TenantId, key: string): Promise<{ readonly reveal: () => string } | undefined>;
84
+ };
85
+
76
86
  export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
77
87
  readonly type: T;
78
88
  readonly default?: ConfigValue<T>;
@@ -81,6 +91,15 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
81
91
  readonly encrypted?: boolean;
82
92
  readonly options?: readonly string[];
83
93
  readonly bounds?: ConfigBounds;
94
+ // Per-key string-pattern validation for type="text". The value must match
95
+ // the regex at write time — set.write hard-rejects a mismatch with
96
+ // ValidationError("invalid_format"), same posture as bounds. Stored as a
97
+ // serializable {regex, flags} pair (not a RegExp/predicate) so it survives
98
+ // JSON like `bounds`/`options` (feature-manifest, docgen) and compiles per
99
+ // write via new RegExp. Keep patterns anchored + length-bounded: the value
100
+ // is tenant-supplied (untrusted), an unbounded catastrophic-backtracking
101
+ // regex applied to it would be a ReDoS vector.
102
+ readonly pattern?: { readonly regex: string; readonly flags?: string };
84
103
  readonly computed?: ConfigComputedFn<T>;
85
104
  // Per-Request opt-in. Default false — resolveConfigOrParam wirft für
86
105
  // Keys ohne diese Marke, auch wenn der Caller paramValue übergibt. Das
@@ -145,6 +164,10 @@ export type ConfigAccessor = {
145
164
  export type ConfigAccessorFactory = (deps: {
146
165
  readonly user: { readonly id: string; readonly tenantId: TenantId };
147
166
  readonly db: DbConnection | TenantDb;
167
+ // Present when the app wired `extraContext.secrets`. Lets the internal
168
+ // `ctx.config.get` read a backing="secrets" key transparently from the
169
+ // secrets store; absent → a backing="secrets" read throws loud.
170
+ readonly secrets?: ConfigSecretsReader;
148
171
  }) => ConfigAccessor;
149
172
 
150
173
  // Row shape returned by ConfigResolver.getAll — just enough for the
@@ -218,6 +241,7 @@ export type ConfigResolver = {
218
241
  tenantId: TenantId,
219
242
  userId: string,
220
243
  db: DbConnection | TenantDb,
244
+ secretsReader?: ConfigSecretsReader,
221
245
  ): Promise<string | number | boolean | undefined>;
222
246
 
223
247
  // Same cascade as get() but also reports which layer produced the value.
@@ -231,6 +255,7 @@ export type ConfigResolver = {
231
255
  tenantId: TenantId,
232
256
  userId: string,
233
257
  db: DbConnection | TenantDb,
258
+ secretsReader?: ConfigSecretsReader,
234
259
  ): Promise<ConfigValueWithSource>;
235
260
 
236
261
  getAll(
@@ -259,17 +284,21 @@ export type ConfigResolver = {
259
284
  tenantId: TenantId,
260
285
  userId: string,
261
286
  db: DbConnection | TenantDb,
287
+ secretsReader?: ConfigSecretsReader,
262
288
  ): Promise<ConfigCascade>;
263
289
 
264
290
  // Batch variant: resolves cascades for N keys in one DB round-trip.
265
291
  // keyDefs must contain definitions for every key in the keys array.
266
- // Returns a map of qualifiedKey → ConfigCascade.
292
+ // Returns a map of qualifiedKey → ConfigCascade. backing="secrets" keys
293
+ // resolve their system rung from the secrets store via secretsReader
294
+ // (one read each — they are system-only and rare).
267
295
  getCascadeBatch(
268
296
  keys: readonly string[],
269
297
  keyDefs: ReadonlyMap<string, ConfigKeyDefinition>,
270
298
  tenantId: TenantId,
271
299
  userId: string,
272
300
  db: DbConnection | TenantDb,
301
+ secretsReader?: ConfigSecretsReader,
273
302
  ): Promise<ReadonlyMap<string, ConfigCascade>>;
274
303
  };
275
304
 
@@ -25,6 +25,7 @@ export type {
25
25
  ConfigKeyType,
26
26
  ConfigMask,
27
27
  ConfigResolver,
28
+ ConfigSecretsReader,
28
29
  ConfigSeedDef,
29
30
  ConfigStoredRow,
30
31
  ConfigStoredRowWithSource,
@@ -299,6 +299,10 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
299
299
  const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
300
300
  const jobContext: AppContext = {
301
301
  ...context,
302
+ // The runner owns the registry it resolved this job from — expose it so
303
+ // workers can reach projections/jobs without the app author duplicating
304
+ // it into `context` (the JobContext contract guarantees `registry`).
305
+ registry,
302
306
  systemUser: createSystemUser(tenantId),
303
307
  triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
304
308
  log: createJobLogger(logs),
@@ -30,6 +30,7 @@ import {
30
30
  unsafePushTables,
31
31
  } from "../../stack";
32
32
  import {
33
+ enqueueProjectionRebuild,
33
34
  listPendingRebuilds,
34
35
  queueRebuildsFromMarkers,
35
36
  runPendingRebuilds,
@@ -226,3 +227,24 @@ describe("pending-rebuilds queue", () => {
226
227
  expect(await getCount()).toBe(1);
227
228
  });
228
229
  });
230
+
231
+ // #362: der framework-Helper. Ohne jobs-Feature (kein jobRunner) rebuildet er
232
+ // synchron inline — das heutige Verhalten, garantiert framework-pur. Der
233
+ // dispatch-Pfad (mit jobs) lebt in jobs/__tests__/projection-rebuild-job.*.
234
+ describe("enqueueProjectionRebuild — inline fallback (no jobs feature)", () => {
235
+ test("without a jobRunner, rebuilds the projection synchronously", async () => {
236
+ await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
237
+ await executor.create({ groupId: GROUP, name: "b" }, admin, tdb);
238
+
239
+ const outcome = await enqueueProjectionRebuild("pendingtest:projection:pending-counts", {
240
+ db: testDb.db,
241
+ registry,
242
+ });
243
+
244
+ expect(outcome.mode).toBe("inline");
245
+ if (outcome.mode === "inline") {
246
+ expect(outcome.result.eventsProcessed).toBe(2);
247
+ }
248
+ expect(await getCount()).toBe(2);
249
+ });
250
+ });
@@ -10,8 +10,12 @@ export {
10
10
  // Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
11
11
  export {
12
12
  createPendingRebuildsTable,
13
+ type EnqueueProjectionRebuildDeps,
14
+ type EnqueueProjectionRebuildResult,
15
+ enqueueProjectionRebuild,
13
16
  listPendingRebuilds,
14
17
  type PendingRebuildRun,
18
+ PROJECTION_REBUILD_JOB,
15
19
  pendingRebuildsTable,
16
20
  queueRebuildsFromMarkers,
17
21
  type RunPendingRebuildsOptions,
@@ -17,8 +17,9 @@ import { deleteMany, selectMany, upsertOnConflict } from "../db/query";
17
17
  import { readRebuildMarker } from "../db/rebuild-marker";
18
18
  import { tableExists } from "../db/schema-inspection";
19
19
  import type { Registry } from "../engine/types";
20
+ import type { JobRunner } from "../jobs";
20
21
  import { createFallbackLogger } from "../logging/utils";
21
- import { rebuildProjection } from "../pipeline";
22
+ import { type RebuildResult, rebuildProjection } from "../pipeline";
22
23
  import { unsafePushTables } from "../stack";
23
24
  import { buildProjectionTableIndex } from "./projection-table-index";
24
25
 
@@ -163,3 +164,37 @@ export async function runPendingRebuilds(
163
164
  }
164
165
  return { rebuilt, failed, unmapped, unresolvedManaged };
165
166
  }
167
+
168
+ // Qualified name of the framework-provided projection-rebuild job. Registered
169
+ // by the `jobs` bundled-feature (createJobsFeature) → available whenever jobs
170
+ // is composed. A jobs-less boot has no jobRunner and rebuilds inline instead.
171
+ export const PROJECTION_REBUILD_JOB = "jobs:job:projection-rebuild";
172
+
173
+ export type EnqueueProjectionRebuildResult =
174
+ | { readonly mode: "dispatched"; readonly bullJobId: string }
175
+ | { readonly mode: "inline"; readonly result: RebuildResult };
176
+
177
+ export type EnqueueProjectionRebuildDeps = {
178
+ readonly db: DbConnection;
179
+ readonly registry: Registry;
180
+ // Present + projection-rebuild job registered (jobs composed) → tracked,
181
+ // retryable job (read_job_runs + read_job_run_logs). Absent → inline rebuild.
182
+ readonly jobRunner?: JobRunner;
183
+ };
184
+
185
+ /** Triggert einen Single-Projection-Rebuild. Mit `jobs`-Feature (jobRunner +
186
+ * registriertem Job) als getrackter, retrybarer Job; ohne jobs synchron inline
187
+ * (heutiges Verhalten). Capability-Detektion über `registry.getJob`, NICHT
188
+ * `hasFeature` — deterministisch und ohne Toggle-Runtime-Abhängigkeit. */
189
+ export async function enqueueProjectionRebuild(
190
+ projection: string,
191
+ deps: EnqueueProjectionRebuildDeps,
192
+ ): Promise<EnqueueProjectionRebuildResult> {
193
+ const { db, registry, jobRunner } = deps;
194
+ if (jobRunner !== undefined && registry.getJob(PROJECTION_REBUILD_JOB) !== undefined) {
195
+ const bullJobId = await jobRunner.dispatch(PROJECTION_REBUILD_JOB, { projection });
196
+ return { mode: "dispatched", bullJobId };
197
+ }
198
+ const result = await rebuildProjection(projection, { db, registry });
199
+ return { mode: "inline", result };
200
+ }
@@ -278,7 +278,11 @@ export function createDispatcher(
278
278
  // Mirror notify: only built when the config feature wired its factory.
279
279
  const config =
280
280
  context._configAccessorFactory && db
281
- ? context._configAccessorFactory({ user: { id: user.id, tenantId: user.tenantId }, db })
281
+ ? context._configAccessorFactory({
282
+ user: { id: user.id, tenantId: user.tenantId },
283
+ db,
284
+ secrets: context.secrets,
285
+ })
282
286
  : undefined;
283
287
 
284
288
  // Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
@@ -1382,7 +1386,11 @@ export function createDispatcher(
1382
1386
  }
1383
1387
  const db = createTenantDb(dbSource, user.tenantId, "tenant", context.tracer, context.meter);
1384
1388
  const configAccessor = context._configAccessorFactory
1385
- ? context._configAccessorFactory({ user: { id: user.id, tenantId: user.tenantId }, db })
1389
+ ? context._configAccessorFactory({
1390
+ user: { id: user.id, tenantId: user.tenantId },
1391
+ db,
1392
+ secrets: context.secrets,
1393
+ })
1386
1394
  : undefined;
1387
1395
  return {
1388
1396
  db,