@cosmicdrift/kumiko-bundled-features 0.14.0 → 0.15.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 (268) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/env-schemas.test.ts +1 -1
  3. package/src/__tests__/es-ops-e2e.integration.ts +10 -9
  4. package/src/audit/__tests__/audit.integration.ts +3 -3
  5. package/src/audit/handlers/list.query.ts +39 -51
  6. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +4 -3
  7. package/src/auth-email-password/__tests__/account-lockout.integration.ts +4 -3
  8. package/src/auth-email-password/__tests__/auth-claims.integration.ts +5 -4
  9. package/src/auth-email-password/__tests__/auth.integration.ts +4 -3
  10. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +1 -1
  11. package/src/auth-email-password/__tests__/email-templates.test.ts +1 -1
  12. package/src/auth-email-password/__tests__/email-verification.integration.ts +7 -10
  13. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +1 -1
  14. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +4 -3
  15. package/src/auth-email-password/__tests__/invite-flow.integration.ts +16 -43
  16. package/src/auth-email-password/__tests__/multi-roles.integration.ts +6 -9
  17. package/src/auth-email-password/__tests__/password-reset.integration.ts +8 -7
  18. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +4 -3
  19. package/src/auth-email-password/__tests__/seed-admin.integration.ts +19 -32
  20. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +6 -5
  21. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +1 -1
  22. package/src/auth-email-password/__tests__/signed-token.test.ts +1 -1
  23. package/src/auth-email-password/__tests__/signup-flow.integration.ts +11 -15
  24. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +26 -26
  25. package/src/auth-email-password/handlers/invite-accept.write.ts +24 -21
  26. package/src/auth-email-password/handlers/invite-create.write.ts +3 -8
  27. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +20 -17
  28. package/src/auth-email-password/handlers/signup-confirm.write.ts +3 -7
  29. package/src/auth-email-password/seeding.ts +1 -1
  30. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +1 -2
  31. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +10 -19
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +12 -18
  33. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +12 -17
  34. package/src/auth-email-password/web/__tests__/session-roles.test.ts +1 -1
  35. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +1 -8
  36. package/src/auth-email-password/web/__tests__/test-utils.tsx +4 -8
  37. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +2 -8
  38. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +10 -15
  39. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +1 -1
  40. package/src/billing-foundation/__tests__/feature.test.ts +1 -1
  41. package/src/billing-foundation/__tests__/webhook-handler.test.ts +6 -5
  42. package/src/billing-foundation/db/queries/subscription-projection.ts +15 -0
  43. package/src/billing-foundation/get-subscription-for-tenant.ts +2 -6
  44. package/src/billing-foundation/handlers/create-portal-session.write.ts +2 -2
  45. package/src/billing-foundation/handlers/list-subscriptions.query.ts +4 -1
  46. package/src/billing-foundation/projection.ts +32 -13
  47. package/src/cap-counter/__tests__/cap-counter.integration.ts +1 -1
  48. package/src/cap-counter/__tests__/enforce-cap.test.ts +37 -32
  49. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +1 -1
  50. package/src/cap-counter/enforce-cap.ts +14 -20
  51. package/src/cap-counter/handlers/get-counter.query.ts +7 -13
  52. package/src/cap-counter/handlers/increment.write.ts +2 -2
  53. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  54. package/src/channel-in-app/handlers/inbox.query.ts +7 -13
  55. package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -9
  56. package/src/channel-in-app/handlers/mark-read.write.ts +8 -14
  57. package/src/channel-in-app/handlers/unread-count.query.ts +10 -9
  58. package/src/channel-in-app/in-app-channel.ts +10 -12
  59. package/src/channel-in-app/tables.ts +1 -1
  60. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +1 -1
  61. package/src/compliance-profiles/__tests__/seeding.integration.ts +1 -1
  62. package/src/compliance-profiles/handlers/for-tenant.query.ts +4 -7
  63. package/src/compliance-profiles/handlers/needs-profile.query.ts +4 -7
  64. package/src/compliance-profiles/handlers/set-profile.write.ts +5 -7
  65. package/src/compliance-profiles/resolve-for-tenant.ts +5 -7
  66. package/src/compliance-profiles/schema/profile-selection.ts +2 -2
  67. package/src/compliance-profiles/seeding.ts +4 -7
  68. package/src/config/__tests__/app-overrides.test.ts +1 -1
  69. package/src/config/__tests__/cascade.integration.ts +1 -1
  70. package/src/config/__tests__/config.integration.ts +8 -27
  71. package/src/config/db/queries/resolver.ts +47 -0
  72. package/src/config/handlers/__tests__/prepare-config-write.test.ts +1 -1
  73. package/src/config/resolver.ts +14 -62
  74. package/src/config/table.ts +4 -4
  75. package/src/config/write-helpers.ts +7 -11
  76. package/src/custom-fields/__tests__/audit-integration.integration.ts +6 -6
  77. package/src/custom-fields/__tests__/custom-fields.integration.ts +7 -7
  78. package/src/custom-fields/__tests__/feature.test.ts +1 -1
  79. package/src/custom-fields/__tests__/field-access.integration.ts +6 -6
  80. package/src/custom-fields/__tests__/quota.integration.ts +6 -6
  81. package/src/custom-fields/__tests__/retention.integration.ts +12 -10
  82. package/src/custom-fields/__tests__/user-data-rights.integration.ts +27 -17
  83. package/src/custom-fields/__tests__/wire-for-entity.test.ts +5 -5
  84. package/src/custom-fields/db/queries/field-access.ts +16 -0
  85. package/src/custom-fields/db/queries/projection.ts +43 -0
  86. package/src/custom-fields/db/queries/quota.ts +14 -0
  87. package/src/custom-fields/db/queries/retention.ts +39 -0
  88. package/src/custom-fields/db/queries/user-data-rights.ts +54 -0
  89. package/src/custom-fields/lib/field-access.ts +2 -41
  90. package/src/custom-fields/lib/quota.ts +2 -25
  91. package/src/custom-fields/run-retention.ts +19 -21
  92. package/src/custom-fields/wire-for-entity.ts +30 -23
  93. package/src/custom-fields/wire-user-data-rights.ts +33 -85
  94. package/src/data-retention/__tests__/data-retention.integration.ts +1 -1
  95. package/src/data-retention/__tests__/keep-for.test.ts +1 -1
  96. package/src/data-retention/__tests__/override-schema.test.ts +1 -1
  97. package/src/data-retention/__tests__/policy-for.integration.ts +1 -1
  98. package/src/data-retention/__tests__/resolver.test.ts +1 -1
  99. package/src/data-retention/handlers/policy-for.query.ts +5 -8
  100. package/src/data-retention/resolve-for-tenant.ts +6 -8
  101. package/src/data-retention/schema/tenant-retention-override.ts +2 -2
  102. package/src/delivery/__tests__/delivery-events.integration.ts +8 -21
  103. package/src/delivery/__tests__/delivery.integration.ts +100 -190
  104. package/src/delivery/db/queries/preferences.ts +30 -0
  105. package/src/delivery/delivery-service.ts +8 -36
  106. package/src/delivery/feature.ts +2 -1
  107. package/src/delivery/handlers/log.query.ts +5 -7
  108. package/src/delivery/handlers/preferences.query.ts +2 -5
  109. package/src/delivery/tables.ts +26 -1
  110. package/src/delivery/upsert-preference.ts +8 -14
  111. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +30 -30
  112. package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -6
  113. package/src/feature-toggles/db/queries/toggle-state.ts +25 -0
  114. package/src/feature-toggles/feature.ts +16 -2
  115. package/src/feature-toggles/global-feature-state-table.ts +1 -1
  116. package/src/feature-toggles/handlers/list.query.ts +9 -2
  117. package/src/feature-toggles/handlers/registered.query.ts +3 -7
  118. package/src/feature-toggles/handlers/set.write.ts +37 -25
  119. package/src/feature-toggles/toggle-runtime.ts +3 -6
  120. package/src/file-foundation/__tests__/feature.test.ts +1 -1
  121. package/src/file-foundation/__tests__/file-foundation.integration.ts +1 -1
  122. package/src/file-provider-inmemory/__tests__/feature.test.ts +1 -1
  123. package/src/file-provider-s3/__tests__/feature.test.ts +1 -1
  124. package/src/files/__tests__/files.integration.ts +18 -7
  125. package/src/files/schema/file-ref.ts +1 -1
  126. package/src/files-provider-s3/__tests__/env-helper.test.ts +1 -1
  127. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +1 -1
  128. package/src/files-provider-s3/__tests__/s3-provider.test.ts +1 -1
  129. package/src/jobs/__tests__/job-system-user.integration.ts +1 -1
  130. package/src/jobs/__tests__/jobs-events.integration.ts +8 -21
  131. package/src/jobs/__tests__/jobs-feature.integration.ts +1 -1
  132. package/src/jobs/feature.ts +22 -14
  133. package/src/jobs/handlers/detail.query.ts +10 -8
  134. package/src/jobs/handlers/list.query.ts +9 -21
  135. package/src/jobs/handlers/retry.write.ts +2 -7
  136. package/src/jobs/job-run-logger.ts +3 -9
  137. package/src/jobs/job-run-table.ts +49 -17
  138. package/src/legal-pages/__tests__/legal-pages.integration.ts +1 -1
  139. package/src/mail-foundation/__tests__/feature.test.ts +1 -1
  140. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +1 -1
  141. package/src/mail-transport-inmemory/__tests__/feature.test.ts +1 -1
  142. package/src/mail-transport-smtp/__tests__/feature.test.ts +1 -1
  143. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +1 -1
  144. package/src/renderer-foundation/__tests__/api.test.ts +2 -2
  145. package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +1 -1
  146. package/src/renderer-simple/__tests__/adapter.test.ts +2 -2
  147. package/src/renderer-simple/__tests__/simple-renderer.test.ts +1 -1
  148. package/src/secrets/__tests__/require-secrets-context.test.ts +6 -5
  149. package/src/secrets/__tests__/rotate.integration.ts +6 -9
  150. package/src/secrets/__tests__/secrets-events.integration.ts +6 -12
  151. package/src/secrets/__tests__/secrets.integration.ts +6 -11
  152. package/src/secrets/db/queries/read.ts +16 -0
  153. package/src/secrets/handlers/list.query.ts +16 -17
  154. package/src/secrets/handlers/rotate.job.ts +8 -12
  155. package/src/secrets/secrets-context.ts +9 -21
  156. package/src/secrets/table.ts +1 -1
  157. package/src/sessions/__tests__/cleanup.integration.ts +8 -6
  158. package/src/sessions/__tests__/password-auto-revoke.integration.ts +7 -6
  159. package/src/sessions/__tests__/sessions.integration.ts +23 -38
  160. package/src/sessions/__tests__/test-helpers.ts +1 -1
  161. package/src/sessions/db/queries/cleanup.ts +21 -0
  162. package/src/sessions/handlers/cleanup.job.ts +6 -29
  163. package/src/sessions/handlers/list.query.ts +24 -24
  164. package/src/sessions/handlers/mine.query.ts +24 -23
  165. package/src/sessions/handlers/revoke-all-for-user.write.ts +7 -11
  166. package/src/sessions/handlers/revoke-all-others.write.ts +7 -12
  167. package/src/sessions/handlers/revoke.write.ts +11 -18
  168. package/src/sessions/schema/user-session.ts +2 -2
  169. package/src/sessions/session-callbacks.ts +19 -21
  170. package/src/subscription-mollie/__tests__/feature.test.ts +1 -1
  171. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +1 -1
  172. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +8 -7
  173. package/src/subscription-stripe/__tests__/feature.test.ts +1 -1
  174. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +14 -15
  175. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +1 -1
  176. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +14 -14
  177. package/src/subscription-stripe/verify-webhook.ts +1 -1
  178. package/src/template-resolver/__tests__/handlers.integration.ts +1 -1
  179. package/src/template-resolver/__tests__/template-resolver.integration.ts +3 -2
  180. package/src/template-resolver/api.ts +7 -13
  181. package/src/template-resolver/handlers/archive.write.ts +4 -7
  182. package/src/template-resolver/handlers/find-by-id.query.ts +4 -7
  183. package/src/template-resolver/handlers/list.query.ts +13 -21
  184. package/src/template-resolver/handlers/publish.write.ts +4 -7
  185. package/src/template-resolver/handlers/upsert-system.write.ts +7 -10
  186. package/src/template-resolver/handlers/upsert-tenant.write.ts +7 -10
  187. package/src/template-resolver/table.ts +2 -5
  188. package/src/tenant/__tests__/multi-tenant.integration.ts +1 -1
  189. package/src/tenant/__tests__/seed-testing.integration.ts +19 -45
  190. package/src/tenant/__tests__/tenant.integration.ts +1 -1
  191. package/src/tenant/handlers/active-tenant-ids.query.ts +3 -8
  192. package/src/tenant/handlers/add-member.write.ts +6 -8
  193. package/src/tenant/handlers/cancel-invitation.write.ts +5 -7
  194. package/src/tenant/handlers/invitations.query.ts +5 -10
  195. package/src/tenant/handlers/me.query.ts +2 -3
  196. package/src/tenant/handlers/members.query.ts +4 -5
  197. package/src/tenant/handlers/memberships.query.ts +2 -5
  198. package/src/tenant/handlers/remove-member.write.ts +6 -8
  199. package/src/tenant/handlers/resolve-user-ids.query.ts +6 -16
  200. package/src/tenant/handlers/update-member-roles.write.ts +6 -8
  201. package/src/tenant/invitation-table.ts +2 -5
  202. package/src/tenant/membership-table.ts +3 -6
  203. package/src/tenant/schema/tenant.ts +2 -2
  204. package/src/tenant/seeding.ts +12 -18
  205. package/src/text-content/README.md +1 -1
  206. package/src/text-content/__tests__/text-content.integration.ts +2 -2
  207. package/src/text-content/api.ts +2 -9
  208. package/src/text-content/handlers/by-slug.query.ts +6 -9
  209. package/src/text-content/handlers/by-tenant.query.ts +2 -2
  210. package/src/text-content/handlers/set.write.ts +7 -9
  211. package/src/text-content/seeding.ts +6 -9
  212. package/src/text-content/table.ts +2 -2
  213. package/src/text-content/web/__tests__/editor-read-only.test.tsx +31 -45
  214. package/src/text-content/web/__tests__/group-blocks.test.ts +1 -18
  215. package/src/text-content/web/client-plugin.tsx +11 -23
  216. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +10 -16
  217. package/src/tier-engine/__tests__/compose-app.test.ts +1 -1
  218. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  219. package/src/tier-engine/__tests__/resolver.integration.ts +6 -6
  220. package/src/tier-engine/__tests__/tier-engine.integration.ts +1 -1
  221. package/src/tier-engine/feature.ts +9 -16
  222. package/src/user/__tests__/seed-testing.integration.ts +10 -22
  223. package/src/user/__tests__/user-status.test.ts +1 -1
  224. package/src/user/__tests__/user.integration.ts +6 -5
  225. package/src/user/handlers/create.write.ts +5 -7
  226. package/src/user/handlers/find-for-auth.query.ts +5 -7
  227. package/src/user/schema/user.ts +2 -2
  228. package/src/user/seeding.ts +2 -3
  229. package/src/user-data-rights/__tests__/audit-log.integration.ts +24 -12
  230. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +64 -37
  231. package/src/user-data-rights/__tests__/download.integration.ts +29 -46
  232. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +35 -28
  233. package/src/user-data-rights/__tests__/export-job-schema.test.ts +2 -2
  234. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +1 -1
  235. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +11 -15
  236. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +10 -12
  237. package/src/user-data-rights/__tests__/request-export.integration.ts +23 -16
  238. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +24 -32
  239. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +142 -137
  240. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +46 -28
  241. package/src/user-data-rights/__tests__/run-user-export.integration.ts +20 -14
  242. package/src/user-data-rights/__tests__/token-helpers.test.ts +1 -1
  243. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +1 -1
  244. package/src/user-data-rights/__tests__/zip-path.test.ts +1 -1
  245. package/src/user-data-rights/audit-download.ts +3 -3
  246. package/src/user-data-rights/db/queries/export-jobs.ts +23 -0
  247. package/src/user-data-rights/db/queries/forget-cleanup.ts +13 -0
  248. package/src/user-data-rights/handlers/cancel-deletion.write.ts +28 -22
  249. package/src/user-data-rights/handlers/download-by-job.query.ts +11 -21
  250. package/src/user-data-rights/handlers/download-by-token.query.ts +20 -35
  251. package/src/user-data-rights/handlers/export-status.query.ts +19 -33
  252. package/src/user-data-rights/handlers/lift-restriction.write.ts +7 -12
  253. package/src/user-data-rights/handlers/list-download-attempts.query.ts +14 -23
  254. package/src/user-data-rights/handlers/my-audit-log.query.ts +33 -23
  255. package/src/user-data-rights/handlers/request-deletion.write.ts +15 -15
  256. package/src/user-data-rights/handlers/request-export.write.ts +7 -11
  257. package/src/user-data-rights/handlers/restrict-account.write.ts +12 -12
  258. package/src/user-data-rights/run-export-jobs.ts +20 -60
  259. package/src/user-data-rights/run-forget-cleanup.ts +19 -33
  260. package/src/user-data-rights/run-user-export.ts +4 -6
  261. package/src/user-data-rights/schema/download-attempt.ts +2 -2
  262. package/src/user-data-rights/schema/download-token.ts +2 -2
  263. package/src/user-data-rights/schema/export-job.ts +2 -3
  264. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +37 -30
  265. package/src/user-data-rights-defaults/db/queries/user-hook.ts +17 -0
  266. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +12 -27
  267. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +16 -18
  268. package/CHANGELOG.md +0 -689
