@jini-ai/cms 0.3.11 → 0.4.1

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 (262) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +47 -19
  3. package/dist/content-types/agent-tools.d.ts +3 -56
  4. package/dist/content-types/agent-tools.js +22 -6
  5. package/dist/content-types/cleanup.d.ts +18 -13
  6. package/dist/content-types/cleanup.js +16 -11
  7. package/dist/content-types/errors.d.ts +57 -24
  8. package/dist/content-types/errors.js +56 -39
  9. package/dist/content-types/field-defs.d.ts +8 -4
  10. package/dist/content-types/field-defs.js +19 -14
  11. package/dist/content-types/index-provisioning.d.ts +61 -47
  12. package/dist/content-types/index-provisioning.js +90 -66
  13. package/dist/content-types/index.d.ts +4 -3
  14. package/dist/content-types/lifecycle-dispatch.d.ts +11 -5
  15. package/dist/content-types/lifecycle-dispatch.js +5 -4
  16. package/dist/content-types/lifecycle.d.ts +23 -18
  17. package/dist/content-types/lifecycle.js +71 -64
  18. package/dist/content-types/repo.memory.d.ts +5 -4
  19. package/dist/content-types/repo.memory.js +3 -2
  20. package/dist/content-types/tool-registrations.d.ts +6 -8
  21. package/dist/content-types/tool-registrations.js +24 -28
  22. package/dist/content-types/types.d.ts +17 -13
  23. package/dist/content-types/types.js +11 -5
  24. package/dist/content-types/write-service.d.ts +20 -22
  25. package/dist/content-types/write-service.js +78 -75
  26. package/dist/core/authorization.d.ts +15 -0
  27. package/dist/core/authorization.js +2 -0
  28. package/dist/core/commands/change-set.d.ts +15 -7
  29. package/dist/core/commands/command.d.ts +45 -26
  30. package/dist/core/commands/command.js +73 -63
  31. package/dist/core/entity-liveness.d.ts +11 -7
  32. package/dist/core/entity-liveness.js +5 -4
  33. package/dist/core/index.d.ts +12 -5
  34. package/dist/core/index.js +10 -4
  35. package/dist/core/ports.d.ts +25 -24
  36. package/dist/core/ports.js +1 -0
  37. package/dist/core/tools/args.d.ts +49 -0
  38. package/dist/core/tools/args.js +46 -0
  39. package/dist/core/tools/index.d.ts +3 -0
  40. package/dist/core/tools/index.js +3 -0
  41. package/dist/entries/agent-tools.d.ts +1 -73
  42. package/dist/entries/agent-tools.js +19 -13
  43. package/dist/entries/errors.d.ts +28 -14
  44. package/dist/entries/errors.js +17 -11
  45. package/dist/entries/field-validation.d.ts +5 -3
  46. package/dist/entries/field-validation.js +7 -5
  47. package/dist/entries/index.d.ts +2 -2
  48. package/dist/entries/lifecycle-dispatch.d.ts +5 -2
  49. package/dist/entries/lifecycle-dispatch.js +2 -1
  50. package/dist/entries/list.d.ts +4 -2
  51. package/dist/entries/list.js +4 -3
  52. package/dist/entries/repo.memory.d.ts +12 -10
  53. package/dist/entries/repo.memory.js +11 -10
  54. package/dist/entries/tool-registrations.d.ts +6 -8
  55. package/dist/entries/tool-registrations.js +42 -41
  56. package/dist/entries/types.d.ts +0 -7
  57. package/dist/entries/write-service.d.ts +28 -29
  58. package/dist/entries/write-service.js +114 -105
  59. package/dist/http/settings/cms-adapter.d.ts +20 -0
  60. package/dist/http/settings/cms-adapter.js +124 -0
  61. package/dist/http/settings/contracts.d.ts +135 -0
  62. package/dist/http/settings/contracts.js +2 -0
  63. package/dist/http/settings/http.d.ts +10 -0
  64. package/dist/http/settings/http.js +211 -0
  65. package/dist/http/settings/index.d.ts +5 -0
  66. package/dist/http/settings/index.js +4 -0
  67. package/dist/http/settings/shared.d.ts +68 -0
  68. package/dist/http/settings/shared.js +54 -0
  69. package/dist/http/settings/stream.d.ts +24 -0
  70. package/dist/http/settings/stream.js +147 -0
  71. package/dist/media/agent-tools.d.ts +1 -66
  72. package/dist/media/agent-tools.js +5 -1
  73. package/dist/media/blob-gc-lock.d.ts +6 -2
  74. package/dist/media/blob-gc-lock.js +4 -2
  75. package/dist/media/blob-gc.d.ts +64 -51
  76. package/dist/media/blob-gc.js +62 -52
  77. package/dist/media/blob-key.d.ts +1 -1
  78. package/dist/media/content-type-sniffer.d.ts +3 -1
  79. package/dist/media/content-type-sniffer.js +1 -1
  80. package/dist/media/html-attributes.d.ts +9 -5
  81. package/dist/media/html-attributes.js +8 -6
  82. package/dist/media/image-transformer.js +1 -1
  83. package/dist/media/image-transformer.sharp.d.ts +6 -0
  84. package/dist/media/image-transformer.sharp.js +12 -6
  85. package/dist/media/import/fetch-image.d.ts +24 -0
  86. package/dist/media/import/fetch-image.js +137 -0
  87. package/dist/media/import/index.d.ts +3 -0
  88. package/dist/media/import/index.js +3 -0
  89. package/dist/media/import/ports.d.ts +38 -0
  90. package/dist/media/index.d.ts +7 -1
  91. package/dist/media/index.js +1 -0
  92. package/dist/media/media-service.d.ts +14 -10
  93. package/dist/media/media-service.js +57 -52
  94. package/dist/media/ports.d.ts +3 -2
  95. package/dist/media/rendition-service.d.ts +3 -3
  96. package/dist/media/rendition-service.js +38 -37
  97. package/dist/media/repo.memory.d.ts +18 -7
  98. package/dist/media/repo.memory.js +12 -6
  99. package/dist/media/tool-registrations.d.ts +16 -15
  100. package/dist/media/tool-registrations.js +84 -72
  101. package/dist/media/transform-lock.d.ts +22 -12
  102. package/dist/media/transform-lock.js +14 -13
  103. package/dist/media/transform-registry.d.ts +3 -3
  104. package/dist/media/transform-registry.js +19 -18
  105. package/dist/media/transform-types.d.ts +8 -3
  106. package/dist/media/transform-types.js +10 -5
  107. package/dist/media/types.d.ts +21 -4
  108. package/dist/media/types.js +15 -2
  109. package/dist/navigation/agent-tools.d.ts +1 -80
  110. package/dist/navigation/agent-tools.js +15 -3
  111. package/dist/navigation/contracts.d.ts +1 -1
  112. package/dist/navigation/index.d.ts +2 -2
  113. package/dist/navigation/menu-service.d.ts +57 -40
  114. package/dist/navigation/menu-service.js +80 -69
  115. package/dist/navigation/ports.d.ts +1 -1
  116. package/dist/navigation/read-model.d.ts +3 -1
  117. package/dist/navigation/read-model.js +2 -2
  118. package/dist/navigation/reconcile.d.ts +2 -2
  119. package/dist/navigation/reconcile.js +2 -1
  120. package/dist/navigation/repo.memory.d.ts +7 -3
  121. package/dist/navigation/repo.memory.js +4 -2
  122. package/dist/navigation/resolver.d.ts +4 -1
  123. package/dist/navigation/resolver.js +1 -1
  124. package/dist/navigation/tool-registrations.d.ts +6 -8
  125. package/dist/navigation/tool-registrations.js +44 -42
  126. package/dist/navigation/types.d.ts +1 -1
  127. package/dist/presentation/presentation.d.ts +11 -3
  128. package/dist/presentation/presentation.js +13 -6
  129. package/dist/presentation/repo.memory.d.ts +6 -2
  130. package/dist/presentation/repo.memory.js +3 -2
  131. package/dist/settings/agent-tools.d.ts +2 -75
  132. package/dist/settings/agent-tools.js +49 -45
  133. package/dist/settings/agent-value-write-tools.d.ts +22 -0
  134. package/dist/settings/agent-value-write-tools.js +154 -0
  135. package/dist/settings/agent-writable-preferences.js +8 -20
  136. package/dist/settings/agent-write-denylist.d.ts +19 -0
  137. package/dist/settings/agent-write-denylist.js +15 -0
  138. package/dist/settings/change-feed.d.ts +11 -2
  139. package/dist/settings/change-feed.js +4 -4
  140. package/dist/settings/definitions-dispatch.d.ts +8 -3
  141. package/dist/settings/definitions-dispatch.js +5 -5
  142. package/dist/settings/dictionaries/index.d.ts +5 -2
  143. package/dist/settings/dictionaries/index.js +2 -2
  144. package/dist/settings/dictionaries/settings-dialog.ar.js +0 -1
  145. package/dist/settings/dictionaries/settings-dialog.bn.js +0 -1
  146. package/dist/settings/dictionaries/settings-dialog.de.js +0 -1
  147. package/dist/settings/dictionaries/settings-dialog.es.js +0 -1
  148. package/dist/settings/dictionaries/settings-dialog.fa.js +0 -1
  149. package/dist/settings/dictionaries/settings-dialog.fr.js +0 -1
  150. package/dist/settings/dictionaries/settings-dialog.hi.js +0 -1
  151. package/dist/settings/dictionaries/settings-dialog.hu.js +0 -1
  152. package/dist/settings/dictionaries/settings-dialog.id.js +0 -1
  153. package/dist/settings/dictionaries/settings-dialog.it.js +0 -1
  154. package/dist/settings/dictionaries/settings-dialog.ja.js +0 -1
  155. package/dist/settings/dictionaries/settings-dialog.ko.js +0 -1
  156. package/dist/settings/dictionaries/settings-dialog.pl.js +0 -1
  157. package/dist/settings/dictionaries/settings-dialog.pt-BR.js +0 -1
  158. package/dist/settings/dictionaries/settings-dialog.ru.js +0 -1
  159. package/dist/settings/dictionaries/settings-dialog.th.js +0 -1
  160. package/dist/settings/dictionaries/settings-dialog.tr.js +0 -1
  161. package/dist/settings/dictionaries/settings-dialog.uk.js +0 -1
  162. package/dist/settings/dictionaries/settings-dialog.ur.js +0 -1
  163. package/dist/settings/dictionaries/settings-dialog.zh-CN.js +0 -1
  164. package/dist/settings/dictionaries/settings-dialog.zh-TW.js +0 -1
  165. package/dist/settings/ensure-definitions.d.ts +5 -5
  166. package/dist/settings/errors.d.ts +7 -1
  167. package/dist/settings/errors.js +7 -1
  168. package/dist/settings/index.d.ts +4 -1
  169. package/dist/settings/index.js +1 -0
  170. package/dist/settings/ports.d.ts +6 -5
  171. package/dist/settings/principal-lookup.d.ts +9 -0
  172. package/dist/settings/principal-lookup.js +2 -0
  173. package/dist/settings/purge-service.d.ts +5 -4
  174. package/dist/settings/purge-service.js +3 -2
  175. package/dist/settings/settings.d.ts +22 -7
  176. package/dist/settings/settings.js +47 -26
  177. package/dist/settings/tool-registrations.d.ts +27 -32
  178. package/dist/settings/tool-registrations.js +66 -56
  179. package/dist/settings/types.d.ts +1 -1
  180. package/dist/settings/ui-tab-definitions.d.ts +1 -1
  181. package/dist/settings/write-service.d.ts +32 -26
  182. package/dist/settings/write-service.js +26 -20
  183. package/dist/taxonomy/agent-tools.d.ts +1 -66
  184. package/dist/taxonomy/agent-tools.js +25 -15
  185. package/dist/taxonomy/content-lookup.js +1 -1
  186. package/dist/taxonomy/index.d.ts +2 -2
  187. package/dist/taxonomy/merge-term.d.ts +7 -4
  188. package/dist/taxonomy/merge-term.js +7 -5
  189. package/dist/taxonomy/repo.memory.d.ts +42 -19
  190. package/dist/taxonomy/repo.memory.js +14 -12
  191. package/dist/taxonomy/validation-chain.d.ts +39 -18
  192. package/dist/taxonomy/validation-chain.js +35 -25
  193. package/dist/taxonomy/write-service.d.ts +103 -44
  194. package/dist/taxonomy/write-service.js +221 -179
  195. package/dist/trash/adapters/record-store.d.ts +52 -0
  196. package/dist/trash/adapters/record-store.js +36 -0
  197. package/dist/trash/cursor.d.ts +20 -0
  198. package/dist/trash/cursor.js +36 -0
  199. package/dist/trash/follow-ups.d.ts +76 -0
  200. package/dist/trash/follow-ups.js +46 -0
  201. package/dist/trash/index.d.ts +8 -0
  202. package/dist/trash/index.js +8 -0
  203. package/dist/trash/ports.d.ts +308 -0
  204. package/dist/trash/ports.js +2 -0
  205. package/dist/trash/repo.memory.d.ts +50 -0
  206. package/dist/trash/repo.memory.js +114 -0
  207. package/dist/trash/sweeper.d.ts +77 -0
  208. package/dist/trash/sweeper.js +127 -0
  209. package/dist/trash/write-service.d.ts +100 -0
  210. package/dist/trash/write-service.js +242 -0
  211. package/dist/workspace/agent-tools.d.ts +1 -59
  212. package/dist/workspace/agent-tools.js +27 -12
  213. package/dist/workspace/create.d.ts +31 -6
  214. package/dist/workspace/create.js +18 -5
  215. package/dist/workspace/delete.d.ts +18 -13
  216. package/dist/workspace/delete.js +20 -12
  217. package/dist/workspace/index.d.ts +1 -1
  218. package/dist/workspace/repo.memory.d.ts +19 -4
  219. package/dist/workspace/repo.memory.js +26 -4
  220. package/dist/workspace/tool-registrations.d.ts +2 -2
  221. package/dist/workspace/tool-registrations.js +13 -13
  222. package/dist/workspace/update.d.ts +6 -5
  223. package/dist/workspace/update.js +9 -8
  224. package/package.json +44 -38
  225. package/dist/core/tools/registration-kit.d.ts +0 -346
  226. package/dist/core/tools/registration-kit.js +0 -426
  227. package/dist/identity/admin-crud-service.d.ts +0 -280
  228. package/dist/identity/admin-crud-service.js +0 -468
  229. package/dist/identity/agent-tool-input.d.ts +0 -58
  230. package/dist/identity/agent-tool-input.js +0 -126
  231. package/dist/identity/agent-tools.d.ts +0 -101
  232. package/dist/identity/agent-tools.js +0 -290
  233. package/dist/identity/auth-service.d.ts +0 -118
  234. package/dist/identity/auth-service.js +0 -170
  235. package/dist/identity/authorize.d.ts +0 -90
  236. package/dist/identity/authorize.js +0 -94
  237. package/dist/identity/grant-service.d.ts +0 -198
  238. package/dist/identity/grant-service.js +0 -361
  239. package/dist/identity/hasher.d.ts +0 -39
  240. package/dist/identity/hasher.js +0 -39
  241. package/dist/identity/index.d.ts +0 -62
  242. package/dist/identity/index.js +0 -54
  243. package/dist/identity/password-policy.d.ts +0 -57
  244. package/dist/identity/password-policy.js +0 -66
  245. package/dist/identity/permission-migrations.d.ts +0 -86
  246. package/dist/identity/permission-migrations.js +0 -67
  247. package/dist/identity/permissions.d.ts +0 -35
  248. package/dist/identity/permissions.js +0 -603
  249. package/dist/identity/ports.d.ts +0 -201
  250. package/dist/identity/repo.memory.d.ts +0 -164
  251. package/dist/identity/repo.memory.js +0 -224
  252. package/dist/identity/seed.d.ts +0 -54
  253. package/dist/identity/seed.js +0 -429
  254. package/dist/identity/tool-registrations.d.ts +0 -50
  255. package/dist/identity/tool-registrations.js +0 -419
  256. package/dist/identity/types.d.ts +0 -185
  257. package/dist/identity/types.js +0 -64
  258. package/dist/identity/username.d.ts +0 -12
  259. package/dist/identity/username.js +0 -14
  260. package/dist/server/index.d.ts +0 -28
  261. package/dist/server/index.js +0 -28
  262. /package/dist/{identity → media/import}/ports.js +0 -0
