@cosmicdrift/kumiko-framework 0.27.0 → 0.28.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.27.0",
3
+ "version": "0.28.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>",
package/src/api/server.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  wrapRedisClient,
24
24
  } from "../observability";
25
25
  import type { DispatcherOptions } from "../pipeline/dispatcher";
26
- import { createDispatcher } from "../pipeline/dispatcher";
26
+ import { createDispatcher, type Dispatcher } from "../pipeline/dispatcher";
27
27
  import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state";
28
28
  import type { EventDedup } from "../pipeline/event-dedup";
29
29
  import type { EventConsumer, EventDispatcher } from "../pipeline/event-dispatcher";
@@ -195,6 +195,11 @@ export type KumikoServer = {
195
195
  jwt: JwtHelper;
196
196
  sseBroker: SseBroker;
197
197
  observability: ObservabilityProvider;
198
+ // The command-dispatcher behind /api/* — same idempotency/jobRunner/
199
+ // lifecycle wiring as HTTP-dispatched writes. For dispatching outside
200
+ // the HTTP pipeline, e.g. provider-webhook routes that authenticate
201
+ // via signature instead of JWT (subscription-stripe et al.).
202
+ dispatcher: Dispatcher;
198
203
  // Present when at least one consumer is wired and context.db is a
199
204
  // DbConnection. Caller owns the lifecycle: `.start()` in boot, `.stop()`
200
205
  // in shutdown. Tests drain via `.runOnce()` instead.
@@ -619,6 +624,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
619
624
  jwt,
620
625
  sseBroker,
621
626
  observability,
627
+ dispatcher,
622
628
  ...(eventDispatcher ? { eventDispatcher } : {}),
623
629
  ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
624
630
  };
@@ -23,6 +23,35 @@ describe("defineFeature", () => {
23
23
  expect(feature.name).toBe("test");
24
24
  });
25
25
 
