@cosmicdrift/kumiko-framework 0.2.3 → 0.4.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.
Files changed (166) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/package.json +124 -39
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/compliance/profiles.ts +8 -8
  10. package/src/db/assert-exists-in.ts +2 -2
  11. package/src/db/cursor.ts +3 -3
  12. package/src/db/event-store-executor.ts +19 -13
  13. package/src/db/located-timestamp.ts +1 -1
  14. package/src/db/money.ts +12 -2
  15. package/src/db/pg-error.ts +1 -1
  16. package/src/db/row-helpers.ts +1 -1
  17. package/src/db/table-builder.ts +3 -5
  18. package/src/db/tenant-db.ts +9 -9
  19. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  20. package/src/engine/__tests__/build-target.test.ts +135 -0
  21. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  22. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  23. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  24. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  25. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  26. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  27. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  28. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  29. package/src/engine/__tests__/raw-table.test.ts +2 -2
  30. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  31. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  32. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  33. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  34. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  35. package/src/engine/__tests__/steps-read.test.ts +142 -0
  36. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  37. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  38. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  39. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  40. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  41. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  42. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  43. package/src/engine/boot-validator/api-ext.ts +77 -0
  44. package/src/engine/boot-validator/config-deps.ts +163 -0
  45. package/src/engine/boot-validator/entity-handler.ts +466 -0
  46. package/src/engine/boot-validator/index.ts +159 -0
  47. package/src/engine/boot-validator/ownership.ts +198 -0
  48. package/src/engine/boot-validator/pii-retention.ts +155 -0
  49. package/src/engine/boot-validator/screens-nav.ts +624 -0
  50. package/src/engine/boot-validator.ts +1 -1804
  51. package/src/engine/build-app-schema.ts +1 -1
  52. package/src/engine/build-target.ts +99 -0
  53. package/src/engine/codemod/index.ts +15 -0
  54. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  55. package/src/engine/config-helpers.ts +9 -19
  56. package/src/engine/constants.ts +1 -1
  57. package/src/engine/define-feature.ts +88 -9
  58. package/src/engine/define-handler.ts +89 -3
  59. package/src/engine/define-roles.ts +2 -2
  60. package/src/engine/define-step.ts +28 -0
  61. package/src/engine/define-workflow.ts +110 -0
  62. package/src/engine/entity-handlers.ts +10 -9
  63. package/src/engine/event-helpers.ts +4 -4
  64. package/src/engine/factories.ts +12 -12
  65. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  66. package/src/engine/feature-ast/extractors/index.ts +74 -0
  67. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  68. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  69. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  70. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  71. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  72. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  73. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  74. package/src/engine/feature-ast/parse.ts +7 -0
  75. package/src/engine/feature-ast/patch.ts +9 -1
  76. package/src/engine/feature-ast/patcher.ts +10 -3
  77. package/src/engine/feature-ast/patterns.ts +49 -1
  78. package/src/engine/feature-ast/render.ts +17 -1
  79. package/src/engine/index.ts +44 -2
  80. package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
  81. package/src/engine/pattern-library/library.ts +42 -2
  82. package/src/engine/pipeline.ts +88 -0
  83. package/src/engine/projection-helpers.ts +1 -1
  84. package/src/engine/read-claim.ts +1 -1
  85. package/src/engine/registry.ts +30 -2
  86. package/src/engine/resolve-config-or-param.ts +4 -0
  87. package/src/engine/run-pipeline.ts +162 -0
  88. package/src/engine/schema-builder.ts +2 -4
  89. package/src/engine/state-machine.ts +1 -1
  90. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  91. package/src/engine/steps/_duration-utils.ts +33 -0
  92. package/src/engine/steps/_no-return-guard.ts +21 -0
  93. package/src/engine/steps/_resolver-utils.ts +42 -0
  94. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  95. package/src/engine/steps/aggregate-append-event.ts +56 -0
  96. package/src/engine/steps/aggregate-create.ts +56 -0
  97. package/src/engine/steps/aggregate-update.ts +68 -0
  98. package/src/engine/steps/branch.ts +84 -0
  99. package/src/engine/steps/call-feature.ts +49 -0
  100. package/src/engine/steps/compute.ts +41 -0
  101. package/src/engine/steps/for-each.ts +111 -0
  102. package/src/engine/steps/mail-send.ts +44 -0
  103. package/src/engine/steps/read-find-many.ts +51 -0
  104. package/src/engine/steps/read-find-one.ts +58 -0
  105. package/src/engine/steps/retry.ts +87 -0
  106. package/src/engine/steps/return.ts +34 -0
  107. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  108. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  109. package/src/engine/steps/wait-for-event.ts +71 -0
  110. package/src/engine/steps/wait.ts +69 -0
  111. package/src/engine/steps/webhook-send.ts +71 -0
  112. package/src/engine/system-user.ts +1 -1
  113. package/src/engine/types/feature.ts +93 -1
  114. package/src/engine/types/handlers.ts +18 -10
  115. package/src/engine/types/index.ts +11 -1
  116. package/src/engine/types/step.ts +334 -0
  117. package/src/engine/types/target-ref.ts +21 -0
  118. package/src/engine/types/tree-node.ts +132 -0
  119. package/src/engine/types/workspace.ts +7 -0
  120. package/src/engine/validate-projection-allowlist.ts +161 -0
  121. package/src/event-store/snapshot.ts +1 -1
  122. package/src/event-store/upcaster-dead-letter.ts +1 -1
  123. package/src/event-store/upcaster.ts +1 -1
  124. package/src/files/file-routes.ts +1 -1
  125. package/src/files/types.ts +2 -2
  126. package/src/jobs/job-runner.ts +10 -10
  127. package/src/lifecycle/lifecycle.ts +0 -3
  128. package/src/logging/index.ts +1 -0
  129. package/src/logging/pino-logger.ts +11 -7
  130. package/src/logging/utils.ts +24 -0
  131. package/src/observability/prometheus-meter.ts +7 -5
  132. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  133. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  134. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  135. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  136. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  137. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  138. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  139. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  140. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  141. package/src/pipeline/append-event-core.ts +22 -6
  142. package/src/pipeline/dispatcher-utils.ts +188 -0
  143. package/src/pipeline/dispatcher.ts +63 -283
  144. package/src/pipeline/distributed-lock.ts +1 -1
  145. package/src/pipeline/entity-cache.ts +2 -2
  146. package/src/pipeline/event-consumer-state.ts +0 -13
  147. package/src/pipeline/event-dispatcher.ts +4 -4
  148. package/src/pipeline/index.ts +0 -2
  149. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  150. package/src/pipeline/msp-rebuild.ts +5 -5
  151. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  152. package/src/pipeline/projection-rebuild.ts +2 -2
  153. package/src/pipeline/projection-state.ts +0 -12
  154. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  155. package/src/rate-limit/resolver.ts +1 -1
  156. package/src/search/in-memory-adapter.ts +1 -1
  157. package/src/search/meilisearch-adapter.ts +3 -3
  158. package/src/search/types.ts +1 -1
  159. package/src/secrets/leak-guard.ts +2 -2
  160. package/src/stack/request-helper.ts +9 -5
  161. package/src/stack/test-stack.ts +1 -1
  162. package/src/testing/handler-context.ts +4 -4
  163. package/src/testing/http-cookies.ts +1 -1
  164. package/src/time/tz-context.ts +1 -2
  165. package/src/ui-types/index.ts +4 -0
  166. package/src/engine/feature-ast/extractors.ts +0 -2602
