@cosmicdrift/kumiko-samples 0.390.1 → 0.391.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/event-classification.ts +5 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +15 -1
- package/packages/bundled-features/src/audit/handlers/details.query.ts +11 -4
- package/packages/bundled-features/src/audit/handlers/list.query.ts +1 -0
- package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +11 -2
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +3 -0
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/feature.ts +6 -0
- package/packages/bundled-features/src/file-foundation/i18n.ts +13 -0
- package/packages/bundled-features/src/file-foundation/index.ts +4 -0
- package/packages/bundled-features/src/file-foundation/probe.ts +68 -0
- package/packages/bundled-features/src/{user-data-rights/lib → file-foundation}/storage-provider-resolver.ts +2 -2
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe-keys.ts +17 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +109 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +3 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -11
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +16 -0
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +2 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +27 -0
- package/packages/bundled-features/src/mail-foundation/index.ts +4 -0
- package/packages/bundled-features/src/{user-data-rights/lib → mail-foundation}/mail-transport-resolver.ts +2 -2
- package/packages/bundled-features/src/mail-foundation/probe.ts +81 -0
- package/packages/bundled-features/src/readiness/built-in-probes.ts +58 -0
- package/packages/bundled-features/src/readiness/changes.json +7 -0
- package/packages/bundled-features/src/readiness/collect-probes.ts +47 -0
- package/packages/bundled-features/src/readiness/constants.ts +25 -0
- package/packages/bundled-features/src/readiness/execute-probes.ts +116 -0
- package/packages/bundled-features/src/readiness/feature.ts +91 -3
- package/packages/bundled-features/src/readiness/handlers/latest-results.query.ts +15 -0
- package/packages/bundled-features/src/readiness/handlers/latest-summary.query.ts +53 -0
- package/packages/bundled-features/src/readiness/handlers/run-detail.query.ts +31 -0
- package/packages/bundled-features/src/readiness/handlers/run-list.query.ts +48 -0
- package/packages/bundled-features/src/readiness/handlers/run-probes.write.ts +98 -0
- package/packages/bundled-features/src/readiness/handlers/run-results.query.ts +18 -0
- package/packages/bundled-features/src/readiness/handlers/run-tone.ts +6 -0
- package/packages/bundled-features/src/readiness/i18n.ts +55 -0
- package/packages/bundled-features/src/readiness/index.ts +1 -1
- package/packages/bundled-features/src/readiness/probe-boot-check.ts +53 -0
- package/packages/bundled-features/src/readiness/probe-context.ts +57 -0
- package/packages/bundled-features/src/readiness/run-probes-job.ts +104 -0
- package/packages/bundled-features/src/readiness/run-view.ts +48 -0
- package/packages/bundled-features/src/readiness/schema/probe-run.ts +89 -0
- package/packages/bundled-features/src/readiness/screens.ts +191 -0
- package/packages/bundled-features/src/readiness/visible-results.ts +106 -0
- package/packages/bundled-features/src/tenant/changes.json +12 -0
- package/packages/bundled-features/src/tenant/membership-stream-history.ts +224 -0
- package/packages/bundled-features/src/tenant/membership-table.ts +3 -3
- package/packages/bundled-features/src/tenant/seeding.ts +8 -13
- package/packages/bundled-features/src/tenant/stream-tenant-backfill.ts +45 -1
- package/packages/bundled-features/src/user-data-rights/feature.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
- package/packages/bundled-features/src/user-profile/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/feature.ts +20 -0
- package/packages/bundled-features/src/user-profile/web/constants.ts +5 -0
- package/packages/bundled-features/src/user-profile/web/i18n.ts +2 -0
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +23 -4
- package/samples/apps/use-all-bundled/kumiko/migrations/0054_readiness-probe-runs.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/src/app/admin-nav-feature.ts +27 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +3 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EditRelatedListSection,
|
|
3
|
+
ListColumnSpec,
|
|
4
|
+
ProjectionDetailScreenDefinition,
|
|
5
|
+
RowAction,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import { ReadinessQueries, ReadinessWrites } from "./constants.ts";
|
|
8
|
+
|
|
9
|
+
export const SYSTEM_CHECK_SCREEN_ID = "system-check";
|
|
10
|
+
export const SYSTEM_CHECK_RUN_SCREEN_ID = "system-check-run";
|
|
11
|
+
export const SYSTEM_CHECK_SCREEN_QN = `readiness:screen:${SYSTEM_CHECK_SCREEN_ID}`;
|
|
12
|
+
export const SYSTEM_CHECK_NAV_LABEL = "readiness:nav.systemCheck";
|
|
13
|
+
|
|
14
|
+
export const ScreenTextKeys = {
|
|
15
|
+
runAction: "readiness.screen.action.run",
|
|
16
|
+
runPaidAction: "readiness.screen.action.run-paid",
|
|
17
|
+
latestTitle: "readiness.screen.latest.title",
|
|
18
|
+
latestDescription: "readiness.screen.latest.description",
|
|
19
|
+
latestEmpty: "readiness.screen.latest.empty",
|
|
20
|
+
historyTitle: "readiness.screen.history.title",
|
|
21
|
+
historyDescription: "readiness.screen.history.description",
|
|
22
|
+
historyEmpty: "readiness.screen.history.empty",
|
|
23
|
+
runResultsTitle: "readiness.screen.run.results-title",
|
|
24
|
+
metricOk: "readiness.screen.metric.ok",
|
|
25
|
+
metricFailed: "readiness.screen.metric.failed",
|
|
26
|
+
metricSkipped: "readiness.screen.metric.skipped",
|
|
27
|
+
columnArea: "readiness.screen.column.area",
|
|
28
|
+
columnCheck: "readiness.screen.column.check",
|
|
29
|
+
columnStatus: "readiness.screen.column.status",
|
|
30
|
+
columnDuration: "readiness.screen.column.duration",
|
|
31
|
+
columnResult: "readiness.screen.column.result",
|
|
32
|
+
columnStarted: "readiness.screen.column.started",
|
|
33
|
+
columnStartedBy: "readiness.screen.column.started-by",
|
|
34
|
+
columnOutcome: "readiness.screen.column.outcome",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export const RESULT_STATUS_KEY_PREFIX = "readiness.result-status.";
|
|
38
|
+
export const RUN_OUTCOME_KEY_PREFIX = "readiness.outcome.";
|
|
39
|
+
|
|
40
|
+
const ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
|
|
41
|
+
|
|
42
|
+
const RESULT_COLUMNS: readonly ListColumnSpec[] = [
|
|
43
|
+
{ field: "area", label: ScreenTextKeys.columnArea, renderer: { format: "i18nText" } },
|
|
44
|
+
{ field: "label", label: ScreenTextKeys.columnCheck, renderer: { format: "i18nText" } },
|
|
45
|
+
{
|
|
46
|
+
field: "status",
|
|
47
|
+
label: ScreenTextKeys.columnStatus,
|
|
48
|
+
renderer: {
|
|
49
|
+
format: "enumOption",
|
|
50
|
+
keyPrefix: RESULT_STATUS_KEY_PREFIX,
|
|
51
|
+
tones: { ok: "ok", failed: "bad", skipped: "neutral" },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
field: "durationMs",
|
|
56
|
+
label: ScreenTextKeys.columnDuration,
|
|
57
|
+
valueType: "number",
|
|
58
|
+
renderer: { format: "number" },
|
|
59
|
+
hideOnNarrow: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
field: "message",
|
|
63
|
+
label: ScreenTextKeys.columnResult,
|
|
64
|
+
renderer: { format: "i18nText" },
|
|
65
|
+
linkScreenField: "fixScreen",
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function resultsSection(
|
|
70
|
+
query: string,
|
|
71
|
+
title: string,
|
|
72
|
+
extra: Partial<EditRelatedListSection>,
|
|
73
|
+
): EditRelatedListSection {
|
|
74
|
+
return {
|
|
75
|
+
kind: "relatedList",
|
|
76
|
+
title,
|
|
77
|
+
query,
|
|
78
|
+
columns: RESULT_COLUMNS,
|
|
79
|
+
emptyState: { title: ScreenTextKeys.latestEmpty },
|
|
80
|
+
...extra,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const RESULT_METRICS = [
|
|
85
|
+
{ field: "okCount", label: ScreenTextKeys.metricOk, tone: "ok", icon: "check-circle" },
|
|
86
|
+
{ field: "failedCount", label: ScreenTextKeys.metricFailed, tone: "negative", icon: "x-circle" },
|
|
87
|
+
{ field: "skippedCount", label: ScreenTextKeys.metricSkipped, icon: "circle-stop" },
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
// The paid button sends hasPaidProbes as includePaid: it is only visible while that field is true.
|
|
91
|
+
const RUN_ACTIONS: readonly RowAction[] = [
|
|
92
|
+
{
|
|
93
|
+
kind: "writeHandler",
|
|
94
|
+
id: "run-check",
|
|
95
|
+
label: ScreenTextKeys.runAction,
|
|
96
|
+
handler: ReadinessWrites.runProbes,
|
|
97
|
+
payload: { pick: [] },
|
|
98
|
+
style: "primary",
|
|
99
|
+
icon: "shield-check",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
kind: "writeHandler",
|
|
103
|
+
id: "run-paid-check",
|
|
104
|
+
label: ScreenTextKeys.runPaidAction,
|
|
105
|
+
handler: ReadinessWrites.runProbes,
|
|
106
|
+
payload: { map: { includePaid: "hasPaidProbes" } },
|
|
107
|
+
visible: { field: "hasPaidProbes", eq: true },
|
|
108
|
+
style: "secondary",
|
|
109
|
+
icon: "sparkles",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
export function systemCheckScreen(dormant: boolean): ProjectionDetailScreenDefinition {
|
|
114
|
+
return {
|
|
115
|
+
id: SYSTEM_CHECK_SCREEN_ID,
|
|
116
|
+
type: "projectionDetail",
|
|
117
|
+
query: ReadinessQueries.latestSummary,
|
|
118
|
+
singleton: true,
|
|
119
|
+
live: true,
|
|
120
|
+
dormant,
|
|
121
|
+
access: ACCESS,
|
|
122
|
+
actions: RUN_ACTIONS,
|
|
123
|
+
primaryAction: "run-check",
|
|
124
|
+
metrics: RESULT_METRICS,
|
|
125
|
+
metricsStyle: "tiles",
|
|
126
|
+
layout: {
|
|
127
|
+
width: "full",
|
|
128
|
+
sections: [
|
|
129
|
+
resultsSection(ReadinessQueries.latestResults, ScreenTextKeys.latestTitle, {
|
|
130
|
+
id: "latest",
|
|
131
|
+
description: ScreenTextKeys.latestDescription,
|
|
132
|
+
}),
|
|
133
|
+
{
|
|
134
|
+
id: "history",
|
|
135
|
+
kind: "relatedList",
|
|
136
|
+
title: ScreenTextKeys.historyTitle,
|
|
137
|
+
description: ScreenTextKeys.historyDescription,
|
|
138
|
+
query: ReadinessQueries.runList,
|
|
139
|
+
entity: "probe-run",
|
|
140
|
+
columns: [
|
|
141
|
+
{
|
|
142
|
+
field: "requestedAt",
|
|
143
|
+
label: ScreenTextKeys.columnStarted,
|
|
144
|
+
renderer: { format: "timestamp", dateStyle: "medium", timeStyle: "short" },
|
|
145
|
+
},
|
|
146
|
+
{ field: "triggeredByName", label: ScreenTextKeys.columnStartedBy },
|
|
147
|
+
{
|
|
148
|
+
field: "outcome",
|
|
149
|
+
label: ScreenTextKeys.columnOutcome,
|
|
150
|
+
renderer: {
|
|
151
|
+
format: "enumOption",
|
|
152
|
+
keyPrefix: RUN_OUTCOME_KEY_PREFIX,
|
|
153
|
+
tones: { ok: "ok", failed: "bad", running: "neutral" },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
field: "durationMs",
|
|
158
|
+
label: ScreenTextKeys.columnDuration,
|
|
159
|
+
valueType: "number",
|
|
160
|
+
renderer: { format: "number" },
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
rowClick: { entity: "probe-run" },
|
|
164
|
+
emptyState: { title: ScreenTextKeys.historyEmpty },
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const systemCheckRunScreen: ProjectionDetailScreenDefinition = {
|
|
172
|
+
id: SYSTEM_CHECK_RUN_SCREEN_ID,
|
|
173
|
+
type: "projectionDetail",
|
|
174
|
+
query: ReadinessQueries.runDetail,
|
|
175
|
+
idParam: "runId",
|
|
176
|
+
detailFor: "probe-run",
|
|
177
|
+
live: true,
|
|
178
|
+
dormant: true,
|
|
179
|
+
listScreenId: SYSTEM_CHECK_SCREEN_ID,
|
|
180
|
+
access: ACCESS,
|
|
181
|
+
metrics: RESULT_METRICS,
|
|
182
|
+
metricsStyle: "tiles",
|
|
183
|
+
layout: {
|
|
184
|
+
width: "full",
|
|
185
|
+
sections: [
|
|
186
|
+
resultsSection(ReadinessQueries.runResults, ScreenTextKeys.runResultsTitle, {
|
|
187
|
+
parentParam: "runId",
|
|
188
|
+
}),
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { PROBE_RUN_STATUS } from "./schema/probe-run.ts";
|
|
4
|
+
|
|
5
|
+
const probeTextSchema = z.object({
|
|
6
|
+
key: z.string(),
|
|
7
|
+
params: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const probeResultRowSchema = z.object({
|
|
11
|
+
probe: z.string(),
|
|
12
|
+
scope: z.enum(["tenant", "system"]),
|
|
13
|
+
area: z.string(),
|
|
14
|
+
label: probeTextSchema,
|
|
15
|
+
targetId: z.string().optional(),
|
|
16
|
+
ownerUserId: z.string().optional(),
|
|
17
|
+
status: z.enum(["ok", "failed", "skipped"]),
|
|
18
|
+
durationMs: z.number().nullable(),
|
|
19
|
+
message: probeTextSchema,
|
|
20
|
+
fixScreen: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const storedResultsSchema = z.object({ rows: z.array(probeResultRowSchema) });
|
|
24
|
+
|
|
25
|
+
export type StoredProbeResult = z.infer<typeof probeResultRowSchema>;
|
|
26
|
+
|
|
27
|
+
// Same shape as a dashboard text, so the result table renders it with the `i18nText` format.
|
|
28
|
+
export type ProbeText = {
|
|
29
|
+
readonly i18nKey: string;
|
|
30
|
+
readonly i18nParams?: Readonly<Record<string, string | number>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type VisibleProbeResult = Omit<StoredProbeResult, "label" | "message" | "fixScreen"> & {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly label: ProbeText;
|
|
36
|
+
readonly message: ProbeText;
|
|
37
|
+
// Only on failed rows: the result table links the row to the screen that fixes it.
|
|
38
|
+
readonly fixScreen?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type RunOutcome = "ok" | "failed" | "running";
|
|
42
|
+
|
|
43
|
+
export type ResultCounts = {
|
|
44
|
+
readonly okCount: number;
|
|
45
|
+
readonly failedCount: number;
|
|
46
|
+
readonly skippedCount: number;
|
|
47
|
+
readonly totalCount: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// A fresh run still holds the column default `{}`; anything unparsable counts as no results.
|
|
51
|
+
export function parseStoredResults(stored: unknown): readonly StoredProbeResult[] {
|
|
52
|
+
const parsed = storedResultsSchema.safeParse(stored);
|
|
53
|
+
return parsed.success ? parsed.data.rows : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resultId(result: StoredProbeResult): string {
|
|
57
|
+
return `${result.probe}#${result.targetId ?? ""}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function canSee(result: StoredProbeResult, user: SessionUser): boolean {
|
|
61
|
+
if (result.scope === "system" && !user.roles.includes("SystemAdmin")) return false;
|
|
62
|
+
return result.ownerUserId === undefined || result.ownerUserId === user.id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toProbeText(text: StoredProbeResult["label"]): ProbeText {
|
|
66
|
+
return text.params === undefined
|
|
67
|
+
? { i18nKey: text.key }
|
|
68
|
+
: { i18nKey: text.key, i18nParams: text.params };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toVisibleResult(result: StoredProbeResult): VisibleProbeResult {
|
|
72
|
+
const { label, message, fixScreen, ...rest } = result;
|
|
73
|
+
return {
|
|
74
|
+
...rest,
|
|
75
|
+
id: resultId(result),
|
|
76
|
+
label: toProbeText(label),
|
|
77
|
+
message: toProbeText(message),
|
|
78
|
+
...(result.status === "failed" && fixScreen !== undefined && { fixScreen }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function visibleResults(stored: unknown, user: SessionUser): readonly VisibleProbeResult[] {
|
|
83
|
+
return parseStoredResults(stored)
|
|
84
|
+
.filter((result) => canSee(result, user))
|
|
85
|
+
.map(toVisibleResult);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function countResults(results: readonly Pick<StoredProbeResult, "status">[]): ResultCounts {
|
|
89
|
+
const count = (status: StoredProbeResult["status"]) =>
|
|
90
|
+
results.filter((result) => result.status === status).length;
|
|
91
|
+
return {
|
|
92
|
+
okCount: count("ok"),
|
|
93
|
+
failedCount: count("failed"),
|
|
94
|
+
skippedCount: count("skipped"),
|
|
95
|
+
totalCount: results.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isRunActive(status: string): boolean {
|
|
100
|
+
return status === PROBE_RUN_STATUS.Pending || status === PROBE_RUN_STATUS.Running;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function outcomeOf(status: string, counts: ResultCounts): RunOutcome {
|
|
104
|
+
if (isRunActive(status)) return "running";
|
|
105
|
+
return status === PROBE_RUN_STATUS.Done && counts.failedCount === 0 ? "ok" : "failed";
|
|
106
|
+
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.391.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "The stream-tenant backfill archives membership streams a projection replay must not see",
|
|
6
|
+
"detail": "Before the membership projection is rebuilt, the backfill reads the membership lifecycle from the event log and archives two kinds of streams. Streams of a user whose every stream is archived are archived with it, so a rebuild does not bring back memberships of users that no longer exist. When a second stream of a (userId, tenantId) pair was created while another one was still alive, the stream with the newest event stays and the others are archived; a pair that was removed and added again is left alone. Before, the rebuild failed on read_tenant_memberships_unique on every boot and the backfill stayed pending. The archived streams are logged as a warning and returned in membershipStreamsArchived. If a collision still remains, the failure says so and the step runs again on the next boot. Membership rows of archived users disappear from read_tenant_memberships with the rebuild."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.391.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "seedTenantMembership reuses the live membership stream of a pair",
|
|
12
|
+
"detail": "seedTenantMembership looked only at read_tenant_memberships. After the row was lost it created a second stream for the same pair, which made every later rebuild of the projection collide. It now also checks the event log and returns the id of the pair's live stream without writing anything. The seed does not bring the lost row back; only a rebuild of the membership projection does, and nothing triggers one on its own, so until then the user has no membership in that tenant."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.390.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Membership lifecycle as the event log tells it, keyed by the (userId, tenantId) pair of each
|
|
2
|
+
// stream's created event. The projection's unique index allows one live row per pair, so a replay
|
|
3
|
+
// fails as soon as a second stream of a pair is created while another one is alive. Such streams
|
|
4
|
+
// came from seeds that only consulted the projection after its rows were lost.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type DbRunner,
|
|
8
|
+
entityEventName,
|
|
9
|
+
toPostgresArrayLiteral,
|
|
10
|
+
unsafeReadRetrying,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import { parseTenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
|
|
14
|
+
|
|
15
|
+
const MEMBERSHIP_AGGREGATE = "tenant-membership";
|
|
16
|
+
const USER_AGGREGATE = "user";
|
|
17
|
+
const CREATED = entityEventName(MEMBERSHIP_AGGREGATE, "created");
|
|
18
|
+
const ROW_REMOVING_TYPES = [
|
|
19
|
+
entityEventName(MEMBERSHIP_AGGREGATE, "deleted"),
|
|
20
|
+
entityEventName(MEMBERSHIP_AGGREGATE, "forgotten"),
|
|
21
|
+
];
|
|
22
|
+
const LIFECYCLE_TYPES = [
|
|
23
|
+
CREATED,
|
|
24
|
+
entityEventName(MEMBERSHIP_AGGREGATE, "updated"),
|
|
25
|
+
...ROW_REMOVING_TYPES,
|
|
26
|
+
];
|
|
27
|
+
const ARCHIVED_BY = "stream-tenant-backfill";
|
|
28
|
+
|
|
29
|
+
type MembershipEventRow = {
|
|
30
|
+
readonly event_id: string;
|
|
31
|
+
readonly aggregate_id: string;
|
|
32
|
+
readonly stream_tenant_id: string;
|
|
33
|
+
readonly type: string;
|
|
34
|
+
readonly user_id: string;
|
|
35
|
+
readonly member_tenant_id: string;
|
|
36
|
+
readonly user_archived: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type MembershipStream = {
|
|
40
|
+
readonly aggregateId: string;
|
|
41
|
+
readonly streamTenantIds: Set<string>;
|
|
42
|
+
readonly userArchived: boolean;
|
|
43
|
+
lastEventId: bigint;
|
|
44
|
+
alive: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ArchivedMembershipStream = {
|
|
48
|
+
readonly aggregateId: string;
|
|
49
|
+
readonly reason: "user_archived" | "duplicate";
|
|
50
|
+
// The stream of the same pair that stays; set for duplicates.
|
|
51
|
+
readonly keptAggregateId?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Archived streams are left out: they do not replay and cannot be written to.
|
|
55
|
+
async function readMembershipEvents(
|
|
56
|
+
db: DbRunner,
|
|
57
|
+
pair?: { readonly userId: string; readonly tenantId: string },
|
|
58
|
+
): Promise<readonly MembershipEventRow[]> {
|
|
59
|
+
const params: unknown[] = [
|
|
60
|
+
MEMBERSHIP_AGGREGATE,
|
|
61
|
+
CREATED,
|
|
62
|
+
toPostgresArrayLiteral(LIFECYCLE_TYPES),
|
|
63
|
+
USER_AGGREGATE,
|
|
64
|
+
];
|
|
65
|
+
let pairSql = "";
|
|
66
|
+
if (pair) {
|
|
67
|
+
params.push(pair.tenantId, pair.userId);
|
|
68
|
+
// Only created events carry the pair; later events of the stream are matched by aggregate.
|
|
69
|
+
// The aggregate type is inlined so the planner can use the partial index
|
|
70
|
+
// events_membership_payload_tenant_idx under a generic plan as well.
|
|
71
|
+
pairSql = ` AND e."aggregate_id" IN (
|
|
72
|
+
SELECT p."aggregate_id" FROM "kumiko_events" p
|
|
73
|
+
WHERE p."aggregate_type" = '${MEMBERSHIP_AGGREGATE}' AND p."type" = $2
|
|
74
|
+
AND p."payload"->>'tenantId' = $5 AND p."payload"->>'userId' = $6)`;
|
|
75
|
+
}
|
|
76
|
+
return unsafeReadRetrying<MembershipEventRow>(
|
|
77
|
+
db,
|
|
78
|
+
// A user counts as archived only while none of its events sits outside an archived stream.
|
|
79
|
+
`WITH archived_users AS (
|
|
80
|
+
SELECT DISTINCT u."aggregate_id"::text AS aggregate_id FROM "kumiko_archived_streams" u
|
|
81
|
+
WHERE u."aggregate_type" = $4
|
|
82
|
+
AND NOT EXISTS (
|
|
83
|
+
SELECT 1 FROM "kumiko_events" ue
|
|
84
|
+
WHERE ue."aggregate_id" = u."aggregate_id" AND ue."aggregate_type" = $4
|
|
85
|
+
AND NOT EXISTS (SELECT 1 FROM "kumiko_archived_streams" ua
|
|
86
|
+
WHERE ua."tenant_id" = ue."tenant_id" AND ua."aggregate_id" = ue."aggregate_id"))
|
|
87
|
+
)
|
|
88
|
+
SELECT e."id"::text AS event_id, e."aggregate_id"::text AS aggregate_id,
|
|
89
|
+
e."tenant_id"::text AS stream_tenant_id, e."type" AS type,
|
|
90
|
+
c."user_id" AS user_id, c."member_tenant_id" AS member_tenant_id,
|
|
91
|
+
c."user_id" IN (SELECT aggregate_id FROM archived_users) AS user_archived
|
|
92
|
+
FROM "kumiko_events" e
|
|
93
|
+
JOIN LATERAL (
|
|
94
|
+
SELECT f."payload"->>'userId' AS user_id, f."payload"->>'tenantId' AS member_tenant_id
|
|
95
|
+
FROM "kumiko_events" f
|
|
96
|
+
WHERE f."aggregate_id" = e."aggregate_id" AND f."aggregate_type" = $1 AND f."type" = $2
|
|
97
|
+
ORDER BY f."id" ASC LIMIT 1
|
|
98
|
+
) c ON c."user_id" IS NOT NULL AND c."member_tenant_id" IS NOT NULL
|
|
99
|
+
WHERE e."aggregate_type" = $1 AND e."type" = ANY($3::text[])${pairSql}
|
|
100
|
+
AND NOT EXISTS (SELECT 1 FROM "kumiko_archived_streams" a
|
|
101
|
+
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id")
|
|
102
|
+
ORDER BY e."id" ASC`,
|
|
103
|
+
params,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type PairHistory = {
|
|
108
|
+
readonly streams: Map<string, MembershipStream>;
|
|
109
|
+
// A created event arrived while another stream of the pair was alive: replay collides.
|
|
110
|
+
collides: boolean;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function pairKeyOf(row: MembershipEventRow): string {
|
|
114
|
+
return `${row.user_id}\u0000${row.member_tenant_id}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function groupByPair(rows: readonly MembershipEventRow[]): Map<string, PairHistory> {
|
|
118
|
+
const pairs = new Map<string, PairHistory>();
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
const key = pairKeyOf(row);
|
|
121
|
+
const pair = pairs.get(key) ?? { streams: new Map(), collides: false };
|
|
122
|
+
pairs.set(key, pair);
|
|
123
|
+
let stream = pair.streams.get(row.aggregate_id);
|
|
124
|
+
if (!stream) {
|
|
125
|
+
stream = {
|
|
126
|
+
aggregateId: row.aggregate_id,
|
|
127
|
+
streamTenantIds: new Set(),
|
|
128
|
+
userArchived: row.user_archived,
|
|
129
|
+
lastEventId: 0n,
|
|
130
|
+
alive: false,
|
|
131
|
+
};
|
|
132
|
+
pair.streams.set(row.aggregate_id, stream);
|
|
133
|
+
}
|
|
134
|
+
stream.streamTenantIds.add(row.stream_tenant_id);
|
|
135
|
+
stream.lastEventId = BigInt(row.event_id);
|
|
136
|
+
if (row.type === CREATED) {
|
|
137
|
+
if ([...pair.streams.values()].some((other) => other !== stream && other.alive)) {
|
|
138
|
+
pair.collides = true;
|
|
139
|
+
}
|
|
140
|
+
stream.alive = true;
|
|
141
|
+
} else if (ROW_REMOVING_TYPES.includes(row.type)) {
|
|
142
|
+
stream.alive = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return pairs;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The stream written last carries the state the live row was built from; an alive one beats a removed one.
|
|
149
|
+
function streamToKeep(streams: readonly MembershipStream[]): MembershipStream | undefined {
|
|
150
|
+
const newestFirst = [...streams].sort((a, b) => (a.lastEventId > b.lastEventId ? -1 : 1));
|
|
151
|
+
return newestFirst.find((s) => s.alive) ?? newestFirst[0];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function findLiveMembershipStreamId(
|
|
155
|
+
db: DbRunner,
|
|
156
|
+
pair: { readonly userId: string; readonly tenantId: string },
|
|
157
|
+
): Promise<string | undefined> {
|
|
158
|
+
const history = groupByPair(await readMembershipEvents(db, pair))
|
|
159
|
+
.values()
|
|
160
|
+
.next().value;
|
|
161
|
+
if (!history) return undefined;
|
|
162
|
+
const kept = streamToKeep([...history.streams.values()]);
|
|
163
|
+
return kept?.alive ? kept.aggregateId : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Archives the membership streams a projection replay must not see: those of users whose own
|
|
168
|
+
* stream is archived, and the older streams of a (user, tenant) pair whose streams collide.
|
|
169
|
+
*/
|
|
170
|
+
export async function archiveUnreplayableMembershipStreams(
|
|
171
|
+
db: DbRunner,
|
|
172
|
+
): Promise<readonly ArchivedMembershipStream[]> {
|
|
173
|
+
const archived: ArchivedMembershipStream[] = [];
|
|
174
|
+
for (const history of groupByPair(await readMembershipEvents(db)).values()) {
|
|
175
|
+
const streams = [...history.streams.values()];
|
|
176
|
+
// Every stream of a pair belongs to the same user.
|
|
177
|
+
if (streams.some((s) => s.userArchived)) {
|
|
178
|
+
for (const stream of streams) {
|
|
179
|
+
await archiveMembershipStream(db, stream, "user stream archived");
|
|
180
|
+
archived.push({ aggregateId: stream.aggregateId, reason: "user_archived" });
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (!history.collides) continue;
|
|
185
|
+
const kept = streamToKeep(streams);
|
|
186
|
+
if (!kept) continue;
|
|
187
|
+
for (const stream of streams.filter((s) => s !== kept)) {
|
|
188
|
+
await archiveMembershipStream(
|
|
189
|
+
db,
|
|
190
|
+
stream,
|
|
191
|
+
`duplicate of membership stream ${kept.aggregateId}`,
|
|
192
|
+
);
|
|
193
|
+
archived.push({
|
|
194
|
+
aggregateId: stream.aggregateId,
|
|
195
|
+
reason: "duplicate",
|
|
196
|
+
keptAggregateId: kept.aggregateId,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return archived;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function archiveMembershipStream(
|
|
204
|
+
db: DbRunner,
|
|
205
|
+
stream: MembershipStream,
|
|
206
|
+
reason: string,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
// The replay filter matches archive markers per (stream tenant, aggregate).
|
|
209
|
+
for (const streamTenantId of stream.streamTenantIds) {
|
|
210
|
+
const tenantId = parseTenantId(streamTenantId);
|
|
211
|
+
if (tenantId === null) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`membership stream ${stream.aggregateId} sits on non-uuid tenant "${streamTenantId}"`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
await archiveStream(db, {
|
|
217
|
+
tenantId,
|
|
218
|
+
aggregateId: stream.aggregateId,
|
|
219
|
+
aggregateType: MEMBERSHIP_AGGREGATE,
|
|
220
|
+
archivedBy: ARCHIVED_BY,
|
|
221
|
+
reason,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -15,6 +15,8 @@ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/eng
|
|
|
15
15
|
// Single source of truth: `tenantMembershipEntity`. The database table is
|
|
16
16
|
// derived from the EntityDefinition through buildEntityTable, and the
|
|
17
17
|
// unique index is declared via entity.indexes.
|
|
18
|
+
export const MEMBERSHIP_UNIQUE_INDEX_NAME = "read_tenant_memberships_unique";
|
|
19
|
+
|
|
18
20
|
export const tenantMembershipEntity = createEntity({
|
|
19
21
|
table: "read_tenant_memberships",
|
|
20
22
|
fields: {
|
|
@@ -24,9 +26,7 @@ export const tenantMembershipEntity = createEntity({
|
|
|
24
26
|
// read-side stays byte-compatible and no MSP/consumer needs rewrites.
|
|
25
27
|
roles: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
26
28
|
},
|
|
27
|
-
indexes: [
|
|
28
|
-
{ unique: true, columns: ["userId", "tenantId"], name: "read_tenant_memberships_unique" },
|
|
29
|
-
],
|
|
29
|
+
indexes: [{ unique: true, columns: ["userId", "tenantId"], name: MEMBERSHIP_UNIQUE_INDEX_NAME }],
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
export const tenantMembershipsTable = buildEntityTable("tenant-membership", tenantMembershipEntity);
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
createLifecycleHooks,
|
|
52
52
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
53
53
|
import { assertAssignableMembershipRoles } from "./membership-roles.ts";
|
|
54
|
+
import { findLiveMembershipStreamId } from "./membership-stream-history.ts";
|
|
54
55
|
import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table.ts";
|
|
55
56
|
import { tenantEntity, tenantTable } from "./schema/tenant.ts";
|
|
56
57
|
|
|
@@ -329,19 +330,13 @@ export async function seedTenantMembership(
|
|
|
329
330
|
reason: "membership seed targets an explicit tenant that may differ from the actor's",
|
|
330
331
|
});
|
|
331
332
|
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
});
|
|
340
|
-
if (existing) {
|
|
341
|
-
// Same validation as the create-path — a missing/non-string id throws
|
|
342
|
-
// instead of silently returning `undefined as string`.
|
|
343
|
-
return { id: extractMembershipId(existing) };
|
|
344
|
-
}
|
|
333
|
+
// Idempotent: an existing row or live stream of the pair wins. A second stream next to a stream
|
|
334
|
+
// whose row got lost would make every projection replay collide on the unique index.
|
|
335
|
+
const pair = { userId: options.userId, tenantId: options.tenantId };
|
|
336
|
+
const existing = await fetchOne(db, tenantMembershipsTable, pair);
|
|
337
|
+
if (existing) return { id: extractMembershipId(existing) };
|
|
338
|
+
const liveStreamId = await findLiveMembershipStreamId(db, pair);
|
|
339
|
+
if (liveStreamId) return { id: liveStreamId };
|
|
345
340
|
|
|
346
341
|
// Keep by.id as audit actor, but force stream tenant to the membership tenant
|
|
347
342
|
// so a cross-tenant `by` override cannot diverge row tenant vs event stream.
|
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
type StreamBackfillStepFailure,
|
|
18
18
|
stepFailures,
|
|
19
19
|
} from "../shared/index.ts";
|
|
20
|
+
import {
|
|
21
|
+
type ArchivedMembershipStream,
|
|
22
|
+
archiveUnreplayableMembershipStreams,
|
|
23
|
+
} from "./membership-stream-history.ts";
|
|
24
|
+
import { MEMBERSHIP_UNIQUE_INDEX_NAME } from "./membership-table.ts";
|
|
20
25
|
|
|
21
26
|
const TENANT_AGGREGATE = "tenant";
|
|
22
27
|
const MEMBERSHIP_AGGREGATE = "tenant-membership";
|
|
@@ -30,6 +35,7 @@ export type TenantStreamBackfillResult = {
|
|
|
30
35
|
readonly searchDocsRemoved: number;
|
|
31
36
|
readonly projectionsRebuilt: readonly string[];
|
|
32
37
|
readonly reindexedTenants: number;
|
|
38
|
+
readonly membershipStreamsArchived: readonly ArchivedMembershipStream[];
|
|
33
39
|
// Per-step failures across the whole run; step names the failing stage.
|
|
34
40
|
readonly failures: readonly StreamBackfillStepFailure[];
|
|
35
41
|
};
|
|
@@ -81,6 +87,7 @@ export async function backfillTenantStreamTenants(
|
|
|
81
87
|
return emptyDerived({ tenants, memberships, searchCleanup, failures });
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
const archive = await archiveMembershipStreamsBeforeRebuild(db);
|
|
84
91
|
const rebuilt = await rebuildAndReindex({
|
|
85
92
|
db,
|
|
86
93
|
registry,
|
|
@@ -94,7 +101,43 @@ export async function backfillTenantStreamTenants(
|
|
|
94
101
|
searchDocsRemoved: searchCleanup.cleanedAggregates(),
|
|
95
102
|
projectionsRebuilt: rebuilt.projectionsRebuilt,
|
|
96
103
|
reindexedTenants: rebuilt.reindexedRows,
|
|
97
|
-
|
|
104
|
+
membershipStreamsArchived: archive.archived,
|
|
105
|
+
failures: [
|
|
106
|
+
...failures,
|
|
107
|
+
...archive.failures,
|
|
108
|
+
...rebuilt.failures.map(explainMembershipCollision),
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function archiveMembershipStreamsBeforeRebuild(db: DbConnection): Promise<{
|
|
114
|
+
readonly archived: readonly ArchivedMembershipStream[];
|
|
115
|
+
readonly failures: readonly StreamBackfillStepFailure[];
|
|
116
|
+
}> {
|
|
117
|
+
try {
|
|
118
|
+
return { archived: await archiveUnreplayableMembershipStreams(db), failures: [] };
|
|
119
|
+
} catch (e) {
|
|
120
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
121
|
+
return {
|
|
122
|
+
archived: [],
|
|
123
|
+
failures: [{ aggregateId: MEMBERSHIP_PROJECTION, step: "rebuild", error }],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function explainMembershipCollision(failure: StreamBackfillStepFailure): StreamBackfillStepFailure {
|
|
129
|
+
if (
|
|
130
|
+
failure.aggregateId !== MEMBERSHIP_PROJECTION ||
|
|
131
|
+
!failure.error.includes(MEMBERSHIP_UNIQUE_INDEX_NAME)
|
|
132
|
+
) {
|
|
133
|
+
return failure;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
...failure,
|
|
137
|
+
error:
|
|
138
|
+
`two membership streams of one (userId, tenantId) pair still replay alive after the boot ` +
|
|
139
|
+
`archived the colliding streams it found in the event log. The live table is untouched and ` +
|
|
140
|
+
`the step runs again on the next boot. Cause: ${failure.error}`,
|
|
98
141
|
};
|
|
99
142
|
}
|
|
100
143
|
|
|
@@ -110,6 +153,7 @@ function emptyDerived(args: {
|
|
|
110
153
|
searchDocsRemoved: args.searchCleanup.cleanedAggregates(),
|
|
111
154
|
projectionsRebuilt: [],
|
|
112
155
|
reindexedTenants: 0,
|
|
156
|
+
membershipStreamsArchived: [],
|
|
113
157
|
failures: args.failures,
|
|
114
158
|
};
|
|
115
159
|
}
|