@cosmicdrift/kumiko-bundled-features 0.12.2 → 0.14.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 (37) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/package.json +5 -5
  3. package/src/channel-email/feature.ts +1 -1
  4. package/src/channel-in-app/constants.ts +1 -1
  5. package/src/channel-in-app/feature.ts +1 -1
  6. package/src/channel-push/feature.ts +1 -1
  7. package/src/custom-fields/__tests__/audit-integration.integration.ts +277 -0
  8. package/src/custom-fields/__tests__/custom-fields.integration.ts +261 -0
  9. package/src/custom-fields/__tests__/feature.test.ts +8 -1
  10. package/src/custom-fields/__tests__/field-access.integration.ts +268 -0
  11. package/src/custom-fields/__tests__/quota.integration.ts +162 -0
  12. package/src/custom-fields/__tests__/retention.integration.ts +262 -0
  13. package/src/custom-fields/__tests__/user-data-rights.integration.ts +290 -0
  14. package/src/custom-fields/__tests__/wire-for-entity.test.ts +123 -0
  15. package/src/custom-fields/constants.ts +19 -4
  16. package/src/custom-fields/events.ts +21 -0
  17. package/src/custom-fields/feature.ts +135 -29
  18. package/src/custom-fields/handlers/clear-custom-field.write.ts +57 -0
  19. package/src/custom-fields/handlers/define-tenant-field.write.ts +72 -35
  20. package/src/custom-fields/handlers/delete-system-field.write.ts +15 -1
  21. package/src/custom-fields/handlers/delete-tenant-field.write.ts +16 -1
  22. package/src/custom-fields/handlers/set-custom-field.write.ts +77 -0
  23. package/src/custom-fields/index.ts +17 -2
  24. package/src/custom-fields/lib/field-access.ts +75 -0
  25. package/src/custom-fields/lib/parse-serialized-field.ts +45 -0
  26. package/src/custom-fields/lib/quota.ts +28 -0
  27. package/src/custom-fields/run-retention.ts +215 -0
  28. package/src/custom-fields/schemas.ts +37 -4
  29. package/src/custom-fields/wire-for-entity.ts +162 -0
  30. package/src/custom-fields/wire-user-data-rights.ts +169 -0
  31. package/src/rate-limiting/constants.ts +1 -1
  32. package/src/rate-limiting/feature.ts +1 -1
  33. package/src/renderer-simple/feature.ts +1 -1
  34. package/src/template-resolver/table.ts +3 -1
  35. package/src/tenant/invitation-table.ts +2 -1
  36. package/src/text-content/table.ts +3 -1
  37. package/src/user/schema/user.ts +4 -2