@@ -1,4 +1,5 @@
1
1
  import { and, asc, desc, eq, gt, inArray, lt, ne, type SQL, sql } from "drizzle-orm";
2
+ import type { AnyPgColumn } from "drizzle-orm/pg-core";
2
3
  import { requestContext } from "../api/request-context";
3
4
  import { checkWriteFieldOwnership } from "../engine/field-access";
4
5
  import {
@@ -32,7 +33,7 @@ import {
32
33
  } from "../event-store";
33
34
  import type { EntityCache } from "../pipeline/entity-cache";
34
35
  import type { SearchAdapter } from "../search/types";
35
- import { generateId } from "../utils";
36
+ import { assertUnreachable, generateId } from "../utils";
36
37
  import { applyEntityEvent } from "./apply-entity-event";
37
38
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
38
39
  import type { DbRow } from "./connection";
@@ -54,8 +55,11 @@ type Table = TableColumns<any>;
54
55
  // vs-Type-Compat ohnehin Kontrolle was reinkommt.
55
56
  //
56
57
  // Empty-array IN ist explizit "no match" (SQL false), nicht "match all".
57
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle-Column ist generic; siehe oben.
58
- function buildFilterCondition(col: any, op: "eq" | "ne" | "lt" | "gt" | "in", value: unknown): SQL {
58
+ function buildFilterCondition(
59
+ col: AnyPgColumn,
60
+ op: "eq" | "ne" | "lt" | "gt" | "in",
61
+ value: unknown,
62
+ ): SQL {
59
63
  switch (op) {
60
64
  case "eq":
61
65
  return eq(col, value as never); // @cast-boundary db-operator
@@ -70,6 +74,8 @@ function buildFilterCondition(col: any, op: "eq" | "ne" | "lt" | "gt" | "in", va
70
74
  return inArray(col, value as never); // @cast-boundary db-operator
71
75
  }
72
76
  return sql`false`;
77
+ default:
78
+ assertUnreachable(op, "filter op");
73
79
  }
74
80
  }
75
81
 
@@ -281,7 +287,7 @@ export function createEventStoreExecutor(
281
287
  }
282
288
  // Drizzle's variadic `and()` is typed `SQL | undefined`; conditions is
283
289
  // guaranteed non-empty above (we pushed at least one).
284
- return and(...conditions) as SQL;
290
+ return and(...conditions) as SQL; // @cast-boundary db-operator
285
291
  }
286
292
 
287
293
  async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
@@ -295,7 +301,7 @@ export function createEventStoreExecutor(
295
301
  // Respect an explicit id in the payload (seed pattern, SCIM import). Without
296
302
  // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
297
303
  // event payload so defaults + downstream consumers don't see a redundant id field.
298
- const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined;
304
+ const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
299
305
  const aggregateId = explicitId ?? generateId();
300
306
  const { id: _id, ...payloadWithoutId } = payload;
301
307
  const data = applyDefaults(payloadWithoutId);
@@ -561,7 +567,7 @@ export function createEventStoreExecutor(
561
567
  isSuccess: true,
562
568
  data: {
563
569
  kind: "save",
564
- id: data["id"] as EntityId,
570
+ id: data["id"] as EntityId, // @cast-boundary engine-payload
565
571
  data,
566
572
  changes: payload.changes,
567
573
  previous,
@@ -793,7 +799,7 @@ export function createEventStoreExecutor(
793
799
  }
794
800
  }
795
801
 
796
- const whereClause = conditions.length > 0 ? (and(...conditions) as SQL) : undefined;
802
+ const whereClause = conditions.length > 0 ? (and(...conditions) as SQL) : undefined; // @cast-boundary db-operator
797
803
  let query = whereClause
798
804
  ? db.select().from(table).where(whereClause)
799
805
  : db.select().from(table);
@@ -822,13 +828,13 @@ export function createEventStoreExecutor(
822
828
  await entityCache.mset(
823
829
  user.tenantId,
824
830
  entityName,
825
- rows.map((r) => ({ id: r["id"] as EntityId, data: r })),
831
+ rows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
826
832
  );
827
833
  }
828
834
 
829
835
  const lastRow = rows[rows.length - 1];
830
836
  const nextCursor =
831
- rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null;
837
+ rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
832
838
 
833
839
  // total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
834
840
  // Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
@@ -842,7 +848,7 @@ export function createEventStoreExecutor(
842
848
  const countQuery = whereClause
843
849
  ? db.select({ count: sql<number>`count(*)::int` }).from(table).where(whereClause)
844
850
  : db.select({ count: sql<number>`count(*)::int` }).from(table);
845
- const countRow = (await countQuery) as Array<{ count: number }>;
851
+ const countRow = (await countQuery) as Array<{ count: number }>; // @cast-boundary db-row
846
852
  total = countRow[0]?.count ?? 0;
847
853
  }
848
854
  }
@@ -872,7 +878,7 @@ export function createEventStoreExecutor(
872
878
  const checked = await db
873
879
  .select()
874
880
  .from(table)
875
- .where(and(idFilter(payload.id), ownership.sql) as SQL)
881
+ .where(and(idFilter(payload.id), ownership.sql) as SQL) // @cast-boundary db-operator
876
882
  .limit(1);
877
883
  if (checked.length === 0) return null;
878
884
  }
@@ -887,11 +893,11 @@ export function createEventStoreExecutor(
887
893
  // thread the ownership clause.
888
894
  const baseFilter = idFilter(payload.id);
889
895
  const whereClause =
890
- ownership.kind === "sql" ? (and(baseFilter, ownership.sql) as SQL) : baseFilter;
896
+ ownership.kind === "sql" ? (and(baseFilter, ownership.sql) as SQL) : baseFilter; // @cast-boundary db-operator
891
897
  const rows = (await db.select().from(table).where(whereClause).limit(1)) as Record<
892
898
  string,
893
899
  unknown
894
- >[];
900
+ >[]; // @cast-boundary db-row
895
901
  const raw = rows[0];
896
902
  if (!raw) return null;
897
903
  const row = rehydrateCompoundTypes(raw, entity);
@@ -51,7 +51,7 @@ export function flattenLocatedTimestamp(
51
51
  `flattenLocatedTimestamp: field "${name}" expects { at, tz } or { utc, tz } object, got ${typeof raw}`,
52
52
  );
53
53
  }
54
- const pair = raw as { at?: string; tz?: string; utc?: string };
54
+ const pair = raw as { at?: string; tz?: string; utc?: string }; // @cast-boundary schema-walk
55
55
 
56
56
  delete result[name];
57
57
 
package/src/db/money.ts CHANGED
@@ -26,6 +26,11 @@ const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
26
26
  *
27
27
  * Pure — mutiert nicht.
28
28
  */
29
+ interface MoneyPair {
30
+ amount: number;
31
+ currency?: string;
32
+ }
33
+
29
34
  export function flattenMoney(
30
35
  payload: Record<string, unknown>,
31
36
  entity: EntityDefinition,
@@ -42,8 +47,13 @@ export function flattenMoney(
42
47
  let amount: number;
43
48
  let currency: string;
44
49
 
45
- if (typeof raw === "object" && "amount" in raw) {
46
- const pair = raw as { amount: number; currency?: string };
50
+ if (
51
+ typeof raw === "object" &&
52
+ raw !== null &&
53
+ "amount" in raw &&
54
+ typeof (raw as MoneyPair).amount === "number" // @cast-boundary schema-walk
55
+ ) {
56
+ const pair = raw as MoneyPair; // @cast-boundary schema-walk
47
57
  amount = pair.amount;
48
58
  currency = pair.currency ?? fallbackCurrency;
49
59
  } else if (typeof raw === "number") {
@@ -20,7 +20,7 @@ export function extractPgError(e: unknown): PgErrorInfo | null {
20
20
  for (const layer of layers) {
21
21
  // @cast-boundary error-details — postgres-js error shape (code, constraint_name)
22
22
  const code = (layer as { code?: string }).code;
23
- const constraintName = (layer as { constraint_name?: string }).constraint_name;
23
+ const constraintName = (layer as { constraint_name?: string }).constraint_name; // @cast-boundary error-details
24
24
  if (code !== undefined || constraintName !== undefined) {
25
25
  return { code, constraint_name: constraintName };
26
26
  }
@@ -49,5 +49,5 @@ export async function fetchOne<TRow = DbRow>(
49
49
  ): Promise<TRow | undefined> {
50
50
  const where = conditions.length === 1 ? conditions[0] : and(...conditions);
51
51
  const rows = await db.select().from(table).where(where).limit(1);
52
- return rows[0] as TRow | undefined;
52
+ return rows[0] as TRow | undefined; // @cast-boundary db-row
53
53
  }
@@ -133,10 +133,8 @@ function fieldToColumns(
133
133
  // wrapper-feld mit boolean-flag oder discriminierte-union.
134
134
  return { [name]: jsonb(snakeName).default({}).notNull() };
135
135
  case "date": {
136
- // TODO(Sprint G): semantisch falsch — `type:"date"` sollte
137
- // Temporal.PlainDate sein (PG `date` Spalte, kein TZ). Heute aliased auf
138
- // instant() = TIMESTAMPTZ damit Caller die gleiche API nutzen wie für
139
- // type:"timestamp". Echte PlainDate-Migration kommt nach Sprint F.
136
+ // `type:"date"` aliased auf instant() = TIMESTAMPTZ. Echte
137
+ // PlainDate-Migration (PG `date` Spalte, kein TZ) kommt später.
140
138
  const col = instant(snakeName);
141
139
  return { [name]: field.required ? col.notNull() : col };
142
140
  }
@@ -478,7 +476,7 @@ export function buildDrizzleTable<E extends EntityDefinition>(
478
476
  def.name ?? `${tableName}_${def.columns.map((c) => toSnakeCase(c)).join("_")}_${suffix}`;
479
477
  const builder = def.unique === true ? uniqueIndex(indexName) : index(indexName);
480
478
  // biome-ignore lint/suspicious/noExplicitAny: drizzle's .on(...cols) is variadic generic
481
- let chain = (builder.on as any)(...cols);
479
+ let chain = (builder.on as any)(...cols); // @cast-boundary drizzle-bridge
482
480
  if (def.where !== undefined) {
483
481
  // Partial-Index: drizzle's IndexBuilder.where(SQL) emittiert das
484
482
  // `WHERE <condition>` ans Ende der `CREATE [UNIQUE] INDEX`-DDL.
@@ -1,5 +1,5 @@
1
- import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import { and, type Column, eq, getTableName, or, type SQL } from "drizzle-orm";
2
+ import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
3
3
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
4
4
  import type { DbRunner } from "./connection";
5
5
  import type { TableColumns } from "./dialect";
@@ -148,7 +148,7 @@ export function createTenantDb(
148
148
  // types don't include PromiseLike. Cast via this helper so the double-
149
149
  // cast is named and lives in exactly one place per scope.
150
150
  function asDrizzleThenable<T>(builder: unknown): PromiseLike<T> {
151
- return builder as PromiseLike<T>;
151
+ return builder as PromiseLike<T>; // @cast-boundary engine-bridge
152
152
  }
153
153
 
154
154
  // Wrap a DB query promise in a `db.query` span + emit the DB duration
@@ -229,7 +229,7 @@ export function createTenantDb(
229
229
  const ownOrGlobal = or(
230
230
  eq(table["tenantId"], tenantId),
231
231
  eq(table["tenantId"], SYSTEM_TENANT_ID),
232
- ) as SQL;
232
+ ) as SQL; // @cast-boundary db-operator
233
233
  return extra.length > 0 ? and(ownOrGlobal, ...extra) : ownOrGlobal;
234
234
  }
235
235
 
@@ -309,7 +309,7 @@ export function createTenantDb(
309
309
  reject ?? undefined,
310
310
  );
311
311
  },
312
- } as TenantSelectQuery;
312
+ } as TenantSelectQuery; // @cast-boundary drizzle-bridge
313
313
  }
314
314
 
315
315
  // --- Where helper for update/delete ---
@@ -342,7 +342,7 @@ export function createTenantDb(
342
342
  return withDbSpan<Record<string, unknown>[]>(
343
343
  "insert",
344
344
  table,
345
- () => q.returning() as PromiseLike<Record<string, unknown>[]>,
345
+ () => q.returning() as PromiseLike<Record<string, unknown>[]>, // @cast-boundary db-runner
346
346
  );
347
347
  },
348
348
  onConflictDoUpdate(spec: ConflictUpdate) {
@@ -370,7 +370,7 @@ export function createTenantDb(
370
370
  reject,
371
371
  );
372
372
  },
373
- } as TenantInsertValues;
373
+ } as TenantInsertValues; // @cast-boundary drizzle-bridge
374
374
  },
375
375
  };
376
376
  },
@@ -387,7 +387,7 @@ export function createTenantDb(
387
387
  return withDbSpan<Record<string, unknown>[]>(
388
388
  "update",
389
389
  table,
390
- () => wq.returning() as PromiseLike<Record<string, unknown>[]>,
390
+ () => wq.returning() as PromiseLike<Record<string, unknown>[]>, // @cast-boundary db-runner
391
391
  );
392
392
  },
393
393
  // biome-ignore lint/suspicious/noThenProperty: thenable for await
@@ -397,7 +397,7 @@ export function createTenantDb(
397
397
  reject,
398
398
  );
399
399
  },
400
- } as TenantUpdateWhere;
400
+ } as TenantUpdateWhere; // @cast-boundary drizzle-bridge
401
401
  },
