@cosmicdrift/kumiko-framework 0.197.1 → 0.199.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__/server-boot-guards.test.ts +128 -1
- package/src/api/__tests__/sse-route.test.ts +129 -0
- package/src/api/auth-routes.ts +21 -6
- package/src/api/server.ts +44 -0
- 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/__tests__/unchecked-system-db.test.ts +117 -0
- package/src/db/dialect.ts +13 -2
- package/src/db/event-store-executor-read.ts +12 -1
- package/src/db/index.ts +7 -2
- package/src/db/money.ts +35 -15
- package/src/db/table-builder.ts +7 -1
- package/src/db/tenant-db.ts +54 -2
- package/src/derivatives/derivatives-context.ts +9 -0
- package/src/engine/__tests__/build-app-schema.test.ts +50 -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-app-schema.ts +6 -0
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/index.ts +2 -1
- package/src/engine/registry-facade.ts +6 -0
- package/src/engine/registry-ingest.ts +1 -0
- package/src/engine/registry-state.ts +2 -0
- 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__/job-systemdb.integration.test.ts +152 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
- package/src/jobs/job-runner.ts +42 -3
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -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/dispatch-shared.ts +3 -1
- package/src/pipeline/idempotency.ts +11 -6
- package/src/ui-types/app-schema.ts +10 -0
- package/src/ui-types/index.ts +1 -1
package/src/jobs/job-runner.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { type Job, 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";
|
|
5
|
-
import { createTenantDb } from "../db/tenant-db";
|
|
5
|
+
import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
|
|
6
6
|
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
|
|
@@ -418,8 +427,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
418
427
|
const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
|
|
419
428
|
// Shared by the config accessor and ctx.derivatives below — both need the
|
|
420
429
|
// same tenant-scoped db, and building it twice would let the two calls
|
|
421
|
-
// drift apart.
|
|
430
|
+
// drift apart. Always "system" mode regardless of the job's own
|
|
431
|
+
// systemScope() status (pre-existing, not something this change alters)
|
|
432
|
+
// — isSystemJob below is what actually keeps ctx.systemDb off a
|
|
433
|
+
// non-system job; it is the only thing standing between this db and an
|
|
434
|
+
// unchecked cross-tenant escape hatch for such a job.
|
|
422
435
|
const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
|
|
436
|
+
const isSystemJob = registry.isJobSystemScoped(jobName);
|
|
437
|
+
const systemDb =
|
|
438
|
+
isSystemJob && tenantScopedDb ? createUncheckedSystemDb(tenantScopedDb) : undefined;
|
|
423
439
|
const config =
|
|
424
440
|
context._configAccessorFactory && tenantScopedDb
|
|
425
441
|
? context._configAccessorFactory({
|
|
@@ -439,12 +455,16 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
439
455
|
tenantId,
|
|
440
456
|
})
|
|
441
457
|
: context.derivatives;
|
|
442
|
-
const jobContext:
|
|
458
|
+
const jobContext: JobContext = {
|
|
443
459
|
...context,
|
|
460
|
+
// Same union as configDb above — job runners are always constructed
|
|
461
|
+
// with a real DbConnection; JobContext requires it non-optional.
|
|
462
|
+
db: configDb as DbConnection, // @cast-boundary db-operator
|
|
444
463
|
files,
|
|
445
464
|
derivatives,
|
|
446
465
|
...(notify !== undefined && { notify }),
|
|
447
466
|
...(config !== undefined && { config }),
|
|
467
|
+
...(systemDb && { systemDb }),
|
|
448
468
|
// The runner owns the registry it resolved this job from — expose it so
|
|
449
469
|
// workers can reach projections/jobs without the app author duplicating
|
|
450
470
|
// it into `context` (the JobContext contract guarantees `registry`).
|
|
@@ -456,6 +476,22 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
456
476
|
triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
|
|
457
477
|
log: createJobLogger(logs),
|
|
458
478
|
...(triggerName !== undefined && { triggerName }),
|
|
479
|
+
write: (qn: string, payload: unknown) => {
|
|
480
|
+
if (!dispatchWriteRef) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
"JobContext.write called before dispatcher attached — call attachDispatcher() first",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return dispatchWriteRef.write(jobSystemUser, qn, payload);
|
|
486
|
+
},
|
|
487
|
+
queryAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
488
|
+
if (!dispatchWriteRef) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
"JobContext.queryAs called before dispatcher attached — call attachDispatcher() first",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return dispatchWriteRef.queryAs(user, qn, payload);
|
|
494
|
+
},
|
|
459
495
|
};
|
|
460
496
|
|
|
461
497
|
await options.onJobStart?.(jobName, jobId, meta);
|
|
@@ -769,6 +805,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
769
805
|
await queues[laneForJob(jobDef)].add(name, data);
|
|
770
806
|
}
|
|
771
807
|
},
|
|
808
|
+
attachDispatcher(ref: DispatchWriteRef): void {
|
|
809
|
+
dispatchWriteRef = ref;
|
|
810
|
+
},
|
|
772
811
|
};
|
|
773
812
|
|
|
774
813
|
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", {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../../engine";
|
|
4
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
5
|
+
|
|
6
|
+
// r.systemScope() is feature-level (define-feature.ts), not per-handler — so
|
|
7
|
+
// two features prove both sides: one system-scoped, one not.
|
|
8
|
+
|
|
9
|
+
const systemScopedFeature = defineFeature("ctxsystemdb-system", (r) => {
|
|
10
|
+
r.systemScope();
|
|
11
|
+
|
|
12
|
+
r.queryHandler(
|
|
13
|
+
"check",
|
|
14
|
+
z.object({}),
|
|
15
|
+
async (query, ctx) => {
|
|
16
|
+
if (!ctx.systemDb) return { present: false as const };
|
|
17
|
+
// assertTenantMatch returns the underlying TenantDb — proves systemDb
|
|
18
|
+
// is bound to the SAME internally-built TenantDb as ctx.db, not a
|
|
19
|
+
// separate instance (dispatch-shared.ts builds `as HandlerContext`,
|
|
20
|
+
// so a mis-wired property wouldn't be caught by tsc).
|
|
21
|
+
const checked = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
22
|
+
return { present: true as const, boundToDb: checked === ctx.db };
|
|
23
|
+
},
|
|
24
|
+
{ access: { roles: ["Admin"] } },
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const tenantScopedFeature = defineFeature("ctxsystemdb-tenant", (r) => {
|
|
29
|
+
r.queryHandler(
|
|
30
|
+
"check",
|
|
31
|
+
z.object({}),
|
|
32
|
+
async (_query, ctx) => ({ present: ctx.systemDb !== undefined }),
|
|
33
|
+
{ access: { roles: ["Admin"] } },
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let stack: TestStack;
|
|
38
|
+
const admin = TestUsers.admin;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
stack = await setupTestStack({ features: [systemScopedFeature, tenantScopedFeature] });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await stack.cleanup();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("ctx.systemDb", () => {
|
|
49
|
+
test("is present and bound to ctx.db for r.systemScope() handlers", async () => {
|
|
50
|
+
const result = await stack.http.queryOk<{ present: boolean; boundToDb: boolean }>(
|
|
51
|
+
"ctxsystemdb-system:query:check",
|
|
52
|
+
{},
|
|
53
|
+
admin,
|
|
54
|
+
);
|
|
55
|
+
expect(result.present).toBe(true);
|
|
56
|
+
expect(result.boundToDb).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("is absent for non-system-scoped handlers", async () => {
|
|
60
|
+
const result = await stack.http.queryOk<{ present: boolean }>(
|
|
61
|
+
"ctxsystemdb-tenant:query:check",
|
|
62
|
+
{},
|
|
63
|
+
admin,
|
|
64
|
+
);
|
|
65
|
+
expect(result.present).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -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
|
};
|
|
@@ -3,7 +3,7 @@ import type { SseBroker } from "../api/sse-broker";
|
|
|
3
3
|
import type { DbConnection, DbRunner, DbTx } from "../db/connection";
|
|
4
4
|
import { runInSavepoint, selectMany } from "../db/query";
|
|
5
5
|
import type { buildEntityTable } from "../db/table-builder";
|
|
6
|
-
import { createTenantDb } from "../db/tenant-db";
|
|
6
|
+
import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
|
|
7
7
|
import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
8
8
|
import type { defineTransitions } from "../engine/state-machine";
|
|
9
9
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
@@ -183,6 +183,7 @@ export async function buildHandlerContext(
|
|
|
183
183
|
// the client has disconnected — handlers with many sequential queries skip
|
|
184
184
|
// the rest of the chain instead of burning DB-CPU for results no one reads.
|
|
185
185
|
const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
|
|
186
|
+
const systemDb = isSystem && db ? createUncheckedSystemDb(db) : undefined;
|
|
186
187
|
// Unbound pool, tenant-scoped like `db` but never tx-bound — writes
|
|
187
188
|
// through it survive a rollback of the handler's own transaction. No
|
|
188
189
|
// AbortSignal here: a client disconnect must not abort a durability write
|
|
@@ -573,6 +574,7 @@ export async function buildHandlerContext(
|
|
|
573
574
|
registry,
|
|
574
575
|
db,
|
|
575
576
|
dbOutsideTransaction,
|
|
577
|
+
...(systemDb && { systemDb }),
|
|
576
578
|
log,
|
|
577
579
|
notify,
|
|
578
580
|
...(config && { config }),
|
|
@@ -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
|
}
|
|
@@ -43,6 +43,16 @@ export type FeatureSchema = {
|
|
|
43
43
|
// Fallback erhalten damit alte clientSchema-Files (vor AppSchema)
|
|
44
44
|
// ohne Migration weiter laufen — toAppSchema() hebt die Liste hoch.
|
|
45
45
|
readonly workspaces?: readonly WorkspaceSchema[];
|
|
46
|
+
// True only when the server confirmed at boot that no SearchAdapter is
|
|
47
|
+
// wired on context.searchAdapter — mirrors the global (not per-entity)
|
|
48
|
+
// check behind api/server.ts's boot warning (#2051). Duplicated
|
|
49
|
+
// identically across every feature purely so it threads through the
|
|
50
|
+
// existing per-feature prop chain into screen renderers (kumiko-screen.tsx)
|
|
51
|
+
// without a separate app-level plumbing path. Omitted for schemas that
|
|
52
|
+
// don't flow through buildAppSchema() (hand-authored fixtures, legacy
|
|
53
|
+
// toAppSchema()) — treated as "not missing" so search bars keep rendering
|
|
54
|
+
// exactly as before this flag existed (#2062).
|
|
55
|
+
readonly searchAdapterMissing?: boolean;
|
|
46
56
|
};
|
|
47
57
|
|
|
48
58
|
// A content collection as it reaches the client: the declaration plus the
|
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,
|