@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.
- package/package.json +6 -6
- package/src/file-foundation/feature.ts +28 -141
- package/src/tags/__tests__/feature.test.ts +46 -13
- package/src/tags/__tests__/tags.integration.test.ts +66 -4
- package/src/tags/constants.ts +17 -1
- package/src/tags/entity.ts +4 -0
- package/src/tags/feature.ts +18 -7
- package/src/tags/handlers/create-tag.write.ts +1 -1
- package/src/tags/handlers/delete-tag.write.ts +54 -0
- package/src/tags/handlers/update-tag.write.ts +31 -0
- package/src/tags/index.ts +15 -5
- package/src/tags/schemas.ts +25 -6
- package/src/tags/web/__tests__/entity-tags.test.tsx +107 -0
- package/src/tags/web/__tests__/tag-chip.test.ts +34 -0
- package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
- package/src/tags/web/__tests__/tag-section.test.tsx +51 -77
- package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
- package/src/tags/web/client-plugin.tsx +20 -1
- package/src/tags/web/entity-tags.tsx +47 -0
- package/src/tags/web/i18n.ts +39 -8
- package/src/tags/web/index.ts +14 -1
- package/src/tags/web/tag-chip.tsx +63 -0
- package/src/tags/web/tag-filter.tsx +100 -0
- package/src/tags/web/tag-manager.tsx +390 -0
- package/src/tags/web/tag-picker.tsx +51 -0
- package/src/tags/web/tag-section.tsx +51 -101
- package/src/tags/web/tags-cell.tsx +46 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
- 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.
|
|
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.
|
|
95
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
98
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
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 —
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
// **
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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`
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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(/
|
|
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(
|
|
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, "
|
|
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, "
|
|
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", "
|
|
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("
|
|
170
|
+
describe("updateTagPayloadSchema", () => {
|
|
167
171
|
const valid = { id: "tag-1", version: 0, name: "Neu" };
|
|
168
172
|
|
|
169
|
-
test("accepts id + version + name", () => {
|
|
170
|
-
expect(
|
|
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(
|
|
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(
|
|
201
|
+
expect(updateTagPayloadSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
|
|
179
202
|
});
|
|
180
203
|
|
|
181
204
|
test("rejects a name over 64 chars", () => {
|
|
182
|
-
expect(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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");
|
package/src/tags/constants.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/tags/entity.ts
CHANGED
|
@@ -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
|
|
package/src/tags/feature.ts
CHANGED
|
@@ -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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// (`wireTagsFor`), search indexing
|
|
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 {
|
|
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`, `
|
|
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(
|
|
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).
|
|
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();
|