@@ -0,0 +1,57 @@
1
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { z } from "zod";
4
+ import { customFieldsFeature } from "../feature";
5
+ import { checkFieldAccessForWrite } from "../lib/field-access";
6
+
7
+ export const clearCustomFieldPayloadSchema = z.object({
8
+ entityName: z.string().min(1).max(64),
9
+ entityId: z.string().min(1),
10
+ fieldKey: z.string().min(1).max(64),
11
+ });
12
+ export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSchema>;
13
+
14
+ // clear-custom-field — entfernt einen Custom-Field-Wert von einer host-
15
+ // entity. Emittiert customField.cleared-Event; MSP entfernt key aus
16
+ // jsonb-column (key-removal, nicht null-set).
17
+ //
18
+ // T1.5b: per-field fieldAccess.write gate — caller muss eine der definierten
19
+ // Rollen halten falls fieldDefinition.serializedField.fieldAccess.write
20
+ // gesetzt ist. Handler-level RBAC (TenantAdmin/Member) bleibt zusätzlich.
21
+ export const clearCustomFieldHandler: WriteHandlerDef = {
22
+ name: "clear-custom-field",
23
+ schema: clearCustomFieldPayloadSchema,
24
+ access: { roles: ["TenantAdmin", "TenantMember"] },
25
+ handler: async (event, ctx) => {
26
+ const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
27
+
28
+ const accessCheck = await checkFieldAccessForWrite(
29
+ ctx.db,
30
+ event.user.tenantId,
31
+ payload.entityName,
32
+ payload.fieldKey,
33
+ event.user.roles,
34
+ );
35
+ if (!accessCheck.ok) {
36
+ if (accessCheck.reason === "field_definition_not_found") {
37
+ return failNotFound("fieldDefinition", payload.fieldKey);
38
+ }
39
+ return failUnprocessable("field_access_denied", {
40
+ fieldKey: payload.fieldKey,
41
+ requiredRoles: accessCheck.requiredRoles ?? [],
42
+ });
43
+ }
44
+
45
+ await ctx.unsafeAppendEvent({
46
+ aggregateId: payload.entityId,
47
+ aggregateType: payload.entityName,
48
+ type: customFieldsFeature.exports.clearedEvent.name,
49
+ payload: { fieldKey: payload.fieldKey },
50
+ });
51
+
52
+ return {
53
+ isSuccess: true as const,
54
+ data: { entityName: payload.entityName, entityId: payload.entityId },
55
+ };
56
+ },
57
+ };
@@ -3,8 +3,10 @@ import {
3
3
  isSystemTenant,
4
4
  type WriteHandlerDef,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
6
7
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
8
  import { fieldDefinitionEntity } from "../entity";
9
+ import { countTenantFieldDefinitions } from "../lib/quota";
8
10
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
9
11
 
10
12
  const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
@@ -24,39 +26,74 @@ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEnt
24
26
  // durch. Resolution beim Read (B2) zeigt dann den system-scope-Wert. v2
25
27
  // kann r.systemScope-Sub-Handler einführen um cross-scope-conflict am Write
26
28
  // abzulehnen.
27
- export const defineTenantFieldHandler: WriteHandlerDef = {
28
- name: "define-tenant-field",
29
- schema: defineFieldPayloadSchema,
30
- access: { roles: ["TenantAdmin"] },
31
- handler: async (event, ctx) => {
32
- const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
33
- const tenantId = event.user.tenantId;
34
-
35
- // TenantAdmin darf NICHT system-scope schreiben — strict-guard.
36
- if (isSystemTenant(tenantId)) {
37
- throw new Error(
38
- "define-tenant-field: tenantId is SYSTEM_TENANT_ID — use define-system-field for system-scope definitions",
29
+ //
30
+ // **Quota (T1.5e)**: optional `fieldDefinitionLimitPerTenant` gate. When the
31
+ // tenant already has ≥ limit definitions, the handler rejects with
32
+ // `unprocessable` + reason `cap_exceeded` BEFORE attempting the insert.
33
+ // The factory below closes over the limit; the legacy const-export keeps
34
+ // behavior unchanged for callers who didn't opt into a limit.
35
+ export interface DefineTenantFieldOptions {
36
+ /** Hard quota — `>= limit` definitions per tenant rejects further defines. */
37
+ readonly fieldDefinitionLimitPerTenant?: number;
38
+ }
39
+
40
+ export function createDefineTenantFieldHandler(
41
+ opts: DefineTenantFieldOptions = {},
42
+ ): WriteHandlerDef {
43
+ const limit = opts.fieldDefinitionLimitPerTenant;
44
+ return {
45
+ name: "define-tenant-field",
46
+ schema: defineFieldPayloadSchema,
47
+ access: { roles: ["TenantAdmin"] },
48
+ handler: async (event, ctx) => {
49
+ const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
50
+ const tenantId = event.user.tenantId;
51
+
52
+ // TenantAdmin darf NICHT system-scope schreiben — strict-guard.
53
+ if (isSystemTenant(tenantId)) {
54
+ throw new Error(
55
+ "define-tenant-field: tenantId is SYSTEM_TENANT_ID — use define-system-field for system-scope definitions",
56
+ );
57
+ }
58
+
59
+ if (limit !== undefined) {
60
+ const current = await countTenantFieldDefinitions(ctx.db, tenantId);
61
+ if (current >= limit) {
62
+ return failUnprocessable("cap_exceeded", {
63
+ capName: "customFields.fieldDefinition.count",
64
+ limit,
65
+ current,
66
+ });
67
+ }
68
+ }
69
+
70
+ const aggregateId = fieldDefinitionAggregateId(
71
+ tenantId,
72
+ payload.entityName,
73
+ payload.fieldKey,
74
+ );
75
+
76
+ return executor.create(
77
+ {
78
+ id: aggregateId,
79
+ entityName: payload.entityName,
80
+ fieldKey: payload.fieldKey,
81
+ type: payload.serializedField.type,
82
+ required: payload.required,
83
+ searchable: payload.searchable,
84
+ displayOrder: payload.displayOrder,
85
+ serializedField: JSON.stringify({
86
+ ...payload.serializedField,
87
+ label: payload.label,
88
+ }),
89
+ },
90
+ event.user,
91
+ ctx.db,
39
92
  );
40
- }
41
-
42
- const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
43
-
44
- return executor.create(
45
- {
46
- id: aggregateId,
47
- entityName: payload.entityName,
48
- fieldKey: payload.fieldKey,
49
- type: payload.serializedField.type,
50
- required: payload.required,
51
- searchable: payload.searchable,
52
- displayOrder: payload.displayOrder,
53
- serializedField: JSON.stringify({
54
- ...payload.serializedField,
55
- label: payload.label,
56
- }),
57
- },
58
- event.user,
59
- ctx.db,
60
- );
61
- },
62
- };
93
+ },
94
+ };
95
+ }
96
+
97
+ // Backwards-compat: existing imports of `defineTenantFieldHandler` keep
98
+ // working — the handler has no quota, identical to pre-T1.5e behavior.
99
+ export const defineTenantFieldHandler: WriteHandlerDef = createDefineTenantFieldHandler();
@@ -5,6 +5,7 @@ import {
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
7
  import { fieldDefinitionEntity } from "../entity";
8
+ import { customFieldsFeature } from "../feature";
8
9
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
9
10
 
10
11
  const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
@@ -28,6 +29,19 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
28
29
  );
29
30
 
30
31
  const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
31
- return executor.delete({ id: aggregateId }, systemUser, ctx.db);
32
+ const result = await executor.delete({ id: aggregateId }, systemUser, ctx.db);
33
+
34
+ // Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
35
+ // ihrer customFields jsonb. Im selben TX = atomic.
36
+ if (result.isSuccess) {
37
+ await ctx.unsafeAppendEvent({
38
+ aggregateId,
39
+ aggregateType: "field-definition",
40
+ type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
41
+ payload: { entityName: payload.entityName, fieldKey: payload.fieldKey },
42
+ });
43
+ }
44
+
45
+ return result;
32
46
  },
33
47
  };
