@cosmicdrift/kumiko-bundled-features 0.97.0 → 0.98.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.97.0",
3
+ "version": "0.98.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.0",
95
- "@cosmicdrift/kumiko-framework": "0.97.0",
96
- "@cosmicdrift/kumiko-headless": "0.97.0",
97
- "@cosmicdrift/kumiko-renderer": "0.97.0",
98
- "@cosmicdrift/kumiko-renderer-web": "0.97.0",
94
+ "@cosmicdrift/kumiko-dispatcher-live": "0.98.0",
95
+ "@cosmicdrift/kumiko-framework": "0.98.0",
96
+ "@cosmicdrift/kumiko-headless": "0.98.0",
97
+ "@cosmicdrift/kumiko-renderer": "0.98.0",
98
+ "@cosmicdrift/kumiko-renderer-web": "0.98.0",
99
99
  "@mollie/api-client": "^4.5.0",
100
100
  "@node-rs/argon2": "^2.0.2",
101
101
  "@types/nodemailer": "^8.0.0",
@@ -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,21 @@ 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
+ // Screen-id of the standalone Tags management screen (custom screen rendering
23
+ // TagManager). Qualified = "tags:screen:tag-list"; the app places it via r.nav.
24
+ export const TAGS_SCREEN_ID = "tag-list";
25
+
17
26
  // Qualified handler names (QN format: scope:type:name). Clients reference the
18
27
  // object instead of magic strings (mirror custom-fields' Handlers/Queries).
19
28
  export const TagsHandlers = {
20
29
  createTag: "tags:write:create-tag",
21
- renameTag: "tags:write:rename-tag",
30
+ updateTag: "tags:write:update-tag",
31
+ deleteTag: "tags:write:delete-tag",
22
32
  assignTag: "tags:write:assign-tag",
23
33
  removeTag: "tags:write:remove-tag",
24
34
  } 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();
@@ -0,0 +1,31 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DEFAULT_TAG_ACCESS } from "../constants";
3
+ import { tagExecutor } from "../executor";
4
+ import { type UpdateTagPayload, updateTagPayloadSchema } from "../schemas";
5
+
6
+ // update-tag — edits a catalog tag (rename / recolor / re-scope). Optimistic-
7
+ // locked: the client sends the `version` it read (mirrors tenant:update). Only
8
+ // the fields present in the payload go into `changes`, and the executor merges
9
+ // shallowly, so any omitted field is preserved (color/scope accept "" to clear).
10
+ // A stale version returns version_conflict; the UI refetches and retries.
11
+ export function createUpdateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
12
+ return {
13
+ name: "update-tag",
14
+ schema: updateTagPayloadSchema,
15
+ access,
16
+ handler: async (event, ctx) => {
17
+ const payload = event.payload as UpdateTagPayload; // @cast-boundary engine-payload
18
+ const changes: Record<string, unknown> = {};
19
+ if (payload.name !== undefined) changes["name"] = payload.name;
20
+ if (payload.color !== undefined) changes["color"] = payload.color;
21
+ if (payload.scope !== undefined) changes["scope"] = payload.scope;
22
+ return tagExecutor.update(
23
+ { id: payload.id, version: payload.version, changes },
24
+ event.user,
25
+ ctx.db,
26
+ );
27
+ },
28
+ };
29
+ }
30
+
31
+ export const updateTagHandler: WriteHandlerDef = createUpdateTagHandler();
package/src/tags/index.ts CHANGED
@@ -3,6 +3,9 @@ export {
3
3
  DEFAULT_TAG_ACCESS,
4
4
  DEFAULT_TAG_ROLES,
5
5
  TAGS_FEATURE_NAME,
6
+ TAGS_FILTER_EXTENSION_NAME,
7
+ TAGS_SCREEN_ID,
8
+ TAGS_SECTION_EXTENSION_NAME,
6
9
  TagsHandlers,
7
10
  TagsQueries,
8
11
  } from "./constants";
@@ -16,21 +19,27 @@ export {
16
19
  createCreateTagHandler,
17
20
  createTagHandler,
18
21
  } from "./handlers/create-tag.write";
22
+ export {
23
+ createDeleteTagHandler,
24
+ deleteTagHandler,
25
+ } from "./handlers/delete-tag.write";
19
26
  export {
20
27
  createRemoveTagHandler,
21
28
  removeTagHandler,
22
29
  } from "./handlers/remove-tag.write";
23
30
  export {
24
- createRenameTagHandler,
25
- renameTagHandler,
26
- } from "./handlers/rename-tag.write";
31
+ createUpdateTagHandler,
32
+ updateTagHandler,
33
+ } from "./handlers/update-tag.write";
27
34
  export {
28
35
  type AssignTagPayload,
29
36
  assignTagPayloadSchema,
30
37
  type CreateTagPayload,
31
38
  createTagPayloadSchema,
39
+ type DeleteTagPayload,
40
+ deleteTagPayloadSchema,
32
41
  type RemoveTagPayload,
33
- type RenameTagPayload,
34
42
  removeTagPayloadSchema,
35
- renameTagPayloadSchema,
43
+ type UpdateTagPayload,
44
+ updateTagPayloadSchema,
36
45
  } from "./schemas";
@@ -3,17 +3,36 @@ import { z } from "zod";
3
3
  export const createTagPayloadSchema = z.object({
4
4
  name: z.string().min(1).max(64),
5
5
  color: z.string().max(32).optional(),
6
+ scope: z.string().max(64).optional(),
6
7
  });
7
8
  export type CreateTagPayload = z.infer<typeof createTagPayloadSchema>;
