@cosmicdrift/kumiko-bundled-features 0.97.1 → 0.100.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.
Files changed (31) hide show
  1. package/package.json +6 -6
  2. package/src/file-foundation/feature.ts +28 -141
  3. package/src/tags/__tests__/feature.test.ts +46 -13
  4. package/src/tags/__tests__/tags.integration.test.ts +66 -4
  5. package/src/tags/constants.ts +17 -1
  6. package/src/tags/entity.ts +4 -0
  7. package/src/tags/feature.ts +18 -7
  8. package/src/tags/handlers/create-tag.write.ts +1 -1
  9. package/src/tags/handlers/delete-tag.write.ts +54 -0
  10. package/src/tags/handlers/update-tag.write.ts +31 -0
  11. package/src/tags/index.ts +15 -5
  12. package/src/tags/schemas.ts +25 -6
  13. package/src/tags/web/__tests__/entity-tags.test.tsx +107 -0
  14. package/src/tags/web/__tests__/tag-chip.test.ts +34 -0
  15. package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
  16. package/src/tags/web/__tests__/tag-section.test.tsx +51 -77
  17. package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
  18. package/src/tags/web/client-plugin.tsx +20 -1
  19. package/src/tags/web/entity-tags.tsx +47 -0
  20. package/src/tags/web/i18n.ts +39 -8
  21. package/src/tags/web/index.ts +14 -1
  22. package/src/tags/web/tag-chip.tsx +63 -0
  23. package/src/tags/web/tag-filter.tsx +100 -0
  24. package/src/tags/web/tag-manager.tsx +390 -0
  25. package/src/tags/web/tag-picker.tsx +51 -0
  26. package/src/tags/web/tag-section.tsx +51 -101
  27. package/src/tags/web/tags-cell.tsx +46 -0
  28. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
  29. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
  30. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
  31. package/src/tags/handlers/rename-tag.write.ts +0 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.97.1",
3
+ "version": "0.100.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -91,11 +91,11 @@
91
91
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
92
92
  },
