@cosmicdrift/kumiko-bundled-features 0.2.3 → 0.4.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/CHANGELOG.md +109 -0
- package/package.json +19 -14
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +1 -1
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -6
- package/src/compliance-profiles/handlers/needs-profile.query.ts +1 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +6 -8
- package/src/compliance-profiles/resolve-for-tenant.ts +7 -5
- package/src/compliance-profiles/seeding.ts +1 -1
- package/src/config/resolver.ts +1 -1
- package/src/data-retention/_internal/parse-override.ts +3 -2
- package/src/data-retention/handlers/policy-for.query.ts +1 -1
- package/src/data-retention/keep-for.ts +1 -1
- package/src/data-retention/presets.ts +1 -1
- package/src/data-retention/resolve-for-tenant.ts +1 -1
- package/src/delivery/__tests__/delivery.integration.ts +6 -0
- package/src/delivery/delivery-service.ts +4 -12
- package/src/delivery/feature.ts +7 -5
- package/src/delivery/index.ts +0 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +1 -1
- package/src/file-provider-s3/feature.ts +2 -2
- package/src/files-provider-s3/s3-provider.ts +2 -2
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +82 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-foundation/README.md +86 -0
- package/src/renderer-foundation/__tests__/api.test.ts +188 -0
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
- package/src/renderer-foundation/api.ts +106 -0
- package/src/renderer-foundation/constants.ts +21 -0
- package/src/renderer-foundation/feature.ts +47 -0
- package/src/renderer-foundation/index.ts +25 -0
- package/src/renderer-foundation/types.ts +109 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
- package/src/renderer-simple/feature.ts +28 -3
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/template-resolver/README.md +89 -0
- package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
- package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
- package/src/template-resolver/api.ts +189 -0
- package/src/template-resolver/constants.ts +28 -0
- package/src/template-resolver/feature.ts +36 -0
- package/src/template-resolver/handlers/archive.write.ts +42 -0
- package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
- package/src/template-resolver/handlers/list.query.ts +69 -0
- package/src/template-resolver/handlers/publish.write.ts +45 -0
- package/src/template-resolver/handlers/shared.ts +41 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
- package/src/template-resolver/index.ts +28 -0
- package/src/template-resolver/qualified-names.ts +24 -0
- package/src/template-resolver/table.ts +67 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/__tests__/text-content.integration.ts +54 -0
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-slug.query.ts +1 -0
- package/src/text-content/handlers/by-tenant.query.ts +58 -0
- package/src/text-content/handlers/set.write.ts +24 -1
- package/src/text-content/seeding.ts +9 -1
- package/src/text-content/table.ts +6 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
- package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
- package/src/text-content/web/client-plugin.tsx +378 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/feature.ts +8 -8
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/feature.ts +4 -3
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +8 -11
- package/src/user-data-rights/handlers/download-by-token.query.ts +14 -16
- package/src/user-data-rights/handlers/export-status.query.ts +1 -1
- package/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
- package/src/user-data-rights/handlers/request-export.write.ts +2 -2
- package/src/user-data-rights/run-export-jobs.ts +2 -2
- package/src/user-data-rights/run-forget-cleanup.ts +27 -28
- package/src/user-data-rights/run-user-export.ts +1 -1
- package/src/user-data-rights/token-helpers.ts +2 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +1 -1
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { type BlockSummary, groupBlocksByFolder } from "../client-plugin";
|
|
6
|
+
|
|
7
|
+
// TreeNode.children ist `readonly TreeNode[] | TreeChildrenSubscribe` —
|
|
8
|
+
// im Provider-Output ist die Subscribe-Form nur für deferred-children
|
|
9
|
+
// gedacht, groupBlocksByFolder produziert ausschließlich statische
|
|
10
|
+
// Array-Children. TypeGuard statt as-Cast (Memory `[Type Assertions]`).
|
|
11
|
+
function childrenArray(children: TreeNode["children"] | undefined): readonly TreeNode[] {
|
|
12
|
+
if (!Array.isArray(children)) throw new Error("expected static children-array");
|
|
13
|
+
return children;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// V.1.5d-Helper: groupBlocksByFolder gibt jetzt einen "Content"-Wrapper-
|
|
17
|
+
// Folder zurück. Tests wollen den Inhalt UNTER dem Wrapper prüfen.
|
|
18
|
+
function inside(result: readonly TreeNode[]): readonly TreeNode[] {
|
|
19
|
+
expect(result).toHaveLength(1);
|
|
20
|
+
const wrapper = result[0];
|
|
21
|
+
expect(wrapper?.label).toBe("Content");
|
|
22
|
+
expect(wrapper?.icon).toBe("folder");
|
|
23
|
+
return childrenArray(wrapper?.children);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Helper: BlockSummary mit defaults für die nicht-test-relevanten Felder.
|
|
27
|
+
function block(opts: {
|
|
28
|
+
slug: string;
|
|
29
|
+
folder?: string | null;
|
|
30
|
+
body?: string | null;
|
|
31
|
+
title?: string;
|
|
32
|
+
}): BlockSummary {
|
|
33
|
+
return {
|
|
34
|
+
slug: opts.slug,
|
|
35
|
+
lang: "de",
|
|
36
|
+
title: opts.title ?? opts.slug,
|
|
37
|
+
// Nicht ?? — null soll durchgereicht werden (state="stub"-Test).
|
|
38
|
+
body: opts.body === undefined ? "irgendwas" : opts.body,
|
|
39
|
+
folder: opts.folder === undefined ? null : opts.folder,
|
|
40
|
+
updatedAt: "2026-05-19T00:00:00Z",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("groupBlocksByFolder", () => {
|
|
45
|
+
test("leeres Array → leeres Array", () => {
|
|
46
|
+
expect(groupBlocksByFolder([])).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("folder=null → Root-Node ohne Folder (innerhalb Content-Wrapper)", () => {
|
|
50
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", folder: null })]));
|
|
51
|
+
expect(nodes).toHaveLength(1);
|
|
52
|
+
const root = nodes[0];
|
|
53
|
+
expect(root).toBeDefined();
|
|
54
|
+
if (!root) return;
|
|
55
|
+
expect(root.label).toBe("imprint");
|
|
56
|
+
expect(root.target).toEqual({
|
|
57
|
+
featureId: "text-content",
|
|
58
|
+
action: "edit",
|
|
59
|
+
args: { slug: "imprint", lang: "de" },
|
|
60
|
+
});
|
|
61
|
+
expect(root.children).toBeUndefined();
|
|
62
|
+
expect(root.icon).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('folder="page" → Folder-Container mit child', () => {
|
|
66
|
+
const nodes = inside(
|
|
67
|
+
groupBlocksByFolder([block({ slug: "hero", folder: "page", title: "Hero" })]),
|
|
68
|
+
);
|
|
69
|
+
expect(nodes).toHaveLength(1);
|
|
70
|
+
const folder = nodes[0];
|
|
71
|
+
expect(folder).toBeDefined();
|
|
72
|
+
if (!folder) return;
|
|
73
|
+
expect(folder.label).toBe("page");
|
|
74
|
+
expect(folder.icon).toBe("folder");
|
|
75
|
+
const children = childrenArray(folder.children);
|
|
76
|
+
expect(children).toHaveLength(1);
|
|
77
|
+
const child = children[0];
|
|
78
|
+
expect(child).toBeDefined();
|
|
79
|
+
if (!child) return;
|
|
80
|
+
expect(child.label).toBe("Hero");
|
|
81
|
+
expect(child.target?.args).toEqual({ slug: "hero", lang: "de" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("mehrere Slugs gleicher Folder → ein Folder mit mehreren children", () => {
|
|
85
|
+
const nodes = inside(
|
|
86
|
+
groupBlocksByFolder([
|
|
87
|
+
block({ slug: "hero", folder: "page", title: "Hero" }),
|
|
88
|
+
block({ slug: "cta", folder: "page", title: "CTA" }),
|
|
89
|
+
block({ slug: "footer", folder: "page", title: "Footer" }),
|
|
90
|
+
]),
|
|
91
|
+
);
|
|
92
|
+
expect(nodes).toHaveLength(1);
|
|
93
|
+
const folder = nodes[0];
|
|
94
|
+
expect(folder).toBeDefined();
|
|
95
|
+
if (!folder) return;
|
|
96
|
+
expect(folder.label).toBe("page");
|
|
97
|
+
const children = childrenArray(folder.children);
|
|
98
|
+
expect(children).toHaveLength(3);
|
|
99
|
+
const labels = children.map((c) => c.label);
|
|
100
|
+
expect(labels).toEqual(["Hero", "CTA", "Footer"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("mixed root + folder → root-nodes zuerst, dann Folders", () => {
|
|
104
|
+
const nodes = inside(
|
|
105
|
+
groupBlocksByFolder([
|
|
106
|
+
block({ slug: "imprint", folder: null }),
|
|
107
|
+
block({ slug: "hero", folder: "page" }),
|
|
108
|
+
block({ slug: "cta", folder: "page" }),
|
|
109
|
+
]),
|
|
110
|
+
);
|
|
111
|
+
expect(nodes).toHaveLength(2);
|
|
112
|
+
expect(nodes[0]?.label).toBe("imprint");
|
|
113
|
+
expect(nodes[0]?.icon).toBeUndefined();
|
|
114
|
+
expect(nodes[1]?.label).toBe("page");
|
|
115
|
+
expect(nodes[1]?.icon).toBe("folder");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("Folders alphabetisch sortiert (deterministisch gegen Map-order)", () => {
|
|
119
|
+
const nodes = inside(
|
|
120
|
+
groupBlocksByFolder([
|
|
121
|
+
block({ slug: "x", folder: "zebra" }),
|
|
122
|
+
block({ slug: "y", folder: "apple" }),
|
|
123
|
+
block({ slug: "z", folder: "mango" }),
|
|
124
|
+
]),
|
|
125
|
+
);
|
|
126
|
+
const folderLabels = nodes.map((n) => n.label);
|
|
127
|
+
expect(folderLabels).toEqual(["apple", "mango", "zebra"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('body=null → state="stub" (Designer-Hinweis dass Slug existiert aber leer)', () => {
|
|
131
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "draft", body: null })]));
|
|
132
|
+
expect(nodes[0]?.state).toBe("stub");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('body=string → state="filled"', () => {
|
|
136
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", body: "content" })]));
|
|
137
|
+
expect(nodes[0]?.state).toBe("filled");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("title leer → fallback auf slug als label", () => {
|
|
141
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "untitled-block", title: "" })]));
|
|
142
|
+
expect(nodes[0]?.label).toBe("untitled-block");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('V.1.6a multi-level folder ("page/marketing") wird genested gerendert', () => {
|
|
146
|
+
const nodes = inside(
|
|
147
|
+
groupBlocksByFolder([block({ slug: "hero", folder: "page/marketing", title: "Hero" })]),
|
|
148
|
+
);
|
|
149
|
+
expect(nodes).toHaveLength(1);
|
|
150
|
+
const pageFolder = nodes[0];
|
|
151
|
+
expect(pageFolder?.label).toBe("page");
|
|
152
|
+
expect(pageFolder?.icon).toBe("folder");
|
|
153
|
+
const pageChildren = childrenArray(pageFolder?.children);
|
|
154
|
+
expect(pageChildren).toHaveLength(1);
|
|
155
|
+
const marketingFolder = pageChildren[0];
|
|
156
|
+
expect(marketingFolder?.label).toBe("marketing");
|
|
157
|
+
expect(marketingFolder?.icon).toBe("folder");
|
|
158
|
+
const marketingChildren = childrenArray(marketingFolder?.children);
|
|
159
|
+
expect(marketingChildren).toHaveLength(1);
|
|
160
|
+
expect(marketingChildren[0]?.label).toBe("Hero");
|
|
161
|
+
expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero", lang: "de" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("V.1.6a shared folder-prefix → ein gemeinsamer parent", () => {
|
|
165
|
+
// Zwei blocks mit verschachteltem Pfad teilen die ersten Segmente.
|
|
166
|
+
// page/hero + page/cta + page/marketing/banner → 1× page-folder mit
|
|
167
|
+
// 3 children (2 leaves + 1 sub-folder).
|
|
168
|
+
const nodes = inside(
|
|
169
|
+
groupBlocksByFolder([
|
|
170
|
+
block({ slug: "hero", folder: "page", title: "Hero" }),
|
|
171
|
+
block({ slug: "cta", folder: "page", title: "CTA" }),
|
|
172
|
+
block({ slug: "banner", folder: "page/marketing", title: "Banner" }),
|
|
173
|
+
]),
|
|
174
|
+
);
|
|
175
|
+
expect(nodes).toHaveLength(1);
|
|
176
|
+
const pageFolder = nodes[0];
|
|
177
|
+
expect(pageFolder?.label).toBe("page");
|
|
178
|
+
const pageChildren = childrenArray(pageFolder?.children);
|
|
179
|
+
// Leaves first (Hero, CTA), dann sub-folder (marketing alphabetisch).
|
|
180
|
+
expect(pageChildren.map((c) => c.label)).toEqual(["Hero", "CTA", "marketing"]);
|
|
181
|
+
expect(pageChildren[2]?.icon).toBe("folder");
|
|
182
|
+
const marketingChildren = childrenArray(pageChildren[2]?.children);
|
|
183
|
+
expect(marketingChildren).toHaveLength(1);
|
|
184
|
+
expect(marketingChildren[0]?.label).toBe("Banner");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("V.1.6a folder/leaf-collision: gleicher Name auf gleicher Ebene", () => {
|
|
188
|
+
// Edge-Case (advisor-flagged): block mit folder=null, slug="page"
|
|
189
|
+
// + block mit folder="page" → "page" existiert als Leaf-Root UND
|
|
190
|
+
// als Folder. Beide bleiben sichtbar; Folder hat Chevron + Folder-
|
|
191
|
+
// Icon, Leaf hat target + ist klickbar. Renderer-Pattern macht
|
|
192
|
+
// visuell klar dass es zwei verschiedene Dinge sind.
|
|
193
|
+
const nodes = inside(
|
|
194
|
+
groupBlocksByFolder([
|
|
195
|
+
block({ slug: "page", folder: null, title: "Page-Root" }),
|
|
196
|
+
block({ slug: "hero", folder: "page", title: "Hero" }),
|
|
197
|
+
]),
|
|
198
|
+
);
|
|
199
|
+
expect(nodes).toHaveLength(2);
|
|
200
|
+
const leaf = nodes[0];
|
|
201
|
+
const folder = nodes[1];
|
|
202
|
+
expect(leaf?.label).toBe("Page-Root");
|
|
203
|
+
expect(leaf?.icon).toBeUndefined();
|
|
204
|
+
expect(leaf?.target).toBeDefined();
|
|
205
|
+
expect(folder?.label).toBe("page");
|
|
206
|
+
expect(folder?.icon).toBe("folder");
|
|
207
|
+
expect(folder?.target).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("Wrapper-Folder 'Content' umschließt alle blocks", () => {
|
|
211
|
+
// V.1.5d Wrapper-Convention: groupBlocksByFolder gibt EINEN Knoten
|
|
212
|
+
// zurück (den Wrapper), Inhalt liegt eine Ebene tiefer.
|
|
213
|
+
const result = groupBlocksByFolder([block({ slug: "imprint" })]);
|
|
214
|
+
expect(result).toHaveLength(1);
|
|
215
|
+
const wrapper = result[0];
|
|
216
|
+
expect(wrapper?.label).toBe("Content");
|
|
217
|
+
expect(wrapper?.icon).toBe("folder");
|
|
218
|
+
expect(wrapper?.target).toBeUndefined();
|
|
219
|
+
expect(childrenArray(wrapper?.children)).toHaveLength(1);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
|
|
3
|
+
// in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
|
|
4
|
+
// und liefert den treeProvider der Text-Blocks aus der by-tenant Query
|
|
5
|
+
// lädt, nach `folder`-Field gruppiert und als TreeNode[] emitted.
|
|
6
|
+
//
|
|
7
|
+
// **Folder-Gruppierung V.1.4**: Block.folder !== null → Knoten landet
|
|
8
|
+
// unter einem Container-Knoten mit Label folder. folder === null →
|
|
9
|
+
// Top-Level (root-node). Slug bleibt kebab-only validiert. Beispiele:
|
|
10
|
+
// - folder="page", slug="hero" → Folder "page", child "hero"
|
|
11
|
+
// - folder=null, slug="imprint" → root-node "imprint"
|
|
12
|
+
//
|
|
13
|
+
// **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
|
|
14
|
+
// "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
|
|
15
|
+
//
|
|
16
|
+
// **Fetch statt Subscribe**: V.1.4 ist Fetch-once beim Mount. Unsubscribe
|
|
17
|
+
// ist no-op. V.1.5+ kann SSE-driven Re-Emit einbauen wenn text-block-
|
|
18
|
+
// updated-Events propagiert werden (Stale-Tree-Fix nach save).
|
|
19
|
+
|
|
20
|
+
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
21
|
+
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
22
|
+
import type {
|
|
23
|
+
TargetRef,
|
|
24
|
+
TreeChildrenSubscribe,
|
|
25
|
+
TreeNode,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
+
import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
28
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
29
|
+
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
|
30
|
+
import { TextContentHandlers, TextContentQueries } from "../constants";
|
|
31
|
+
|
|
32
|
+
// Exported für Unit-Test (groupBlocksByFolder ist pure-function ohne
|
|
33
|
+
// fetch/DOM). Public-API für externe Konsumenten ist nicht intendiert —
|
|
34
|
+
// sub-path-Export endet bei textContentClient().
|
|
35
|
+
export type BlockSummary = {
|
|
36
|
+
readonly slug: string;
|
|
37
|
+
readonly lang: string;
|
|
38
|
+
readonly title: string;
|
|
39
|
+
readonly body: string | null;
|
|
40
|
+
readonly folder: string | null;
|
|
41
|
+
readonly updatedAt: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ByTenantResponse = {
|
|
45
|
+
readonly data: { readonly blocks: readonly BlockSummary[] };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// V.1.6a Multi-level Folder-Splitting: `folder` ist ein `/`-separierter
|
|
49
|
+
// Pfad (z.B. "page/marketing"). Der Tree splittet pro Segment und
|
|
50
|
+
// erzeugt nested Folder-Knoten:
|
|
51
|
+
// block.folder = null → root-leaf
|
|
52
|
+
// block.folder = "page" → 📁 page > leaf
|
|
53
|
+
// block.folder = "page/marketing" → 📁 page > 📁 marketing > leaf
|
|
54
|
+
//
|
|
55
|
+
// Walk-or-create-pattern: pro Block durch die Folder-Hierarchie laufen,
|
|
56
|
+
// Folder-Knoten anlegen wo nötig, Leaf am Ende anhängen. Folders pro
|
|
57
|
+
// Ebene alphabetisch sortiert (deterministisch).
|
|
58
|
+
//
|
|
59
|
+
// **Folder/Leaf-Collision** (advisor-Edge-Case): wenn ein Block
|
|
60
|
+
// `folder=null, slug="page"` UND ein anderer `folder="page", slug=...`
|
|
61
|
+
// existieren, leben beide an derselben Ebene — der Folder mit children
|
|
62
|
+
// + ein klickbarer Leaf "page" als root. Visual unterscheidbar (Folder
|
|
63
|
+
// hat Chevron + Folder-Icon, Leaf nicht). Kein Crash, keine Surprise.
|
|
64
|
+
//
|
|
65
|
+
// **V.1.5d Wrapper-Folder "Content"**: alle text-content-Blocks landen
|
|
66
|
+
// unter einem expliziten Top-Level-Folder. Empty-Tree → kein Wrapper.
|
|
67
|
+
|
|
68
|
+
type FolderNode = {
|
|
69
|
+
readonly leaves: TreeNode[];
|
|
70
|
+
readonly subFolders: Map<string, FolderNode>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function newFolderNode(): FolderNode {
|
|
74
|
+
return { leaves: [], subFolders: new Map() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function attachBlock(root: FolderNode, block: BlockSummary): void {
|
|
78
|
+
const leaf: TreeNode = {
|
|
79
|
+
label: block.title || block.slug,
|
|
80
|
+
target: {
|
|
81
|
+
featureId: "text-content",
|
|
82
|
+
action: "edit",
|
|
83
|
+
args: { slug: block.slug, lang: block.lang },
|
|
84
|
+
},
|
|
85
|
+
state: block.body ? "filled" : "stub",
|
|
86
|
+
};
|
|
87
|
+
if (block.folder === null) {
|
|
88
|
+
root.leaves.push(leaf);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let cur = root;
|
|
92
|
+
for (const segment of block.folder.split("/")) {
|
|
93
|
+
let next = cur.subFolders.get(segment);
|
|
94
|
+
if (next === undefined) {
|
|
95
|
+
next = newFolderNode();
|
|
96
|
+
cur.subFolders.set(segment, next);
|
|
97
|
+
}
|
|
98
|
+
cur = next;
|
|
99
|
+
}
|
|
100
|
+
cur.leaves.push(leaf);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderFolderNode(node: FolderNode): TreeNode[] {
|
|
104
|
+
// Leaves first (root-items), dann Folders alphabetisch.
|
|
105
|
+
const result: TreeNode[] = [...node.leaves];
|
|
106
|
+
for (const folderName of [...node.subFolders.keys()].sort()) {
|
|
107
|
+
const subNode = node.subFolders.get(folderName);
|
|
108
|
+
if (subNode === undefined) continue;
|
|
109
|
+
result.push({
|
|
110
|
+
label: folderName,
|
|
111
|
+
icon: "folder",
|
|
112
|
+
state: "filled",
|
|
113
|
+
children: renderFolderNode(subNode),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function groupBlocksByFolder(blocks: readonly BlockSummary[]): readonly TreeNode[] {
|
|
120
|
+
const root = newFolderNode();
|
|
121
|
+
for (const block of blocks) {
|
|
122
|
+
attachBlock(root, block);
|
|
123
|
+
}
|
|
124
|
+
const rendered = renderFolderNode(root);
|
|
125
|
+
if (rendered.length === 0) return [];
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
label: "Content",
|
|
129
|
+
icon: "folder",
|
|
130
|
+
state: "filled",
|
|
131
|
+
children: rendered,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
|
|
137
|
+
// CSRF-Header bei authenticated requests pflicht (auth-middleware
|
|
138
|
+
// double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
|
|
139
|
+
// → header weggelassen → server lässt die anonymous-Variante durch.
|
|
140
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
141
|
+
const csrf = readCsrfToken();
|
|
142
|
+
if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
|
|
143
|
+
fetch("/api/query", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers,
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
type: TextContentQueries.byTenant,
|
|
148
|
+
payload: {},
|
|
149
|
+
}),
|
|
150
|
+
})
|
|
151
|
+
.then(async (r) => {
|
|
152
|
+
if (!r.ok) {
|
|
153
|
+
// 403 (CSRF/auth) oder 5xx — explicit error statt silent empty.
|
|
154
|
+
const text = await r.text().catch(() => r.statusText);
|
|
155
|
+
throw new Error(`text-content load failed: ${r.status} ${text}`);
|
|
156
|
+
}
|
|
157
|
+
return r.json();
|
|
158
|
+
})
|
|
159
|
+
.then((data: ByTenantResponse) => {
|
|
160
|
+
const nodes = groupBlocksByFolder(data.data.blocks);
|
|
161
|
+
emit(nodes);
|
|
162
|
+
})
|
|
163
|
+
.catch((e) => {
|
|
164
|
+
// V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
|
|
165
|
+
// Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
|
|
166
|
+
// kein emitError unterstützt (Tests etc.).
|
|
167
|
+
if (emitError) {
|
|
168
|
+
emitError(e instanceof Error ? e : new Error(String(e)));
|
|
169
|
+
} else {
|
|
170
|
+
emit([]);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
return () => {};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// V.1.3 echte Edit-Form: lädt aktuelle Werte via by-slug-query, lässt
|
|
177
|
+
// TenantAdmin/SystemAdmin title + body editieren, dispatcht set-write
|
|
178
|
+
// bei Submit. Non-Admin-User sehen die Form read-only mit Hint-Banner —
|
|
179
|
+
// das ist der Kumiko-Weg (Memory `[Sicherheit > Convenience]`: write-
|
|
180
|
+
// permission bleibt opinionated TenantAdmin-only, App-Roles erweitern
|
|
181
|
+
// per Dual-Role-Mapping wenn gewollt).
|
|
182
|
+
//
|
|
183
|
+
// **Stale-Tree-Caveat (V.1.4-Followup)**: TreeProvider ist fetch-once.
|
|
184
|
+
// Nach erfolgreichem Save flippt der visual state="stub"→"filled" in
|
|
185
|
+
// der Sidebar NICHT, bis der User den Workspace re-mountet. Editor selbst
|
|
186
|
+
// ist konsistent (lokaler Form-State trägt die neuen Werte). Echte
|
|
187
|
+
// Lösung: SSE-driven Tree-Refresh oder explicit cache-bust nach set-write.
|
|
188
|
+
|
|
189
|
+
type TextBlock = {
|
|
190
|
+
readonly slug: string;
|
|
191
|
+
readonly lang: string;
|
|
192
|
+
readonly title: string;
|
|
193
|
+
readonly body: string | null;
|
|
194
|
+
readonly folder: string | null;
|
|
195
|
+
readonly updatedAt: string;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type SetResponse = { readonly slug: string; readonly lang: string; readonly isNew: boolean };
|
|
199
|
+
|
|
200
|
+
function TextContentEditor({
|
|
201
|
+
target,
|
|
202
|
+
onClose,
|
|
203
|
+
}: {
|
|
204
|
+
readonly target: TargetRef;
|
|
205
|
+
readonly onClose: () => void;
|
|
206
|
+
}): ReactNode {
|
|
207
|
+
// @cast-boundary visual-tree-args — TargetRef.args ist erased zu
|
|
208
|
+
// Record<string, unknown>; der Resolver kennt das Action-Shape (siehe
|
|
209
|
+
// treeActions.edit-Definition unten) und de-erased pro Action analog
|
|
210
|
+
// zu Event-Payloads. Optional-Chain absorbiert fehlende Felder ohne
|
|
211
|
+
// throw, damit der Editor auch bei manuellem URL-Tampering nicht
|
|
212
|
+
// crasht (TargetRef könnte aus old localStorage / URL-State stammen).
|
|
213
|
+
const args = target.args as { slug?: string; lang?: string } | undefined;
|
|
214
|
+
const slug = args?.slug ?? "";
|
|
215
|
+
const lang = args?.lang ?? "";
|
|
216
|
+
|
|
217
|
+
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
218
|
+
const dispatcher = useDispatcher();
|
|
219
|
+
const user = useShellUser();
|
|
220
|
+
const canWrite =
|
|
221
|
+
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
222
|
+
|
|
223
|
+
// Load existing block via by-slug-query. Result ist entweder TextBlock
|
|
224
|
+
// oder null (slug existiert nicht — create-flow). useQuery returnt
|
|
225
|
+
// `data: T | null`, initial-loading: data=null + loading=true.
|
|
226
|
+
const {
|
|
227
|
+
data: loaded,
|
|
228
|
+
loading,
|
|
229
|
+
error: loadError,
|
|
230
|
+
} = useQuery<TextBlock | null>(
|
|
231
|
+
TextContentQueries.bySlug,
|
|
232
|
+
{ slug, lang },
|
|
233
|
+
{ enabled: slug !== "" && lang !== "" },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Form-State unabhängig vom geladenen Block. Sync nur initial oder
|
|
237
|
+
// wenn target.slug+lang wechselt (User springt zwischen Knoten).
|
|
238
|
+
const [title, setTitle] = useState("");
|
|
239
|
+
const [body, setBody] = useState("");
|
|
240
|
+
const [folder, setFolder] = useState("");
|
|
241
|
+
const [submitting, setSubmitting] = useState(false);
|
|
242
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
243
|
+
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
|
244
|
+
|
|
245
|
+
// Sync loaded data → form state. Trigger sobald loaded-shape eindeutig
|
|
246
|
+
// ist (data ≠ undefined). loaded === null heißt "Block existiert noch
|
|
247
|
+
// nicht" — leere Form für create-flow.
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (loading) return;
|
|
250
|
+
setTitle(loaded?.title ?? "");
|
|
251
|
+
setBody(loaded?.body ?? "");
|
|
252
|
+
setFolder(loaded?.folder ?? "");
|
|
253
|
+
setSaveError(null);
|
|
254
|
+
setSavedMsg(null);
|
|
255
|
+
}, [loading, loaded]);
|
|
256
|
+
|
|
257
|
+
const handleSave = async (): Promise<void> => {
|
|
258
|
+
setSubmitting(true);
|
|
259
|
+
setSaveError(null);
|
|
260
|
+
setSavedMsg(null);
|
|
261
|
+
try {
|
|
262
|
+
const result = await dispatcher.write<SetResponse>(TextContentHandlers.set, {
|
|
263
|
+
slug,
|
|
264
|
+
lang,
|
|
265
|
+
title,
|
|
266
|
+
body: body.length > 0 ? body : null,
|
|
267
|
+
folder: folder.length > 0 ? folder : null,
|
|
268
|
+
});
|
|
269
|
+
if (result.isSuccess) {
|
|
270
|
+
setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
|
|
271
|
+
} else {
|
|
272
|
+
setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
// Network-blip / dispatcher-throw — sonst bleibt submitting=true,
|
|
276
|
+
// Save-Button locked-forever, User klickt repeat ohne Feedback.
|
|
277
|
+
// Generic message reicht: konkreter Recovery-Pfad ist Retry.
|
|
278
|
+
setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
|
|
279
|
+
} finally {
|
|
280
|
+
setSubmitting(false);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
285
|
+
e?.preventDefault();
|
|
286
|
+
void handleSave();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const disabled = submitting || loading || !canWrite;
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div className="flex h-full flex-col">
|
|
293
|
+
<header className="flex items-center justify-between border-b px-6 py-4">
|
|
294
|
+
<div>
|
|
295
|
+
<h2 className="text-lg font-semibold">Text-Block bearbeiten</h2>
|
|
296
|
+
<p className="text-xs text-muted-foreground">
|
|
297
|
+
{slug || "—"} ({lang || "—"})
|
|
298
|
+
</p>
|
|
299
|
+
</div>
|
|
300
|
+
<Button variant="secondary" onClick={onClose}>
|
|
301
|
+
schließen
|
|
302
|
+
</Button>
|
|
303
|
+
</header>
|
|
304
|
+
<div className="flex-1 overflow-y-auto px-6 py-6">
|
|
305
|
+
<Form onSubmit={onSubmit}>
|
|
306
|
+
{loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
|
|
307
|
+
{loadError !== null && (
|
|
308
|
+
<Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
|
|
309
|
+
)}
|
|
310
|
+
{!canWrite && !loading && (
|
|
311
|
+
<Banner variant="info">
|
|
312
|
+
Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.
|
|
313
|
+
</Banner>
|
|
314
|
+
)}
|
|
315
|
+
<Field id="text-content-title" label="Titel" required>
|
|
316
|
+
<Input
|
|
317
|
+
kind="text"
|
|
318
|
+
id="text-content-title"
|
|
319
|
+
name="text-content-title"
|
|
320
|
+
value={title}
|
|
321
|
+
onChange={setTitle}
|
|
322
|
+
disabled={disabled}
|
|
323
|
+
required
|
|
324
|
+
/>
|
|
325
|
+
</Field>
|
|
326
|
+
<Field id="text-content-folder" label="Ordner (optional)">
|
|
327
|
+
<Input
|
|
328
|
+
kind="text"
|
|
329
|
+
id="text-content-folder"
|
|
330
|
+
name="text-content-folder"
|
|
331
|
+
value={folder}
|
|
332
|
+
onChange={setFolder}
|
|
333
|
+
disabled={disabled}
|
|
334
|
+
placeholder="z.B. page oder legal"
|
|
335
|
+
/>
|
|
336
|
+
</Field>
|
|
337
|
+
<Field id="text-content-body" label="Inhalt">
|
|
338
|
+
<Input
|
|
339
|
+
kind="textarea"
|
|
340
|
+
id="text-content-body"
|
|
341
|
+
name="text-content-body"
|
|
342
|
+
value={body}
|
|
343
|
+
onChange={setBody}
|
|
344
|
+
disabled={disabled}
|
|
345
|
+
rows={14}
|
|
346
|
+
/>
|
|
347
|
+
</Field>
|
|
348
|
+
{saveError !== null && <Banner variant="error">{saveError}</Banner>}
|
|
349
|
+
{savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
|
|
350
|
+
{canWrite && (
|
|
351
|
+
<Button type="submit" loading={submitting} disabled={disabled}>
|
|
352
|
+
{submitting ? "Speichern…" : "Speichern"}
|
|
353
|
+
</Button>
|
|
354
|
+
)}
|
|
355
|
+
</Form>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function textContentClient(): ClientFeatureDefinition {
|
|
362
|
+
return {
|
|
363
|
+
name: "text-content",
|
|
364
|
+
treeProvider,
|
|
365
|
+
// V.1.5b: SSE-driven Tree-Refresh. Bei jedem text-block-Event
|
|
366
|
+
// (created/updated/deleted) ruft ProviderBranch den treeProvider
|
|
367
|
+
// neu auf → Tree-State spiegelt save sofort wider (Stale-Tree-Fix).
|
|
368
|
+
treeEntities: ["text-block"],
|
|
369
|
+
treeActions: {
|
|
370
|
+
edit: { args: { slug: "" as string, lang: "" as string } },
|
|
371
|
+
list: {},
|
|
372
|
+
create: { args: { folder: "" as string } },
|
|
373
|
+
},
|
|
374
|
+
resolvers: {
|
|
375
|
+
"text-content:edit": TextContentEditor,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public exports für die Browser-Seite des text-content Features.
|
|
3
|
+
// Wird über den Sub-Path-Export `@cosmicdrift/kumiko-bundled-features/text-content/web`
|
|
4
|
+
// konsumiert — die Server-Seite (defineFeature) lebt in
|
|
5
|
+
// `@cosmicdrift/kumiko-bundled-features/text-content` und hat keine
|
|
6
|
+
// React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
|
|
7
|
+
|
|
8
|
+
export { textContentClient } from "./client-plugin";
|
|
@@ -197,7 +197,7 @@ export function createTierEngineFeature<
|
|
|
197
197
|
// Invalidation: tier-assignment events update the cache.
|
|
198
198
|
r.entityHook("postSave", "tier-assignment", async (result) => {
|
|
199
199
|
// result.data has tenantId + tier (after entity-update merge)
|
|
200
|
-
const data = result.data as { tenantId?: unknown; tier?: unknown };
|
|
200
|
+
const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
|
|
201
201
|
// skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
|
|
202
202
|
// entity-events sind beide fields immer strings; ein malformed-payload
|
|
203
203
|
// (custom-handler-bug) würde hier silent zum cache-skip führen statt
|
|
@@ -210,7 +210,7 @@ export function createTierEngineFeature<
|
|
|
210
210
|
);
|
|
211
211
|
});
|
|
212
212
|
r.entityHook("postDelete", "tier-assignment", async (payload) => {
|
|
213
|
-
const data = payload.data as { tenantId?: unknown };
|
|
213
|
+
const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
|
|
214
214
|
// skip: gleiche type-guard semantik wie postSave-hook oben.
|
|
215
215
|
if (typeof data.tenantId !== "string") return;
|
|
216
216
|
cache.delete(data.tenantId as TenantId);
|
|
@@ -235,16 +235,16 @@ export function createTierEngineFeature<
|
|
|
235
235
|
"tenant",
|
|
236
236
|
async (result, ctx) => {
|
|
237
237
|
// result-shape: kumiko-framework's SaveContext mit isNew + data
|
|
238
|
-
const saveResult = result as { isNew?: unknown; data?: unknown };
|
|
238
|
+
const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
|
|
239
239
|
// skip: nur bei tenant-create (initial) — tenant-updates feuern
|
|
240
240
|
// auch postSave aber wir wollen kein neues tier-assignment bei
|
|
241
241
|
// re-keying oder name-update.
|
|
242
242
|
if (saveResult.isNew !== true) return;
|
|
243
|
-
const data = saveResult.data as { id?: unknown };
|
|
243
|
+
const data = saveResult.data as { id?: unknown }; // @cast-boundary engine-payload
|
|
244
244
|
// skip: defensive type-guard. Tenant-entity hat id zwingend, aber
|
|
245
245
|
// CrudExecutor's payload-shape ist runtime-unknown.
|
|
246
246
|
if (typeof data.id !== "string") return;
|
|
247
|
-
const newTenantId = data.id as TenantId;
|
|
247
|
+
const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
|
|
248
248
|
const aggregateId = tierAssignmentAggregateId(newTenantId);
|
|
249
249
|
|
|
250
250
|
// skip: defensive — inTransaction phase hat ctx.db immer gesetzt,
|
|
@@ -268,7 +268,7 @@ export function createTierEngineFeature<
|
|
|
268
268
|
// `as TenantDb` gegen future refactor.
|
|
269
269
|
// skip: defensive — sollte im inTransaction nie greifen.
|
|
270
270
|
if (!("raw" in ctx.db)) return;
|
|
271
|
-
const rawDb = ctx.db.raw as DbConnection;
|
|
271
|
+
const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
|
|
272
272
|
|
|
273
273
|
// Idempotency: stream-existence-check vor create. Pattern aus
|
|
274
274
|
// seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
|
|
@@ -276,7 +276,7 @@ export function createTierEngineFeature<
|
|
|
276
276
|
const [streamRow] = (await rawDb
|
|
277
277
|
.select({ v: maxFn(eventsTable.version) })
|
|
278
278
|
.from(eventsTable)
|
|
279
|
-
.where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[];
|
|
279
|
+
.where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[]; // @cast-boundary db-row
|
|
280
280
|
// skip: idempotency — aggregate-stream existiert schon (re-replay
|
|
281
281
|
// nach projection-rebuild oder hook-retry). create() würde
|
|
282
282
|
// version_conflict werfen + tenant-create rollback'n. Pattern aus
|
|
@@ -336,7 +336,7 @@ export function createTierEngineFeature<
|
|
|
336
336
|
// Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
|
|
337
337
|
// Bedürfnis entsteht.
|
|
338
338
|
type AssignmentRow = { tenantId: string; tier: string };
|
|
339
|
-
const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[];
|
|
339
|
+
const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[]; // @cast-boundary db-row
|
|
340
340
|
for (const row of rows) {
|
|
341
341
|
cache.set(
|
|
342
342
|
row.tenantId as TenantId,
|
|
@@ -30,7 +30,7 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
30
30
|
const condition =
|
|
31
31
|
query.payload.email !== undefined
|
|
32
32
|
? eq(userTable["email"], query.payload.email)
|
|
33
|
-
: eq(userTable["id"], query.payload.id as string);
|
|
33
|
+
: eq(userTable["id"], query.payload.id as string); // @cast-boundary engine-payload
|
|
34
34
|
|
|
35
35
|
const [row] = await ctx.db.select().from(userTable).where(condition).limit(1);
|
|
36
36
|
return (row as DbRow) ?? null;
|