@@ -5,6 +5,7 @@ import {
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
7
  import { fieldDefinitionEntity } from "../entity";
8
+ import { customFieldsFeature } from "../feature";
8
9
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
9
10
 
10
11
  const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
@@ -35,6 +36,20 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
35
36
 
36
37
  const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
37
38
 
38
- return executor.delete({ id: aggregateId }, event.user, ctx.db);
39
+ const result = await executor.delete({ id: aggregateId }, event.user, ctx.db);
40
+
41
+ // Emit cascade-cleanup-Event NACH erfolgreichem Delete. host-entity-MSPs
42
+ // (registriert via wireCustomFieldsFor) konsumieren das + entfernen orphan
43
+ // values aus ihrer customFields jsonb. Im selben TX = atomic cleanup.
44
+ if (result.isSuccess) {
45
+ await ctx.unsafeAppendEvent({
46
+ aggregateId,
47
+ aggregateType: "field-definition",
48
+ type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
49
+ payload: { entityName: payload.entityName, fieldKey: payload.fieldKey },
50
+ });
51
+ }
52
+
53
+ return result;
39
54
  },
40
55
  };
@@ -0,0 +1,77 @@
1
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { z } from "zod";
4
+ import { customFieldsFeature } from "../feature";
5
+ import { checkFieldAccessForWrite } from "../lib/field-access";
6
+
7
+ export const setCustomFieldPayloadSchema = z.object({
8
+ entityName: z.string().min(1).max(64),
9
+ entityId: z.string().min(1),
10
+ fieldKey: z
11
+ .string()
12
+ .min(1)
13
+ .max(64)
14
+ .regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
15
+ value: z.unknown(),
16
+ });
17
+ export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
18
+
19
+ // set-custom-field — schreibt einen Custom-Field-Wert auf eine host-entity.
20
+ //
21
+ // **ES-Option-B**: emittiert customField.set-Event auf dem host-aggregate
22
+ // stream (aggregateType = host-entity-name, aggregateId = host-entity-id).
23
+ // Last-Wins-Semantik: customField.set wird OHNE expectedVersion appended,
24
+ // concurrent writes auf gleiches Field gehen beide durch (Plan-Doc v2
25
+ // Concurrency-Tabelle).
26
+ //
27
+ // **WAS DIESER HANDLER NICHT MACHT (yet)**:
28
+ // - Validation des Werts gegen fieldDefinition-type (B2-todo: rehydriere
29
+ // r.field.X() aus serializedField, .schema.safeParse(value))
30
+ // - cap-counter-quota-Check (T1.5e)
31
+ // → Diese Aspekte kommen als Folgekommits oder durch consumer-side hooks.
32
+ //
33
+ // **WAS NEU IST (T1.5b)**:
34
+ // - field-access-check: wenn fieldDefinition.serializedField.fieldAccess.write
35
+ // gesetzt ist, muss der calling user mindestens eine der Rollen halten.
36
+ // Handler-level RBAC (TenantAdmin/Member) bleibt zusätzlich.
37
+ export const setCustomFieldHandler: WriteHandlerDef = {
38
+ name: "set-custom-field",
39
+ schema: setCustomFieldPayloadSchema,
40
+ access: { roles: ["TenantAdmin", "TenantMember"] },
41
+ handler: async (event, ctx) => {
42
+ const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
43
+
44
+ const accessCheck = await checkFieldAccessForWrite(
45
+ ctx.db,
46
+ event.user.tenantId,
47
+ payload.entityName,
48
+ payload.fieldKey,
49
+ event.user.roles,
50
+ );
51
+ if (!accessCheck.ok) {
52
+ if (accessCheck.reason === "field_definition_not_found") {
53
+ return failNotFound("fieldDefinition", payload.fieldKey);
54
+ }
55
+ return failUnprocessable("field_access_denied", {
56
+ fieldKey: payload.fieldKey,
57
+ requiredRoles: accessCheck.requiredRoles ?? [],
58
+ });
59
+ }
60
+
61
+ // Emit customField.set on host-aggregate stream. unsafeAppendEvent
62
+ // (statt strict appendEvent) weil event-type-map keine cross-feature-
63
+ // augmentation für diesen event-typ hat — wir nutzen den qualified
64
+ // string-namen direkt.
65
+ await ctx.unsafeAppendEvent({
66
+ aggregateId: payload.entityId,
67
+ aggregateType: payload.entityName,
68
+ type: customFieldsFeature.exports.setEvent.name,
69
+ payload: { fieldKey: payload.fieldKey, value: payload.value },
70
+ });
71
+
72
+ return {
73
+ isSuccess: true as const,
74
+ data: { entityName: payload.entityName, entityId: payload.entityId },
75
+ };
76
+ },
77
+ };
@@ -1,17 +1,32 @@
1
1
  export { fieldDefinitionAggregateId } from "./aggregate-id";
