@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- 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 +46 -27
- 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/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- 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 +396 -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/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- 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__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- 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-handler.ts +10 -1
- 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 +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- 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 +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- 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/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- 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/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -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 +134 -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/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- 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/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- 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 +17 -45
- package/src/secrets/types.ts +2 -177
- 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/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
|
@@ -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";
|
|
@@ -25,6 +25,17 @@ const echoFeature = defineFeature("echo", (r) => {
|
|
|
25
25
|
{ access: { openToAll: true } },
|
|
26
26
|
);
|
|
27
27
|
|
|
28
|
+
r.streamHandler(
|
|
29
|
+
"item:tail",
|
|
30
|
+
z.object({ count: z.number().int().min(0) }),
|
|
31
|
+
async function* (query) {
|
|
32
|
+
for (let i = 0; i < query.payload.count; i++) {
|
|
33
|
+
yield { i };
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{ access: { roles: ["Admin"] } },
|
|
37
|
+
);
|
|
38
|
+
|
|
28
39
|
r.hook("validation", "item:create", (data) => {
|
|
29
40
|
if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
|
|
30
41
|
return null;
|
|
@@ -214,6 +225,55 @@ describe("dispatcher.query", () => {
|
|
|
214
225
|
});
|
|
215
226
|
});
|
|
216
227
|
|
|
228
|
+
async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
|
|
229
|
+
const out: T[] = [];
|
|
230
|
+
for await (const chunk of gen) out.push(chunk);
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
describe("dispatcher.stream", () => {
|
|
235
|
+
test("validates and calls stream handler, yielding chunks in order", async () => {
|
|
236
|
+
const dispatcher = createTestDispatcher();
|
|
237
|
+
const chunks = await collect(
|
|
238
|
+
dispatcher.stream(
|
|
239
|
+
"echo:stream:item:tail",
|
|
240
|
+
{ count: 3 },
|
|
241
|
+
createTestUser({ roles: ["Admin"] }),
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(chunks).toEqual([{ i: 0 }, { i: 1 }, { i: 2 }]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("gates fire on first pull, not on the stream() call itself", async () => {
|
|
249
|
+
const dispatcher = createTestDispatcher();
|
|
250
|
+
const gen = dispatcher.stream(
|
|
251
|
+
"echo:stream:item:tail",
|
|
252
|
+
{ count: -1 },
|
|
253
|
+
createTestUser({ roles: ["Admin"] }),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await expect(collect(gen)).rejects.toMatchObject({ code: "validation_error", httpStatus: 400 });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("rejects unauthorized user", async () => {
|
|
260
|
+
const dispatcher = createTestDispatcher();
|
|
261
|
+
const guest = createTestUser({ roles: ["Guest"] });
|
|
262
|
+
|
|
263
|
+
await expect(
|
|
264
|
+
collect(dispatcher.stream("echo:stream:item:tail", { count: 1 }, guest)),
|
|
265
|
+
).rejects.toMatchObject({ code: "access_denied" });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("throws for unknown stream handler", async () => {
|
|
269
|
+
const dispatcher = createTestDispatcher();
|
|
270
|
+
|
|
271
|
+
await expect(
|
|
272
|
+
collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
|
|
273
|
+
).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
217
277
|
// --- postQuery hooks on standalone (entity-less) queries ---
|
|
218
278
|
|
|
219
279
|
describe("dispatcher.query postQuery hooks", () => {
|
|
@@ -362,6 +422,20 @@ describe("dispatcher feature-gate", () => {
|
|
|
362
422
|
async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
|
|
363
423
|
{ access: { roles: ["Admin"] } },
|
|
364
424
|
);
|
|
425
|
+
r.streamHandler(
|
|
426
|
+
"widget:tail",
|
|
427
|
+
z.object({}).passthrough(),
|
|
428
|
+
async function* () {
|
|
429
|
+
// Never completes on its own — the mid-stream toggle-off test
|
|
430
|
+
// relies on the generator staying open past the first yield.
|
|
431
|
+
let i = 0;
|
|
432
|
+
while (true) {
|
|
433
|
+
yield { i };
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
{ access: { openToAll: true } },
|
|
438
|
+
);
|
|
365
439
|
});
|
|
366
440
|
}
|
|
367
441
|
|
|
@@ -397,6 +471,28 @@ describe("dispatcher feature-gate", () => {
|
|
|
397
471
|
});
|
|
398
472
|
});
|
|
399
473
|
|
|
474
|
+
test("disabling the feature mid-stream aborts an already-open stream", async () => {
|
|
475
|
+
const registry = createRegistry([toggled()]);
|
|
476
|
+
const disabled = new Set<string>();
|
|
477
|
+
const dispatcher = createDispatcher(
|
|
478
|
+
registry,
|
|
479
|
+
{},
|
|
480
|
+
{
|
|
481
|
+
effectiveFeatures: () => {
|
|
482
|
+
const all = new Set(registry.features.keys());
|
|
483
|
+
for (const d of disabled) all.delete(d);
|
|
484
|
+
return all;
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const gen = dispatcher.stream("toggled:stream:widget:tail", {}, user);
|
|
490
|
+
expect((await gen.next()).value).toEqual({ i: 0 });
|
|
491
|
+
|
|
492
|
+
disabled.add("toggled");
|
|
493
|
+
await expect(gen.next()).rejects.toThrow(/feature toggled is disabled/);
|
|
494
|
+
});
|
|
495
|
+
|
|
400
496
|
test("write of disabled feature returns WriteFailure with feature_disabled reason", async () => {
|
|
401
497
|
const registry = createRegistry([toggled()]);
|
|
402
498
|
const disabled = new Set<string>(["toggled"]);
|
|
@@ -509,7 +605,7 @@ describe("dispatcher feature-gate", () => {
|
|
|
509
605
|
r.queryHandler(
|
|
510
606
|
"check",
|
|
511
607
|
z.object({}).passthrough(),
|
|
512
|
-
async (_event, ctx) => ({ enabled: ctx.hasFeature("toggled") }),
|
|
608
|
+
async (_event, ctx) => ({ enabled: await ctx.hasFeature("toggled") }),
|
|
513
609
|
{ access: { openToAll: true } },
|
|
514
610
|
);
|
|
515
611
|
});
|
|
@@ -533,6 +629,43 @@ describe("dispatcher feature-gate", () => {
|
|
|
533
629
|
enabled: false,
|
|
534
630
|
});
|
|
535
631
|
});
|
|
632
|
+
|
|
633
|
+
test("issue #1233: ctx.hasFeature falls back to trialGate for a companion feature the tenant doesn't own", async () => {
|
|
634
|
+
// Regression for the trial-tenant bug: a handler owned by an always-on
|
|
635
|
+
// feature checks a DIFFERENT (trial-tier) feature via ctx.hasFeature.
|
|
636
|
+
// The sync resolver never carries trial features, so without the
|
|
637
|
+
// trialGate fallback this silently reads false for a trial tenant even
|
|
638
|
+
// though the tenant is genuinely in-trial for that companion feature —
|
|
639
|
+
// mirrors packages/bundled-features/src/managed-pages/handlers/branding.query.ts
|
|
640
|
+
// checking MANAGED_PAGES_CSS_FEATURE, which no handler owns.
|
|
641
|
+
const trialTenant = "00000000-0000-4000-8000-0000000000c5" as TenantId;
|
|
642
|
+
const plainTenant = "00000000-0000-4000-8000-0000000000d6" as TenantId;
|
|
643
|
+
|
|
644
|
+
const probe = defineFeature("probe", (r) => {
|
|
645
|
+
r.queryHandler(
|
|
646
|
+
"check",
|
|
647
|
+
z.object({}).passthrough(),
|
|
648
|
+
async (_event, ctx) => ({ enabled: await ctx.hasFeature("premium-companion") }),
|
|
649
|
+
{ access: { openToAll: true } },
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
const registry = createRegistry([probe]);
|
|
653
|
+
const effectiveFeatures = Object.assign(() => new Set(["probe"]), {
|
|
654
|
+
trialGate: async (tenantId: TenantId, feature: string) =>
|
|
655
|
+
tenantId === trialTenant && feature === "premium-companion",
|
|
656
|
+
});
|
|
657
|
+
const dispatcher = createDispatcher(registry, {}, { effectiveFeatures });
|
|
658
|
+
|
|
659
|
+
const trialUser = createTestUser({ id: "u-c5", tenantId: trialTenant, roles: ["Admin"] });
|
|
660
|
+
const plainUser = createTestUser({ id: "u-d6", tenantId: plainTenant, roles: ["Admin"] });
|
|
661
|
+
|
|
662
|
+
await expect(dispatcher.query("probe:query:check", {}, trialUser)).resolves.toEqual({
|
|
663
|
+
enabled: true,
|
|
664
|
+
});
|
|
665
|
+
await expect(dispatcher.query("probe:query:check", {}, plainUser)).resolves.toEqual({
|
|
666
|
+
enabled: false,
|
|
667
|
+
});
|
|
668
|
+
});
|
|
536
669
|
});
|
|
537
670
|
|
|
538
671
|
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
|
+
});
|