@cosmicdrift/kumiko-framework 0.234.0 → 0.235.1
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/__tests__/list-row-meta-drift.test.ts +15 -0
- package/src/db/queries/event-consumer.ts +20 -5
- package/src/engine/__tests__/boot-validator.test.ts +114 -0
- 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/entity-list-screens.ts +13 -1
- package/src/engine/boot-validator/i18n-keys.ts +13 -5
- package/src/engine/boot-validator/screens.ts +25 -1
- 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/pipeline/multi-stream-apply-context.ts +4 -4
- 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 +4 -0
- package/src/ui-types/list-row-meta.ts +25 -0
|
@@ -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.
|
|
@@ -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
|
|
@@ -11,10 +11,10 @@ export type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-st
|
|
|
11
11
|
|
|
12
12
|
export type MultiStreamApplyContextDeps = {
|
|
13
13
|
readonly registry: Registry;
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// Same DbRunner the applyFn receives as its 2nd arg. In the server's MSP
|
|
15
|
+
// consumer wiring (api/server.ts) that's the unbound pool, not the
|
|
16
|
+
// dispatcher's cursor tx — a throw does NOT roll back appends/inline
|
|
17
|
+
// projections already made; apply must be idempotent for the retry.
|
|
18
18
|
readonly db: DbRunner;
|
|
19
19
|
// tenantId + userId of the TRIGGERING event. appendEvent stamps these
|
|
20
20
|
// onto the new event so the causal chain stays tenant-consistent and
|
|
@@ -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();
|