@cosmicdrift/kumiko-framework 0.233.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.
@@ -0,0 +1,176 @@
1
+ // EventConsumer.startFrom — opt-in cursor seed on FIRST registration
2
+ // (fw#2482/1). Mounting a NEW consumer into an app whose event log is
3
+ // already populated must not replay history and fire every side-effect
4
+ // step retroactively.
5
+ //
6
+ // setupTestStack's own dispatcher pre-registers every consumer eagerly at
7
+ // stack construction time (test-stack.ts: "Pre-register consumer state
8
+ // rows..."), before any test body can seed historical events — so it can't
9
+ // reproduce "mount into an already-running app" the way this fix needs.
10
+ // This test instead builds its own EventDispatcher via createEventDispatcher()
11
+ // against a plain createTestDb(), and only calls ensureRegistered() AFTER
12
+ // historical events already exist — the same order a real deploy of a new
13
+ // event-triggered workflow hits in production.
14
+
15
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
+ import type { DbTx } from "../../db/connection";
17
+ import { insertOne } from "../../db/query";
18
+ import type { AppContext } from "../../engine/types";
19
+ import { eventsTable } from "../../event-store";
20
+ import { createTestDb, type TestDb, TestUsers } from "../../stack";
21
+ import { createEventConsumerStateTable } from "../event-consumer-state";
22
+ import { createEventDispatcher, type EventConsumer, getConsumerState } from "../event-dispatcher";
23
+ import { fetchPendingEvents } from "../event-dispatcher-delivery";
24
+
25
+ const admin = TestUsers.admin;
26
+ let testDb: TestDb;
27
+ let historicalEventCount = 0;
28
+
29
+ function stubContext(): AppContext {
30
+ return {
31
+ db: {} as unknown as AppContext["db"],
32
+ redis: {} as unknown as AppContext["redis"],
33
+ registry: {} as unknown as AppContext["registry"],
34
+ } as AppContext;
35
+ }
36
+
37
+ async function appendHistoricalEvent(type: string): Promise<void> {
38
+ await insertOne(testDb.db, eventsTable, {
39
+ aggregateId: crypto.randomUUID(),
40
+ aggregateType: "start-from-test-source",
41
+ tenantId: admin.tenantId,
42
+ version: 1,
43
+ type,
44
+ eventVersion: 1,
45
+ payload: {},
46
+ metadata: { userId: admin.id },
47
+ createdBy: admin.id,
48
+ });
49
+ historicalEventCount += 1;
50
+ }
51
+
52
+ beforeAll(async () => {
53
+ // createTestDb() already materializes kumiko_events (see stack/db.ts).
54
+ testDb = await createTestDb();
55
+ await createEventConsumerStateTable(testDb.db);
56
+ });
57
+
58
+ afterAll(async () => {
59
+ await testDb.cleanup();
60
+ });
61
+
62
+ describe("EventConsumer.startFrom", () => {
63
+ test("'now' seeds the cursor at MAX(events.id) on first registration and skips the historical log", async () => {
64
+ await appendHistoricalEvent("start-from-test.one");
65
+ await appendHistoricalEvent("start-from-test.two");
66
+ await appendHistoricalEvent("start-from-test.three");
67
+ const headAtMount = BigInt(historicalEventCount);
68
+
69
+ const captured: string[] = [];
70
+ const consumer: EventConsumer = {
71
+ name: "test:consumer:mounted-now",
72
+ startFrom: "now",
73
+ handler: async (event) => {
74
+ captured.push(event.type);
75
+ },
76
+ };
77
+
78
+ // Mirrors mounting a new event-triggered workflow into a production app
79
+ // that already has a populated event log: the dispatcher (and its
80
+ // consumer) is only constructed AFTER the historical events above exist.
81
+ const dispatcher = createEventDispatcher({
82
+ db: testDb.db,
83
+ consumers: [consumer],
84
+ context: stubContext(),
85
+ });
86
+ await dispatcher.ensureRegistered();
87
+
88
+ const state = await getConsumerState(testDb.db, consumer.name);
89
+ expect(state?.lastProcessedEventId).toBe(headAtMount);
90
+ expect(state?.status).toBe("idle");
91
+
92
+ const result = await dispatcher.runOnce();
93
+ expect(result.byConsumer[consumer.name]).toEqual({ processed: 0, failed: 0 });
94
+ expect(captured).toHaveLength(0);
95
+
96
+ // Not just "not delivered" — proves the id > cursor contract directly:
97
+ // no historical row is even fetched on the first pass.
98
+ await testDb.db.begin(async (tx: DbTx) => {
99
+ const pending = await fetchPendingEvents(tx, headAtMount, 100);
100
+ expect(pending).toHaveLength(0);
101
+ });
102
+ });
103
+
104
+ test("without startFrom (default 'beginning') a freshly-registered consumer replays the full historical log", async () => {
105
+ await appendHistoricalEvent("start-from-test.four");
106
+ await appendHistoricalEvent("start-from-test.five");
107
+ const expectedReplayCount = historicalEventCount;
108
+
109
+ const captured: string[] = [];
110
+ const consumer: EventConsumer = {
111
+ name: "test:consumer:mounted-beginning",
112
+ handler: async (event) => {
113
+ captured.push(event.type);
114
+ },
115
+ };
116
+
117
+ const dispatcher = createEventDispatcher({
118
+ db: testDb.db,
119
+ consumers: [consumer],
120
+ context: stubContext(),
121
+ });
122
+ await dispatcher.ensureRegistered();
123
+
124
+ const stateBefore = await getConsumerState(testDb.db, consumer.name);
125
+ expect(stateBefore?.lastProcessedEventId).toBe(0n);
126
+
127
+ const result = await dispatcher.runOnce();
128
+ expect(result.byConsumer[consumer.name]).toEqual({ processed: expectedReplayCount, failed: 0 });
129
+ expect(captured).toEqual([
130
+ "start-from-test.one",
131
+ "start-from-test.two",
132
+ "start-from-test.three",
133
+ "start-from-test.four",
134
+ "start-from-test.five",
135
+ ]);
136
+ });
137
+
138
+ test("re-registering an already-registered consumer never clobbers an advanced cursor", async () => {
139
+ const consumer: EventConsumer = {
140
+ name: "test:consumer:advanced-then-remounted",
141
+ startFrom: "now",
142
+ handler: async () => {},
143
+ };
144
+
145
+ const dispatcher = createEventDispatcher({
146
+ db: testDb.db,
147
+ consumers: [consumer],
148
+ context: stubContext(),
149
+ });
150
+ await dispatcher.ensureRegistered();
151
+ const seeded = await getConsumerState(testDb.db, consumer.name);
152
+ expect(seeded?.lastProcessedEventId).toBe(BigInt(historicalEventCount));
153
+
154
+ await appendHistoricalEvent("start-from-test.six");
155
+ await dispatcher.runOnce();
156
+ const advanced = await getConsumerState(testDb.db, consumer.name);
157
+ expect(advanced?.lastProcessedEventId).toBe(BigInt(historicalEventCount));
158
+
159
+ // A new historical event exists again, not yet consumed by this consumer.
160
+ await appendHistoricalEvent("start-from-test.seven");
161
+
162
+ // A second dispatcher instance for the SAME consumer name simulates a
163
+ // process restart re-running the boot-time pre-registration — the row
164
+ // already exists, so ON CONFLICT DO NOTHING must leave its advanced
165
+ // cursor untouched, not reseed it at the new (higher) MAX(events.id).
166
+ const redeployedDispatcher = createEventDispatcher({
167
+ db: testDb.db,
168
+ consumers: [consumer],
169
+ context: stubContext(),
170
+ });
171
+ await redeployedDispatcher.ensureRegistered();
172
+
173
+ const afterRedeploy = await getConsumerState(testDb.db, consumer.name);
174
+ expect(afterRedeploy?.lastProcessedEventId).toBe(advanced?.lastProcessedEventId);
175
+ });
176
+ });
@@ -155,7 +155,7 @@ export async function preRegisterConsumers(
155
155
  ): Promise<void> {
156
156
  for (const consumer of consumers) {
157
157
  const instanceId = consumerInstanceId(consumer, dispatcherInstanceId);
158
- await insertConsumerIfAbsent(db, consumer.name, instanceId);
158
+ await insertConsumerIfAbsent(db, consumer.name, instanceId, consumer.startFrom);
159
159
  }
160
160
  }
