@cosmicdrift/kumiko-framework 0.336.1 → 0.338.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/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +9 -0
- package/dist/api/auth-middleware.js +45 -21
- 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 +14 -4
- package/dist/api/sse-route.js +14 -1
- package/dist/bun-db/query.js +6 -4
- package/dist/compliance/sub-processors.js +10 -1
- package/dist/crypto/kek-source.js +1 -0
- package/dist/db/blind-index-cleanup.d.ts +1 -0
- package/dist/db/blind-index-cleanup.js +12 -1
- package/dist/db/event-store-executor-read.js +30 -4
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- 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/db/tenant-db.js +16 -8
- package/dist/derivatives/derivatives-context.js +5 -2
- package/dist/engine/boot-validator/access-declarations.js +13 -7
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +26 -0
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/pii-retention.js +11 -1
- 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/config-helpers.d.ts +2 -1
- package/dist/engine/config-helpers.js +1 -0
- package/dist/engine/define-feature.js +1 -0
- package/dist/engine/extensions/escape-hatch-usage.js +3 -1
- package/dist/engine/feature-ast/extractors/hooks.js +7 -1
- 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 +630 -1
- package/dist/engine/feature-ast/pattern-change-schema.js +19 -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 +25 -1
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/feature-ui-extensions.js +24 -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 +3 -3
- 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/jobs/job-runner.js +2 -1
- 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-shared.js +32 -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 +11 -8
- 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 +153 -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,6 +33,7 @@ export declare const Routes: {
|
|
|
33
33
|
};
|
|
34
34
|
export declare const PUBLIC_API_PATHS: ReadonlySet<string>;
|
|
35
35
|
export declare const NON_PUBLIC_API_PATHS: ReadonlySet<string>;
|
|
36
|
+
export declare function isSessionRequiredApiPath(path: string): boolean;
|
|
36
37
|
export declare const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string>;
|
|
37
38
|
export declare const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
|
|
38
39
|
readonly method: string;
|
|
@@ -87,6 +87,15 @@ export const NON_PUBLIC_API_PATHS = new Set([
|
|
|
87
87
|
`/api${Routes.files}`,
|
|
88
88
|
`/api${Routes.schema}`,
|
|
89
89
|
]);
|
|
90
|
+
// Paths that need a session but no tenant: without a token they answer 401
|
|
91
|
+
// before any tenant resolution, so an unresolvable tenant can never turn
|
|
92
|
+
// "not logged in" into a 400 tenant_required.
|
|
93
|
+
const SESSION_REQUIRED_API_PREFIXES = [`/api${Routes.auth}/`];
|
|
94
|
+
const SESSION_REQUIRED_API_EXACT_PATHS = new Set([`/api${Routes.schema}`]);
|
|
95
|
+
export function isSessionRequiredApiPath(path) {
|
|
96
|
+
return (SESSION_REQUIRED_API_EXACT_PATHS.has(path) ||
|
|
97
|
+
SESSION_REQUIRED_API_PREFIXES.some((prefix) => path.startsWith(prefix)));
|
|
98
|
+
}
|
|
90
99
|
// Opt-out from the default request-body-size cap (registerBodyLimit applies
|
|
91
100
|
// it to all of /api/* by construction — a new route needs no entry here to
|
|
92
101
|
// be covered). Only routes with their own, deliberately different size
|
|
@@ -2,7 +2,7 @@ import { getCookie } from "hono/cookie";
|
|
|
2
2
|
import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
|
|
3
3
|
import { createAnonymousUser } from "../engine/system-user.js";
|
|
4
4
|
import { parseTenantId } from "../engine/types/identifiers.js";
|
|
5
|
-
import { TENANT_COOKIE_NAME, TENANT_HEADER_NAME } from "./api-constants.js";
|
|
5
|
+
import { isSessionRequiredApiPath, TENANT_COOKIE_NAME, TENANT_HEADER_NAME, } from "./api-constants.js";
|
|
6
6
|
import { isForeignCookieOrigin } from "./origin-middleware.js";
|
|
7
7
|
const USER_KEY = "pipelineUser";
|
|
8
8
|
const AUTH_TRANSPORT_KEY = "authTransport";
|
|
@@ -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
|
|
@@ -95,7 +109,7 @@ export function authMiddleware(jwt, options = {}) {
|
|
|
95
109
|
// missing authentication, nicht missing tenant. Daher hier direkt
|
|
96
110
|
// 401, ohne den anonymous-Tenant-Flow zu durchlaufen.
|
|
97
111
|
if (anonymousAccess) {
|
|
98
|
-
if (c.req.path
|
|
112
|
+
if (isSessionRequiredApiPath(c.req.path)) {
|
|
99
113
|
return middlewareReject(c, {
|
|
100
114
|
code: "missing_token",
|
|
101
115
|
status: 401,
|
|
@@ -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
|
@@ -23,9 +23,9 @@ import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } fr
|
|
|
23
23
|
import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
|
|
24
24
|
import { assertUnreachable, generateId } from "../utils/index.js";
|
|
25
25
|
import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
|
|
26
|
-
import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
|
|
26
|
+
import { NO_ROUTE_MATCH_HEADER_NAME, NON_PUBLIC_API_PATHS, 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
|
];
|
|
@@ -708,6 +708,16 @@ export function buildServer(options) {
|
|
|
708
708
|
throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
|
|
709
709
|
'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.');
|
|
710
710
|
}
|
|
711
|
+
// A `:param` segment is as broad as a wildcard, and the public matchers test
|
|
712
|
+
// the bare request path: "/:seg/write" and "/api/:hook" both match /api/write.
|
|
713
|
+
if (route.entry === ExtraRouteEntries.signature) {
|
|
714
|
+
const routePattern = honoPathToRegex(route.path);
|
|
715
|
+
const shadowedFrameworkPath = [...PUBLIC_API_PATHS, ...NON_PUBLIC_API_PATHS].find((path) => routePattern.test(path));
|
|
716
|
+
if (shadowedFrameworkPath !== undefined) {
|
|
717
|
+
throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
|
|
718
|
+
`match the framework path "${shadowedFrameworkPath}" — it would bypass the auth chain for it.`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
711
721
|
}
|
|
712
722
|
const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
|
|
713
723
|
const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
|
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
|
}
|
|
@@ -84,13 +84,22 @@ export const KUMIKO_SUB_PROCESSORS = [
|
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
name: "Scaleway SAS",
|
|
87
|
-
purpose: "Offsite Backup Mirror (Object Storage)
|
|
87
|
+
purpose: "Offsite Backup Mirror (Object Storage)",
|
|
88
88
|
region: "EU (France)",
|
|
89
89
|
dpa: "https://www.scaleway.com/en/contracts/",
|
|
90
90
|
addedAt: "2026-09-18",
|
|
91
91
|
appliesTo: ["all-tiers"],
|
|
92
92
|
status: "active",
|
|
93
93
|
},
|
|
94
|
+
{
|
|
95
|
+
name: "Scaleway SAS",
|
|
96
|
+
purpose: "Platform key custody (Key Manager) and Transactional Email Delivery",
|
|
97
|
+
region: "EU (France)",
|
|
98
|
+
dpa: "https://www.scaleway.com/en/contracts/",
|
|
99
|
+
addedAt: "2026-09-18",
|
|
100
|
+
appliesTo: ["all-tiers"],
|
|
101
|
+
status: "planned",
|
|
102
|
+
},
|
|
94
103
|
];
|
|
95
104
|
/**
|
|
96
105
|
* Filter helper — nur aktive Sub-Processors (kein status="planned").
|
|
@@ -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
|
}
|
|
@@ -3,4 +3,5 @@ import type { DbRunner } from "./connection.js";
|
|
|
3
3
|
export declare function nullBlindIndexesForSubject(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectKey: string): Promise<void>;
|
|
4
4
|
export declare function subjectRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectId: string, tenantId: string): Promise<boolean>;
|
|
5
5
|
export declare function recordRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string, tenantId: string): Promise<boolean>;
|
|
6
|
+
export declare function recordEventsOwnedExclusivelyByTenant(db: DbRunner, aggregateType: string, aggregateId: string, tenantId: string): Promise<boolean>;
|
|
6
7
|
export declare function recordRowOwningTenantId(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string): Promise<string | undefined>;
|
|
@@ -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
|
|
@@ -131,6 +131,17 @@ export async function recordRowExistsInTenant(db, features, entityName, recordId
|
|
|
131
131
|
const rows = await executeRawQueryRead(db, `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`, [recordId, tenantId]);
|
|
132
132
|
return rows.length > 0;
|
|
133
133
|
}
|
|
134
|
+
// Provenance fallback for record subjects whose projection row is gone (hard
|
|
135
|
+
// delete) or that have no registered entity at all (custom aggregates): the
|
|
136
|
+
// event stream still names the owning tenant. The unique index is per tenant
|
|
137
|
+
// and the subject key is global, so another tenant could open a stream under
|
|
138
|
+
// the same aggregate id; any foreign event voids the proof.
|
|
139
|
+
export async function recordEventsOwnedExclusivelyByTenant(db, aggregateType, aggregateId, tenantId) {
|
|
140
|
+
const rows = await executeRawQueryRead(db, `SELECT 1 FROM "kumiko_events" WHERE aggregate_type = $1 AND aggregate_id = $2::uuid AND tenant_id = $3::uuid
|
|
141
|
+
AND NOT EXISTS (SELECT 1 FROM "kumiko_events" WHERE aggregate_type = $1 AND aggregate_id = $2::uuid AND tenant_id <> $3::uuid)
|
|
142
|
+
LIMIT 1`, [aggregateType, aggregateId, tenantId]);
|
|
143
|
+
return rows.length > 0;
|
|
144
|
+
}
|
|
134
145
|
// Owning tenant of a record subject's row, for callers that must evaluate
|
|
135
146
|
// tenant-scoped policy for the row's real owner (not the acting user's tenant).
|
|
136
147
|
export async function recordRowOwningTenantId(db, features, entityName, recordId) {
|
|
@@ -249,11 +249,24 @@ 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, user, db) {
|
|
262
|
+
// The label must not leak through match counts: a viewer who cannot read the
|
|
263
|
+
// target row or its label field would otherwise probe names by searching.
|
|
264
|
+
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
265
|
+
if (labelAccess.kind !== "pass")
|
|
266
|
+
return undefined;
|
|
267
|
+
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
268
|
+
if (ownership.kind === "empty")
|
|
269
|
+
return undefined;
|
|
257
270
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
258
271
|
const subParams = [`%${escapeLikePattern(searchTerm)}%`];
|
|
259
272
|
let tenantClause = "";
|
|
@@ -261,9 +274,19 @@ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTab
|
|
|
261
274
|
subParams.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
262
275
|
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
|
|
263
276
|
}
|
|
277
|
+
let ownershipClause = "";
|
|
278
|
+
if (ownership.kind === "sql") {
|
|
279
|
+
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, subParams.length);
|
|
280
|
+
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
281
|
+
for (const p of shifted.params)
|
|
282
|
+
subParams.push(p);
|
|
283
|
+
}
|
|
284
|
+
const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
|
|
285
|
+
? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
|
|
286
|
+
: "";
|
|
264
287
|
// ::text cast covers a non-text labelField (e.g. a number/select column
|
|
265
288
|
// used as label) — Postgres has no ILIKE for those types otherwise.
|
|
266
|
-
const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
|
|
289
|
+
const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${ownershipClause}${deletedClause} ` +
|
|
267
290
|
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
268
291
|
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
|
|
269
292
|
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
@@ -320,7 +343,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
320
343
|
const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
321
344
|
const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
|
|
322
345
|
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
323
|
-
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db);
|
|
346
|
+
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
|
|
324
347
|
}
|
|
325
348
|
if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
|
|
326
349
|
// skip: no plaintext anywhere to match — boot rejects this normally,
|
|
@@ -373,6 +396,9 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
373
396
|
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
374
397
|
const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
|
|
375
398
|
const tenantId = db.mode === "tenant" ? db.tenantId : undefined;
|
|
399
|
+
const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
|
|
400
|
+
? ` AND t."${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
|
|
401
|
+
: "";
|
|
376
402
|
// No ::text cast (unlike the ILIKE search path): a non-text label must keep
|
|
377
403
|
// its native collation/numeric order, and the keyset param infers its type
|
|
378
404
|
// from the comparison anyway.
|
|
@@ -383,7 +409,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
383
409
|
tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
|
|
384
410
|
}
|
|
385
411
|
return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
|
|
386
|
-
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause})`);
|
|
412
|
+
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${deletedClause})`);
|
|
387
413
|
};
|
|
388
414
|
}
|
|
389
415
|
export function createReadVerbs(ctx) {
|
package/dist/db/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
|
-
export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
2
|
+
export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
package/dist/db/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
|
-
export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
2
|
+
export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
|
@@ -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;
|