@cosmicdrift/kumiko-bundled-features 0.352.0 → 0.354.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/agent-tools/agent-doc-lint.d.ts +2 -0
- package/dist/agent-tools/agent-doc-lint.js +61 -1
- package/dist/agent-tools/agent-manifest.d.ts +1 -0
- package/dist/agent-tools/agent-manifest.js +17 -5
- package/dist/agent-tools/feature.js +7 -2
- package/dist/agent-tools/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/manifest-deny-qns.d.ts +3 -0
- package/dist/agent-tools/manifest-deny-qns.js +17 -0
- package/dist/agent-tools/tool-catalog.d.ts +2 -1
- package/dist/agent-tools/tool-catalog.js +4 -1
- package/dist/agent-tools/tool-dispatch.js +1 -1
- package/dist/audit/handlers/list.query.js +13 -8
- package/dist/auth-email-password/handlers/change-password.write.js +1 -0
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
- package/dist/auth-email-password/handlers/login.write.js +1 -0
- package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-confirm.write.js +6 -4
- package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
- package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
- package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
- package/dist/auth-email-password/index.d.ts +2 -0
- package/dist/auth-email-password/index.js +2 -0
- package/dist/auth-email-password/seeding.d.ts +2 -0
- package/dist/auth-email-password/seeding.js +30 -8
- package/dist/auth-email-password/signup-activation.d.ts +24 -0
- package/dist/auth-email-password/signup-activation.js +41 -0
- package/dist/auth-email-password/signup-token-store.d.ts +5 -0
- package/dist/auth-email-password/signup-token-store.js +8 -3
- package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
- package/dist/auth-email-password/web/auth-gate.js +5 -5
- package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
- package/dist/auth-email-password/web/auth-redirect.js +14 -0
- package/dist/auth-email-password/web/login-screen.js +2 -2
- package/dist/auth-email-password/web/user-menu.js +1 -1
- package/dist/auth-mfa/feature.js +5 -0
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
- package/dist/auth-mfa/handlers/verify.write.js +1 -0
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
- package/dist/auth-mfa/screens.js +2 -2
- package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
- package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
- package/dist/billing-foundation/billing-info-query.d.ts +5 -2
- package/dist/billing-foundation/checkout-core.d.ts +7 -0
- package/dist/billing-foundation/checkout-core.js +7 -2
- package/dist/billing-foundation/entities.d.ts +8 -0
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
- package/dist/billing-foundation/projection.d.ts +8 -0
- package/dist/cap-counter/entity.d.ts +1 -0
- package/dist/cap-overview/access.js +2 -9
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
- package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
- package/dist/channel-in-app/in-app-channel.js +1 -1
- package/dist/channel-in-app/tables.js +4 -2
- package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
- package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
- package/dist/config/handlers/values.query.d.ts +3 -1
- package/dist/config/handlers/values.query.js +7 -2
- package/dist/config/table.d.ts +6 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
- package/dist/custom-fields/entity.d.ts +4 -0
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
- package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
- package/dist/delivery/db/queries/address-opt-outs.js +12 -7
- package/dist/delivery/delivery-service.js +60 -12
- package/dist/delivery/public-names.js +1 -1
- package/dist/delivery/tables.d.ts +6 -0
- package/dist/delivery/unsubscribe.js +66 -14
- package/dist/document-ingest-foundation/entity.d.ts +4 -0
- package/dist/feature-toggles/feature.js +2 -3
- package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
- package/dist/feature-toggles/toggle-runtime.js +30 -6
- package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
- package/dist/feature-toggles/toggle-sync-signal.js +29 -10
- package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
- package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
- package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
- package/dist/files-tenant-data/hooks.js +61 -24
- package/dist/folders/entity.d.ts +8 -0
- package/dist/folders/feature.js +2 -10
- package/dist/folders/handlers/delete-folder.write.js +31 -4
- package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
- package/dist/form-draft/db/queries/cleanup.js +4 -3
- package/dist/form-draft/entity.d.ts +2 -0
- package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
- package/dist/form-draft/handlers/cleanup.job.js +11 -5
- package/dist/foundation-shared/index.d.ts +1 -1
- package/dist/foundation-shared/index.js +1 -1
- package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
- package/dist/foundation-shared/mail-host-policy.js +13 -4
- package/dist/inbound-mail-foundation/entities.d.ts +25 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -0
- package/dist/inbound-provider-imap/feature.js +2 -0
- package/dist/inbound-provider-imap/imap-client.js +3 -4
- package/dist/jobs/constants.d.ts +1 -0
- package/dist/jobs/constants.js +1 -0
- package/dist/jobs/db/queries/retention.d.ts +1 -0
- package/dist/jobs/db/queries/retention.js +5 -0
- package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
- package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
- package/dist/jobs/feature.js +7 -1
- package/dist/jobs/handlers/list.query.d.ts +2 -1
- package/dist/jobs/handlers/list.query.js +40 -15
- package/dist/jobs/handlers/tenant-failures.query.js +1 -1
- package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
- package/dist/jobs/handlers/tenant-runs.query.js +72 -0
- package/dist/jobs/i18n.js +1 -0
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/job-run-logger.d.ts +2 -2
- package/dist/jobs/job-run-logger.js +98 -9
- package/dist/jobs/job-run-table.d.ts +6 -0
- package/dist/jobs/tenant-job-failure-table.js +15 -2
- package/dist/jobs/tenant-job-run-table.d.ts +4 -0
- package/dist/jobs/tenant-job-run-table.js +34 -0
- package/dist/ledger/entity.d.ts +24 -8
- package/dist/ledger/entity.js +17 -5
- package/dist/ledger/feature.js +3 -0
- package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
- package/dist/ledger/handlers/create-transaction.write.js +1 -0
- package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
- package/dist/ledger/schedule-subject-pair.d.ts +2 -0
- package/dist/ledger/schedule-subject-pair.js +24 -0
- package/dist/ledger/schemas.js +8 -2
- package/dist/mail-transport-smtp/feature.d.ts +3 -4
- package/dist/mail-transport-smtp/feature.js +4 -13
- package/dist/managed-pages/feature.js +28 -10
- package/dist/managed-pages/table.d.ts +12 -0
- package/dist/notes-history/entity.d.ts +12 -0
- package/dist/notes-history/entity.js +4 -0
- package/dist/notes-history/feature.js +2 -10
- package/dist/personal-access-tokens/handlers/create.write.js +1 -0
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
- package/dist/personal-access-tokens/screens.js +14 -3
- package/dist/secrets/table.d.ts +1 -0
- package/dist/seo/feature.js +28 -6
- package/dist/sessions/feature.d.ts +1 -0
- package/dist/sessions/feature.js +9 -8
- package/dist/sessions/handlers/detail.query.d.ts +1 -1
- package/dist/sessions/handlers/detail.query.js +2 -2
- package/dist/sessions/handlers/list.query.d.ts +1 -1
- package/dist/sessions/handlers/list.query.js +5 -3
- package/dist/sessions/schema/user-session.d.ts +4 -0
- package/dist/sessions/screens.d.ts +2 -2
- package/dist/sessions/screens.js +6 -7
- package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
- package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
- package/dist/shared/encrypt-for-direct-write.js +10 -2
- package/dist/shared/index.d.ts +4 -3
- package/dist/shared/index.js +4 -3
- package/dist/shared/parent-visibility.d.ts +2 -1
- package/dist/shared/parent-visibility.js +21 -0
- package/dist/shared/rate-limited-text-response.d.ts +2 -0
- package/dist/shared/rate-limited-text-response.js +9 -0
- package/dist/shared/row-bound-grant.js +16 -2
- package/dist/shared/session-locale-field.d.ts +1 -0
- package/dist/shared/session-locale-field.js +6 -0
- package/dist/tags/entity.d.ts +9 -0
- package/dist/tags/feature.js +2 -10
- package/dist/template-resolver/api.d.ts +2 -2
- package/dist/template-resolver/handlers/list.query.d.ts +2 -0
- package/dist/template-resolver/handlers/list.query.js +2 -2
- package/dist/template-resolver/table.d.ts +48 -30
- package/dist/template-resolver/user-content-table.d.ts +34 -20
- package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
- package/dist/tenant/decrypt-tenant-names.js +10 -0
- package/dist/tenant/feature.js +7 -1
- package/dist/tenant/handlers/me.query.js +5 -1
- package/dist/tenant/handlers/memberships.query.js +3 -2
- package/dist/tenant/handlers/tenant-directory.query.js +9 -3
- package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
- package/dist/tenant/handlers/update-member-roles.write.js +1 -0
- package/dist/tenant/index.d.ts +1 -0
- package/dist/tenant/index.js +1 -0
- package/dist/tenant/invitation-table.d.ts +54 -8
- package/dist/tenant/invitation-table.js +6 -1
- package/dist/tenant/invited-membership.d.ts +8 -6
- package/dist/tenant/invited-membership.js +28 -7
- package/dist/tenant/membership-table.d.ts +4 -0
- package/dist/tenant/schema/tenant.d.ts +18 -10
- package/dist/tenant/schema/tenant.js +4 -6
- package/dist/tenant/screens.js +1 -1
- package/dist/tenant-handover/move-entity-graph.js +11 -18
- package/dist/tenant-handover/transfer-graph.d.ts +2 -21
- package/dist/tenant-handover/transfer-graph.js +8 -75
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +13 -0
- package/dist/tenant-lifecycle/constants.d.ts +3 -0
- package/dist/tenant-lifecycle/constants.js +5 -0
- package/dist/tenant-lifecycle/events.d.ts +5 -0
- package/dist/tenant-lifecycle/events.js +5 -0
- package/dist/tenant-lifecycle/feature.js +6 -3
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +10 -5
- package/dist/tenant-lifecycle/run-tenant-destroy.js +59 -17
- package/dist/tenant-lifecycle/stages.d.ts +8 -1
- package/dist/tenant-lifecycle/stages.js +33 -4
- package/dist/tenant-lifecycle/testing.d.ts +8 -0
- package/dist/tenant-lifecycle/testing.js +42 -0
- package/dist/tier-engine/entity.d.ts +2 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
- package/dist/tier-engine/tier-resolver.d.ts +2 -0
- package/dist/user/handlers/detail.query.js +2 -0
- package/dist/user/handlers/list.query.js +6 -2
- package/dist/user/handlers/update.write.js +1 -0
- package/dist/user/schema/user.d.ts +22 -8
- package/dist/user/seeding.d.ts +1 -0
- package/dist/user/seeding.js +20 -0
- package/dist/user-data-rights/boot-checks.js +5 -0
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
- package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -0
- package/dist/user-data-rights/index.js +1 -0
- package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
- package/dist/user-data-rights/schema/download-attempt.js +17 -9
- package/dist/user-data-rights/schema/download-token.d.ts +8 -0
- package/dist/user-data-rights/schema/export-job.d.ts +16 -8
- package/dist/user-profile/handlers/change-email.write.js +8 -2
- package/dist/waitlist/entity.d.ts +22 -6
- package/dist/workflow-runner/event-trigger.d.ts +1 -0
- package/dist/workflow-runner/event-trigger.js +63 -15
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -2
- package/dist/workflow-runner/runner.d.ts +2 -1
- package/dist/workflow-runner/runner.js +5 -1
- package/package.json +35 -10
- package/src/agent-tools/changes.json +12 -0
- package/src/audit/changes.json +6 -0
- package/src/auth-email-password/changes.json +24 -0
- package/src/auth-mfa/changes.json +7 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/channel-in-app/changes.json +8 -1
- package/src/crypto-shredding/changes.json +12 -0
- package/src/delivery/changes.json +6 -0
- package/src/files/changes.json +24 -0
- package/src/files-tenant-data/changes.json +5 -0
- package/src/folders/changes.json +6 -0
- package/src/form-draft/changes.json +7 -1
- package/src/inbound-provider-imap/changes.json +6 -0
- package/src/jobs/changes.json +30 -0
- package/src/ledger/changes.json +23 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/notes-history/changes.json +13 -0
- package/src/seo/changes.json +5 -0
- package/src/template-resolver/changes.json +5 -0
- package/src/tenant/changes.json +19 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-lifecycle/changes.json +22 -0
- package/src/tier-engine/changes.json +5 -0
- package/src/user/changes.json +15 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/workflow-runner/changes.json +5 -0
- package/dist/shared/has-where-rule.d.ts +0 -2
- package/dist/shared/has-where-rule.js +0 -10
|
@@ -4,90 +4,23 @@
|
|
|
4
4
|
// the graph depth is read off the existing declarations, never a hand-
|
|
5
5
|
// maintained per-app list).
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// These nest freely.
|
|
14
|
-
//
|
|
15
|
-
// What comes out is static adjacency, not a walk: which edges lead away from
|
|
16
|
-
// each type, read straight off the declarations. The mover drives the actual
|
|
17
|
-
// traversal from the row ids it moves (#3131), because how deep a type sits
|
|
18
|
-
// depends on the rows found, not on the schema alone.
|
|
7
|
+
// The edge definition (parentRef + single-valued reference fields, minus
|
|
8
|
+
// `handover: "stay"`) lives in the framework so the boot validator's depth
|
|
9
|
+
// limit and the mover's traversal measure the same graph. What comes out is
|
|
10
|
+
// static adjacency, not a walk: the mover drives the actual traversal from the
|
|
11
|
+
// row ids it moves (#3131), because how deep a type sits depends on the rows
|
|
12
|
+
// found, not on the schema alone.
|
|
19
13
|
//
|
|
20
14
|
// `transferable: true` stays the only gate on what actually moves, and it is
|
|
21
15
|
// enforced by the caller against edges that turn out to have rows rather than
|
|
22
16
|
// here: a declaration names which types an edge accepts, never proof that any
|
|
23
17
|
// row currently travels it. Narrowing here would need the very query the caller
|
|
24
18
|
// is about to run anyway.
|
|
25
|
-
import {
|
|
19
|
+
import { resolveEntityTransferAdjacency, } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
20
|
export function resolveTransferableRoot(registry, entityType) {
|
|
27
21
|
const entity = registry.getAllEntities().get(entityType);
|
|
28
22
|
return entity?.transferable === true ? entity : undefined;
|
|
29
23
|
}
|
|
30
|
-
// A `multiple` reference stores a jsonb array rather than a single id column,
|
|
31
|
-
// which the mover's `= ANY($ids)` match cannot address. The boot validator
|
|
32
|
-
// (engine/boot-validator/transfer-graph.ts) rejects that combination up front,
|
|
33
|
-
// so skipping it here can only ever hit an entity that is not transferable —
|
|
34
|
-
// never a silent partial move.
|
|
35
|
-
function referenceEdgesFrom(entityName, entity) {
|
|
36
|
-
const edges = [];
|
|
37
|
-
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
38
|
-
if (field.type !== "reference")
|
|
39
|
-
continue;
|
|
40
|
-
if (field.multiple === true)
|
|
41
|
-
continue;
|
|
42
|
-
edges.push({
|
|
43
|
-
entityName,
|
|
44
|
-
entity,
|
|
45
|
-
parentEntityName: parseRefTargetEntityName(field.entity),
|
|
46
|
-
link: { kind: "reference", field: fieldName },
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
return edges;
|
|
50
|
-
}
|
|
51
|
-
// A parentRef names the types it accepts; without `allowedTypes` it accepts
|
|
52
|
-
// any of them, so the edge is recorded against every registered type.
|
|
53
|
-
function parentRefEdgesFrom(entityName, entity, allEntityNames) {
|
|
54
|
-
const parentRef = entity.parentRef;
|
|
55
|
-
if (!parentRef)
|
|
56
|
-
return [];
|
|
57
|
-
const link = {
|
|
58
|
-
kind: "parentRef",
|
|
59
|
-
typeField: parentRef.entityTypeField,
|
|
60
|
-
idField: parentRef.entityIdField,
|
|
61
|
-
};
|
|
62
|
-
return (parentRef.allowedTypes ?? allEntityNames).map((parentEntityName) => ({
|
|
63
|
-
entityName,
|
|
64
|
-
entity,
|
|
65
|
-
parentEntityName,
|
|
66
|
-
link,
|
|
67
|
-
}));
|
|
68
|
-
}
|
|
69
|
-
// A type can legitimately be reached by more than one edge — `note.vehicleId`
|
|
70
|
-
// and `note.campaignId` are distinct, and collapsing them by entity name would
|
|
71
|
-
// leave one edge's rows behind, the silent partial move #3088 exists to end.
|
|
72
|
-
// Both are kept here; the mover runs each against whatever parent ids it has.
|
|
73
24
|
export function resolveTransferAdjacency(registry, rootEntityType) {
|
|
74
|
-
|
|
75
|
-
const allEntityNames = [...entities.keys()];
|
|
76
|
-
const byParent = new Map();
|
|
77
|
-
for (const [entityName, entity] of entities) {
|
|
78
|
-
// skip: the root is never collected as someone else's descendant. A self
|
|
79
|
-
// reference would otherwise drag unrelated rows of the root's own type
|
|
80
|
-
// across the tenant boundary along with the one that was claimed.
|
|
81
|
-
if (entityName === rootEntityType)
|
|
82
|
-
continue;
|
|
83
|
-
for (const edge of [
|
|
84
|
-
...parentRefEdgesFrom(entityName, entity, allEntityNames),
|
|
85
|
-
...referenceEdgesFrom(entityName, entity),
|
|
86
|
-
]) {
|
|
87
|
-
const edges = byParent.get(edge.parentEntityName) ?? [];
|
|
88
|
-
edges.push(edge);
|
|
89
|
-
byParent.set(edge.parentEntityName, edges);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return byParent;
|
|
25
|
+
return resolveEntityTransferAdjacency(registry.getAllEntities(), rootEntityType);
|
|
93
26
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
export declare function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void;
|
|
3
3
|
export declare function validateTenantDestroyHookShapes(features: readonly FeatureDefinition[]): void;
|
|
4
|
+
export declare function warnIfFilesMountedWithoutTenantData(features: readonly FeatureDefinition[]): void;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
3
|
import { entitiesOf } from "../shared/index.js";
|
|
4
|
+
const log = createFallbackLogger("kumiko:boot");
|
|
3
5
|
// V4: tenantOwned-entity-without-hook gate. Mirrors user-data-rights' V3
|
|
4
6
|
// (validateGdprPiiHookCoverage) but for EXT_TENANT_DATA. Registered as this
|
|
5
7
|
// feature's own `r.bootCheck()` (#1314, moved off the framework-internal
|
|
@@ -60,3 +62,14 @@ export function validateTenantDestroyHookShapes(features) {
|
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
}
|
|
65
|
+
// `files-tenant-data` is opt-in (files stays usable without tenant-lifecycle),
|
|
66
|
+
// so a tenant-lifecycle app that mounts `files` but forgets it destroys the
|
|
67
|
+
// tenant's fileRef rows and storage binaries never — only a warning, since
|
|
68
|
+
// some apps legitimately handle file erasure themselves.
|
|
69
|
+
export function warnIfFilesMountedWithoutTenantData(features) {
|
|
70
|
+
const mounted = new Set(features.map((f) => f.name));
|
|
71
|
+
// skip: nothing to cover (no files) or already covered.
|
|
72
|
+
if (!mounted.has("files") || mounted.has("files-tenant-data"))
|
|
73
|
+
return;
|
|
74
|
+
log.warn('feature "files" is mounted with "tenant-lifecycle" but "files-tenant-data" is not — tenant destroy will leave fileRef rows and stored file binaries behind. Mount createFilesTenantDataFeature() to cover them.');
|
|
75
|
+
}
|
|
@@ -5,6 +5,7 @@ export declare const DESTRUCTION_CANCELLED_EVENT_SHORT: "destruction-cancelled";
|
|
|
5
5
|
export declare const TENANT_DESTRUCTION_STARTED_EVENT_SHORT: "tenant-destruction-started";
|
|
6
6
|
export declare const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT: "tenant-destruction-stage-started";
|
|
7
7
|
export declare const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT: "tenant-destruction-stage-succeeded";
|
|
8
|
+
export declare const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT: "tenant-destruction-stage-progressed";
|
|
8
9
|
export declare const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT: "tenant-destruction-stage-failed";
|
|
9
10
|
export declare const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT: "tenant-destruction-stage-abandoned";
|
|
10
11
|
export declare const TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT: "tenant-destruction-completed";
|
|
@@ -13,6 +14,7 @@ export declare const DESTRUCTION_REQUESTED_EVENT_QN: "tenant-lifecycle:event:des
|
|
|
13
14
|
export declare const DESTRUCTION_CANCELLED_EVENT_QN: "tenant-lifecycle:event:destruction-cancelled";
|
|
14
15
|
export declare const TENANT_DESTRUCTION_STARTED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-started";
|
|
15
16
|
export declare const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-stage-succeeded";
|
|
17
|
+
export declare const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-stage-progressed";
|
|
16
18
|
export declare const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-stage-failed";
|
|
17
19
|
export declare const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-stage-abandoned";
|
|
18
20
|
export declare const TENANT_DESTRUCTION_COMPLETED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-completed";
|
|
@@ -24,3 +26,4 @@ export declare const TenantLifecycleHandlers: {
|
|
|
24
26
|
};
|
|
25
27
|
export declare const TENANT_DESTRUCTION_STAGES: readonly ["external-resources", "search-indices", "cache", "app-data", "subject-keys", "files", "infra-resources", "tenant-row"];
|
|
26
28
|
export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[number];
|
|
29
|
+
export declare const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30000;
|
|
@@ -6,6 +6,7 @@ export const DESTRUCTION_CANCELLED_EVENT_SHORT = "destruction-cancelled";
|
|
|
6
6
|
export const TENANT_DESTRUCTION_STARTED_EVENT_SHORT = "tenant-destruction-started";
|
|
7
7
|
export const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT = "tenant-destruction-stage-started";
|
|
8
8
|
export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT = "tenant-destruction-stage-succeeded";
|
|
9
|
+
export const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT = "tenant-destruction-stage-progressed";
|
|
9
10
|
export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT = "tenant-destruction-stage-failed";
|
|
10
11
|
export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT = "tenant-destruction-stage-abandoned";
|
|
11
12
|
export const TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT = "tenant-destruction-completed";
|
|
@@ -14,6 +15,7 @@ export const DESTRUCTION_REQUESTED_EVENT_QN = `${EVENT_PREFIX}${DESTRUCTION_REQU
|
|
|
14
15
|
export const DESTRUCTION_CANCELLED_EVENT_QN = `${EVENT_PREFIX}${DESTRUCTION_CANCELLED_EVENT_SHORT}`;
|
|
15
16
|
export const TENANT_DESTRUCTION_STARTED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STARTED_EVENT_SHORT}`;
|
|
16
17
|
export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT}`;
|
|
18
|
+
export const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT}`;
|
|
17
19
|
export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT}`;
|
|
18
20
|
export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT}`;
|
|
19
21
|
export const TENANT_DESTRUCTION_COMPLETED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT}`;
|
|
@@ -33,3 +35,6 @@ export const TENANT_DESTRUCTION_STAGES = [
|
|
|
33
35
|
"infra-resources",
|
|
34
36
|
"tenant-row",
|
|
35
37
|
];
|
|
38
|
+
// The sweep cron fires every minute with concurrency "skip", so a tick must
|
|
39
|
+
// finish well inside that window or the next one is skipped.
|
|
40
|
+
export const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30_000;
|
|
@@ -17,6 +17,11 @@ export declare const tenantDestructionStageSucceededSchema: z.ZodObject<{
|
|
|
17
17
|
stage: z.ZodString;
|
|
18
18
|
attempts: z.ZodNumber;
|
|
19
19
|
}, z.core.$strip>;
|
|
20
|
+
export declare const tenantDestructionStageProgressedSchema: z.ZodObject<{
|
|
21
|
+
stage: z.ZodString;
|
|
22
|
+
attempts: z.ZodNumber;
|
|
23
|
+
processed: z.ZodNumber;
|
|
24
|
+
}, z.core.$strip>;
|
|
20
25
|
export declare const tenantDestructionStageFailedSchema: z.ZodObject<{
|
|
21
26
|
stage: z.ZodString;
|
|
22
27
|
attempts: z.ZodNumber;
|
|
@@ -17,6 +17,11 @@ export const tenantDestructionStageSucceededSchema = z.object({
|
|
|
17
17
|
stage: z.string().min(1),
|
|
18
18
|
attempts: z.number().int().positive(),
|
|
19
19
|
});
|
|
20
|
+
export const tenantDestructionStageProgressedSchema = z.object({
|
|
21
|
+
stage: z.string().min(1),
|
|
22
|
+
attempts: z.number().int().positive(),
|
|
23
|
+
processed: z.number().int().nonnegative(),
|
|
24
|
+
});
|
|
20
25
|
export const tenantDestructionStageFailedSchema = z.object({
|
|
21
26
|
stage: z.string().min(1),
|
|
22
27
|
attempts: z.number().int().positive(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineFeature, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { validateTenantDataHookCoverage, validateTenantDestroyHookShapes } from "./boot-checks.js";
|
|
3
|
-
import { DESTRUCTION_CANCELLED_EVENT_SHORT, DESTRUCTION_REQUESTED_EVENT_SHORT, TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, TENANT_DESTRUCTION_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
|
|
4
|
-
import { destructionCancelledSchema, destructionRequestedSchema, tenantDestructionCompletedSchema, tenantDestructionFailedSchema, tenantDestructionStageAbandonedSchema, tenantDestructionStageFailedSchema, tenantDestructionStageStartedSchema, tenantDestructionStageSucceededSchema, tenantDestructionStartedSchema, } from "./events.js";
|
|
2
|
+
import { validateTenantDataHookCoverage, validateTenantDestroyHookShapes, warnIfFilesMountedWithoutTenantData, } from "./boot-checks.js";
|
|
3
|
+
import { DESTRUCTION_CANCELLED_EVENT_SHORT, DESTRUCTION_REQUESTED_EVENT_SHORT, TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, TENANT_DESTRUCTION_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
|
|
4
|
+
import { destructionCancelledSchema, destructionRequestedSchema, tenantDestructionCompletedSchema, tenantDestructionFailedSchema, tenantDestructionStageAbandonedSchema, tenantDestructionStageFailedSchema, tenantDestructionStageProgressedSchema, tenantDestructionStageStartedSchema, tenantDestructionStageSucceededSchema, tenantDestructionStartedSchema, } from "./events.js";
|
|
5
5
|
import { cancelDestructionWrite } from "./handlers/cancel-destruction.write.js";
|
|
6
6
|
import { requestDestructionWrite } from "./handlers/request-destruction.write.js";
|
|
7
7
|
import { hasTenantDestructionWork, resolveTenantLifecycleGate, runTenantDestructionSweep, } from "./run-tenant-destroy.js";
|
|
@@ -37,6 +37,7 @@ export function createTenantLifecycleFeature() {
|
|
|
37
37
|
// guard's "tenant-lifecycle mounted" gate exactly.
|
|
38
38
|
r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
|
|
39
39
|
r.bootCheck(({ features }) => validateTenantDestroyHookShapes(features));
|
|
40
|
+
r.bootCheck(({ features }) => warnIfFilesMountedWithoutTenantData(features));
|
|
40
41
|
r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema, {
|
|
41
42
|
piiFields: "none",
|
|
42
43
|
});
|
|
@@ -50,6 +51,7 @@ export function createTenantLifecycleFeature() {
|
|
|
50
51
|
piiFields: "none",
|
|
51
52
|
});
|
|
52
53
|
r.defineEvent(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, tenantDestructionStageSucceededSchema, { piiFields: "none" });
|
|
54
|
+
r.defineEvent(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, tenantDestructionStageProgressedSchema, { piiFields: "none" });
|
|
53
55
|
r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema, {
|
|
54
56
|
piiFields: "none",
|
|
55
57
|
});
|
|
@@ -85,6 +87,7 @@ export function createTenantLifecycleFeature() {
|
|
|
85
87
|
now,
|
|
86
88
|
log: (message) => ctx.log?.warn(message),
|
|
87
89
|
fileProviderResolver: ctx._fileProviderResolver,
|
|
90
|
+
searchAdapter: ctx.searchAdapter,
|
|
88
91
|
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
89
92
|
actor: ctx.systemUser.id,
|
|
90
93
|
});
|
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { type EscapeHatchAuditSink, type Registry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
5
|
+
import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
6
|
+
type StageTickResult = {
|
|
7
|
+
readonly done: boolean;
|
|
8
|
+
readonly error?: string;
|
|
9
|
+
readonly halted?: boolean;
|
|
10
|
+
readonly progressed?: boolean;
|
|
11
|
+
};
|
|
5
12
|
export declare function runNextDestructionStage(args: {
|
|
6
13
|
readonly db: DbRunner;
|
|
7
14
|
readonly registry: Registry;
|
|
8
15
|
readonly tenantId: TenantId;
|
|
9
16
|
readonly log?: (message: string) => void;
|
|
10
17
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
18
|
+
readonly searchAdapter?: SearchAdapter;
|
|
11
19
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
12
20
|
readonly actor?: string;
|
|
13
|
-
}): Promise<
|
|
14
|
-
readonly done: boolean;
|
|
15
|
-
readonly error?: string;
|
|
16
|
-
readonly halted?: boolean;
|
|
17
|
-
}>;
|
|
21
|
+
}): Promise<StageTickResult>;
|
|
18
22
|
export declare function hasTenantDestructionWork(reads: Pick<TenantDb, "count">, now: Temporal.Instant): Promise<boolean>;
|
|
19
23
|
/** Cron entry: start destruction for tenants past grace, then advance stages. */
|
|
20
24
|
export declare function runTenantDestructionSweep(args: {
|
|
@@ -23,6 +27,7 @@ export declare function runTenantDestructionSweep(args: {
|
|
|
23
27
|
readonly now?: Temporal.Instant;
|
|
24
28
|
readonly log?: (message: string) => void;
|
|
25
29
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
30
|
+
readonly searchAdapter?: SearchAdapter;
|
|
26
31
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
27
32
|
readonly actor?: string;
|
|
28
33
|
}): Promise<{
|
|
@@ -5,7 +5,7 @@ import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
5
5
|
import { append, loadAggregate, VersionConflictError, } from "@cosmicdrift/kumiko-framework/event-store";
|
|
6
6
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
7
7
|
import { tenantEntity, tenantTable } from "../tenant/index.js";
|
|
8
|
-
import { TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
|
|
8
|
+
import { DESTRUCTION_STAGE_TICK_BUDGET_MS, TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
|
|
9
9
|
import { invalidateTenantLifecycleGate, TEARDOWN_GATE_SETTLE_MS } from "./lifecycle-gate.js";
|
|
10
10
|
import { isDestructionPipelineComplete, pickNextStage, } from "./stages.js";
|
|
11
11
|
const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
@@ -35,6 +35,17 @@ function replayStageState(events) {
|
|
|
35
35
|
}
|
|
36
36
|
return { completed, abandoned, attemptsByStage };
|
|
37
37
|
}
|
|
38
|
+
// A progressed event as the stage's latest lifecycle event means the previous
|
|
39
|
+
// tick handed over mid-stage; its stage-started is still the open one.
|
|
40
|
+
function isStageContinuation(events, stageName) {
|
|
41
|
+
const stageEventTypes = new Set([
|
|
42
|
+
qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT),
|
|
43
|
+
qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT),
|
|
44
|
+
qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT),
|
|
45
|
+
]);
|
|
46
|
+
const lastStageEvent = events.findLast((event) => stageEventTypes.has(event.type) && event.payload["stage"] === stageName);
|
|
47
|
+
return lastStageEvent?.type === qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT);
|
|
48
|
+
}
|
|
38
49
|
function lastEventVersion(events) {
|
|
39
50
|
const last = events.at(-1);
|
|
40
51
|
return last?.version ?? 0;
|
|
@@ -114,6 +125,22 @@ async function maybeAppendDestructionCompleted(db, tenantId, completed) {
|
|
|
114
125
|
const T = getTemporal();
|
|
115
126
|
await appendTenantStageEvent(db, tenantId, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, { destroyedAt: T.Now.instant().toString() }, lastEventVersion(events));
|
|
116
127
|
}
|
|
128
|
+
// `version` differs per caller: the catch path appends against the pre-run
|
|
129
|
+
// version, the no-progress path against the version reloaded after run().
|
|
130
|
+
async function recordFailedStageAttempt(args) {
|
|
131
|
+
if (args.attempt >= args.stage.maxAttempts) {
|
|
132
|
+
await haltPipelineOnAbandon({
|
|
133
|
+
db: args.db,
|
|
134
|
+
tenantId: args.tenantId,
|
|
135
|
+
stage: args.stage.name,
|
|
136
|
+
attempts: args.attempt,
|
|
137
|
+
error: args.error,
|
|
138
|
+
});
|
|
139
|
+
return { done: false, error: args.error, halted: true };
|
|
140
|
+
}
|
|
141
|
+
await appendTenantStageEvent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT), { stage: args.stage.name, attempts: args.attempt, error: args.error }, args.version);
|
|
142
|
+
return { done: false, error: args.error };
|
|
143
|
+
}
|
|
117
144
|
export async function runNextDestructionStage(args) {
|
|
118
145
|
const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
|
|
119
146
|
const { completed, abandoned, attemptsByStage } = replayStageState(events);
|
|
@@ -133,38 +160,52 @@ export async function runNextDestructionStage(args) {
|
|
|
133
160
|
tenantId: args.tenantId,
|
|
134
161
|
log: args.log,
|
|
135
162
|
fileProviderResolver: args.fileProviderResolver,
|
|
163
|
+
searchAdapter: args.searchAdapter,
|
|
136
164
|
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
137
165
|
actor: args.actor,
|
|
166
|
+
deadlineAt: Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
|
|
138
167
|
};
|
|
139
168
|
let version = lastEventVersion(events);
|
|
140
|
-
|
|
169
|
+
if (!isStageContinuation(events, next.name)) {
|
|
170
|
+
version = await appendTenantStageEventIdempotent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT), { stage: next.name, attempts: attempt }, version, stageOutcomeRecorded(events, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT));
|
|
171
|
+
}
|
|
141
172
|
try {
|
|
142
|
-
await next.run(ctx);
|
|
173
|
+
const outcome = await next.run(ctx);
|
|
143
174
|
// next.run() may itself have appended events to this aggregate (e.g.
|
|
144
175
|
// tombstoneTenantRow updates the tenant row on the same stream) — reload
|
|
145
176
|
// the version fresh instead of trusting the pre-run `version`, or this
|
|
146
177
|
// append throws VersionConflictError on every stage whose run() writes.
|
|
147
178
|
const eventsAfterRun = await loadAggregate(args.db, args.tenantId, args.tenantId);
|
|
179
|
+
// A tick that neither finishes nor processes anything would otherwise loop
|
|
180
|
+
// forever, because progressed events do not count as attempts.
|
|
181
|
+
if (!outcome.done && outcome.processed === 0) {
|
|
182
|
+
return await recordFailedStageAttempt({
|
|
183
|
+
db: args.db,
|
|
184
|
+
tenantId: args.tenantId,
|
|
185
|
+
stage: next,
|
|
186
|
+
attempt,
|
|
187
|
+
error: `stage "${next.name}" made no progress`,
|
|
188
|
+
version: lastEventVersion(eventsAfterRun),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (!outcome.done) {
|
|
192
|
+
await appendTenantStageEvent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT), { stage: next.name, attempts: attempt, processed: outcome.processed }, lastEventVersion(eventsAfterRun));
|
|
193
|
+
return { done: false, progressed: true };
|
|
194
|
+
}
|
|
148
195
|
version = await appendTenantStageEventIdempotent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT), { stage: next.name, attempts: attempt }, lastEventVersion(eventsAfterRun), stageOutcomeRecorded(eventsAfterRun, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT));
|
|
149
196
|
const completedAfter = new Set([...completed, next.name]);
|
|
150
197
|
await maybeAppendDestructionCompleted(args.db, args.tenantId, completedAfter);
|
|
151
198
|
return { done: isDestructionPipelineComplete(completedAfter) };
|
|
152
199
|
}
|
|
153
200
|
catch (err) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
error: message,
|
|
163
|
-
});
|
|
164
|
-
return { done: false, error: message, halted: true };
|
|
165
|
-
}
|
|
166
|
-
await appendTenantStageEvent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT), { stage: next.name, attempts: attempt, error: message }, version);
|
|
167
|
-
return { done: false, error: message };
|
|
201
|
+
return await recordFailedStageAttempt({
|
|
202
|
+
db: args.db,
|
|
203
|
+
tenantId: args.tenantId,
|
|
204
|
+
stage: next,
|
|
205
|
+
attempt,
|
|
206
|
+
error: err instanceof Error ? err.message : String(err),
|
|
207
|
+
version,
|
|
208
|
+
});
|
|
168
209
|
}
|
|
169
210
|
}
|
|
170
211
|
function destroyRequestedDueWhere(now) {
|
|
@@ -215,6 +256,7 @@ export async function runTenantDestructionSweep(args) {
|
|
|
215
256
|
tenantId: row.id,
|
|
216
257
|
log: args.log,
|
|
217
258
|
fileProviderResolver: args.fileProviderResolver,
|
|
259
|
+
searchAdapter: args.searchAdapter,
|
|
218
260
|
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
219
261
|
actor: args.actor,
|
|
220
262
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { type EscapeHatchAuditSink, type Registry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
4
|
+
import { type SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
4
5
|
import type { TenantDestructionStageName } from "./constants.js";
|
|
5
6
|
export type DestructionStageCtx = {
|
|
6
7
|
readonly db: DbRunner;
|
|
@@ -8,13 +9,19 @@ export type DestructionStageCtx = {
|
|
|
8
9
|
readonly tenantId: TenantId;
|
|
9
10
|
readonly log?: (message: string) => void;
|
|
10
11
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
12
|
+
readonly searchAdapter?: SearchAdapter;
|
|
11
13
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
12
14
|
readonly actor?: string;
|
|
15
|
+
readonly deadlineAt: number;
|
|
16
|
+
};
|
|
17
|
+
export type StageRunOutcome = {
|
|
18
|
+
readonly done: boolean;
|
|
19
|
+
readonly processed: number;
|
|
13
20
|
};
|
|
14
21
|
export type DestructionStage = {
|
|
15
22
|
readonly name: TenantDestructionStageName;
|
|
16
23
|
readonly maxAttempts: number;
|
|
17
|
-
readonly run: (ctx: DestructionStageCtx) => Promise<
|
|
24
|
+
readonly run: (ctx: DestructionStageCtx) => Promise<StageRunOutcome>;
|
|
18
25
|
};
|
|
19
26
|
export declare const DESTRUCTION_STAGES: readonly DestructionStage[];
|
|
20
27
|
export declare function pickNextStage(completedStages: ReadonlySet<string>, abandonedStages: ReadonlySet<string>): DestructionStage | null;
|
|
@@ -1,26 +1,45 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
import { configuredPiiSubjectKms, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import { createEventStoreExecutor, createTenantDb, } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createSystemUser, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, extensionUsageEscapeHatchReason, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { createEscapeHatchReporter, UNATTRIBUTED_ACTOR, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
6
|
+
import { purgeSearchDocumentsForSubject, } from "@cosmicdrift/kumiko-framework/search";
|
|
6
7
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
7
8
|
import { tenantEntity, tenantMembershipEntity, tenantMembershipsTable, tenantTable, } from "../tenant/index.js";
|
|
8
9
|
import { invalidateTenantLifecycleGate } from "./lifecycle-gate.js";
|
|
10
|
+
const STAGE_DONE = { done: true, processed: 0 };
|
|
9
11
|
const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
10
12
|
const tenantMembershipCrud = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
11
13
|
entityName: "tenant-membership",
|
|
12
14
|
});
|
|
15
|
+
// Every hook still runs each tick (they are idempotent), so a later hook is not
|
|
16
|
+
// starved by an earlier one that keeps reporting done:false.
|
|
17
|
+
function newOutcomeAccumulator() {
|
|
18
|
+
let allDone = true;
|
|
19
|
+
let processed = 0;
|
|
20
|
+
return {
|
|
21
|
+
add: (result) => {
|
|
22
|
+
if (result?.done === false)
|
|
23
|
+
allDone = false;
|
|
24
|
+
processed += result?.processed ?? 0;
|
|
25
|
+
},
|
|
26
|
+
outcome: () => ({ done: allDone, processed }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
13
29
|
async function runExtensionDestroyHooks(registry, extensionName, ctx) {
|
|
14
30
|
const usages = registry.getExtensionUsages(extensionName);
|
|
31
|
+
const total = newOutcomeAccumulator();
|
|
15
32
|
for (const usage of usages) {
|
|
16
33
|
if (!isTenantResourceExtensionHooks(usage.options)) {
|
|
17
34
|
throw new Error(`${extensionName} registration for "${usage.entityName}" has no destroy function`);
|
|
18
35
|
}
|
|
19
|
-
await usage.options.destroyTenant(ctx.tenantId, ctx);
|
|
36
|
+
total.add(await usage.options.destroyTenant(ctx.tenantId, ctx));
|
|
20
37
|
}
|
|
38
|
+
return total.outcome();
|
|
21
39
|
}
|
|
22
40
|
async function runTenantDataHooks(ctx) {
|
|
23
41
|
const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
|
|
42
|
+
const total = newOutcomeAccumulator();
|
|
24
43
|
for (const usage of usages) {
|
|
25
44
|
if (!isTenantDataExtensionHooks(usage.options)) {
|
|
26
45
|
throw new Error(`${EXT_TENANT_DATA} registration for "${usage.entityName}" has no destroy function`);
|
|
@@ -40,24 +59,28 @@ async function runTenantDataHooks(ctx) {
|
|
|
40
59
|
}),
|
|
41
60
|
registry: ctx.registry,
|
|
42
61
|
tenantId: ctx.tenantId,
|
|
62
|
+
deadlineAt: ctx.deadlineAt,
|
|
43
63
|
fileProviderResolver: ctx.fileProviderResolver,
|
|
44
64
|
log: ctx.log,
|
|
45
65
|
};
|
|
46
|
-
await destroy(hookCtx);
|
|
66
|
+
total.add(await destroy(hookCtx));
|
|
47
67
|
}
|
|
68
|
+
return total.outcome();
|
|
48
69
|
}
|
|
49
70
|
async function eraseSubjectKeys(ctx) {
|
|
50
71
|
const kms = configuredPiiSubjectKms();
|
|
51
72
|
if (!kms) {
|
|
52
73
|
ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
|
|
53
74
|
// skip: KMS optional — apps without crypto-shredding still run other destroy stages
|
|
54
|
-
return;
|
|
75
|
+
return STAGE_DONE;
|
|
55
76
|
}
|
|
56
77
|
const memberships = await selectMany(ctx.db, tenantMembershipsTable, {
|
|
57
78
|
tenantId: ctx.tenantId,
|
|
58
79
|
});
|
|
80
|
+
const tenantRecordSubject = { kind: "record", entity: "tenant", id: ctx.tenantId };
|
|
59
81
|
const subjects = [
|
|
60
82
|
{ kind: "tenant", tenantId: ctx.tenantId },
|
|
83
|
+
tenantRecordSubject,
|
|
61
84
|
...memberships.map((m) => ({ kind: "user", userId: m.userId })),
|
|
62
85
|
];
|
|
63
86
|
for (const subject of subjects) {
|
|
@@ -66,11 +89,16 @@ async function eraseSubjectKeys(ctx) {
|
|
|
66
89
|
eraseReason: "tenant-destroy stage subject-keys",
|
|
67
90
|
});
|
|
68
91
|
}
|
|
92
|
+
if (ctx.searchAdapter) {
|
|
93
|
+
await purgeSearchDocumentsForSubject(ctx.db, ctx.registry.features, ctx.searchAdapter, subjectIdToKey(tenantRecordSubject), tenantRecordSubject);
|
|
94
|
+
}
|
|
95
|
+
return STAGE_DONE;
|
|
69
96
|
}
|
|
70
97
|
async function purgeTenantCache(ctx) {
|
|
71
98
|
// ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
|
|
72
99
|
// then this stage is a documented no-op (no cache layer in test stack).
|
|
73
100
|
ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
|
|
101
|
+
return STAGE_DONE;
|
|
74
102
|
}
|
|
75
103
|
async function tombstoneTenantRow(ctx) {
|
|
76
104
|
const now = getTemporal().Now.instant();
|
|
@@ -104,6 +132,7 @@ async function tombstoneTenantRow(ctx) {
|
|
|
104
132
|
},
|
|
105
133
|
}, user, db, { skipOptimisticLock: true });
|
|
106
134
|
invalidateTenantLifecycleGate(ctx.tenantId);
|
|
135
|
+
return STAGE_DONE;
|
|
107
136
|
}
|
|
108
137
|
export const DESTRUCTION_STAGES = [
|
|
109
138
|
{
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
4
|
+
import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
5
|
+
import { runTenantDestructionSweep } from "./run-tenant-destroy.js";
|
|
6
|
+
export declare function seedDestroyingTenant(db: DbConnection, tenantId: TenantId): Promise<void>;
|
|
7
|
+
export declare function driveDestructionToCompletion(stack: TestStack, db: DbConnection, tenantId: TenantId, searchAdapter?: SearchAdapter): Promise<string>;
|
|
8
|
+
export { runTenantDestructionSweep };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { append, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
|
+
import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
5
|
+
import { tenantTable } from "../tenant/index.js";
|
|
6
|
+
import { TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_STARTED_EVENT_QN } from "./constants.js";
|
|
7
|
+
import { runTenantDestructionSweep } from "./run-tenant-destroy.js";
|
|
8
|
+
// Sidesteps the `request-destruction` write handler (needs user/auth/sessions
|
|
9
|
+
// features wired) by seeding the same "destroying" state it would produce.
|
|
10
|
+
export async function seedDestroyingTenant(db, tenantId) {
|
|
11
|
+
const now = getTemporal().Now.instant();
|
|
12
|
+
await updateRows(db, tenantTable, { status: "destroying", destroyStartedAt: now }, { id: tenantId });
|
|
13
|
+
await append(db, {
|
|
14
|
+
aggregateId: tenantId,
|
|
15
|
+
aggregateType: TENANT_AGGREGATE_TYPE,
|
|
16
|
+
tenantId,
|
|
17
|
+
expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
|
|
18
|
+
type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
|
|
19
|
+
payload: { startedAt: now.toString() },
|
|
20
|
+
metadata: { userId: "system", requestId: "test:destruction-started" },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function driveDestructionToCompletion(stack, db, tenantId, searchAdapter) {
|
|
24
|
+
const farFuture = getTemporal()
|
|
25
|
+
.Now.instant()
|
|
26
|
+
.add({ hours: 24 * 3650 });
|
|
27
|
+
let status = "";
|
|
28
|
+
for (let i = 0; i < 20; i++) {
|
|
29
|
+
await runTenantDestructionSweep({
|
|
30
|
+
db: stack.db,
|
|
31
|
+
registry: stack.registry,
|
|
32
|
+
now: farFuture,
|
|
33
|
+
searchAdapter,
|
|
34
|
+
});
|
|
35
|
+
const rows = await selectMany(db, tenantTable, { id: tenantId });
|
|
36
|
+
status = String(rows[0]?.["status"]);
|
|
37
|
+
if (status === "destroyed" || status === "destroyFailed")
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
return status;
|
|
41
|
+
}
|
|
42
|
+
export { runTenantDestructionSweep };
|
|
@@ -3,6 +3,7 @@ export declare const tierAssignmentEntity: import("@cosmicdrift/kumiko-types/fie
|
|
|
3
3
|
readonly type: "text";
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
|
+
readonly minLength?: number;
|
|
6
7
|
readonly required?: boolean;
|
|
7
8
|
readonly searchable?: boolean;
|
|
8
9
|
readonly sortable?: boolean;
|
|
@@ -24,6 +25,7 @@ export declare const tierAssignmentEntity: import("@cosmicdrift/kumiko-types/fie
|
|
|
24
25
|
readonly type: "text";
|
|
25
26
|
readonly description?: string;
|
|
26
27
|
readonly maxLength?: number;
|
|
28
|
+
readonly minLength?: number;
|
|
27
29
|
readonly required?: boolean;
|
|
28
30
|
readonly searchable?: boolean;
|
|
29
31
|
readonly sortable?: boolean;
|
|
@@ -45,6 +45,7 @@ export function createSetTenantTierWrite(opts = {}) {
|
|
|
45
45
|
.refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
|
|
46
46
|
}),
|
|
47
47
|
access: { roles: ["SystemAdmin"] },
|
|
48
|
+
agent: { risk: "high" },
|
|
48
49
|
escapeHatch: {
|
|
49
50
|
grants: ["unsafeRaw"],
|
|
50
51
|
reason: SET_TENANT_TIER_REASON,
|
|
@@ -11,6 +11,7 @@ export declare function createTierResolver<TTier extends string, TCaps>(deps: Ti
|
|
|
11
11
|
readonly type: "text";
|
|
12
12
|
readonly description?: string;
|
|
13
13
|
readonly maxLength?: number;
|
|
14
|
+
readonly minLength?: number;
|
|
14
15
|
readonly required?: boolean;
|
|
15
16
|
readonly searchable?: boolean;
|
|
16
17
|
readonly sortable?: boolean;
|
|
@@ -32,6 +33,7 @@ export declare function createTierResolver<TTier extends string, TCaps>(deps: Ti
|
|
|
32
33
|
readonly type: "text";
|
|
33
34
|
readonly description?: string;
|
|
34
35
|
readonly maxLength?: number;
|
|
36
|
+
readonly minLength?: number;
|
|
35
37
|
readonly required?: boolean;
|
|
36
38
|
readonly searchable?: boolean;
|
|
37
39
|
readonly sortable?: boolean;
|
|
@@ -16,6 +16,8 @@ export const detailQuery = {
|
|
|
16
16
|
const result = await baseDetail.handler?.(query, ctx);
|
|
17
17
|
if (result === null || typeof result !== "object")
|
|
18
18
|
return result;
|
|
19
|
+
if (!ctx.registry.features.has("tenant"))
|
|
20
|
+
return result;
|
|
19
21
|
const [enriched] = await attachTenantLabels([result], dbForList(ctx));
|
|
20
22
|
return enriched ?? result;
|
|
21
23
|
},
|