@cosmicdrift/kumiko-bundled-features 0.81.1 → 0.82.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.81.1",
3
+ "version": "0.82.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>",
@@ -85,11 +85,11 @@
85
85
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
86
86
  },
87
87
  "dependencies": {
88
- "@cosmicdrift/kumiko-dispatcher-live": "0.81.1",
89
- "@cosmicdrift/kumiko-framework": "0.81.1",
90
- "@cosmicdrift/kumiko-headless": "0.81.1",
91
- "@cosmicdrift/kumiko-renderer": "0.81.1",
92
- "@cosmicdrift/kumiko-renderer-web": "0.81.1",
88
+ "@cosmicdrift/kumiko-dispatcher-live": "0.82.0",
89
+ "@cosmicdrift/kumiko-framework": "0.82.0",
90
+ "@cosmicdrift/kumiko-headless": "0.82.0",
91
+ "@cosmicdrift/kumiko-renderer": "0.82.0",
92
+ "@cosmicdrift/kumiko-renderer-web": "0.82.0",
93
93
  "@mollie/api-client": "^4.5.0",
94
94
  "@node-rs/argon2": "^2.0.2",
95
95
  "@types/nodemailer": "^8.0.0",
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { DEFAULT_TAG_ROLES } from "../constants";
3
3
  import { createTagsFeature } from "../feature";
4
- import { assignTagPayloadSchema, createTagPayloadSchema, removeTagPayloadSchema } from "../schemas";
4
+ import {
5
+ assignTagPayloadSchema,
6
+ createTagPayloadSchema,
7
+ removeTagPayloadSchema,
8
+ renameTagPayloadSchema,
9
+ } from "../schemas";
5
10
 
6
11
  // Unit tests: feature-shape, role-options, schema-validation. The ES-loop
7
12
  // behaviour (idempotent assign/remove, projection, tenant-isolation, read
@@ -42,7 +47,7 @@ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch
42
47
  }
43
48
 