161
161
 
@@ -94,6 +94,12 @@ export type EventConsumer = {
94
94
  // respect to shared storage (no DB writes), otherwise
95
95
  // each instance duplicates the effect.
96
96
  readonly delivery?: "shared" | "per-instance";
97
+ // Where the consumer's cursor starts on its FIRST registration. Default
98
+ // "beginning" replays the full log. "now" seeds the cursor at the current
99
+ // MAX(events.id) so mounting the consumer into an existing app does not
100
+ // retroactively fire side effects for historical events. Only affects the
101
+ // first registration — an existing cursor row is never clobbered.
102
+ readonly startFrom?: "beginning" | "now";
97
103
  };
98
104
 
99
105
  // Result of a dispatcher pass (runOnce / doPass). Shared across the public
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env bun
2
+ // `UnprocessableOpts.details` was tightened (fw#2460) to `& { readonly reason?:
3
+ // never }` — `reason` is owned exclusively by the ctor's first positional
4
+ // argument, which already folds it into `details` internally
5
+ // (`{ ...opts?.details, reason }`). Any consumer that ALSO puts `reason`
6
+ // inside its `details` object literal now fails with TS2322. This codemod
7
+ // removes that redundant `reason` property; the value is never lost, it was
8
+ // already duplicated from the positional argument.
9
+ //
10
+ // Usage: bun scripts/codemod/unprocessable-error-details-reason.ts <targetDir> [--dry-run]
11
+
12
+ import { type Dirent, readdirSync } from "node:fs";
13
+ import { join, relative, resolve } from "node:path";
14
+ import {
15
+ type Identifier,
16
+ Node,
17
+ type ObjectLiteralExpression,
18
+ Project,
19
+ type SourceFile,
20
+ SyntaxKind,
21
+ } from "ts-morph";
22
+
23
+ const EXCLUDE_DIRS = new Set(["node_modules", "dist", "build"]);
24
+ const KUMIKO_MODULE_RE = /^@cosmicdrift\/kumiko-/;
25
+
26
+ function findTargetFiles(rootDir: string): string[] {
27
+ // Walk the tree and skip excluded dirs while descending — filtering the
28
+ // absolute path after a full scan silently no-ops when the repo root
29
+ // itself contains "/build/" or "/node_modules/" (fw#2289).
30
+ const files: string[] = [];
31
+ const walk = (dir: string): void => {
32
+ let entries: Dirent[];
33
+ try {
34
+ entries = readdirSync(dir, { withFileTypes: true });
35
+ } catch {
36
+ // skip: unreadable directory during walk — treat as empty.
37
+ return;
38
+ }
39
+ for (const ent of entries) {
40
+ if (ent.isDirectory()) {
41
+ if (EXCLUDE_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
42
+ walk(join(dir, ent.name));
43
+ continue;
44
+ }
45
+ if (ent.isFile() && /\.(ts|tsx)$/.test(ent.name)) {
46
+ files.push(join(dir, ent.name));
47
+ }
48
+ }
49
+ };
50
+ walk(rootDir);
51
+ return files.sort();
52
+ }
53
+
54
+ type ImportedBinding = { readonly importedName: string; readonly moduleSpecifier: string };
55
+
56
+ /** Local-name -> where it's imported from, for every named value import in the file. */
57
+ function collectImportedNames(sourceFile: SourceFile): Map<string, ImportedBinding> {
58
+ const bindings = new Map<string, ImportedBinding>();
59
+ for (const imp of sourceFile.getImportDeclarations()) {
60
+ if (imp.isTypeOnly()) continue;
61
+ const moduleSpecifier = imp.getModuleSpecifierValue();
62
+ for (const spec of imp.getNamedImports()) {
63
+ if (spec.isTypeOnly()) continue;
64
+ const importedName = spec.getName();
65
+ const localName = spec.getAliasNode()?.getText() ?? importedName;
66
+ bindings.set(localName, { importedName, moduleSpecifier });
67
+ }
68
+ }
69
+ return bindings;
70
+ }
71
+
72
+ type DetailsResolution =
73
+ | { readonly kind: "object-literal"; readonly literal: ObjectLiteralExpression }
74
+ | { readonly kind: "unresolvable"; readonly note: string }
75
+ | { readonly kind: "absent" };
76
+
77
+ /** Resolves `opts.details` to a statically-inspectable object literal, or explains why it can't be. */
78
+ function resolveDetailsProperty(optsLiteral: ObjectLiteralExpression): DetailsResolution {
79
+ const prop = optsLiteral.getProperty("details");
80
+ if (!prop) return { kind: "absent" };
81
+
82
+ if (Node.isShorthandPropertyAssignment(prop)) {
83
+ return { kind: "unresolvable", note: "details is a shorthand property (a variable)" };
84
+ }
85
+ if (!Node.isPropertyAssignment(prop)) {
86
+ return { kind: "unresolvable", note: "details is not a plain property assignment" };
87
+ }
88
+ if (Node.isComputedPropertyName(prop.getNameNode())) {
89
+ return { kind: "unresolvable", note: "details key is computed" };
90
+ }
91
+ const init = prop.getInitializer();
92
+ if (!init) return { kind: "unresolvable", note: "details has no initializer" };
93
+ if (Node.isObjectLiteralExpression(init)) return { kind: "object-literal", literal: init };
94
+
95
+ const note = Node.isIdentifier(init)
96
+ ? `details is a variable ("${init.getText()}")`
97
+ : Node.isCallExpression(init)
98
+ ? "details is a call-expression result"
99
+ : Node.isConditionalExpression(init)
100
+ ? "details is a conditional expression"
101
+ : `details is not a statically-analyzable object literal (${init.getKindName()})`;
102
+ return { kind: "unresolvable", note };
103
+ }
104
+
105
+ /** True if removing this shorthand-bound identifier would leave its declaration with no other use. */
106
+ function wouldLeaveUnusedVariable(nameNode: Identifier): boolean {
107
+ try {
108
+ const refs = nameNode.findReferencesAsNodes();
109
+ const others = refs.filter((r) => r !== nameNode);
110
+ return others.length === 0;
111
+ } catch {
112
+ // Can't verify — treat as unsafe rather than guess.
113
+ return true;
114
+ }
115
+ }
116
+
117
+ type RemovalResult =
118
+ | { readonly kind: "removed" }
119
+ | { readonly kind: "absent" }
120
+ | { readonly kind: "skip"; readonly note: string };
121
+
122
+ /** Removes the `reason` property from a `details` object literal, in place. */
123
+ function removeReasonProperty(detailsLiteral: ObjectLiteralExpression): RemovalResult {
124
+ const properties = detailsLiteral.getProperties();
125
+ if (properties.some((p) => Node.isSpreadAssignment(p))) {
126
+ return {
127
+ kind: "skip",
128
+ note: "details object literal contains a spread — not statically decidable",
129
+ };
130
+ }
131
+
132
+ for (const prop of properties) {
133
+ if (Node.isPropertyAssignment(prop)) {
134
+ const nameNode = prop.getNameNode();
135
+ if (Node.isComputedPropertyName(nameNode)) {
136
+ const expr = nameNode.getExpression();
137
+ if (Node.isStringLiteral(expr) && expr.getLiteralText() === "reason") {
138
+ return { kind: "skip", note: "reason key is computed — not safe to remove mechanically" };
139
+ }
140
+ continue;
141
+ }
142
+ const propName = Node.isIdentifier(nameNode)
143
+ ? nameNode.getText()
144
+ : Node.isStringLiteral(nameNode)
145
+ ? nameNode.getLiteralText()
146
+ : undefined;
147
+ if (propName === "reason") {
148
+ prop.remove();
149
+ return { kind: "removed" };
150
+ }
151
+ continue;
152
+ }
153
+ if (Node.isShorthandPropertyAssignment(prop) && prop.getName() === "reason") {
154
+ if (wouldLeaveUnusedVariable(prop.getNameNode())) {
155
+ return {
156
+ kind: "skip",
157
+ note: "reason is a shorthand property; removing it would leave the bound variable unused",
158
+ };
159
+ }
160
+ prop.remove();
161
+ return { kind: "removed" };
162
+ }
163
+ }
164
+ return { kind: "absent" };
165
+ }
166
+
167
+ type CallOutcome = "changed" | "skipped" | "not-a-match";
168
+
169
+ function processNewExpression(args: readonly Node[], report: (note: string) => void): CallOutcome {
170
+ const optsArg = args[1];
171
+ if (!optsArg) return "not-a-match";
172
+
173
+ if (!Node.isObjectLiteralExpression(optsArg)) {
174
+ report(
175
+ "second argument (opts) is not a statically-analyzable object literal — cannot verify details.reason",
176
+ );
177
+ return "skipped";
178
+ }
179
+
180
+ const detailsRes = resolveDetailsProperty(optsArg);
181
+ if (detailsRes.kind === "absent") return "not-a-match";
182
+ if (detailsRes.kind === "unresolvable") {
183
+ report(detailsRes.note);
184
+ return "skipped";
185
+ }
186
+
187
+ const removal = removeReasonProperty(detailsRes.literal);
188
+ if (removal.kind === "absent") return "not-a-match";
189
+ if (removal.kind === "skip") {
190
+ report(removal.note);
191
+ return "skipped";
192
+ }
193
+ return "changed";
194
+ }
195
+
196
+ type SkipEntry = { readonly file: string; readonly line: number; readonly note: string };
197
+
198
+ /** Migrates one file in place. Returns how many `reason` properties were removed. */
199
+ function migrateFile(sourceFile: SourceFile, skips: SkipEntry[]): number {
200
+ const importedNames = collectImportedNames(sourceFile);
201
+ let removedCount = 0;
202
+
203
+ for (const newExpr of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) {
204
+ const callee = newExpr.getExpression();
205
+ if (!Node.isIdentifier(callee)) continue;
206
+ const localName = callee.getText();
207
+
208
+ const binding = importedNames.get(localName);
209
+ const isFrameworkUnprocessableError = binding?.importedName === "UnprocessableError";
210
+
211
+ if (!isFrameworkUnprocessableError) {
212
+ if (localName !== "UnprocessableError") continue;
213
+ const note = binding
214
+ ? `UnprocessableError is imported from "${binding.moduleSpecifier}", not a @cosmicdrift/kumiko-* package`
215
+ : "UnprocessableError has no @cosmicdrift/kumiko-* import in this file (likely a locally defined class)";
216
+ skips.push({ file: sourceFile.getFilePath(), line: newExpr.getStartLineNumber(), note });
217
+ continue;
218
+ }
219
+ if (!KUMIKO_MODULE_RE.test(binding.moduleSpecifier)) {
220
+ skips.push({
221
+ file: sourceFile.getFilePath(),
222
+ line: newExpr.getStartLineNumber(),
223
+ note: `UnprocessableError is imported from "${binding.moduleSpecifier}", not a @cosmicdrift/kumiko-* package`,
224
+ });
225
+ continue;
226
+ }
227
+
228
+ const outcome = processNewExpression(newExpr.getArguments(), (note) => {
229
+ skips.push({ file: sourceFile.getFilePath(), line: newExpr.getStartLineNumber(), note });
230
+ });
231
+ if (outcome === "changed") removedCount++;
232
+ }
233
+
234
+ return removedCount;
235
+ }
236
+
237
+ async function main(): Promise<void> {
238
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
239
+ const dryRun = process.argv.includes("--dry-run");
240
+ const rootDir = resolve(positional[0] ?? process.cwd());
241
+
242
+ const files = findTargetFiles(rootDir);
243
+ const project = new Project({
244
+ skipAddingFilesFromTsConfig: true,
245
+ skipFileDependencyResolution: true,
246
+ });
247
+
248
+ let touchedFiles = 0;
249
+ let removedTotal = 0;
250
+ const skips: SkipEntry[] = [];
251
+
252
+ for (const file of files) {
253
+ const sourceFile = project.addSourceFileAtPath(file);
254
+ const removed = migrateFile(sourceFile, skips);
255
+ if (removed === 0) continue;
256
+
257
+ touchedFiles++;
258
+ removedTotal += removed;
259
+ if (!dryRun) sourceFile.saveSync();
260
+ }
261
+
262
+ console.log(`\nScanned ${files.length} files under ${rootDir}${dryRun ? " (dry-run)" : ""}.`);
263
+ console.log(
264
+ `Touched ${touchedFiles} files, removed ${removedTotal} redundant "reason" propert(y/ies).`,
265
+ );
266
+ console.log(`Skipped ${skips.length} site(s):`);
267
+ for (const s of skips) {
268
+ console.log(` ${relative(rootDir, s.file)}:${s.line} — ${s.note}`);
269
+ }
270
+ console.log();
271
+ }
272
+
273
+ await main();
@@ -112,6 +112,9 @@ export function generateE2ESpec(
112
112
  // pro Field geschrieben, ohne CRUD-Zustand zu generieren wäre der
113
113
  // Spec wertlos. Branding/SMTP/etc. sind Author-spezifisch.
114
114
  if (screen.type === "configEdit") continue;
115
+ // secretsEdit: dito — writes go through secrets:write:set with
116
+ // plaintext values the generator has no author-defined test data for.
117
+ if (screen.type === "secretsEdit") continue;
115
118
  // projectionList: query-getrieben, Author-spezifische Projection —
116
119
  // kein generischer CRUD-Spec ableitbar (wie actionForm/configEdit).
117
120
  if (screen.type === "projectionList") continue;
@@ -52,7 +52,7 @@ export type {
52
52
  TextFieldDef,
53
53
  } from "../engine/types/fields";
54
54
  export type { AccessRule } from "../engine/types/handlers";
55
- export type { NavDefinition, NavIconKey } from "../engine/types/nav";
55
+ export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav";
56
56
  export type {
57
57
  ActionFormScreenDefinition,
58
58
  ConfigEditScreenDefinition,
@@ -93,6 +93,8 @@ export type {
93
93
  ScreenFilter,
94
94
  ScreenFilterOp,
95
95
  ScreenSlots,
96
+ SecretsEditScreenDefinition,
97
+ SecretsEditSection,
96
98
  ToolbarAction,
97
99
  } from "../engine/types/screen";
98
100
  export type { TargetRef } from "../engine/types/target-ref";