@cosmicdrift/kumiko-framework 0.307.0 → 0.309.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/field-access.integration.test.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/transition-guard.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
- package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write.integration.test.ts +1 -1
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +73 -6
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- package/src/api/__tests__/sse-broker.test.ts +107 -13
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
- package/src/api/auth-routes.ts +1 -1
- package/src/api/redis-sse-broker.ts +51 -11
- package/src/api/server.ts +11 -0
- package/src/api/sse-broker.ts +61 -22
- package/src/bun-db/__tests__/postgres-closed-socket-write.integration.test.ts +87 -0
- package/src/bun-db/connection.ts +2 -0
- package/src/changes.json +61 -0
- package/src/compliance/override-schema.ts +1 -1
- package/src/crypto/__tests__/event-pii.test.ts +1 -1
- package/src/crypto/kms-adapter.ts +1 -1
- package/src/db/__tests__/feature-table-sources.test.ts +2 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/collect-table-metas.ts +3 -6
- package/src/db/feature-table-sources.ts +3 -4
- package/src/db/queries/event-consumer.ts +98 -6
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
- package/src/derivatives/delete-stored-file.ts +86 -0
- package/src/derivatives/index.ts +2 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +35 -1
- package/src/engine/__tests__/build-app-schema.test.ts +1 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
- package/src/engine/__tests__/engine.test.ts +1 -1
- package/src/engine/__tests__/event-helpers.test.ts +1 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
- package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
- package/src/engine/__tests__/extends-registrar.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +1 -1
- package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/registry.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +1 -1
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
- package/src/engine/boot-validator/screens.ts +83 -0
- package/src/engine/define-handler.ts +2 -1
- package/src/engine/entity-handlers.ts +2 -1
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
- package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
- package/src/engine/feature-builder-state.ts +2 -1
- package/src/engine/feature-config-events-jobs.ts +2 -1
- package/src/engine/feature-entity-handlers.ts +2 -1
- package/src/engine/schema-builder.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
- package/src/env/__tests__/compose-env-schema.test.ts +1 -1
- package/src/env/__tests__/dry-run.test.ts +1 -1
- package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
- package/src/env/_zod-introspect.ts +1 -1
- package/src/env/dry-run.ts +1 -1
- package/src/env/index.ts +1 -1
- package/src/errors/__tests__/classes.test.ts +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
- package/src/event-store/__tests__/event-attribution.integration.test.ts +1 -1
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
- package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +1 -1
- package/src/observability/metrics-wiring.ts +1 -1
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
- package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +192 -0
- package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
- package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +1 -1
- package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/dispatch-stream.ts +6 -1
- package/src/pipeline/event-dispatcher-delivery.ts +132 -37
- package/src/pipeline/event-dispatcher.ts +121 -17
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/msp-rebuild.ts +39 -1
- package/src/pipeline/pending-gap-ranges.ts +48 -0
- package/src/pipeline/system-hooks.ts +223 -68
- package/src/pipeline/write-origin.ts +1 -1
- package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
- package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
- package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
- package/src/search/derive-search-adapter-config.ts +27 -0
- package/src/search/index.ts +1 -0
- package/src/search/meilisearch-adapter.ts +96 -14
- package/src/secrets/__tests__/contains-secret.test.ts +1 -1
- package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-entity-tables.integration.test.ts +65 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
- package/src/stack/test-stack.ts +61 -54
- package/src/testing/__tests__/e2e-generator.test.ts +1 -1
- package/src/testing/__tests__/real-providers.test.ts +9 -1
- package/src/testing/e2e-generator.ts +1 -1
- package/src/testing/index.ts +1 -1
- package/src/testing/real-providers.ts +12 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SseBroker } from "../api/sse-broker";
|
|
1
|
+
import type { AccessInvalidationScope, SseBroker } from "../api/sse-broker";
|
|
2
2
|
import {
|
|
3
3
|
collectSearchableSubjectFields,
|
|
4
4
|
configuredPiiSubjectKms,
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
SessionUser,
|
|
19
19
|
TenantId,
|
|
20
20
|
} from "../engine/types";
|
|
21
|
+
import type { StoredEvent } from "../event-store";
|
|
21
22
|
import type { SearchAdapter, SearchDocument } from "../search/types";
|
|
22
23
|
import type { EventConsumer } from "./event-dispatcher";
|
|
23
24
|
|
|
@@ -50,11 +51,171 @@ import type { EventConsumer } from "./event-dispatcher";
|
|
|
50
51
|
// instead, so the same sensitive+searchable boot-guard (entity-handler.ts)
|
|
51
52
|
// keeps them out there too.
|
|
52
53
|
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
54
|
+
// batchHandler collapses a turn's events to one search-op per
|
|
55
|
+
// (tenantId, entityType, entityId) and issues one indexBatch/removeBatch
|
|
56
|
+
// call per tenant instead of one round-trip per event; handler stays the
|
|
57
|
+
// per-event fallback the dispatcher falls back to when a batch throws.
|
|
56
58
|
export const SEARCH_CONSUMER_NAME = "system:consumer:search";
|
|
57
59
|
|
|
60
|
+
// #3227 — one resolved search operation for a single event: either the
|
|
61
|
+
// document to (re-)index or the entityType/entityId to remove. A
|
|
62
|
+
// discriminated union so the batch collapse step (last op per key wins)
|
|
63
|
+
// doesn't need to special-case index vs remove.
|
|
64
|
+
export type SearchOperation =
|
|
65
|
+
| { readonly kind: "index"; readonly tenantId: TenantId; readonly doc: SearchDocument }
|
|
66
|
+
| {
|
|
67
|
+
readonly kind: "remove";
|
|
68
|
+
readonly tenantId: TenantId;
|
|
69
|
+
readonly entityType: string;
|
|
70
|
+
readonly entityId: EntityId;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Resolves what one event implies for the search index, without touching
|
|
74
|
+
// the adapter — same decision tree the old per-event handler ran inline.
|
|
75
|
+
async function resolveSearchOperation(
|
|
76
|
+
event: StoredEvent,
|
|
77
|
+
ctx: AppContext,
|
|
78
|
+
registry: Registry,
|
|
79
|
+
): Promise<SearchOperation | undefined> {
|
|
80
|
+
const entityName = event.aggregateType;
|
|
81
|
+
const verb = event.type.split(".").pop();
|
|
82
|
+
const tenantId = event.tenantId;
|
|
83
|
+
|
|
84
|
+
// skip: delete/forgotten remove the index entry — reconstruct only
|
|
85
|
+
// makes sense for created/updated/restored (field data in payload).
|
|
86
|
+
if (verb === "deleted" || verb === "forgotten") {
|
|
87
|
+
return { kind: "remove", tenantId, entityType: entityName, entityId: event.aggregateId };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let state: Record<string, unknown>;
|
|
91
|
+
if (verb === "created" || verb === "updated" || verb === "restored") {
|
|
92
|
+
state = reconstructStateForSearch(event.payload, verb);
|
|
93
|
+
} else {
|
|
94
|
+
// #2765 — named domain event: no fixed payload shape to reconstruct
|
|
95
|
+
// state from. Only worth a projection read when the entity actually
|
|
96
|
+
// declared searchable fields/extensions — same check
|
|
97
|
+
// buildSearchDocument makes below, done here first so a non-
|
|
98
|
+
// searchable entity's named events stay a no-op like before.
|
|
99
|
+
const entity = registry.getEntity(entityName);
|
|
100
|
+
const isSearchable =
|
|
101
|
+
entity !== undefined &&
|
|
102
|
+
(registry.getSearchableFields(entityName).length > 0 ||
|
|
103
|
+
registry.getSearchPayloadExtensions(entityName).length > 0);
|
|
104
|
+
// skip: entity declares no searchable fields/extensions — nothing to
|
|
105
|
+
// index, stays a no-op with no extra query, same as before #2765.
|
|
106
|
+
if (!entity || !isSearchable) return undefined;
|
|
107
|
+
|
|
108
|
+
const row = await readProjectionRowForSearch(
|
|
109
|
+
ctx,
|
|
110
|
+
registry,
|
|
111
|
+
entityName,
|
|
112
|
+
entity,
|
|
113
|
+
tenantId,
|
|
114
|
+
event.aggregateId,
|
|
115
|
+
);
|
|
116
|
+
// skip: no live row (hard-deleted stream, or soft-deleted) — same
|
|
117
|
+
// outcome as the deleted/forgotten branch above.
|
|
118
|
+
if (!row) {
|
|
119
|
+
return { kind: "remove", tenantId, entityType: entityName, entityId: event.aggregateId };
|
|
120
|
+
}
|
|
121
|
+
state = row;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
state = await decryptSearchableSubjectFields(entityName, state, registry);
|
|
125
|
+
// skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
|
|
126
|
+
if (hasErasedSearchableSubjectField(entityName, state, registry)) {
|
|
127
|
+
return { kind: "remove", tenantId, entityType: entityName, entityId: event.aggregateId };
|
|
128
|
+
}
|
|
129
|
+
const doc = await buildSearchDocument(entityName, event.aggregateId, state, registry);
|
|
130
|
+
// skip: entity isn't searchable (no searchable fields declared)
|
|
131
|
+
if (!doc) return undefined;
|
|
132
|
+
return { kind: "index", tenantId, doc };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function applySearchOperation(
|
|
136
|
+
searchAdapter: SearchAdapter,
|
|
137
|
+
op: SearchOperation,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
if (op.kind === "index") {
|
|
140
|
+
await searchAdapter.index(op.tenantId, op.doc);
|
|
141
|
+
} else {
|
|
142
|
+
await searchAdapter.remove(op.tenantId, op.entityType, op.entityId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Collapse key for one search op — same (tenant, entity) target as the old
|
|
147
|
+
// per-event index()/remove() calls addressed, index and remove share the
|
|
148
|
+
// key so a later op in the same turn always overwrites an earlier one
|
|
149
|
+
// (update-then-delete ⇒ remove wins, delete-then-restore ⇒ index wins).
|
|
150
|
+
function searchOperationKey(op: SearchOperation): string {
|
|
151
|
+
const entityType = op.kind === "index" ? op.doc.entityType : op.entityType;
|
|
152
|
+
const entityId = op.kind === "index" ? op.doc.entityId : op.entityId;
|
|
153
|
+
return `${op.tenantId}\u0000${entityType}\u0000${String(entityId)}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type SearchOperationsByTenant = Map<
|
|
157
|
+
TenantId,
|
|
158
|
+
{ toIndex: SearchDocument[]; toRemove: { entityType: string; entityId: EntityId }[] }
|
|
159
|
+
>;
|
|
160
|
+
|
|
161
|
+
// Resolves every event's op sequentially (the named-event path does DB
|
|
162
|
+
// reads, so resolving in parallel would fan out uncapped concurrent
|
|
163
|
+
// queries for one turn) and collapses to the last op per
|
|
164
|
+
// (tenantId, entityType, entityId) — update-then-delete ⇒ remove wins,
|
|
165
|
+
// delete-then-restore ⇒ index wins.
|
|
166
|
+
async function resolveSearchOperationsByTenant(
|
|
167
|
+
events: readonly StoredEvent[],
|
|
168
|
+
ctx: AppContext,
|
|
169
|
+
registry: Registry,
|
|
170
|
+
): Promise<SearchOperationsByTenant> {
|
|
171
|
+
const byKey = new Map<string, SearchOperation>();
|
|
172
|
+
for (const event of events) {
|
|
173
|
+
const op = await resolveSearchOperation(event, ctx, registry);
|
|
174
|
+
if (!op) continue;
|
|
175
|
+
byKey.set(searchOperationKey(op), op);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const perTenant: SearchOperationsByTenant = new Map();
|
|
179
|
+
for (const op of byKey.values()) {
|
|
180
|
+
let bucket = perTenant.get(op.tenantId);
|
|
181
|
+
if (!bucket) {
|
|
182
|
+
bucket = { toIndex: [], toRemove: [] };
|
|
183
|
+
perTenant.set(op.tenantId, bucket);
|
|
184
|
+
}
|
|
185
|
+
if (op.kind === "index") bucket.toIndex.push(op.doc);
|
|
186
|
+
else bucket.toRemove.push({ entityType: op.entityType, entityId: op.entityId });
|
|
187
|
+
}
|
|
188
|
+
return perTenant;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Flushes one tenant's collapsed ops — indexBatch/removeBatch when the
|
|
192
|
+
// adapter implements them, otherwise falls back to looping the single-doc
|
|
193
|
+
// methods for that group. Keys were already made disjoint by the collapse
|
|
194
|
+
// in resolveSearchOperationsByTenant, so index-vs-remove order here
|
|
195
|
+
// doesn't matter.
|
|
196
|
+
async function flushSearchOperationsForTenant(
|
|
197
|
+
searchAdapter: SearchAdapter,
|
|
198
|
+
tenantId: TenantId,
|
|
199
|
+
bucket: { toIndex: SearchDocument[]; toRemove: { entityType: string; entityId: EntityId }[] },
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
if (bucket.toIndex.length > 0) {
|
|
202
|
+
if (searchAdapter.indexBatch) {
|
|
203
|
+
await searchAdapter.indexBatch(tenantId, bucket.toIndex);
|
|
204
|
+
} else {
|
|
205
|
+
for (const doc of bucket.toIndex) await searchAdapter.index(tenantId, doc);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (bucket.toRemove.length > 0) {
|
|
209
|
+
if (searchAdapter.removeBatch) {
|
|
210
|
+
await searchAdapter.removeBatch(tenantId, bucket.toRemove);
|
|
211
|
+
} else {
|
|
212
|
+
for (const item of bucket.toRemove) {
|
|
213
|
+
await searchAdapter.remove(tenantId, item.entityType, item.entityId);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
58
219
|
export function createSearchEventConsumer(
|
|
59
220
|
searchAdapter: SearchAdapter,
|
|
60
221
|
registry: Registry,
|
|
@@ -69,64 +230,14 @@ export function createSearchEventConsumer(
|
|
|
69
230
|
// dead-lettering in event-dispatcher-delivery.ts if 2min isn't enough.
|
|
70
231
|
errorPolicy: { maxAttempts: 1200 },
|
|
71
232
|
handler: async (event, ctx) => {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
await searchAdapter.remove(tenantId, entityName, event.aggregateId);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
let state: Record<string, unknown>;
|
|
84
|
-
if (verb === "created" || verb === "updated" || verb === "restored") {
|
|
85
|
-
state = reconstructStateForSearch(event.payload, verb);
|
|
86
|
-
} else {
|
|
87
|
-
// #2765 — named domain event: no fixed payload shape to reconstruct
|
|
88
|
-
// state from. Only worth a projection read when the entity actually
|
|
89
|
-
// declared searchable fields/extensions — same check
|
|
90
|
-
// buildSearchDocument makes below, done here first so a non-
|
|
91
|
-
// searchable entity's named events stay a no-op like before.
|
|
92
|
-
const entity = registry.getEntity(entityName);
|
|
93
|
-
const isSearchable =
|
|
94
|
-
entity !== undefined &&
|
|
95
|
-
(registry.getSearchableFields(entityName).length > 0 ||
|
|
96
|
-
registry.getSearchPayloadExtensions(entityName).length > 0);
|
|
97
|
-
// skip: entity declares no searchable fields/extensions — nothing to
|
|
98
|
-
// index, stays a no-op with no extra query, same as before #2765.
|
|
99
|
-
if (!entity || !isSearchable) return;
|
|
100
|
-
|
|
101
|
-
const row = await readProjectionRowForSearch(
|
|
102
|
-
ctx,
|
|
103
|
-
registry,
|
|
104
|
-
entityName,
|
|
105
|
-
entity,
|
|
106
|
-
tenantId,
|
|
107
|
-
event.aggregateId,
|
|
108
|
-
);
|
|
109
|
-
// skip: no live row (hard-deleted stream, or soft-deleted) — same
|
|
110
|
-
// outcome as the deleted/forgotten branch above.
|
|
111
|
-
if (!row) {
|
|
112
|
-
await searchAdapter.remove(tenantId, entityName, event.aggregateId);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
state = row;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
state = await decryptSearchableSubjectFields(entityName, state, registry);
|
|
119
|
-
// skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
|
|
120
|
-
if (hasErasedSearchableSubjectField(entityName, state, registry)) {
|
|
121
|
-
await searchAdapter.remove(tenantId, entityName, event.aggregateId);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
const doc = await buildSearchDocument(entityName, event.aggregateId, state, registry);
|
|
125
|
-
if (!doc) {
|
|
126
|
-
// skip: entity isn't searchable (no searchable fields declared)
|
|
127
|
-
return;
|
|
233
|
+
const op = await resolveSearchOperation(event, ctx, registry);
|
|
234
|
+
if (op) await applySearchOperation(searchAdapter, op);
|
|
235
|
+
},
|
|
236
|
+
batchHandler: async (events, ctx) => {
|
|
237
|
+
const perTenant = await resolveSearchOperationsByTenant(events, ctx, registry);
|
|
238
|
+
for (const [tenantId, bucket] of perTenant) {
|
|
239
|
+
await flushSearchOperationsForTenant(searchAdapter, tenantId, bucket);
|
|
128
240
|
}
|
|
129
|
-
await searchAdapter.index(tenantId, doc);
|
|
130
241
|
},
|
|
131
242
|
};
|
|
132
243
|
}
|
|
@@ -451,11 +562,19 @@ export function createJobTriggerEventConsumer(
|
|
|
451
562
|
//
|
|
452
563
|
// "sessions:event:session-revoked" — payload.userId direct (own aggregate,
|
|
453
564
|
// see bundled-features/sessions/session-revoked-event.ts). Fired for
|
|
454
|
-
//
|
|
455
|
-
//
|
|
565
|
+
// self-service revoke, the privileged cross-tenant revoke-all-for-user
|
|
566
|
+
// (SYSTEM_TENANT_ID-anchored DSGVO Art.18 freeze), and the raw
|
|
567
|
+
// sessionRevoker callback (logout / tenant-switch — the only source
|
|
568
|
+
// that also sets payload.streamScope, see readSessionRevokedScope below).
|
|
456
569
|
// fetchPendingEvents has no tenant predicate, so the SYSTEM_TENANT_ID-
|
|
457
570
|
// anchored event reaches this consumer the same as any other — routing
|
|
458
571
|
// is purely on payload.userId, never on event.tenantId.
|
|
572
|
+
// "personal-access-tokens:event:pat-revoked" — payload.userId direct (own
|
|
573
|
+
// aggregate, see bundled-features/personal-access-tokens/pat-revoked-event.ts).
|
|
574
|
+
// Fired by both the self-service revoke handler and
|
|
575
|
+
// revokeAllPatTokensForUser (password-change / MFA-toggle / DSGVO
|
|
576
|
+
// forget-subject). Always scoped to payload.tokenIds — a PAT revoke
|
|
577
|
+
// never touches another credential's stream.
|
|
459
578
|
// "tenant-membership.updated" / "tenant-membership.deleted" — role change
|
|
460
579
|
// or member removal. userId isn't in payload.changes (update only
|
|
461
580
|
// carries the changed fields, e.g. { roles }) so it's read from
|
|
@@ -480,6 +599,7 @@ export function createJobTriggerEventConsumer(
|
|
|
480
599
|
export const ACCESS_INVALIDATION_CONSUMER_NAME = "system:consumer:access-invalidation";
|
|
481
600
|
|
|
482
601
|
const SESSION_REVOKED_EVENT_TYPE = "sessions:event:session-revoked";
|
|
602
|
+
const PAT_REVOKED_EVENT_TYPE = "personal-access-tokens:event:pat-revoked";
|
|
483
603
|
const TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE = "tenant-membership.updated";
|
|
484
604
|
const TENANT_MEMBERSHIP_DELETED_EVENT_TYPE = "tenant-membership.deleted";
|
|
485
605
|
|
|
@@ -490,11 +610,38 @@ function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): strin
|
|
|
490
610
|
return typeof userId === "string" && userId.length > 0 ? userId : undefined;
|
|
491
611
|
}
|
|
492
612
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
613
|
+
function readNonEmptyStringArray(value: unknown): string[] | undefined {
|
|
614
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
615
|
+
return value.every((v): v is string => typeof v === "string") ? value : undefined;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// session-revoked's scope resolution, in order:
|
|
619
|
+
// 1. streamScope === "revoked-sessions" with a non-empty sessionIds array
|
|
620
|
+
// (only the raw sessionRevoker callback sets this) → narrow "sessions"
|
|
621
|
+
// scope, closes exactly those sids.
|
|
622
|
+
// 2. Otherwise, a non-empty keptSessionId ("revoke all others") → spares
|
|
623
|
+
// that one sid, same as before streamScope existed.
|
|
624
|
+
// 3. Otherwise → userwide. Events appended before streamScope existed have
|
|
625
|
+
// neither field and fall straight through to this same userwide default
|
|
626
|
+
// they always had — a replay is unaffected.
|
|
627
|
+
function readSessionRevokedScope(payload: Record<string, unknown>): AccessInvalidationScope {
|
|
628
|
+
if (payload["streamScope"] === "revoked-sessions") {
|
|
629
|
+
const sessionIds = readNonEmptyStringArray(payload["sessionIds"]);
|
|
630
|
+
if (sessionIds) return { kind: "sessions", sessionIds };
|
|
631
|
+
}
|
|
496
632
|
const keptSessionId = payload["keptSessionId"];
|
|
497
|
-
|
|
633
|
+
if (typeof keptSessionId === "string" && keptSessionId.length > 0) {
|
|
634
|
+
return { kind: "all-except-session", keptSessionId };
|
|
635
|
+
}
|
|
636
|
+
return { kind: "user" };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// pat-revoked is always narrow — a PAT revoke never has a reason to close
|
|
640
|
+
// another credential's stream. Malformed tokenIds fail closed to userwide
|
|
641
|
+
// rather than silently dropping the invalidation.
|
|
642
|
+
function readPatRevokedScope(payload: Record<string, unknown>): AccessInvalidationScope {
|
|
643
|
+
const tokenIds = readNonEmptyStringArray(payload["tokenIds"]);
|
|
644
|
+
return tokenIds ? { kind: "pat-tokens", tokenIds } : { kind: "user" };
|
|
498
645
|
}
|
|
499
646
|
|
|
500
647
|
export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
|
|
@@ -518,7 +665,15 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
|
|
|
518
665
|
// poison would otherwise permanently stop access-invalidation for
|
|
519
666
|
// every user behind one bad row).
|
|
520
667
|
if (typeof userId !== "string" || userId.length === 0) return;
|
|
521
|
-
sseBroker.publishAccessInvalidation(userId,
|
|
668
|
+
sseBroker.publishAccessInvalidation(userId, readSessionRevokedScope(event.payload));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (event.type === PAT_REVOKED_EVENT_TYPE) {
|
|
672
|
+
const userId = event.payload["userId"];
|
|
673
|
+
// skip: malformed pat-revoked payload — same fail-open reasoning as
|
|
674
|
+
// session-revoked above.
|
|
675
|
+
if (typeof userId !== "string" || userId.length === 0) return;
|
|
676
|
+
sseBroker.publishAccessInvalidation(userId, readPatRevokedScope(event.payload));
|
|
522
677
|
}
|
|
523
678
|
|
|
524
679
|
if (
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// input schema; writes reached via ctx.write/writeAs/queryAs, hooks or foreign-feature
|
|
3
3
|
// tables are only visible at the actual write, so the gate runs there at runtime.
|
|
4
4
|
import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
5
|
-
import
|
|
5
|
+
import * as z from "zod";
|
|
6
6
|
import { buildEntityTable } from "../db/table-builder";
|
|
7
7
|
import { type PersonalDataGate, tableNameOf } from "../db/tenant-db";
|
|
8
8
|
import {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { defineFeature, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import
|
|
3
|
+
import * as z from "zod";
|
|
4
4
|
import { createTestUser, setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
5
5
|
|
|
6
6
|
// Full-stack L3 proof: a handler with `rateLimit` opt-in is gated by the
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createEmbeddedField,
|
|
4
|
+
createEntity,
|
|
5
|
+
createRegistry,
|
|
6
|
+
createTextField,
|
|
7
|
+
defineFeature,
|
|
8
|
+
} from "../../engine";
|
|
9
|
+
import { deriveSearchAdapterConfig } from "../derive-search-adapter-config";
|
|
10
|
+
|
|
11
|
+
describe("deriveSearchAdapterConfig", () => {
|
|
12
|
+
test("returns undefined for an empty registry", () => {
|
|
13
|
+
expect(deriveSearchAdapterConfig(createRegistry([]))).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("returns undefined when no entity has a searchable field", () => {
|
|
17
|
+
const feature = defineFeature("plain", (r) => {
|
|
18
|
+
r.entity(
|
|
19
|
+
"note",
|
|
20
|
+
createEntity({
|
|
21
|
+
table: "notes",
|
|
22
|
+
fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(deriveSearchAdapterConfig(createRegistry([feature]))).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("unions searchable fields across entities, deduplicated, in first-occurrence order", () => {
|
|
31
|
+
const feature = defineFeature("crm", (r) => {
|
|
32
|
+
r.entity(
|
|
33
|
+
"customer",
|
|
34
|
+
createEntity({
|
|
35
|
+
table: "customers",
|
|
36
|
+
fields: {
|
|
37
|
+
title: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
|
|
38
|
+
email: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
r.entity(
|
|
43
|
+
"order",
|
|
44
|
+
createEntity({
|
|
45
|
+
table: "orders",
|
|
46
|
+
fields: {
|
|
47
|
+
title: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
|
|
48
|
+
note: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
|
|
49
|
+
address: createEmbeddedField(
|
|
50
|
+
{ city: { type: "text", searchable: true } },
|
|
51
|
+
{ personal: false, reason: "test_fixture" },
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const config = deriveSearchAdapterConfig(createRegistry([feature]));
|
|
59
|
+
|
|
60
|
+
expect(config).toEqual({
|
|
61
|
+
searchableFields: ["title", "email", "note", "address_city"],
|
|
62
|
+
rankingFields: ["title", "email", "note", "address_city"],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -255,3 +255,94 @@ describe.skipIf(!MEILI_UP)("meilisearch adapter (live)", () => {
|
|
|
255
255
|
});
|
|
256
256
|
});
|
|
257
257
|
});
|
|
258
|
+
|
|
259
|
+
// Lazy tenant-index configuration off setDefaultConfig, with no
|
|
260
|
+
// explicit per-tenant configure() call from the app.
|
|
261
|
+
describe.skipIf(!MEILI_UP)("meilisearch adapter — lazy default config", () => {
|
|
262
|
+
let lazyClient: Meilisearch;
|
|
263
|
+
let lazyPrefix: string;
|
|
264
|
+
|
|
265
|
+
beforeAll(() => {
|
|
266
|
+
lazyClient = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
|
|
267
|
+
lazyPrefix = `test_lazy_${uuid()}_`;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
afterAll(async () => {
|
|
271
|
+
const indices = await lazyClient.getIndexes();
|
|
272
|
+
for (const idx of indices.results) {
|
|
273
|
+
if (idx.uid.startsWith(lazyPrefix)) {
|
|
274
|
+
try {
|
|
275
|
+
await lazyClient.index(idx.uid).delete().waitTask();
|
|
276
|
+
} catch {
|
|
277
|
+
/* ok */
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("first access via index() configures the tenant index off the default config", async () => {
|
|
284
|
+
const tenant = uuid();
|
|
285
|
+
const lazyAdapter = createMeilisearchAdapter({
|
|
286
|
+
url: MEILI_URL,
|
|
287
|
+
apiKey: MEILI_KEY,
|
|
288
|
+
indexPrefix: lazyPrefix,
|
|
289
|
+
});
|
|
290
|
+
lazyAdapter.setDefaultConfig?.({ searchableFields: ["firstName"] });
|
|
291
|
+
|
|
292
|
+
await lazyAdapter.index(tenant, {
|
|
293
|
+
entityType: "user",
|
|
294
|
+
entityId: 1,
|
|
295
|
+
weight: 1,
|
|
296
|
+
fields: { firstName: "Lazy" },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Mutation-check target: without ensureConfigured() in index(), Meili
|
|
300
|
+
// auto-creates the index on addDocuments but never sets filterable/
|
|
301
|
+
// searchable attributes — assert those directly instead of relying on
|
|
302
|
+
// adapter.search(), which would itself lazily configure the index and
|
|
303
|
+
// mask the mutation.
|
|
304
|
+
const index = lazyClient.index(meilisearchTenantIndex(lazyPrefix, tenant));
|
|
305
|
+
expect(await index.getFilterableAttributes()).toEqual(
|
|
306
|
+
expect.arrayContaining(["_type", "_weight"]),
|
|
307
|
+
);
|
|
308
|
+
expect(await index.getSearchableAttributes()).toEqual(["firstName"]);
|
|
309
|
+
|
|
310
|
+
const results = await lazyAdapter.search(tenant, "lazy", { filterType: "user" });
|
|
311
|
+
expect(results.some((r) => r.entityId === 1 && r.entityType === "user")).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("first access via search() with filterType on a never-written tenant returns empty, not an error", async () => {
|
|
315
|
+
const tenant = uuid();
|
|
316
|
+
const lazyAdapter = createMeilisearchAdapter({
|
|
317
|
+
url: MEILI_URL,
|
|
318
|
+
apiKey: MEILI_KEY,
|
|
319
|
+
indexPrefix: lazyPrefix,
|
|
320
|
+
});
|
|
321
|
+
lazyAdapter.setDefaultConfig?.({ searchableFields: ["firstName"] });
|
|
322
|
+
|
|
323
|
+
const results = await lazyAdapter.search(tenant, "anything", { filterType: "user" });
|
|
324
|
+
expect(results).toEqual([]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("explicit configure() wins over a later setDefaultConfig() — never overwritten", async () => {
|
|
328
|
+
const tenant = uuid();
|
|
329
|
+
const lazyAdapter = createMeilisearchAdapter({
|
|
330
|
+
url: MEILI_URL,
|
|
331
|
+
apiKey: MEILI_KEY,
|
|
332
|
+
indexPrefix: lazyPrefix,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await lazyAdapter.configure(tenant, { searchableFields: ["a"] });
|
|
336
|
+
lazyAdapter.setDefaultConfig?.({ searchableFields: ["b"] });
|
|
337
|
+
|
|
338
|
+
await lazyAdapter.index(tenant, {
|
|
339
|
+
entityType: "user",
|
|
340
|
+
entityId: 1,
|
|
341
|
+
weight: 1,
|
|
342
|
+
fields: { a: "value" },
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const index = lazyClient.index(meilisearchTenantIndex(lazyPrefix, tenant));
|
|
346
|
+
expect(await index.getSearchableAttributes()).toEqual(["a"]);
|
|
347
|
+
});
|
|
348
|
+
});
|