8
9
 
9
- // rename-tag — id + the version the client read (optimistic lock, mirrors
10
- // tenant:update) + the new name. The executor merges shallowly, so color stays.
11
- export const renameTagPayloadSchema = z.object({
10
+ // update-tag — id + the version the client read (optimistic lock, mirrors
11
+ // tenant:update) + the fields to change. name/color/scope are each optional so
12
+ // the management UI can rename, recolor or re-scope independently; the executor
13
+ // shallow-merges, so any field left undefined is preserved. color/scope accept ""
14
+ // to clear them. At least one mutable field must be present (no-op guard).
15
+ export const updateTagPayloadSchema = z
16
+ .object({
17
+ id: z.string().min(1),
18
+ version: z.number().int().nonnegative(),
19
+ name: z.string().min(1).max(64).optional(),
20
+ color: z.string().max(32).optional(),
21
+ scope: z.string().max(64).optional(),
22
+ })
23
+ .refine((p) => p.name !== undefined || p.color !== undefined || p.scope !== undefined, {
24
+ message: "update-tag needs at least one of name, color or scope",
25
+ });
26
+ export type UpdateTagPayload = z.infer<typeof updateTagPayloadSchema>;
27
+
28
+ // delete-tag — hard-deletes the catalog tag AND cascades a soft-delete over
29
+ // every assignment carrying it (no FK, so the handler does the cascade). No
30
+ // version: deleting a label is a destructive "make it gone" intent — last writer
31
+ // wins toward deletion rather than 409-ing on a concurrent rename. Idempotent.
32
+ export const deleteTagPayloadSchema = z.object({
12
33
  id: z.string().min(1),
13
- version: z.number().int().nonnegative(),
14
- name: z.string().min(1).max(64),
15
34
  });
16
- export type RenameTagPayload = z.infer<typeof renameTagPayloadSchema>;
35
+ export type DeleteTagPayload = z.infer<typeof deleteTagPayloadSchema>;
17
36
 
18
37
  // assign + remove share the (tag, entity) reference shape.
19
38
  const entityTagRef = {
@@ -0,0 +1,107 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { render, screen } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { TagsQueries } from "../../constants";
11
+ import { EntityTags } from "../entity-tags";
12
+ import { defaultTranslations } from "../i18n";
13
+
14
+ type TagRow = { id: string; name: string; color?: string };
15
+ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
16
+
17
+ let catalogRows: readonly TagRow[] = [];
18
+ let assignmentRows: readonly AssignmentRow[] = [];
19
+
20
+ beforeEach(() => {
21
+ catalogRows = [];
22
+ assignmentRows = [];
23
+ });
24
+
25
+ const useQuerySpy = mock((type: string) => ({
26
+ data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
27
+ loading: false,
28
+ error: null,
29
+ refetch: mock(async () => {}),
30
+ }));
31
+
32
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
33
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
34
+ ...actual_renderer,
35
+ useQuery: useQuerySpy,
36
+ }));
37
+
38
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
39
+ return (
40
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
41
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
42
+ </LocaleProvider>
43
+ );
44
+ }
45
+
46
+ describe("EntityTags", () => {
47
+ test("renders a chip per assigned tag, joined to the catalog by id", () => {
48
+ catalogRows = [
49
+ { id: "t1", name: "important", color: "#ef4444" },
50
+ { id: "t2", name: "project-x", color: "#3b82f6" },
51
+ ];
52
+ assignmentRows = [
53
+ { tagId: "t1", entityType: "note", entityId: "n1" },
54
+ { tagId: "t2", entityType: "note", entityId: "n1" },
55
+ ];
56
+
57
+ render(
58
+ <Wrapper>
59
+ <EntityTags entityName="note" entityId="n1" />
60
+ </Wrapper>,
61
+ );
62
+
63
+ expect(screen.getByTestId("entity-tags")).toBeTruthy();
64
+ expect(screen.getByText("important")).toBeTruthy();
65
+ expect(screen.getByText("project-x")).toBeTruthy();
66
+ });
67
+
68
+ test("renders nothing when the entity has no assignments", () => {
69
+ catalogRows = [{ id: "t1", name: "important" }];
70
+ assignmentRows = [];
71
+
72
+ render(
73
+ <Wrapper>
74
+ <EntityTags entityName="note" entityId="n1" />
75
+ </Wrapper>,
76
+ );
77
+
78
+ expect(screen.queryByTestId("entity-tags")).toBeNull();
79
+ expect(screen.queryByTestId("tag-chip")).toBeNull();
80
+ });
81
+
82
+ test("ignores assignments belonging to a different entityType", () => {
83
+ catalogRows = [{ id: "t1", name: "important" }];
84
+ assignmentRows = [{ tagId: "t1", entityType: "invoice", entityId: "n1" }];
85
+
86
+ render(
87
+ <Wrapper>
88
+ <EntityTags entityName="note" entityId="n1" />
89
+ </Wrapper>,
90
+ );
91
+
92
+ expect(screen.queryByTestId("entity-tags")).toBeNull();
93
+ });
94
+
95
+ test("renders nothing for a not-yet-saved entity (entityId null)", () => {
96
+ catalogRows = [{ id: "t1", name: "important" }];
97
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "n1" }];
98
+
99
+ render(
100
+ <Wrapper>
101
+ <EntityTags entityName="note" entityId={null} />
102
+ </Wrapper>,
103
+ );
104
+
105
+ expect(screen.queryByTestId("entity-tags")).toBeNull();
106
+ });
107
+ });