26
+ test("r.describe() flows into the definition, trimmed", () => {
27
+ const feature = defineFeature("test", (r) => {
28
+ r.describe(" Stores per-tenant widgets. ");
29
+ });
30
+ expect(feature.description).toBe("Stores per-tenant widgets.");
31
+ });
32
+
33
+ test("description is absent when r.describe() is not called", () => {
34
+ const feature = defineFeature("test", () => {});
35
+ expect("description" in feature).toBe(false);
36
+ });
37
+
38
+ test("r.describe() throws when called twice", () => {
39
+ expect(() =>
40
+ defineFeature("test", (r) => {
41
+ r.describe("first");
42
+ r.describe("second");
43
+ }),
44
+ ).toThrow(/r\.describe\(\) called twice/);
45
+ });
46
+
47
+ test("r.describe() throws on empty or whitespace-only text", () => {
48
+ expect(() =>
49
+ defineFeature("test", (r) => {
50
+ r.describe(" ");
51
+ }),
52
+ ).toThrow(/non-empty string/);
53
+ });
54
+
26
55
  test("collects entities", () => {
27
56
  const feature = defineFeature("test", (r) => {
28
57
  r.entity(
@@ -153,6 +153,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
153
153
 
154
154
  let isSystemScoped = false;
155
155
  let toggleableDefault: boolean | undefined;
156
+ let description: string | undefined;
156
157
  // Visual-Tree-Slots — at-most-one per feature, only-once-guard im
157
158
  // registrar (siehe r.treeActions / r.tree). Undefined wenn das Feature
158
159
  // keinen Visual-Tree-Beitrag liefert (Zero-Whitelist-Filter).
@@ -182,6 +183,18 @@ export function defineFeature<const TName extends string, TExports = undefined>(
182
183
  isSystemScoped = true;
183
184
  },
184
185
 
186
+ describe(text: string): void {
187
+ if (description !== undefined) {
188
+ throw new Error(
189
+ `[Feature ${name}] r.describe() called twice — a feature's description is declared once`,
190
+ );
191
+ }
192
+ if (typeof text !== "string" || text.trim().length === 0) {
193
+ throw new Error(`[Feature ${name}] r.describe(): text must be a non-empty string`);
194
+ }
195
+ description = text.trim();
196
+ },
197
+
185
198
  requires: (() => {
186
199
  const fn = (...featureNames: string[]) => {
187
200
  requires.push(...featureNames);
@@ -888,6 +901,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
888
901
 
889
902
  return {
890
903
  name,
904
+ ...(description !== undefined && { description }),
891
905
  systemScope: isSystemScoped,
892
906
  exports,
893
907
  requires,
@@ -1,4 +1,5 @@
1
1
  export {
2
+ extractDescribe,
2
3
  extractOptionalRequires,
3
4
  extractReadsConfig,
4
5
  extractRequires,
@@ -1,5 +1,6 @@
1
1
  import type { CallExpression, SourceFile } from "ts-morph";
2
2
  import type {
3
+ DescribePattern,
3
4
  OptionalRequiresPattern,
4
5
  ReadsConfigPattern,
5
6
  RequiresPattern,
@@ -12,6 +13,7 @@ import {
12
13
  fail,
13
14
  ok,
14
15
  readBooleanProperty,
16
+ readStringLiteralArgs,
15
17
  readVarargsOrArrayProp,
16
18
  } from "./shared";
17
19
 
@@ -82,6 +84,26 @@ export function extractSystemScope(
82
84
  });
83
85
  }
84
86
 
87
+ export function extractDescribe(
88
+ call: CallExpression,
89
+ sourceFile: SourceFile,
90
+ ): ExtractOutput<DescribePattern> {
91
+ const args = readStringLiteralArgs(call);
92
+ const text = args?.[0];
93
+ if (text === undefined || args?.length !== 1) {
94
+ return fail(
95
+ "describe",
96
+ sourceLocationFromNode(call, sourceFile),
97
+ "expected a single string literal",
98
+ );
99
+ }
100
+ return ok({
101
+ kind: "describe",
102
+ source: sourceLocationFromNode(call, sourceFile),
103
+ text,
104
+ });
105
+ }
106
+
85
107
  export function extractToggleable(
86
108
  call: CallExpression,
87
109
  sourceFile: SourceFile,
@@ -30,6 +30,7 @@ import {
30
30
  extractClaimKey,
31
31
  extractConfig,
32
32
  extractDefineEvent,
33
+ extractDescribe,
33
34
  extractEntity,
34
35
  extractEntityHook,
35
36
  extractEnvSchema,
@@ -297,6 +298,8 @@ function dispatchExtractor(
297
298
  return extractSystemScope(call, sourceFile);
298
299
  case "toggleable":
299
300
  return extractToggleable(call, sourceFile);
301
+ case "describe":
302
+ return extractDescribe(call, sourceFile);
300
303
  // Round 2 — object-literal-based static patterns
301
304
  case "entity":
302
305
  return extractEntity(call, sourceFile);
@@ -79,6 +79,7 @@ export type PatternId =
79
79
  | { readonly kind: "readsConfig" }
80
80
  | { readonly kind: "systemScope" }
81
81
  | { readonly kind: "toggleable" }
82
+ | { readonly kind: "describe" }
82
83
  | { readonly kind: "config" }
83
84
  | { readonly kind: "translations" }
84
85
  | { readonly kind: "authClaims" }
@@ -270,6 +271,7 @@ export const SINGLETON_KINDS: ReadonlySet<PatternId["kind"]> = new Set([
270
271
  "readsConfig",
271
272
  "systemScope",
272
273
  "toggleable",
274
+ "describe",
273
275
  "config",
274
276
  "translations",
275
277
  "authClaims",
@@ -326,6 +328,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
326
328
  case "readsConfig":
327
329
  case "systemScope":
328
330
  case "toggleable":
331
+ case "describe":
329
332
  case "config":
330
333
  case "translations":
331
334
  case "authClaims":
@@ -197,6 +197,7 @@ export type AddRequiresArgs = { readonly features: readonly string[] };
197
197
  export type AddOptionalRequiresArgs = { readonly features: readonly string[] };
198
198
  export type AddReadsConfigArgs = { readonly keys: readonly string[] };
199
199
  export type AddToggleableArgs = { readonly default: boolean };
200
+ export type AddDescribeArgs = { readonly text: string };
200
201
  export type AddNavArgs = { readonly definition: NavDefinition };
201
202
  export type AddWorkspaceArgs = { readonly definition: WorkspaceDefinition };
202
203
  export type AddConfigArgs = {
@@ -223,6 +224,7 @@ export type FeaturePatcher = {
223
224
  readonly addReadsConfig: (args: AddReadsConfigArgs) => void;
224
225
  readonly addSystemScope: () => void;
225
226
  readonly addToggleable: (args: AddToggleableArgs) => void;
227
+ readonly addDescribe: (args: AddDescribeArgs) => void;
226
228
  readonly addEntity: (args: AddEntityArgs) => void;
227
229
  readonly addRelation: (args: AddRelationArgs) => void;
228
230
  readonly addNav: (args: AddNavArgs) => void;
@@ -300,6 +302,9 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
300
302
  addToggleable({ default: defaultOn }) {
301
303
  add({ kind: "toggleable", source: SYNTHETIC_LOC, default: defaultOn });
302
304
  },
305
+ addDescribe({ text }) {
306
+ add({ kind: "describe", source: SYNTHETIC_LOC, text });
307
+ },
303
308
  addEntity({ name, definition }) {
304
309
  add({
305
310
  kind: "entity",
@@ -138,6 +138,12 @@ export type ToggleablePattern = {
138
138
  readonly default: boolean;
139
139
  };
140
140
 
141
+ export type DescribePattern = {
142
+ readonly kind: "describe";
143
+ readonly source: SourceLocation;
144
+ readonly text: string;
145
+ };
146
+
141
147
  export type MetricPattern = {
142
148
  readonly kind: "metric";
143
149
  readonly source: SourceLocation;
@@ -419,6 +425,7 @@ export type FeaturePattern =
419
425
  | OptionalRequiresPattern
420
426
  | SystemScopePattern
421
427
  | ToggleablePattern
428
+ | DescribePattern
422
429
  | MetricPattern
423
430
  | SecretPattern
424
431
  | ClaimKeyPattern
@@ -476,6 +483,7 @@ export function getEditability(pattern: FeaturePattern): Editability {
476
483
  case "optionalRequires":
477
484
  case "systemScope":
478
485
  case "toggleable":
486
+ case "describe":
479
487
  case "metric":
480
488
  case "secret":
481
489
  case "claimKey":
@@ -21,6 +21,7 @@ import type {
21
21
  ClaimKeyPattern,
22
22
  ConfigPattern,
23
23
  DefineEventPattern,
24
+ DescribePattern,
24
25
  EntityHookPattern,
25
26
  EntityPattern,
26
27
  EnvSchemaPattern,
@@ -77,6 +78,8 @@ export function renderPattern(pattern: FeaturePattern): string {
77
78
  return renderSystemScope(pattern);
78
79
  case "toggleable":
79
80
  return renderToggleable(pattern);
81
+ case "describe":
82
+ return renderDescribe(pattern);
80
83
  case "entity":
81
84
  return renderEntity(pattern);
82
85
  case "relation":
@@ -232,6 +235,10 @@ function renderToggleable(p: ToggleablePattern): string {
232
235
  return `r.toggleable({ default: ${p.default} });`;
233
236
  }
234
237
 
238
+ function renderDescribe(p: DescribePattern): string {
239
+ return `r.describe(${JSON.stringify(p.text)});`;
240
+ }
241
+
235
242
  function renderEntity(p: EntityPattern): string {
236
243
  // Inline `name` into the definition object — canonical Object-Form
237
244
  // is a single arg with name-as-property.
@@ -30,6 +30,7 @@ const ALL_KINDS: FeaturePatternKind[] = [
30
30
  "readsConfig",
31
31
  "systemScope",
32
32
  "toggleable",
33
+ "describe",
33
34
  "entity",
34
35
  "relation",
35
36
  "nav",
@@ -197,6 +198,8 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
197
198
  return { kind, source: PLACEHOLDER_LOC };
198
199
  case "toggleable":
199
200
  return { kind, source: PLACEHOLDER_LOC, default: false };
201
+ case "describe":
202
+ return { kind, source: PLACEHOLDER_LOC, text: "x" };
200
203
  case "entity":
201
204
  return { kind, source: PLACEHOLDER_LOC, entityName: "x", definition: { fields: {} } };
202
205
  case "relation":
@@ -176,6 +176,24 @@ const toggleableSchema: PatternFormSchema = {
176
176
  ],
177
177
  };
178
178
 
179
+ const describeSchema: PatternFormSchema = {
180
+ kind: "describe",
181
+ label: { en: "Description", de: "Beschreibung" },
182
+ summary: { en: "One-to-three-sentence docs-lead: what the feature does + when you need it." },
183
+ category: "meta",
184
+ editability: "static",
185
+ singleton: true,
186
+ fields: [
187
+ {
188
+ path: "text",
189
+ label: { en: "Text", de: "Text" },
190
+ input: "textarea",
191
+ required: true,
192
+ placeholder: "Stores per-tenant widgets and exposes CRUD handlers for them.",
193
+ },
194
+ ],
195
+ };
196
+
179
197
  const entitySchema: PatternFormSchema = {
180
198
  kind: "entity",
181
199
  label: { en: "Entity", de: "Entität" },
@@ -1142,6 +1160,7 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
1142
1160
  readsConfig: readsConfigSchema,
1143
1161
  systemScope: systemScopeSchema,
1144
1162
  toggleable: toggleableSchema,
1163
+ describe: describeSchema,
1145
1164
  entity: entitySchema,
1146
1165
  relation: relationSchema,
1147
1166
  nav: navSchema,
@@ -7,11 +7,19 @@ import type { TenantId } from "./types/identifiers";
7
7
  export const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
8
8
  export const SYSTEM_ROLE = "system" as const;
9
9
 
10
- export function createSystemUser(tenantId: TenantId): SessionUser {
10
+ // extraRoles: hasAccess kennt keinen System-Bypass — Handler gaten auf
11
+ // explizite Rollen. Caller, die Handler mit z.B. SystemAdmin-Gate erreichen
12
+ // müssen (extraRoutes.dispatchSystemWrite → billing-foundation
13
+ // process-event), geben die Rolle hier zusätzlich mit; createdBy bleibt
14
+ // SYSTEM_USER_ID, der Audit-Trail zeigt weiterhin System.
15
+ export function createSystemUser(
16
+ tenantId: TenantId,
17
+ extraRoles: readonly string[] = [],
18
+ ): SessionUser {
11
19
  return {
12
20
  id: SYSTEM_USER_ID,
13
21
  tenantId,
14
- roles: [SYSTEM_ROLE],
22
+ roles: [SYSTEM_ROLE, ...extraRoles],
15
23
  };
16
24
  }
17
25
 
@@ -170,6 +170,9 @@ export type UnmanagedTableDef = UnmanagedTableEntry & {
170
170
 
171
171
  export type FeatureDefinition = {
172
172
  readonly name: string;
173
+ // Docs-lead paragraph declared via r.describe(). Flows through the
174
+ // manifest introspection into the generated feature-reference pages.
175
+ readonly description?: string;
173
176
  readonly systemScope: boolean;
174
177
  // Set from the setup-callback return — typed via `defineFeature<TExports>`.
175
178
  // `undefined` for setups that return nothing.
@@ -335,6 +338,9 @@ export type RequiresApi = ((...featureNames: string[]) => void) & {
335
338
 
336
339
  export type FeatureRegistrar<TFeature extends string = string> = {
337
340
  systemScope(): void;
341
+ // One-to-three-sentence docs-lead for the feature ("what it does + when
342
+ // you need it"). At most once per feature; must be non-empty.
343
+ describe(text: string): void;
338
344
  requires: RequiresApi;
339
345
  optionalRequires(...featureNames: string[]): void;
340
346
  // Declare the feature as operator-togglable. `default` is the effective
@@ -45,7 +45,7 @@ import type { Lifecycle } from "../lifecycle";
45
45
  import { createLifecycle } from "../lifecycle";
46
46
  import type { ObservabilityOptions, ObservabilityProvider } from "../observability";
47
47
  import type { EventDedup, EventDispatcher } from "../pipeline";
48
- import type { DispatcherOptions } from "../pipeline/dispatcher";
48
+ import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher";
49
49
  import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
50
50
 
51
51
  // Shared fields across all three modes. A caller that swaps between
@@ -114,9 +114,12 @@ export type ApiEntrypoint = {
114
114
  readonly sseBroker: SseBroker;
115
115
  readonly lifecycle: Lifecycle;
116
116
  readonly observability: ObservabilityProvider;
117
+ // Command-dispatcher behind /api/* — for writes outside the HTTP
118
+ // pipeline (provider-webhook routes, see KumikoServer.dispatcher).
119
+ readonly dispatcher: Dispatcher;
117
120
  readonly mode: "api";
118
- // No-op on API mode — dispatcher isn't built, job-runner doesn't exist.
119
- // Kept for a uniform call-site so `main.ts` doesn't branch on mode.
121
+ // No-op on API mode — event-dispatcher isn't built, job-runner doesn't
122
+ // exist. Kept for a uniform call-site so `main.ts` doesn't branch on mode.
120
123
  start(): Promise<void>;
121
124
  stop(): Promise<void>;
122
125
  };
@@ -329,6 +332,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
329
332
  sseBroker: server.sseBroker,
330
333
  lifecycle,
331
334
  observability: server.observability,
335
+ dispatcher: server.dispatcher,
332
336
  mode: "api",
333
337
  async start() {
334
338
  // Start the local BullMQ worker when runLocalJobs=true; enqueuer-only
@@ -424,6 +428,7 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
424
428
  eventDispatcher,
425
429
  jobRunner: workerJobRunner,
426
430
  observability: server.observability,
431
+ dispatcher: server.dispatcher,
427
432
  mode: "all-in-one",
428
433
  async start() {
429
434
  await eventDispatcher.start();
@@ -16,6 +16,9 @@ export { fileRefEntity } from "./file-ref-entity";
16
16
  // file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
17
17
  export function createFilesFeature(): FeatureDefinition {
18
18
  return defineFeature("files", (r) => {
19
+ r.describe(
20
+ "Exposes the `fileRef` entity and `createFilesFeature` from the framework core so that uploaded files \u2014 tracked in the `file_refs` table by `createFileRoutes` \u2014 participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and future tenant-lifecycle cleanup will delete all refs on tenant destroy. This feature does not add upload or download routes; those remain in the server bootstrap via the `options.files` parameter.",
21
+ );
19
22
  r.entity("fileRef", fileRefEntity);
20
23
  });
21
24
  }
@@ -10,7 +10,7 @@ import type { FeatureDefinition, Registry, TenantId } from "../engine/types";
10
10
  import { createArchivedStreamsTable, createEventsTable } from "../event-store";
11
11
  import type { Lifecycle } from "../lifecycle";
12
12
  import type { ObservabilityProvider } from "../observability";
13
- import type { EventDispatcher } from "../pipeline";
13
+ import type { Dispatcher, EventDispatcher } from "../pipeline";
14
14
  import { createEntityCache, createEventDedup, createIdempotencyGuard } from "../pipeline";
15
15
  import { createInMemorySearchAdapter } from "../search";
16
16
  import type { SearchAdapter } from "../search/types";
@@ -31,6 +31,9 @@ export type TestStack = {
31
31
  events: EventCollector;
32
32
  http: RequestHelper;
33
33
  observability: ObservabilityProvider;
34
+ // Command-dispatcher behind the HTTP routes — for direct system-writes
35
+ // in tests and dev-server extraRoutes (provider-webhook wiring).
36
+ dispatcher: Dispatcher;
34
37
  // Present whenever a system consumer (SSE, Search) or
35
38
  // r.multiStreamProjection is wired. Tests drain it via runOnce() for
36
39
  // deterministic assertion — no timer-induced flakiness.
@@ -355,6 +358,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
355
358
  events,
356
359
  http,
357
360
  observability: server.observability,
361
+ dispatcher: server.dispatcher,
358
362
  ...(eventDispatcher ? { eventDispatcher } : {}),
359
363
  ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
360
364
  cleanup: async () => {