402
402
  returning(): PromiseLike<Record<string, unknown>[]> {
403
403
  return Promise.reject(
@@ -416,7 +416,7 @@ export function createTenantDb(
416
416
  ),
417
417
  ).then(resolve, reject);
418
418
  },
419
- } as TenantUpdateSet;
419
+ } as TenantUpdateSet; // @cast-boundary drizzle-bridge
420
420
  },
421
421
  };
422
422
  },
@@ -0,0 +1,23 @@
1
+ // Shared helpers for unit-tests of pipeline.ts / run-pipeline.ts and the
2
+ // step-builders. The minimal-ctx helper was 4 lines duplicated in every
3
+ // pipeline-* test file; centralised here for the M.1.6 cleanup-pass
4
+ // (Followup #7 splitting).
5
+ //
6
+ // Real-ctx integration lives in pipeline-handler.integration.ts —
7
+ // these helpers are deliberately for the no-DB tests where step-args
8
+ // + assembly + boot-time guards are what's exercised.
9
+
10
+ import type { HandlerContext } from "../types/handlers";
11
+
12
+ /**
13
+ * Returns an empty object cast as HandlerContext. Steps that only
14
+ * exercise their own arg-resolution + step-list-assembly don't read
15
+ * any ctx field; the runner needs an object-shaped ctx but no surface
16
+ * beyond that.
17
+ *
18
+ * Tests that actually use ctx fields (db, query, appendEvent, etc.)
19
+ * belong in pipeline-handler.integration.ts against the real stack.
20
+ */
21
+ export function buildMinimalCtx(): HandlerContext {
22
+ return {} as HandlerContext;
23
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { buildTarget, type TreeActionDef } from "../index";
3
+
4
+ // createTreeActionsStub — Test-Helper für Phase-0-Stub-Features. Das
5
+ // `const`-Generic-Modifier forciert Literal-Inference, sodass die
6
+ // Action-Namen als string-literal-Union ankommen (statt zu `string`
7
+ // widening). In V.1.1 wird der Helper überflüssig: echte defineFeature-
8
+ // Outputs haben dieselbe Shape und Tests konsumieren die direkt.
9
+ function createTreeActionsStub<const TActions extends Record<string, TreeActionDef>>(spec: {
10
+ readonly id: string;
11
+ readonly treeActions: TActions;
12
+ }): { readonly id: string; readonly treeActions: TActions } {
13
+ return Object.freeze({ id: spec.id, treeActions: spec.treeActions });
14
+ }
15
+
16
+ const textContentStub = createTreeActionsStub({
17
+ id: "text-content",
18
+ treeActions: {
19
+ edit: { args: { slug: "" as string } },
20
+ create: { args: { folder: "" as string } },
21
+ list: {},
22
+ },
23
+ });
24
+
25
+ describe("buildTarget — NoArgs-Action", () => {
26
+ test("erzeugt TargetRef ohne args-Feld", () => {
27
+ const ref = buildTarget({ target: textContentStub, action: "list" });
28
+ expect(ref).toEqual({ featureId: "text-content", action: "list" });
29
+ expect("args" in ref).toBe(false);
30
+ });
31
+
32
+ test("output ist frozen (immutable)", () => {
33
+ const ref = buildTarget({ target: textContentStub, action: "list" });
34
+ expect(Object.isFrozen(ref)).toBe(true);
35
+ });
36
+ });
37
+
38
+ describe("buildTarget — WithArgs-Action", () => {
39
+ test("erzeugt TargetRef mit args", () => {
40
+ const ref = buildTarget({
41
+ target: textContentStub,
42
+ action: "edit",
43
+ args: { slug: "imprint" },
44
+ });
45
+ expect(ref).toEqual({
46
+ featureId: "text-content",
47
+ action: "edit",
48
+ args: { slug: "imprint" },
49
+ });
50
+ });
51
+
52
+ test("args sind frozen — Mutation am Input-Objekt schlägt nicht durch", () => {
53
+ const inputArgs = { slug: "imprint" };
54
+ const ref = buildTarget({
55
+ target: textContentStub,
56
+ action: "edit",
57
+ args: inputArgs,
58
+ });
59
+ inputArgs.slug = "mutated";
60
+ expect(ref.args).toEqual({ slug: "imprint" });
61
+ expect(Object.isFrozen(ref.args)).toBe(true);
62
+ });
63
+
64
+ test("verschiedene Actions haben unterschiedliche Arg-Shapes", () => {
65
+ const editRef = buildTarget({
66
+ target: textContentStub,
67
+ action: "edit",
68
+ args: { slug: "imprint" },
69
+ });
70
+ const createRef = buildTarget({
71
+ target: textContentStub,
72
+ action: "create",
73
+ args: { folder: "/marketing" },
74
+ });
75
+ expect(editRef.args).toEqual({ slug: "imprint" });
76
+ expect(createRef.args).toEqual({ folder: "/marketing" });
77
+ });
78
+ });
79
+
80
+ describe("buildTarget — Compile-Time-Safety (verified via @ts-expect-error)", () => {
81
+ // Jeder Test paart ein @ts-expect-error-Block (compile-time-validation
82
+ // via TypeScript) mit einem runtime-expect über den korrespondierenden
83
+ // Happy-Path. Doppelt-Coverage: Compiler prüft Rejection, vitest prüft
84
+ // dass die korrekte Form bei valid input das richtige Ergebnis liefert.
85
+ // Memory `[Keine Fake-Tests]` — Tests müssen runtime-Verhalten prüfen,
86
+ // nicht nur Compile-Time (Fake-Test-Guard).
87
+
88
+ test("unbekannte action wird vom Compiler abgelehnt", () => {
89
+ // @ts-expect-error — "delet" ist keine Action von textContentStub
90
+ buildTarget({ target: textContentStub, action: "delet" });
91
+ // Runtime: bekannte Action liefert valid TargetRef
92
+ const ref = buildTarget({ target: textContentStub, action: "list" });
93
+ expect(ref.action).toBe("list");
94
+ });
95
+
96
+ test("falsche args-shape wird vom Compiler abgelehnt", () => {
97
+ // @ts-expect-error — slug muss string sein, nicht number
98
+ buildTarget({
99
+ target: textContentStub,
100
+ action: "edit",
101
+ args: { slug: 42 },
102
+ });
103
+ // Runtime: korrekt-typed args liefern valid TargetRef
104
+ const ref = buildTarget({
105
+ target: textContentStub,
106
+ action: "edit",
107
+ args: { slug: "imprint" },
108
+ });
109
+ expect(ref.args).toEqual({ slug: "imprint" });
110
+ });
111
+
112
+ test("args bei NoArgs-Action wird vom Compiler abgelehnt", () => {
113
+ buildTarget({
114
+ target: textContentStub,
115
+ action: "list",
116
+ // @ts-expect-error — list hat keine args, args-Feld nicht erlaubt
117
+ args: { x: 1 },
118
+ });
119
+ // Runtime: NoArgs-Action ohne args-Feld liefert valid TargetRef
120
+ const ref = buildTarget({ target: textContentStub, action: "list" });
121
+ expect("args" in ref).toBe(false);
122
+ });
123
+
124
+ test("fehlende args bei WithArgs-Action wird vom Compiler abgelehnt", () => {
125
+ // @ts-expect-error — edit braucht args, fehlt
126
+ buildTarget({ target: textContentStub, action: "edit" });
127
+ // Runtime: WithArgs-Action mit args liefert valid TargetRef
128
+ const ref = buildTarget({
129
+ target: textContentStub,
130
+ action: "edit",
131
+ args: { slug: "imprint" },
132
+ });
133
+ expect(ref.args).toBeDefined();
134
+ });
135
+ });