@cosmicdrift/kumiko-framework 0.157.2 → 0.159.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +66 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +4 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +83 -17
- package/src/api/index.ts +8 -4
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +34 -2
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +92 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +10 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +2 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/define-handler.ts +94 -0
- package/src/engine/types/entity-handlers.ts +30 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +45 -0
- package/src/engine/types/fields.ts +19 -31
- package/src/engine/types/handlers.ts +7 -26
- package/src/engine/types/hooks.ts +1 -15
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +34 -9
- package/src/engine/types/ownership.ts +83 -0
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +0 -46
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/entrypoint/index.ts +2 -2
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/event-store.ts +28 -32
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/types.ts +22 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/dispatch-shared.ts +12 -2
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +13 -39
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/ui-types/index.ts +7 -7
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isExtensionEditSection, normalizeListColumn } from "../engine/screen-helpers";
|
|
1
2
|
import type {
|
|
2
3
|
ActionFormScreenDefinition,
|
|
3
4
|
ConfigEditScreenDefinition,
|
|
@@ -15,7 +16,6 @@ import type {
|
|
|
15
16
|
ToolbarAction,
|
|
16
17
|
WorkspaceDefinition,
|
|
17
18
|
} from "../engine/types";
|
|
18
|
-
import { isExtensionEditSection, normalizeListColumn } from "../engine/types/screen";
|
|
19
19
|
|
|
20
20
|
/** Pseudo-entity for actionForm field labels (renderer action-form-shim). */
|
|
21
21
|
export const ACTION_FORM_ENTITY = "__action-form__";
|
package/src/logging/types.ts
CHANGED
|
@@ -1,7 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
info(msg: string, data?: Record<string, unknown>): void;
|
|
3
|
-
warn(msg: string, data?: Record<string, unknown>): void;
|
|
4
|
-
error(msg: string, data?: Record<string, unknown>): void;
|
|
5
|
-
debug(msg: string, data?: Record<string, unknown>): void;
|
|
6
|
-
child(context: Record<string, unknown>): Logger;
|
|
7
|
-
};
|
|
1
|
+
export * from "@cosmicdrift/kumiko-types/logger";
|
|
@@ -1,29 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// each module stays focused; consumers still import from "./types".
|
|
3
|
-
|
|
4
|
-
export type {
|
|
5
|
-
Counter,
|
|
6
|
-
Gauge,
|
|
7
|
-
Histogram,
|
|
8
|
-
Meter,
|
|
9
|
-
MetricDefinition,
|
|
10
|
-
MetricLabels,
|
|
11
|
-
MetricsHandle,
|
|
12
|
-
MetricType,
|
|
13
|
-
} from "./metric";
|
|
14
|
-
export type {
|
|
15
|
-
ObservabilityOptions,
|
|
16
|
-
ObservabilityProvider,
|
|
17
|
-
SamplingConfig,
|
|
18
|
-
SensitiveFilterConfig,
|
|
19
|
-
} from "./provider";
|
|
20
|
-
export type {
|
|
21
|
-
SerializedTraceContext,
|
|
22
|
-
Span,
|
|
23
|
-
SpanAttributes,
|
|
24
|
-
SpanAttributeValue,
|
|
25
|
-
SpanKind,
|
|
26
|
-
SpanStatus,
|
|
27
|
-
StartSpanOptions,
|
|
28
|
-
Tracer,
|
|
29
|
-
} from "./span";
|
|
1
|
+
export * from "@cosmicdrift/kumiko-types/observability";
|
|
@@ -1,56 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// (MetricsHandle); the framework wires Counter/Histogram/Gauge behind that.
|
|
3
|
-
|
|
4
|
-
export type MetricLabels = Record<string, string | number | boolean>;
|
|
5
|
-
|
|
6
|
-
export type MetricType = "counter" | "histogram" | "gauge";
|
|
7
|
-
|
|
8
|
-
export type MetricDefinition = {
|
|
9
|
-
// Fully-qualified name (with kumiko_<feature>_ prefix already applied).
|
|
10
|
-
readonly name: string;
|
|
11
|
-
readonly type: MetricType;
|
|
12
|
-
readonly description?: string;
|
|
13
|
-
// Declared label keys. Inc/observe calls with unknown label keys throw.
|
|
14
|
-
readonly labels?: readonly string[];
|
|
15
|
-
// Buckets only for histogram. If omitted, provider-default is used.
|
|
16
|
-
readonly buckets?: readonly number[];
|
|
17
|
-
readonly unit?: string;
|
|
18
|
-
// If true, the framework auto-injects tenant_id from the active ctx.
|
|
19
|
-
// Default false — adding tenant_id multiplies cardinality by tenant count.
|
|
20
|
-
readonly tenantLabel?: boolean;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export interface Counter {
|
|
24
|
-
inc(value?: number, labels?: MetricLabels): void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface Histogram {
|
|
28
|
-
observe(value: number, labels?: MetricLabels): void;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface Gauge {
|
|
32
|
-
set(value: number, labels?: MetricLabels): void;
|
|
33
|
-
inc(value?: number, labels?: MetricLabels): void;
|
|
34
|
-
dec(value?: number, labels?: MetricLabels): void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface Meter {
|
|
38
|
-
// Called once per metric during boot. Duplicate names throw.
|
|
39
|
-
registerMetric(def: MetricDefinition): void;
|
|
40
|
-
// Lookup by fully-qualified name. Unknown names throw — typed access only.
|
|
41
|
-
counter(name: string): Counter;
|
|
42
|
-
histogram(name: string): Histogram;
|
|
43
|
-
gauge(name: string): Gauge;
|
|
44
|
-
// List of registered definitions — used by ctx.metrics to validate labels.
|
|
45
|
-
definitions(): ReadonlyMap<string, MetricDefinition>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Public handle for feature code — ctx.metrics.
|
|
49
|
-
// Name resolution uses the fully-qualified name (kumiko_<feature>_<short>).
|
|
50
|
-
// The registrar resolves the short name from the calling feature context
|
|
51
|
-
// at boot time; at handler time the map is ready.
|
|
52
|
-
export interface MetricsHandle {
|
|
53
|
-
inc(name: string, labels?: MetricLabels, value?: number): void;
|
|
54
|
-
observe(name: string, value: number, labels?: MetricLabels): void;
|
|
55
|
-
set(name: string, value: number, labels?: MetricLabels): void;
|
|
56
|
-
}
|
|
1
|
+
export * from "@cosmicdrift/kumiko-types/observability/metric";
|
|
@@ -1,32 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// console, otlp, prometheus) fulfil. Configuration types that consumers
|
|
3
|
-
// (buildServer, setupTestStack) hand in also live here.
|
|
4
|
-
|
|
5
|
-
import type { Meter } from "./metric";
|
|
6
|
-
import type { Tracer } from "./span";
|
|
7
|
-
|
|
8
|
-
export type SamplingConfig = {
|
|
9
|
-
// Base sampling rate 0..1. Default 1 in v1 (sample everything).
|
|
10
|
-
readonly tracing?: number;
|
|
11
|
-
readonly alwaysOnError?: boolean;
|
|
12
|
-
readonly alwaysOnSlow?: { readonly thresholdMs: number };
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export type SensitiveFilterConfig = {
|
|
16
|
-
readonly redactedHeaders: readonly string[];
|
|
17
|
-
readonly redactedQueryParams: readonly string[];
|
|
18
|
-
readonly redactedAttributeKeyPatterns: readonly RegExp[];
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export type ObservabilityOptions = {
|
|
22
|
-
readonly sampling?: SamplingConfig;
|
|
23
|
-
readonly sensitiveFilter?: Partial<SensitiveFilterConfig>;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export interface ObservabilityProvider {
|
|
27
|
-
readonly name: string;
|
|
28
|
-
readonly tracer: Tracer;
|
|
29
|
-
readonly meter: Meter;
|
|
30
|
-
// Graceful flush. Called from framework lifecycle shutdown.
|
|
31
|
-
shutdown(): Promise<void>;
|
|
32
|
-
}
|
|
1
|
+
export * from "@cosmicdrift/kumiko-types/observability/provider";
|
|
@@ -1,58 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// all speak this — everything else (middleware, dispatcher, db, redis, jobs)
|
|
3
|
-
// is provider-agnostic.
|
|
4
|
-
|
|
5
|
-
export type SpanAttributeValue = string | number | boolean;
|
|
6
|
-
export type SpanAttributes = Record<string, SpanAttributeValue>;
|
|
7
|
-
|
|
8
|
-
export type SpanStatus = "unset" | "ok" | "error";
|
|
9
|
-
|
|
10
|
-
export type SpanKind = "internal" | "server" | "client" | "producer" | "consumer";
|
|
11
|
-
|
|
12
|
-
// Serialized form of a trace context — what we pass across process boundaries
|
|
13
|
-
// (outbox row, BullMQ job payload). Matches W3C trace-context spec loosely,
|
|
14
|
-
// but staying minimal — a full W3C parser is v2.
|
|
15
|
-
export type SerializedTraceContext = {
|
|
16
|
-
readonly traceId: string;
|
|
17
|
-
readonly spanId: string;
|
|
18
|
-
readonly baggage?: Readonly<Record<string, string>>;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export type StartSpanOptions = {
|
|
22
|
-
// Either a live Span (normal in-process parent) or a serialized trace
|
|
23
|
-
// context (cross-process — outbox row, job payload). Both carry traceId
|
|
24
|
-
// and spanId, so the tracer reads them uniformly. Omitted → fall back to
|
|
25
|
-
// the AsyncLocalStorage active span.
|
|
26
|
-
readonly parent?: Span | SerializedTraceContext;
|
|
27
|
-
readonly attributes?: SpanAttributes;
|
|
28
|
-
readonly kind?: SpanKind;
|
|
29
|
-
readonly startTime?: number;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export interface Span {
|
|
33
|
-
readonly traceId: string;
|
|
34
|
-
readonly spanId: string;
|
|
35
|
-
readonly parentSpanId: string | undefined;
|
|
36
|
-
readonly name: string;
|
|
37
|
-
setAttribute(key: string, value: SpanAttributeValue): void;
|
|
38
|
-
setAttributes(attrs: SpanAttributes): void;
|
|
39
|
-
setStatus(status: SpanStatus, message?: string): void;
|
|
40
|
-
recordException(error: Error): void;
|
|
41
|
-
end(endTime?: number): void;
|
|
42
|
-
// Whether end() has been called. Idempotency guard for auto-wrappers.
|
|
43
|
-
readonly ended: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface Tracer {
|
|
47
|
-
startSpan(name: string, options?: StartSpanOptions): Span;
|
|
48
|
-
// Runs fn inside the span context (AsyncLocalStorage), ends the span
|
|
49
|
-
// automatically — including on thrown errors, where the error is recorded
|
|
50
|
-
// and status set to "error" before re-throwing.
|
|
51
|
-
withSpan<T>(
|
|
52
|
-
name: string,
|
|
53
|
-
optionsOrFn: StartSpanOptions | ((span: Span) => Promise<T>),
|
|
54
|
-
fn?: (span: Span) => Promise<T>,
|
|
55
|
-
): Promise<T>;
|
|
56
|
-
// Current active span from AsyncLocalStorage, or undefined.
|
|
57
|
-
getActiveSpan(): Span | undefined;
|
|
58
|
-
}
|
|
1
|
+
export * from "@cosmicdrift/kumiko-types/observability/span";
|
|
@@ -509,7 +509,7 @@ describe("dispatcher feature-gate", () => {
|
|
|
509
509
|
r.queryHandler(
|
|
510
510
|
"check",
|
|
511
511
|
z.object({}).passthrough(),
|
|
512
|
-
async (_event, ctx) => ({ enabled: ctx.hasFeature("toggled") }),
|
|
512
|
+
async (_event, ctx) => ({ enabled: await ctx.hasFeature("toggled") }),
|
|
513
513
|
{ access: { openToAll: true } },
|
|
514
514
|
);
|
|
515
515
|
});
|
|
@@ -533,6 +533,43 @@ describe("dispatcher feature-gate", () => {
|
|
|
533
533
|
enabled: false,
|
|
534
534
|
});
|
|
535
535
|
});
|
|
536
|
+
|
|
537
|
+
test("issue #1233: ctx.hasFeature falls back to trialGate for a companion feature the tenant doesn't own", async () => {
|
|
538
|
+
// Regression for the trial-tenant bug: a handler owned by an always-on
|
|
539
|
+
// feature checks a DIFFERENT (trial-tier) feature via ctx.hasFeature.
|
|
540
|
+
// The sync resolver never carries trial features, so without the
|
|
541
|
+
// trialGate fallback this silently reads false for a trial tenant even
|
|
542
|
+
// though the tenant is genuinely in-trial for that companion feature —
|
|
543
|
+
// mirrors packages/bundled-features/src/managed-pages/handlers/branding.query.ts
|
|
544
|
+
// checking MANAGED_PAGES_CSS_FEATURE, which no handler owns.
|
|
545
|
+
const trialTenant = "00000000-0000-4000-8000-0000000000c5" as TenantId;
|
|
546
|
+
const plainTenant = "00000000-0000-4000-8000-0000000000d6" as TenantId;
|
|
547
|
+
|
|
548
|
+
const probe = defineFeature("probe", (r) => {
|
|
549
|
+
r.queryHandler(
|
|
550
|
+
"check",
|
|
551
|
+
z.object({}).passthrough(),
|
|
552
|
+
async (_event, ctx) => ({ enabled: await ctx.hasFeature("premium-companion") }),
|
|
553
|
+
{ access: { openToAll: true } },
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
const registry = createRegistry([probe]);
|
|
557
|
+
const effectiveFeatures = Object.assign(() => new Set(["probe"]), {
|
|
558
|
+
trialGate: async (tenantId: TenantId, feature: string) =>
|
|
559
|
+
tenantId === trialTenant && feature === "premium-companion",
|
|
560
|
+
});
|
|
561
|
+
const dispatcher = createDispatcher(registry, {}, { effectiveFeatures });
|
|
562
|
+
|
|
563
|
+
const trialUser = createTestUser({ id: "u-c5", tenantId: trialTenant, roles: ["Admin"] });
|
|
564
|
+
const plainUser = createTestUser({ id: "u-d6", tenantId: plainTenant, roles: ["Admin"] });
|
|
565
|
+
|
|
566
|
+
await expect(dispatcher.query("probe:query:check", {}, trialUser)).resolves.toEqual({
|
|
567
|
+
enabled: true,
|
|
568
|
+
});
|
|
569
|
+
await expect(dispatcher.query("probe:query:check", {}, plainUser)).resolves.toEqual({
|
|
570
|
+
enabled: false,
|
|
571
|
+
});
|
|
572
|
+
});
|
|
536
573
|
});
|
|
537
574
|
|
|
538
575
|
describe("write-handler shape guard", () => {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// EventConsumerErrorPolicy.maxAttempts lets a consumer override the
|
|
2
|
+
// dispatcher-wide default (kumiko-framework#1349) — a consumer depending on
|
|
3
|
+
// infra that may still be provisioning at boot (search adapter) needs more
|
|
4
|
+
// retry headroom before it gets dead-lettered. deliverEvents is a pure
|
|
5
|
+
// free function, so this is unit-level: no DB, no dispatcher lifecycle.
|
|
6
|
+
|
|
7
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import type { AppContext } from "../../engine/types";
|
|
9
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
10
|
+
import type { EventConsumer } from "../event-dispatcher";
|
|
11
|
+
import {
|
|
12
|
+
type ConsumerStateRow,
|
|
13
|
+
deliverEvents,
|
|
14
|
+
type StoredEventRow,
|
|
15
|
+
} from "../event-dispatcher-delivery";
|
|
16
|
+
import { createSearchEventConsumer } from "../system-hooks";
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
await ensureTemporalPolyfill();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function stubContext(): AppContext {
|
|
23
|
+
return {
|
|
24
|
+
db: {} as unknown as AppContext["db"],
|
|
25
|
+
redis: {} as unknown as AppContext["redis"],
|
|
26
|
+
registry: {} as unknown as AppContext["registry"],
|
|
27
|
+
} as AppContext;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stubEvent(id: bigint): StoredEventRow {
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
aggregateId: "agg-1",
|
|
34
|
+
aggregateType: "widget",
|
|
35
|
+
tenantId: "tenant-1",
|
|
36
|
+
version: 1,
|
|
37
|
+
type: "widget.created",
|
|
38
|
+
eventVersion: 1,
|
|
39
|
+
payload: {},
|
|
40
|
+
metadata: { userId: "system" },
|
|
41
|
+
createdAt: Temporal.Now.instant(),
|
|
42
|
+
createdBy: "system",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stubState(): ConsumerStateRow {
|
|
47
|
+
return {
|
|
48
|
+
name: "consumer",
|
|
49
|
+
instanceId: "shared",
|
|
50
|
+
lastProcessedEventId: 0n,
|
|
51
|
+
status: "idle",
|
|
52
|
+
attempts: 0,
|
|
53
|
+
rearmCount: 0,
|
|
54
|
+
lastError: null,
|
|
55
|
+
updatedAt: Temporal.Now.instant(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("deliverEvents — per-consumer maxAttempts override", () => {
|
|
60
|
+
test("stays alive past the dispatcher-wide default when errorPolicy.maxAttempts is higher", async () => {
|
|
61
|
+
const consumer: EventConsumer = {
|
|
62
|
+
name: "search",
|
|
63
|
+
errorPolicy: { maxAttempts: 20 },
|
|
64
|
+
handler: async () => {
|
|
65
|
+
throw new Error("meilisearch not provisioned yet");
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const outcome = await deliverEvents(
|
|
70
|
+
consumer,
|
|
71
|
+
[stubEvent(1n)],
|
|
72
|
+
stubContext(),
|
|
73
|
+
/* dispatcher-wide default */ 10,
|
|
74
|
+
{ ...stubState(), attempts: 10 },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(outcome.attempts).toBe(11);
|
|
78
|
+
expect(outcome.deadLettered).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("dead-letters at the override once attempts reach it", async () => {
|
|
82
|
+
const consumer: EventConsumer = {
|
|
83
|
+
name: "search",
|
|
84
|
+
errorPolicy: { maxAttempts: 20 },
|
|
85
|
+
handler: async () => {
|
|
86
|
+
throw new Error("meilisearch not provisioned yet");
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const outcome = await deliverEvents(consumer, [stubEvent(1n)], stubContext(), 10, {
|
|
91
|
+
...stubState(),
|
|
92
|
+
attempts: 19,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(outcome.attempts).toBe(20);
|
|
96
|
+
expect(outcome.deadLettered).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a consumer without the override still dies at the dispatcher-wide default", async () => {
|
|
100
|
+
const consumer: EventConsumer = {
|
|
101
|
+
name: "plain",
|
|
102
|
+
handler: async () => {
|
|
103
|
+
throw new Error("boom");
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const outcome = await deliverEvents(consumer, [stubEvent(1n)], stubContext(), 10, {
|
|
108
|
+
...stubState(),
|
|
109
|
+
attempts: 9,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(outcome.attempts).toBe(10);
|
|
113
|
+
expect(outcome.deadLettered).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("createSearchEventConsumer — errorPolicy wiring", () => {
|
|
118
|
+
test("wires more retry headroom than the dispatcher-wide default", () => {
|
|
119
|
+
const consumer = createSearchEventConsumer(
|
|
120
|
+
{} as unknown as Parameters<typeof createSearchEventConsumer>[0],
|
|
121
|
+
{} as unknown as Parameters<typeof createSearchEventConsumer>[1],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(consumer.errorPolicy?.maxAttempts).toBeGreaterThan(10);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Issue #1350 — bounded auto-revival of a dead consumer.
|
|
2
|
+
//
|
|
3
|
+
// acquireConsumerState no longer parks a dead consumer forever: once its
|
|
4
|
+
// last write is older than rearmCooldownMs it gets reset to idle and
|
|
5
|
+
// retried, up to maxRearmCount times. A poison event (permanent failure)
|
|
6
|
+
// still ends up permanently dead once the budget is exhausted — the
|
|
7
|
+
// mechanism is a bounded number of extra chances, not unlimited retry.
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
11
|
+
import { asRawClient } from "../../db/query";
|
|
12
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
13
|
+
import { defineFeature } from "../../engine";
|
|
14
|
+
import { getConsumerState } from "../../pipeline";
|
|
15
|
+
import {
|
|
16
|
+
resetEventStore,
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
TestUsers,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
} from "../../stack";
|
|
22
|
+
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
23
|
+
import { SHARED_INSTANCE_SENTINEL } from "../event-consumer-state";
|
|
24
|
+
|
|
25
|
+
const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
|
|
26
|
+
entityName: "widget",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let poisonNames = new Set<string>();
|
|
30
|
+
let observed: Array<{ name: string }> = [];
|
|
31
|
+
|
|
32
|
+
const rearmFeature = defineFeature("rearmtest", (r) => {
|
|
33
|
+
r.entity("widget", sharedWidgetEntity);
|
|
34
|
+
|
|
35
|
+
r.multiStreamProjection({
|
|
36
|
+
name: "observer",
|
|
37
|
+
apply: {
|
|
38
|
+
"widget.created": async (event) => {
|
|
39
|
+
const name = event.payload["name"] as string;
|
|
40
|
+
if (poisonNames.has(name)) {
|
|
41
|
+
throw new Error(`poisoned: ${name}`);
|
|
42
|
+
}
|
|
43
|
+
observed.push({ name });
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const admin = TestUsers.admin;
|
|
50
|
+
const qn = "rearmtest:projection:observer";
|
|
51
|
+
let stack: TestStack;
|
|
52
|
+
let tdb: TenantDb;
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
stack = await setupTestStack({
|
|
56
|
+
features: [rearmFeature],
|
|
57
|
+
systemHooks: [],
|
|
58
|
+
});
|
|
59
|
+
await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
|
|
60
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
poisonNames = new Set();
|
|
65
|
+
observed = [];
|
|
66
|
+
await resetEventStore(stack, ["read_widgets"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
async function appendWidget(name: string): Promise<void> {
|
|
70
|
+
await executor.create({ name }, admin, tdb);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function driveUntilDead(): Promise<void> {
|
|
74
|
+
for (let i = 0; i < 10; i++) {
|
|
75
|
+
await stack.eventDispatcher?.runOnce();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Simulates cooldown elapsed without waiting real wall-clock time.
|
|
80
|
+
async function backdateUpdatedAt(minutesAgo: number): Promise<void> {
|
|
81
|
+
await asRawClient(stack.db).unsafe(
|
|
82
|
+
`UPDATE "kumiko_event_consumers" SET "updated_at" = now() - make_interval(mins => $1)
|
|
83
|
+
WHERE "name" = $2 AND "instance_id" = $3`,
|
|
84
|
+
[minutesAgo, qn, SHARED_INSTANCE_SENTINEL],
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("issue #1350 — bounded dead-consumer re-arm", () => {
|
|
89
|
+
test("stays dead within the cooldown window", async () => {
|
|
90
|
+
poisonNames.add("poison");
|
|
91
|
+
await appendWidget("poison");
|
|
92
|
+
await driveUntilDead();
|
|
93
|
+
|
|
94
|
+
const dead = await getConsumerState(stack.db, qn);
|
|
95
|
+
expect(dead?.status).toBe("dead");
|
|
96
|
+
expect(dead?.rearmCount).toBe(0);
|
|
97
|
+
|
|
98
|
+
// No backdate — cooldown (5min default) hasn't elapsed.
|
|
99
|
+
await stack.eventDispatcher?.runOnce();
|
|
100
|
+
const stillDead = await getConsumerState(stack.db, qn);
|
|
101
|
+
expect(stillDead?.status).toBe("dead");
|
|
102
|
+
expect(stillDead?.rearmCount).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("auto-revives once cooldown elapses, and re-dies on the same poison event", async () => {
|
|
106
|
+
poisonNames.add("poison");
|
|
107
|
+
await appendWidget("poison");
|
|
108
|
+
await driveUntilDead();
|
|
109
|
+
|
|
110
|
+
await backdateUpdatedAt(10);
|
|
111
|
+
await stack.eventDispatcher?.runOnce();
|
|
112
|
+
|
|
113
|
+
const revived = await getConsumerState(stack.db, qn);
|
|
114
|
+
expect(revived?.status).toBe("idle");
|
|
115
|
+
expect(revived?.rearmCount).toBe(1);
|
|
116
|
+
// Same pass that revives it also retries the pending poisoned event —
|
|
117
|
+
// acquireConsumerState's reset (attempts=0) is followed immediately by
|
|
118
|
+
// one failed delivery attempt within this runOnce() call.
|
|
119
|
+
expect(revived?.attempts).toBe(1);
|
|
120
|
+
|
|
121
|
+
// Handler still throws on "poison" — climbs back to dead.
|
|
122
|
+
await driveUntilDead();
|
|
123
|
+
const deadAgain = await getConsumerState(stack.db, qn);
|
|
124
|
+
expect(deadAgain?.status).toBe("dead");
|
|
125
|
+
expect(deadAgain?.rearmCount).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a re-arm that leads to a real delivery resets rearmCount to 0", async () => {
|
|
129
|
+
poisonNames.add("poison");
|
|
130
|
+
await appendWidget("poison");
|
|
131
|
+
await driveUntilDead();
|
|
132
|
+
|
|
133
|
+
// Cause is gone by the time the cooldown elapses — the retried event
|
|
134
|
+
// now succeeds instead of re-poisoning the consumer.
|
|
135
|
+
poisonNames.delete("poison");
|
|
136
|
+
await backdateUpdatedAt(10);
|
|
137
|
+
await stack.eventDispatcher?.runOnce();
|
|
138
|
+
|
|
139
|
+
const recovered = await getConsumerState(stack.db, qn);
|
|
140
|
+
expect(recovered?.status).toBe("idle");
|
|
141
|
+
expect(recovered?.rearmCount).toBe(0);
|
|
142
|
+
expect(observed.map((o) => o.name)).toEqual(["poison"]);
|
|
143
|
+
|
|
144
|
+
// A later, unrelated poison event gets its own fresh maxRearmCount
|
|
145
|
+
// budget instead of inheriting the spent counter from the resolved one.
|
|
146
|
+
poisonNames.add("later-poison");
|
|
147
|
+
await appendWidget("later-poison");
|
|
148
|
+
await driveUntilDead();
|
|
149
|
+
const deadAgain = await getConsumerState(stack.db, qn);
|
|
150
|
+
expect(deadAgain?.status).toBe("dead");
|
|
151
|
+
expect(deadAgain?.rearmCount).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("permanently dead after maxRearmCount cycles, even once cooldown elapses again", async () => {
|
|
155
|
+
poisonNames.add("poison");
|
|
156
|
+
await appendWidget("poison");
|
|
157
|
+
await driveUntilDead();
|
|
158
|
+
|
|
159
|
+
// Cycles 1..3: each backdate+drive re-arms once, then re-dies on the
|
|
160
|
+
// same poison event. Default maxRearmCount is 3.
|
|
161
|
+
for (let cycle = 1; cycle <= 3; cycle++) {
|
|
162
|
+
await backdateUpdatedAt(10);
|
|
163
|
+
await stack.eventDispatcher?.runOnce();
|
|
164
|
+
const revived = await getConsumerState(stack.db, qn);
|
|
165
|
+
expect(revived?.rearmCount).toBe(cycle);
|
|
166
|
+
await driveUntilDead();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const exhausted = await getConsumerState(stack.db, qn);
|
|
170
|
+
expect(exhausted?.status).toBe("dead");
|
|
171
|
+
expect(exhausted?.rearmCount).toBe(3);
|
|
172
|
+
|
|
173
|
+
// Cooldown elapsed again, but the budget is spent — stays dead.
|
|
174
|
+
await backdateUpdatedAt(10);
|
|
175
|
+
await stack.eventDispatcher?.runOnce();
|
|
176
|
+
const stillExhausted = await getConsumerState(stack.db, qn);
|
|
177
|
+
expect(stillExhausted?.status).toBe("dead");
|
|
178
|
+
expect(stillExhausted?.rearmCount).toBe(3);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -484,8 +484,18 @@ export function buildHandlerContext(
|
|
|
484
484
|
// When the feature-toggles or tier-engine feature isn't wired (no
|
|
485
485
|
// effectiveFeatures callback), always returns true — apps without
|
|
486
486
|
// tier-cuts treat all features on.
|
|
487
|
-
|
|
488
|
-
|
|
487
|
+
//
|
|
488
|
+
// Falls back to the live trialGate when the sync set says the feature
|
|
489
|
+
// is off — the sync set never contains trial-tier features (time-
|
|
490
|
+
// derived, can't boot-cache), so without this a trial tenant checking
|
|
491
|
+
// a companion feature's toggle would silently read `false` even though
|
|
492
|
+
// the dispatch gate already lets trial-tier handlers run.
|
|
493
|
+
hasFeature: async (featureName: string): Promise<boolean> => {
|
|
494
|
+
if (!effectiveFeatures) return true;
|
|
495
|
+
if (effectiveFeatures(user.tenantId).has(featureName)) return true;
|
|
496
|
+
if (!effectiveFeatures.trialGate) return false;
|
|
497
|
+
return effectiveFeatures.trialGate(user.tenantId, featureName);
|
|
498
|
+
},
|
|
489
499
|
};
|
|
490
500
|
|
|
491
501
|
// Registry is always the dispatcher's registry — injecting it here lets
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EntityCache } from "@cosmicdrift/kumiko-types/entity-cache";
|
|
1
2
|
import type Redis from "ioredis";
|
|
2
3
|
import type { EntityId, TenantId } from "../engine/types/identifiers";
|
|
3
4
|
import { RedisKeys } from "./redis-keys";
|
|
@@ -23,39 +24,7 @@ function parseCached(raw: string): Record<string, unknown> | null {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export type EntityCache
|
|
27
|
-
/** Get a single cached entity. Returns null on miss. */
|
|
28
|
-
get(
|
|
29
|
-
tenantId: TenantId,
|
|
30
|
-
entityName: string,
|
|
31
|
-
id: EntityId,
|
|
32
|
-
): Promise<Record<string, unknown> | null>;
|
|
33
|
-
|
|
34
|
-
/** Get multiple cached entities. Returns a Map of id → data (misses are absent). */
|
|
35
|
-
mget(
|
|
36
|
-
tenantId: TenantId,
|
|
37
|
-
entityName: string,
|
|
38
|
-
ids: readonly EntityId[],
|
|
39
|
-
): Promise<Map<EntityId, Record<string, unknown>>>;
|
|
40
|
-
|
|
41
|
-
/** Cache a single entity. */
|
|
42
|
-
set(
|
|
43
|
-
tenantId: TenantId,
|
|
44
|
-
entityName: string,
|
|
45
|
-
id: EntityId,
|
|
46
|
-
data: Record<string, unknown>,
|
|
47
|
-
): Promise<void>;
|
|
48
|
-
|
|
49
|
-
/** Cache multiple entities at once. */
|
|
50
|
-
mset(
|
|
51
|
-
tenantId: TenantId,
|
|
52
|
-
entityName: string,
|
|
53
|
-
entries: ReadonlyArray<{ id: EntityId; data: Record<string, unknown> }>,
|
|
54
|
-
): Promise<void>;
|
|
55
|
-
|
|
56
|
-
/** Invalidate a single cached entity. */
|
|
57
|
-
del(tenantId: TenantId, entityName: string, id: EntityId): Promise<void>;
|
|
58
|
-
};
|
|
27
|
+
export type { EntityCache } from "@cosmicdrift/kumiko-types/entity-cache";
|
|
59
28
|
|
|
60
29
|
export type EntityCacheOptions = {
|
|
61
30
|
ttlSeconds?: number;
|