@cosmicdrift/kumiko-framework 0.307.0 → 0.309.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 +5 -5
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/field-access.integration.test.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/transition-guard.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
- package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write.integration.test.ts +1 -1
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +73 -6
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- package/src/api/__tests__/sse-broker.test.ts +107 -13
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
- package/src/api/auth-routes.ts +1 -1
- package/src/api/redis-sse-broker.ts +51 -11
- package/src/api/server.ts +11 -0
- package/src/api/sse-broker.ts +61 -22
- package/src/bun-db/__tests__/postgres-closed-socket-write.integration.test.ts +87 -0
- package/src/bun-db/connection.ts +2 -0
- package/src/changes.json +61 -0
- package/src/compliance/override-schema.ts +1 -1
- package/src/crypto/__tests__/event-pii.test.ts +1 -1
- package/src/crypto/kms-adapter.ts +1 -1
- package/src/db/__tests__/feature-table-sources.test.ts +2 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/collect-table-metas.ts +3 -6
- package/src/db/feature-table-sources.ts +3 -4
- package/src/db/queries/event-consumer.ts +98 -6
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
- package/src/derivatives/delete-stored-file.ts +86 -0
- package/src/derivatives/index.ts +2 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +35 -1
- package/src/engine/__tests__/build-app-schema.test.ts +1 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
- package/src/engine/__tests__/engine.test.ts +1 -1
- package/src/engine/__tests__/event-helpers.test.ts +1 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
- package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
- package/src/engine/__tests__/extends-registrar.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +1 -1
- package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/registry.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +1 -1
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
- package/src/engine/boot-validator/screens.ts +83 -0
- package/src/engine/define-handler.ts +2 -1
- package/src/engine/entity-handlers.ts +2 -1
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
- package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
- package/src/engine/feature-builder-state.ts +2 -1
- package/src/engine/feature-config-events-jobs.ts +2 -1
- package/src/engine/feature-entity-handlers.ts +2 -1
- package/src/engine/schema-builder.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
- package/src/env/__tests__/compose-env-schema.test.ts +1 -1
- package/src/env/__tests__/dry-run.test.ts +1 -1
- package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
- package/src/env/_zod-introspect.ts +1 -1
- package/src/env/dry-run.ts +1 -1
- package/src/env/index.ts +1 -1
- package/src/errors/__tests__/classes.test.ts +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
- package/src/event-store/__tests__/event-attribution.integration.test.ts +1 -1
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
- package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +1 -1
- package/src/observability/metrics-wiring.ts +1 -1
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
- package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +192 -0
- package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
- package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +1 -1
- package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/dispatch-stream.ts +6 -1
- package/src/pipeline/event-dispatcher-delivery.ts +132 -37
- package/src/pipeline/event-dispatcher.ts +121 -17
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/msp-rebuild.ts +39 -1
- package/src/pipeline/pending-gap-ranges.ts +48 -0
- package/src/pipeline/system-hooks.ts +223 -68
- package/src/pipeline/write-origin.ts +1 -1
- package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
- package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
- package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
- package/src/search/derive-search-adapter-config.ts +27 -0
- package/src/search/index.ts +1 -0
- package/src/search/meilisearch-adapter.ts +96 -14
- package/src/secrets/__tests__/contains-secret.test.ts +1 -1
- package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-entity-tables.integration.test.ts +65 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
- package/src/stack/test-stack.ts +61 -54
- package/src/testing/__tests__/e2e-generator.test.ts +1 -1
- package/src/testing/__tests__/real-providers.test.ts +9 -1
- package/src/testing/e2e-generator.ts +1 -1
- package/src/testing/index.ts +1 -1
- package/src/testing/real-providers.ts +12 -4
package/src/api/server.ts
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
type GlobalIpRateLimitOptions,
|
|
51
51
|
globalIpRateLimit,
|
|
52
52
|
} from "../rate-limit";
|
|
53
|
+
import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config";
|
|
53
54
|
import type { SearchAdapter } from "../search/types";
|
|
54
55
|
import { assertUnreachable, generateId } from "../utils";
|
|
55
56
|
import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
|
|
@@ -488,6 +489,16 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
488
489
|
// @cast-boundary engine-bridge — searchAdapter is an optional context-extension
|
|
489
490
|
const searchAdapter = (contextWithObservability as { searchAdapter?: SearchAdapter })
|
|
490
491
|
.searchAdapter;
|
|
492
|
+
// Adapter is built before the registry exists (app boot order) — hand it
|
|
493
|
+
// the registry-derived default config here so it can lazily configure
|
|
494
|
+
// each tenant index on first access instead of requiring per-tenant app
|
|
495
|
+
// wiring.
|
|
496
|
+
const derivedSearchConfig = searchAdapter
|
|
497
|
+
? deriveSearchAdapterConfig(options.registry)
|
|
498
|
+
: undefined;
|
|
499
|
+
if (searchAdapter && derivedSearchConfig) {
|
|
500
|
+
searchAdapter.setDefaultConfig?.(derivedSearchConfig);
|
|
501
|
+
}
|
|
491
502
|
|
|
492
503
|
const sseConsumerEnabled = options.eventDispatcher?.systemConsumers?.sse ?? true;
|
|
493
504
|
const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -12,6 +12,26 @@ export type SseEvent = {
|
|
|
12
12
|
data: Record<string, unknown>;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// What a publish invalidates. "user" is the broadest (and the historical
|
|
16
|
+
// default — every scope narrower than this is an opt-in from a caller that
|
|
17
|
+
// knows exactly which credential(s) it revoked).
|
|
18
|
+
export type AccessInvalidationScope =
|
|
19
|
+
| { readonly kind: "user" }
|
|
20
|
+
| { readonly kind: "all-except-session"; readonly keptSessionId: string }
|
|
21
|
+
| { readonly kind: "sessions"; readonly sessionIds: readonly string[] }
|
|
22
|
+
| { readonly kind: "pat-tokens"; readonly tokenIds: readonly string[] };
|
|
23
|
+
|
|
24
|
+
// What a stream's own listener is authenticated as — used to decide whether
|
|
25
|
+
// a narrow scope (sessions/pat-tokens) applies to it. A listener with
|
|
26
|
+
// neither field (should not normally happen — dispatch-stream.ts always
|
|
27
|
+
// passes at least one) is treated as unidentifiable and closed by every
|
|
28
|
+
// narrow scope, same fail-closed stance as a PAT stream facing a
|
|
29
|
+
// sessions-scope.
|
|
30
|
+
export type AccessInvalidationCredential = {
|
|
31
|
+
readonly sid?: string;
|
|
32
|
+
readonly patTokenId?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
15
35
|
export type SseBroker = {
|
|
16
36
|
addClient(channel: string, send: (event: SseEvent) => void, close: () => void): string;
|
|
17
37
|
removeClient(channel: string, clientId: string): void;
|
|
@@ -27,22 +47,37 @@ export type SseBroker = {
|
|
|
27
47
|
subscribeAccessInvalidation(
|
|
28
48
|
userId: string,
|
|
29
49
|
onInvalidate: () => void,
|
|
30
|
-
|
|
50
|
+
credential?: AccessInvalidationCredential,
|
|
31
51
|
): () => void;
|
|
32
|
-
// `
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
publishAccessInvalidation(userId: string, keptSessionId?: string): void;
|
|
52
|
+
// `scope` narrows which credential(s) close; omitted = userwide (every
|
|
53
|
+
// listener), the historical default and still what unrelated
|
|
54
|
+
// reasons (role change, membership removal) use.
|
|
55
|
+
publishAccessInvalidation(userId: string, scope?: AccessInvalidationScope): void;
|
|
37
56
|
};
|
|
38
57
|
|
|
39
|
-
// Fail-closed:
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
// Fail-closed: a narrow scope only spares/limits a listener it can positively
|
|
59
|
+
// match against its own credential. A listener with neither sid nor
|
|
60
|
+
// patTokenId of its own can't be matched by "sessions"/"pat-tokens", so it
|
|
61
|
+
// closes — same reasoning "all-except-session" already used for a sidless
|
|
62
|
+
// (PAT/bearer) listener.
|
|
63
|
+
export function shouldInvalidateListener(
|
|
64
|
+
credential: AccessInvalidationCredential,
|
|
65
|
+
scope: AccessInvalidationScope,
|
|
44
66
|
): boolean {
|
|
45
|
-
|
|
67
|
+
switch (scope.kind) {
|
|
68
|
+
case "user":
|
|
69
|
+
return true;
|
|
70
|
+
case "all-except-session":
|
|
71
|
+
return credential.sid !== scope.keptSessionId;
|
|
72
|
+
case "sessions":
|
|
73
|
+
return credential.sid !== undefined
|
|
74
|
+
? scope.sessionIds.includes(credential.sid)
|
|
75
|
+
: credential.patTokenId === undefined;
|
|
76
|
+
case "pat-tokens":
|
|
77
|
+
return credential.patTokenId !== undefined
|
|
78
|
+
? scope.tokenIds.includes(credential.patTokenId)
|
|
79
|
+
: credential.sid === undefined;
|
|
80
|
+
}
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
export function createSseBroker(): SseBroker {
|
|
@@ -51,11 +86,14 @@ export function createSseBroker(): SseBroker {
|
|
|
51
86
|
// makes pushToChannel/publishAccessInvalidation reach every replica's
|
|
52
87
|
// clients — this reference implementation stays single-process only.
|
|
53
88
|
const channels = new Map<string, Map<string, SseClient>>();
|
|
54
|
-
// Keyed by callback reference, value is the subscriber's own
|
|
55
|
-
// subscriber must pass a distinct closure (dispatch-stream.ts does,
|
|
56
|
-
// stream). Two subscribes with the SAME reference for the same
|
|
57
|
-
// collapse into one listener, and the first unsubscribe kills both.
|
|
58
|
-
const accessInvalidationListeners = new Map<
|
|
89
|
+
// Keyed by callback reference, value is the subscriber's own credential.
|
|
90
|
+
// Every subscriber must pass a distinct closure (dispatch-stream.ts does,
|
|
91
|
+
// one per stream). Two subscribes with the SAME reference for the same
|
|
92
|
+
// user collapse into one listener, and the first unsubscribe kills both.
|
|
93
|
+
const accessInvalidationListeners = new Map<
|
|
94
|
+
string,
|
|
95
|
+
Map<() => void, AccessInvalidationCredential>
|
|
96
|
+
>();
|
|
59
97
|
|
|
60
98
|
function getOrCreateChannel(channel: string): Map<string, SseClient> {
|
|
61
99
|
let clients = channels.get(channel);
|
|
@@ -103,14 +141,14 @@ export function createSseBroker(): SseBroker {
|
|
|
103
141
|
return total;
|
|
104
142
|
},
|
|
105
143
|
|
|
106
|
-
subscribeAccessInvalidation(userId, onInvalidate,
|
|
144
|
+
subscribeAccessInvalidation(userId, onInvalidate, credential) {
|
|
107
145
|
const channel = userAccessChannel(userId);
|
|
108
146
|
let listeners = accessInvalidationListeners.get(channel);
|
|
109
147
|
if (!listeners) {
|
|
110
148
|
listeners = new Map();
|
|
111
149
|
accessInvalidationListeners.set(channel, listeners);
|
|
112
150
|
}
|
|
113
|
-
listeners.set(onInvalidate,
|
|
151
|
+
listeners.set(onInvalidate, credential ?? {});
|
|
114
152
|
return () => {
|
|
115
153
|
const current = accessInvalidationListeners.get(channel);
|
|
116
154
|
// skip: already unsubscribed (e.g. stream ended after a publish already fired)
|
|
@@ -120,15 +158,16 @@ export function createSseBroker(): SseBroker {
|
|
|
120
158
|
};
|
|
121
159
|
},
|
|
122
160
|
|
|
123
|
-
publishAccessInvalidation(userId,
|
|
161
|
+
publishAccessInvalidation(userId, scope) {
|
|
124
162
|
const channel = userAccessChannel(userId);
|
|
125
163
|
const listeners = accessInvalidationListeners.get(channel);
|
|
126
164
|
// skip: no live stream is watching this user right now
|
|
127
165
|
if (!listeners) return;
|
|
166
|
+
const resolvedScope: AccessInvalidationScope = scope ?? { kind: "user" };
|
|
128
167
|
// Snapshot before iterating — a fired listener unsubscribes itself,
|
|
129
168
|
// which would mutate `listeners` mid-iteration otherwise.
|
|
130
|
-
for (const [onInvalidate,
|
|
131
|
-
if (
|
|
169
|
+
for (const [onInvalidate, credential] of [...listeners]) {
|
|
170
|
+
if (!shouldInvalidateListener(credential, resolvedScope)) continue;
|
|
132
171
|
onInvalidate();
|
|
133
172
|
}
|
|
134
173
|
},
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Guards the nextWrite null-socket backport (porsager/postgres#1209) on the
|
|
2
|
+
// @bender0oo0/postgres fork pinned in package.json: reserve a connection, kill
|
|
3
|
+
// the backend socket mid-flight, then send another query on the reserved
|
|
4
|
+
// connection. Upstream postgres 3.4.9 crashes the process with an
|
|
5
|
+
// uncaughtException ("null is not an object (evaluating 'socket.write')")
|
|
6
|
+
// instead of rejecting; the fork rejects with CONNECTION_CLOSED. Must stay
|
|
7
|
+
// green after switching back to an upstream release (kumiko-framework#3248).
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
10
|
+
import net from "node:net";
|
|
11
|
+
import postgres from "postgres";
|
|
12
|
+
import { testDatabaseUrl } from "../../testing/closed-connection-error";
|
|
13
|
+
|
|
14
|
+
const DATABASE_URL = testDatabaseUrl();
|
|
15
|
+
const dbUrl = new URL(DATABASE_URL);
|
|
16
|
+
|
|
17
|
+
let uncaught: unknown;
|
|
18
|
+
function onUncaught(err: unknown): void {
|
|
19
|
+
uncaught = err;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
uncaught = undefined;
|
|
24
|
+
process.on("uncaughtException", onUncaught);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
process.off("uncaughtException", onUncaught);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("reserved connection rejects with CONNECTION_CLOSED instead of crashing on a dead socket", async () => {
|
|
32
|
+
let downstream: net.Socket | undefined;
|
|
33
|
+
const proxy = net.createServer((socket) => {
|
|
34
|
+
downstream = socket;
|
|
35
|
+
const upstream = net.connect(Number(dbUrl.port), dbUrl.hostname);
|
|
36
|
+
socket.pipe(upstream).pipe(socket);
|
|
37
|
+
socket.on("error", () => upstream.destroy());
|
|
38
|
+
upstream.on("error", () => socket.destroy());
|
|
39
|
+
});
|
|
40
|
+
await new Promise<void>((resolve) => proxy.listen(0, resolve));
|
|
41
|
+
const address = proxy.address();
|
|
42
|
+
if (address === null || typeof address === "string") {
|
|
43
|
+
throw new Error("proxy did not bind to a TCP port");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sql = postgres({
|
|
47
|
+
host: "127.0.0.1",
|
|
48
|
+
port: address.port,
|
|
49
|
+
user: dbUrl.username,
|
|
50
|
+
password: dbUrl.password,
|
|
51
|
+
database: dbUrl.pathname.slice(1),
|
|
52
|
+
max: 1,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const reserved = await sql.reserve();
|
|
57
|
+
try {
|
|
58
|
+
await reserved`select 1`;
|
|
59
|
+
downstream?.end();
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
61
|
+
|
|
62
|
+
const HANG = Symbol("hang");
|
|
63
|
+
const result = await Promise.race([
|
|
64
|
+
reserved`select 1`.then(
|
|
65
|
+
() => {
|
|
66
|
+
throw new Error("expected the query to reject with CONNECTION_CLOSED, but it resolved");
|
|
67
|
+
},
|
|
68
|
+
(err: unknown) => err,
|
|
69
|
+
),
|
|
70
|
+
new Promise((resolve) => setTimeout(() => resolve(HANG), 3000)),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
if (result === HANG) {
|
|
74
|
+
throw new Error("query on the dead-socket connection hung instead of rejecting");
|
|
75
|
+
}
|
|
76
|
+
expect(result).toMatchObject({ code: "CONNECTION_CLOSED" });
|
|
77
|
+
// Give the process a tick to surface an uncaughtException if the guard regressed.
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
79
|
+
expect(uncaught).toBeUndefined();
|
|
80
|
+
} finally {
|
|
81
|
+
reserved.release();
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
proxy.close();
|
|
85
|
+
await sql.end({ timeout: 1 });
|
|
86
|
+
}
|
|
87
|
+
});
|
package/src/bun-db/connection.ts
CHANGED
|
@@ -54,6 +54,8 @@ export function createBunDbConnection(
|
|
|
54
54
|
if (options.connectTimeoutSeconds !== undefined) {
|
|
55
55
|
pgOpts.connect_timeout = options.connectTimeoutSeconds;
|
|
56
56
|
}
|
|
57
|
+
// ponytail: "postgres" resolves to the @bender0oo0/postgres fork (nextWrite
|
|
58
|
+
// null-socket guard, porsager/postgres#1209); switch back per kumiko-framework#3248.
|
|
57
59
|
const listenClient = postgres(url, pgOpts);
|
|
58
60
|
|
|
59
61
|
return {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,65 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.309.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "LISTEN gauge drops to 0 on a DB outage",
|
|
6
|
+
"detail": "`kumiko_event_dispatcher_listen_connected` stayed at 1 when the LISTEN\nconnection died during a DB outage, although delivery had already fallen\nback to polling. The dispatcher now sets it to 0 when the idle pre-check\ndetects the outage. postgres.js re-LISTENs on its own once the DB is back\n(delayed by its connect backoff, measured 10-30s) and sets it back to 1."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.309.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "event-dispatcher logs when the DB comes back after an outage (fw#3243)",
|
|
12
|
+
"detail": "A DB outage already kept the dispatcher process alive (the postgres pool\nreconnects per query and the dispatcher keeps polling from its cursor) and\nlogged `idle pre-check failed` once per outage, but recovery was silent:\nops had no signal that delivery was back to normal without restarting the\nprocess. The idle pre-check now logs `idle pre-check recovered, database\nreachable again` once the next pass succeeds after a logged failure, no\nrestart needed."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.309.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Pin postgres to a fork carrying the nextWrite null-socket fix (fw#3243)",
|
|
18
|
+
"detail": "The framework now depends on @bender0oo0/postgres@3.4.9-kumiko.1, an exact pin of\npostgres 3.4.9 plus a backport of porsager/postgres#1209. Without the backport, a\nclosed connection can crash the whole process with an uncaughtException\n(\"null is not an object (evaluating 'socket.write')\") instead of rejecting the\nin-flight query with CONNECTION_CLOSED. Apps get the fix automatically with this\nframework bump, no app-side changes needed. The pin is temporary and tracked for\nremoval in kumiko-framework#3248 once the fix lands upstream."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.309.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "setupTestStack creates r.entity() tables — same table list as kumiko schema generate (fw#3102)",
|
|
24
|
+
"detail": "Since 0.290.0 (fw#2881), every authenticated request reads `read_tenants` (rejectIfTenantTeardown) as soon as tenant-lifecycle is mounted. Test stacks needed a manually created tenant table for that to work, because setupTestStack pushed projection/multiStreamProjection/storeTable sources but never r.entity() tables. setupTestStack now creates every entity's backing table itself via collectTableMetas, the same table list `kumiko schema generate` uses, so a feature that only declares r.entity() no longer 42P01s on first write in a test stack. App-side wrappers like `unsafeEnsureEntityTable(db, tenantEntity, \"tenant\")` right after setupTestStack can be removed. Callers that create/migrate entity tables themselves can opt out via `entityTables: false` (used internally by `setupAppTestStack`'s `registryTables: false`), and setupTestStack now drops the ephemeral test DB/Redis if the table DDL fails instead of leaking them."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.308.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "startFrom \"now\" and MSP rebuild hand-off no longer lose out-of-order commits"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"version": "0.308.0",
|
|
33
|
+
"type": "fix",
|
|
34
|
+
"title": "Idle event-dispatcher passes no longer lock consumer rows or write WAL"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"version": "0.308.0",
|
|
38
|
+
"type": "breaking",
|
|
39
|
+
"title": "Boot validator rejects a projectionDetail screen/section/emptyState action with no resolvable icon (fw#3234)",
|
|
40
|
+
"migration": "Every screen.actions / section.actions / section.emptyState.action entry on a projectionDetail screen, and every section.actions entry on an entityEdit screen, must resolve an icon: either an explicit `icon` on the action, or an id the shared action-icon map (@cosmicdrift/kumiko-types resolveActionIcon) already derives from the full id or its last/first kebab segment. entityEdit's own screen-level `actions` are not checked (unchanged, out of scope for this rule). An action that fails this now fails boot instead of rendering without an icon; give it an explicit `icon` or rename it to an id the map covers."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"version": "0.308.0",
|
|
44
|
+
"type": "improvement",
|
|
45
|
+
"title": "EventConsumer.batchHandler — turn-level batching with per-event fallback, used by the search consumer"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"version": "0.308.0",
|
|
49
|
+
"type": "fix",
|
|
50
|
+
"title": "Meilisearch tenant indexes are configured automatically on first access"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"version": "0.308.0",
|
|
54
|
+
"type": "breaking",
|
|
55
|
+
"title": "Session and PAT revoke now close their own credential's open SSE stream, not just other reasons' userwide invalidations",
|
|
56
|
+
"migration": "An app-injected SseBroker implementation must update its subscribeAccessInvalidation/publishAccessInvalidation signatures: the third parameter of subscribeAccessInvalidation is now an AccessInvalidationCredential object (`{ sid?, patTokenId? }`) instead of a bare sid string, and the second parameter of publishAccessInvalidation is now an AccessInvalidationScope object (`{ kind: \"user\" } | { kind: \"all-except-session\"; keptSessionId } | { kind: \"sessions\"; sessionIds } | { kind: \"pat-tokens\"; tokenIds }`) instead of a bare keptSessionId string. Example: `subscribeAccessInvalidation(userId, cb, sid)` becomes `subscribeAccessInvalidation(userId, cb, { sid })`, and `publishAccessInvalidation(userId, keptSessionId)` becomes `publishAccessInvalidation(userId, { kind: \"all-except-session\", keptSessionId })`."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"version": "0.308.0",
|
|
60
|
+
"type": "fix",
|
|
61
|
+
"title": "zod namespace import lets client bundles tree-shake unused locales"
|
|
62
|
+
},
|
|
2
63
|
{
|
|
3
64
|
"version": "0.307.0",
|
|
4
65
|
"type": "fix",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// Schema — wer die overriden will, würde die Profile-Identitaet
|
|
18
18
|
// zerstören.
|
|
19
19
|
|
|
20
|
-
import
|
|
20
|
+
import * as z from "zod";
|
|
21
21
|
|
|
22
22
|
// DurationSpec: { days } | { hours } — strict bedeutet beide Forms
|
|
23
23
|
// muessen exakt 1 property haben (kein "{ days: 30, hours: 1 }").
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
7
|
import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
|
|
8
|
-
import
|
|
8
|
+
import * as z from "zod";
|
|
9
9
|
import { createRegistry, defineFeature } from "../../engine";
|
|
10
10
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
11
11
|
import {
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
type SubjectId,
|
|
4
4
|
subjectIdToKey,
|
|
5
5
|
} from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
6
|
-
import
|
|
6
|
+
import * as z from "zod";
|
|
7
7
|
import type { TenantId } from "../engine/types/identifiers";
|
|
8
8
|
|
|
9
9
|
export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Regression test for #255-class drift: storeTables must appear in
|
|
2
|
-
// enumerateFeatureTableSources so setupTestStack's
|
|
3
|
-
//
|
|
2
|
+
// enumerateFeatureTableSources so collectTableMetas (shared by setupTestStack's
|
|
3
|
+
// auto-push and `kumiko schema generate`) sees the exact same table set. Before this fix,
|
|
4
4
|
// unmanaged tables were only handled by collectTableMetas directly, so any
|
|
5
5
|
// app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
|
|
6
6
|
// its store tables created — e.g. the bundled "sessions" feature's
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
8
|
import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
9
|
-
import
|
|
9
|
+
import * as z from "zod";
|
|
10
10
|
import { selectMany } from "../../bun-db";
|
|
11
11
|
import {
|
|
12
12
|
createEntity,
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// die frühere Template-Variante sammelte nur entities + storeTables, wodurch
|
|
5
|
-
// projection-only-Tabellen (z.B. billing-foundation read_subscriptions) nie
|
|
6
|
-
// in Migrations landeten und der erste Prod-Write crashte (#255).
|
|
1
|
+
// Canonical table source for both `kumiko schema generate` and setupTestStack
|
|
2
|
+
// (entities, storeTables, projections, multiStreamProjections). One shared
|
|
3
|
+
// source so the test-DB push and migrations cannot drift apart again (#255, #3102).
|
|
7
4
|
|
|
8
5
|
import type { FeatureDefinition } from "../engine/types";
|
|
9
6
|
import { compareByCodepoint } from "../utils";
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Single enumeration of every table-bearing registration on a feature
|
|
2
2
|
// (r.projection, r.multiStreamProjection with table, r.storeTable).
|
|
3
|
-
// Consumed by
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// A new table-bearing registrar must be added HERE, not in the consumers.
|
|
3
|
+
// Consumed by collectTableMetas's Pass 2 (`kumiko schema generate` and
|
|
4
|
+
// setupTestStack both go through collectTableMetas — see there).
|
|
5
|
+
// A new table-bearing registrar must be added HERE, not in the consumer.
|
|
7
6
|
|
|
8
7
|
import type { FeatureDefinition } from "../engine/types";
|
|
9
8
|
|
|
@@ -30,9 +30,60 @@ export async function lockEventConsumersShareMode(db: AnyDb): Promise<void> {
|
|
|
30
30
|
await asRawClient(db).unsafe(`LOCK TABLE "kumiko_event_consumers" IN SHARE MODE`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// The MAX(id) horizon plus every currently-open id gap below it, read from a
|
|
34
|
+
// single snapshot (READ COMMITTED gives each statement its own). A gap can
|
|
35
|
+
// only later fill in if some transaction holding a lower id was still
|
|
36
|
+
// in-flight in that same snapshot — pg_snapshot_xip is empty otherwise, so
|
|
37
|
+
// the gap CTEs short-circuit and the window scan over kumiko_events never
|
|
38
|
+
// runs. Used both to seed startFrom "now" (#3231) and to seed an MSP
|
|
39
|
+
// rebuild's handoff to the live dispatcher (msp-rebuild.ts).
|
|
40
|
+
export async function selectEventIdHorizonWithMissingRanges(
|
|
41
|
+
db: AnyDb,
|
|
42
|
+
): Promise<{ readonly horizon: bigint; readonly gaps: PendingGapEntry[] }> {
|
|
43
|
+
const rows = (await asRawClient(db).unsafe(
|
|
44
|
+
`WITH b AS (
|
|
45
|
+
SELECT COALESCE(MAX("id"), 0) AS horizon, MIN("id") AS min_id FROM "kumiko_events"
|
|
46
|
+
),
|
|
47
|
+
r AS (
|
|
48
|
+
SELECT 1::bigint AS f, b.min_id - 1 AS t FROM b
|
|
49
|
+
WHERE b.min_id > 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
|
|
50
|
+
UNION ALL
|
|
51
|
+
SELECT w."id" + 1, w.next_id - 1
|
|
52
|
+
FROM (SELECT "id", lead("id") OVER (ORDER BY "id") AS next_id FROM "kumiko_events") w
|
|
53
|
+
WHERE w.next_id > w."id" + 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
|
|
54
|
+
)
|
|
55
|
+
SELECT b.horizon::text AS horizon,
|
|
56
|
+
pg_snapshot_xmax(pg_current_snapshot())::text AS xmax,
|
|
57
|
+
r.f::text AS gap_from,
|
|
58
|
+
r.t::text AS gap_to
|
|
59
|
+
FROM b LEFT JOIN r ON true
|
|
60
|
+
ORDER BY r.f`,
|
|
61
|
+
)) as ReadonlyArray<{
|
|
62
|
+
horizon: string;
|
|
63
|
+
xmax: string;
|
|
64
|
+
gap_from: string | null;
|
|
65
|
+
gap_to: string | null;
|
|
66
|
+
}>;
|
|
67
|
+
const first = rows[0];
|
|
68
|
+
if (first === undefined)
|
|
69
|
+
throw new Error("selectEventIdHorizonWithMissingRanges: no row returned");
|
|
70
|
+
const gaps: PendingGapEntry[] = [];
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
if (row.gap_from === null || row.gap_to === null) continue;
|
|
73
|
+
gaps.push({ from: row.gap_from, to: row.gap_to, xmax: row.xmax });
|
|
74
|
+
}
|
|
75
|
+
return { horizon: BigInt(first.horizon), gaps };
|
|
76
|
+
}
|
|
77
|
+
|
|
33
78
|
// startFrom "now" seeds the FIRST-registration cursor at the current
|
|
34
79
|
// MAX(events.id) instead of the column default 0 — mounting a consumer into
|
|
35
|
-
// an existing app then skips the historical log instead of replaying it.
|
|
80
|
+
// an existing app then skips the historical log instead of replaying it. The
|
|
81
|
+
// horizon and pending_gaps are read from one snapshot (selectEventIdHorizon
|
|
82
|
+
// WithMissingRanges): a transaction holding a lower id can still be
|
|
83
|
+
// in-flight at boot, and without seeding those gaps its event would commit
|
|
84
|
+
// after the cursor already sits above it, so `id > cursor` would never
|
|
85
|
+
// deliver it (#3231). The cheap existence check skips the scan entirely for
|
|
86
|
+
// the (overwhelmingly common) case of an already-registered consumer.
|
|
36
87
|
// ON CONFLICT DO NOTHING keeps both branches safe for an existing row: the
|
|
37
88
|
// subquery only ever affects the row this statement inserts.
|
|
38
89
|
export async function insertConsumerIfAbsent(
|
|
@@ -42,11 +93,18 @@ export async function insertConsumerIfAbsent(
|
|
|
42
93
|
startFrom: "beginning" | "now" = "beginning",
|
|
43
94
|
): Promise<void> {
|
|
44
95
|
if (startFrom === "now") {
|
|
96
|
+
const existing = (await asRawClient(db).unsafe(
|
|
97
|
+
`SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`,
|
|
98
|
+
[name, instanceId],
|
|
99
|
+
)) as ReadonlyArray<unknown>;
|
|
100
|
+
// skip: already registered — an existing cursor is never re-seeded.
|
|
101
|
+
if (existing.length > 0) return;
|
|
102
|
+
const { horizon, gaps } = await selectEventIdHorizonWithMissingRanges(db);
|
|
45
103
|
await asRawClient(db).unsafe(
|
|
46
|
-
`INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status", "last_processed_event_id")
|
|
47
|
-
VALUES ($1, $2, 'idle',
|
|
104
|
+
`INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status", "last_processed_event_id", "pending_gaps")
|
|
105
|
+
VALUES ($1, $2, 'idle', $3, $4::text::jsonb)
|
|
48
106
|
ON CONFLICT ("name", "instance_id") DO NOTHING`,
|
|
49
|
-
[name, instanceId],
|
|
107
|
+
[name, instanceId, horizon, JSON.stringify(gaps)],
|
|
50
108
|
);
|
|
51
109
|
} else {
|
|
52
110
|
await asRawClient(db).unsafe(
|
|
@@ -83,6 +141,37 @@ export async function deleteOrphanedPerInstanceConsumerRows(
|
|
|
83
141
|
);
|
|
84
142
|
}
|
|
85
143
|
|
|
144
|
+
// Lock-free pre-check for doPass: which (name, instance_id) pairs are
|
|
145
|
+
// provably idle — no locking, no xid, no WAL. A pair only qualifies when its
|
|
146
|
+
// row exists, its status isn't dead (dead must still go through
|
|
147
|
+
// acquireConsumerState for auto-rearm), it has no pending_gaps, and no event
|
|
148
|
+
// exists past its cursor. Paired via unnest so the two positional arrays
|
|
149
|
+
// zip element-wise into rows instead of a cartesian product.
|
|
150
|
+
//
|
|
151
|
+
// `deadStatus` is passed in rather than imported from event-consumer-state.ts
|
|
152
|
+
// (which defines ConsumerStatuses) — that module already imports from this
|
|
153
|
+
// one, so importing back would be a require cycle (same pattern as
|
|
154
|
+
// deleteOrphanedPerInstanceConsumerRows above).
|
|
155
|
+
export async function selectProvablyIdleConsumerPairs(
|
|
156
|
+
db: AnyDb,
|
|
157
|
+
names: readonly string[],
|
|
158
|
+
instanceIds: readonly string[],
|
|
159
|
+
deadStatus: string,
|
|
160
|
+
): Promise<ReadonlyArray<{ readonly name: string; readonly instanceId: string }>> {
|
|
161
|
+
const rows = (await asRawClient(db).unsafe(
|
|
162
|
+
`SELECT c."name" AS name, c."instance_id" AS instance_id
|
|
163
|
+
FROM "kumiko_event_consumers" c
|
|
164
|
+
WHERE ("name", "instance_id") = ANY (SELECT * FROM unnest($1::text[], $2::text[]))
|
|
165
|
+
AND "status" != $3
|
|
166
|
+
AND "pending_gaps" = '[]'::jsonb
|
|
167
|
+
AND NOT EXISTS (
|
|
168
|
+
SELECT 1 FROM "kumiko_events" e WHERE e."id" > c."last_processed_event_id"
|
|
169
|
+
)`,
|
|
170
|
+
[names, instanceIds, deadStatus],
|
|
171
|
+
)) as ReadonlyArray<{ name: string; instance_id: string }>;
|
|
172
|
+
return rows.map((r) => ({ name: r.name, instanceId: r.instance_id }));
|
|
173
|
+
}
|
|
174
|
+
|
|
86
175
|
export async function selectConsumerForUpdateSkipLocked(
|
|
87
176
|
db: AnyDb,
|
|
88
177
|
name: string,
|
|
@@ -249,6 +338,7 @@ export async function updateConsumerRebuildCursor(
|
|
|
249
338
|
name: string,
|
|
250
339
|
instanceId: string,
|
|
251
340
|
lastProcessedEventId: bigint,
|
|
341
|
+
pendingGaps: readonly PendingGapEntry[],
|
|
252
342
|
): Promise<void> {
|
|
253
343
|
await asRawClient(db).unsafe(
|
|
254
344
|
`UPDATE "kumiko_event_consumers" SET
|
|
@@ -256,9 +346,11 @@ export async function updateConsumerRebuildCursor(
|
|
|
256
346
|
"status" = 'idle',
|
|
257
347
|
"attempts" = 0,
|
|
258
348
|
"last_error" = NULL,
|
|
349
|
+
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
|
|
350
|
+
"pending_gaps" = $2::text::jsonb,
|
|
259
351
|
"updated_at" = now()
|
|
260
|
-
WHERE "name" = $
|
|
261
|
-
[lastProcessedEventId, name, instanceId],
|
|
352
|
+
WHERE "name" = $3 AND "instance_id" = $4`,
|
|
353
|
+
[lastProcessedEventId, JSON.stringify(pendingGaps), name, instanceId],
|
|
262
354
|
);
|
|
263
355
|
}
|
|
264
356
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
8
|
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
9
|
-
import
|
|
9
|
+
import * as z from "zod";
|
|
10
10
|
import { defineFeature, EXT_DERIVATIVE_RENDERER } from "../../engine";
|
|
11
11
|
import { InternalError, NotFoundError, writeFailure } from "../../errors";
|
|
12
12
|
import { createFilesFeature } from "../../files/feature";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Shared binary-deletion helper: deletes an original storage key plus every
|
|
2
|
+
// derivative rendered from it (thumbnails/resized variants — see
|
|
3
|
+
// derivatives-context.ts). Derivatives are never tracked anywhere but the
|
|
4
|
+
// storage layer: they're rendered under a deterministic key (deriveKey +
|
|
5
|
+
// variantSuffix) computed from the original's key, on demand, and nothing
|
|
6
|
+
// writes that key back onto a FileRef row. Listing the original's key-prefix
|
|
7
|
+
// and keeping only candidates whose suffix matches the derivative grammar,
|
|
8
|
+
// anchored to the original's own basename AND extension, means a
|
|
9
|
+
// same-directory sibling (a different file, possibly another tenant's) shares
|
|
10
|
+
// the list prefix but fails the extension/suffix check and is never touched.
|
|
11
|
+
//
|
|
12
|
+
// Used by both the user-data-rights-defaults forget hook and the
|
|
13
|
+
// data-retention hardDelete cleanup — moved here so neither copies the logic.
|
|
14
|
+
|
|
15
|
+
import { assertSafeStorageKey } from "../files/types";
|
|
16
|
+
import { derivativeListPrefix, isDerivativeKeyOf } from "./variant-key";
|
|
17
|
+
|
|
18
|
+
export type StoredFileStore = {
|
|
19
|
+
list(prefix: string): Promise<readonly string[]>;
|
|
20
|
+
delete(key: string): Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Wrap rather than let a raw provider error (e.g. S3 AccessDenied) surface
|
|
24
|
+
// unexplained — list() is a requirement on top of delete(); a bucket policy
|
|
25
|
+
// granting only s3:DeleteObject would otherwise fail every run instead of
|
|
26
|
+
// just leaving derivatives behind. Name the likely cause so an operator
|
|
27
|
+
// doesn't have to guess. `errorContext` names the caller for the message
|
|
28
|
+
// prefix (e.g. "user-data-rights-defaults:fileRef", "data-retention:hardDelete").
|
|
29
|
+
export async function listDerivativeKeys(
|
|
30
|
+
originalKey: string,
|
|
31
|
+
store: Pick<StoredFileStore, "list">,
|
|
32
|
+
errorContext: string,
|
|
33
|
+
): Promise<readonly string[]> {
|
|
34
|
+
let candidates: readonly string[];
|
|
35
|
+
try {
|
|
36
|
+
candidates = await store.list(derivativeListPrefix(originalKey));
|
|
37
|
+
} catch (err) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`[${errorContext}] provider.list() failed while looking up derivatives of key=${originalKey} — forget/erasure requires list permission on the storage bucket (e.g. s3:ListBucket), not just delete. Original error: ${err instanceof Error ? err.message : String(err)}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const derivativeKeys: string[] = [];
|
|
43
|
+
for (const candidate of candidates) {
|
|
44
|
+
if (!isDerivativeKeyOf(originalKey, candidate)) continue;
|
|
45
|
+
try {
|
|
46
|
+
assertSafeStorageKey(candidate);
|
|
47
|
+
} catch {
|
|
48
|
+
// ponytail: a listing hit that fails the storage-key grammar can't be
|
|
49
|
+
// a real derivative (every derivative is written via a validated
|
|
50
|
+
// write()) — skip rather than pass a malformed key to delete().
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
derivativeKeys.push(candidate);
|
|
54
|
+
}
|
|
55
|
+
return derivativeKeys;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Deletes the original key + every derivative. Returns the keys whose delete
|
|
59
|
+
// threw — callers fail closed on a non-empty list (delete is idempotent, so a
|
|
60
|
+
// retry on the next run converges). assertSafeStorageKey guards the original
|
|
61
|
+
// AND every derivative right before the delete call, not just at listing time.
|
|
62
|
+
export async function deleteStoredFileAndDerivatives(
|
|
63
|
+
originalKey: string,
|
|
64
|
+
store: StoredFileStore,
|
|
65
|
+
errorContext: string,
|
|
66
|
+
): Promise<readonly string[]> {
|
|
67
|
+
assertSafeStorageKey(originalKey);
|
|
68
|
+
const keysToDelete = [
|
|
69
|
+
originalKey,
|
|
70
|
+
...(await listDerivativeKeys(originalKey, store, errorContext)),
|
|
71
|
+
];
|
|
72
|
+
const failedKeys: string[] = [];
|
|
73
|
+
for (const key of keysToDelete) {
|
|
74
|
+
try {
|
|
75
|
+
assertSafeStorageKey(key);
|
|
76
|
+
await store.delete(key);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
failedKeys.push(key);
|
|
79
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
80
|
+
console.warn(
|
|
81
|
+
`[${errorContext}] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return failedKeys;
|
|
86
|
+
}
|
package/src/derivatives/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { StoredFileStore } from "./delete-stored-file";
|
|
2
|
+
export { deleteStoredFileAndDerivatives, listDerivativeKeys } from "./delete-stored-file";
|
|
1
3
|
export type { DerivativesContextDeps } from "./derivatives-context";
|
|
2
4
|
export { createDerivativesContext, resolveRenderer } from "./derivatives-context";
|
|
3
5
|
export { resolveFieldVariant } from "./field-variants";
|