@cosmicdrift/kumiko-framework 0.336.1 → 0.337.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/dist/api/auth-middleware.js +43 -19
- package/dist/api/auth-routes.d.ts +1 -0
- package/dist/api/auth-routes.js +20 -0
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +4 -5
- package/dist/api/server.js +3 -3
- package/dist/api/sse-route.js +14 -1
- package/dist/bun-db/query.js +6 -4
- package/dist/crypto/kek-source.js +1 -0
- package/dist/db/blind-index-cleanup.js +1 -1
- package/dist/db/event-store-executor-read.js +15 -4
- package/dist/db/queries/event-consumer.d.ts +2 -2
- package/dist/db/queries/event-consumer.js +5 -4
- package/dist/db/queries/event-store.js +6 -6
- package/dist/db/queries/projection-rebuild.js +4 -4
- package/dist/db/queries/seed-context.js +6 -6
- package/dist/derivatives/derivatives-context.js +5 -2
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +13 -0
- package/dist/engine/boot-validator/screens.js +12 -0
- package/dist/engine/build-config-feature-schema.js +4 -0
- package/dist/engine/define-feature.js +1 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/jobs-routes.js +6 -2
- package/dist/engine/feature-ast/extractors/round3.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round3.js +11 -0
- package/dist/engine/feature-ast/extractors/shared.js +1 -3
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +3 -0
- package/dist/engine/feature-ast/patch.js +8 -11
- package/dist/engine/feature-ast/patcher.d.ts +6 -1
- package/dist/engine/feature-ast/patcher.js +3 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +590 -1
- package/dist/engine/feature-ast/pattern-change-schema.js +17 -2
- package/dist/engine/feature-ast/patterns.d.ts +8 -2
- package/dist/engine/feature-ast/patterns.js +1 -0
- package/dist/engine/feature-ast/render.js +9 -0
- package/dist/engine/feature-builder-state.d.ts +2 -1
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-config-events-jobs.d.ts +4 -1
- package/dist/engine/feature-config-events-jobs.js +20 -0
- package/dist/engine/feature-ui-extensions.js +15 -21
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +47 -4
- package/dist/engine/registry-facade.js +7 -0
- package/dist/engine/registry-ingest.js +7 -0
- package/dist/engine/registry-state.d.ts +2 -1
- package/dist/engine/registry-state.js +1 -0
- package/dist/engine/registry-validate.d.ts +1 -1
- package/dist/engine/registry-validate.js +39 -17
- package/dist/engine/registry.js +2 -2
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/errors/classes.d.ts +5 -0
- package/dist/errors/classes.js +15 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/i18n/required-surface-keys.js +3 -5
- package/dist/migrations/pending-rebuilds.d.ts +2 -0
- package/dist/migrations/pending-rebuilds.js +3 -0
- package/dist/pipeline/dispatch-query.js +3 -5
- package/dist/pipeline/dispatch-stream.js +2 -4
- package/dist/pipeline/dispatch-write.js +2 -4
- package/dist/pipeline/event-dispatcher-delivery.d.ts +0 -1
- package/dist/pipeline/event-dispatcher-delivery.js +1 -8
- package/dist/pipeline/event-dispatcher.js +16 -25
- package/dist/pipeline/handler-access-error.d.ts +3 -0
- package/dist/pipeline/handler-access-error.js +17 -0
- package/dist/pipeline/irreversible-operation-gate.js +9 -3
- package/dist/pipeline/member-read-only-transaction.js +8 -3
- package/dist/pipeline/system-hooks.js +7 -1
- package/dist/pipeline/system-identity-switch.js +1 -1
- package/dist/search/meilisearch-adapter.js +0 -2
- package/dist/stack/push-entity-projection-tables.js +5 -3
- package/dist/stack/redis.d.ts +1 -0
- package/dist/stack/redis.js +11 -2
- package/dist/stack/table-helpers.d.ts +3 -1
- package/dist/stack/table-helpers.js +9 -7
- package/dist/stack/test-stack.js +1 -7
- package/dist/testing/handler-context.js +3 -6
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +29 -2
- package/package.json +4 -4
- package/src/changes.json +92 -0
- package/src/scripts/codemod/collect-source-files.ts +48 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +3 -23
- package/src/scripts/codemod/migrate-db-raw.ts +6 -23
- package/src/scripts/codemod/migrate-open-to-all.ts +4 -24
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +47 -3
|
@@ -33,15 +33,29 @@ function middlewareReject(c, opts) {
|
|
|
33
33
|
},
|
|
34
34
|
}, opts.status);
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return middlewareReject(c, {
|
|
36
|
+
function sessionInvalidRejection(reason) {
|
|
37
|
+
return {
|
|
39
38
|
code: "session_invalid",
|
|
40
39
|
status: 401,
|
|
41
40
|
message: `session ${reason}`,
|
|
42
41
|
i18nKey: "auth.errors.sessionInvalid",
|
|
43
42
|
details: { reason },
|
|
44
|
-
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const INVALID_TOKEN_REJECTION = {
|
|
46
|
+
code: "invalid_token",
|
|
47
|
+
status: 401,
|
|
48
|
+
message: "token verification failed",
|
|
49
|
+
i18nKey: "auth.errors.invalidToken",
|
|
50
|
+
};
|
|
51
|
+
// A stale auth cookie (revoked session, rotated signing key) must not lock the
|
|
52
|
+
// browser out of public handlers until the cookie expires, so with
|
|
53
|
+
// anonymousAccess on the request continues anonymously and protected handlers
|
|
54
|
+
// answer 401 unauthenticated in the dispatcher. Bearer clients own their header
|
|
55
|
+
// and keep the explicit reject; /api/auth/* keeps it for the same reason as the
|
|
56
|
+
// missing-token branch.
|
|
57
|
+
function isStaleCookieDowngradable(c, transport, anonymousAccess) {
|
|
58
|
+
return (transport === "cookie" && anonymousAccess !== undefined && !c.req.path.startsWith("/api/auth/"));
|
|
45
59
|
}
|
|
46
60
|
// Extract the JWT from either the kumiko_auth cookie (web) or the
|
|
47
61
|
// Authorization Bearer header (native / server-to-server). The two paths
|
|
@@ -136,17 +150,20 @@ export function authMiddleware(jwt, options = {}) {
|
|
|
136
150
|
return await handleVerifiedBearerUser(c, verifiedUser, next, resolveTenantLifecycleStatus);
|
|
137
151
|
}
|
|
138
152
|
}
|
|
153
|
+
const rejectInvalidCredential = async (rejection) => {
|
|
154
|
+
if (isStaleCookieDowngradable(c, transport, anonymousAccess)) {
|
|
155
|
+
return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, {
|
|
156
|
+
rejectionIfTenantUnresolved: rejection,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return middlewareReject(c, rejection);
|
|
160
|
+
};
|
|
139
161
|
let payload;
|
|
140
162
|
try {
|
|
141
163
|
payload = await jwt.verify(token);
|
|
142
164
|
}
|
|
143
165
|
catch {
|
|
144
|
-
return
|
|
145
|
-
code: "invalid_token",
|
|
146
|
-
status: 401,
|
|
147
|
-
message: "token verification failed",
|
|
148
|
-
i18nKey: "auth.errors.invalidToken",
|
|
149
|
-
});
|
|
166
|
+
return await rejectInvalidCredential(INVALID_TOKEN_REJECTION);
|
|
150
167
|
}
|
|
151
168
|
// Session liveness check — only when both a checker is wired AND the
|
|
152
169
|
// token carries a sid.
|
|
@@ -158,14 +175,14 @@ export function authMiddleware(jwt, options = {}) {
|
|
|
158
175
|
const result = await sessionChecker(payload.jti, payload.sub);
|
|
159
176
|
const status = sessionCheckStatus(result);
|
|
160
177
|
if (status !== "live") {
|
|
161
|
-
return
|
|
178
|
+
return await rejectInvalidCredential(sessionInvalidRejection(status));
|
|
162
179
|
}
|
|
163
180
|
if (typeof result === "object") {
|
|
164
181
|
derivedRoles = result.roles;
|
|
165
182
|
}
|
|
166
183
|
}
|
|
167
184
|
else {
|
|
168
|
-
return
|
|
185
|
+
return await rejectInvalidCredential(sessionInvalidRejection("no_sid"));
|
|
169
186
|
}
|
|
170
187
|
}
|
|
171
188
|
// Tenant-mismatch guard: if the caller sends BOTH a JWT (carrying a
|
|
@@ -254,17 +271,21 @@ async function handleVerifiedBearerUser(c, user, next, resolveTenantLifecycleSta
|
|
|
254
271
|
// 5. Synthesise the anonymous SessionUser. The transport flag stays unset
|
|
255
272
|
// so csrf-middleware skips the double-submit check (no auth-cookie ⇒
|
|
256
273
|
// no CSRF vector to defend against).
|
|
257
|
-
async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
|
|
274
|
+
async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, staleCredential) {
|
|
275
|
+
// A downgraded stale credential whose anonymous tenant can't be resolved or
|
|
276
|
+
// is torn down answers with the credential's own 401: a tenant error would
|
|
277
|
+
// hide the session end from the client.
|
|
278
|
+
const rejectTenant = (tenantError) => middlewareReject(c, staleCredential?.rejectionIfTenantUnresolved ?? tenantError);
|
|
258
279
|
// Step 1+2: parse client-supplied tenant. Reject malformed values before
|
|
259
280
|
// they touch any downstream consumer (DB, cache, audit row).
|
|
260
281
|
const headerRaw = c.req.header(TENANT_HEADER_NAME);
|
|
261
282
|
const cookieRaw = getCookie(c, TENANT_COOKIE_NAME);
|
|
262
283
|
const headerCheck = parseClientTenant(headerRaw, "X-Tenant header");
|
|
263
284
|
if (headerCheck.error)
|
|
264
|
-
return
|
|
285
|
+
return rejectTenant(headerCheck.error);
|
|
265
286
|
const cookieCheck = parseClientTenant(cookieRaw, "kumiko_tenant cookie");
|
|
266
287
|
if (cookieCheck.error)
|
|
267
|
-
return
|
|
288
|
+
return rejectTenant(cookieCheck.error);
|
|
268
289
|
const clientTenant = headerCheck.tenantId !== null
|
|
269
290
|
? { id: headerCheck.tenantId, source: "header" }
|
|
270
291
|
: cookieCheck.tenantId !== null
|
|
@@ -273,12 +294,12 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
|
|
|
273
294
|
// Step 3: pick the authoritative tenant.
|
|
274
295
|
const resolved = await resolveTenant(c, config, clientTenant);
|
|
275
296
|
if ("error" in resolved)
|
|
276
|
-
return
|
|
297
|
+
return rejectTenant(resolved.error);
|
|
277
298
|
// Step 4: existence check for untrusted sources.
|
|
278
299
|
if (resolved.source !== "default" && config.tenantExists) {
|
|
279
300
|
const exists = await config.tenantExists(resolved.tenantId);
|
|
280
301
|
if (!exists) {
|
|
281
|
-
return
|
|
302
|
+
return rejectTenant({
|
|
282
303
|
code: "tenant_not_found",
|
|
283
304
|
status: 404,
|
|
284
305
|
message: `tenant "${resolved.tenantId}" does not exist`,
|
|
@@ -289,8 +310,11 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
|
|
|
289
310
|
}
|
|
290
311
|
// Step 5: synthesise + continue.
|
|
291
312
|
const lifecycleReject = await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
|
|
292
|
-
if (lifecycleReject)
|
|
293
|
-
return
|
|
313
|
+
if (lifecycleReject) {
|
|
314
|
+
return staleCredential
|
|
315
|
+
? middlewareReject(c, staleCredential.rejectionIfTenantUnresolved)
|
|
316
|
+
: lifecycleReject;
|
|
317
|
+
}
|
|
294
318
|
c.set(USER_KEY, createAnonymousUser(resolved.tenantId));
|
|
295
319
|
await next();
|
|
296
320
|
// skip: anonymous path completed — Hono middleware contract returns void
|
|
@@ -19,6 +19,7 @@ export type SessionRevoker = (sid: string) => Promise<void>;
|
|
|
19
19
|
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
20
20
|
export type SessionChecker = AuthSessionChecker;
|
|
21
21
|
export type { AuthSessionStatus };
|
|
22
|
+
export declare function assertSessionCallbacksPaired(auth: Pick<AuthRoutesConfig, "sessionCreator" | "sessionRevoker" | "sessionChecker"> | undefined): void;
|
|
22
23
|
export type AuthRoutesConfig = {
|
|
23
24
|
membershipQuery: string;
|
|
24
25
|
userQuery?: string;
|
package/dist/api/auth-routes.js
CHANGED
|
@@ -159,6 +159,26 @@ function switchTenantRejectionResponse(c, reason) {
|
|
|
159
159
|
return assertUnreachable(reason, "ActiveMembershipRejection");
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
+
// A sid minted by sessionCreator is only revocable when logout can revoke it
|
|
163
|
+
// (sessionRevoker) AND every request re-checks it (sessionChecker). Wiring the
|
|
164
|
+
// first without the other two would make logout look effective while the JWT
|
|
165
|
+
// stays valid until expiry. A checker alone stays allowed: it fails closed.
|
|
166
|
+
export function assertSessionCallbacksPaired(auth) {
|
|
167
|
+
const issuesSessions = auth?.sessionCreator !== undefined || auth?.sessionRevoker !== undefined;
|
|
168
|
+
const missing = issuesSessions
|
|
169
|
+
? [
|
|
170
|
+
...(auth?.sessionCreator === undefined ? ["sessionCreator"] : []),
|
|
171
|
+
...(auth?.sessionRevoker === undefined ? ["sessionRevoker"] : []),
|
|
172
|
+
...(auth?.sessionChecker === undefined ? ["sessionChecker"] : []),
|
|
173
|
+
]
|
|
174
|
+
: [];
|
|
175
|
+
if (missing.length > 0) {
|
|
176
|
+
throw new Error(`[kumiko:boot] auth session callbacks are wired partially — missing ${missing.join(", ")}. ` +
|
|
177
|
+
"Server sessions need sessionCreator, sessionRevoker and sessionChecker together, " +
|
|
178
|
+
"otherwise logout leaves the JWT valid until it expires. Wire all three (the sessions " +
|
|
179
|
+
"feature's sessionStore provides them) or none for stateless JWTs.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
162
182
|
// Second rate-limit axis for preauth-enable-start: unlike the IP, a
|
|
163
183
|
// preauthSetupToken isn't freely choosable by the attacker (it's minted by
|
|
164
184
|
// login.write.ts), so hashing it into a bucket key still caps the replay
|
|
@@ -5,6 +5,7 @@ export type RedisSseBrokerOptions = {
|
|
|
5
5
|
readonly clientOptions?: RedisClientOptions;
|
|
6
6
|
};
|
|
7
7
|
export type RedisSseBroker = SseBroker & {
|
|
8
|
+
readonly kind: "redis";
|
|
8
9
|
close(): Promise<void>;
|
|
9
10
|
};
|
|
10
11
|
export declare function isRedisSseBroker(broker: SseBroker): broker is RedisSseBroker;
|
|
@@ -9,12 +9,10 @@ import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
|
|
|
9
9
|
const CHANNEL_PREFIX = "kumiko:sse:ch:";
|
|
10
10
|
const INVALIDATION_PREFIX = "kumiko:sse:inval:";
|
|
11
11
|
const PSUBSCRIBE_PATTERN = "kumiko:sse:*";
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// pulled it back out of a generic ServerOptions.sseBroker slot) can tell
|
|
15
|
-
// which lifecycle they own without an unsound `as` cast.
|
|
12
|
+
// Narrows on the `kind` discriminant, not on `close`: an app-owned SseBroker
|
|
13
|
+
// may have its own close() and must not be mistaken for the Redis one.
|
|
16
14
|
export function isRedisSseBroker(broker) {
|
|
17
|
-
return "
|
|
15
|
+
return "kind" in broker && broker.kind === "redis";
|
|
18
16
|
}
|
|
19
17
|
// Single decision point for "which SseBroker should a caller default to
|
|
20
18
|
// when it doesn't inject its own": REDIS_URL present → cross-replica
|
|
@@ -117,6 +115,7 @@ export function createRedisSseBroker(opts) {
|
|
|
117
115
|
}
|
|
118
116
|
});
|
|
119
117
|
return {
|
|
118
|
+
kind: "redis",
|
|
120
119
|
addClient: inner.addClient,
|
|
121
120
|
removeClient: inner.removeClient,
|
|
122
121
|
getClientCount: inner.getClientCount,
|
package/dist/api/server.js
CHANGED
|
@@ -25,7 +25,7 @@ import { assertUnreachable, generateId } from "../utils/index.js";
|
|
|
25
25
|
import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
|
|
26
26
|
import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
|
|
27
27
|
import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
|
|
28
|
-
import { createAuthRoutes } from "./auth-routes.js";
|
|
28
|
+
import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
|
|
29
29
|
import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
|
|
30
30
|
import { csrfMiddleware } from "./csrf-middleware.js";
|
|
31
31
|
import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
|
|
@@ -505,6 +505,7 @@ export function buildServer(options) {
|
|
|
505
505
|
// without an allowlist and without an explicit opt-out — that config is the
|
|
506
506
|
// unguarded-subdomain-XSS footgun, not a warn-and-continue case.
|
|
507
507
|
assertOriginGuardConfig(options.auth);
|
|
508
|
+
assertSessionCallbacksPaired(options.auth);
|
|
508
509
|
const allowedOrigins = options.auth?.allowedOrigins;
|
|
509
510
|
const originGuard = allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
|
|
510
511
|
if (originGuard) {
|
|
@@ -527,11 +528,10 @@ export function buildServer(options) {
|
|
|
527
528
|
return next();
|
|
528
529
|
return csrfGuard(c, next);
|
|
529
530
|
});
|
|
530
|
-
//
|
|
531
|
+
// No PAT rate-limit here: patRouteGuardMiddleware rejects every PAT on httpRoutes before it could count.
|
|
531
532
|
const sessionOnlyHttpRouteGuards = [
|
|
532
533
|
sessionOnlyGuard,
|
|
533
534
|
patRouteGuardMiddleware,
|
|
534
|
-
...(patRateLimitGuard ? [patRateLimitGuard] : []),
|
|
535
535
|
...(originGuard ? [originGuard] : []),
|
|
536
536
|
csrfGuard,
|
|
537
537
|
];
|
package/dist/api/sse-route.js
CHANGED
|
@@ -45,6 +45,16 @@ function toEntitySignalWire(event, data) {
|
|
|
45
45
|
createdAt: data["createdAt"],
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
+
// The signal covers every row of the entity, including rows the anonymous
|
|
49
|
+
// query hides, so id and version would leak which hidden rows exist and
|
|
50
|
+
// change. Anonymous clients refetch the whole query anyway.
|
|
51
|
+
function toAnonymousEntitySignalWire(event, data) {
|
|
52
|
+
return {
|
|
53
|
+
aggregateType: data.aggregateType,
|
|
54
|
+
eventType: event.type,
|
|
55
|
+
createdAt: data["createdAt"],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
48
58
|
// The tenant channel fans out to every member, so a frame without an entity
|
|
49
59
|
// (e.g. channel-in-app:event:delivered) reaches only its addressee; a frame
|
|
50
60
|
// lacking userId is dropped (fail-closed).
|
|
@@ -61,7 +71,10 @@ function decideWireFrame(event, user, options) {
|
|
|
61
71
|
// skip: anonymous connections only get signals for entities an anonymous query declares
|
|
62
72
|
if (!mayReceiveEntitySignal(user, data.aggregateType, options))
|
|
63
73
|
return undefined;
|
|
64
|
-
|
|
74
|
+
const signal = isAnonymousSessionUser(user)
|
|
75
|
+
? toAnonymousEntitySignalWire(event, data)
|
|
76
|
+
: toEntitySignalWire(event, data);
|
|
77
|
+
return { name: data.aggregateType, data: signal };
|
|
65
78
|
}
|
|
66
79
|
// skip: frames without an entity belong to their addressee only
|
|
67
80
|
if (!isAddressedTo(event, user.id))
|
package/dist/bun-db/query.js
CHANGED
|
@@ -735,10 +735,11 @@ async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
|
|
|
735
735
|
throw err;
|
|
736
736
|
}
|
|
737
737
|
}
|
|
738
|
+
const DEFAULT_POOL_MAX = 10;
|
|
738
739
|
function poolMaxOf(raw) {
|
|
739
740
|
// @cast-boundary driver pool options — both postgres-js and Bun.SQL expose options.max
|
|
740
741
|
const max = raw.options?.max;
|
|
741
|
-
return typeof max === "number" && Number.isInteger(max) && max > 0 ? max :
|
|
742
|
+
return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_POOL_MAX;
|
|
742
743
|
}
|
|
743
744
|
// Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
|
|
744
745
|
// db/queries/*.ts) can opt into the same closed-connection retry instead of
|
|
@@ -754,9 +755,10 @@ export async function unsafeReadRetrying(db, sqlText, params) {
|
|
|
754
755
|
logClosedConnection(raw, err, 1);
|
|
755
756
|
throw err;
|
|
756
757
|
}
|
|
757
|
-
|
|
758
|
+
// Enough retries to flush every dead pooled connection plus one fresh one; total calls = maxRetries + 1.
|
|
759
|
+
const maxRetries = poolMaxOf(raw) + 1;
|
|
758
760
|
let lastErr = err;
|
|
759
|
-
for (let attempt = 0; attempt <
|
|
761
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
760
762
|
try {
|
|
761
763
|
return (await raw.unsafe(sqlText, params));
|
|
762
764
|
}
|
|
@@ -768,7 +770,7 @@ export async function unsafeReadRetrying(db, sqlText, params) {
|
|
|
768
770
|
}
|
|
769
771
|
}
|
|
770
772
|
}
|
|
771
|
-
logClosedConnection(raw, lastErr,
|
|
773
|
+
logClosedConnection(raw, lastErr, maxRetries + 1);
|
|
772
774
|
throw lastErr;
|
|
773
775
|
}
|
|
774
776
|
}
|
|
@@ -43,6 +43,7 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
|
|
|
43
43
|
}
|
|
44
44
|
if (!response.ok) {
|
|
45
45
|
if (isRetryableStatus(response.status) && attempt < maxAttempts) {
|
|
46
|
+
await response.body?.cancel();
|
|
46
47
|
await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
|
|
47
48
|
continue;
|
|
48
49
|
}
|
|
@@ -55,7 +55,7 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
|
|
|
55
55
|
async function existingTableNames(db, tableNames) {
|
|
56
56
|
if (tableNames.length === 0)
|
|
57
57
|
return new Set();
|
|
58
|
-
const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass(quote_ident(t.name)) IS NOT NULL`, [tableNames]);
|
|
58
|
+
const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [tableNames]);
|
|
59
59
|
return new Set(rows.map((r) => r.name));
|
|
60
60
|
}
|
|
61
61
|
// Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
|
|
@@ -249,11 +249,16 @@ function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
|
|
|
249
249
|
}
|
|
250
250
|
return descriptors;
|
|
251
251
|
}
|
|
252
|
+
// Matches the main list's own default: soft-deleted rows are invisible, so a
|
|
253
|
+
// deleted target must neither match a search nor influence an ordering.
|
|
254
|
+
function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
255
|
+
return targetEntity.softDelete === true && targetTable["isDeleted"] !== undefined;
|
|
256
|
+
}
|
|
252
257
|
// Tenant-scoped ILIKE lookup against one reference target — never raw/
|
|
253
258
|
// unscoped SQL, a match in a foreign tenant must never surface a row here
|
|
254
259
|
// (fw#2660's hard constraint). Mirrors the main table's own tenant filter
|
|
255
260
|
// (see the `table["tenantId"]` check further down in list()).
|
|
256
|
-
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db) {
|
|
261
|
+
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, db) {
|
|
257
262
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
258
263
|
const subParams = [`%${escapeLikePattern(searchTerm)}%`];
|
|
259
264
|
let tenantClause = "";
|
|
@@ -261,9 +266,12 @@ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTab
|
|
|
261
266
|
subParams.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
262
267
|
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
|
|
263
268
|
}
|
|
269
|
+
const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
|
|
270
|
+
? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
|
|
271
|
+
: "";
|
|
264
272
|
// ::text cast covers a non-text labelField (e.g. a number/select column
|
|
265
273
|
// used as label) — Postgres has no ILIKE for those types otherwise.
|
|
266
|
-
const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
|
|
274
|
+
const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${deletedClause} ` +
|
|
267
275
|
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
268
276
|
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
|
|
269
277
|
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
@@ -320,7 +328,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
320
328
|
const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
321
329
|
const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
|
|
322
330
|
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
323
|
-
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db);
|
|
331
|
+
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, db);
|
|
324
332
|
}
|
|
325
333
|
if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
|
|
326
334
|
// skip: no plaintext anywhere to match — boot rejects this normally,
|
|
@@ -373,6 +381,9 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
373
381
|
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
374
382
|
const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
|
|
375
383
|
const tenantId = db.mode === "tenant" ? db.tenantId : undefined;
|
|
384
|
+
const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
|
|
385
|
+
? ` AND t."${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
|
|
386
|
+
: "";
|
|
376
387
|
// No ::text cast (unlike the ILIKE search path): a non-text label must keep
|
|
377
388
|
// its native collation/numeric order, and the keyset param infers its type
|
|
378
389
|
// from the comparison anyway.
|
|
@@ -383,7 +394,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
383
394
|
tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
|
|
384
395
|
}
|
|
385
396
|
return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
|
|
386
|
-
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause})`);
|
|
397
|
+
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${deletedClause})`);
|
|
387
398
|
};
|
|
388
399
|
}
|
|
389
400
|
export function createReadVerbs(ctx) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PendingGapEntry } from "../../pipeline/event-consumer-state.js";
|
|
1
|
+
import type { ConsumerStatuses, PendingGapEntry } from "../../pipeline/event-consumer-state.js";
|
|
2
2
|
import type { AnyDb } from "../query.js";
|
|
3
3
|
export declare function selectSnapshotXmin(db: AnyDb): Promise<string>;
|
|
4
4
|
export declare function selectSnapshotXmax(db: AnyDb): Promise<string>;
|
|
@@ -10,7 +10,7 @@ export declare function selectEventIdHorizonWithMissingRanges(db: AnyDb): Promis
|
|
|
10
10
|
}>;
|
|
11
11
|
export declare function insertConsumerIfAbsent(db: AnyDb, name: string, instanceId: string, startFrom?: "beginning" | "now"): Promise<void>;
|
|
12
12
|
export declare function deleteOrphanedPerInstanceConsumerRows(db: AnyDb, consumerNames: readonly string[], sharedInstanceSentinel: string): Promise<void>;
|
|
13
|
-
export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readonly string[], instanceIds: readonly string[], deadStatus:
|
|
13
|
+
export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readonly string[], instanceIds: readonly string[], deadStatus: typeof ConsumerStatuses.dead): Promise<ReadonlyArray<{
|
|
14
14
|
readonly name: string;
|
|
15
15
|
readonly instanceId: string;
|
|
16
16
|
}>>;
|
|
@@ -112,10 +112,11 @@ sharedInstanceSentinel) {
|
|
|
112
112
|
// exists past its cursor. Paired via unnest so the two positional arrays
|
|
113
113
|
// zip element-wise into rows instead of a cartesian product.
|
|
114
114
|
//
|
|
115
|
-
// `deadStatus` is passed in rather than imported from
|
|
116
|
-
// (which defines ConsumerStatuses) — that module
|
|
117
|
-
// one, so
|
|
118
|
-
// deleteOrphanedPerInstanceConsumerRows above).
|
|
115
|
+
// `deadStatus` is passed in rather than imported as a value from
|
|
116
|
+
// event-consumer-state.ts (which defines ConsumerStatuses) — that module
|
|
117
|
+
// already imports from this one, so a value import back would be a require
|
|
118
|
+
// cycle (same pattern as deleteOrphanedPerInstanceConsumerRows above). The
|
|
119
|
+
// type-only import keeps the parameter pinned to the "dead" literal.
|
|
119
120
|
export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, deadStatus) {
|
|
120
121
|
const rows = (await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
|
|
121
122
|
FROM "kumiko_event_consumers" c
|
|
@@ -130,16 +130,16 @@ export async function insertSubsequentEventRow(db, params) {
|
|
|
130
130
|
return rows[0];
|
|
131
131
|
}
|
|
132
132
|
export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
|
|
133
|
-
const rows =
|
|
133
|
+
const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`, [aggregateId, tenantId]);
|
|
134
134
|
return rows[0]?.v ?? 0;
|
|
135
135
|
}
|
|
136
136
|
/** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
|
|
137
137
|
export async function selectAggregateMaxVersion(db, aggregateId) {
|
|
138
|
-
const rows =
|
|
138
|
+
const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`, [aggregateId]);
|
|
139
139
|
return rows[0]?.v ?? 0;
|
|
140
140
|
}
|
|
141
141
|
export async function selectEventsHighWaterMark(db) {
|
|
142
|
-
const rows =
|
|
142
|
+
const rows = await unsafeReadRetrying(db, `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`, []);
|
|
143
143
|
const raw = rows[0]?.max;
|
|
144
144
|
if (typeof raw === "bigint")
|
|
145
145
|
return raw;
|
|
@@ -153,7 +153,7 @@ export async function selectEventsHeadId(db) {
|
|
|
153
153
|
return selectEventsHighWaterMark(db);
|
|
154
154
|
}
|
|
155
155
|
export async function selectNextEventIdAfter(db, afterId) {
|
|
156
|
-
const rows =
|
|
156
|
+
const rows = await unsafeReadRetrying(db, `SELECT "id" FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT 1`, [afterId]);
|
|
157
157
|
const row = rows[0];
|
|
158
158
|
if (!row)
|
|
159
159
|
return null;
|
|
@@ -222,10 +222,10 @@ export async function selectPendingAndNewEventRows(db, cursor, pendingRanges, ba
|
|
|
222
222
|
export async function selectSmallestVisibleIdInRanges(db, pendingRanges) {
|
|
223
223
|
if (pendingRanges.length === 0)
|
|
224
224
|
return null;
|
|
225
|
-
const rows =
|
|
225
|
+
const rows = await unsafeReadRetrying(db, `SELECT MIN("id")::text AS id FROM "kumiko_events"
|
|
226
226
|
WHERE EXISTS (
|
|
227
227
|
SELECT 1 FROM unnest($1::bigint[], $2::bigint[]) AS g(f, t) WHERE "id" BETWEEN g.f AND g.t
|
|
228
|
-
)`, [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())])
|
|
228
|
+
)`, [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())]);
|
|
229
229
|
const id = rows[0]?.id;
|
|
230
230
|
return id === null || id === undefined ? null : BigInt(id);
|
|
231
231
|
}
|
|
@@ -14,7 +14,7 @@ export async function selectEventsForProjectionRebuildBatch(db, aggregateTypes,
|
|
|
14
14
|
// Archived streams don't replay (Marten-aligned): their aggregates are
|
|
15
15
|
// frozen ops-tombstones — replaying them would resurrect rows (or, for
|
|
16
16
|
// stranded duplicate-aggregates like fw#832, collide on unique indexes).
|
|
17
|
-
return
|
|
17
|
+
return unsafeReadRetrying(db, `SELECT * FROM "kumiko_events" e
|
|
18
18
|
WHERE e."aggregate_type" = ANY($1::text[])
|
|
19
19
|
AND e."type" = ANY($2::text[])
|
|
20
20
|
AND e."id" > $3
|
|
@@ -23,7 +23,7 @@ export async function selectEventsForProjectionRebuildBatch(db, aggregateTypes,
|
|
|
23
23
|
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
|
|
24
24
|
)
|
|
25
25
|
ORDER BY e."id" ASC
|
|
26
|
-
LIMIT $4`, [aggregateTypes, eventTypes, afterId, limit])
|
|
26
|
+
LIMIT $4`, [aggregateTypes, eventTypes, afterId, limit]);
|
|
27
27
|
}
|
|
28
28
|
// Total subscribed events in the log — same source/type filter as
|
|
29
29
|
// selectEventsForProjectionRebuildBatch, no id cursor. Under the cutover fence
|
|
@@ -33,13 +33,13 @@ export async function countSubscribedEvents(db, aggregateTypes, eventTypes) {
|
|
|
33
33
|
// Same archived-streams exclusion as the batch query — the #443 recompute
|
|
34
34
|
// compares this count against applied events; a filter mismatch would make
|
|
35
35
|
// every rebuild with an archived stream loop the full-re-replay forever.
|
|
36
|
-
const rows =
|
|
36
|
+
const rows = await unsafeReadRetrying(db, `SELECT count(*)::bigint AS n FROM "kumiko_events" e
|
|
37
37
|
WHERE e."aggregate_type" = ANY($1::text[])
|
|
38
38
|
AND e."type" = ANY($2::text[])
|
|
39
39
|
AND NOT EXISTS (
|
|
40
40
|
SELECT 1 FROM "kumiko_archived_streams" a
|
|
41
41
|
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
|
|
42
|
-
)`, [aggregateTypes, eventTypes])
|
|
42
|
+
)`, [aggregateTypes, eventTypes]);
|
|
43
43
|
const raw = rows[0]?.n;
|
|
44
44
|
if (typeof raw === "bigint")
|
|
45
45
|
return raw;
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "../query.js";
|
|
2
2
|
export async function selectUserByEmail(db, email) {
|
|
3
|
-
const rows =
|
|
3
|
+
const rows = await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
|
|
4
4
|
FROM read_users
|
|
5
5
|
WHERE email = $1
|
|
6
|
-
LIMIT 1`, [email])
|
|
6
|
+
LIMIT 1`, [email]);
|
|
7
7
|
const row = rows[0];
|
|
8
8
|
if (!row)
|
|
9
9
|
return null;
|
|
10
10
|
return { id: row.id, email: row.email, tenantId: row.tenant_id };
|
|
11
11
|
}
|
|
12
12
|
export async function selectMembershipsOfUser(db, userId) {
|
|
13
|
-
return
|
|
13
|
+
return unsafeReadRetrying(db, `SELECT m.user_id::text AS user_id,
|
|
14
14
|
m.tenant_id::text AS tenant_id,
|
|
15
15
|
e.tenant_id::text AS stream_tenant_id,
|
|
16
16
|
m.roles
|
|
17
17
|
FROM read_tenant_memberships m
|
|
18
18
|
JOIN kumiko_events e ON e.aggregate_id = m.id AND e.version = 1
|
|
19
|
-
WHERE m.user_id = $1`, [userId])
|
|
19
|
+
WHERE m.user_id = $1`, [userId]);
|
|
20
20
|
}
|
|
21
21
|
export async function selectAllTenants(db) {
|
|
22
|
-
return
|
|
22
|
+
return unsafeReadRetrying(db, `SELECT id::text AS id, name, key AS tenant_key
|
|
23
23
|
FROM read_tenants
|
|
24
|
-
ORDER BY inserted_at`, [])
|
|
24
|
+
ORDER BY inserted_at`, []);
|
|
25
25
|
}
|
|
@@ -97,8 +97,11 @@ function resolveOverlayLayer(layer, qrContext, tenantId) {
|
|
|
97
97
|
// the spec, so a resolver whose target changes (e.g. a base-URL rotation)
|
|
98
98
|
// invalidates the cache instead of serving a stale target under the old key.
|
|
99
99
|
async function resolveOverlaySpec(spec, row, registry, tenantId) {
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// `resolvedOverlays` is framework-internal; a caller-supplied value must
|
|
101
|
+
// never reach the renderer past the token resolution below.
|
|
102
|
+
if (!spec.overlays || spec.overlays.length === 0) {
|
|
103
|
+
return spec.resolvedOverlays === undefined ? spec : { ...spec, resolvedOverlays: undefined };
|
|
104
|
+
}
|
|
102
105
|
const qrContext = spec.overlays.some((layer) => layer.kind === "qr")
|
|
103
106
|
? resolveQrOverlayContext(row, registry)
|
|
104
107
|
: undefined;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type FeatureDefinition, type QueryHandlerDef } from "../types/index.js";
|
|
2
2
|
export declare function buildQueryHandlerMap(features: readonly FeatureDefinition[]): ReadonlyMap<string, QueryHandlerDef>;
|
|
3
3
|
export declare function validateProjectionListScreens(features: readonly FeatureDefinition[]): void;
|
|
4
4
|
export declare function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { parseQn, QnTypes, qualifyEntityName } from "../qualified-name.js";
|
|
3
3
|
import { isEditLayoutScreen } from "../screen-helpers.js";
|
|
4
|
+
import { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../types/index.js";
|
|
4
5
|
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
|
|
5
6
|
import { findEntity } from "./parent-ref.js";
|
|
6
7
|
import { getZodObjectShape } from "./zod-shape.js";
|
|
@@ -198,6 +199,17 @@ function validateRelatedListParentFilter(prefix, section, schema, handler, featu
|
|
|
198
199
|
`entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`);
|
|
199
200
|
}
|
|
200
201
|
}
|
|
202
|
+
// A collapsed group without a header has no toggle, so its rows could never be opened.
|
|
203
|
+
function validateRelatedListGroupBy(prefix, section) {
|
|
204
|
+
const groupBy = section.groupBy;
|
|
205
|
+
const collapsedKey = groupBy?.collapsedWhen === undefined ? undefined : relatedListGroupKey(groupBy.collapsedWhen);
|
|
206
|
+
if (groupBy !== undefined &&
|
|
207
|
+
collapsedKey !== undefined &&
|
|
208
|
+
relatedListGroupHeaderLabel(groupBy, collapsedKey) === undefined) {
|
|
209
|
+
throw new Error(`${prefix}: groupBy.collapsedWhen "${collapsedKey}" names a group without a header; ` +
|
|
210
|
+
`set groupBy.label or groupBy.labels["${collapsedKey}"], or drop collapsedWhen.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
201
213
|
// relatedList sections declare search/facets the same way a projectionList
|
|
202
214
|
// screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
|
|
203
215
|
// auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
|
|
@@ -214,6 +226,7 @@ export function validateRelatedListSectionQueries(features) {
|
|
|
214
226
|
validateRelatedListSearchable(prefix, section, schema);
|
|
215
227
|
validateRelatedListFacets(prefix, section, schema);
|
|
216
228
|
validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
|
|
229
|
+
validateRelatedListGroupBy(prefix, section);
|
|
217
230
|
};
|
|
218
231
|
for (const feature of features) {
|
|
219
232
|
for (const screen of Object.values(feature.screens)) {
|
|
@@ -620,6 +620,15 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, allScre
|
|
|
620
620
|
}
|
|
621
621
|
validateScreenNavTarget(feature.name, screenId, screenKind, "redirect", redirectTarget, allScreenQns, feature.screens);
|
|
622
622
|
}
|
|
623
|
+
// redirect is only honored on record actions; on list/related-list row
|
|
624
|
+
// actions it would be silently dropped, so the author would believe it works.
|
|
625
|
+
function rejectRedirectOnListRowAction(featureName, screenId, screenKind, action) {
|
|
626
|
+
if (action.kind !== "navigate" && action.kind !== "drawer" && action.redirect !== undefined) {
|
|
627
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) rowAction "${action.id}" sets redirect — ` +
|
|
628
|
+
`redirect is only honored on record actions (projectionDetail/entityEdit header and section ` +
|
|
629
|
+
`actions) and is ignored on list row actions. Remove it.`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
623
632
|
// A writeHandler record action's redirect (projectionDetail/entityEdit
|
|
624
633
|
// header and section actions) follows the entityEdit.redirect rule.
|
|
625
634
|
function validateRecordActionRedirect(feature, screenId, screenKind, action, allScreenQns) {
|
|
@@ -780,6 +789,7 @@ function validateRelatedListSection(args) {
|
|
|
780
789
|
function validateRelatedListRowActions(args, rowActions) {
|
|
781
790
|
const { featureName, screenId, screenType, where, section, screens, allWriteHandlerQns, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
|
|
782
791
|
for (const action of rowActions) {
|
|
792
|
+
rejectRedirectOnListRowAction(featureName, screenId, screenType, action);
|
|
783
793
|
if (action.kind === "navigate") {
|
|
784
794
|
const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
785
795
|
validateRowActionNavigateParams(featureName, screenId, screenType, undefined, action, target);
|
|
@@ -899,6 +909,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
899
909
|
}
|
|
900
910
|
if (screen.rowActions !== undefined) {
|
|
901
911
|
for (const action of screen.rowActions) {
|
|
912
|
+
rejectRedirectOnListRowAction(feature.name, screenId, "projectionList", action);
|
|
902
913
|
if (action.kind === "navigate") {
|
|
903
914
|
const target = resolveRowActionNavigateTarget(feature.name, screenId, "projectionList", "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
904
915
|
validateRowActionNavigateParams(feature.name, screenId, "projectionList", undefined, action, target);
|
|
@@ -1414,6 +1425,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1414
1425
|
// erst beim ersten Klick als "Screen not found" / 404 auf.
|
|
1415
1426
|
if (screen.rowActions !== undefined) {
|
|
1416
1427
|
for (const action of screen.rowActions) {
|
|
1428
|
+
rejectRedirectOnListRowAction(feature.name, screenId, "entityList", action);
|
|
1417
1429
|
if (action.kind === "navigate") {
|
|
1418
1430
|
const target = resolveRowActionNavigateTarget(feature.name, screenId, "entityList", "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
1419
1431
|
// The renderer's default-entityId fallback (row["id"]) only fires
|