93
93
  "dependencies": {
94
- "@cosmicdrift/kumiko-dispatcher-live": "0.97.1",
95
- "@cosmicdrift/kumiko-framework": "0.97.1",
96
- "@cosmicdrift/kumiko-headless": "0.97.1",
97
- "@cosmicdrift/kumiko-renderer": "0.97.1",
98
- "@cosmicdrift/kumiko-renderer-web": "0.97.1",
94
+ "@cosmicdrift/kumiko-dispatcher-live": "0.100.0",
95
+ "@cosmicdrift/kumiko-framework": "0.100.0",
96
+ "@cosmicdrift/kumiko-headless": "0.100.0",
97
+ "@cosmicdrift/kumiko-renderer": "0.100.0",
98
+ "@cosmicdrift/kumiko-renderer-web": "0.100.0",
99
99
  "@mollie/api-client": "^4.5.0",
100
100
  "@node-rs/argon2": "^2.0.2",
101
101
  "@types/nodemailer": "^8.0.0",
@@ -1,103 +1,44 @@
1
1
  // kumiko-feature-version: 1
2
2
  //
3
- // file-foundation as a Kumiko bundled feature — plugin-API shape.
3
+ // file-foundation as a Kumiko bundled feature — declares the `fileProvider`
4
+ // extension point + the per-tenant `provider` config key. Provider RESOLUTION
5
+ // (createFileProviderForTenant) + the plugin types now live in the framework
6
+ // (packages/framework/src/files/provider-resolver.ts) so the upload routes,
7
+ // `ctx.files` and the GDPR jobs resolve through ONE path — uploads, export and
8
+ // erasure hit the same store by construction. This feature re-exports the moved
9
+ // symbols unchanged so existing imports keep working.
4
10
  //
5
- // **Pattern-Vorbild:** identisch zu `mail-foundation`. Foundation
6
- // deklariert extension-point `fileProvider`, Provider-Features (file-
7
- // provider-s3, später file-provider-azure-blob, file-provider-gcs)
8
- // registrieren sich namentlich. Tenant wählt zur Runtime via config-
9
- // key `provider`.
11
+ // **Pattern-Vorbild:** identisch zu `mail-foundation`. Foundation deklariert
12
+ // extension-point `fileProvider`, Provider-Features (file-provider-s3, -inmemory,
13
+ // -s3-env, später -gcs/-azure-blob) registrieren sich namentlich. Tenant wählt
14
+ // zur Runtime via config-key `provider`.
10
15
  //
11
- // **Was diese Foundation NICHT mehr macht:**
12
- // - Keine S3-spezifischen Config-Keys mehr (bucket/region/endpoint/
13
- // forcePathStyle/accessKeyId) — die leben im Provider-Plugin.
14
- // - Kein direkter Import von `createS3Provider`. Foundation kennt
15
- // nur das `FileStorageProvider`-Interface (Type-Import, kein
16
- // runtime-coupling).
17
- //
18
- // **Standalone:** Foundation ist ohne tier-engine nutzbar. Existing
19
- // `files-provider-s3` (App-wide-Library) bleibt unangetastet.
20
- //
21
- // **Boot-Dependencies:** config (für provider-selector). Kein secrets,
22
- // weil Foundation selbst keine Secrets hält.
16
+ // **Boot-Dependencies:** config (für provider-selector). Kein secrets, weil
17
+ // Foundation selbst keine Secrets hält.
23
18
 
24
- import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
25
19
  import {
26
20
  access,
27
- type ConfigAccessor,
28
21
  createTenantConfig,
29
22
  defineFeature,
30
- type Registry,
23
+ EXT_FILE_PROVIDER,
31
24
  } from "@cosmicdrift/kumiko-framework/engine";
32
- import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
33
-
34
- const FEATURE_NAME = "file-foundation";
35
-
36
- // =============================================================================
37
- // Plugin-Interface — what a Provider-Plugin must implement
38
- // =============================================================================
39
-
40
- /**
41
- * Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu
42
- * fett (haelt tx, actor, signal etc.) — Provider sollen sich auf die
43
- * read-Felder beschraenken die fuer Tenant-Config + Secret-Lookup
44
- * gebraucht werden.
45
- *
46
- * **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt
47
- * es keinen request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider
48
- * `ctx.tx` lesen wuerde, wuerde der ganze Worker-Pfad zur Runtime
49
- * brechen — und das wuerde NUR mit S3 und nur in production auffallen.
50
- * Die schmale Surface zwingt Provider zur expliziten Erweiterung
51
- * (extra-arg) statt silent ctx-feld-ausnutzen.
52
- *
53
- * **Felder:**
54
- * config — fuer tenant-config-reads (bucket/region/endpoint/...)
55
- * registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
56
- * secrets — fuer tenant-secret-reads (s3.secretAccessKey)
57
- * _userId — Audit-Identity fuer secret-reads. Im Handler-Pfad setzt
58
- * der dispatcher das auf die Caller-User-ID; im Worker-Pfad
59
- * muss der r.job-Wrap das explizit auf eine System-Identity
60
- * setzen (z.B. "system:user-data-rights:run-export-jobs").
61
- */
62
- export type FileProviderContext = {
63
- readonly config?: ConfigAccessor;
64
- readonly registry?: Registry;
65
- readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
66
- readonly _userId?: string | undefined;
67
- };
68
-
69
- /**
70
- * File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
71
- * file-provider-azure-blob, ...) registers an implementation via
72
- * `r.useExtension("fileProvider", "<name>", { build })`.
73
- *
74
- * **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein FileProviderContext,
75
- * nicht ein voller HandlerContext. Felder ausserhalb der schmalen
76
- * Surface (z.B. `ctx.tx`, `ctx.actor`, `ctx.signal`, `ctx.notify`) sind
77
- * im Worker-Pfad (r.job-getriggerte Provider-Builds) NICHT vorhanden.
78
- * Cast `ctx as unknown as HandlerContext` macht den Compiler happy aber
79
- * fliegt zur Runtime im Worker — und der Crash kommt erst in production
80
- * mit dem ersten S3-Tenant. Wenn ein Plugin Felder braucht die nicht in
81
- * FileProviderContext sind: lieber FileProviderContext explizit erweitern
82
- * (sichtbarer breaking change) als ctx-cast.
83
- */
84
- export type FileProviderPlugin = {
85
- readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
86
- };
87
25
 
88
- // extension-usage `options` is engine-payload (unknown) — structurally validate
89
- // instead of casting blind.
90
- export function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
91
- return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
92
- }
26
+ export type {
27
+ FileProviderContext,
28
+ FileProviderPlugin,
29
+ } from "@cosmicdrift/kumiko-framework/files";
30
+ // Moved into the framework — re-exported here so `@cosmicdrift/kumiko-bundled-
31
+ // features/file-foundation` consumers (user-data-rights, app code) keep working.
32
+ export {
33
+ createFileProviderForTenant,
34
+ isFileProviderPlugin,
35
+ } from "@cosmicdrift/kumiko-framework/files";
93
36
 