2
2
  export {
3
+ CUSTOM_FIELDS_EXTENSION,
3
4
  CUSTOM_FIELDS_FEATURE_NAME,
4
5
  FIELD_DEFINITION_CREATED_EVENT,
5
- FIELD_DEFINITION_DELETED_EVENT,
6
6
  FIELD_DEFINITION_UPDATED_EVENT,
7
7
  SUPPORTED_FIELD_TYPES,
8
8
  type SupportedFieldType,
9
9
  } from "./constants";
10
10
  export { fieldDefinitionEntity } from "./entity";
11
- export { createCustomFieldsFeature } from "./feature";
11
+ export {
12
+ type CustomFieldClearedPayload,
13
+ type CustomFieldSetPayload,
14
+ customFieldClearedSchema,
15
+ customFieldSetSchema,
16
+ } from "./events";
17
+ export { createCustomFieldsFeature, customFieldsFeature } from "./feature";
18
+ export {
19
+ type ClearCustomFieldPayload,
20
+ clearCustomFieldPayloadSchema,
21
+ } from "./handlers/clear-custom-field.write";
22
+ export {
23
+ type SetCustomFieldPayload,
24
+ setCustomFieldPayloadSchema,
25
+ } from "./handlers/set-custom-field.write";
12
26
  export {
13
27
  type DefineFieldPayload,
14
28
  type DeleteFieldPayload,
15
29
  defineFieldPayloadSchema,
16
30
  deleteFieldPayloadSchema,
17
31
  } from "./schemas";
