@cosmicdrift/kumiko-framework 0.354.1 → 0.355.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/dist/db/entity-field-encryption.d.ts +10 -1
- package/dist/db/entity-field-encryption.js +31 -2
- package/dist/db/event-store-executor-context.d.ts +8 -2
- package/dist/db/event-store-executor-context.js +41 -9
- package/dist/db/event-store-executor-read.js +7 -4
- package/dist/db/event-store-executor-write.js +95 -56
- package/dist/engine/build-app-schema.js +32 -5
- package/dist/engine/cross-tenant.d.ts +2 -1
- package/dist/engine/cross-tenant.js +18 -14
- package/dist/engine/entity-handlers.js +17 -16
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +7 -1
- package/dist/engine/registry-validate.js +82 -2
- package/dist/engine/registry.js +2 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/event-store/snapshot.d.ts +1 -0
- package/dist/event-store/snapshot.js +12 -3
- package/dist/jobs/job-runner.js +32 -17
- package/dist/observability/escape-hatch-report.d.ts +5 -2
- package/dist/observability/escape-hatch-report.js +52 -22
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/pipeline/dispatch-query.js +4 -0
- package/dist/pipeline/dispatch-shared.js +33 -6
- package/dist/pipeline/dispatch-stream.js +4 -0
- package/dist/pipeline/dispatcher-utils.d.ts +4 -0
- package/dist/pipeline/dispatcher-utils.js +10 -0
- package/dist/pipeline/dispatcher.js +7 -7
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/pre-handler-gates.js +4 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +4 -4
- package/src/changes.json +30 -0
|
@@ -273,15 +273,42 @@ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreen
|
|
|
273
273
|
return projectConfigEditScreen(screen, encryptedConfigKeyQns);
|
|
274
274
|
if (screen.type === "projectionList")
|
|
275
275
|
return projectProjectionListScreen(screen, registry);
|
|
276
|
-
if (screen.type === "
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
276
|
+
if (screen.type === "entityEdit") {
|
|
277
|
+
const { writeExcludedFields: _authoredExclusions, ...authoredScreen } = screen;
|
|
278
|
+
return {
|
|
279
|
+
...authoredScreen,
|
|
280
|
+
urlPrefillFields: sortedUrlPrefillFields(urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId))),
|
|
281
|
+
...entityEditWriteExcludedFields(featureName, screen.entity, registry),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (screen.type === "actionForm" || screen.type === "secretMint") {
|
|
285
|
+
return {
|
|
286
|
+
...screen,
|
|
287
|
+
urlPrefillFields: sortedUrlPrefillFields(urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId))),
|
|
288
|
+
};
|
|
281
289
|
}
|
|
282
290
|
return screen;
|
|
283
291
|
});
|
|
284
292
|
}
|
|
293
|
+
function sortedUrlPrefillFields(declared) {
|
|
294
|
+
return declared === undefined ? [] : [...declared].sort();
|
|
295
|
+
}
|
|
296
|
+
// The screen is the one thing the renderer reads, so the per-verb excludeFields
|
|
297
|
+
// of the entity's generic create/update handlers are projected onto it here.
|
|
298
|
+
function entityEditWriteExcludedFields(featureName, entityName, registry) {
|
|
299
|
+
const excludedFor = (verb) => registry.getWriteHandler(`${qualifyEntityName(featureName, QnTypes.write, entityName)}:${verb}`)
|
|
300
|
+
?.excludedFields;
|
|
301
|
+
const create = excludedFor("create");
|
|
302
|
+
const update = excludedFor("update");
|
|
303
|
+
if (create === undefined && update === undefined)
|
|
304
|
+
return {};
|
|
305
|
+
return {
|
|
306
|
+
writeExcludedFields: {
|
|
307
|
+
...(create !== undefined && { create }),
|
|
308
|
+
...(update !== undefined && { update }),
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
285
312
|
function collectEncryptedConfigKeyQns(registry) {
|
|
286
313
|
const qns = new Set();
|
|
287
314
|
for (const [featureName, feature] of registry.features) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { AccessDeniedError } from "../errors/index.js";
|
|
2
2
|
import type { SessionUser } from "./types/index.js";
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function mayOverrideTenant(user: SessionUser): boolean;
|
|
4
|
+
export declare function tenantOverrideDenied(user: SessionUser, parsedPayload: unknown): AccessDeniedError | undefined;
|
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { ROLES } from "../auth/roles.js";
|
|
2
|
+
import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
|
|
3
|
+
export function mayOverrideTenant(user) {
|
|
4
|
+
return user.roles.includes(ROLES.SystemAdmin);
|
|
5
|
+
}
|
|
6
|
+
function carriesTenantOverride(payload) {
|
|
7
|
+
return (typeof payload === "object" &&
|
|
8
|
+
payload !== null &&
|
|
9
|
+
"tenantIdOverride" in payload &&
|
|
10
|
+
payload.tenantIdOverride !== undefined);
|
|
11
|
+
}
|
|
12
|
+
// A payload-supplied target tenant (tenantIdOverride) is the cross-tenant escape hatch:
|
|
13
|
+
// hasAccess passes a handler for any TenantAdmin, so the dispatcher refuses the field
|
|
14
|
+
// for everyone but a SystemAdmin before any handler sees it. Runs on the schema-parsed
|
|
15
|
+
// payload, so only handlers that declare the field are affected.
|
|
16
|
+
export function tenantOverrideDenied(user, parsedPayload) {
|
|
17
|
+
if (!carriesTenantOverride(parsedPayload) || mayOverrideTenant(user))
|
|
13
18
|
return undefined;
|
|
14
19
|
return new AccessDeniedError({
|
|
15
|
-
|
|
16
|
-
details: { reason: "tenant_override_requires_system_admin" },
|
|
20
|
+
details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
|
|
17
21
|
});
|
|
18
22
|
}
|
|
@@ -217,15 +217,14 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
217
217
|
// actingUserFor reads the row it is about to touch anyway, so for these calls it reports the
|
|
218
218
|
// cross-tenant use per row (with that row as target) instead of dbFor reporting once up front.
|
|
219
219
|
const reportsPerTouchedRow = (ctx) => ctx.systemDb === undefined && crossTenantReason !== undefined && !entity.systemStream;
|
|
220
|
-
// The event stream
|
|
221
|
-
// event-store-executor-context.ts), not
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
const actingUserFor = async (user, id, db, ctx) => {
|
|
220
|
+
// The event stream defaults to the acting user's tenant (streamTenantFor in
|
|
221
|
+
// event-store-executor-context.ts), not the db's — an unfiltered "system"-mode db alone
|
|
222
|
+
// reads the foreign row but appends to an empty stream in the operator's own tenant,
|
|
223
|
+
// surfacing as a bogus version_conflict. The row's tenant goes to the executor as
|
|
224
|
+
// streamTenantId so stream and PII keys follow the row while the actor stays the operator.
|
|
225
|
+
const foreignRowTenantFor = async (user, id, db, ctx) => {
|
|
227
226
|
if (crossTenantReason === undefined || entity.systemStream)
|
|
228
|
-
return
|
|
227
|
+
return undefined;
|
|
229
228
|
const row = await db.fetchOne(table, { id });
|
|
230
229
|
const rowTenantId = row?.["tenantId"];
|
|
231
230
|
if (reportsPerTouchedRow(ctx)) {
|
|
@@ -235,8 +234,8 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
235
234
|
: undefined);
|
|
236
235
|
}
|
|
237
236
|
if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId)
|
|
238
|
-
return
|
|
239
|
-
return
|
|
237
|
+
return undefined;
|
|
238
|
+
return parseTenantId(rowTenantId) ?? undefined;
|
|
240
239
|
};
|
|
241
240
|
let schema;
|
|
242
241
|
let handler;
|
|
@@ -272,14 +271,15 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
272
271
|
...submitted,
|
|
273
272
|
changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
|
|
274
273
|
};
|
|
275
|
-
const
|
|
274
|
+
const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
|
|
276
275
|
// skipUnchanged (#464): API-driven updates diff against the stored
|
|
277
276
|
// row so a resubmitted-but-identical field doesn't force a fresh
|
|
278
277
|
// pii/encrypted ciphertext. Direct executor.update() callers (e.g.
|
|
279
278
|
// KEK-rotation, the user-data-rights #494 backfill) don't go through
|
|
280
279
|
// this handler and keep today's always-re-encrypt behavior, which
|
|
281
280
|
// they rely on to intentionally force a fresh event/ciphertext.
|
|
282
|
-
return executor.update(payload, user, db, {
|
|
281
|
+
return executor.update(payload, event.user, db, {
|
|
282
|
+
streamTenantId,
|
|
283
283
|
skipUnchanged: true,
|
|
284
284
|
preSave: runPreSave &&
|
|
285
285
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -291,8 +291,8 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
291
291
|
handler = async (event, ctx) => {
|
|
292
292
|
const db = dbFor(ctx);
|
|
293
293
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
294
|
-
const
|
|
295
|
-
return executor.delete(payload, user, db);
|
|
294
|
+
const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
|
|
295
|
+
return executor.delete(payload, event.user, db, { streamTenantId });
|
|
296
296
|
};
|
|
297
297
|
break;
|
|
298
298
|
case "restore":
|
|
@@ -300,8 +300,8 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
300
300
|
handler = async (event, ctx) => {
|
|
301
301
|
const db = dbFor(ctx);
|
|
302
302
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
303
|
-
const
|
|
304
|
-
return executor.restore(payload, user, db);
|
|
303
|
+
const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
|
|
304
|
+
return executor.restore(payload, event.user, db, { streamTenantId });
|
|
305
305
|
};
|
|
306
306
|
break;
|
|
307
307
|
default:
|
|
@@ -315,6 +315,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
315
315
|
access: options.access,
|
|
316
316
|
...(options.description !== undefined && { description: options.description }),
|
|
317
317
|
...(agentHints !== undefined && { agent: agentHints }),
|
|
318
|
+
...(excludedFields.length > 0 && { excludedFields }),
|
|
318
319
|
};
|
|
319
320
|
}
|
|
320
321
|
// Append read-time derived-field values (EntityDefinition.derivedFields) to
|
|
@@ -179,6 +179,8 @@ export declare function createEntity<F, const T extends EntityTenancy = "tenant"
|
|
|
179
179
|
/** Event stream lives on SYSTEM_TENANT_ID (tenant-independent aggregate, e.g.
|
|
180
180
|
* user) instead of the creator's tenant. See EntityDefinition.systemStream. */
|
|
181
181
|
readonly systemStream?: boolean;
|
|
182
|
+
readonly eventVersion?: number;
|
|
183
|
+
readonly eventMigrations?: EntityDefinition["eventMigrations"];
|
|
182
184
|
readonly searchWeight?: number;
|
|
183
185
|
readonly defaultCurrency?: string;
|
|
184
186
|
readonly transitions?: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
|
|
@@ -103,6 +103,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
103
103
|
handler: def.handler,
|
|
104
104
|
access: def.access,
|
|
105
105
|
...agentSlots(def),
|
|
106
|
+
...(def.excludedFields && { excludedFields: def.excludedFields }),
|
|
106
107
|
...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
|
|
107
108
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
108
109
|
...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type { SystemHookName } from "./constants.js";
|
|
|
14
14
|
export { ConcurrencyModes, ConfigScopes, LifecycleHookTypes, MessageKind, OnDeleteStrategies, SystemHookNames, SystemHookPriorities, TENANT_CURRENCY_CONFIG_KEY, tenantChannel, } from "./constants.js";
|
|
15
15
|
export type { App, AppConfig } from "./create-app.js";
|
|
16
16
|
export { createApp } from "./create-app.js";
|
|
17
|
-
export {
|
|
17
|
+
export { mayOverrideTenant, tenantOverrideDenied } from "./cross-tenant.js";
|
|
18
18
|
export { dedupeFeatures } from "./dedupe-features.js";
|
|
19
19
|
export { defineFeature } from "./define-feature.js";
|
|
20
20
|
export type { PagedQueryHandlerDefinition, QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "./define-handler.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -12,7 +12,7 @@ export { parseChangesetChanges } from "./changeset-changes.js";
|
|
|
12
12
|
export { access, createSeed, createSystemConfig, createSystemSeed, createTenantConfig, createTenantSeed, createUserConfig, createUserSeed, isEncryptedAtRest, } from "./config-helpers.js";
|
|
13
13
|
export { ConcurrencyModes, ConfigScopes, LifecycleHookTypes, MessageKind, OnDeleteStrategies, SystemHookNames, SystemHookPriorities, TENANT_CURRENCY_CONFIG_KEY, tenantChannel, } from "./constants.js";
|
|
14
14
|
export { createApp } from "./create-app.js";
|
|
15
|
-
export {
|
|
15
|
+
export { mayOverrideTenant, tenantOverrideDenied } from "./cross-tenant.js";
|
|
16
16
|
export { dedupeFeatures } from "./dedupe-features.js";
|
|
17
17
|
export { defineFeature } from "./define-feature.js";
|
|
18
18
|
export { definePagedQueryHandler, defineQueryHandler, defineWriteHandler, isPagedQueryHandler, } from "./define-handler.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { type EntityLifecycleVerb } from "../db/event-store-executor-context.js";
|
|
1
2
|
import type { RegistryState } from "./registry-state.js";
|
|
2
|
-
import type { FeatureDefinition } from "./types/index.js";
|
|
3
|
+
import type { EntityEventMigration, EventUpcastFn, FeatureDefinition } from "./types/index.js";
|
|
3
4
|
export declare function finalizeWorkspaceNavMembership(state: RegistryState): void;
|
|
4
5
|
export declare function populateHandlerEntityMappings(state: RegistryState, features: readonly FeatureDefinition[]): void;
|
|
5
6
|
export declare function validateExtensionSelectors(state: RegistryState): void;
|
|
@@ -10,6 +11,10 @@ export declare function buildIncomingRelations(state: RegistryState): void;
|
|
|
10
11
|
export declare function validateFieldAccessHandlersAreEntityMapped(state: RegistryState, features: readonly FeatureDefinition[]): void;
|
|
11
12
|
export declare function validateRelationTargetsExist(state: RegistryState): void;
|
|
12
13
|
export declare function validateEventMigrationVersions(state: RegistryState, features: readonly FeatureDefinition[]): void;
|
|
14
|
+
export declare const ENTITY_LIFECYCLE_VERBS: readonly ["created", "updated", "deleted", "restored", "forgotten"];
|
|
15
|
+
type RowTransform = EntityEventMigration["transform"];
|
|
16
|
+
export declare function adaptRowTransformToPayload(verb: EntityLifecycleVerb, transform: RowTransform): EventUpcastFn;
|
|
17
|
+
export declare function buildEntityEventUpcasterChains(state: RegistryState): void;
|
|
13
18
|
export declare function buildEventUpcasterChains(state: RegistryState, features: readonly FeatureDefinition[]): void;
|
|
14
19
|
export declare function validateProjectionApplyKeys(state: RegistryState): void;
|
|
15
20
|
export declare function validateRequiredFeatures(state: RegistryState, features: readonly FeatureDefinition[]): void;
|
|
@@ -24,3 +29,4 @@ export declare function validateExtensionUsageTargets(state: RegistryState): voi
|
|
|
24
29
|
export declare function computeHasRateLimitedHandler(state: RegistryState): void;
|
|
25
30
|
export declare function publishEventPiiCatalog(state: RegistryState): void;
|
|
26
31
|
export declare function autoWireSoftDeleteJobs(state: RegistryState): void;
|
|
32
|
+
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii.js";
|
|
2
2
|
import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption.js";
|
|
3
|
+
import { entityEventName } from "../db/event-store-executor-context.js";
|
|
3
4
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
|
|
4
5
|
import { resolveName } from "./handler-helpers.js";
|
|
5
6
|
import { parseRefTargetEntityName } from "./parse-ref-target.js";
|
|
@@ -327,6 +328,86 @@ export function validateEventMigrationVersions(state, features) {
|
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
}
|
|
331
|
+
export const ENTITY_LIFECYCLE_VERBS = [
|
|
332
|
+
"created",
|
|
333
|
+
"updated",
|
|
334
|
+
"deleted",
|
|
335
|
+
"restored",
|
|
336
|
+
"forgotten",
|
|
337
|
+
];
|
|
338
|
+
const MAX_ENTITY_EVENT_VERSION = 999;
|
|
339
|
+
function isRecord(value) {
|
|
340
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
341
|
+
}
|
|
342
|
+
// created is the flat row, updated carries changes + previous, the rest only previous.
|
|
343
|
+
function rowPartKeys(verb) {
|
|
344
|
+
if (verb === "created")
|
|
345
|
+
return "whole";
|
|
346
|
+
if (verb === "updated")
|
|
347
|
+
return ["changes", "previous"];
|
|
348
|
+
return ["previous"];
|
|
349
|
+
}
|
|
350
|
+
export function adaptRowTransformToPayload(verb, transform) {
|
|
351
|
+
const parts = rowPartKeys(verb);
|
|
352
|
+
return async (payload, ctx) => {
|
|
353
|
+
if (!isRecord(payload))
|
|
354
|
+
return payload;
|
|
355
|
+
if (parts === "whole")
|
|
356
|
+
return transform(payload, ctx);
|
|
357
|
+
const result = { ...payload };
|
|
358
|
+
for (const key of parts) {
|
|
359
|
+
const part = payload[key];
|
|
360
|
+
if (isRecord(part))
|
|
361
|
+
result[key] = await transform(part, ctx);
|
|
362
|
+
}
|
|
363
|
+
return result;
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
// Boot-time check of an entity's eventVersion/eventMigrations; returns the steps keyed by fromVersion, empty for v1.
|
|
367
|
+
function entityEventMigrationSteps(entityName, eventVersion, migrations) {
|
|
368
|
+
if (!Number.isInteger(eventVersion) ||
|
|
369
|
+
eventVersion < 1 ||
|
|
370
|
+
eventVersion > MAX_ENTITY_EVENT_VERSION) {
|
|
371
|
+
throw new Error(`Entity "${entityName}" declares eventVersion ${String(eventVersion)}; it must be an integer between 1 and ${MAX_ENTITY_EVENT_VERSION}.`);
|
|
372
|
+
}
|
|
373
|
+
if (eventVersion === 1 && migrations.length > 0) {
|
|
374
|
+
throw new Error(`Entity "${entityName}" declares eventMigrations but no eventVersion > 1. Set eventVersion to the target version.`);
|
|
375
|
+
}
|
|
376
|
+
const steps = new Map();
|
|
377
|
+
for (const m of migrations) {
|
|
378
|
+
if (m.toVersion !== m.fromVersion + 1 || m.toVersion > eventVersion) {
|
|
379
|
+
throw new Error(`Entity "${entityName}" declares an eventMigration v${m.fromVersion} → v${m.toVersion}; ` +
|
|
380
|
+
`steps must advance by exactly one and not exceed eventVersion ${eventVersion}.`);
|
|
381
|
+
}
|
|
382
|
+
steps.set(m.fromVersion, m.transform);
|
|
383
|
+
}
|
|
384
|
+
for (let v = 1; v < eventVersion; v++) {
|
|
385
|
+
if (!steps.has(v)) {
|
|
386
|
+
throw new Error(`Entity "${entityName}" declares eventVersion ${eventVersion} but no eventMigration ` +
|
|
387
|
+
`covers the step v${v} → v${v + 1}.`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return steps;
|
|
391
|
+
}
|
|
392
|
+
export function buildEntityEventUpcasterChains(state) {
|
|
393
|
+
for (const [entityName, entity] of state.entityMap) {
|
|
394
|
+
const eventVersion = entity.eventVersion ?? 1;
|
|
395
|
+
const steps = entityEventMigrationSteps(entityName, eventVersion, entity.eventMigrations ?? []);
|
|
396
|
+
if (eventVersion === 1)
|
|
397
|
+
continue;
|
|
398
|
+
for (const verb of ENTITY_LIFECYCLE_VERBS) {
|
|
399
|
+
const key = entityEventName(entityName, verb);
|
|
400
|
+
if (state.eventUpcasterMap.has(key) || state.eventMap.has(key)) {
|
|
401
|
+
throw new Error(`Entity event type "${key}" collides with an already registered event; event types must be unique.`);
|
|
402
|
+
}
|
|
403
|
+
const chain = new Map();
|
|
404
|
+
for (const [from, transform] of steps) {
|
|
405
|
+
chain.set(from, adaptRowTransformToPayload(verb, transform));
|
|
406
|
+
}
|
|
407
|
+
state.eventUpcasterMap.set(key, { currentVersion: eventVersion, chain });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
330
411
|
export function buildEventUpcasterChains(state, features) {
|
|
331
412
|
// Stitch the upcaster chain per qualified event. At this point, gaps in
|
|
332
413
|
// the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
|
|
@@ -371,7 +452,6 @@ export function validateProjectionApplyKeys(state) {
|
|
|
371
452
|
// CRUD types per source entity PLUS every domain event registered via
|
|
372
453
|
// r.defineEvent — an apply-handler for a domain event is how a projection
|
|
373
454
|
// reacts to ctx.appendEvent writes on the same aggregate stream.
|
|
374
|
-
const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored", "forgotten"];
|
|
375
455
|
const allDomainEventNames = new Set(state.eventMap.keys());
|
|
376
456
|
for (const [projName, projDef] of state.projectionMap) {
|
|
377
457
|
const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
|
|
@@ -397,7 +477,7 @@ export function validateProjectionApplyKeys(state) {
|
|
|
397
477
|
const isEventsOnlySource = !sources.every((src) => state.entityMap.has(src));
|
|
398
478
|
for (const src of rebuildSources) {
|
|
399
479
|
if (state.entityMap.has(src)) {
|
|
400
|
-
for (const verb of
|
|
480
|
+
for (const verb of ENTITY_LIFECYCLE_VERBS)
|
|
401
481
|
validEventTypes.add(`${src}.${verb}`);
|
|
402
482
|
}
|
|
403
483
|
}
|
package/dist/engine/registry.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dedupeFeatures } from "./dedupe-features.js";
|
|
|
3
3
|
import { buildRegistryFacade } from "./registry-facade.js";
|
|
4
4
|
import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateSystemEvents, populateTranslations, } from "./registry-ingest.js";
|
|
5
5
|
import { createInitialState } from "./registry-state.js";
|
|
6
|
-
import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
|
|
6
|
+
import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEntityEventUpcasterChains, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
|
|
7
7
|
// This is where the magic happens. By "magic" I mean: precomputed maps.
|
|
8
8
|
// I build everything once at boot (hooks, relations, searchable fields, ...)
|
|
9
9
|
// so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
|
|
@@ -37,6 +37,7 @@ export function createRegistry(rawFeatures) {
|
|
|
37
37
|
validateRelationTargetsExist(state);
|
|
38
38
|
validateEventMigrationVersions(state, features);
|
|
39
39
|
buildEventUpcasterChains(state, features);
|
|
40
|
+
buildEntityEventUpcasterChains(state);
|
|
40
41
|
validateProjectionApplyKeys(state);
|
|
41
42
|
validateRequiredFeatures(state, features);
|
|
42
43
|
resolveNotificationTriggersAndRegisterHooks(state);
|
|
@@ -6,7 +6,7 @@ export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap,
|
|
|
6
6
|
export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
|
|
7
7
|
export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
|
|
8
8
|
export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
|
|
9
|
-
export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
|
|
9
|
+
export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityEventMigration, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
|
|
10
10
|
export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
|
|
11
11
|
export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
12
12
|
export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
|
package/dist/errors/reasons.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare const FrameworkReasons: {
|
|
|
6
6
|
readonly featureDisabled: "feature_disabled";
|
|
7
7
|
readonly systemIdentitySwitchDenied: "system_identity_switch_denied";
|
|
8
8
|
readonly identitySwitchDenied: "identity_switch_denied";
|
|
9
|
+
readonly tenantOverrideRequiresSystemAdmin: "tenant_override_requires_system_admin";
|
|
9
10
|
readonly unsafeAllTenantsDenied: "unsafe_all_tenants_denied";
|
|
10
11
|
readonly memberResolutionDenied: "member_resolution_denied";
|
|
11
12
|
readonly memberResolutionReadOnly: "member_resolution_read_only";
|
package/dist/errors/reasons.js
CHANGED
|
@@ -40,6 +40,8 @@ export const FrameworkReasons = {
|
|
|
40
40
|
// non-SYSTEM identity other than its own caller (different user, tenant,
|
|
41
41
|
// claims, origin or extra roles) without r.systemScope() or escapeHatch.
|
|
42
42
|
identitySwitchDenied: "identity_switch_denied",
|
|
43
|
+
// AccessDeniedError: a payload carried tenantIdOverride but the caller is no SystemAdmin.
|
|
44
|
+
tenantOverrideRequiresSystemAdmin: "tenant_override_requires_system_admin",
|
|
43
45
|
// AccessDeniedError: a handler/hook called ctx.queryProjection(..., { unsafeAllTenants: true })
|
|
44
46
|
// without an r.systemScope() feature or a declared escapeHatch.
|
|
45
47
|
unsafeAllTenantsDenied: "unsafe_all_tenants_denied",
|
|
@@ -4,6 +4,7 @@ import type { DbConnection, DbRunner } from "../db/connection.js";
|
|
|
4
4
|
import type { TenantId } from "../engine/types/index.js";
|
|
5
5
|
export declare const snapshotsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
|
|
6
6
|
export declare function createSnapshotsTable(db: DbConnection): Promise<void>;
|
|
7
|
+
export declare function effectiveSnapshotGeneration(callerVersion: number, entityEventVersion: number | undefined): number;
|
|
7
8
|
export type Snapshot<TState extends Record<string, unknown> = Record<string, unknown>> = {
|
|
8
9
|
readonly aggregateId: string;
|
|
9
10
|
readonly tenantId: TenantId;
|
|
@@ -68,6 +68,14 @@ export async function createSnapshotsTable(db) {
|
|
|
68
68
|
}
|
|
69
69
|
await unsafePushTables(db, { kumikoSnapshots: snapshotsTable });
|
|
70
70
|
}
|
|
71
|
+
const ENTITY_EVENT_VERSION_RADIX = 1000;
|
|
72
|
+
// Entity eventVersion 1 keeps the caller generation (existing snapshots stay valid);
|
|
73
|
+
// higher versions map to negative values, disjoint from caller generations (>= 1).
|
|
74
|
+
export function effectiveSnapshotGeneration(callerVersion, entityEventVersion) {
|
|
75
|
+
if (entityEventVersion === undefined || entityEventVersion === 1)
|
|
76
|
+
return callerVersion;
|
|
77
|
+
return -(callerVersion * ENTITY_EVENT_VERSION_RADIX + entityEventVersion);
|
|
78
|
+
}
|
|
71
79
|
// Upsert-style save so re-snapshotting the same (aggregateId, version) is
|
|
72
80
|
// idempotent. Caller can retake a snapshot at the same version without
|
|
73
81
|
// bespoke error handling — useful when a feature's snapshot policy runs
|
|
@@ -120,9 +128,10 @@ export async function loadAggregateWithSnapshot(db, aggregateId, tenantId, reduc
|
|
|
120
128
|
return { state: initial, version: 0, snapshotHit: false };
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
|
-
const
|
|
131
|
+
const callerVersion = options?.snapshotVersion ?? 1;
|
|
132
|
+
const generationFor = (aggregateType) => effectiveSnapshotGeneration(callerVersion, options?.entityEventVersionOf?.(aggregateType));
|
|
124
133
|
const stored = await loadLatestSnapshot(db, aggregateId, tenantId);
|
|
125
|
-
const snapshot = stored && stored.snapshotVersion ===
|
|
134
|
+
const snapshot = stored && stored.snapshotVersion === generationFor(stored.aggregateType) ? stored : null;
|
|
126
135
|
const baseState = snapshot ? snapshot.state : initial;
|
|
127
136
|
const afterVersion = snapshot ? snapshot.version : 0;
|
|
128
137
|
const delta = await loadEventsAfterVersion(db, aggregateId, tenantId, afterVersion);
|
|
@@ -141,7 +150,7 @@ export async function loadAggregateWithSnapshot(db, aggregateId, tenantId, reduc
|
|
|
141
150
|
aggregateType: lastDelta.aggregateType,
|
|
142
151
|
version: latestVersion,
|
|
143
152
|
state,
|
|
144
|
-
snapshotVersion:
|
|
153
|
+
snapshotVersion: generationFor(lastDelta.aggregateType),
|
|
145
154
|
});
|
|
146
155
|
}
|
|
147
156
|
catch {
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -11,9 +11,10 @@ import { InternalError } from "../errors/index.js";
|
|
|
11
11
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
12
12
|
import { createFileContext } from "../files/file-handle.js";
|
|
13
13
|
import { createFallbackLogger } from "../logging/index.js";
|
|
14
|
-
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
14
|
+
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
15
15
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
16
16
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
17
|
+
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
17
18
|
import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
|
|
18
19
|
import { bridgeStub } from "../testing/handler-context.js";
|
|
19
20
|
// Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
|
|
@@ -333,6 +334,7 @@ export function createJobRunner(options) {
|
|
|
333
334
|
if (context.meter)
|
|
334
335
|
registerStandardMetrics(context.meter);
|
|
335
336
|
// Instance-scoped, not module-level: each runner (and each test) audits a system cron's declared hatch once.
|
|
337
|
+
const escapeHatchReportWindow = createEscapeHatchReportWindow();
|
|
336
338
|
const systemCronEscapeHatchDedup = createEscapeHatchProcessDedup([
|
|
337
339
|
"unsafe-raw",
|
|
338
340
|
"global-write",
|
|
@@ -365,7 +367,8 @@ export function createJobRunner(options) {
|
|
|
365
367
|
tenantId: SYSTEM_TENANT_ID,
|
|
366
368
|
actor: systemUser.id,
|
|
367
369
|
sink: context._escapeHatchAuditSink,
|
|
368
|
-
log:
|
|
370
|
+
log: errorLogger,
|
|
371
|
+
window: escapeHatchReportWindow,
|
|
369
372
|
});
|
|
370
373
|
const result = await handler.handler({ type: ACTIVE_TENANT_IDS_QUERY_NAME, payload: {}, user: systemUser }, {
|
|
371
374
|
db: systemModeDb,
|
|
@@ -815,7 +818,9 @@ export function createJobRunner(options) {
|
|
|
815
818
|
tenantId,
|
|
816
819
|
actor: jobSystemUser.id,
|
|
817
820
|
sink: context._escapeHatchAuditSink,
|
|
818
|
-
log:
|
|
821
|
+
log: errorLogger,
|
|
822
|
+
window: escapeHatchReportWindow,
|
|
823
|
+
...(triggeredById !== null && { caller: triggeredById }),
|
|
819
824
|
...(isSystemCronRun && { processDedup: systemCronEscapeHatchDedup }),
|
|
820
825
|
...(context.meter && { meter: context.meter }),
|
|
821
826
|
});
|
|
@@ -855,6 +860,25 @@ export function createJobRunner(options) {
|
|
|
855
860
|
// Per-job-run reader, created lazily on queryAsMember's first call and
|
|
856
861
|
// cached for the rest of this run.
|
|
857
862
|
let memberReader;
|
|
863
|
+
const identitySwitchHatch = escapeHatchFor(jobDef.escapeHatch, "systemIdentity");
|
|
864
|
+
const identitySwitchGrantReason = identitySwitchHatch?.reason ??
|
|
865
|
+
(isSystemJob ? `r.systemScope() feature of job "${jobName}"` : undefined);
|
|
866
|
+
const ungatedQueryAs = (user, qn, payload) => {
|
|
867
|
+
if (!dispatchWriteRef) {
|
|
868
|
+
throw new Error("JobContext.queryAs called before dispatcher attached — call attachDispatcher() first");
|
|
869
|
+
}
|
|
870
|
+
return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
|
|
871
|
+
};
|
|
872
|
+
// Only writeAs is gated for jobs; queryAs stays ungated (fw#2859).
|
|
873
|
+
const gatedIdentitySwitch = createGatedIdentitySwitch(`job "${jobName}"`, jobSystemUser, isSystemJob || identitySwitchHatch !== undefined, {
|
|
874
|
+
queryAs: ungatedQueryAs,
|
|
875
|
+
writeAs: async (user, qn, payload) => {
|
|
876
|
+
if (!dispatchWriteRef) {
|
|
877
|
+
throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
|
|
878
|
+
}
|
|
879
|
+
return dispatchWriteRef.write(user, qn, payload, jobOrigin);
|
|
880
|
+
},
|
|
881
|
+
}, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
|
|
858
882
|
const jobContext = {
|
|
859
883
|
...context,
|
|
860
884
|
db: jobDb ?? missingDbProxy(jobName),
|
|
@@ -884,18 +908,8 @@ export function createJobRunner(options) {
|
|
|
884
908
|
}
|
|
885
909
|
return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
|
|
886
910
|
},
|
|
887
|
-
writeAs:
|
|
888
|
-
|
|
889
|
-
throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
|
|
890
|
-
}
|
|
891
|
-
return dispatchWriteRef.write(user, qn, payload, jobOrigin);
|
|
892
|
-
},
|
|
893
|
-
queryAs: (user, qn, payload) => {
|
|
894
|
-
if (!dispatchWriteRef) {
|
|
895
|
-
throw new Error("JobContext.queryAs called before dispatcher attached — call attachDispatcher() first");
|
|
896
|
-
}
|
|
897
|
-
return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
|
|
898
|
-
},
|
|
911
|
+
writeAs: gatedIdentitySwitch.writeAs,
|
|
912
|
+
queryAs: ungatedQueryAs,
|
|
899
913
|
queryAsMember: (userId, qn, payload) => {
|
|
900
914
|
if (!dispatchWriteRef) {
|
|
901
915
|
throw new Error("JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first");
|
|
@@ -970,7 +984,7 @@ export function createJobRunner(options) {
|
|
|
970
984
|
// When parentContext is set, the new parent-aware StartSpanOptions
|
|
971
985
|
// plumbs it through to startSpan — no manual try/finally needed.
|
|
972
986
|
try {
|
|
973
|
-
await tracer.withSpan("job.execute", {
|
|
987
|
+
await withEscapeHatchAuditScope(() => tracer.withSpan("job.execute", {
|
|
974
988
|
attributes: {
|
|
975
989
|
"job.name": jobName,
|
|
976
990
|
"job.id": jobId,
|
|
@@ -988,7 +1002,7 @@ export function createJobRunner(options) {
|
|
|
988
1002
|
...(consumerLane !== undefined ? { "kumiko.job.consumer_lane": consumerLane } : {}),
|
|
989
1003
|
},
|
|
990
1004
|
...(parentContext ? { parent: parentContext } : {}),
|
|
991
|
-
}, runInSpan);
|
|
1005
|
+
}, runInSpan));
|
|
992
1006
|
}
|
|
993
1007
|
finally {
|
|
994
1008
|
// Release the sequential lock value-matched (Lua compare-and-delete
|
|
@@ -1191,6 +1205,7 @@ export function createJobRunner(options) {
|
|
|
1191
1205
|
readinessTimeout.cancel();
|
|
1192
1206
|
}
|
|
1193
1207
|
await Promise.all([queues.api.close(), queues.worker.close()]);
|
|
1208
|
+
await flushEscapeHatchAudits();
|
|
1194
1209
|
if (lockRedis) {
|
|
1195
1210
|
// quit() drains in-flight commands; disconnect() would cancel them
|
|
1196
1211
|
// and risk a half-released lock.
|
|
@@ -14,16 +14,19 @@ export type EscapeHatchProcessDedup = {
|
|
|
14
14
|
readonly seen: Set<string>;
|
|
15
15
|
};
|
|
16
16
|
export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
|
|
17
|
+
export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
|
|
18
|
+
export declare function flushEscapeHatchAudits(): Promise<void>;
|
|
17
19
|
export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
|
|
18
20
|
readonly sink?: EscapeHatchAuditSink;
|
|
19
|
-
readonly log
|
|
21
|
+
readonly log: Pick<Logger, "warn" | "error">;
|
|
20
22
|
}): void;
|
|
21
23
|
export declare function createEscapeHatchReporter(opts: {
|
|
22
24
|
readonly handler: string;
|
|
23
25
|
readonly tenantId: TenantId;
|
|
24
26
|
readonly actor: string;
|
|
27
|
+
readonly caller?: string | undefined;
|
|
25
28
|
readonly sink?: EscapeHatchAuditSink;
|
|
26
|
-
readonly log
|
|
29
|
+
readonly log: Pick<Logger, "warn" | "error">;
|
|
27
30
|
readonly window?: EscapeHatchReportWindow;
|
|
28
31
|
readonly processDedup?: EscapeHatchProcessDedup;
|
|
29
32
|
readonly meter?: Meter;
|