@@ -1,3 +1,4 @@
1
+ import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
1
2
  import { ForbiddenError } from "../core/commands/command.js";
2
3
  import { validateContentJoin, validateHierarchyAssignment, wouldCreateCycle } from "./validation-chain.js";
3
4
  /** The hardcoded post/page taxonomy allow-list (permanent, see
@@ -7,24 +8,28 @@ import { validateContentJoin, validateHierarchyAssignment, wouldCreateCycle } fr
7
8
  * an intentionally parallel (not layered) per-content-type policy carried by a host's own
8
9
  * `content_types` registry. */
9
10
  export const TAXONOMY_ALLOWED_CONTENT_TYPES = new Set(["post", "page"]);
10
- export function isContentTypeOnAllowList(contentType) {
11
+ export function isContentTypeOnAllowList(requiredArgs, optionalArgs = {}) {
12
+ const { contentType } = requiredArgs;
11
13
  return TAXONOMY_ALLOWED_CONTENT_TYPES.has(contentType);
12
14
  }
13
15
  export class TaxonomyRecordNotFoundError extends Error {
14
- constructor(message) {
16
+ constructor(requiredArgs, optionalArgs = {}) {
17
+ const { message } = requiredArgs;
15
18
  super(message);
16
19
  this.name = "TaxonomyRecordNotFoundError";
17
20
  }
18
21
  }
19
22
  export class TermRecordNotFoundError extends Error {
20
- constructor(message) {
23
+ constructor(requiredArgs, optionalArgs = {}) {
24
+ const { message } = requiredArgs;
21
25
  super(message);
22
26
  this.name = "TermRecordNotFoundError";
23
27
  }
24
28
  }
25
29
  /** Finding 1 fix — the target of an `assignTerms` call does not resolve to any real content row. */
26
30
  export class ContentRecordNotFoundError extends Error {
27
- constructor(message) {
31
+ constructor(requiredArgs, optionalArgs = {}) {
32
+ const { message } = requiredArgs;
28
33
  super(message);
29
34
  this.name = "ContentRecordNotFoundError";
30
35
  }
@@ -38,7 +43,8 @@ export class ContentRecordNotFoundError extends Error {
38
43
  * "catch the one it actually called into" rule.
39
44
  */
40
45
  export class TaxonomyVersionConflictError extends Error {
41
- constructor(message) {
46
+ constructor(requiredArgs, optionalArgs = {}) {
47
+ const { message } = requiredArgs;
42
48
  super(message);
43
49
  this.name = "TaxonomyVersionConflictError";
44
50
  }
@@ -48,7 +54,11 @@ export class TaxonomyVersionConflictError extends Error {
48
54
  async function authorizeTaxonomyManage(deps, principalId) {
49
55
  const result = await deps.authorize({ principalId, permission: "admin.taxonomy.manage" });
50
56
  if (!result.allowed) {
51
- throw new ForbiddenError(`principal '${principalId}' is not authorized for 'admin.taxonomy.manage' (${result.reason})`, "admin.taxonomy.manage", result.reason);
57
+ throw new ForbiddenError({
58
+ message: `principal '${principalId}' is not authorized for 'admin.taxonomy.manage' (${result.reason})`,
59
+ permission: "admin.taxonomy.manage",
60
+ reason: result.reason,
61
+ });
52
62
  }
53
63
  }
54
64
  /** Creates a taxonomy row (hierarchical=true -> "category"-shaped, false -> "tag"-shaped — same
@@ -56,7 +66,7 @@ async function authorizeTaxonomyManage(deps, principalId) {
56
66
  export async function createTaxonomy(required, _optional = {}) {
57
67
  const { deps, principalId, name, hierarchical } = required;
58
68
  await authorizeTaxonomyManage(deps, principalId);
59
- const now = deps.clock.nowIso();
69
+ const now = kernelNowIso({ clock: deps.clock });
60
70
  const taxonomy = {
61
71
  id: deps.idGen.newId(),
62
72
  name,
@@ -73,25 +83,28 @@ export async function createTaxonomy(required, _optional = {}) {
73
83
  actorId: principalId,
74
84
  recordedAt: now,
75
85
  });
76
- await deps.stampWatermark();
77
- await deps.outbox.enqueue({ name: "taxonomy.created", taxonomyId: taxonomy.id, actorId: principalId, occurredAt: now });
86
+ await deps.stampWatermark({});
87
+ await deps.outbox.enqueue({ event: { name: "taxonomy.created", taxonomyId: taxonomy.id, actorId: principalId, occurredAt: now } });
78
88
  return taxonomy;
79
89
  }
80
- /** AC-03/AC-13/EC-04 — validates `parentId` via `validateHierarchyAssignment` before writing. A
90
+ /** — validates `parentId` via `validateHierarchyAssignment` before writing. A
81
91
  * freshly-created term has no descendants yet, so it structurally cannot be a cycle source; the
82
- * cycle check is therefore always a no-op (`() => false`) here, unlike a reparent of an existing
83
- * term (not yet built — no certified test in this slice exercises it). */
84
- export async function createTerm(required, _optional = {}) {
85
- const { deps, principalId, taxonomyId, name, parentId } = required;
92
+ * cycle check is therefore always a no-op (` => false`) here, unlike a reparent of an existing
93
+ * term (not yet built — no certified test in this slice exercises it).
94
+ * See docs/decisions/DR-005-ordered-taxonomy-validation.md.
95
+ */
96
+ export async function createTerm(required, optional = {}) {
97
+ const { deps, principalId, taxonomyId, name } = required;
98
+ const { parentId } = optional;
86
99
  await authorizeTaxonomyManage(deps, principalId);
87
- const taxonomy = await deps.taxonomies.findById(taxonomyId);
100
+ const taxonomy = await deps.taxonomies.findById({ id: taxonomyId });
88
101
  if (!taxonomy) {
89
- throw new TaxonomyRecordNotFoundError(`taxonomy '${taxonomyId}' was not found`);
102
+ throw new TaxonomyRecordNotFoundError({ message: `taxonomy '${taxonomyId}' was not found` });
90
103
  }
91
104
  const candidateParentId = parentId ?? null;
92
105
  let resolvedParent = "not-applicable";
93
106
  if (candidateParentId !== null) {
94
- const parentTerm = await deps.terms.findById(candidateParentId);
107
+ const parentTerm = await deps.terms.findById({ id: candidateParentId });
95
108
  resolvedParent = parentTerm ? { id: parentTerm.id, taxonomyId: parentTerm.taxonomyId } : null;
96
109
  }
97
110
  validateHierarchyAssignment({
@@ -102,7 +115,7 @@ export async function createTerm(required, _optional = {}) {
102
115
  wouldCreateCycle: () => false,
103
116
  termId: "__new__",
104
117
  });
105
- const now = deps.clock.nowIso();
118
+ const now = kernelNowIso({ clock: deps.clock });
106
119
  const term = {
107
120
  id: deps.idGen.newId(),
108
121
  taxonomyId,
@@ -114,8 +127,8 @@ export async function createTerm(required, _optional = {}) {
114
127
  };
115
128
  await deps.terms.insert(term);
116
129
  await deps.revisions.insert({ taxonomyId, op: "create", previousState: null, actorId: principalId, recordedAt: now });
117
- await deps.stampWatermark();
118
- await deps.outbox.enqueue({ name: "taxonomy.term_created", termId: term.id, actorId: principalId, occurredAt: now });
130
+ await deps.stampWatermark({});
131
+ await deps.outbox.enqueue({ event: { name: "taxonomy.term_created", termId: term.id, actorId: principalId, occurredAt: now } });
119
132
  return term;
120
133
  }
121
134
  /**
@@ -131,21 +144,21 @@ export async function createTerm(required, _optional = {}) {
131
144
  export async function importTaxonomy(required, _optional = {}) {
132
145
  const { deps, principalId, id, name, hierarchical, expectedVersion } = required;
133
146
  await authorizeTaxonomyManage(deps, principalId);
134
- const existing = await deps.taxonomies.findByIdFull(id);
147
+ const existing = await deps.taxonomies.findByIdFull({ id: id });
135
148
  if (expectedVersion === undefined) {
136
149
  if (existing) {
137
- throw new TaxonomyVersionConflictError(`taxonomy '${id}' already exists, but no expectedVersion was supplied for import`);
150
+ throw new TaxonomyVersionConflictError({ message: `taxonomy '${id}' already exists, but no expectedVersion was supplied for import` });
138
151
  }
139
152
  }
140
153
  else {
141
154
  if (!existing) {
142
- throw new TaxonomyVersionConflictError(`expected version ${expectedVersion} for taxonomy '${id}', but no such taxonomy exists`);
155
+ throw new TaxonomyVersionConflictError({ message: `expected version ${expectedVersion} for taxonomy '${id}', but no such taxonomy exists` });
143
156
  }
144
157
  if (existing.version !== expectedVersion) {
145
- throw new TaxonomyVersionConflictError(`expected version ${expectedVersion} for taxonomy '${id}', found ${existing.version}`);
158
+ throw new TaxonomyVersionConflictError({ message: `expected version ${expectedVersion} for taxonomy '${id}', found ${existing.version}` });
146
159
  }
147
160
  }
148
- const now = deps.clock.nowIso();
161
+ const now = kernelNowIso({ clock: deps.clock });
149
162
  const taxonomy = {
150
163
  id,
151
164
  name,
@@ -167,8 +180,8 @@ export async function importTaxonomy(required, _optional = {}) {
167
180
  actorId: principalId,
168
181
  recordedAt: now,
169
182
  });
170
- await deps.stampWatermark();
171
- await deps.outbox.enqueue({ name: "taxonomy.imported", taxonomyId: taxonomy.id, actorId: principalId, occurredAt: now });
183
+ await deps.stampWatermark({});
184
+ await deps.outbox.enqueue({ event: { name: "taxonomy.imported", taxonomyId: taxonomy.id, actorId: principalId, occurredAt: now } });
172
185
  return taxonomy;
173
186
  }
174
187
  /**
@@ -183,7 +196,7 @@ async function loadAncestorChain(terms, startId) {
183
196
  const chain = new Map();
184
197
  let current = startId;
185
198
  while (current !== null && !chain.has(current)) {
186
- const parentId = (await terms.findByIdFull(current))?.parentId ?? null;
199
+ const parentId = (await terms.findByIdFull({ id: current }))?.parentId ?? null;
187
200
  chain.set(current, parentId);
188
201
  current = parentId;
189
202
  }
@@ -202,35 +215,36 @@ async function loadAncestorChain(terms, startId) {
202
215
  * @complexity O(d) — one taxonomy lookup, one id lookup, one optional parent lookup, `d` ancestor
203
216
  * lookups (the candidate parent's depth), one insert-or-update, one revision append.
204
217
  */
205
- export async function importTerm(required, _optional = {}) {
206
- const { deps, principalId, id, taxonomyId, name, parentId, expectedVersion } = required;
218
+ export async function importTerm(required, optional = {}) {
219
+ const { deps, principalId, id, taxonomyId, name, expectedVersion } = required;
220
+ const { parentId } = optional;
207
221
  await authorizeTaxonomyManage(deps, principalId);
208
- const taxonomy = await deps.taxonomies.findById(taxonomyId);
222
+ const taxonomy = await deps.taxonomies.findById({ id: taxonomyId });
209
223
  if (!taxonomy) {
210
- throw new TaxonomyRecordNotFoundError(`taxonomy '${taxonomyId}' was not found`);
224
+ throw new TaxonomyRecordNotFoundError({ message: `taxonomy '${taxonomyId}' was not found` });
211
225
  }
212
- const existing = await deps.terms.findByIdFull(id);
226
+ const existing = await deps.terms.findByIdFull({ id: id });
213
227
  if (expectedVersion === undefined) {
214
228
  if (existing) {
215
- throw new TaxonomyVersionConflictError(`term '${id}' already exists, but no expectedVersion was supplied for import`);
229
+ throw new TaxonomyVersionConflictError({ message: `term '${id}' already exists, but no expectedVersion was supplied for import` });
216
230
  }
217
231
  }
218
232
  else {
219
233
  if (!existing) {
220
- throw new TaxonomyVersionConflictError(`expected version ${expectedVersion} for term '${id}', but no such term exists`);
234
+ throw new TaxonomyVersionConflictError({ message: `expected version ${expectedVersion} for term '${id}', but no such term exists` });
221
235
  }
222
236
  if (existing.version !== expectedVersion) {
223
- throw new TaxonomyVersionConflictError(`expected version ${expectedVersion} for term '${id}', found ${existing.version}`);
237
+ throw new TaxonomyVersionConflictError({ message: `expected version ${expectedVersion} for term '${id}', found ${existing.version}` });
224
238
  }
225
239
  // A cross-taxonomy move would leave the term's children parented across taxonomies.
226
240
  if (existing.taxonomyId !== taxonomyId) {
227
- throw new TaxonomyVersionConflictError(`term '${id}' belongs to taxonomy '${existing.taxonomyId}', not '${taxonomyId}'; an import cannot move a term between taxonomies`);
241
+ throw new TaxonomyVersionConflictError({ message: `term '${id}' belongs to taxonomy '${existing.taxonomyId}', not '${taxonomyId}'; an import cannot move a term between taxonomies` });
228
242
  }
229
243
  }
230
244
  const candidateParentId = parentId ?? null;
231
245
  let resolvedParent = "not-applicable";
232
246
  if (candidateParentId !== null) {
233
- const parentTerm = await deps.terms.findById(candidateParentId);
247
+ const parentTerm = await deps.terms.findById({ id: candidateParentId });
234
248
  resolvedParent = parentTerm ? { id: parentTerm.id, taxonomyId: parentTerm.taxonomyId } : null;
235
249
  }
236
250
  const ancestors = candidateParentId === null ? new Map() : await loadAncestorChain(deps.terms, candidateParentId);
@@ -239,10 +253,10 @@ export async function importTerm(required, _optional = {}) {
239
253
  taxonomyIsHierarchical: taxonomy.hierarchical,
240
254
  candidateParentId,
241
255
  resolvedParent,
242
- wouldCreateCycle: (parent) => wouldCreateCycle({ termId: id, candidateParentId: parent, tree: { getParentId: (termId) => ancestors.get(termId) ?? null } }),
256
+ wouldCreateCycle: ({ candidateParentId }) => wouldCreateCycle({ termId: id, candidateParentId, tree: { getParentId: ({ termId }) => ancestors.get(termId) ?? null } }),
243
257
  termId: existing ? id : "__new__",
244
258
  });
245
- const now = deps.clock.nowIso();
259
+ const now = kernelNowIso({ clock: deps.clock });
246
260
  const term = {
247
261
  id,
248
262
  taxonomyId,
@@ -265,44 +279,53 @@ export async function importTerm(required, _optional = {}) {
265
279
  actorId: principalId,
266
280
  recordedAt: now,
267
281
  });
268
- await deps.stampWatermark();
269
- await deps.outbox.enqueue({ name: "taxonomy.term_imported", termId: term.id, actorId: principalId, occurredAt: now });
282
+ await deps.stampWatermark({});
283
+ await deps.outbox.enqueue({ event: { name: "taxonomy.term_imported", termId: term.id, actorId: principalId, occurredAt: now } });
270
284
  return term;
271
285
  }
272
- /** AC-15/AC-19/AC-25/REQ-12/REQ-17 — same-tx rename + revision (carrying the pre-rename state) +
273
- * watermark + outbox. `authorize()` runs before the term lookup, so a denied caller produces zero
274
- * side effects of any kind. */
286
+ /** — same-tx rename + revision (carrying the pre-rename state) +
287
+ * watermark + outbox. `authorize` runs before the term lookup, so a denied caller produces zero
288
+ * side effects of any kind. A missing transaction capability fails closed before any lookup
289
+ * or write; a caller must supply a real shared transaction for durable atomicity.
290
+ * See docs/decisions/DR-005-ordered-taxonomy-validation.md.
291
+ */
275
292
  export async function renameTerm(required, _optional = {}) {
276
293
  const { deps, principalId, termId, newName } = required;
277
294
  await authorizeTaxonomyManage(deps, principalId);
278
- const current = await deps.terms.findById(termId);
279
- if (!current) {
280
- throw new TermRecordNotFoundError(`term '${termId}' was not found`);
281
- }
282
- const now = deps.clock.nowIso();
283
- const updated = {
284
- id: current.id,
285
- taxonomyId: current.taxonomyId,
286
- parentId: current.parentId ?? null,
287
- name: newName,
288
- status: current.status ?? "active",
289
- updatedAt: now,
290
- version: (current.version ?? 1) + 1,
291
- };
292
- await deps.terms.update(updated);
293
- await deps.revisions.insert({
294
- taxonomyId: current.taxonomyId,
295
- op: "rename",
296
- previousState: { name: current.name },
297
- actorId: principalId,
298
- recordedAt: now,
299
- });
300
- await deps.stampWatermark();
301
- await deps.outbox.enqueue({ name: "taxonomy.term_renamed", termId, actorId: principalId, occurredAt: now });
302
- return updated;
295
+ // The narrow taxonomy repo stays independent; the host binds the shared transaction explicitly.
296
+ const transaction = deps.transaction;
297
+ if (!transaction)
298
+ throw new Error("term rename requires a transaction port");
299
+ return transaction({ fn: async () => {
300
+ const current = await deps.terms.findById({ id: termId });
301
+ if (!current) {
302
+ throw new TermRecordNotFoundError({ message: `term '${termId}' was not found` });
303
+ }
304
+ const now = kernelNowIso({ clock: deps.clock });
305
+ const updated = {
306
+ id: current.id,
307
+ taxonomyId: current.taxonomyId,
308
+ parentId: current.parentId ?? null,
309
+ name: newName,
310
+ status: current.status ?? "active",
311
+ updatedAt: now,
312
+ version: (current.version ?? 1) + 1,
313
+ };
314
+ await deps.terms.update(updated);
315
+ await deps.revisions.insert({
316
+ taxonomyId: current.taxonomyId,
317
+ op: "rename",
318
+ previousState: { name: current.name },
319
+ actorId: principalId,
320
+ recordedAt: now,
321
+ });
322
+ await deps.stampWatermark({});
323
+ await deps.outbox.enqueue({ event: { name: "taxonomy.term_renamed", termId, actorId: principalId, occurredAt: now } });
324
+ return updated;
325
+ } });
303
326
  }
304
- /** AC-17/AC-20/INV-05/REQ-13/REQ-14 — upserts every `entry_terms` row (idempotent on-conflict per
305
- * `entry_terms_unique`, EC-09), then stamps the watermark and enqueues the outbox event exactly
327
+ /** — upserts every `entry_terms` row (idempotent on-conflict per
328
+ * `entry_terms_unique`), then stamps the watermark and enqueues the outbox event exactly
306
329
  * ONCE per call regardless of `termIds.length` — never once per term. Never produces a
307
330
  * `taxonomy_revisions` row (see this file's header for the disclosed narrowing this implements).
308
331
  *
@@ -313,6 +336,7 @@ export async function renameTerm(required, _optional = {}) {
313
336
  * Validation runs for ALL termIds before ANY write, so a failure partway through never leaves a
314
337
  * partial assignment. Content is resolved once per call (not once per term) since every term in
315
338
  * one call shares the same `(contentType, contentId)` target.
339
+ * See docs/decisions/DR-005-ordered-taxonomy-validation.md.
316
340
  */
317
341
  /**
318
342
  * A1 (taxonomy plan) — shared by `assignTerms` and `unassignTerms`: resolves whether
@@ -322,14 +346,15 @@ export async function renameTerm(required, _optional = {}) {
322
346
  * re-derived copy that could drift.
323
347
  *
324
348
  * `post`/`page` keep today's behavior exactly: `isPostOrPage` short-circuits before
325
- * `contentTypeTaxonomyPolicy` is ever consulted (AC-08). A non-post/page `contentType` is
349
+ * `contentTypeTaxonomyPolicy` is ever consulted. A non-post/page `contentType` is
326
350
  * resolvable only when a policy is wired AND that policy doesn't return `null` (not applicable) —
327
351
  * an unknown `contentType` (no policy, or a policy returning `null`) stays unresolvable, so it
328
352
  * still fails at `validateContentJoin`'s allow-list check (`TaxonomyNotApplicableError`), not a
329
353
  * `ContentRecordNotFoundError` — this is what keeps `"product"` a 400, not a 404.
354
+ * See docs/decisions/DR-005-ordered-taxonomy-validation.md.
330
355
  */
331
356
  async function validateAssignmentTarget(deps, contentType, contentId, termIds) {
332
- const isPostOrPage = isContentTypeOnAllowList(contentType);
357
+ const isPostOrPage = isContentTypeOnAllowList({ contentType: contentType });
333
358
  const policy = isPostOrPage
334
359
  ? null
335
360
  : deps.contentTypeTaxonomyPolicy
@@ -338,12 +363,12 @@ async function validateAssignmentTarget(deps, contentType, contentId, termIds) {
338
363
  const isResolvable = isPostOrPage || policy !== null;
339
364
  const content = isResolvable ? await deps.contentLookup.resolve({ contentType, contentId }) : null;
340
365
  if (isResolvable && !content) {
341
- throw new ContentRecordNotFoundError(`content '${contentType}:${contentId}' was not found`);
366
+ throw new ContentRecordNotFoundError({ message: `content '${contentType}:${contentId}' was not found` });
342
367
  }
343
368
  for (const termId of termIds) {
344
- const term = await deps.terms.findById(termId);
369
+ const term = await deps.terms.findById({ id: termId });
345
370
  if (!term) {
346
- throw new TermRecordNotFoundError(`term '${termId}' was not found`);
371
+ throw new TermRecordNotFoundError({ message: `term '${termId}' was not found` });
347
372
  }
348
373
  const isOnAllowList = isPostOrPage || policy === "all" || (policy instanceof Set && policy.has(term.taxonomyId));
349
374
  validateContentJoin({
@@ -364,28 +389,30 @@ export async function assignTerms(required, _optional = {}) {
364
389
  const { deps, principalId, contentType, contentId, termIds } = required;
365
390
  await authorizeTaxonomyManage(deps, principalId);
366
391
  await validateAssignmentTarget(deps, contentType, contentId, termIds);
367
- const now = deps.clock.nowIso();
392
+ const now = kernelNowIso({ clock: deps.clock });
368
393
  for (const termId of termIds) {
369
394
  await deps.entryTerms.upsert({ contentType, contentId, termId, addedAt: now });
370
395
  }
371
- await deps.stampWatermark();
372
- await deps.outbox.enqueue({
373
- name: "taxonomy.terms_assigned",
374
- contentType,
375
- contentId,
376
- termIds,
377
- actorId: principalId,
378
- occurredAt: now,
379
- });
396
+ await deps.stampWatermark({});
397
+ await deps.outbox.enqueue({ event: {
398
+ name: "taxonomy.terms_assigned",
399
+ contentType,
400
+ contentId,
401
+ termIds,
402
+ actorId: principalId,
403
+ occurredAt: now,
404
+ } });
380
405
  }
381
- /** SPEC-018 REQ-07/REQ-13/REQ-14/REQ-17/REQ-22 — spec debt this codebase never built (the spec
406
+ /** — spec debt this codebase never built (the spec
382
407
  * always named "assign/unassign tools"; only `assignTerms` shipped). Same
383
408
  * authorize -> validateAssignmentTarget -> write -> stamp -> outbox chokepoint as `assignTerms`,
384
- * and — like `assignTerms` — never produces a `taxonomy_revisions` row (REQ-13/INV-05's disclosed
409
+ * and — like `assignTerms` — never produces a `taxonomy_revisions` row ( disclosed
385
410
  * narrowing applies equally to removal). Idempotent: unassigning a row that isn't there is a no-op,
386
- * not an error (`remove()`'s 0-vs-1 return is intentionally not surfaced to the caller). Chosen over
411
+ * not an error (`remove`'s 0-vs-1 return is intentionally not surfaced to the caller). Chosen over
387
412
  * a "set terms" replace call — see the taxonomy plan's "What the docs settle" #3 for why replace
388
- * risks silently erasing a concurrent assignment. */
413
+ * risks silently erasing a concurrent assignment.
414
+ * See docs/decisions/DR-005-ordered-taxonomy-validation.md.
415
+ */
389
416
  export async function unassignTerms(required, _optional = {}) {
390
417
  const { deps, principalId, contentType, contentId, termIds } = required;
391
418
  await authorizeTaxonomyManage(deps, principalId);
@@ -393,36 +420,49 @@ export async function unassignTerms(required, _optional = {}) {
393
420
  for (const termId of termIds) {
394
421
  await deps.entryTerms.remove({ contentType, contentId, termId });
395
422
  }
396
- const now = deps.clock.nowIso();
397
- await deps.stampWatermark();
398
- await deps.outbox.enqueue({
399
- name: "taxonomy.terms_unassigned",
400
- contentType,
401
- contentId,
402
- termIds,
403
- actorId: principalId,
404
- occurredAt: now,
405
- });
423
+ const now = kernelNowIso({ clock: deps.clock });
424
+ await deps.stampWatermark({});
425
+ await deps.outbox.enqueue({ event: {
426
+ name: "taxonomy.terms_unassigned",
427
+ contentType,
428
+ contentId,
429
+ termIds,
430
+ actorId: principalId,
431
+ occurredAt: now,
432
+ } });
406
433
  }
407
434
  /** Content-deletion event subscriber. Best-effort (a missed event leaves an orphaned
408
435
  * `entry_terms` row that is inert on read; a periodic/boot reconciliation sweep — modeled by
409
436
  * re-invoking this same function for the orphan — is the backstop, never a hard failure). A
410
437
  * no-op for content with no
411
- * assigned terms, never an error. */
412
- export async function onContentDeleted(required, _optional = {}) {
438
+ * assigned terms, never an error. Repository failures are warnings through the optional
439
+ * logger port; logger failure also cannot turn cleanup into a hard failure. Hosts should
440
+ * wire the logger for diagnostics; legacy callers without it remain silent. */
441
+ export async function onContentDeleted(required, optional = {}) {
413
442
  const { event, entryTerms } = required;
414
- await entryTerms.deleteByContent({
415
- workspaceId: event.workspaceId,
416
- contentType: event.contentType,
417
- contentId: event.contentId,
418
- });
443
+ try {
444
+ await entryTerms.deleteByContent({
445
+ workspaceId: event.workspaceId,
446
+ contentType: event.contentType,
447
+ contentId: event.contentId,
448
+ });
449
+ }
450
+ catch (error) {
451
+ try {
452
+ await optional.logger?.warn({ message: "content-deletion taxonomy cleanup failed", error, event });
453
+ }
454
+ catch {
455
+ // Cleanup is best effort even when its diagnostic sink is unavailable.
456
+ }
457
+ }
419
458
  }
420
459
  /** `deleteTerm`/`deleteTaxonomy` refuse rather than silently orphan when content is still
421
460
  * assigned. Carries `assignedCount` so a caller (route/UI) can render "N items are still
422
461
  * assigned" instead of a generic failure. */
423
462
  export class TermHasAssignedContentError extends Error {
424
463
  assignedCount;
425
- constructor(message, assignedCount) {
464
+ constructor(requiredArgs, _optionalArgs = {}) {
465
+ const { message, assignedCount } = requiredArgs;
426
466
  super(message);
427
467
  this.assignedCount = assignedCount;
428
468
  this.name = "TermHasAssignedContentError";
@@ -432,7 +472,8 @@ export class TermHasAssignedContentError extends Error {
432
472
  * `childCount` for the same reason `TermHasAssignedContentError` carries `assignedCount`. */
433
473
  export class TermHasChildTermsError extends Error {
434
474
  childCount;
435
- constructor(message, childCount) {
475
+ constructor(requiredArgs, _optionalArgs = {}) {
476
+ const { message, childCount } = requiredArgs;
436
477
  super(message);
437
478
  this.childCount = childCount;
438
479
  this.name = "TermHasChildTermsError";
@@ -442,7 +483,8 @@ export class TermHasChildTermsError extends Error {
442
483
  * term, not a single term's count. */
443
484
  export class TaxonomyHasAssignedContentError extends Error {
444
485
  assignedCount;
445
- constructor(message, assignedCount) {
486
+ constructor(requiredArgs, _optionalArgs = {}) {
487
+ const { message, assignedCount } = requiredArgs;
446
488
  super(message);
447
489
  this.assignedCount = assignedCount;
448
490
  this.name = "TaxonomyHasAssignedContentError";
@@ -472,38 +514,38 @@ export class TaxonomyHasAssignedContentError extends Error {
472
514
  export async function deleteTerm(required, _optional = {}) {
473
515
  const { deps, principalId, termId } = required;
474
516
  await authorizeTaxonomyManage(deps, principalId);
475
- return deps.transaction(async () => {
476
- const current = await deps.terms.findById(termId);
477
- if (!current) {
478
- throw new TermRecordNotFoundError(`term '${termId}' was not found`);
479
- }
480
- const childCount = await deps.terms.countChildren({ parentId: termId });
481
- if (childCount > 0) {
482
- throw new TermHasChildTermsError(`term '${termId}' has ${childCount} child term(s) and cannot be deleted`, childCount);
483
- }
484
- const assignedCount = await deps.entryTerms.countByTerm({ termId });
485
- if (assignedCount > 0) {
486
- throw new TermHasAssignedContentError(`term '${termId}' is assigned to ${assignedCount} content item(s) and cannot be deleted`, assignedCount);
487
- }
488
- await deps.terms.delete(termId);
489
- const now = deps.clock.nowIso();
490
- await deps.revisions.insert({
491
- taxonomyId: current.taxonomyId,
492
- op: "delete",
493
- previousState: { termId, name: current.name ?? null },
494
- actorId: principalId,
495
- recordedAt: now,
496
- });
497
- await deps.stampWatermark();
498
- await deps.outbox.enqueue({
499
- name: "taxonomy.term_deleted",
500
- termId,
501
- taxonomyId: current.taxonomyId,
502
- actorId: principalId,
503
- occurredAt: now,
504
- });
505
- return { deletedTermId: termId };
506
- });
517
+ return deps.transaction({ fn: async () => {
518
+ const current = await deps.terms.findById({ id: termId });
519
+ if (!current) {
520
+ throw new TermRecordNotFoundError({ message: `term '${termId}' was not found` });
521
+ }
522
+ const childCount = await deps.terms.countChildren({ parentId: termId });
523
+ if (childCount > 0) {
524
+ throw new TermHasChildTermsError({ message: `term '${termId}' has ${childCount} child term(s) and cannot be deleted`, childCount: childCount });
525
+ }
526
+ const assignedCount = await deps.entryTerms.countByTerm({ termId });
527
+ if (assignedCount > 0) {
528
+ throw new TermHasAssignedContentError({ message: `term '${termId}' is assigned to ${assignedCount} content item(s) and cannot be deleted`, assignedCount: assignedCount });
529
+ }
530
+ await deps.terms.delete({ id: termId });
531
+ const now = kernelNowIso({ clock: deps.clock });
532
+ await deps.revisions.insert({
533
+ taxonomyId: current.taxonomyId,
534
+ op: "delete",
535
+ previousState: { termId, name: current.name ?? null },
536
+ actorId: principalId,
537
+ recordedAt: now,
538
+ });
539
+ await deps.stampWatermark({});
540
+ await deps.outbox.enqueue({ event: {
541
+ name: "taxonomy.term_deleted",
542
+ termId,
543
+ taxonomyId: current.taxonomyId,
544
+ actorId: principalId,
545
+ occurredAt: now,
546
+ } });
547
+ return { deletedTermId: termId };
548
+ } });
507
549
  }
508
550
  /**
509
551
  * Deletes a taxonomy together with its member terms, refusing when ANY member term is still
@@ -527,41 +569,41 @@ export async function deleteTerm(required, _optional = {}) {
527
569
  export async function deleteTaxonomy(required, _optional = {}) {
528
570
  const { deps, principalId, taxonomyId } = required;
529
571
  await authorizeTaxonomyManage(deps, principalId);
530
- return deps.transaction(async () => {
531
- const taxonomy = await deps.taxonomies.findById(taxonomyId);
532
- if (!taxonomy) {
533
- throw new TaxonomyRecordNotFoundError(`taxonomy '${taxonomyId}' was not found`);
534
- }
535
- const memberTerms = await deps.terms.listByTaxonomy({ taxonomyId });
536
- let totalAssigned = 0;
537
- for (const term of memberTerms) {
538
- totalAssigned += await deps.entryTerms.countByTerm({ termId: term.id });
539
- }
540
- if (totalAssigned > 0) {
541
- throw new TaxonomyHasAssignedContentError(`taxonomy '${taxonomyId}' has ${totalAssigned} content assignment(s) across its terms and cannot be deleted`, totalAssigned);
542
- }
543
- for (const term of memberTerms) {
544
- await deps.terms.delete(term.id);
545
- }
546
- await deps.taxonomies.delete(taxonomyId);
547
- const now = deps.clock.nowIso();
548
- const deletedTermIds = memberTerms.map((term) => term.id);
549
- await deps.revisions.insert({
550
- taxonomyId,
551
- op: "delete",
552
- previousState: { deletedTermIds },
553
- actorId: principalId,
554
- recordedAt: now,
555
- });
556
- await deps.stampWatermark();
557
- await deps.outbox.enqueue({
558
- name: "taxonomy.deleted",
559
- taxonomyId,
560
- deletedTermIds,
561
- actorId: principalId,
562
- occurredAt: now,
563
- });
564
- return { deletedTaxonomyId: taxonomyId, deletedTermIds };
565
- });
572
+ return deps.transaction({ fn: async () => {
573
+ const taxonomy = await deps.taxonomies.findById({ id: taxonomyId });
574
+ if (!taxonomy) {
575
+ throw new TaxonomyRecordNotFoundError({ message: `taxonomy '${taxonomyId}' was not found` });
576
+ }
577
+ const memberTerms = await deps.terms.listByTaxonomy({ taxonomyId });
578
+ let totalAssigned = 0;
579
+ for (const term of memberTerms) {
580
+ totalAssigned += await deps.entryTerms.countByTerm({ termId: term.id });
581
+ }
582
+ if (totalAssigned > 0) {
583
+ throw new TaxonomyHasAssignedContentError({ message: `taxonomy '${taxonomyId}' has ${totalAssigned} content assignment(s) across its terms and cannot be deleted`, assignedCount: totalAssigned });
584
+ }
585
+ for (const term of memberTerms) {
586
+ await deps.terms.delete({ id: term.id });
587
+ }
588
+ await deps.taxonomies.delete({ id: taxonomyId });
589
+ const now = kernelNowIso({ clock: deps.clock });
590
+ const deletedTermIds = memberTerms.map((term) => term.id);
591
+ await deps.revisions.insert({
592
+ taxonomyId,
593
+ op: "delete",
594
+ previousState: { deletedTermIds },
595
+ actorId: principalId,
596
+ recordedAt: now,
597
+ });
598
+ await deps.stampWatermark({});
599
+ await deps.outbox.enqueue({ event: {
600
+ name: "taxonomy.deleted",
601
+ taxonomyId,
602
+ deletedTermIds,
603
+ actorId: principalId,
604
+ occurredAt: now,
605
+ } });
606
+ return { deletedTaxonomyId: taxonomyId, deletedTermIds };
607
+ } });
566
608
  }
567
609
  //# sourceMappingURL=write-service.js.map