@@ -9,7 +9,9 @@
9
9
  // The reference timestamp is the host row's `modified_at`, not a per-key
10
10
  // timestamp — see run-retention.ts header for the rationale.
11
11
 
12
- import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
13
15
  import {
14
16
  createEntity,
15
17
  createEntityExecutor,
@@ -25,8 +27,6 @@ import {
25
27
  unsafeCreateEntityTable,
26
28
  } from "@cosmicdrift/kumiko-framework/stack";
27
29
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
28
- import { sql } from "drizzle-orm";
29
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
30
30
  import { z } from "zod";
31
31
  import { fieldDefinitionEntity } from "../entity";
32
32
  import { createCustomFieldsFeature } from "../feature";
@@ -40,7 +40,7 @@ const propertyEntity = createEntity({
40
40
  customFields: customFieldsField(),
41
41
  },
42
42
  });
43
- const propertyTable = buildDrizzleTable("property", propertyEntity);
43
+ const propertyTable = buildEntityTable("property", propertyEntity);
44
44
 
45
45
  const propertyFeature = defineFeature("property-t15d", (r) => {
46
46
  r.entity("property", propertyEntity);
@@ -81,8 +81,8 @@ afterAll(async () => {
81
81
 
82
82
  beforeEach(async () => {
83
83
  await resetEventStore(stack);
84
- await stack.db.execute(sql`DELETE FROM read_t15d_properties`);
85
- await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
84
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_t15d_properties`);
85
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
86
86
  });
87
87
 
88
88
  async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
@@ -116,14 +116,16 @@ async function setField(entityId: string, fieldKey: string, value: unknown) {
116
116
  // older than the retention cutoff. Faster than waiting `keepFor` real
117
117
  // time and the cleanest way to drive the cron under test.
118
118
  async function backdateRow(id: string, isoOlderThan: string) {
119
- await stack.db.execute(
120
- sql`UPDATE read_t15d_properties SET modified_at = ${isoOlderThan}::timestamptz WHERE id = ${id}`,
119
+ await asRawClient(stack.db).unsafe(
120
+ `UPDATE read_t15d_properties SET modified_at = $1::timestamptz WHERE id = $2`,
121
+ [isoOlderThan, id],
121
122
  );
122
123
  }
123
124
 
124
125
  async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
125
- const rows = await stack.db.execute(
126
- sql`SELECT id, custom_fields FROM read_t15d_properties WHERE id = ${id}`,
126
+ const rows = await asRawClient(stack.db).unsafe(
127
+ `SELECT id, custom_fields FROM read_t15d_properties WHERE id = $1`,
128
+ [id],
127
129
  );
128
130
  return (rows as ReadonlyArray<Record<string, unknown>>)[0];
129
131
  }
@@ -14,7 +14,9 @@
14
14
  // * Forget strategy=delete: no-op — the host entity's own user-data-
15
15
  // rights hook handles the row delete, jsonb travels with the row.
16
16
 
17
- import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
19
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
18
20
  import {
19
21
  createEntity,
20
22
  createEntityExecutor,
@@ -32,8 +34,6 @@ import {
32
34
  type TestStack,
33
35
  unsafeCreateEntityTable,
34
36
  } from "@cosmicdrift/kumiko-framework/stack";
35
- import { sql } from "drizzle-orm";
36
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
37
37
  import { z } from "zod";
38
38
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
39
39
  import { createDataRetentionFeature } from "../../data-retention";
@@ -52,17 +52,20 @@ const propertyEntity = createEntity({
52
52
  customFields: customFieldsField(),
53
53
  },
54
54
  });
55
- const propertyTable = buildDrizzleTable("property", propertyEntity);
55
+ const propertyTable = buildEntityTable("property", propertyEntity);
56
56
 
57
57
  // Host entity gets its own EXT_USER_DATA-registration too — that's the
58
58
  // canonical setup (host bundle handles row-anonymize/delete, custom-fields
59
59
  // adds its strip-sensitive-jsonb layer on top). Both hooks fire in the
60
60
  // same cleanup-run.
61
61
  const hostExportHook: UserDataExportHook = async (ctx) => {
62
- const rows = await ctx.db.execute(sql`
62
+ const rows = await asRawClient(ctx.db).unsafe(
63
+ `
63
64
  SELECT id, name FROM read_t15c_properties
64
- WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
65
- `);
65
+ WHERE inserted_by_id = $1 AND tenant_id = $2
66
+ `,
67
+ [ctx.userId, ctx.tenantId],
68
+ );
66
69
  const list = rows as ReadonlyArray<Record<string, unknown>>;
67
70
  if (list.length === 0) return null;
68
71
  return {
@@ -73,16 +76,22 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
73
76
 
74
77
  const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
75
78
  if (strategy === "delete") {
76
- await ctx.db.execute(sql`
79
+ await asRawClient(ctx.db).unsafe(
80
+ `
77
81
  DELETE FROM read_t15c_properties
78
- WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
79
- `);
82
+ WHERE inserted_by_id = $1 AND tenant_id = $2
83
+ `,
84
+ [ctx.userId, ctx.tenantId],
85
+ );
80
86
  } else {
81
87
  // anonymize: clear owner, keep row + non-sensitive customFields
82
- await ctx.db.execute(sql`
88
+ await asRawClient(ctx.db).unsafe(
89
+ `
83
90
  UPDATE read_t15c_properties SET inserted_by_id = NULL
84
- WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
85
- `);
91
+ WHERE inserted_by_id = $1 AND tenant_id = $2
92
+ `,
93
+ [ctx.userId, ctx.tenantId],
94
+ );
86
95
  }
87
96
  };
88
97
 
@@ -143,8 +152,8 @@ afterAll(async () => {
143
152
 
144
153
  beforeEach(async () => {
145
154
  await resetEventStore(stack);
146
- await stack.db.execute(sql`DELETE FROM read_t15c_properties`);
147
- await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
155
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_t15c_properties`);
156
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
148
157
  });
149
158
 
150
159
  async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
@@ -175,8 +184,9 @@ async function setField(entityId: string, fieldKey: string, value: unknown) {
175
184
  }
176
185
 
177
186
  async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
178
- const rows = await stack.db.execute(
179
- sql`SELECT id, custom_fields FROM read_t15c_properties WHERE id = ${id}`,
187
+ const rows = await asRawClient(stack.db).unsafe(
188
+ `SELECT id, custom_fields FROM read_t15c_properties WHERE id = $1`,
189
+ [id],
180
190
  );
181
191
  const list = rows as ReadonlyArray<Record<string, unknown>>;
182
192
  return list[0];
@@ -1,6 +1,6 @@
1
- import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
3
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
- import { describe, expect, test } from "vitest";
4
4
  import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
5
5
 
6
6
  // B2 wireCustomFieldsFor: einziger Aufruf registriert MSP + postQuery-hook +
@@ -15,7 +15,7 @@ const propertyEntity = createEntity({
15
15
  },
16
16
  });
17
17
 
18
- const propertyTable = buildDrizzleTable("property", propertyEntity);
18
+ const propertyTable = buildEntityTable("property", propertyEntity);
19
19
 
20
20
  describe("wireCustomFieldsFor", () => {
21
21
  test("registers useExtension + MSP + postQuery-entity-hook + search-payload-extension", () => {
@@ -43,7 +43,7 @@ describe("wireCustomFieldsFor", () => {
43
43
  expect(feature.entityHooks.postQuery["property"]).toHaveLength(1);
44
44
 
45
45
  // 4. search-payload-extension on "property"
46
- expect(feature.searchPayloadExtensions["property"]).toHaveLength(1);
46
+ expect(feature.searchPayloadExtensions!["property"]).toHaveLength(1);
47
47
  });
48
48
 
49
49
  test("postQuery-hook flattens row.customFields onto root", async () => {
@@ -107,7 +107,7 @@ describe("wireCustomFieldsFor", () => {
107
107
  wireCustomFieldsFor(r, "property", propertyTable);
108
108
  });
109
109
 
110
- const contributor = feature.searchPayloadExtensions["property"]?.[0]?.fn;
110
+ const contributor = feature.searchPayloadExtensions!["property"]?.[0]?.fn;
111
111
  expect(contributor).toBeDefined();
112
112
  const result = await contributor?.({
113
113
  entityName: "property",
@@ -0,0 +1,16 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+
4
+ export async function selectSerializedFieldDefinition(
5
+ db: TenantDb,
6
+ tenantId: string,
7
+ entityName: string,
8
+ fieldKey: string,
9
+ ): Promise<unknown | null> {
10
+ const rows = await asRawClient(db.raw).unsafe(
11
+ "SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
12
+ [entityName, fieldKey, tenantId],
13
+ );
14
+ const first = (rows as ReadonlyArray<Record<string, unknown>>)[0];
15
+ return first ? (first["serialized_field"] ?? null) : null;
16
+ }
@@ -0,0 +1,43 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+
4
+ function quoteTable(tableName: string): string {
5
+ return `"${tableName.replace(/"/g, '""')}"`;
6
+ }
7
+
8
+ export async function setCustomFieldValue(
9
+ db: DbRunner,
10
+ tableName: string,
11
+ fieldKey: string,
12
+ valueJson: string,
13
+ aggregateId: string,
14
+ ): Promise<void> {
15
+ const tbl = quoteTable(tableName);
16
+ const escapedKey = fieldKey.replace(/'/g, "''");
17
+ await asRawClient(db).unsafe(
18
+ `UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', $1::jsonb, true) WHERE id = $2`,
19
+ [valueJson, aggregateId],
20
+ );
21
+ }
22
+
23
+ export async function clearCustomFieldKey(
24
+ db: DbRunner,
25
+ tableName: string,
26
+ fieldKey: string,
27
+ aggregateId: string,
28
+ ): Promise<void> {
29
+ const tbl = quoteTable(tableName);
30
+ await asRawClient(db).unsafe(
31
+ `UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2`,
32
+ [fieldKey, aggregateId],
33
+ );
34
+ }
35
+
36
+ export async function removeCustomFieldKeyFromAllRows(
37
+ db: DbRunner,
38
+ tableName: string,
39
+ fieldKey: string,
40
+ ): Promise<void> {
41
+ const tbl = quoteTable(tableName);
42
+ await asRawClient(db).unsafe(`UPDATE ${tbl} SET custom_fields = custom_fields - $1`, [fieldKey]);
43
+ }
@@ -0,0 +1,14 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+
4
+ export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string): Promise<number> {
5
+ const rowsResult = await asRawClient(db.raw).unsafe(
6
+ "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1",
7
+ [tenantId],
8
+ );
9
+ const rows = rowsResult as ReadonlyArray<Record<string, unknown>>;
10
+ const first = rows[0];
11
+ if (!first) return 0;
12
+ const n = first["n"];
13
+ return typeof n === "number" ? n : Number.parseInt(String(n ?? 0), 10);
14
+ }
@@ -0,0 +1,39 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+
4
+ export async function selectFieldDefinitionsWithSerialized(
5
+ db: DbRunner,
6
+ entityName: string,
7
+ tenantId: string,
8
+ ): Promise<readonly { field_key: string; serialized_field: unknown }[]> {
9
+ return asRawClient(db).unsafe(
10
+ "SELECT field_key, serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND tenant_id = $2",
11
+ [entityName, tenantId],
12
+ ) as Promise<readonly { field_key: string; serialized_field: unknown }[]>;
13
+ }
14
+
15
+ export async function selectHostRowsWithCustomFields(
16
+ db: DbRunner,
17
+ tableName: string,
18
+ tenantId: string,
19
+ ): Promise<readonly unknown[]> {
20
+ const quoted = `"${tableName.replace(/"/g, '""')}"`;
21
+ const rowsResult = await asRawClient(db).unsafe(
22
+ `SELECT id, modified_at, custom_fields FROM ${quoted} WHERE tenant_id = $1 AND custom_fields IS NOT NULL`,
23
+ [tenantId],
24
+ );
25
+ return Array.isArray(rowsResult) ? rowsResult : [];
26
+ }
27
+
28
+ export async function updateHostRowCustomFields(
29
+ db: DbRunner,
30
+ tableName: string,
31
+ customFieldsJson: string,
32
+ rowId: string,
33
+ ): Promise<void> {
34
+ const quoted = `"${tableName.replace(/"/g, '""')}"`;
35
+ await asRawClient(db).unsafe(`UPDATE ${quoted} SET custom_fields = $1::jsonb WHERE id = $2`, [
36
+ customFieldsJson,
37
+ rowId,
38
+ ]);
39
+ }
@@ -0,0 +1,54 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+
4
+ function quoteTable(tableName: string): string {
5
+ return `"${tableName.replace(/"/g, '""')}"`;
6
+ }
7
+
8
+ function quoteColumn(columnName: string): string {
9
+ return `"${columnName.replace(/"/g, '""')}"`;
10
+ }
11
+
12
+ export async function selectCustomFieldsHostRows(
13
+ db: DbRunner,
14
+ tableName: string,
15
+ userIdColumn: string,
16
+ userId: string,
17
+ tenantId: string,
18
+ ): Promise<readonly unknown[]> {
19
+ const tbl = quoteTable(tableName);
20
+ const userCol = quoteColumn(userIdColumn);
21
+ const rowsResult = await asRawClient(db).unsafe(
22
+ `SELECT id, custom_fields FROM ${tbl} WHERE ${userCol} = $1 AND tenant_id = $2`,
23
+ [userId, tenantId],
24
+ );
25
+ return Array.isArray(rowsResult) ? rowsResult : [];
26
+ }
27
+
28
+ export async function stripSensitiveCustomFieldKeys(
29
+ db: DbRunner,
30
+ tableName: string,
31
+ userIdColumn: string,
32
+ sensitiveKeys: readonly string[],
33
+ userId: string,
34
+ tenantId: string,
35
+ ): Promise<void> {
36
+ const tbl = quoteTable(tableName);
37
+ const userCol = quoteColumn(userIdColumn);
38
+ const placeholders = sensitiveKeys.map((_, i) => `$${i + 1}`).join(" - ");
39
+ await asRawClient(db).unsafe(
40
+ `UPDATE ${tbl} SET custom_fields = custom_fields - ${placeholders} WHERE ${userCol} = $${sensitiveKeys.length + 1} AND tenant_id = $${sensitiveKeys.length + 2}`,
41
+ [...sensitiveKeys, userId, tenantId],
42
+ );
43
+ }
44
+
45
+ export async function selectFieldDefinitionsForEntity(
46
+ db: DbRunner,
47
+ entityName: string,
48
+ tenantId: string,
49
+ ): Promise<readonly { field_key: string; serialized_field: unknown }[]> {
50
+ return asRawClient(db).unsafe(
51
+ "SELECT field_key, serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND tenant_id = $2",
52
+ [entityName, tenantId],
53
+ ) as Promise<readonly { field_key: string; serialized_field: unknown }[]>;
54
+ }
@@ -1,12 +1,7 @@
1
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
2
 
8
3
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
9
- import { sql } from "drizzle-orm";
4
+ import { selectSerializedFieldDefinition } from "../db/queries/field-access";
10
5
  import { parseSerializedField } from "./parse-serialized-field";
11
6
 
12
7
  export type FieldAccessCheckResult =
@@ -17,36 +12,6 @@ export type FieldAccessCheckResult =
17
12
  requiredRoles?: ReadonlyArray<string>;
18
13
  };
19
14
 
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
15
  export async function checkFieldAccessForWrite(
51
16
  db: TenantDb,
52
17
  tenantId: string,
@@ -54,16 +19,12 @@ export async function checkFieldAccessForWrite(
54
19
  fieldKey: string,
55
20
  userRoles: ReadonlyArray<string>,
56
21
  ): Promise<FieldAccessCheckResult> {
57
- const serialized = await loadSerializedField(db, tenantId, entityName, fieldKey);
22
+ const serialized = await selectSerializedFieldDefinition(db, tenantId, entityName, fieldKey);
58
23
  if (serialized === null) {
59
24
  return { ok: false, reason: "field_definition_not_found" };
60
25
  }
61
26
 
62
27
  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
28
  if (!parsed) return { ok: true };
68
29
 
69
30
  const required = parsed.fieldAccess?.write;
@@ -1,28 +1,5 @@
1
1
  // T1.5e — per-tenant fieldDefinition quota.
2
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";
3
+ // Re-exports from db/queries/quota.ts for backward-compatible import paths.
16
4
 
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
- }
5
+ export { countTenantFieldDefinitions } from "../db/queries/quota";
@@ -17,10 +17,22 @@
17
17
 
18
18
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
19
19
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
- import { getTableName, sql } from "drizzle-orm";
21
- import type { PgTable } from "drizzle-orm/pg-core";
20
+ import {
21
+ selectFieldDefinitionsWithSerialized,
22
+ selectHostRowsWithCustomFields,
23
+ updateHostRowCustomFields,
24
+ } from "./db/queries/retention";
22
25
  import { parseSerializedField } from "./lib/parse-serialized-field";
23
26
 
27
+ const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
28
+ function getTableName(table: unknown): string {
29
+ if (typeof table === "object" && table !== null) {
30
+ const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
31
+ if (typeof sym === "string") return sym;
32
+ }
33
+ throw new Error("custom-fields/run-retention: table missing kumiko:schema:Name symbol");
34
+ }
35
+
24
36
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
25
37
 
26
38
  // Lifted from data-retention/keep-for.ts because the helper isn't re-exported
@@ -48,7 +60,7 @@ export interface RunCustomFieldsRetentionOptions {
48
60
  readonly db: DbRunner;
49
61
  readonly tenantId: string;
50
62
  readonly entityName: string;
51
- readonly entityTable: PgTable;
63
+ readonly entityTable: unknown;
52
64
  /** Current time, injected for time-travel-tests. */
53
65
  readonly now: Instant;
54
66
  }
@@ -75,18 +87,13 @@ export async function runCustomFieldsRetention(
75
87
  return { rowsScanned: 0, rowsUpdated: 0, removalsByFieldKey: {} };
76
88
  }
77
89
 
78
- const tableName = sql.identifier(getTableName(opts.entityTable));
79
- const rowsResult = await opts.db.execute(sql`
80
- SELECT id, modified_at, custom_fields
81
- FROM ${tableName}
82
- WHERE tenant_id = ${opts.tenantId} AND custom_fields IS NOT NULL
83
- `);
90
+ const tableName = getTableName(opts.entityTable);
91
+ const rows = await selectHostRowsWithCustomFields(opts.db, tableName, opts.tenantId);
84
92
 
85
93
  const removalsByFieldKey: Record<string, number> = {};
86
94
  let rowsUpdated = 0;
87
95
  let rowsScanned = 0;
88
96
 
89
- const rows: ReadonlyArray<unknown> = Array.isArray(rowsResult) ? rowsResult : [];
90
97
  for (const raw of rows) {
91
98
  rowsScanned++;
92
99
  const row = asHostRow(raw);
@@ -125,11 +132,7 @@ export async function runCustomFieldsRetention(
125
132
  removalsByFieldKey[key] = (removalsByFieldKey[key] ?? 0) + 1;
126
133
  }
127
134
 
128
- await opts.db.execute(sql`
129
- UPDATE ${tableName}
130
- SET custom_fields = ${JSON.stringify(mutated)}::jsonb
131
- WHERE id = ${row.id}
132
- `);
135
+ await updateHostRowCustomFields(opts.db, tableName, JSON.stringify(mutated), row.id);
133
136
  rowsUpdated++;
134
137
  }
135
138
 
@@ -171,12 +174,7 @@ async function loadRetentionPolicies(
171
174
  tenantId: string,
172
175
  entityName: string,
173
176
  ): Promise<Map<string, RetentionPolicy>> {
174
- const rowsResult = await db.execute(sql`
175
- SELECT field_key, serialized_field
176
- FROM read_custom_field_definitions
177
- WHERE entity_name = ${entityName} AND tenant_id = ${tenantId}
178
- `);
179
- const rows: ReadonlyArray<unknown> = Array.isArray(rowsResult) ? rowsResult : [];
177
+ const rows = await selectFieldDefinitionsWithSerialized(db, entityName, tenantId);
180
178
  const out = new Map<string, RetentionPolicy>();
181
179
  for (const raw of rows) {
182
180
  // skip: see asHostRow rationale.
@@ -3,10 +3,22 @@ import {
3
3
  type FeatureRegistrar,
4
4
  type JsonbFieldDef,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
- import type { AnyColumn } from "drizzle-orm";
7
- import { eq, sql } from "drizzle-orm";
8
- import type { PgTable } from "drizzle-orm/pg-core";
9
6
  import { CUSTOM_FIELDS_EXTENSION } from "./constants";
7
+ import {
8
+ clearCustomFieldKey,
9
+ removeCustomFieldKeyFromAllRows,
10
+ setCustomFieldValue,
11
+ } from "./db/queries/projection";
12
+
13
+ const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
14
+ function getTableName(table: unknown): string {
15
+ if (typeof table === "object" && table !== null) {
16
+ const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
17
+ if (typeof sym === "string") return sym;
18
+ }
19
+ throw new Error("wire-for-entity: table missing kumiko:schema:Name symbol");
20
+ }
21
+
10
22
  import type { CustomFieldClearedPayload, CustomFieldSetPayload } from "./events";
11
23
  import { customFieldsFeature } from "./feature";
12
24
 
@@ -40,7 +52,7 @@ export function customFieldsField(): JsonbFieldDef {
40
52
  // });
41
53
  //
42
54
  // Der `entityTable`-Parameter ist die Drizzle-Table-Instance (typically
43
- // `buildDrizzleTable(name, entity)`-Output). Die Closure über `entityTable`
55
+ // `buildEntityTable(name, entity)`-Output). Die Closure über `entityTable`
44
56
  // erspart der MSP-apply-fn einen runtime-table-lookup über die Registry.
45
57
  //
46
58
  // **Was registriert wird**:
@@ -67,7 +79,7 @@ export function customFieldsField(): JsonbFieldDef {
67
79
  export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
68
80
  r: TReg,
69
81
  entityName: string,
70
- entityTable: PgTable,
82
+ entityTable: unknown,
71
83
  ): void {
72
84
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar-API method, not a React hook — false positive on the "use"-prefix heuristic.
73
85
  r.useExtension(CUSTOM_FIELDS_EXTENSION, entityName);
@@ -91,14 +103,15 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
91
103
 
92
104
  // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
93
105
  // wird angelegt (create_missing=true ist default). value muss als
94
- // jsonb-literal kommen — Drizzle sql-template stringifiziert für uns.
95
- const idCol = (entityTable as unknown as Record<string, AnyColumn>)["id"] as AnyColumn; // @cast-boundary db-row
96
- await tx
97
- .update(entityTable)
98
- .set({
99
- customFields: sql`jsonb_set(${sql.identifier("custom_fields")}, ${sql.raw(`'{${payload.fieldKey.replace(/'/g, "''")}}'`)}, ${JSON.stringify(payload.value)}::jsonb, true)`,
100
- })
101
- .where(eq(idCol, event.aggregateId));
106
+ // jsonb-literal kommen.
107
+ const tableName = getTableName(entityTable);
108
+ await setCustomFieldValue(
109
+ tx,
110
+ tableName,
111
+ payload.fieldKey,
112
+ JSON.stringify(payload.value),
113
+ event.aggregateId,
114
+ );
102
115
  },
103
116
  [clearedEventType]: async (event, tx) => {
104
117
  // skip: MSP feuert für alle aggregate-types — nur unsere host-entity
@@ -107,13 +120,8 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
107
120
  const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
108
121
 
109
122
  // jsonb minus operator (`-`) entfernt key aus jsonb-object.
110
- const idCol = (entityTable as unknown as Record<string, AnyColumn>)["id"] as AnyColumn; // @cast-boundary db-row
111
- await tx
112
- .update(entityTable)
113
- .set({
114
- customFields: sql`${sql.identifier("custom_fields")} - ${payload.fieldKey}`,
115
- })
116
- .where(eq(idCol, event.aggregateId));
123
+ const tableName = getTableName(entityTable);
124
+ await clearCustomFieldKey(tx, tableName, payload.fieldKey, event.aggregateId);
117
125
  },
118
126
  [fieldDefDeletedType]: async (event, tx) => {
119
127
  // fieldDefinition.deleted fires nur einmal pro fieldDef-delete
@@ -125,9 +133,8 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
125
133
  // ihre Rows.
126
134
  if (payload.entityName !== entityName) return;
127
135
 
128
- await tx.update(entityTable).set({
129
- customFields: sql`${sql.identifier("custom_fields")} - ${payload.fieldKey}`,
130
- });
136
+ const tableName = getTableName(entityTable);
137
+ await removeCustomFieldKeyFromAllRows(tx, tableName, payload.fieldKey);
131
138
  },
132
139
  },
133
140
  });