@cosmicdrift/kumiko-framework 0.197.1 → 0.198.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 +3 -3
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/sse-route.test.ts +129 -0
- package/src/api/auth-routes.ts +21 -6
- package/src/api/sse-route.ts +13 -1
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
- package/src/bun-db/query.ts +5 -1
- package/src/db/__tests__/compound-types.test.ts +12 -2
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
- package/src/db/__tests__/money.test.ts +49 -18
- package/src/db/dialect.ts +13 -2
- package/src/db/event-store-executor-read.ts +12 -1
- package/src/db/money.ts +35 -15
- package/src/db/table-builder.ts +7 -1
- package/src/derivatives/derivatives-context.ts +9 -0
- package/src/engine/__tests__/build-app-schema.test.ts +20 -0
- package/src/engine/__tests__/nav.test.ts +12 -4
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/index.ts +2 -1
- package/src/engine/types/index.ts +7 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
- package/src/entrypoint/index.ts +20 -3
- package/src/files/__tests__/files.integration.test.ts +16 -0
- package/src/files/file-routes.ts +12 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
- package/src/jobs/job-runner.ts +32 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
- package/src/pipeline/dispatch-batch.ts +2 -2
- package/src/pipeline/idempotency.ts +11 -6
- package/src/ui-types/index.ts +1 -1
package/src/jobs/job-runner.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
|
7
7
|
import { createSystemUser } from "../engine/system-user";
|
|
8
8
|
import {
|
|
9
9
|
type AppContext,
|
|
10
|
+
type DispatchWriteRef,
|
|
11
|
+
type JobContext,
|
|
10
12
|
type JobRunIn,
|
|
11
13
|
type Registry,
|
|
12
14
|
type SessionUser,
|
|
@@ -114,6 +116,10 @@ export type JobRunner = {
|
|
|
114
116
|
payload: Record<string, unknown>,
|
|
115
117
|
user?: SessionUser,
|
|
116
118
|
): Promise<void>;
|
|
119
|
+
// Wires JobContext.write/queryAs to the real dispatcher — called once at
|
|
120
|
+
// boot, after the dispatcher exists (job-runner construction happens
|
|
121
|
+
// before it). Before this runs, JobContext.write/queryAs throw.
|
|
122
|
+
attachDispatcher(ref: DispatchWriteRef): void;
|
|
117
123
|
};
|
|
118
124
|
|
|
119
125
|
export type JobRunnerOptions = {
|
|
@@ -285,6 +291,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
285
291
|
// delivery.render → delivery.send). Assigned just before return; reads happen
|
|
286
292
|
// at job-execution time (after start()), so it is always defined by then.
|
|
287
293
|
let selfRunner: JobRunner | undefined;
|
|
294
|
+
// Set by attachDispatcher() once the boot-level dispatcher exists.
|
|
295
|
+
// JobContext.write/queryAs throw until this is set — see JobContext doc.
|
|
296
|
+
let dispatchWriteRef: DispatchWriteRef | undefined;
|
|
288
297
|
|
|
289
298
|
// Counts active + waiting jobs with this name for this tenant across
|
|
290
299
|
// BOTH lane queues. Jobs with the same name should only live in one
|
|
@@ -439,8 +448,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
439
448
|
tenantId,
|
|
440
449
|
})
|
|
441
450
|
: context.derivatives;
|
|
442
|
-
const jobContext:
|
|
451
|
+
const jobContext: JobContext = {
|
|
443
452
|
...context,
|
|
453
|
+
// Same union as configDb above — job runners are always constructed
|
|
454
|
+
// with a real DbConnection; JobContext requires it non-optional.
|
|
455
|
+
db: configDb as DbConnection, // @cast-boundary db-operator
|
|
444
456
|
files,
|
|
445
457
|
derivatives,
|
|
446
458
|
...(notify !== undefined && { notify }),
|
|
@@ -456,6 +468,22 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
456
468
|
triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
|
|
457
469
|
log: createJobLogger(logs),
|
|
458
470
|
...(triggerName !== undefined && { triggerName }),
|
|
471
|
+
write: (qn: string, payload: unknown) => {
|
|
472
|
+
if (!dispatchWriteRef) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
"JobContext.write called before dispatcher attached — call attachDispatcher() first",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
return dispatchWriteRef.write(jobSystemUser, qn, payload);
|
|
478
|
+
},
|
|
479
|
+
queryAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
480
|
+
if (!dispatchWriteRef) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
"JobContext.queryAs called before dispatcher attached — call attachDispatcher() first",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return dispatchWriteRef.queryAs(user, qn, payload);
|
|
486
|
+
},
|
|
459
487
|
};
|
|
460
488
|
|
|
461
489
|
await options.onJobStart?.(jobName, jobId, meta);
|
|
@@ -769,6 +797,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
769
797
|
await queues[laneForJob(jobDef)].add(name, data);
|
|
770
798
|
}
|
|
771
799
|
},
|
|
800
|
+
attachDispatcher(ref: DispatchWriteRef): void {
|
|
801
|
+
dispatchWriteRef = ref;
|
|
802
|
+
},
|
|
772
803
|
};
|
|
773
804
|
|
|
774
805
|
selfRunner = runnerApi;
|
|
@@ -318,6 +318,7 @@ describe("enqueueProjectionRebuild — inline fallback (jobRunner without the jo
|
|
|
318
318
|
dispatchCalls++;
|
|
319
319
|
return "should-not-happen";
|
|
320
320
|
},
|
|
321
|
+
attachDispatcher: () => {},
|
|
321
322
|
};
|
|
322
323
|
|
|
323
324
|
const outcome = await enqueueProjectionRebuild("pendingtest:projection:pending-counts", {
|
|
@@ -1021,11 +1021,11 @@ describe("dispatcher context.geoTzProvider (680/1)", () => {
|
|
|
1021
1021
|
function createMockIdempotencyGuard() {
|
|
1022
1022
|
const cache = new Map<string, string>();
|
|
1023
1023
|
return {
|
|
1024
|
-
async check(requestId: string) {
|
|
1025
|
-
return cache.get(requestId) ?? null;
|
|
1024
|
+
async check(tenantId: string, userId: string, requestId: string) {
|
|
1025
|
+
return cache.get(`${tenantId}:${userId}:${requestId}`) ?? null;
|
|
1026
1026
|
},
|
|
1027
|
-
async store(requestId: string, result: unknown) {
|
|
1028
|
-
cache.set(requestId
|
|
1027
|
+
async store(tenantId: string, userId: string, requestId: string, result: unknown) {
|
|
1028
|
+
cache.set(`${tenantId}:${userId}:${requestId}`, JSON.stringify(result));
|
|
1029
1029
|
},
|
|
1030
1030
|
};
|
|
1031
1031
|
}
|
|
@@ -19,9 +19,12 @@ afterAll(async () => {
|
|
|
19
19
|
// --- Idempotency ---
|
|
20
20
|
|
|
21
21
|
describe("idempotency guard", () => {
|
|
22
|
+
const tenantA = "00000000-0000-4000-8000-00000000000a";
|
|
23
|
+
const userA = "00000000-0000-4000-8000-0000000000a1";
|
|
24
|
+
|
|
22
25
|
test("returns null for new request", async () => {
|
|
23
26
|
const guard = createIdempotencyGuard(testRedis.redis);
|
|
24
|
-
const result = await guard.check("req-new-123");
|
|
27
|
+
const result = await guard.check(tenantA, userA, "req-new-123");
|
|
25
28
|
expect(result).toBeNull();
|
|
26
29
|
});
|
|
27
30
|
|
|
@@ -29,8 +32,8 @@ describe("idempotency guard", () => {
|
|
|
29
32
|
const guard = createIdempotencyGuard(testRedis.redis);
|
|
30
33
|
const requestId = "req-dup-456";
|
|
31
34
|
|
|
32
|
-
await guard.store(requestId, { isSuccess: true, data: { id: 1 } });
|
|
33
|
-
const cached = await guard.check(requestId);
|
|
35
|
+
await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { id: 1 } });
|
|
36
|
+
const cached = await guard.check(tenantA, userA, requestId);
|
|
34
37
|
|
|
35
38
|
expect(cached).not.toBeNull();
|
|
36
39
|
if (!cached) throw new Error("expected cached value");
|
|
@@ -41,15 +44,15 @@ describe("idempotency guard", () => {
|
|
|
41
44
|
const guard = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 1 });
|
|
42
45
|
const requestId = "req-ttl-789";
|
|
43
46
|
|
|
44
|
-
await guard.store(requestId, { done: true });
|
|
47
|
+
await guard.store(tenantA, userA, requestId, { done: true });
|
|
45
48
|
|
|
46
49
|
// Should exist immediately
|
|
47
|
-
expect(await guard.check(requestId)).not.toBeNull();
|
|
50
|
+
expect(await guard.check(tenantA, userA, requestId)).not.toBeNull();
|
|
48
51
|
|
|
49
52
|
// Wait for expiry
|
|
50
53
|
await new Promise((r) => setTimeout(r, 1100));
|
|
51
54
|
|
|
52
|
-
expect(await guard.check(requestId)).toBeNull();
|
|
55
|
+
expect(await guard.check(tenantA, userA, requestId)).toBeNull();
|
|
53
56
|
});
|
|
54
57
|
|
|
55
58
|
test("parallel check(): second caller waits for the first's store() instead of racing", async () => {
|
|
@@ -61,11 +64,11 @@ describe("idempotency guard", () => {
|
|
|
61
64
|
const requestId = "req-race-1";
|
|
62
65
|
|
|
63
66
|
// Request #1 starts — claims the in-progress lock.
|
|
64
|
-
const first = await guard.check(requestId);
|
|
67
|
+
const first = await guard.check(tenantA, userA, requestId);
|
|
65
68
|
expect(first).toBeNull(); // got the lock
|
|
66
69
|
|
|
67
70
|
// Request #2 runs concurrently — must block until #1 stores a result.
|
|
68
|
-
const secondPromise = guard.check(requestId);
|
|
71
|
+
const secondPromise = guard.check(tenantA, userA, requestId);
|
|
69
72
|
|
|
70
73
|
// After a tick the second must still be pending: no result yet.
|
|
71
74
|
await new Promise((r) => setTimeout(r, 80));
|
|
@@ -77,7 +80,7 @@ describe("idempotency guard", () => {
|
|
|
77
80
|
expect(quickResult.done).toBe(false);
|
|
78
81
|
|
|
79
82
|
// Request #1 finishes.
|
|
80
|
-
await guard.store(requestId, { isSuccess: true, data: { id: 99 } });
|
|
83
|
+
await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { id: 99 } });
|
|
81
84
|
|
|
82
85
|
// Request #2 should now see the stored result, not null — no duplicate work.
|
|
83
86
|
const second = await secondPromise;
|
|
@@ -93,13 +96,39 @@ describe("idempotency guard", () => {
|
|
|
93
96
|
});
|
|
94
97
|
const requestId = "req-crashed";
|
|
95
98
|
|
|
96
|
-
const first = await guard.check(requestId);
|
|
99
|
+
const first = await guard.check(tenantA, userA, requestId);
|
|
97
100
|
expect(first).toBeNull(); // we acquired the lock, then "crash" — never call store()
|
|
98
101
|
|
|
99
102
|
// After the pending-TTL lapses, a retry should be allowed to take over.
|
|
100
|
-
const second = await guard.check(requestId);
|
|
103
|
+
const second = await guard.check(tenantA, userA, requestId);
|
|
101
104
|
expect(second).toBeNull(); // reclaimed
|
|
102
105
|
});
|
|
106
|
+
|
|
107
|
+
test("same requestId from different tenant/user does not hit the same cache entry", async () => {
|
|
108
|
+
const guard = createIdempotencyGuard(testRedis.redis);
|
|
109
|
+
const requestId = "req-shared-across-tenants";
|
|
110
|
+
const tenantB = "00000000-0000-4000-8000-00000000000b";
|
|
111
|
+
const userB = "00000000-0000-4000-8000-0000000000b1";
|
|
112
|
+
|
|
113
|
+
// Tenant A / user A owns the request and stores its result.
|
|
114
|
+
const firstCheck = await guard.check(tenantA, userA, requestId);
|
|
115
|
+
expect(firstCheck).toBeNull();
|
|
116
|
+
await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { tenant: "A" } });
|
|
117
|
+
|
|
118
|
+
// Same requestId, different tenant+user: must be treated as a fresh
|
|
119
|
+
// request, not see tenant A's cached/pending state.
|
|
120
|
+
const otherTenantCheck = await guard.check(tenantB, userB, requestId);
|
|
121
|
+
expect(otherTenantCheck).toBeNull();
|
|
122
|
+
|
|
123
|
+
// Different user, same tenant: also isolated.
|
|
124
|
+
const otherUserCheck = await guard.check(tenantA, userB, requestId);
|
|
125
|
+
expect(otherUserCheck).toBeNull();
|
|
126
|
+
|
|
127
|
+
// Tenant A's own result is still retrievable and unaffected.
|
|
128
|
+
const ownResult = await guard.check(tenantA, userA, requestId);
|
|
129
|
+
expect(ownResult).not.toBeNull();
|
|
130
|
+
expect(JSON.parse(ownResult as string)).toEqual({ isSuccess: true, data: { tenant: "A" } });
|
|
131
|
+
});
|
|
103
132
|
});
|
|
104
133
|
|
|
105
134
|
// --- Event Dedup ---
|
|
@@ -30,7 +30,7 @@ export async function runBatch(
|
|
|
30
30
|
// Idempotency: if the same requestId has already been processed, return the
|
|
31
31
|
// cached result without re-executing. The cache holds the full BatchResult.
|
|
32
32
|
if (requestId && idempotency) {
|
|
33
|
-
const cached = await idempotency.check(requestId);
|
|
33
|
+
const cached = await idempotency.check(user.tenantId, user.id, requestId);
|
|
34
34
|
if (cached) {
|
|
35
35
|
const parsed = parseJsonSafe<BatchResult | null>(cached, null);
|
|
36
36
|
if (parsed) return parsed;
|
|
@@ -42,7 +42,7 @@ export async function runBatch(
|
|
|
42
42
|
// the same answer (both success and failure results are cached).
|
|
43
43
|
const finalize = async (result: BatchResult): Promise<BatchResult> => {
|
|
44
44
|
if (requestId && idempotency) {
|
|
45
|
-
await idempotency.store(requestId, result);
|
|
45
|
+
await idempotency.store(user.tenantId, user.id, requestId, result);
|
|
46
46
|
}
|
|
47
47
|
return result;
|
|
48
48
|
};
|
|
@@ -2,8 +2,8 @@ import type Redis from "ioredis";
|
|
|
2
2
|
import { RedisKeys } from "./redis-keys";
|
|
3
3
|
|
|
4
4
|
export type IdempotencyGuard = {
|
|
5
|
-
check(requestId: string): Promise<string | null>;
|
|
6
|
-
store(requestId: string, result: unknown): Promise<void>;
|
|
5
|
+
check(tenantId: string, userId: string, requestId: string): Promise<string | null>;
|
|
6
|
+
store(tenantId: string, userId: string, requestId: string, result: unknown): Promise<void>;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
// Sentinel stored under the key while the handler is running. A second
|
|
@@ -39,8 +39,8 @@ export function createIdempotencyGuard(
|
|
|
39
39
|
// both see a cache miss, both execute side-effects, and only one persist
|
|
40
40
|
// the result. This version uses a pending-marker lock so the second caller
|
|
41
41
|
// waits for the first to finish and reuses its result.
|
|
42
|
-
async check(requestId) {
|
|
43
|
-
const key = `${prefix}${requestId}`;
|
|
42
|
+
async check(tenantId, userId, requestId) {
|
|
43
|
+
const key = `${prefix}${tenantId}:${userId}:${requestId}`;
|
|
44
44
|
|
|
45
45
|
// Try to acquire the in-progress lock.
|
|
46
46
|
const acquired = await redis.set(key, PENDING_MARKER, "EX", pendingTtl, "NX");
|
|
@@ -67,10 +67,15 @@ export function createIdempotencyGuard(
|
|
|
67
67
|
return null;
|
|
68
68
|
},
|
|
69
69
|
|
|
70
|
-
async store(requestId, result) {
|
|
70
|
+
async store(tenantId, userId, requestId, result) {
|
|
71
71
|
// Overwrite the pending marker with the real result. Plain SET (no NX)
|
|
72
72
|
// on purpose: we own the lock; writing the result is the final step.
|
|
73
|
-
await redis.set(
|
|
73
|
+
await redis.set(
|
|
74
|
+
`${prefix}${tenantId}:${userId}:${requestId}`,
|
|
75
|
+
JSON.stringify(result),
|
|
76
|
+
"EX",
|
|
77
|
+
ttl,
|
|
78
|
+
);
|
|
74
79
|
},
|
|
75
80
|
};
|
|
76
81
|
}
|
package/src/ui-types/index.ts
CHANGED
|
@@ -51,7 +51,7 @@ export type {
|
|
|
51
51
|
TextFieldDef,
|
|
52
52
|
} from "../engine/types/fields";
|
|
53
53
|
export type { AccessRule } from "../engine/types/handlers";
|
|
54
|
-
export type { NavDefinition } from "../engine/types/nav";
|
|
54
|
+
export type { NavDefinition, NavIconKey } from "../engine/types/nav";
|
|
55
55
|
export type {
|
|
56
56
|
ActionFormScreenDefinition,
|
|
57
57
|
ConfigEditScreenDefinition,
|