94
- // =============================================================================
95
- // Feature-definition
96
- // =============================================================================
37
+ const FEATURE_NAME = "file-foundation";
97
38
 
98
39
  export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
99
40
  r.describe(
100
- "Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider` \u2014 use this feature together with at least one `file-provider-*` feature; the `files` feature builds on top of it for tracked `FileRef` entities with GDPR hooks.",
41
+ "Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider` use this feature together with at least one `file-provider-*` feature; the `files` feature builds on top of it for tracked `FileRef` entities with GDPR hooks.",
101
42
  );
102
43
  r.uiHints({
103
44
  displayLabel: "File Provider Foundation",
@@ -106,7 +47,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
106
47
  });
107
48
  r.requires("config");
108
49
 
109
- r.extendsRegistrar("fileProvider", {
50
+ r.extendsRegistrar(EXT_FILE_PROVIDER, {
110
51
  onRegister: () => {
111
52
  // No side-effects at register-time — registry stores the usage,
112
53
  // factory looks it up at request-time.
@@ -124,61 +65,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
124
65
  });
125
66
  // Readiness gating: provider-plugins' required keys/secrets count only
126
67
  // while their plugin is the one this key selects.
127
- r.extensionSelector("fileProvider", configKeys.provider);
68
+ r.extensionSelector(EXT_FILE_PROVIDER, configKeys.provider);
128
69
 
129
70
  return { configKeys };
130
71
  });
131
-
132
- // =============================================================================
133
- // Provider-factory — looks up the registered plugin + delegates
134
- // =============================================================================
135
-
136
- export async function createFileProviderForTenant(
137
- ctx: FileProviderContext,
138
- tenantId: string,
139
- handlerName = "file-foundation:provider-factory",
140
- ): Promise<FileStorageProvider> {
141
- const ctxConfig = ctx.config;
142
- if (!ctxConfig) {
143
- throw new Error(
144
- `${handlerName}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
145
- );
146
- }
147
- if (!ctx.registry) {
148
- throw new Error(
149
- `${handlerName}: ctx.registry is missing — required to look up registered file-provider plugins`,
150
- );
151
- }
152
-
153
- const provider = requireDefined(
154
- await ctxConfig(fileFoundationFeature.exports.configKeys.provider),
155
- FEATURE_NAME,
156
- "provider",
157
- ) as string; // @cast-boundary engine-payload
158
- if (provider.length === 0) {
159
- const usages = ctx.registry.getExtensionUsages("fileProvider");
160
- const known = usages.map((u) => u.entityName).join(", ") || "<none>";
161
- throw new Error(
162
- `${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
163
- `Mount a file-provider-* feature first if no plugins are registered.`,
164
- );
165
- }
166
-
167
- const usages = ctx.registry.getExtensionUsages("fileProvider");
168
- const usage = usages.find((u) => u.entityName === provider);
169
- if (!usage) {
170
- const known = usages.map((u) => u.entityName).join(", ") || "<none>";
171
- throw new Error(
172
- `${FEATURE_NAME}: provider "${provider}" not registered. Known: ${known}. ` +
173
- `Mount the matching file-provider-${provider} feature.`,
174
- );
175
- }
176
-
177
- if (!isFileProviderPlugin(usage.options)) {
178
- throw new Error(
179
- `${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
180
- `extension options must be a FileProviderPlugin.`,
181
- );
182
- }
183
- return usage.options.build(ctx, tenantId);
184
- }
@@ -4,8 +4,9 @@ import { createTagsFeature } from "../feature";
4
4
  import {
5
5
  assignTagPayloadSchema,
6
6
  createTagPayloadSchema,
7
+ deleteTagPayloadSchema,
7
8
  removeTagPayloadSchema,
8
- renameTagPayloadSchema,
9
+ updateTagPayloadSchema,
9
10
  } from "../schemas";
10
11
 
11
12
  // Unit tests: feature-shape, role-options, schema-validation. The ES-loop
@@ -47,7 +48,7 @@ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch
47
48
  }
48
49
 
49
50
  describe("createTagsFeature shape", () => {
50
- test("registers tag + tag-assignment entities, 4 write-handlers, 2 query-handlers", () => {
51
+ test("registers tag + tag-assignment entities, 5 write-handlers, 2 query-handlers", () => {
51
52
  const feature = createTagsFeature();
52
53
 
53
54
  expect(Object.keys(feature.entities ?? {})).toEqual(
@@ -57,12 +58,13 @@ describe("createTagsFeature shape", () => {
57
58
  expect(Object.keys(feature.writeHandlers)).toEqual(
58
59
  expect.arrayContaining([
59
60
  expect.stringMatching(/create-tag/),
60
- expect.stringMatching(/rename-tag/),
61
+ expect.stringMatching(/update-tag/),
62
+ expect.stringMatching(/delete-tag/),
61
63
  expect.stringMatching(/assign-tag/),
62
64
  expect.stringMatching(/remove-tag/),
63
65
  ]),
64
66
  );
65
- expect(Object.keys(feature.writeHandlers)).toHaveLength(4);
67
+ expect(Object.keys(feature.writeHandlers)).toHaveLength(5);
66
68
 
67
69
  expect(Object.keys(feature.queryHandlers)).toEqual(
68
70
  expect.arrayContaining([
@@ -79,7 +81,8 @@ describe("createTagsFeature access-options", () => {
79
81
  const feature = createTagsFeature();
80
82
  expect(feature).toBe(createTagsFeature());
81
83
  expect(writeAccess(feature, "create-tag")).toEqual([...DEFAULT_TAG_ROLES]);
82
- expect(writeAccess(feature, "rename-tag")).toEqual([...DEFAULT_TAG_ROLES]);
84
+ expect(writeAccess(feature, "update-tag")).toEqual([...DEFAULT_TAG_ROLES]);
85
+ expect(writeAccess(feature, "delete-tag")).toEqual([...DEFAULT_TAG_ROLES]);
83
86
  expect(writeAccess(feature, "assign-tag")).toEqual([...DEFAULT_TAG_ROLES]);
84
87
  expect(writeAccess(feature, "remove-tag")).toEqual([...DEFAULT_TAG_ROLES]);
85
88
  expect(queryAccess(feature, "tag:list")).toEqual([...DEFAULT_TAG_ROLES]);
@@ -89,7 +92,8 @@ describe("createTagsFeature access-options", () => {
89
92
  test("roles option overrides every write- and query-path", () => {
90
93
  const feature = createTagsFeature({ roles: ["Admin", "Editor"] });
91
94
  expect(writeAccess(feature, "create-tag")).toEqual(["Admin", "Editor"]);
92
- expect(writeAccess(feature, "rename-tag")).toEqual(["Admin", "Editor"]);
95
+ expect(writeAccess(feature, "update-tag")).toEqual(["Admin", "Editor"]);
96
+ expect(writeAccess(feature, "delete-tag")).toEqual(["Admin", "Editor"]);
93
97
  expect(writeAccess(feature, "assign-tag")).toEqual(["Admin", "Editor"]);
94
98
  expect(writeAccess(feature, "remove-tag")).toEqual(["Admin", "Editor"]);
95
99
  expect(queryAccess(feature, "tag:list")).toEqual(["Admin", "Editor"]);
@@ -98,7 +102,7 @@ describe("createTagsFeature access-options", () => {
98
102
 
99
103
  test("access:{openToAll} applies to every write- and query-path", () => {
100
104
  const feature = createTagsFeature({ access: { openToAll: true } });
101
- for (const path of ["create-tag", "rename-tag", "assign-tag", "remove-tag"]) {
105
+ for (const path of ["create-tag", "update-tag", "delete-tag", "assign-tag", "remove-tag"]) {
102
106
  expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
103
107
  }
104
108
  for (const query of ["tag:list", "tag-assignment:list"]) {
@@ -163,28 +167,57 @@ describe("createTagPayloadSchema", () => {
163
167
  });
164
168
  });
165
169
 
166
- describe("renameTagPayloadSchema", () => {
170
+ describe("updateTagPayloadSchema", () => {
167
171
  const valid = { id: "tag-1", version: 0, name: "Neu" };
168
172
 
169
- test("accepts id + version + name", () => {
170
- expect(renameTagPayloadSchema.safeParse(valid).success).toBe(true);
173
+ test("accepts id + version + name (rename)", () => {
174
+ expect(updateTagPayloadSchema.safeParse(valid).success).toBe(true);
175
+ });
176
+
177
+ test("accepts color-only (recolor) and scope-only (re-scope)", () => {
178
+ expect(updateTagPayloadSchema.safeParse({ id: "t", version: 0, color: "#abc" }).success).toBe(
179
+ true,
180
+ );
181
+ expect(updateTagPayloadSchema.safeParse({ id: "t", version: 0, scope: "note" }).success).toBe(
182
+ true,
183
+ );
184
+ });
185
+
186
+ test("accepts color/scope = '' to clear them", () => {
187
+ expect(
188
+ updateTagPayloadSchema.safeParse({ id: "t", version: 0, color: "", scope: "" }).success,
189
+ ).toBe(true);
190
+ });
191
+
192
+ test("rejects a no-op (id + version, no mutable field)", () => {
193
+ expect(updateTagPayloadSchema.safeParse({ id: "tag-1", version: 0 }).success).toBe(false);
171
194
  });
172
195
 
173
196
  test("rejects a missing version (optimistic lock is mandatory)", () => {
174
- expect(renameTagPayloadSchema.safeParse({ id: "tag-1", name: "Neu" }).success).toBe(false);
197
+ expect(updateTagPayloadSchema.safeParse({ id: "tag-1", name: "Neu" }).success).toBe(false);
175
198
  });
176
199
 
177
200
  test("rejects an empty name", () => {
178
- expect(renameTagPayloadSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
201
+ expect(updateTagPayloadSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
179
202
  });
180
203
 
181
204
  test("rejects a name over 64 chars", () => {
182
- expect(renameTagPayloadSchema.safeParse({ ...valid, name: "x".repeat(65) }).success).toBe(
205
+ expect(updateTagPayloadSchema.safeParse({ ...valid, name: "x".repeat(65) }).success).toBe(
183
206
  false,
184
207
  );
185
208
  });
186
209
  });
187
210
 
211
+ describe("deleteTagPayloadSchema", () => {
212
+ test("accepts a bare id (no version — destructive intent)", () => {
213
+ expect(deleteTagPayloadSchema.safeParse({ id: "tag-1" }).success).toBe(true);
214
+ });
215
+
216
+ test("rejects an empty id", () => {
217
+ expect(deleteTagPayloadSchema.safeParse({ id: "" }).success).toBe(false);
218
+ });
219
+ });
220
+
188
221
  describe("assign/remove payload schemas", () => {
189
222
  const valid = { tagId: "tag-1", entityType: "credit", entityId: "c-1" };
190
223
 
@@ -61,6 +61,10 @@ async function remove(tagId: string, entityType: string, entityId: string, user
61
61
  return stack.http.writeOk(TagsHandlers.removeTag, { tagId, entityType, entityId }, user);
62
62
  }
63
63
 
64
+ async function deleteTag(id: string, user = admin) {
65
+ return stack.http.writeOk(TagsHandlers.deleteTag, { id }, user);
66
+ }
67
+
64
68
  async function tagById(id: string, user = admin): Promise<Record<string, unknown> | undefined> {
65
69
  return (await listTags(user)).find((t) => t["id"] === id);
66
70
  }
@@ -147,7 +151,7 @@ describe("tags integration — rename", () => {
147
151
  expect(typeof before?.["version"]).toBe("number");
148
152
  const version = before?.["version"] as number;
149
153
 
150
- await stack.http.writeOk(TagsHandlers.renameTag, { id, version, name: "Mandant Neu" }, admin);
154
+ await stack.http.writeOk(TagsHandlers.updateTag, { id, version, name: "Mandant Neu" }, admin);
151
155
 
152
156
  const after = await tagById(id);
153
157
  expect(after?.["name"]).toBe("Mandant Neu");
@@ -162,10 +166,10 @@ describe("tags integration — rename", () => {
162
166
  admin,
163
167
  );
164
168
  const stale = (await tagById(id))?.["version"] as number;
165
- await stack.http.writeOk(TagsHandlers.renameTag, { id, version: stale, name: "Erster" }, admin);
169
+ await stack.http.writeOk(TagsHandlers.updateTag, { id, version: stale, name: "Erster" }, admin);
166
170
 
167
171
  const err = await stack.http.writeErr(
168
- TagsHandlers.renameTag,
172
+ TagsHandlers.updateTag,
169
173
  { id, version: stale, name: "Zweiter" }, // stale: the row already moved to stale+1
170
174
  admin,
171
175
  );
@@ -182,7 +186,7 @@ describe("tags integration — rename", () => {
182
186
  const version = (await tagById(id, admin))?.["version"] as number;
183
187
 
184
188
  const err = await stack.http.writeErr(
185
- TagsHandlers.renameTag,
189
+ TagsHandlers.updateTag,
186
190
  { id, version, name: "B-Übernahme" },
187
191
  otherTenant,
188
192
  );
@@ -191,6 +195,64 @@ describe("tags integration — rename", () => {
191
195
  });
192
196
  });
193
197
 
198
+ describe("tags integration — update (recolor / re-scope)", () => {
199
+ test("update-tag changes color + scope, preserves the untouched name", async () => {
200
+ const { id } = await stack.http.writeOk<{ id: string }>(
201
+ TagsHandlers.createTag,
202
+ { name: "Projekt", color: "#111111" },
203
+ admin,
204
+ );
205
+ const version = (await tagById(id))?.["version"] as number;
206
+ await stack.http.writeOk(
207
+ TagsHandlers.updateTag,
208
+ { id, version, color: "#22cc88", scope: "note" },
209
+ admin,
210
+ );
211
+ const after = await tagById(id);
212
+ expect(after?.["name"]).toBe("Projekt");
213
+ expect(after?.["color"]).toBe("#22cc88");
214
+ expect(after?.["scope"]).toBe("note");
215
+ });
216
+
217
+ test("update-tag with empty color clears it", async () => {
218
+ const { id } = await stack.http.writeOk<{ id: string }>(
219
+ TagsHandlers.createTag,
220
+ { name: "Farbe", color: "#abcdef" },
221
+ admin,
222
+ );
223
+ const version = (await tagById(id))?.["version"] as number;
224
+ await stack.http.writeOk(TagsHandlers.updateTag, { id, version, color: "" }, admin);
225
+ expect((await tagById(id))?.["color"]).toBe("");
226
+ });
227
+ });
228
+
229
+ describe("tags integration — delete-tag cascade", () => {
230
+ test("deletes the tag and detaches it from every entity (multiple types)", async () => {
231
+ const target = await createTag("Sammelmappe");
232
+ const other = await createTag("bleibt");
233
+ // target spans two entityTypes; other tag stays attached to prove scoping
234
+ await assign(target, "credit", "credit-d1");
235
+ await assign(target, "note", "note-d1");
236
+ await assign(other, "credit", "credit-d1");
237
+ expect(await countAssignments(admin.tenantId)).toBe(3);
238
+
239
+ await deleteTag(target);
240
+
241
+ expect(await tagById(target)).toBeUndefined();
242
+ expect(await tagById(other)).toBeDefined();
243
+ expect(await countAssignments(admin.tenantId)).toBe(1);
244
+ expect(await listAssignments({ field: "tagId", op: "eq", value: target })).toHaveLength(0);
245
+ expect(await listAssignments({ field: "tagId", op: "eq", value: other })).toHaveLength(1);
246
+ });
247
+
248
+ test("deleting an already-gone tag is idempotent success", async () => {
249
+ const id = await createTag("einmalig");
250
+ await deleteTag(id);
251
+ await deleteTag(id); // second call must not error
252
+ expect(await tagById(id)).toBeUndefined();
253
+ });
254
+ });
255
+
194
256
  describe("tags integration — many-to-many composition", () => {
195
257
  test("one entity carries multiple tags", async () => {
196
258
  const a = await createTag("rot");
@@ -14,11 +14,27 @@ export const TAGS_FEATURE_NAME = "tags";
14
14
  // standalone use from `@cosmicdrift/kumiko-bundled-features/tags/web`.
15
15
  export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
16
16
 
17
+ // Registry name for the <TagFilter> header-slot control. A host entityList wires
18
+ // it via `slots: { header: { react: { __component: TAGS_FILTER_EXTENSION_NAME } } }`
19
+ // after mounting tagsClient(); the renderer passes it the list's screenId.
20
+ export const TAGS_FILTER_EXTENSION_NAME = "TagFilter";
21
+
22
+ // Registry name for the <TagsCell> column renderer. A host entityList shows an
23
+ // entity's tags inline by declaring a labeled virtual column:
24
+ // `{ field: "tags", label: "Tags", renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } }`
25
+ // after mounting tagsClient() (which registers it under clientFeatures.columnRenderers).
26
+ export const TAGS_COLUMN_RENDERER_NAME = "TagsCell";
27
+
28
+ // Screen-id of the standalone Tags management screen (custom screen rendering
29
+ // TagManager). Qualified = "tags:screen:tag-list"; the app places it via r.nav.
30
+ export const TAGS_SCREEN_ID = "tag-list";
31
+
17
32
  // Qualified handler names (QN format: scope:type:name). Clients reference the
18
33
  // object instead of magic strings (mirror custom-fields' Handlers/Queries).
19
34
  export const TagsHandlers = {
20
35
  createTag: "tags:write:create-tag",
21
- renameTag: "tags:write:rename-tag",
36
+ updateTag: "tags:write:update-tag",
37
+ deleteTag: "tags:write:delete-tag",
22
38
  assignTag: "tags:write:assign-tag",
23
39
  removeTag: "tags:write:remove-tag",
24
40
  } as const;
@@ -9,6 +9,10 @@ export const tagEntity = createEntity({
9
9
  name: createTextField({ required: true, maxLength: 64 }),
10
10
  // Optional UI hint (hex or token). No enforcement — purely for rendering.
11
11
  color: createTextField({ maxLength: 32 }),
12
+ // Optional entity-type scope (GitLab project-vs-group labels): empty = global
13
+ // (offered on every entity); a value like "note" restricts the tag to that
14
+ // entityType in the picker. No enforcement on assign — purely a picker hint.
15
+ scope: createTextField({ maxLength: 64 }),
12
16
  },
13
17
  });
14
18
 
@@ -12,9 +12,9 @@
12
12
  // tag-assignments filtered on entityId (tags of an entity) or tagId (entities
13
13
  // with a tag). See entity.ts.
14
14
  //
15
- // Scope: create-tag, rename-tag, assign-tag, remove-tag, list tags, list assignments.
16
- // Deferred: delete-tag, optional host-projection decoration
17
- // (`wireTagsFor`), search indexing, user-data-rights anonymization.
15
+ // Handlers: create-tag, update-tag (rename/recolor/re-scope), delete-tag
16
+ // (cascades over assignments), assign-tag, remove-tag, list tags, list assignments.
17
+ // Deferred: optional host-projection decoration (`wireTagsFor`), search indexing.
18
18
 
19
19
  import {
20
20
  type AccessRule,
@@ -22,12 +22,13 @@ import {
22
22
  defineFeature,
23
23
  type FeatureRegistrar,
24
24
  } from "@cosmicdrift/kumiko-framework/engine";
25
- import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants";
25
+ import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME, TAGS_SCREEN_ID } from "./constants";
26
26
  import { tagAssignmentEntity, tagEntity } from "./entity";
27
27
  import { createAssignTagHandler } from "./handlers/assign-tag.write";
28
28
  import { createCreateTagHandler } from "./handlers/create-tag.write";
29
+ import { createDeleteTagHandler } from "./handlers/delete-tag.write";
29
30
  import { createRemoveTagHandler } from "./handlers/remove-tag.write";
30
- import { createRenameTagHandler } from "./handlers/rename-tag.write";
31
+ import { createUpdateTagHandler } from "./handlers/update-tag.write";
31
32
 
32
33
  // Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
33
34
  // dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
@@ -43,7 +44,7 @@ function registerTags(
43
44
  toggleable: TagsToggleable | undefined,
44
45
  ): void {
45
46
  r.describe(
46
- "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `rename-tag` (optimistic-locked), `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
47
+ "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `update-tag` (optimistic-locked rename/recolor/re-scope), `delete-tag` (cascades over assignments), `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
47
48
  );
48
49
  r.uiHints({
49
50
  displayLabel: "Tags",
@@ -59,12 +60,22 @@ function registerTags(
59
60
  r.entity("tag-assignment", tagAssignmentEntity);
60
61
 
61
62
  r.writeHandler(createCreateTagHandler(access));
62
- r.writeHandler(createRenameTagHandler(access));
63
+ r.writeHandler(createUpdateTagHandler(access));
64
+ r.writeHandler(createDeleteTagHandler(access));
63
65
  r.writeHandler(createAssignTagHandler(access));
64
66
  r.writeHandler(createRemoveTagHandler(access));
65
67
 
66
68
  r.queryHandler(defineEntityListHandler("tag", tagEntity, { access }));
67
69
  r.queryHandler(defineEntityListHandler("tag-assignment", tagAssignmentEntity, { access }));
70
+
71
+ // Standalone Tags management screen (custom React: TagManager). The app places
72
+ // it in nav via r.nav("tags:screen:tag-list"); tagsClient() maps the component.
73
+ r.screen({
74
+ id: TAGS_SCREEN_ID,
75
+ type: "custom",
76
+ renderer: { react: { __component: "TagsScreen" } },
77
+ access,
78
+ });
68
79
  }
69
80
 
70
81
  export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
@@ -6,7 +6,7 @@ import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
6
6
  // create-tag — adds a tag to the tenant's catalog. The framework mints a fresh
7
7
  // UUIDv7 id (no explicit id passed). Tag names are not unique by design: the
8
8
  // catalog is a free list and dedup is a UI concern (autocomplete from existing
9
- // tags). Rename is rename-tag.write.ts; delete is still deferred.
9
+ // tags). Editing is update-tag.write.ts; delete (with cascade) is delete-tag.write.ts.
10
10
  export function createCreateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
11
11
  return {
12
12
  name: "create-tag",
@@ -0,0 +1,54 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DEFAULT_TAG_ACCESS } from "../constants";
3
+ import { tagAssignmentExecutor, tagExecutor } from "../executor";
4
+ import { type DeleteTagPayload, deleteTagPayloadSchema } from "../schemas";
5
+
6
+ const CASCADE_PAGE = 200;
7
+
8
+ // delete-tag — removes a tag from the catalog and detaches it everywhere. No FK
9
+ // (event-sourced, no JOIN), so the handler cascades: soft-delete every
10
+ // assignment carrying this tag, then hard-delete the catalog tag.
11
+ //
12
+ // Idempotent: deleting an already-gone tag returns success (mirrors remove-tag).
13
+ //
14
+ // The cascade re-reads page 1 (no cursor) until it comes back empty instead of
15
+ // paging with a cursor: assignment deletes are soft-deletes and the list query
16
+ // hides isDeleted rows, so a keyset cursor over the shrinking result set would
17
+ // silently skip rows. Re-reading the head always returns the remaining live
18
+ // rows and terminates when none are left.
19
+ export function createDeleteTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
20
+ return {
21
+ name: "delete-tag",
22
+ schema: deleteTagPayloadSchema,
23
+ access,
24
+ handler: async (event, ctx) => {
25
+ const payload = event.payload as DeleteTagPayload; // @cast-boundary engine-payload
26
+
27
+ const existing = await tagExecutor.detail({ id: payload.id }, event.user, ctx.db);
28
+ if (!existing) {
29
+ return { isSuccess: true as const, data: { id: payload.id } };
30
+ }
31
+
32
+ for (;;) {
33
+ const page = await tagAssignmentExecutor.list(
34
+ { filter: { field: "tagId", op: "eq", value: payload.id }, limit: CASCADE_PAGE },
35
+ event.user,
36
+ ctx.db,
37
+ );
38
+ if (page.rows.length === 0) break;
39
+ for (const row of page.rows) {
40
+ const removed = await tagAssignmentExecutor.delete(
41
+ { id: String(row["id"]) },
42
+ event.user,
43
+ ctx.db,
44
+ );
45
+ if (!removed.isSuccess) return removed;
46
+ }
47
+ }
48
+
49
+ return tagExecutor.delete({ id: payload.id }, event.user, ctx.db);
50
+ },
51
+ };
52
+ }
53
+
54
+ export const deleteTagHandler: WriteHandlerDef = createDeleteTagHandler();