44
49
  describe("createTagsFeature shape", () => {
45
- test("registers tag + tag-assignment entities, 3 write-handlers, 2 query-handlers", () => {
50
+ test("registers tag + tag-assignment entities, 4 write-handlers, 2 query-handlers", () => {
46
51
  const feature = createTagsFeature();
47
52
 
48
53
  expect(Object.keys(feature.entities ?? {})).toEqual(
@@ -52,11 +57,12 @@ describe("createTagsFeature shape", () => {
52
57
  expect(Object.keys(feature.writeHandlers)).toEqual(
53
58
  expect.arrayContaining([
54
59
  expect.stringMatching(/create-tag/),
60
+ expect.stringMatching(/rename-tag/),
55
61
  expect.stringMatching(/assign-tag/),
56
62
  expect.stringMatching(/remove-tag/),
57
63
  ]),
58
64
  );
59
- expect(Object.keys(feature.writeHandlers)).toHaveLength(3);
65
+ expect(Object.keys(feature.writeHandlers)).toHaveLength(4);
60
66
 
61
67
  expect(Object.keys(feature.queryHandlers)).toEqual(
62
68
  expect.arrayContaining([
@@ -73,6 +79,7 @@ describe("createTagsFeature access-options", () => {
73
79
  const feature = createTagsFeature();
74
80
  expect(feature).toBe(createTagsFeature());
75
81
  expect(writeAccess(feature, "create-tag")).toEqual([...DEFAULT_TAG_ROLES]);
82
+ expect(writeAccess(feature, "rename-tag")).toEqual([...DEFAULT_TAG_ROLES]);
76
83
  expect(writeAccess(feature, "assign-tag")).toEqual([...DEFAULT_TAG_ROLES]);
77
84
  expect(writeAccess(feature, "remove-tag")).toEqual([...DEFAULT_TAG_ROLES]);
78
85
  expect(queryAccess(feature, "tag:list")).toEqual([...DEFAULT_TAG_ROLES]);
@@ -82,6 +89,7 @@ describe("createTagsFeature access-options", () => {
82
89
  test("roles option overrides every write- and query-path", () => {
83
90
  const feature = createTagsFeature({ roles: ["Admin", "Editor"] });
84
91
  expect(writeAccess(feature, "create-tag")).toEqual(["Admin", "Editor"]);
92
+ expect(writeAccess(feature, "rename-tag")).toEqual(["Admin", "Editor"]);
85
93
  expect(writeAccess(feature, "assign-tag")).toEqual(["Admin", "Editor"]);
86
94
  expect(writeAccess(feature, "remove-tag")).toEqual(["Admin", "Editor"]);
87
95
  expect(queryAccess(feature, "tag:list")).toEqual(["Admin", "Editor"]);
@@ -90,7 +98,7 @@ describe("createTagsFeature access-options", () => {
90
98
 
91
99
  test("access:{openToAll} applies to every write- and query-path", () => {
92
100
  const feature = createTagsFeature({ access: { openToAll: true } });
93
- for (const path of ["create-tag", "assign-tag", "remove-tag"]) {
101
+ for (const path of ["create-tag", "rename-tag", "assign-tag", "remove-tag"]) {
94
102
  expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
95
103
  }
96
104
  for (const query of ["tag:list", "tag-assignment:list"]) {
@@ -155,6 +163,28 @@ describe("createTagPayloadSchema", () => {
155
163
  });
156
164
  });
157
165
 
166
+ describe("renameTagPayloadSchema", () => {
167
+ const valid = { id: "tag-1", version: 0, name: "Neu" };
168
+
169
+ test("accepts id + version + name", () => {
170
+ expect(renameTagPayloadSchema.safeParse(valid).success).toBe(true);
171
+ });
172
+
173
+ test("rejects a missing version (optimistic lock is mandatory)", () => {
174
+ expect(renameTagPayloadSchema.safeParse({ id: "tag-1", name: "Neu" }).success).toBe(false);
175
+ });
176
+
177
+ test("rejects an empty name", () => {
178
+ expect(renameTagPayloadSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
179
+ });
180
+
181
+ test("rejects a name over 64 chars", () => {
182
+ expect(renameTagPayloadSchema.safeParse({ ...valid, name: "x".repeat(65) }).success).toBe(
183
+ false,
184
+ );
185
+ });
186
+ });
187
+
158
188
  describe("assign/remove payload schemas", () => {
159
189
  const valid = { tagId: "tag-1", entityType: "credit", entityId: "c-1" };
160
190
 
@@ -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 tagById(id: string, user = admin): Promise<Record<string, unknown> | undefined> {
65
+ return (await listTags(user)).find((t) => t["id"] === id);
66
+ }
67
+
64
68
  async function listTags(user = admin): Promise<Array<Record<string, unknown>>> {
65
69
  const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
66
70
  TagsQueries.tagList,
@@ -129,6 +133,64 @@ describe("tags integration — catalog + assignment roundtrip", () => {
129
133
  });
130
134
  });
131
135
 
136
+ describe("tags integration — rename", () => {
137
+ test("rename-tag updates name, preserves color, bumps version", async () => {
138
+ const { id } = await stack.http.writeOk<{ id: string }>(
139
+ TagsHandlers.createTag,
140
+ { name: "Mandant Alt", color: "#abc" },
141
+ admin,
142
+ );
143
+ const before = await tagById(id);
144
+ expect(before?.["name"]).toBe("Mandant Alt");
145
+ // The rename UI passes the list-row version as the optimistic-lock base —
146
+ // assert it's actually there (tags are CRUD-only, so it's authoritative).
147
+ expect(typeof before?.["version"]).toBe("number");
148
+ const version = before?.["version"] as number;
149
+
150
+ await stack.http.writeOk(TagsHandlers.renameTag, { id, version, name: "Mandant Neu" }, admin);
151
+
152
+ const after = await tagById(id);
153
+ expect(after?.["name"]).toBe("Mandant Neu");
154
+ expect(after?.["color"]).toBe("#abc"); // shallow merge keeps non-renamed fields
155
+ expect(after?.["version"]).toBe(version + 1);
156
+ });
157
+
158
+ test("rename-tag with a stale version is rejected (409), name unchanged", async () => {
159
+ const { id } = await stack.http.writeOk<{ id: string }>(
160
+ TagsHandlers.createTag,
161
+ { name: "Konflikt" },
162
+ admin,
163
+ );
164
+ const stale = (await tagById(id))?.["version"] as number;
165
+ await stack.http.writeOk(TagsHandlers.renameTag, { id, version: stale, name: "Erster" }, admin);
166
+
167
+ const err = await stack.http.writeErr(
168
+ TagsHandlers.renameTag,
169
+ { id, version: stale, name: "Zweiter" }, // stale: the row already moved to stale+1
170
+ admin,
171
+ );
172
+ expect(err.httpStatus).toBe(409);
173
+ expect((await tagById(id))?.["name"]).toBe("Erster");
174
+ });
175
+
176
+ test("tenant B cannot rename tenant A's tag (404, A untouched)", async () => {
177
+ const { id } = await stack.http.writeOk<{ id: string }>(
178
+ TagsHandlers.createTag,
179
+ { name: "A privat" },
180
+ admin,
181
+ );
182
+ const version = (await tagById(id, admin))?.["version"] as number;
183
+
184
+ const err = await stack.http.writeErr(
185
+ TagsHandlers.renameTag,
186
+ { id, version, name: "B-Übernahme" },
187
+ otherTenant,
188
+ );
189
+ expect(err.httpStatus).toBe(404);
190
+ expect((await tagById(id, admin))?.["name"]).toBe("A privat");
191
+ });
192
+ });
193
+
132
194
  describe("tags integration — many-to-many composition", () => {
133
195
  test("one entity carries multiple tags", async () => {
134
196
  const a = await createTag("rot");
@@ -18,6 +18,7 @@ export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
18
18
  // object instead of magic strings (mirror custom-fields' Handlers/Queries).
19
19
  export const TagsHandlers = {
20
20
  createTag: "tags:write:create-tag",
21
+ renameTag: "tags:write:rename-tag",
21
22
  assignTag: "tags:write:assign-tag",
22
23
  removeTag: "tags:write:remove-tag",
23
24
  } as const;
@@ -12,8 +12,8 @@
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
- // v1 scope: create-tag, assign-tag, remove-tag, list tags, list assignments.
16
- // Deferred: rename/delete-tag, optional host-projection decoration
15
+ // Scope: create-tag, rename-tag, assign-tag, remove-tag, list tags, list assignments.
16
+ // Deferred: delete-tag, optional host-projection decoration
17
17
  // (`wireTagsFor`), search indexing, user-data-rights anonymization.
18
18
 
19
19
  import {
@@ -27,6 +27,7 @@ import { tagAssignmentEntity, tagEntity } from "./entity";
27
27
  import { createAssignTagHandler } from "./handlers/assign-tag.write";
28
28
  import { createCreateTagHandler } from "./handlers/create-tag.write";
29
29
  import { createRemoveTagHandler } from "./handlers/remove-tag.write";
30
+ import { createRenameTagHandler } from "./handlers/rename-tag.write";
30
31
 
31
32
  // Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
32
33
  // dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
@@ -42,7 +43,7 @@ function registerTags(
42
43
  toggleable: TagsToggleable | undefined,
43
44
  ): void {
44
45
  r.describe(
45
- "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`, `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).",
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).",
46
47
  );
47
48
  r.uiHints({
48
49
  displayLabel: "Tags",
@@ -58,6 +59,7 @@ function registerTags(
58
59
  r.entity("tag-assignment", tagAssignmentEntity);
59
60
 
60
61
  r.writeHandler(createCreateTagHandler(access));
62
+ r.writeHandler(createRenameTagHandler(access));
61
63
  r.writeHandler(createAssignTagHandler(access));
62
64
  r.writeHandler(createRemoveTagHandler(access));
63
65
 
@@ -6,8 +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/delete are deferred to a later iteration (v1 scope: create,
10
- // assign, remove, list).
9
+ // tags). Rename is rename-tag.write.ts; delete is still deferred.
11
10
  export function createCreateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
12
11
  return {
13
12
  name: "create-tag",
@@ -0,0 +1,26 @@
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 RenameTagPayload, renameTagPayloadSchema } from "../schemas";
5
+
6
+ // rename-tag — renames a tag in the tenant's catalog. Optimistic-locked: the
7
+ // client sends the `version` it read (mirrors tenant:update). The executor
8
+ // merges shallowly so only `name` changes — `color` is preserved. A stale
9
+ // version returns version_conflict; the UI refetches and retries.
10
+ export function createRenameTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
11
+ return {
12
+ name: "rename-tag",
13
+ schema: renameTagPayloadSchema,
14
+ access,
15
+ handler: async (event, ctx) => {
16
+ const payload = event.payload as RenameTagPayload; // @cast-boundary engine-payload
17
+ return tagExecutor.update(
18
+ { id: payload.id, version: payload.version, changes: { name: payload.name } },
19
+ event.user,
20
+ ctx.db,
21
+ );
22
+ },
23
+ };
24
+ }
25
+
26
+ export const renameTagHandler: WriteHandlerDef = createRenameTagHandler();
package/src/tags/index.ts CHANGED
@@ -20,11 +20,17 @@ export {
20
20
  createRemoveTagHandler,
21
21
  removeTagHandler,
22
22
  } from "./handlers/remove-tag.write";
23
+ export {
24
+ createRenameTagHandler,
25
+ renameTagHandler,
26
+ } from "./handlers/rename-tag.write";
23
27
  export {
24
28
  type AssignTagPayload,
25
29
  assignTagPayloadSchema,
26
30
  type CreateTagPayload,
27
31
  createTagPayloadSchema,
28
32
  type RemoveTagPayload,
33
+ type RenameTagPayload,
29
34
  removeTagPayloadSchema,
35
+ renameTagPayloadSchema,
30
36
  } from "./schemas";
@@ -6,6 +6,15 @@ export const createTagPayloadSchema = z.object({
6
6
  });
7
7
  export type CreateTagPayload = z.infer<typeof createTagPayloadSchema>;
8
8
 
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({
12
+ id: z.string().min(1),
13
+ version: z.number().int().nonnegative(),
14
+ name: z.string().min(1).max(64),
15
+ });
16
+ export type RenameTagPayload = z.infer<typeof renameTagPayloadSchema>;
17
+
9
18
  // assign + remove share the (tag, entity) reference shape.
10
19
  const entityTagRef = {
11
20
  tagId: z.string().min(1).max(64),