@cosmicdrift/kumiko-framework 0.56.0 → 0.57.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-framework",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
2
2
  import { asRawClient } from "../../db/query";
3
3
  import { createBooleanField, createEntity, createTextField } from "../../engine";
4
4
  import { append, createEventsTable } from "../../event-store";
5
+ import type { EntityCache } from "../../pipeline/entity-cache";
5
6
  import {
6
7
  createTestDb,
7
8
  type TestDb,
@@ -285,3 +286,48 @@ describe("event-store-executor — detail liefert die Stream-Version", () => {
285
286
  expect(updated.isSuccess).toBe(true);
286
287
  });
287
288
  });
289
+
290
+ describe("event-store-executor — entity cache read-through", () => {
291
+ // In-memory stand-in for EntityCache — no Redis needed.
292
+ const store = new Map<string, Record<string, unknown>>();
293
+ const entityCache: EntityCache = {
294
+ get: async (tenantId, name, id) => store.get(`${tenantId}:${name}:${id}`) ?? null,
295
+ mget: async () => new Map(),
296
+ set: async (tenantId, name, id, data) => {
297
+ store.set(`${tenantId}:${name}:${id}`, data);
298
+ },
299
+ mset: async (tenantId, name, entries) => {
300
+ for (const { id, data } of entries) store.set(`${tenantId}:${name}:${id}`, data);
301
+ },
302
+ del: async (tenantId, name, id) => {
303
+ store.delete(`${tenantId}:${name}:${id}`);
304
+ },
305
+ };
306
+ const cachedCrud = createEventStoreExecutor(table, entity, {
307
+ entityName: "esExecUser",
308
+ entityCache,
309
+ });
310
+
311
+ beforeEach(() => store.clear());
312
+
313
+ test("first detail populates cache; second detail is served from cache", async () => {
314
+ const created = await cachedCrud.create({ email: "cache@test.de" }, adminUser, tdb);
315
+ if (!created.isSuccess) throw new Error("create failed");
316
+ const id = created.data.id;
317
+ const storeKey = `${adminUser.tenantId}:esExecUser:${id}`;
318
+
319
+ // create() calls del() — cache must be empty after create.
320
+ expect(store.has(storeKey)).toBe(false);
321
+
322
+ // First detail: miss → DB → populates cache.
323
+ const first = await cachedCrud.detail({ id }, adminUser, tdb);
324
+ expect(first).not.toBeNull();
325
+ expect(store.has(storeKey)).toBe(true);
326
+
327
+ // Poison the cache entry — proves the second detail reads from cache, not DB.
328
+ store.set(storeKey, { ...store.get(storeKey)!, email: "from-cache@test.de" });
329
+
330
+ const second = await cachedCrud.detail({ id }, adminUser, tdb);
331
+ expect(second?.["email"]).toBe("from-cache@test.de");
332
+ });
333
+ });
@@ -42,4 +42,46 @@ describe("buildManifestFromRegistry — deterministic codepoint sort (#330)", ()
42
42
  "demo:config:z-flag",
43
43
  ]);
44
44
  });
45
+
46
+ test("featureNames filter — nur genannte Features im Manifest", () => {
47
+ const registry = createRegistry([
48
+ defineFeature("alpha", () => {}),
49
+ defineFeature("beta", () => {}),
50
+ defineFeature("gamma", () => {}),
51
+ ]);
52
+
53
+ const manifest = buildManifestFromRegistry(registry, {
54
+ source: "test",
55
+ featureNames: new Set(["alpha", "gamma"]),
56
+ });
57
+
58
+ expect(manifest.features.map((f) => f.name)).toEqual(["alpha", "gamma"]);
59
+ expect(manifest.featureCount).toBe(2);
60
+ });
61
+
62
+ test("tier-Tagging — per-Feature + top-level im Manifest", () => {
63
+ const registry = createRegistry([
64
+ defineFeature("pro", () => {}),
65
+ defineFeature("free", () => {}),
66
+ ]);
67
+
68
+ const manifest = buildManifestFromRegistry(registry, {
69
+ source: "test",
70
+ tier: "enterprise",
71
+ });
72
+
73
+ expect(manifest.tier).toBe("enterprise");
74
+ for (const feature of manifest.features) {
75
+ expect(feature.tier).toBe("enterprise");
76
+ }
77
+ });
78
+
79
+ test("tier: undefined — weder per-Feature noch top-level vorhanden", () => {
80
+ const registry = createRegistry([defineFeature("basic", () => {})]);
81
+
82
+ const manifest = buildManifestFromRegistry(registry, { source: "test" });
83
+
84
+ expect(manifest.tier).toBeUndefined();
85
+ expect(manifest.features[0]?.tier).toBeUndefined();
86
+ });
45
87
  });
@@ -1,4 +1,5 @@
1
1
  import type { ConfigScope } from "./constants";
2
+ import { SYSTEM_ROLE } from "./system-user";
2
3
  import type {
3
4
  ConfigBacking,
4
5
  ConfigBounds,
@@ -27,6 +28,13 @@ export const access = {
27
28
  authenticated: ["User", "Admin", "SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
28
29
  anonymous: ["anonymous"] as readonly string[], // @cast-boundary schema-walk
29
30
  roles: (...roles: string[]): readonly string[] => roles,
31
+ // Tenant self-service roles PLUS the system actor — for tenant-scope keys a
32
+ // tenant edits itself (configEdit) but that provisioning/migration/jobs must
33
+ // also set via ctx.systemWriteAs (roles = [SYSTEM_ROLE]). Stays human-writable
34
+ // because checkWriteAccess only collapses to system-only when system is the
35
+ // SOLE writer (humanWriters empty). Composes any preset, so apps with custom
36
+ // roles get the same provisioning path (issue #396).
37
+ withSystem: (roles: readonly string[]): readonly string[] => [SYSTEM_ROLE, ...roles],
30
38
  } as const;
31
39
 
32
40
  // --- Config Key Options ---