@jini-ai/cms 0.3.11 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +47 -19
- package/dist/content-types/agent-tools.d.ts +3 -56
- package/dist/content-types/agent-tools.js +22 -6
- package/dist/content-types/cleanup.d.ts +18 -13
- package/dist/content-types/cleanup.js +16 -11
- package/dist/content-types/errors.d.ts +57 -24
- package/dist/content-types/errors.js +56 -39
- package/dist/content-types/field-defs.d.ts +8 -4
- package/dist/content-types/field-defs.js +19 -14
- package/dist/content-types/index-provisioning.d.ts +61 -47
- package/dist/content-types/index-provisioning.js +90 -66
- package/dist/content-types/index.d.ts +4 -3
- package/dist/content-types/lifecycle-dispatch.d.ts +11 -5
- package/dist/content-types/lifecycle-dispatch.js +5 -4
- package/dist/content-types/lifecycle.d.ts +23 -18
- package/dist/content-types/lifecycle.js +71 -64
- package/dist/content-types/repo.memory.d.ts +5 -4
- package/dist/content-types/repo.memory.js +3 -2
- package/dist/content-types/tool-registrations.d.ts +6 -8
- package/dist/content-types/tool-registrations.js +24 -28
- package/dist/content-types/types.d.ts +17 -13
- package/dist/content-types/types.js +11 -5
- package/dist/content-types/write-service.d.ts +20 -22
- package/dist/content-types/write-service.js +78 -75
- package/dist/core/authorization.d.ts +15 -0
- package/dist/core/authorization.js +2 -0
- package/dist/core/commands/change-set.d.ts +15 -7
- package/dist/core/commands/command.d.ts +45 -26
- package/dist/core/commands/command.js +73 -63
- package/dist/core/entity-liveness.d.ts +11 -7
- package/dist/core/entity-liveness.js +5 -4
- package/dist/core/index.d.ts +12 -5
- package/dist/core/index.js +10 -4
- package/dist/core/ports.d.ts +25 -24
- package/dist/core/ports.js +1 -0
- package/dist/core/tools/args.d.ts +49 -0
- package/dist/core/tools/args.js +46 -0
- package/dist/core/tools/index.d.ts +3 -0
- package/dist/core/tools/index.js +3 -0
- package/dist/entries/agent-tools.d.ts +1 -73
- package/dist/entries/agent-tools.js +19 -13
- package/dist/entries/errors.d.ts +28 -14
- package/dist/entries/errors.js +17 -11
- package/dist/entries/field-validation.d.ts +5 -3
- package/dist/entries/field-validation.js +7 -5
- package/dist/entries/index.d.ts +2 -2
- package/dist/entries/lifecycle-dispatch.d.ts +5 -2
- package/dist/entries/lifecycle-dispatch.js +2 -1
- package/dist/entries/list.d.ts +4 -2
- package/dist/entries/list.js +4 -3
- package/dist/entries/repo.memory.d.ts +12 -10
- package/dist/entries/repo.memory.js +11 -10
- package/dist/entries/tool-registrations.d.ts +6 -8
- package/dist/entries/tool-registrations.js +42 -41
- package/dist/entries/types.d.ts +0 -7
- package/dist/entries/write-service.d.ts +28 -29
- package/dist/entries/write-service.js +114 -105
- package/dist/http/settings/cms-adapter.d.ts +20 -0
- package/dist/http/settings/cms-adapter.js +124 -0
- package/dist/http/settings/contracts.d.ts +135 -0
- package/dist/http/settings/contracts.js +2 -0
- package/dist/http/settings/http.d.ts +10 -0
- package/dist/http/settings/http.js +211 -0
- package/dist/http/settings/index.d.ts +5 -0
- package/dist/http/settings/index.js +4 -0
- package/dist/http/settings/shared.d.ts +68 -0
- package/dist/http/settings/shared.js +54 -0
- package/dist/http/settings/stream.d.ts +24 -0
- package/dist/http/settings/stream.js +147 -0
- package/dist/media/agent-tools.d.ts +1 -66
- package/dist/media/agent-tools.js +5 -1
- package/dist/media/blob-gc-lock.d.ts +6 -2
- package/dist/media/blob-gc-lock.js +4 -2
- package/dist/media/blob-gc.d.ts +64 -51
- package/dist/media/blob-gc.js +62 -52
- package/dist/media/blob-key.d.ts +1 -1
- package/dist/media/content-type-sniffer.d.ts +3 -1
- package/dist/media/content-type-sniffer.js +1 -1
- package/dist/media/html-attributes.d.ts +9 -5
- package/dist/media/html-attributes.js +8 -6
- package/dist/media/image-transformer.js +1 -1
- package/dist/media/image-transformer.sharp.d.ts +6 -0
- package/dist/media/image-transformer.sharp.js +12 -6
- package/dist/media/import/fetch-image.d.ts +24 -0
- package/dist/media/import/fetch-image.js +137 -0
- package/dist/media/import/index.d.ts +3 -0
- package/dist/media/import/index.js +3 -0
- package/dist/media/import/ports.d.ts +38 -0
- package/dist/media/index.d.ts +7 -1
- package/dist/media/index.js +1 -0
- package/dist/media/media-service.d.ts +14 -10
- package/dist/media/media-service.js +57 -52
- package/dist/media/ports.d.ts +3 -2
- package/dist/media/rendition-service.d.ts +3 -3
- package/dist/media/rendition-service.js +38 -37
- package/dist/media/repo.memory.d.ts +18 -7
- package/dist/media/repo.memory.js +12 -6
- package/dist/media/tool-registrations.d.ts +16 -15
- package/dist/media/tool-registrations.js +84 -72
- package/dist/media/transform-lock.d.ts +22 -12
- package/dist/media/transform-lock.js +14 -13
- package/dist/media/transform-registry.d.ts +3 -3
- package/dist/media/transform-registry.js +19 -18
- package/dist/media/transform-types.d.ts +8 -3
- package/dist/media/transform-types.js +10 -5
- package/dist/media/types.d.ts +21 -4
- package/dist/media/types.js +15 -2
- package/dist/navigation/agent-tools.d.ts +1 -80
- package/dist/navigation/agent-tools.js +15 -3
- package/dist/navigation/contracts.d.ts +1 -1
- package/dist/navigation/index.d.ts +2 -2
- package/dist/navigation/menu-service.d.ts +57 -40
- package/dist/navigation/menu-service.js +80 -69
- package/dist/navigation/ports.d.ts +1 -1
- package/dist/navigation/read-model.d.ts +3 -1
- package/dist/navigation/read-model.js +2 -2
- package/dist/navigation/reconcile.d.ts +2 -2
- package/dist/navigation/reconcile.js +2 -1
- package/dist/navigation/repo.memory.d.ts +7 -3
- package/dist/navigation/repo.memory.js +4 -2
- package/dist/navigation/resolver.d.ts +4 -1
- package/dist/navigation/resolver.js +1 -1
- package/dist/navigation/tool-registrations.d.ts +6 -8
- package/dist/navigation/tool-registrations.js +44 -42
- package/dist/navigation/types.d.ts +1 -1
- package/dist/presentation/presentation.d.ts +11 -3
- package/dist/presentation/presentation.js +13 -6
- package/dist/presentation/repo.memory.d.ts +6 -2
- package/dist/presentation/repo.memory.js +3 -2
- package/dist/settings/agent-tools.d.ts +2 -75
- package/dist/settings/agent-tools.js +49 -45
- package/dist/settings/agent-value-write-tools.d.ts +22 -0
- package/dist/settings/agent-value-write-tools.js +154 -0
- package/dist/settings/agent-writable-preferences.js +8 -20
- package/dist/settings/agent-write-denylist.d.ts +19 -0
- package/dist/settings/agent-write-denylist.js +15 -0
- package/dist/settings/change-feed.d.ts +11 -2
- package/dist/settings/change-feed.js +4 -4
- package/dist/settings/definitions-dispatch.d.ts +8 -3
- package/dist/settings/definitions-dispatch.js +5 -5
- package/dist/settings/dictionaries/index.d.ts +5 -2
- package/dist/settings/dictionaries/index.js +2 -2
- package/dist/settings/dictionaries/settings-dialog.ar.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.bn.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.de.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.es.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.fa.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.fr.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.hi.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.hu.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.id.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.it.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ja.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ko.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.pl.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.pt-BR.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ru.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.th.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.tr.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.uk.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ur.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-CN.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-TW.js +0 -1
- package/dist/settings/ensure-definitions.d.ts +5 -5
- package/dist/settings/errors.d.ts +7 -1
- package/dist/settings/errors.js +7 -1
- package/dist/settings/index.d.ts +4 -1
- package/dist/settings/index.js +1 -0
- package/dist/settings/ports.d.ts +6 -5
- package/dist/settings/principal-lookup.d.ts +9 -0
- package/dist/settings/principal-lookup.js +2 -0
- package/dist/settings/purge-service.d.ts +5 -4
- package/dist/settings/purge-service.js +3 -2
- package/dist/settings/settings.d.ts +22 -7
- package/dist/settings/settings.js +47 -26
- package/dist/settings/tool-registrations.d.ts +25 -32
- package/dist/settings/tool-registrations.js +66 -56
- package/dist/settings/types.d.ts +1 -1
- package/dist/settings/ui-tab-definitions.d.ts +1 -1
- package/dist/settings/write-service.d.ts +32 -26
- package/dist/settings/write-service.js +26 -20
- package/dist/taxonomy/agent-tools.d.ts +1 -66
- package/dist/taxonomy/agent-tools.js +25 -15
- package/dist/taxonomy/content-lookup.js +1 -1
- package/dist/taxonomy/index.d.ts +2 -2
- package/dist/taxonomy/merge-term.d.ts +7 -4
- package/dist/taxonomy/merge-term.js +7 -5
- package/dist/taxonomy/repo.memory.d.ts +42 -19
- package/dist/taxonomy/repo.memory.js +14 -12
- package/dist/taxonomy/validation-chain.d.ts +39 -18
- package/dist/taxonomy/validation-chain.js +35 -25
- package/dist/taxonomy/write-service.d.ts +103 -44
- package/dist/taxonomy/write-service.js +221 -179
- package/dist/trash/adapters/record-store.d.ts +52 -0
- package/dist/trash/adapters/record-store.js +36 -0
- package/dist/trash/cursor.d.ts +20 -0
- package/dist/trash/cursor.js +36 -0
- package/dist/trash/follow-ups.d.ts +76 -0
- package/dist/trash/follow-ups.js +46 -0
- package/dist/trash/index.d.ts +8 -0
- package/dist/trash/index.js +8 -0
- package/dist/trash/ports.d.ts +308 -0
- package/dist/trash/ports.js +2 -0
- package/dist/trash/repo.memory.d.ts +50 -0
- package/dist/trash/repo.memory.js +114 -0
- package/dist/trash/sweeper.d.ts +77 -0
- package/dist/trash/sweeper.js +127 -0
- package/dist/trash/write-service.d.ts +100 -0
- package/dist/trash/write-service.js +242 -0
- package/dist/workspace/agent-tools.d.ts +1 -59
- package/dist/workspace/agent-tools.js +27 -12
- package/dist/workspace/create.d.ts +31 -6
- package/dist/workspace/create.js +18 -5
- package/dist/workspace/delete.d.ts +18 -13
- package/dist/workspace/delete.js +20 -12
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/repo.memory.d.ts +19 -4
- package/dist/workspace/repo.memory.js +26 -4
- package/dist/workspace/tool-registrations.d.ts +2 -2
- package/dist/workspace/tool-registrations.js +13 -13
- package/dist/workspace/update.d.ts +6 -5
- package/dist/workspace/update.js +9 -8
- package/package.json +44 -38
- package/dist/identity/admin-crud-service.d.ts +0 -280
- package/dist/identity/admin-crud-service.js +0 -468
- package/dist/identity/agent-tool-input.d.ts +0 -58
- package/dist/identity/agent-tool-input.js +0 -126
- package/dist/identity/agent-tools.d.ts +0 -101
- package/dist/identity/agent-tools.js +0 -290
- package/dist/identity/auth-service.d.ts +0 -118
- package/dist/identity/auth-service.js +0 -170
- package/dist/identity/authorize.d.ts +0 -90
- package/dist/identity/authorize.js +0 -94
- package/dist/identity/grant-service.d.ts +0 -198
- package/dist/identity/grant-service.js +0 -361
- package/dist/identity/hasher.d.ts +0 -39
- package/dist/identity/hasher.js +0 -39
- package/dist/identity/index.d.ts +0 -62
- package/dist/identity/index.js +0 -54
- package/dist/identity/password-policy.d.ts +0 -57
- package/dist/identity/password-policy.js +0 -66
- package/dist/identity/permission-migrations.d.ts +0 -86
- package/dist/identity/permission-migrations.js +0 -67
- package/dist/identity/permissions.d.ts +0 -35
- package/dist/identity/permissions.js +0 -603
- package/dist/identity/ports.d.ts +0 -201
- package/dist/identity/repo.memory.d.ts +0 -164
- package/dist/identity/repo.memory.js +0 -224
- package/dist/identity/seed.d.ts +0 -54
- package/dist/identity/seed.js +0 -429
- package/dist/identity/tool-registrations.d.ts +0 -50
- package/dist/identity/tool-registrations.js +0 -419
- package/dist/identity/types.d.ts +0 -185
- package/dist/identity/types.js +0 -64
- package/dist/identity/username.d.ts +0 -12
- package/dist/identity/username.js +0 -14
- /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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
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 (`
|
|
83
|
-
* term (not yet built — no certified test in this slice exercises it).
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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,
|
|
206
|
-
const { deps, principalId, id, taxonomyId, name,
|
|
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: (
|
|
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
|
|
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
|
-
/**
|
|
273
|
-
* watermark + outbox. `authorize
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
/**
|
|
305
|
-
* `entry_terms_unique
|
|
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
|
|
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
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
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
|
|
397
|
-
await deps.stampWatermark();
|
|
398
|
-
await deps.outbox.enqueue({
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|