@cosmicdrift/kumiko-framework 0.306.0 → 0.308.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 +4 -4
- 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 +133 -0
- 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 +144 -1
- 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 +57 -3
- package/src/api/request-context.ts +24 -0
- package/src/api/server.ts +11 -0
- package/src/api/sse-broker.ts +69 -12
- package/src/changes.json +63 -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__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/queries/event-consumer.ts +155 -9
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +43 -5
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
- 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 +54 -4
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- 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 +536 -0
- 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/jobs/job-runner.ts +61 -6
- 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-utils.test.ts +8 -0
- 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-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -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 +262 -3
- 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-batch.ts +49 -19
- package/src/pipeline/dispatch-stream.ts +12 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +212 -65
- package/src/pipeline/event-dispatcher.ts +253 -49
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/msp-rebuild.ts +39 -1
- package/src/pipeline/pending-gap-ranges.ts +120 -0
- package/src/pipeline/system-hooks.ts +226 -64
- package/src/pipeline/write-origin.ts +31 -10
- 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-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 +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +1 -1
- package/src/testing/e2e-generator.ts +1 -1
package/src/jobs/job-runner.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
1
2
|
import { type JobsOptions, Queue, Worker } from "bullmq";
|
|
2
3
|
import { Redis } from "ioredis";
|
|
3
4
|
import { requestContext } from "../api/request-context";
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
SYSTEM_TENANT_ID,
|
|
19
20
|
type TenantId,
|
|
20
21
|
} from "../engine/types";
|
|
22
|
+
import { InternalError } from "../errors";
|
|
21
23
|
import { isKumikoError } from "../errors/kumiko-error";
|
|
22
24
|
import { createFileContext } from "../files/file-handle";
|
|
23
25
|
import { createFallbackLogger } from "../logging";
|
|
@@ -33,8 +35,22 @@ import {
|
|
|
33
35
|
import { createEscapeHatchReporter } from "../observability/escape-hatch-report";
|
|
34
36
|
import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
|
|
35
37
|
import { RedisKeys } from "../pipeline/redis-keys";
|
|
38
|
+
import {
|
|
39
|
+
buildPersonalDataGate,
|
|
40
|
+
isPersonalDataGated,
|
|
41
|
+
parseWriteOrigin,
|
|
42
|
+
} from "../pipeline/write-origin";
|
|
36
43
|
import { bridgeStub } from "../testing/handler-context";
|
|
37
44
|
|
|
45
|
+
// A payload's own `_writeOrigin` is never trusted; only the ambient gated origin is stamped.
|
|
46
|
+
function stampGatedWriteOrigin(data: Record<string, unknown>): void {
|
|
47
|
+
delete data["_writeOrigin"];
|
|
48
|
+
const origin = requestContext.get()?.writeOrigin;
|
|
49
|
+
if (origin && isPersonalDataGated(origin)) {
|
|
50
|
+
data["_writeOrigin"] = origin;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
// Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
|
|
39
55
|
// ("kumiko-jobs") — it must match between enqueuers and consumers, and an
|
|
40
56
|
// accidental drift would silently drop jobs. Tests override via
|
|
@@ -731,6 +747,21 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
731
747
|
// multi-trigger dispatch; exposed as jobContext.triggerName so handlers
|
|
732
748
|
// don't dig through the raw payload themselves.
|
|
733
749
|
const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
|
|
750
|
+
|
|
751
|
+
// Absent = legacy or ungated root; present but invalid fails the run closed.
|
|
752
|
+
const rawWriteOrigin = rawData["_writeOrigin"]; // @cast-boundary dynamic-key
|
|
753
|
+
let jobOrigin: WriteOrigin | undefined;
|
|
754
|
+
let writeOriginInvalid = false;
|
|
755
|
+
if (rawWriteOrigin !== undefined) {
|
|
756
|
+
const parsed = parseWriteOrigin(rawWriteOrigin);
|
|
757
|
+
if (parsed) {
|
|
758
|
+
jobOrigin = { ...parsed, viaJob: jobName };
|
|
759
|
+
} else {
|
|
760
|
+
writeOriginInvalid = true;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const jobPersonalDataGate = jobOrigin ? buildPersonalDataGate(registry, jobOrigin) : undefined;
|
|
764
|
+
|
|
734
765
|
// Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
|
|
735
766
|
// through the same _fileProviderResolver for jobs as for write-handlers,
|
|
736
767
|
// otherwise event-triggered jobs silently get an unresolved ctx.files.
|
|
@@ -749,8 +780,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
749
780
|
// systemScope() status (pre-existing, not something this change alters)
|
|
750
781
|
// — isSystemJob below is what actually keeps ctx.systemDb off a
|
|
751
782
|
// non-system job; it is the only thing standing between this db and an
|
|
752
|
-
// unchecked cross-tenant escape hatch for such a job.
|
|
753
|
-
|
|
783
|
+
// unchecked cross-tenant escape hatch for such a job. Gated like jobDb so
|
|
784
|
+
// ctx.systemDb, built from it, is gated too.
|
|
785
|
+
const tenantScopedDb = configDb
|
|
786
|
+
? createTenantDb(
|
|
787
|
+
configDb,
|
|
788
|
+
tenantId,
|
|
789
|
+
"system",
|
|
790
|
+
undefined,
|
|
791
|
+
undefined,
|
|
792
|
+
undefined,
|
|
793
|
+
jobPersonalDataGate ? { personalDataGate: jobPersonalDataGate } : undefined,
|
|
794
|
+
)
|
|
795
|
+
: undefined;
|
|
754
796
|
const isSystemJob = registry.isJobSystemScoped(jobName);
|
|
755
797
|
// One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
|
|
756
798
|
const reportEscapeHatch = createEscapeHatchReporter({
|
|
@@ -768,6 +810,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
768
810
|
? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
|
|
769
811
|
unsafeRaw: jobDef.escapeHatch,
|
|
770
812
|
report: reportEscapeHatch,
|
|
813
|
+
...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
|
|
771
814
|
})
|
|
772
815
|
: undefined;
|
|
773
816
|
const config =
|
|
@@ -818,7 +861,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
818
861
|
"JobContext.write called before dispatcher attached — call attachDispatcher() first",
|
|
819
862
|
);
|
|
820
863
|
}
|
|
821
|
-
return dispatchWriteRef.write(jobSystemUser, qn, payload);
|
|
864
|
+
return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
|
|
822
865
|
},
|
|
823
866
|
writeAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
824
867
|
if (!dispatchWriteRef) {
|
|
@@ -826,7 +869,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
826
869
|
"JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
|
|
827
870
|
);
|
|
828
871
|
}
|
|
829
|
-
return dispatchWriteRef.write(user, qn, payload);
|
|
872
|
+
return dispatchWriteRef.write(user, qn, payload, jobOrigin);
|
|
830
873
|
},
|
|
831
874
|
queryAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
832
875
|
if (!dispatchWriteRef) {
|
|
@@ -834,7 +877,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
834
877
|
"JobContext.queryAs called before dispatcher attached — call attachDispatcher() first",
|
|
835
878
|
);
|
|
836
879
|
}
|
|
837
|
-
return dispatchWriteRef.queryAs(user, qn, payload);
|
|
880
|
+
return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
|
|
838
881
|
},
|
|
839
882
|
queryAsMember: (userId: string, qn: string, payload: unknown) => {
|
|
840
883
|
if (!dispatchWriteRef) {
|
|
@@ -866,6 +909,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
866
909
|
|
|
867
910
|
const runInSpan = async (): Promise<void> => {
|
|
868
911
|
try {
|
|
912
|
+
if (writeOriginInvalid) {
|
|
913
|
+
throw new InternalError({
|
|
914
|
+
message:
|
|
915
|
+
`Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
|
|
916
|
+
"a trustworthy anonymous-root gate.",
|
|
917
|
+
});
|
|
918
|
+
}
|
|
869
919
|
await requestContext.run(
|
|
870
920
|
{
|
|
871
921
|
requestId: jobRequestId,
|
|
@@ -873,6 +923,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
873
923
|
// #3043 — events a job writes carry the job as their origin.
|
|
874
924
|
handler: jobName,
|
|
875
925
|
feature: qnScope(jobName),
|
|
926
|
+
writeOrigin: jobOrigin,
|
|
876
927
|
},
|
|
877
928
|
() => jobDef.handler(payload, jobContext),
|
|
878
929
|
);
|
|
@@ -1107,9 +1158,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
1107
1158
|
|
|
1108
1159
|
// perTenant: dispatch the fan-out wrapper instead
|
|
1109
1160
|
if (jobDef.perTenant) {
|
|
1161
|
+
const perTenantData: Record<string, unknown> = { ...(payload ?? {}) };
|
|
1162
|
+
stampGatedWriteOrigin(perTenantData);
|
|
1110
1163
|
const job = await targetQueue.add(
|
|
1111
1164
|
`_perTenant:${jobName}`,
|
|
1112
|
-
|
|
1165
|
+
perTenantData,
|
|
1113
1166
|
buildRetryBullOpts(jobDef),
|
|
1114
1167
|
);
|
|
1115
1168
|
return job.id ?? "unknown";
|
|
@@ -1184,6 +1237,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
1184
1237
|
// stamp the same correlation as the HTTP request that scheduled it.
|
|
1185
1238
|
const reqCtx = requestContext.get();
|
|
1186
1239
|
if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
|
|
1240
|
+
stampGatedWriteOrigin(data);
|
|
1187
1241
|
|
|
1188
1242
|
const job = await targetQueue.add(jobName, data, bullOpts);
|
|
1189
1243
|
return job.id ?? "unknown";
|
|
@@ -1228,6 +1282,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
1228
1282
|
continue;
|
|
1229
1283
|
}
|
|
1230
1284
|
}
|
|
1285
|
+
stampGatedWriteOrigin(data);
|
|
1231
1286
|
// Route to the job's declared lane, not a fixed queue — that's
|
|
1232
1287
|
// the whole reason both queues are held.
|
|
1233
1288
|
await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import * as z from "zod";
|
|
3
3
|
import type { TableColumns } from "../../db/dialect";
|
|
4
4
|
import { buildEntityTable } from "../../db/table-builder";
|
|
5
5
|
import { createRegistry, defineFeature } from "../../engine";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// streams never pay for extra metadata writes.
|
|
7
7
|
|
|
8
8
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
-
import
|
|
9
|
+
import * as z from "zod";
|
|
10
10
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
11
11
|
import { buildEntityTable } from "../../db/table-builder";
|
|
12
12
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// msp-multi-hop.integration.ts.
|
|
17
17
|
|
|
18
18
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
19
|
-
import
|
|
19
|
+
import * as z from "zod";
|
|
20
20
|
import { requestContext } from "../../api/request-context";
|
|
21
21
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
22
22
|
import { selectMany } from "../../db/query";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import * as z from "zod";
|
|
3
3
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
4
4
|
import { asRawClient, selectMany } from "../../db/query";
|
|
5
5
|
import { buildEntityTable } from "../../db/table-builder";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createSystemUser } from "../../engine/system-user";
|
|
3
3
|
import { InternalError } from "../../errors";
|
|
4
|
+
import { VersionConflictError as EventStoreVersionConflictError } from "../../event-store/errors";
|
|
4
5
|
import {
|
|
5
6
|
describeShape,
|
|
6
7
|
dispatcherSpanAttributes,
|
|
@@ -115,6 +116,13 @@ describe("wrapToKumiko", () => {
|
|
|
115
116
|
expect(wrapped.code).toBe("internal_error");
|
|
116
117
|
expect(wrapped.cause).toBeInstanceOf(TypeError);
|
|
117
118
|
});
|
|
119
|
+
|
|
120
|
+
test("maps an event-store version conflict to a 409 version_conflict", () => {
|
|
121
|
+
const wrapped = wrapToKumiko(new EventStoreVersionConflictError("agg-1", 3));
|
|
122
|
+
expect(wrapped.code).toBe("version_conflict");
|
|
123
|
+
expect(wrapped.httpStatus).toBe(409);
|
|
124
|
+
expect(wrapped.details).toMatchObject({ entityId: "agg-1", expectedVersion: 3 });
|
|
125
|
+
});
|
|
118
126
|
});
|
|
119
127
|
|
|
120
128
|
describe("extractNestedSpecs", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import * as z from "zod";
|
|
3
3
|
import type { SseBroker } from "../../api/sse-broker";
|
|
4
4
|
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
5
5
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// projections-runner fires on appendEvent, dispatcher routes appendEvent to
|
|
11
11
|
// the aggregate stream), any of the assertions below go red.
|
|
12
12
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
-
import
|
|
13
|
+
import * as z from "zod";
|
|
14
14
|
import {
|
|
15
15
|
integer as pgInteger,
|
|
16
16
|
table as pgTable,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// system-identity-switch.integration.test.ts.
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
-
import
|
|
7
|
+
import * as z from "zod";
|
|
8
8
|
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
9
9
|
import { executeRawQuery } from "../../db/queries/raw-sql";
|
|
10
10
|
import { createSystemUser, defineFeature } from "../../engine";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Real HTTP calls + setupTestStack — never createTestDispatcher.
|
|
4
4
|
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
-
import
|
|
6
|
+
import * as z from "zod";
|
|
7
7
|
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
8
8
|
import { defineFeature } from "../../engine";
|
|
9
9
|
import { createTestUser, setupTestStack, type TestStack } from "../../stack";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import * as z from "zod";
|
|
3
3
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
4
4
|
import { defineFeature, type SaveContext } from "../../engine";
|
|
5
5
|
import {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// 3. r.defineEvent returns `{ name: qualifiedName, schema }` — callers
|
|
12
12
|
// pass `def.name` to ctx.appendEvent without building the qn manually.
|
|
13
13
|
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
-
import
|
|
14
|
+
import * as z from "zod";
|
|
15
15
|
import { selectMany } from "../../db/query";
|
|
16
16
|
import { defineFeature } from "../../engine";
|
|
17
17
|
import { eventsTable } from "../../event-store";
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// #3227 — deliverEvents' batchHandler fast-path: batch success must land
|
|
2
|
+
// the exact outcome the per-event loop would produce, a batch throw falls
|
|
3
|
+
// back to per-event delivery without bumping attempts for the failed
|
|
4
|
+
// batch attempt itself, and pending-gap resolution still splits pending
|
|
5
|
+
// vs new ids the same way it does for the per-event path.
|
|
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
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
await ensureTemporalPolyfill();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function stubContext(): AppContext {
|
|
22
|
+
return {
|
|
23
|
+
db: {} as unknown as AppContext["db"],
|
|
24
|
+
redis: {} as unknown as AppContext["redis"],
|
|
25
|
+
registry: {} as unknown as AppContext["registry"],
|
|
26
|
+
} as AppContext;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stubEvent(id: bigint): StoredEventRow {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
aggregateId: "agg-1",
|
|
33
|
+
aggregateType: "widget",
|
|
34
|
+
tenantId: "tenant-1",
|
|
35
|
+
version: 1,
|
|
36
|
+
type: "widget.created",
|
|
37
|
+
eventVersion: 1,
|
|
38
|
+
payload: {},
|
|
39
|
+
metadata: { userId: "system" },
|
|
40
|
+
createdAt: Temporal.Now.instant(),
|
|
41
|
+
createdBy: "system",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stubState(overrides: Partial<ConsumerStateRow> = {}): ConsumerStateRow {
|
|
46
|
+
return {
|
|
47
|
+
name: "consumer",
|
|
48
|
+
instanceId: "shared",
|
|
49
|
+
lastProcessedEventId: 0n,
|
|
50
|
+
status: "idle",
|
|
51
|
+
attempts: 0,
|
|
52
|
+
rearmCount: 0,
|
|
53
|
+
pendingGaps: [],
|
|
54
|
+
lastError: null,
|
|
55
|
+
updatedAt: Temporal.Now.instant(),
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("deliverEvents — batchHandler fast-path", () => {
|
|
61
|
+
test("batch success calls batchHandler once, never handler, and matches the per-event outcome", async () => {
|
|
62
|
+
const rows = [1n, 2n, 3n].map(stubEvent);
|
|
63
|
+
const batchCalls: string[][] = [];
|
|
64
|
+
let handlerCalls = 0;
|
|
65
|
+
|
|
66
|
+
const batchConsumer: EventConsumer = {
|
|
67
|
+
name: "search",
|
|
68
|
+
batchHandler: async (events) => {
|
|
69
|
+
batchCalls.push(events.map((e) => e.id));
|
|
70
|
+
},
|
|
71
|
+
handler: async () => {
|
|
72
|
+
handlerCalls += 1;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const perEventConsumer: EventConsumer = {
|
|
76
|
+
name: "search",
|
|
77
|
+
handler: async () => {
|
|
78
|
+
// no-op — succeeds for every event, same as the batch above
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const batchOutcome = await deliverEvents(batchConsumer, rows, stubContext(), 10, stubState());
|
|
83
|
+
const perEventOutcome = await deliverEvents(
|
|
84
|
+
perEventConsumer,
|
|
85
|
+
rows,
|
|
86
|
+
stubContext(),
|
|
87
|
+
10,
|
|
88
|
+
stubState(),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(batchCalls).toEqual([["1", "2", "3"]]);
|
|
92
|
+
expect(handlerCalls).toBe(0);
|
|
93
|
+
expect(batchOutcome).toEqual(perEventOutcome);
|
|
94
|
+
expect(batchOutcome.cursor).toBe(3n);
|
|
95
|
+
expect(batchOutcome.attempts).toBe(0);
|
|
96
|
+
expect(batchOutcome.lastError).toBeNull();
|
|
97
|
+
expect(batchOutcome.processed).toBe(3);
|
|
98
|
+
expect(batchOutcome.failed).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("pending-gap ids resolve the same way as per-event delivery", async () => {
|
|
102
|
+
const rows = [4n, 7n, 11n, 12n].map(stubEvent);
|
|
103
|
+
const consumer: EventConsumer = {
|
|
104
|
+
name: "search",
|
|
105
|
+
batchHandler: async () => {
|
|
106
|
+
// succeeds
|
|
107
|
+
},
|
|
108
|
+
handler: async () => {
|
|
109
|
+
throw new Error("handler must not run on batch success");
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const outcome = await deliverEvents(
|
|
114
|
+
consumer,
|
|
115
|
+
rows,
|
|
116
|
+
stubContext(),
|
|
117
|
+
10,
|
|
118
|
+
stubState({ lastProcessedEventId: 10n }),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(outcome.resolvedPendingIds).toEqual([4n, 7n]);
|
|
122
|
+
expect(outcome.cursor).toBe(12n);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("batch throws, one poison row: falls back to per-event delivery without bumping attempts for the batch failure itself", async () => {
|
|
126
|
+
const rows = [1n, 2n, 3n, 4n, 5n].map(stubEvent);
|
|
127
|
+
const handlerCalls: string[] = [];
|
|
128
|
+
const consumer: EventConsumer = {
|
|
129
|
+
name: "search",
|
|
130
|
+
errorPolicy: { maxAttempts: 2 },
|
|
131
|
+
batchHandler: async () => {
|
|
132
|
+
throw new Error("batch failed");
|
|
133
|
+
},
|
|
134
|
+
handler: async (event) => {
|
|
135
|
+
handlerCalls.push(event.id);
|
|
136
|
+
if (event.id === "3") throw new Error("poison event");
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const first = await deliverEvents(consumer, rows, stubContext(), 10, stubState());
|
|
141
|
+
expect(first.cursor).toBe(2n);
|
|
142
|
+
expect(first.attempts).toBe(1);
|
|
143
|
+
expect(first.deadLettered).toBe(false);
|
|
144
|
+
expect(handlerCalls).toEqual(["1", "2", "3"]);
|
|
145
|
+
|
|
146
|
+
const remaining = [3n, 4n, 5n].map(stubEvent);
|
|
147
|
+
const second = await deliverEvents(
|
|
148
|
+
consumer,
|
|
149
|
+
remaining,
|
|
150
|
+
stubContext(),
|
|
151
|
+
10,
|
|
152
|
+
stubState({ lastProcessedEventId: first.cursor, attempts: first.attempts }),
|
|
153
|
+
);
|
|
154
|
+
expect(second.attempts).toBe(2);
|
|
155
|
+
expect(second.deadLettered).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("batch throws with skipApplyErrors: true skips the poison row and delivers the rest via handler", async () => {
|
|
159
|
+
const rows = [1n, 2n, 3n, 4n, 5n].map(stubEvent);
|
|
160
|
+
const handlerCalls: string[] = [];
|
|
161
|
+
const consumer: EventConsumer = {
|
|
162
|
+
name: "search",
|
|
163
|
+
errorPolicy: { skipApplyErrors: true },
|
|
164
|
+
batchHandler: async () => {
|
|
165
|
+
throw new Error("batch failed");
|
|
166
|
+
},
|
|
167
|
+
handler: async (event) => {
|
|
168
|
+
handlerCalls.push(event.id);
|
|
169
|
+
if (event.id === "3") throw new Error("poison event");
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const outcome = await deliverEvents(consumer, rows, stubContext(), 10, stubState());
|
|
174
|
+
|
|
175
|
+
expect(outcome.cursor).toBe(5n);
|
|
176
|
+
expect(handlerCalls).toEqual(["1", "2", "3", "4", "5"]);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// #3227 — batchHandler wired into a real dispatcher pass: a successful
|
|
2
|
+
// batch is delivered in one call (handler never runs, cursor lands at the
|
|
3
|
+
// last event) and a batch+handler failure still dead-letters through the
|
|
4
|
+
// normal errorPolicy machinery, delivering the poisoned event via handler
|
|
5
|
+
// exactly once before it does.
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
9
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
10
|
+
import { defineFeature } from "../../engine";
|
|
11
|
+
import type { StoredEvent } from "../../event-store";
|
|
12
|
+
import {
|
|
13
|
+
createEventDispatcher,
|
|
14
|
+
type EventConsumer,
|
|
15
|
+
type EventDispatcher,
|
|
16
|
+
getConsumerState,
|
|
17
|
+
} from "../../pipeline";
|
|
18
|
+
import {
|
|
19
|
+
resetEventStore,
|
|
20
|
+
setupTestStack,
|
|
21
|
+
type TestStack,
|
|
22
|
+
TestUsers,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
} from "../../stack";
|
|
25
|
+
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
26
|
+
|
|
27
|
+
const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
|
|
28
|
+
entityName: "widget",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const feature = defineFeature("batchhandlerintegration", (r) => {
|
|
32
|
+
r.entity("widget", sharedWidgetEntity);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const admin = TestUsers.admin;
|
|
36
|
+
let stack: TestStack;
|
|
37
|
+
let tdb: TenantDb;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
stack = await setupTestStack({ features: [feature], systemHooks: [] });
|
|
41
|
+
await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
|
|
42
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await resetEventStore(stack, ["read_widgets"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function appendWidget(name: string): Promise<string> {
|
|
50
|
+
const created = await executor.create({ name }, admin, tdb);
|
|
51
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
52
|
+
return String(created.data.id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildDispatcher(consumers: readonly EventConsumer[]): EventDispatcher {
|
|
56
|
+
return createEventDispatcher({
|
|
57
|
+
db: stack.db,
|
|
58
|
+
consumers,
|
|
59
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
60
|
+
batchSize: 200,
|
|
61
|
+
// No timer/LISTEN involved — runOnce() below drives every pass
|
|
62
|
+
// explicitly, so pollIntervalMs never fires a competing pass.
|
|
63
|
+
pollIntervalMs: 60_000,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("event-dispatcher — batchHandler", () => {
|
|
68
|
+
test("a successful batch delivers all events in one call, handler stays unused, cursor lands on the last event", async () => {
|
|
69
|
+
const consumerName = "batchhandlerintegration:success";
|
|
70
|
+
const batchSizes: number[] = [];
|
|
71
|
+
const batchIds: bigint[] = [];
|
|
72
|
+
const handlerIds: string[] = [];
|
|
73
|
+
|
|
74
|
+
const consumer: EventConsumer = {
|
|
75
|
+
name: consumerName,
|
|
76
|
+
batchHandler: async (events) => {
|
|
77
|
+
batchSizes.push(events.length);
|
|
78
|
+
for (const event of events) batchIds.push(BigInt(event.id));
|
|
79
|
+
},
|
|
80
|
+
handler: async (event) => {
|
|
81
|
+
handlerIds.push(event.aggregateId);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const dispatcher = buildDispatcher([consumer]);
|
|
86
|
+
await dispatcher.ensureRegistered();
|
|
87
|
+
|
|
88
|
+
await appendWidget("one");
|
|
89
|
+
await appendWidget("two");
|
|
90
|
+
await appendWidget("three");
|
|
91
|
+
|
|
92
|
+
const result = await dispatcher.runOnce();
|
|
93
|
+
|
|
94
|
+
expect(batchSizes).toEqual([3]);
|
|
95
|
+
expect(handlerIds).toEqual([]);
|
|
96
|
+
expect(result.byConsumer[consumerName]).toEqual({ processed: 3, failed: 0 });
|
|
97
|
+
|
|
98
|
+
const state = await getConsumerState(stack.db, consumerName);
|
|
99
|
+
expect(state?.status).toBe("idle");
|
|
100
|
+
expect(state?.lastProcessedEventId).toBe(batchIds.at(-1));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("batch throws, handler poisons the 2nd event: dead-letters at maxAttempts, first event delivered exactly once via handler", async () => {
|
|
104
|
+
const consumerName = "batchhandlerintegration:poison";
|
|
105
|
+
const handlerCalls: StoredEvent[] = [];
|
|
106
|
+
let poisonAggregateId: string | undefined;
|
|
107
|
+
|
|
108
|
+
const consumer: EventConsumer = {
|
|
109
|
+
name: consumerName,
|
|
110
|
+
errorPolicy: { maxAttempts: 2 },
|
|
111
|
+
batchHandler: async () => {
|
|
112
|
+
throw new Error("batch always fails in this test");
|
|
113
|
+
},
|
|
114
|
+
handler: async (event) => {
|
|
115
|
+
handlerCalls.push(event);
|
|
116
|
+
if (event.aggregateId === poisonAggregateId) {
|
|
117
|
+
throw new Error("poisoned event");
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const dispatcher = buildDispatcher([consumer]);
|
|
123
|
+
await dispatcher.ensureRegistered();
|
|
124
|
+
|
|
125
|
+
await appendWidget("first");
|
|
126
|
+
poisonAggregateId = await appendWidget("second");
|
|
127
|
+
|
|
128
|
+
const firstPass = await dispatcher.runOnce();
|
|
129
|
+
expect(firstPass.byConsumer[consumerName]?.failed).toBeGreaterThan(0);
|
|
130
|
+
|
|
131
|
+
const secondPass = await dispatcher.runOnce();
|
|
132
|
+
expect(secondPass.byConsumer[consumerName]).toBeDefined();
|
|
133
|
+
|
|
134
|
+
const state = await getConsumerState(stack.db, consumerName);
|
|
135
|
+
expect(state?.status).toBe("dead");
|
|
136
|
+
|
|
137
|
+
const firstEventDeliveries = handlerCalls.filter(
|
|
138
|
+
(event) => event.aggregateId === handlerCalls[0]?.aggregateId,
|
|
139
|
+
);
|
|
140
|
+
expect(firstEventDeliveries).toHaveLength(1);
|
|
141
|
+
expect(state?.lastProcessedEventId).toBe(BigInt(handlerCalls[0]?.id ?? "0"));
|
|
142
|
+
});
|
|
143
|
+
});
|