32
+ export { customFieldsField, wireCustomFieldsFor } from "./wire-for-entity";
@@ -0,0 +1,75 @@
1
+ // T1.5b — per-field write access-check for the set/clear handlers.
2
+ //
3
+ // Loads a fieldDefinition by (tenantId, entityName, fieldKey), reads its
4
+ // `serializedField.fieldAccess.write` array, and verifies the calling user
5
+ // holds at least one of the listed roles. When `fieldAccess.write` is
6
+ // absent or empty the handler-level RBAC is the only gate.
7
+
8
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
9
+ import { sql } from "drizzle-orm";
10
+ import { parseSerializedField } from "./parse-serialized-field";
11
+
12
+ export type FieldAccessCheckResult =
13
+ | { ok: true }
14
+ | {
15
+ ok: false;
16
+ reason: "field_definition_not_found" | "field_access_denied";
17
+ requiredRoles?: ReadonlyArray<string>;
18
+ };
19
+
20
+ // Resolution mirrors the Plan-Doc v2 system+tenant UNION: the active
21
+ // definition for a fieldKey on an entity is either system-scope or
22
+ // tenant-scope, never both (B1 conflict-rule). The tenant-scoped row sits
23
+ // in the caller's tenantId; system-scoped rows would sit under
24
+ // SYSTEM_TENANT_ID. B1 only ships the tenant-scoped pipeline, so we only
25
+ // query the caller's tenant — system-scope lookup will land in B2.
26
+ async function loadSerializedField(
27
+ db: TenantDb,
28
+ tenantId: string,
29
+ entityName: string,
30
+ fieldKey: string,
31
+ ): Promise<unknown | null> {
32
+ // TenantDb's tenant-filtered API doesn't expose raw SQL — for this
33
+ // single-row lookup we drop down to the underlying DbRunner. tenantId
34
+ // is still pinned in the WHERE clause so we don't lose isolation.
35
+ const rows = await db.raw.execute(sql`
36
+ SELECT serialized_field
37
+ FROM read_custom_field_definitions
38
+ WHERE entity_name = ${entityName}
39
+ AND field_key = ${fieldKey}
40
+ AND tenant_id = ${tenantId}
41
+ LIMIT 1
42
+ `);
43
+ const first = (rows as ReadonlyArray<Record<string, unknown>>)[0]; // @cast-boundary db-row
44
+ return first ? (first["serialized_field"] ?? null) : null;
45
+ }
46
+
47
+ // Per Plan-Doc T1.5b: an empty / undefined `write` array means the field
48
+ // inherits the handler-level RBAC unchanged. Only an explicit non-empty
49
+ // list constrains. Intersection is role-name equality (case-sensitive).
50
+ export async function checkFieldAccessForWrite(
51
+ db: TenantDb,
52
+ tenantId: string,
53
+ entityName: string,
54
+ fieldKey: string,
55
+ userRoles: ReadonlyArray<string>,
56
+ ): Promise<FieldAccessCheckResult> {
57
+ const serialized = await loadSerializedField(db, tenantId, entityName, fieldKey);
58
+ if (serialized === null) {
59
+ return { ok: false, reason: "field_definition_not_found" };
60
+ }
61
+
62
+ const parsed = parseSerializedField(serialized);
63
+ // skip: corrupt serialized_field on disk → treat as no-access-restriction
64
+ // rather than 500. Loader already returned null on missing row, so a
65
+ // null here means parse-failure on a present row; behave like an open
66
+ // field (next gate is the handler-level RBAC).
67
+ if (!parsed) return { ok: true };
68
+
69
+ const required = parsed.fieldAccess?.write;
70
+ if (!required || required.length === 0) {
71
+ return { ok: true };
72
+ }
73
+ const hit = userRoles.some((role) => required.includes(role));
74
+ return hit ? { ok: true } : { ok: false, reason: "field_access_denied", requiredRoles: required };
75
+ }
@@ -0,0 +1,45 @@
1
+ // Centralised parser for `fieldDefinition.serializedField`.
2
+ //
3
+ // The column is stored as `text` in B1 (entity.ts) — what the db row hands
4
+ // back depends on the driver: postgres-js returns text as a `string`, but
5
+ // jsonb-tolerant drivers and middleware can deliver an already-parsed
6
+ // object. This helper normalises both, validates the shape, and centralises
7
+ // the single boundary cast so callers don't sprinkle `as { … }`-narrowings
8
+ // across the bundle.
9
+ //
10
+ // All structured `serializedField`-keys recognised today (`fieldAccess`,
11
+ // `sensitive`, `retention`) live on this shape. New keys go here so the
12
+ // other call-sites can read them via the typed result instead of re-parsing.
13
+
14
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
15
+
16
+ export interface SerializedFieldShape {
17
+ readonly type: string;
18
+ readonly fieldAccess?: {
19
+ readonly read?: ReadonlyArray<string>;
20
+ readonly write?: ReadonlyArray<string>;
21
+ };
22
+ readonly sensitive?: boolean;
23
+ readonly retention?: {
24
+ readonly keepFor: string;
25
+ readonly strategy: "delete" | "anonymize";
26
+ };
27
+ }
28
+
29
+ function isShape(v: unknown): v is SerializedFieldShape {
30
+ if (!v || typeof v !== "object") return false;
31
+ // `in` narrows v's type from `object` to `object & { type: unknown }` so
32
+ // the property access below does not need a cast.
33
+ if (!("type" in v)) return false;
34
+ return typeof v.type === "string";
35
+ }
36
+
37
+ /**
38
+ * Normalises the row's `serialized_field` column. Returns `null` for
39
+ * absent/corrupt rows so callers can short-circuit cleanly without
40
+ * having to mirror the safe-json fallback semantics themselves.
41
+ */
42
+ export function parseSerializedField(raw: unknown): SerializedFieldShape | null {
43
+ const parsed = typeof raw === "string" ? parseJsonSafe<unknown>(raw, null) : raw;
44
+ return isShape(parsed) ? parsed : null;
45
+ }
@@ -0,0 +1,28 @@
1
+ // T1.5e — per-tenant fieldDefinition quota.
2
+ //
3
+ // `countTenantFieldDefinitions(db, tenantId)` runs a single COUNT(*) against
4
+ // `read_custom_field_definitions` scoped to the caller's tenant. The
5
+ // `define-tenant-field` handler consults this before insert and rejects
6
+ // with `cap_exceeded` once a configurable per-tenant ceiling is reached.
7
+ //
8
+ // This is a simple projection-count rather than a `cap-counter`-bundle
9
+ // counter, because the read-projection is the authoritative source
10
+ // (soft-deleted rows already drop out) and we don't need rolling-window
11
+ // semantics. A future iteration can swap to `cap-counter` if pricing
12
+ // wants e.g. monthly-roll definition allowances.
13
+
14
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
15
+ import { sql } from "drizzle-orm";
16
+
17
+ export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string): Promise<number> {
18
+ const rowsResult = await db.raw.execute(sql`
19
+ SELECT COUNT(*)::int AS n
20
+ FROM read_custom_field_definitions
21
+ WHERE tenant_id = ${tenantId}
22
+ `);
23
+ const rows = rowsResult as ReadonlyArray<Record<string, unknown>>; // @cast-boundary db-row
24
+ const first = rows[0];
25
+ if (!first) return 0;
26
+ const n = first["n"];
27
+ return typeof n === "number" ? n : Number.parseInt(String(n ?? 0), 10);
28
+ }