@cosmicdrift/kumiko-framework 0.270.0 → 0.273.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/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +91 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +51 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +67 -2
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +71 -19
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
HandlerRef,
|
|
24
24
|
NameOrRef,
|
|
25
25
|
QueryHandlerFn,
|
|
26
|
-
|
|
26
|
+
RateLimitDeclaration,
|
|
27
27
|
RelationDefinition,
|
|
28
28
|
StreamHandlerFn,
|
|
29
29
|
WriteHandlerFn,
|
|
@@ -124,7 +124,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
124
124
|
handler?: WriteHandlerFn<z.infer<TSchema>>,
|
|
125
125
|
options?: {
|
|
126
126
|
access: AccessRule;
|
|
127
|
-
rateLimit?:
|
|
127
|
+
rateLimit?: RateLimitDeclaration;
|
|
128
128
|
description?: string;
|
|
129
129
|
agent?: AgentHandlerHints;
|
|
130
130
|
escapeHatch?: EscapeHatchDeclaration;
|
|
@@ -181,7 +181,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
181
181
|
handler?: QueryHandlerFn<z.infer<TSchema>>,
|
|
182
182
|
options?: {
|
|
183
183
|
access: AccessRule;
|
|
184
|
-
rateLimit?:
|
|
184
|
+
rateLimit?: RateLimitDeclaration;
|
|
185
185
|
outputSchema?: ZodType;
|
|
186
186
|
description?: string;
|
|
187
187
|
agent?: AgentHandlerHints;
|
|
@@ -230,7 +230,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
230
230
|
handler?: StreamHandlerFn<z.infer<TSchema>>,
|
|
231
231
|
options?: {
|
|
232
232
|
access: AccessRule;
|
|
233
|
-
rateLimit?:
|
|
233
|
+
rateLimit?: RateLimitDeclaration;
|
|
234
234
|
escapeHatch?: EscapeHatchDeclaration;
|
|
235
235
|
},
|
|
236
236
|
): HandlerRef {
|
package/src/engine/index.ts
CHANGED
|
@@ -374,7 +374,12 @@ export type {
|
|
|
374
374
|
EntityListScreenDefinition,
|
|
375
375
|
EntityRef,
|
|
376
376
|
EntityRelations,
|
|
377
|
+
EscapeHatchAuditSink,
|
|
377
378
|
EscapeHatchDeclaration,
|
|
379
|
+
EscapeHatchKind,
|
|
380
|
+
EscapeHatchReporter,
|
|
381
|
+
EscapeHatchTarget,
|
|
382
|
+
EscapeHatchUseEvent,
|
|
378
383
|
EventDef,
|
|
379
384
|
FeatureDefinition,
|
|
380
385
|
FeatureRegistrar,
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
PreSaveHookFn,
|
|
26
26
|
ReferenceFieldDef,
|
|
27
27
|
} from "./types";
|
|
28
|
-
import { HookPhases } from "./types";
|
|
28
|
+
import { HookPhases, isRateLimitDisabled } from "./types";
|
|
29
29
|
|
|
30
30
|
function allHandlerQns(state: RegistryState): ReadonlySet<string> {
|
|
31
31
|
return new Set([...state.writeHandlerMap.keys(), ...state.queryHandlerMap.keys()]);
|
|
@@ -721,10 +721,18 @@ export function validateExtensionUsageTargets(state: RegistryState): void {
|
|
|
721
721
|
export function computeHasRateLimitedHandler(state: RegistryState): void {
|
|
722
722
|
// Pre-compute: any handler with a rateLimit option? Keeps the boot
|
|
723
723
|
// path able to short-circuit the RateLimitResolver wiring (and its
|
|
724
|
-
// Lua-script registration on Redis) when nobody opted in.
|
|
724
|
+
// Lua-script registration on Redis) when nobody opted in. Deliberately
|
|
725
|
+
// stays limited to explicit, non-disabled `rateLimit` declarations —
|
|
726
|
+
// `{ disabled: true }` must not auto-wire a resolver for an app that only
|
|
727
|
+
// opted OUT, and fw#2861's systemScope default only fires once a resolver
|
|
728
|
+
// already exists, so it must not force wantsL3 either.
|
|
725
729
|
state.hasRateLimitedHandlerCached = (() => {
|
|
726
|
-
for (const h of state.writeHandlerMap.values())
|
|
727
|
-
|
|
730
|
+
for (const h of state.writeHandlerMap.values()) {
|
|
731
|
+
if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
|
|
732
|
+
}
|
|
733
|
+
for (const h of state.queryHandlerMap.values()) {
|
|
734
|
+
if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
|
|
735
|
+
}
|
|
728
736
|
return false;
|
|
729
737
|
})();
|
|
730
738
|
}
|
|
@@ -380,6 +380,12 @@ export function buildInsertSchema(
|
|
|
380
380
|
shape[name] = isRequired || hasDefault ? zodField : zodField.optional();
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
// Caller-chosen aggregate id for idempotent creates (deterministic-id
|
|
384
|
+
// patterns). Accepting it here only lets it survive parsing — the create
|
|
385
|
+
// handler still honors it only for a system-identity caller (jobs/hooks
|
|
386
|
+
// via createSystemUser), never for an ordinary HTTP-authenticated user.
|
|
387
|
+
shape["id"] = z.uuid().optional();
|
|
388
|
+
|
|
383
389
|
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
384
390
|
}
|
|
385
391
|
|
|
@@ -11,10 +11,62 @@ import type {
|
|
|
11
11
|
FormatSpec,
|
|
12
12
|
ListColumnSpec,
|
|
13
13
|
ProjectionDetailScreenDefinition,
|
|
14
|
+
RowAction,
|
|
14
15
|
ScreenDefinition,
|
|
15
16
|
SecretMintScreenDefinition,
|
|
17
|
+
ToolbarAction,
|
|
16
18
|
} from "./types/screen";
|
|
17
19
|
|
|
20
|
+
// Every screen type carrying `listScreenId` declares it identically; centralizing
|
|
21
|
+
// here keeps the switch the only place that grows for a new screen type.
|
|
22
|
+
export function explicitListScreenId(screen: ScreenDefinition): string | undefined {
|
|
23
|
+
switch (screen.type) {
|
|
24
|
+
case "custom":
|
|
25
|
+
case "projectionDetail":
|
|
26
|
+
case "entityEdit":
|
|
27
|
+
case "actionForm":
|
|
28
|
+
case "secretMint":
|
|
29
|
+
return screen.listScreenId;
|
|
30
|
+
default:
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shared by the renderer breadcrumb and the boot-validator so both resolve
|
|
36
|
+
// parents the same way; `getId` lets each caller normalize `screen.id`.
|
|
37
|
+
export function resolveNavParentScreen(
|
|
38
|
+
screens: readonly ScreenDefinition[],
|
|
39
|
+
detail: ScreenDefinition,
|
|
40
|
+
getId: (screen: ScreenDefinition) => string,
|
|
41
|
+
): ScreenDefinition | undefined {
|
|
42
|
+
const detailScreenId = getId(detail);
|
|
43
|
+
|
|
44
|
+
const listFromExplicit = ((): ScreenDefinition | undefined => {
|
|
45
|
+
const explicitId = explicitListScreenId(detail);
|
|
46
|
+
return explicitId !== undefined ? screens.find((s) => getId(s) === explicitId) : undefined;
|
|
47
|
+
})();
|
|
48
|
+
|
|
49
|
+
// rowActions, toolbarActions, and drawer-kind actions (which mount inline
|
|
50
|
+
// and have no page of their own) all count as "reached from" this list.
|
|
51
|
+
const navigatesToDetail = (
|
|
52
|
+
actions: readonly (RowAction | ToolbarAction)[] | undefined,
|
|
53
|
+
): boolean =>
|
|
54
|
+
(actions ?? []).some(
|
|
55
|
+
(a) => (a.kind === "navigate" || a.kind === "drawer") && a.screen === detailScreenId,
|
|
56
|
+
);
|
|
57
|
+
const listFromRowAction = screens.find((s) => {
|
|
58
|
+
if (s.type !== "entityList" && s.type !== "projectionList") return false;
|
|
59
|
+
return navigatesToDetail(s.rowActions) || navigatesToDetail(s.toolbarActions);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const listFromEntity =
|
|
63
|
+
detail.type === "entityEdit"
|
|
64
|
+
? screens.find((s) => s.type === "entityList" && s.entity === detail.entity)
|
|
65
|
+
: undefined;
|
|
66
|
+
|
|
67
|
+
return listFromExplicit ?? listFromRowAction ?? listFromEntity;
|
|
68
|
+
}
|
|
69
|
+
|
|
18
70
|
export type EditLayoutScreen =
|
|
19
71
|
| ProjectionDetailScreenDefinition
|
|
20
72
|
| EntityEditScreenDefinition
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DbRunner } from "../../db/connection";
|
|
2
|
+
import { type TenantDb, unsafeRawForDeclaredStep } from "../../db/tenant-db";
|
|
3
|
+
import type { PipelineCtx } from "../types/step";
|
|
4
|
+
|
|
5
|
+
// unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
|
|
6
|
+
export function readSourceFor(
|
|
7
|
+
ctx: PipelineCtx,
|
|
8
|
+
unsafeAllTenants: { readonly reason: string } | undefined,
|
|
9
|
+
): TenantDb | DbRunner {
|
|
10
|
+
if (!unsafeAllTenants) return ctx.db;
|
|
11
|
+
return unsafeRawForDeclaredStep(ctx.systemDb ?? ctx.db, unsafeAllTenants.reason);
|
|
12
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// r.step.read.findMany — load multiple rows from a projection table.
|
|
2
2
|
//
|
|
3
|
-
// Sibling to read.findOne — same tenant-
|
|
3
|
+
// Sibling to read.findOne — same tenant-filtering: own tenant +
|
|
4
|
+
// SYSTEM_TENANT_ID reference rows by default, `unsafeAllTenants: { reason }`
|
|
5
|
+
// plus `escapeHatch: { reason }` on the handler to read across tenants.
|
|
4
6
|
// Resolves to a row-array (possibly empty), landed under steps.<name>.
|
|
5
7
|
//
|
|
6
8
|
// Optional `limit` — defaults to no-limit (caller-chosen, NOT a
|
|
@@ -11,6 +13,7 @@
|
|
|
11
13
|
import { selectMany, type WhereObject } from "../../db/query";
|
|
12
14
|
import { defineStep } from "../define-step";
|
|
13
15
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
16
|
+
import { readSourceFor } from "./_read-source";
|
|
14
17
|
import { resolveOptional } from "./_resolver-utils";
|
|
15
18
|
|
|
16
19
|
type ReadFindManyArgs = {
|
|
@@ -18,6 +21,7 @@ type ReadFindManyArgs = {
|
|
|
18
21
|
readonly table: unknown;
|
|
19
22
|
readonly where?: StepResolver<WhereObject | undefined>;
|
|
20
23
|
readonly limit?: number;
|
|
24
|
+
readonly unsafeAllTenants?: { readonly reason: string };
|
|
21
25
|
};
|
|
22
26
|
|
|
23
27
|
defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
|
|
@@ -26,8 +30,9 @@ defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
|
|
|
26
30
|
resultKey: (args) => args.name,
|
|
27
31
|
run: async (args, ctx: PipelineCtx) => {
|
|
28
32
|
const where = resolveOptional(args.where, ctx);
|
|
33
|
+
const source = readSourceFor(ctx, args.unsafeAllTenants);
|
|
29
34
|
const rows = await selectMany(
|
|
30
|
-
|
|
35
|
+
source,
|
|
31
36
|
args.table,
|
|
32
37
|
where,
|
|
33
38
|
args.limit !== undefined ? { limit: args.limit } : undefined,
|
|
@@ -42,6 +47,7 @@ export function buildReadFindManyStep(
|
|
|
42
47
|
readonly table: unknown;
|
|
43
48
|
readonly where?: StepResolver<WhereObject | undefined>;
|
|
44
49
|
readonly limit?: number;
|
|
50
|
+
readonly unsafeAllTenants?: { readonly reason: string };
|
|
45
51
|
},
|
|
46
52
|
): StepInstance {
|
|
47
53
|
return {
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// r.step.read.findOne — load a single row from a projection table.
|
|
2
2
|
//
|
|
3
3
|
// Thin wrapper on selectMany(db, table, where, { limit: 1 }) (bun-db).
|
|
4
|
-
// Resolves to the first row or null. Tenant-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// who pass an explicit tenantId.
|
|
4
|
+
// Resolves to the first row or null. Tenant-filtered like ctx.db
|
|
5
|
+
// method-form reads (own tenant + SYSTEM_TENANT_ID reference rows); a
|
|
6
|
+
// foreign `where.tenantId` is narrowed to the caller's own scope.
|
|
7
|
+
// Cross-tenant reads require `unsafeAllTenants: { reason }` here AND
|
|
8
|
+
// `escapeHatch: { reason }` on the handler — reported as an "unsafe-raw"
|
|
9
|
+
// escape-hatch audit event.
|
|
11
10
|
//
|
|
12
11
|
// Use when a subsequent step needs a row from the read-side. For
|
|
13
12
|
// cross-feature reads, prefer `r.step.callFeature(...)` (M.2) so the
|
|
@@ -23,12 +22,14 @@
|
|
|
23
22
|
import { selectMany, type WhereObject } from "../../db/query";
|
|
24
23
|
import { defineStep } from "../define-step";
|
|
25
24
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
25
|
+
import { readSourceFor } from "./_read-source";
|
|
26
26
|
import { resolveRequired } from "./_resolver-utils";
|
|
27
27
|
|
|
28
28
|
type ReadFindOneArgs = {
|
|
29
29
|
readonly name: string;
|
|
30
30
|
readonly table: unknown;
|
|
31
31
|
readonly where: StepResolver<WhereObject | undefined>;
|
|
32
|
+
readonly unsafeAllTenants?: { readonly reason: string };
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
|
|
@@ -37,7 +38,8 @@ defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
|
|
|
37
38
|
resultKey: (args) => args.name,
|
|
38
39
|
run: async (args, ctx: PipelineCtx) => {
|
|
39
40
|
const where = resolveRequired(args.where, ctx);
|
|
40
|
-
const
|
|
41
|
+
const source = readSourceFor(ctx, args.unsafeAllTenants);
|
|
42
|
+
const rows = await selectMany(source, args.table, where, { limit: 1 });
|
|
41
43
|
return (rows[0] as Record<string, unknown> | undefined) ?? null;
|
|
42
44
|
},
|
|
43
45
|
});
|
|
@@ -47,6 +49,7 @@ export function buildReadFindOneStep(
|
|
|
47
49
|
opts: {
|
|
48
50
|
readonly table: unknown;
|
|
49
51
|
readonly where: StepResolver<WhereObject | undefined>;
|
|
52
|
+
readonly unsafeAllTenants?: { readonly reason: string };
|
|
50
53
|
},
|
|
51
54
|
): StepInstance {
|
|
52
55
|
return {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
20
20
|
import { deleteMany, type WhereObject } from "../../db/query";
|
|
21
|
+
import { tenantDbRunner } from "../../db/tenant-db-runner";
|
|
21
22
|
import { defineStep } from "../define-step";
|
|
22
23
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
23
24
|
import { resolveRequired } from "./_resolver-utils";
|
|
@@ -40,7 +41,7 @@ defineStep<UnsafeProjectionDeleteArgs, void>({
|
|
|
40
41
|
// The `unsafe*`-step IS the sanctioned direct projection write — it exists
|
|
41
42
|
// to bypass the executor deliberately (boot-validated allowlist + reviewer
|
|
42
43
|
// gate). The brand-strip cast makes that explicit at the one blessed seam.
|
|
43
|
-
await deleteMany(ctx.db
|
|
44
|
+
await deleteMany(tenantDbRunner(ctx.db), args.table as EntityTableMeta, where);
|
|
44
45
|
},
|
|
45
46
|
});
|
|
46
47
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
15
15
|
import { extractTableName } from "../../db";
|
|
16
16
|
import { executeRawQuery } from "../../db/queries/raw-sql";
|
|
17
|
+
import { tenantDbRunner } from "../../db/tenant-db-runner";
|
|
17
18
|
import { defineStep } from "../define-step";
|
|
18
19
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
19
20
|
import { resolveRequired } from "./_resolver-utils";
|
|
@@ -78,7 +79,7 @@ defineStep<UnsafeProjectionUpsertArgs, void>({
|
|
|
78
79
|
`INSERT INTO ${quoteIdent(tableName)} (${colNames.join(", ")}) VALUES (${placeholders.join(", ")}) ` +
|
|
79
80
|
`ON CONFLICT (${conflictCols}) DO UPDATE SET ${setClauses.join(", ")}`;
|
|
80
81
|
|
|
81
|
-
await executeRawQuery(ctx.db
|
|
82
|
+
await executeRawQuery(tenantDbRunner(ctx.db), sqlText, params);
|
|
82
83
|
},
|
|
83
84
|
});
|
|
84
85
|
|
|
@@ -159,7 +159,12 @@ export type {
|
|
|
159
159
|
DeclarativeEventMigration,
|
|
160
160
|
DispatchWriteRef,
|
|
161
161
|
EntityRef,
|
|
162
|
+
EscapeHatchAuditSink,
|
|
162
163
|
EscapeHatchDeclaration,
|
|
164
|
+
EscapeHatchKind,
|
|
165
|
+
EscapeHatchReporter,
|
|
166
|
+
EscapeHatchTarget,
|
|
167
|
+
EscapeHatchUseEvent,
|
|
163
168
|
EventDef,
|
|
164
169
|
EventMigrationDef,
|
|
165
170
|
EventPiiFields,
|
|
@@ -184,6 +189,8 @@ export type {
|
|
|
184
189
|
QueryEvent,
|
|
185
190
|
QueryHandlerDef,
|
|
186
191
|
QueryHandlerFn,
|
|
192
|
+
RateLimitDeclaration,
|
|
193
|
+
RateLimitDisabled,
|
|
187
194
|
RateLimitOption,
|
|
188
195
|
RateLimitPer,
|
|
189
196
|
SessionUser,
|
|
@@ -198,7 +205,7 @@ export type {
|
|
|
198
205
|
WriteHandlerFn,
|
|
199
206
|
WriteResult,
|
|
200
207
|
} from "@cosmicdrift/kumiko-types/handlers";
|
|
201
|
-
export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
|
|
208
|
+
export { isOpenToAllGranted, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
202
209
|
export type {
|
|
203
210
|
DeleteContext,
|
|
204
211
|
EntityHookMap,
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -113,6 +113,20 @@ identity_switch_denied:
|
|
|
113
113
|
(oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
|
|
114
114
|
oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
|
|
115
115
|
|
|
116
|
+
unsafe_all_tenants_denied:
|
|
117
|
+
endUser: |
|
|
118
|
+
Diese Aktion ist nicht erlaubt.
|
|
119
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
120
|
+
developer: |
|
|
121
|
+
`AccessDeniedError`: ein Handler oder Hook hat
|
|
122
|
+
`ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })`
|
|
123
|
+
aufgerufen, ohne `r.systemScope()`-Handler zu sein oder
|
|
124
|
+
`escapeHatch: { reason }` zu deklarieren.
|
|
125
|
+
|
|
126
|
+
Deklariere `escapeHatch: { reason: "..." }` am Write-, Query- oder
|
|
127
|
+
Stream-Handler (oder am `r.hook(...)`-Aufruf, wenn der Read in einem Hook
|
|
128
|
+
passiert), oder verlagere die Logik in ein `r.systemScope()`-Feature.
|
|
129
|
+
|
|
116
130
|
member_resolution_denied:
|
|
117
131
|
endUser: |
|
|
118
132
|
Diese Aktion ist nicht erlaubt.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -109,6 +109,19 @@ identity_switch_denied:
|
|
|
109
109
|
(or on the `r.hook(...)` call, if the switch happens inside a hook), or
|
|
110
110
|
move the logic into an `r.systemScope()` feature or a job.
|
|
111
111
|
|
|
112
|
+
unsafe_all_tenants_denied:
|
|
113
|
+
endUser: |
|
|
114
|
+
This action isn't allowed.
|
|
115
|
+
Please contact an administrator if you believe this is a mistake.
|
|
116
|
+
developer: |
|
|
117
|
+
`AccessDeniedError`: a handler or hook called
|
|
118
|
+
`ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })` without
|
|
119
|
+
being an `r.systemScope()` handler or declaring `escapeHatch: { reason }`.
|
|
120
|
+
|
|
121
|
+
Declare `escapeHatch: { reason: "..." }` on the write, query or stream
|
|
122
|
+
handler (or on the `r.hook(...)` call, if the read happens inside a
|
|
123
|
+
hook), or move the logic into an `r.systemScope()` feature.
|
|
124
|
+
|
|
112
125
|
member_resolution_denied:
|
|
113
126
|
endUser: |
|
|
114
127
|
This action isn't allowed.
|
package/src/errors/reasons.ts
CHANGED
|
@@ -48,6 +48,10 @@ export const FrameworkReasons = {
|
|
|
48
48
|
// claims, origin or extra roles) without r.systemScope() or escapeHatch.
|
|
49
49
|
identitySwitchDenied: "identity_switch_denied",
|
|
50
50
|
|
|
51
|
+
// AccessDeniedError: a handler/hook called ctx.queryProjection(..., { unsafeAllTenants: true })
|
|
52
|
+
// without an r.systemScope() feature or a declared escapeHatch.
|
|
53
|
+
unsafeAllTenantsDenied: "unsafe_all_tenants_denied",
|
|
54
|
+
|
|
51
55
|
// AccessDeniedError: ctx.queryAsMember's userId is not an active member of
|
|
52
56
|
// ctx's tenant. Deliberately generic — a caller can't probe which check failed.
|
|
53
57
|
memberResolutionDenied: "member_resolution_denied",
|
|
@@ -196,7 +196,9 @@ export function requiredKeysFromScreen(
|
|
|
196
196
|
const form = screen as ActionFormScreenDefinition;
|
|
197
197
|
pushKey(out, form.submitLabel);
|
|
198
198
|
for (const fieldName of Object.keys(form.fields)) {
|
|
199
|
-
|
|
199
|
+
const override = form.fieldLabels?.[fieldName];
|
|
200
|
+
if (override !== undefined) pushKey(out, override);
|
|
201
|
+
else out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
200
202
|
}
|
|
201
203
|
for (const section of form.layout.sections) {
|
|
202
204
|
if (isExtensionEditSection(section)) {
|
|
@@ -207,7 +209,9 @@ export function requiredKeysFromScreen(
|
|
|
207
209
|
pushKey(out, section.title);
|
|
208
210
|
for (const f of section.fields) {
|
|
209
211
|
const fieldName = editFieldName(f);
|
|
210
|
-
|
|
212
|
+
const override = form.fieldLabels?.[fieldName];
|
|
213
|
+
if (override !== undefined) pushKey(out, override);
|
|
214
|
+
else out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
211
215
|
}
|
|
212
216
|
}
|
|
213
217
|
break;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
3
|
+
import { tenantDbRunner } from "../../db/tenant-db-runner";
|
|
3
4
|
import { createRegistry, defineFeature, type Registry } from "../../engine";
|
|
4
5
|
import { createTestRedis, type TestRedis, testTenantId } from "../../stack";
|
|
5
6
|
import { waitFor } from "../../testing";
|
|
@@ -12,9 +13,9 @@ import { createJobRunner, type JobRunner } from "../job-runner";
|
|
|
12
13
|
type JobRunResult = {
|
|
13
14
|
readonly name: "system" | "tenant" | "system-per-tenant";
|
|
14
15
|
readonly present: boolean;
|
|
15
|
-
// assertTenantMatch() must return a TenantDb
|
|
16
|
-
// underlying DbConnection ctx.db carries — proves systemDb is
|
|
17
|
-
// the job's own tenant-scoped db, not a separate instance.
|
|
16
|
+
// assertTenantMatch() must return a TenantDb bound (via tenantDbRunner) to
|
|
17
|
+
// the SAME underlying DbConnection ctx.db carries — proves systemDb is
|
|
18
|
+
// bound to the job's own tenant-scoped db, not a separate instance.
|
|
18
19
|
readonly boundToRawDb: boolean | undefined;
|
|
19
20
|
// A foreign tenantId must throw fail-closed (AccessDeniedError), same as
|
|
20
21
|
// the HandlerContext.systemDb self-check.
|
|
@@ -48,7 +49,7 @@ const systemScopedFeature = defineFeature("jobsystemdb-system", (r) => {
|
|
|
48
49
|
results.push({
|
|
49
50
|
name: "system",
|
|
50
51
|
present: true,
|
|
51
|
-
boundToRawDb: checked
|
|
52
|
+
boundToRawDb: tenantDbRunner(checked) === ctx.db,
|
|
52
53
|
foreignTenantThrew,
|
|
53
54
|
});
|
|
54
55
|
});
|
|
@@ -31,6 +31,10 @@ let redisUrl: string;
|
|
|
31
31
|
// Track which jobs ran and when
|
|
32
32
|
const jobLog: Array<{ name: string; payload: Record<string, unknown>; timestamp: number }> = [];
|
|
33
33
|
|
|
34
|
+
// Counts eventRetrySucceeds' own attempts — throws once on attempt 1, then
|
|
35
|
+
// succeeds, so a real retry must have happened for the job to ever reach 2.
|
|
36
|
+
let eventRetryAttemptCount = 0;
|
|
37
|
+
|
|
34
38
|
function clearLog() {
|
|
35
39
|
jobLog.length = 0;
|
|
36
40
|
}
|
|
@@ -140,6 +144,25 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
140
144
|
},
|
|
141
145
|
);
|
|
142
146
|
|
|
147
|
+
r.writeHandler(
|
|
148
|
+
"retry-event-trigger",
|
|
149
|
+
z.object({ n: z.number() }),
|
|
150
|
+
async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
|
|
151
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
152
|
+
);
|
|
153
|
+
// Event-triggered job with retries — proves handleEvent passes
|
|
154
|
+
// retries/backoff to the queue the same way dispatch() does (fw
|
|
155
|
+
// job-runner retries regression, solon).
|
|
156
|
+
r.job(
|
|
157
|
+
"eventRetrySucceeds",
|
|
158
|
+
{ trigger: { on: "test:write:retry-event-trigger" }, retries: 2 },
|
|
159
|
+
async (payload) => {
|
|
160
|
+
eventRetryAttemptCount += 1;
|
|
161
|
+
jobLog.push({ name: "test:job:event-retry-succeeds", payload, timestamp: Date.now() });
|
|
162
|
+
if (eventRetryAttemptCount === 1) throw new Error("fails on first attempt");
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
143
166
|
// Job that fails
|
|
144
167
|
r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
|
|
145
168
|
throw new Error("intentional failure");
|
|
@@ -1197,6 +1220,24 @@ describe("handleEvent maxPerTenant", () => {
|
|
|
1197
1220
|
});
|
|
1198
1221
|
});
|
|
1199
1222
|
|
|
1223
|
+
describe("handleEvent retries", () => {
|
|
1224
|
+
test("an event-triggered job with retries retries on failure and eventually succeeds", async () => {
|
|
1225
|
+
clearLog();
|
|
1226
|
+
eventRetryAttemptCount = 0;
|
|
1227
|
+
await withRunner(async (runner) => {
|
|
1228
|
+
const user = { ...TestUsers.admin, tenantId: "retry-tenant-evt" };
|
|
1229
|
+
await runner.handleEvent("test:write:retry-event-trigger", { n: 1 }, user);
|
|
1230
|
+
// Without retries/backoff reaching the queue, the job fails on
|
|
1231
|
+
// attempt 1 and BullMQ never retries — this would time out.
|
|
1232
|
+
await waitFor(() => {
|
|
1233
|
+
const entries = jobLog.filter((e) => e.name === "test:job:event-retry-succeeds");
|
|
1234
|
+
expect(entries.length).toBe(2);
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
expect(eventRetryAttemptCount).toBe(2);
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1200
1241
|
// --- Registry ---
|
|
1201
1242
|
|
|
1202
1243
|
describe("job registry", () => {
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type AppContext,
|
|
10
10
|
type DispatchWriteRef,
|
|
11
11
|
type JobContext,
|
|
12
|
+
type JobDefinition,
|
|
12
13
|
type JobRunIn,
|
|
13
14
|
type MemberReader,
|
|
14
15
|
type Registry,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
type Tracer,
|
|
28
29
|
} from "../observability";
|
|
29
30
|
import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
|
|
31
|
+
import { createEscapeHatchReporter } from "../pipeline/escape-hatch-report";
|
|
30
32
|
import { RedisKeys } from "../pipeline/redis-keys";
|
|
31
33
|
import { bridgeStub } from "../testing/handler-context";
|
|
32
34
|
|
|
@@ -251,6 +253,16 @@ function timeoutReject(
|
|
|
251
253
|
};
|
|
252
254
|
}
|
|
253
255
|
|
|
256
|
+
// Shared by dispatch() and handleEvent() — an event-triggered job must retry
|
|
257
|
+
// on failure the same way a directly-dispatched one does; a duplicated
|
|
258
|
+
// inline computation in handleEvent previously dropped both options.
|
|
259
|
+
function buildRetryBullOpts(jobDef: JobDefinition): Record<string, unknown> {
|
|
260
|
+
const opts: Record<string, unknown> = {};
|
|
261
|
+
if (jobDef.retries !== undefined) opts["attempts"] = jobDef.retries + 1;
|
|
262
|
+
if (jobDef.backoff) opts["backoff"] = { type: jobDef.backoff };
|
|
263
|
+
return opts;
|
|
264
|
+
}
|
|
265
|
+
|
|
254
266
|
export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
255
267
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
256
268
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
@@ -282,12 +294,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
282
294
|
return async () => {
|
|
283
295
|
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
284
296
|
const systemModeDb = createTenantDb(db, SYSTEM_TENANT_ID, "system");
|
|
297
|
+
const reportEscapeHatch = createEscapeHatchReporter({
|
|
298
|
+
handler: ACTIVE_TENANT_IDS_QUERY_NAME,
|
|
299
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
300
|
+
actor: systemUser.id,
|
|
301
|
+
sink: context._escapeHatchAuditSink,
|
|
302
|
+
log: context.log,
|
|
303
|
+
});
|
|
285
304
|
const result = await handler.handler(
|
|
286
305
|
{ type: ACTIVE_TENANT_IDS_QUERY_NAME, payload: {}, user: systemUser },
|
|
287
306
|
{
|
|
288
307
|
db: systemModeDb,
|
|
289
308
|
dbOutsideTransaction: systemModeDb,
|
|
290
|
-
systemDb: createUncheckedSystemDb(systemModeDb),
|
|
309
|
+
systemDb: createUncheckedSystemDb(systemModeDb, undefined, reportEscapeHatch),
|
|
291
310
|
registry,
|
|
292
311
|
...bridgeStub(),
|
|
293
312
|
},
|
|
@@ -533,7 +552,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
533
552
|
const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
|
|
534
553
|
const isSystemJob = registry.isJobSystemScoped(jobName);
|
|
535
554
|
const systemDb =
|
|
536
|
-
isSystemJob && tenantScopedDb
|
|
555
|
+
isSystemJob && tenantScopedDb
|
|
556
|
+
? createUncheckedSystemDb(
|
|
557
|
+
tenantScopedDb,
|
|
558
|
+
undefined,
|
|
559
|
+
createEscapeHatchReporter({
|
|
560
|
+
handler: jobName,
|
|
561
|
+
tenantId,
|
|
562
|
+
actor: jobSystemUser.id,
|
|
563
|
+
sink: context._escapeHatchAuditSink,
|
|
564
|
+
log: context.log,
|
|
565
|
+
}),
|
|
566
|
+
)
|
|
567
|
+
: undefined;
|
|
537
568
|
const config =
|
|
538
569
|
context._configAccessorFactory && tenantScopedDb
|
|
539
570
|
? context._configAccessorFactory({
|
|
@@ -883,8 +914,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
883
914
|
break;
|
|
884
915
|
}
|
|
885
916
|
|
|
886
|
-
|
|
887
|
-
if (jobDef.backoff) bullOpts["backoff"] = { type: jobDef.backoff };
|
|
917
|
+
Object.assign(bullOpts, buildRetryBullOpts(jobDef));
|
|
888
918
|
if (jobDef.timeout) bullOpts["timeout"] = jobDef.timeout;
|
|
889
919
|
if (meta?.priority !== undefined) bullOpts["priority"] = meta.priority;
|
|
890
920
|
|
|
@@ -948,7 +978,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
948
978
|
}
|
|
949
979
|
// Route to the job's declared lane, not a fixed queue — that's
|
|
950
980
|
// the whole reason both queues are held.
|
|
951
|
-
await queues[laneForJob(jobDef)].add(name, data);
|
|
981
|
+
await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
|
|
952
982
|
}
|
|
953
983
|
},
|
|
954
984
|
attachDispatcher(ref: DispatchWriteRef): void {
|