@happyvertical/smrt-tags 0.37.2 → 0.37.4

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/dist/index.js CHANGED
@@ -1,747 +1,680 @@
1
- import { ObjectRegistry, field, smrt, SmrtHierarchical, SmrtObject, SmrtCollection } from "@happyvertical/smrt-core";
2
- import { tenantId, TenantScoped, queryGlobal, queryWithGlobals } from "@happyvertical/smrt-tenancy";
3
1
  import { calculateLevel, generateUniqueSlug, hasCircularReference, sanitizeSlug, validateSlug } from "./utils.js";
4
- ObjectRegistry.registerPackageManifest(
5
- new URL("./manifest.json", import.meta.url)
6
- );
2
+ import { ObjectRegistry, SmrtCollection, SmrtHierarchical, SmrtObject, field, smrt } from "@happyvertical/smrt-core";
3
+ import { TenantScoped, queryGlobal, queryWithGlobals, tenantId } from "@happyvertical/smrt-tenancy";
4
+ //#region \0rolldown/runtime.js
5
+ var __defProp$2 = Object.defineProperty;
6
+ var __exportAll = (all, no_symbols) => {
7
+ let target = {};
8
+ for (var name in all) __defProp$2(target, name, {
9
+ get: all[name],
10
+ enumerable: true
11
+ });
12
+ if (!no_symbols) __defProp$2(target, Symbol.toStringTag, { value: "Module" });
13
+ return target;
14
+ };
15
+ //#endregion
16
+ //#region src/__smrt-register__.ts
17
+ ObjectRegistry.registerPackageManifest(new URL("./manifest.json", "" + import.meta.url));
18
+ //#endregion
19
+ //#region src/tag.ts
7
20
  var __defProp$1 = Object.defineProperty;
8
21
  var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
9
22
  var __decorateClass$1 = (decorators, target, key, kind) => {
10
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
11
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
12
- if (decorator = decorators[i])
13
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
14
- if (kind && result) __defProp$1(target, key, result);
15
- return result;
23
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
24
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
25
+ if (kind && result) __defProp$1(target, key, result);
26
+ return result;
16
27
  };
17
- let Tag = class extends SmrtHierarchical {
18
- // id: UUID (auto-generated by SmrtObject)
19
- _slug = "";
20
- // Unique identifier
21
- _context = "global";
22
- // Namespace/grouping
23
- // Override SmrtObject accessors
24
- get slug() {
25
- return this._slug;
26
- }
27
- set slug(value) {
28
- this._slug = value;
29
- }
30
- get context() {
31
- return this._context;
32
- }
33
- set context(value) {
34
- this._context = value;
35
- }
36
- name = "";
37
- // Display name
38
- // parentId inherited from SmrtHierarchical (UUID, nullable). Stores the
39
- // parent Tag's id (not its slug) — Tag's slug+context natural key remains
40
- // the public identifier on TagCollection's API, but the FK is now UUID
41
- // for consistency with Place / Event / Account / Zone.
42
- level = 0;
43
- // Hierarchy depth (0 = root)
44
- description = "";
45
- // Optional description
46
- metadata = "";
47
- tenantId = null;
48
- // Timestamps
49
- createdAt = /* @__PURE__ */ new Date();
50
- updatedAt = /* @__PURE__ */ new Date();
51
- constructor(options = {}) {
52
- super(options);
53
- if (options.name) this.name = options.name;
54
- if (options.parentId !== void 0)
55
- this.parentId = options.parentId ?? null;
56
- if (options.slug) this._slug = options.slug;
57
- if (options.context !== void 0) this._context = options.context;
58
- if (options.level !== void 0) this.level = options.level;
59
- if (options.description !== void 0)
60
- this.description = options.description;
61
- if (options.metadata !== void 0) {
62
- if (typeof options.metadata === "string") {
63
- this.metadata = options.metadata;
64
- } else {
65
- this.metadata = JSON.stringify(options.metadata);
66
- }
67
- }
68
- }
69
- /**
70
- * Get metadata as parsed object
71
- *
72
- * @returns Parsed metadata object or empty object if no metadata
73
- */
74
- getMetadata() {
75
- const metadataValue = String(this.metadata || "");
76
- if (!metadataValue) return {};
77
- try {
78
- return JSON.parse(metadataValue);
79
- } catch {
80
- return {};
81
- }
82
- }
83
- /**
84
- * Set metadata from object
85
- *
86
- * @param data - Metadata object to store
87
- */
88
- setMetadata(data) {
89
- this.metadata = JSON.stringify(data);
90
- }
91
- /**
92
- * Update metadata by merging with existing values
93
- *
94
- * @param updates - Partial metadata to merge
95
- */
96
- updateMetadata(updates) {
97
- const current = this.getMetadata();
98
- this.setMetadata({ ...current, ...updates });
99
- }
100
- // Hierarchy traversal (getParent / getChildren / getAncestors /
101
- // getDescendants / getHierarchy / moveTo) provided by SmrtHierarchical
102
- // against the inherited UUID `parentId`. TagCollection wraps these in
103
- // slug-friendly methods (moveTag / mergeTag / getChildren etc.) so the
104
- // existing public API surface keeps working for callers that prefer
105
- // slug references.
106
- /**
107
- * Convenience method for slug-based lookup
108
- *
109
- * @param slug - The slug to search for
110
- * @param context - Optional context filter
111
- * @returns Tag instance or null if not found
112
- */
113
- static async getBySlug(_slug, _context) {
114
- return null;
115
- }
116
- /**
117
- * Get root tags (no parent) for a context
118
- *
119
- * @param context - The context to filter by
120
- * @returns Array of root tags
121
- */
122
- static async getRootTags(_context = "global") {
123
- return [];
124
- }
28
+ var Tag = class extends SmrtHierarchical {
29
+ _slug = "";
30
+ _context = "global";
31
+ get slug() {
32
+ return this._slug;
33
+ }
34
+ set slug(value) {
35
+ this._slug = value;
36
+ }
37
+ get context() {
38
+ return this._context;
39
+ }
40
+ set context(value) {
41
+ this._context = value;
42
+ }
43
+ name = "";
44
+ level = 0;
45
+ description = "";
46
+ metadata = "";
47
+ tenantId = null;
48
+ createdAt = /* @__PURE__ */ new Date();
49
+ updatedAt = /* @__PURE__ */ new Date();
50
+ constructor(options = {}) {
51
+ super(options);
52
+ if (options.name) this.name = options.name;
53
+ if (options.parentId !== void 0) this.parentId = options.parentId ?? null;
54
+ if (options.slug) this._slug = options.slug;
55
+ if (options.context !== void 0) this._context = options.context;
56
+ if (options.level !== void 0) this.level = options.level;
57
+ if (options.description !== void 0) this.description = options.description;
58
+ if (options.metadata !== void 0) if (typeof options.metadata === "string") this.metadata = options.metadata;
59
+ else this.metadata = JSON.stringify(options.metadata);
60
+ }
61
+ /**
62
+ * Get metadata as parsed object
63
+ *
64
+ * @returns Parsed metadata object or empty object if no metadata
65
+ */
66
+ getMetadata() {
67
+ const metadataValue = String(this.metadata || "");
68
+ if (!metadataValue) return {};
69
+ try {
70
+ return JSON.parse(metadataValue);
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+ /**
76
+ * Set metadata from object
77
+ *
78
+ * @param data - Metadata object to store
79
+ */
80
+ setMetadata(data) {
81
+ this.metadata = JSON.stringify(data);
82
+ }
83
+ /**
84
+ * Update metadata by merging with existing values
85
+ *
86
+ * @param updates - Partial metadata to merge
87
+ */
88
+ updateMetadata(updates) {
89
+ const current = this.getMetadata();
90
+ this.setMetadata({
91
+ ...current,
92
+ ...updates
93
+ });
94
+ }
95
+ /**
96
+ * Convenience method for slug-based lookup
97
+ *
98
+ * @param slug - The slug to search for
99
+ * @param context - Optional context filter
100
+ * @returns Tag instance or null if not found
101
+ */
102
+ static async getBySlug(_slug, _context) {
103
+ return null;
104
+ }
105
+ /**
106
+ * Get root tags (no parent) for a context
107
+ *
108
+ * @param context - The context to filter by
109
+ * @returns Array of root tags
110
+ */
111
+ static async getRootTags(_context = "global") {
112
+ return [];
113
+ }
125
114
  };
126
- __decorateClass$1([
127
- field({ required: true })
128
- ], Tag.prototype, "name", 2);
129
- __decorateClass$1([
130
- tenantId({ nullable: true })
131
- ], Tag.prototype, "tenantId", 2);
132
- Tag = __decorateClass$1([
133
- TenantScoped({ mode: "optional" }),
134
- smrt({
135
- tableStrategy: "sti",
136
- api: { include: ["list", "get", "create", "update", "delete"] },
137
- mcp: { include: ["list", "get", "create", "update"] },
138
- cli: true
139
- })
140
- ], Tag);
115
+ __decorateClass$1([field({ required: true })], Tag.prototype, "name", 2);
116
+ __decorateClass$1([tenantId({ nullable: true })], Tag.prototype, "tenantId", 2);
117
+ Tag = __decorateClass$1([TenantScoped({ mode: "optional" }), smrt({
118
+ tableStrategy: "sti",
119
+ api: { include: [
120
+ "list",
121
+ "get",
122
+ "create",
123
+ "update",
124
+ "delete"
125
+ ] },
126
+ mcp: { include: [
127
+ "list",
128
+ "get",
129
+ "create",
130
+ "update"
131
+ ] },
132
+ cli: true
133
+ })], Tag);
134
+ //#endregion
135
+ //#region src/tag-alias.ts
141
136
  var __defProp = Object.defineProperty;
142
137
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
143
138
  var __decorateClass = (decorators, target, key, kind) => {
144
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
145
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
146
- if (decorator = decorators[i])
147
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
148
- if (kind && result) __defProp(target, key, result);
149
- return result;
139
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
140
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
141
+ if (kind && result) __defProp(target, key, result);
142
+ return result;
143
+ };
144
+ var TagAlias = class extends SmrtObject {
145
+ tagSlug = "";
146
+ alias = "";
147
+ language = "";
148
+ _context = "";
149
+ get context() {
150
+ return this._context;
151
+ }
152
+ set context(value) {
153
+ this._context = value;
154
+ }
155
+ tenantId = null;
156
+ createdAt = /* @__PURE__ */ new Date();
157
+ constructor(options = {}) {
158
+ super(options);
159
+ if (options.tagSlug !== void 0) this.tagSlug = options.tagSlug;
160
+ if (options.alias) this.alias = options.alias;
161
+ if (options.language !== void 0) this.language = options.language;
162
+ if (options.context !== void 0) this._context = options.context;
163
+ }
164
+ /**
165
+ * Get the tag this alias belongs to
166
+ *
167
+ * @returns Tag instance or null if not found
168
+ */
169
+ async getTag() {
170
+ const { TagCollection } = await Promise.resolve().then(() => tags_exports);
171
+ return await (await TagCollection.create(this.options)).get({ slug: this.tagSlug });
172
+ }
173
+ /**
174
+ * Search tags by alias
175
+ *
176
+ * @param alias - The alias to search for
177
+ * @param language - Optional language filter
178
+ * @returns Array of matching tags
179
+ */
180
+ static async searchByAlias(_alias, _language) {
181
+ return [];
182
+ }
183
+ /**
184
+ * Get all aliases for a tag
185
+ *
186
+ * @param tagSlug - The tag slug to get aliases for
187
+ * @returns Array of TagAlias instances
188
+ */
189
+ static async getAliasesForTag(_tagSlug) {
190
+ return [];
191
+ }
150
192
  };
151
- let TagAlias = class extends SmrtObject {
152
- // id: UUID (auto-generated by SmrtObject)
153
- tagSlug = "";
154
- alias = "";
155
- // Alternative name or translation
156
- language = "";
157
- // ISO 639-1 language code (nullable)
158
- _context = "";
159
- // Optional context scoping (nullable)
160
- // Override SmrtObject accessor
161
- get context() {
162
- return this._context;
163
- }
164
- set context(value) {
165
- this._context = value;
166
- }
167
- tenantId = null;
168
- // Timestamps
169
- createdAt = /* @__PURE__ */ new Date();
170
- constructor(options = {}) {
171
- super(options);
172
- if (options.tagSlug !== void 0) this.tagSlug = options.tagSlug;
173
- if (options.alias) this.alias = options.alias;
174
- if (options.language !== void 0) this.language = options.language;
175
- if (options.context !== void 0) this._context = options.context;
176
- }
177
- /**
178
- * Get the tag this alias belongs to
179
- *
180
- * @returns Tag instance or null if not found
181
- */
182
- async getTag() {
183
- const { TagCollection: TagCollection2 } = await Promise.resolve().then(() => tags);
184
- const collection = await TagCollection2.create(this.options);
185
- return await collection.get({ slug: this.tagSlug });
186
- }
187
- /**
188
- * Search tags by alias
189
- *
190
- * @param alias - The alias to search for
191
- * @param language - Optional language filter
192
- * @returns Array of matching tags
193
- */
194
- static async searchByAlias(_alias, _language) {
195
- return [];
196
- }
197
- /**
198
- * Get all aliases for a tag
199
- *
200
- * @param tagSlug - The tag slug to get aliases for
201
- * @returns Array of TagAlias instances
202
- */
203
- static async getAliasesForTag(_tagSlug) {
204
- return [];
205
- }
193
+ __decorateClass([field({ required: true })], TagAlias.prototype, "alias", 2);
194
+ __decorateClass([tenantId({ nullable: true })], TagAlias.prototype, "tenantId", 2);
195
+ TagAlias = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
196
+ tableStrategy: "sti",
197
+ api: { include: [
198
+ "list",
199
+ "get",
200
+ "create",
201
+ "update",
202
+ "delete"
203
+ ] },
204
+ mcp: { include: [
205
+ "list",
206
+ "get",
207
+ "create"
208
+ ] },
209
+ cli: true
210
+ })], TagAlias);
211
+ //#endregion
212
+ //#region src/tag-aliases.ts
213
+ var tag_aliases_exports = /* @__PURE__ */ __exportAll({ TagAliasCollection: () => TagAliasCollection });
214
+ var TagAliasCollection = class extends SmrtCollection {
215
+ static _itemClass = TagAlias;
216
+ /**
217
+ * Add an alias to a tag (get or create)
218
+ *
219
+ * @param tagSlug - The tag slug
220
+ * @param alias - The alias text
221
+ * @param language - Optional language code
222
+ * @param context - Optional context
223
+ * @returns TagAlias instance
224
+ */
225
+ async addAlias(tagSlug, alias, language, context) {
226
+ const where = {
227
+ tagSlug,
228
+ alias
229
+ };
230
+ if (language) where.language = language;
231
+ if (context) where.context = context;
232
+ const existing = await this.list({
233
+ where,
234
+ limit: 1
235
+ });
236
+ if (existing.length > 0) return existing[0];
237
+ return await this.create({
238
+ tagSlug,
239
+ alias,
240
+ language,
241
+ context
242
+ });
243
+ }
244
+ /**
245
+ * Search tags by alias
246
+ *
247
+ * @param alias - The alias to search for
248
+ * @param language - Optional language filter
249
+ * @returns Array of matching tags
250
+ */
251
+ async searchByAlias(alias, language) {
252
+ const where = { alias };
253
+ if (language) where.language = language;
254
+ const aliases = await this.list({ where });
255
+ const tagSlugs = [...new Set(aliases.map((a) => a.tagSlug))];
256
+ const { TagCollection } = await Promise.resolve().then(() => tags_exports);
257
+ const tagCollection = await TagCollection.create(this.options);
258
+ const tags = [];
259
+ for (const slug of tagSlugs) {
260
+ const tag = await tagCollection.get({ slug });
261
+ if (tag) tags.push(tag);
262
+ }
263
+ return tags;
264
+ }
265
+ /**
266
+ * Get all aliases for a tag
267
+ *
268
+ * @param tagSlug - The tag slug
269
+ * @param language - Optional language filter
270
+ * @returns Array of TagAlias instances
271
+ */
272
+ async getAliasesForTag(tagSlug, language) {
273
+ const where = { tagSlug };
274
+ if (language) where.language = language;
275
+ return await this.list({ where });
276
+ }
277
+ /**
278
+ * Remove an alias by ID
279
+ *
280
+ * @param aliasId - The alias UUID
281
+ */
282
+ async removeAlias(aliasId) {
283
+ const alias = await this.get({ id: aliasId });
284
+ if (alias) await alias.delete();
285
+ }
286
+ /**
287
+ * Bulk add aliases to a tag
288
+ *
289
+ * @param tagSlug - The tag slug
290
+ * @param aliases - Array of alias configurations
291
+ * @returns Array of created TagAlias instances
292
+ */
293
+ async bulkAddAliases(tagSlug, aliases) {
294
+ const created = [];
295
+ for (const aliasData of aliases) {
296
+ const tagAlias = await this.addAlias(tagSlug, aliasData.alias, aliasData.language, aliasData.context);
297
+ created.push(tagAlias);
298
+ }
299
+ return created;
300
+ }
301
+ /**
302
+ * Get aliases grouped by language
303
+ *
304
+ * @param tagSlug - The tag slug
305
+ * @returns Map of language code to array of aliases
306
+ */
307
+ async getAliasesByLanguage(tagSlug) {
308
+ const aliases = await this.getAliasesForTag(tagSlug);
309
+ const grouped = /* @__PURE__ */ new Map();
310
+ for (const alias of aliases) {
311
+ const lang = String(alias.language || "default");
312
+ if (!grouped.has(lang)) grouped.set(lang, []);
313
+ grouped.get(lang)?.push(String(alias.alias));
314
+ }
315
+ return grouped;
316
+ }
317
+ /**
318
+ * Find matching aliases (case-insensitive partial match)
319
+ *
320
+ * Note: This is a simple implementation. For production use,
321
+ * consider using full-text search or fuzzy matching.
322
+ *
323
+ * @param query - The search query
324
+ * @param language - Optional language filter
325
+ * @returns Array of matching TagAlias instances
326
+ */
327
+ async findMatchingAliases(query, language) {
328
+ const where = {};
329
+ if (language) where.language = language;
330
+ const all = await this.list({ where });
331
+ const queryLower = query.toLowerCase();
332
+ return all.filter((alias) => String(alias.alias).toLowerCase().includes(queryLower));
333
+ }
334
+ /**
335
+ * Find all tag aliases belonging to a specific tenant
336
+ *
337
+ * @param tenantId - The tenant ID to filter by
338
+ * @returns Array of tag aliases for the specified tenant
339
+ */
340
+ async findByTenant(tenantId) {
341
+ return this.list({ where: { tenantId } });
342
+ }
343
+ /**
344
+ * Find all global (tenant-less) tag aliases.
345
+ *
346
+ * Routes through the shared tenant-global helper so it does not throw under
347
+ * an active tenant context (an explicit `tenant_id IS NULL` filter would be
348
+ * flagged as an isolation violation). (#1600)
349
+ *
350
+ * @returns Array of global tag aliases with null tenantId
351
+ */
352
+ async findGlobal() {
353
+ return queryGlobal(this);
354
+ }
355
+ /**
356
+ * Find tag aliases for a tenant including global aliases.
357
+ *
358
+ * Fails closed if an active tenant context requests a different tenant's
359
+ * rows; the admin/system path keeps the cross-tenant capability. (#1600)
360
+ *
361
+ * @param tenantId - The tenant ID to filter by
362
+ * @returns Array of tag aliases for the tenant plus all global aliases
363
+ */
364
+ async findWithGlobals(tenantId) {
365
+ return queryWithGlobals(this, tenantId, "TagAlias.findWithGlobals");
366
+ }
206
367
  };
207
- __decorateClass([
208
- field({ required: true })
209
- ], TagAlias.prototype, "alias", 2);
210
- __decorateClass([
211
- tenantId({ nullable: true })
212
- ], TagAlias.prototype, "tenantId", 2);
213
- TagAlias = __decorateClass([
214
- TenantScoped({ mode: "optional" }),
215
- smrt({
216
- tableStrategy: "sti",
217
- api: { include: ["list", "get", "create", "update", "delete"] },
218
- mcp: { include: ["list", "get", "create"] },
219
- cli: true
220
- })
221
- ], TagAlias);
222
- class TagAliasCollection extends SmrtCollection {
223
- static _itemClass = TagAlias;
224
- /**
225
- * Add an alias to a tag (get or create)
226
- *
227
- * @param tagSlug - The tag slug
228
- * @param alias - The alias text
229
- * @param language - Optional language code
230
- * @param context - Optional context
231
- * @returns TagAlias instance
232
- */
233
- async addAlias(tagSlug, alias, language, context) {
234
- const where = { tagSlug, alias };
235
- if (language) where.language = language;
236
- if (context) where.context = context;
237
- const existing = await this.list({ where, limit: 1 });
238
- if (existing.length > 0) {
239
- return existing[0];
240
- }
241
- return await this.create({
242
- tagSlug,
243
- alias,
244
- language,
245
- context
246
- });
247
- }
248
- /**
249
- * Search tags by alias
250
- *
251
- * @param alias - The alias to search for
252
- * @param language - Optional language filter
253
- * @returns Array of matching tags
254
- */
255
- async searchByAlias(alias, language) {
256
- const where = { alias };
257
- if (language) where.language = language;
258
- const aliases = await this.list({ where });
259
- const tagSlugs = [...new Set(aliases.map((a) => a.tagSlug))];
260
- const { TagCollection: TagCollection2 } = await Promise.resolve().then(() => tags);
261
- const tagCollection = await TagCollection2.create(this.options);
262
- const tags$1 = [];
263
- for (const slug of tagSlugs) {
264
- const tag = await tagCollection.get({ slug });
265
- if (tag) tags$1.push(tag);
266
- }
267
- return tags$1;
268
- }
269
- /**
270
- * Get all aliases for a tag
271
- *
272
- * @param tagSlug - The tag slug
273
- * @param language - Optional language filter
274
- * @returns Array of TagAlias instances
275
- */
276
- async getAliasesForTag(tagSlug, language) {
277
- const where = { tagSlug };
278
- if (language) where.language = language;
279
- return await this.list({ where });
280
- }
281
- /**
282
- * Remove an alias by ID
283
- *
284
- * @param aliasId - The alias UUID
285
- */
286
- async removeAlias(aliasId) {
287
- const alias = await this.get({ id: aliasId });
288
- if (alias) {
289
- await alias.delete();
290
- }
291
- }
292
- /**
293
- * Bulk add aliases to a tag
294
- *
295
- * @param tagSlug - The tag slug
296
- * @param aliases - Array of alias configurations
297
- * @returns Array of created TagAlias instances
298
- */
299
- async bulkAddAliases(tagSlug, aliases) {
300
- const created = [];
301
- for (const aliasData of aliases) {
302
- const tagAlias = await this.addAlias(
303
- tagSlug,
304
- aliasData.alias,
305
- aliasData.language,
306
- aliasData.context
307
- );
308
- created.push(tagAlias);
309
- }
310
- return created;
311
- }
312
- /**
313
- * Get aliases grouped by language
314
- *
315
- * @param tagSlug - The tag slug
316
- * @returns Map of language code to array of aliases
317
- */
318
- async getAliasesByLanguage(tagSlug) {
319
- const aliases = await this.getAliasesForTag(tagSlug);
320
- const grouped = /* @__PURE__ */ new Map();
321
- for (const alias of aliases) {
322
- const lang = String(alias.language || "default");
323
- if (!grouped.has(lang)) {
324
- grouped.set(lang, []);
325
- }
326
- grouped.get(lang)?.push(String(alias.alias));
327
- }
328
- return grouped;
329
- }
330
- /**
331
- * Find matching aliases (case-insensitive partial match)
332
- *
333
- * Note: This is a simple implementation. For production use,
334
- * consider using full-text search or fuzzy matching.
335
- *
336
- * @param query - The search query
337
- * @param language - Optional language filter
338
- * @returns Array of matching TagAlias instances
339
- */
340
- async findMatchingAliases(query, language) {
341
- const where = {};
342
- if (language) where.language = language;
343
- const all = await this.list({ where });
344
- const queryLower = query.toLowerCase();
345
- return all.filter(
346
- (alias) => String(alias.alias).toLowerCase().includes(queryLower)
347
- );
348
- }
349
- // =========================================================================
350
- // Tenant Helper Methods
351
- // =========================================================================
352
- /**
353
- * Find all tag aliases belonging to a specific tenant
354
- *
355
- * @param tenantId - The tenant ID to filter by
356
- * @returns Array of tag aliases for the specified tenant
357
- */
358
- async findByTenant(tenantId2) {
359
- return this.list({ where: { tenantId: tenantId2 } });
360
- }
361
- /**
362
- * Find all global (tenant-less) tag aliases.
363
- *
364
- * Routes through the shared tenant-global helper so it does not throw under
365
- * an active tenant context (an explicit `tenant_id IS NULL` filter would be
366
- * flagged as an isolation violation). (#1600)
367
- *
368
- * @returns Array of global tag aliases with null tenantId
369
- */
370
- async findGlobal() {
371
- return queryGlobal(this);
372
- }
373
- /**
374
- * Find tag aliases for a tenant including global aliases.
375
- *
376
- * Fails closed if an active tenant context requests a different tenant's
377
- * rows; the admin/system path keeps the cross-tenant capability. (#1600)
378
- *
379
- * @param tenantId - The tenant ID to filter by
380
- * @returns Array of tag aliases for the tenant plus all global aliases
381
- */
382
- async findWithGlobals(tenantId2) {
383
- return queryWithGlobals(
384
- this,
385
- tenantId2,
386
- "TagAlias.findWithGlobals"
387
- );
388
- }
389
- }
390
- const tagAliases = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
391
- __proto__: null,
392
- TagAliasCollection
393
- }, Symbol.toStringTag, { value: "Module" }));
394
- class TagCollection extends SmrtCollection {
395
- static _itemClass = Tag;
396
- /**
397
- * Get or create a tag with context
398
- *
399
- * @param slug - Tag slug
400
- * @param context - Tag context (default: 'global')
401
- * @returns Tag instance
402
- */
403
- async getOrCreate(slug, context = "global") {
404
- const existing = await this.list({
405
- where: { slug, context },
406
- limit: 1
407
- });
408
- if (existing.length > 0) {
409
- return existing[0];
410
- }
411
- return await this.create({
412
- slug,
413
- name: slug.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()),
414
- context,
415
- level: 0
416
- });
417
- }
418
- /**
419
- * Resolve a tag by slug, optionally scoped to a context.
420
- *
421
- * Tags are identified by `(slug, context)`. When `context` is omitted
422
- * and the slug exists in more than one context, this throws a clear
423
- * ambiguity error rather than silently picking the first matching row.
424
- * Callers that know their context should pass it; callers that work in
425
- * a single-context world can leave it off.
426
- *
427
- * @returns The matching Tag, or `null` if nothing matches.
428
- * @throws Error if `context` is omitted and the slug is ambiguous.
429
- */
430
- async resolveBySlug(slug, context) {
431
- const where = { slug };
432
- if (context !== void 0) where.context = context;
433
- const matches = await this.list({ where, limit: 2 });
434
- if (matches.length === 0) return null;
435
- if (matches.length > 1) {
436
- throw new Error(
437
- `Tag slug '${slug}' is ambiguous: resolves to ${matches.length}+ rows across contexts. Pass an explicit \`context\` argument.`
438
- );
439
- }
440
- return matches[0];
441
- }
442
- /**
443
- * List tags by context with optional parent filtering by slug.
444
- *
445
- * @param context - The context to filter by
446
- * @param parentSlug - Optional parent slug to filter children. Pass an
447
- * empty string or `null` to find root tags; pass a slug to find that
448
- * tag's immediate children. Typed as `string | null` so TypeScript
449
- * callers can pass `null` without a cast — the `null` and `''` paths
450
- * are both treated as "roots only".
451
- * @returns Array of matching tags
452
- */
453
- async listByContext(context, parentSlug) {
454
- const where = { context };
455
- if (parentSlug === "" || parentSlug === null) {
456
- where.parentId = null;
457
- } else if (parentSlug !== void 0) {
458
- const parent = await this.get({ slug: parentSlug, context });
459
- if (!parent?.id) return [];
460
- where.parentId = parent.id;
461
- }
462
- return await this.list({ where });
463
- }
464
- /**
465
- * Get root tags (no parent) for a context
466
- *
467
- * @param context - The context to filter by (default: 'global')
468
- * @returns Array of root tags
469
- */
470
- async getRootTags(context = "global") {
471
- return await this.list({
472
- where: { context, parentId: null }
473
- });
474
- }
475
- /**
476
- * Get immediate children of a parent tag, looked up by slug.
477
- *
478
- * @param parentSlug - The parent tag slug
479
- * @param context - Optional context for the parent lookup. When omitted,
480
- * the parent slug must be unambiguous across contexts (throws if not).
481
- * @returns Array of child tags, or `[]` if the parent slug doesn't
482
- * resolve. Children are filtered to the resolved parent's context so
483
- * cross-context children don't leak in.
484
- */
485
- async getChildren(parentSlug, context) {
486
- const parent = await this.resolveBySlug(parentSlug, context);
487
- if (!parent?.id) return [];
488
- return await this.list({
489
- where: { parentId: parent.id, context: parent.context }
490
- });
491
- }
492
- /**
493
- * Get tag hierarchy (all ancestors and descendants)
494
- *
495
- * @param slug - The tag slug
496
- * @param context - Optional context for the slug lookup. When omitted,
497
- * the slug must be unambiguous across contexts.
498
- * @returns Object with ancestors, current tag, and descendants
499
- */
500
- async getHierarchy(slug, context) {
501
- const tag = await this.resolveBySlug(slug, context);
502
- if (!tag) throw new Error(`Tag '${slug}' not found`);
503
- const [ancestors, descendants] = await Promise.all([
504
- tag.getAncestors(),
505
- tag.getDescendants()
506
- ]);
507
- return { ancestors, current: tag, descendants };
508
- }
509
- /**
510
- * Move a tag to a new parent. Slug-based API; UUIDs resolved internally.
511
- *
512
- * Cycle detection is inlined here (mirroring `SmrtHierarchical.moveTo`'s
513
- * self-loop + descendant checks) so that both `parentId` and the
514
- * denormalised `level` field can be persisted in a single `save()`.
515
- * Delegating to `moveTo` would write `parentId` first and `level` in a
516
- * second save — if the second save failed, the tag would be left with
517
- * the new parent but a stale level, breaking the depth cache.
518
- *
519
- * After the moved tag persists, descendant levels are recalculated
520
- * recursively via `updateDescendantLevels`.
521
- *
522
- * @param slug - The tag to move
523
- * @param newParentSlug - The new parent slug (null for root)
524
- * @param context - Optional context. When provided, both source and new
525
- * parent are resolved within it. When omitted, both slugs must be
526
- * unambiguous across contexts; the resolver throws otherwise.
527
- * @throws Error if either slug fails to resolve, if either slug is
528
- * ambiguous across contexts (no context provided), if source and new
529
- * parent live in different contexts, or if the move would create a
530
- * cycle.
531
- */
532
- async moveTag(slug, newParentSlug, context) {
533
- const tag = await this.resolveBySlug(slug, context);
534
- if (!tag) throw new Error(`Tag '${slug}' not found`);
535
- let newParent = null;
536
- if (newParentSlug) {
537
- newParent = await this.resolveBySlug(
538
- newParentSlug,
539
- context ?? tag.context
540
- );
541
- if (!newParent) {
542
- throw new Error(`Tag '${newParentSlug}' not found`);
543
- }
544
- if (newParent.context !== tag.context) {
545
- throw new Error(
546
- `Cannot move Tag '${slug}' (context '${tag.context}') under '${newParentSlug}' (context '${newParent.context}') — contexts must match.`
547
- );
548
- }
549
- }
550
- const newParentId = newParent?.id ?? null;
551
- if (newParentId !== null && newParentId === tag.id) {
552
- throw new Error(`Cannot move Tag ${tag.id} to itself.`);
553
- }
554
- if (newParentId !== null) {
555
- const descendants = await tag.getDescendants();
556
- if (descendants.some((d) => d.id === newParentId)) {
557
- throw new Error(
558
- `Cannot move Tag ${tag.id} under one of its own descendants (${newParentId}) — would create a cycle.`
559
- );
560
- }
561
- }
562
- tag.parentId = newParentId;
563
- tag.level = newParent ? newParent.level + 1 : 0;
564
- await tag.save();
565
- await this.updateDescendantLevels(tag);
566
- }
567
- /**
568
- * Merge one tag into another (updates all references)
569
- *
570
- * Reparents `fromTag`'s direct children onto `toTag` and recalculates
571
- * their `level` field plus the level of every descendant — without
572
- * this, children moved from a different depth would carry stale
573
- * levels relative to their new parent. `TagAlias.tagSlug` references
574
- * are also rewritten, then `fromTag` is deleted.
575
- *
576
- * Note: Consuming packages are responsible for updating their own
577
- * join tables (e.g. `asset_tags`).
578
- *
579
- * @param fromSlug - The tag to merge from
580
- * @param toSlug - The tag to merge into
581
- * @param context - Optional context. When provided, both tags are
582
- * resolved within it. When omitted, both slugs must be unambiguous
583
- * across contexts.
584
- * @throws Error if either slug fails to resolve, if either slug is
585
- * ambiguous, or if the two tags live in different contexts.
586
- */
587
- async mergeTag(fromSlug, toSlug, context) {
588
- const fromTag = await this.resolveBySlug(fromSlug, context);
589
- const toTag = await this.resolveBySlug(toSlug, context ?? fromTag?.context);
590
- if (!fromTag) throw new Error(`Source tag '${fromSlug}' not found`);
591
- if (!toTag) throw new Error(`Target tag '${toSlug}' not found`);
592
- if (!fromTag.id) throw new Error(`Source tag '${fromSlug}' has no id`);
593
- if (!toTag.id) throw new Error(`Target tag '${toSlug}' has no id`);
594
- if (fromTag.context !== toTag.context) {
595
- throw new Error(
596
- `Cannot merge '${fromSlug}' (context '${fromTag.context}') into '${toSlug}' (context '${toTag.context}') — contexts must match.`
597
- );
598
- }
599
- if (fromTag.id === toTag.id) {
600
- throw new Error(`Cannot merge tag '${fromSlug}' into itself.`);
601
- }
602
- const fromDescendants = await fromTag.getDescendants();
603
- if (fromDescendants.some((d) => d.id === toTag.id)) {
604
- throw new Error(
605
- `Cannot merge '${fromSlug}' into '${toSlug}' — target is a descendant of source (would create a cycle).`
606
- );
607
- }
608
- const children = await this.list({
609
- where: { parentId: fromTag.id }
610
- });
611
- const newChildLevel = toTag.level + 1;
612
- for (const child of children) {
613
- child.parentId = toTag.id;
614
- child.level = newChildLevel;
615
- await child.save();
616
- await this.updateDescendantLevels(child);
617
- }
618
- const { TagAliasCollection: TagAliasCollection2 } = await Promise.resolve().then(() => tagAliases);
619
- const aliasCollection = await TagAliasCollection2.create(this.options);
620
- const aliasContexts = [fromTag.context];
621
- if (fromTag.context === "global") {
622
- aliasContexts.push("");
623
- }
624
- const aliases = await aliasCollection.list({
625
- where: { tagSlug: fromSlug, context: aliasContexts }
626
- });
627
- for (const alias of aliases) {
628
- alias.tagSlug = toSlug;
629
- await alias.save();
630
- }
631
- await fromTag.delete();
632
- }
633
- /**
634
- * Remove tags with no references (cleanup unused tags)
635
- *
636
- * Note: This requires consuming packages to provide usage information.
637
- * By default, only removes tags with no children and no aliases.
638
- *
639
- * @param context - Optional context to filter cleanup
640
- */
641
- async cleanupUnused(context) {
642
- const where = {};
643
- if (context) where.context = context;
644
- const tags2 = await this.list({ where });
645
- const { TagAliasCollection: TagAliasCollection2 } = await Promise.resolve().then(() => tagAliases);
646
- const aliasCollection = await TagAliasCollection2.create(this.options);
647
- let deletedCount = 0;
648
- for (const tag of tags2) {
649
- if (!tag.id) continue;
650
- const children = await this.list({
651
- where: { parentId: tag.id },
652
- limit: 1
653
- });
654
- if (children.length > 0) continue;
655
- const aliases = await aliasCollection.list({
656
- where: { tagSlug: tag.slug },
657
- limit: 1
658
- });
659
- if (aliases.length > 0) continue;
660
- await tag.delete();
661
- deletedCount++;
662
- }
663
- return deletedCount;
664
- }
665
- /**
666
- * Calculate hierarchy level for a tag, looking the parent up by slug.
667
- *
668
- * @param parentSlug - The parent tag slug (null/empty for root)
669
- * @param context - Optional context for the parent lookup. When
670
- * omitted, the parent slug must be unambiguous across contexts.
671
- * @returns The calculated level (root parent → 1, missing parent → 0)
672
- */
673
- async calculateLevel(parentSlug, context) {
674
- if (!parentSlug) return 0;
675
- const parent = await this.resolveBySlug(parentSlug, context);
676
- if (!parent) return 0;
677
- return parent.level + 1;
678
- }
679
- /**
680
- * Update levels for all descendants after moving a tag
681
- *
682
- * @param tag - The tag that was moved
683
- */
684
- async updateDescendantLevels(tag) {
685
- if (!tag.id) return;
686
- const children = await this.list({
687
- where: { parentId: tag.id }
688
- });
689
- for (const child of children) {
690
- child.level = tag.level + 1;
691
- await child.save();
692
- await this.updateDescendantLevels(child);
693
- }
694
- }
695
- // =========================================================================
696
- // Tenant Helper Methods
697
- // =========================================================================
698
- /**
699
- * Find all tags belonging to a specific tenant
700
- *
701
- * @param tenantId - The tenant ID to filter by
702
- * @returns Array of tags for the specified tenant
703
- */
704
- async findByTenant(tenantId2) {
705
- return this.list({ where: { tenantId: tenantId2 } });
706
- }
707
- /**
708
- * Find all global (tenant-less) tags.
709
- *
710
- * Routes through the shared tenant-global helper so it does not throw under
711
- * an active tenant context (an explicit `tenant_id IS NULL` filter would be
712
- * flagged as an isolation violation). (#1600)
713
- *
714
- * @returns Array of global tags with null tenantId
715
- */
716
- async findGlobal() {
717
- return queryGlobal(this);
718
- }
719
- /**
720
- * Find tags for a tenant including global tags.
721
- *
722
- * Fails closed if an active tenant context requests a different tenant's
723
- * rows; the admin/system path keeps the cross-tenant capability. (#1600)
724
- *
725
- * @param tenantId - The tenant ID to filter by
726
- * @returns Array of tags for the tenant plus all global tags
727
- */
728
- async findWithGlobals(tenantId2) {
729
- return queryWithGlobals(this, tenantId2, "Tag.findWithGlobals");
730
- }
731
- }
732
- const tags = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
733
- __proto__: null,
734
- TagCollection
735
- }, Symbol.toStringTag, { value: "Module" }));
736
- export {
737
- Tag,
738
- TagAlias,
739
- TagAliasCollection,
740
- TagCollection,
741
- calculateLevel,
742
- generateUniqueSlug,
743
- hasCircularReference,
744
- sanitizeSlug,
745
- validateSlug
368
+ //#endregion
369
+ //#region src/tags.ts
370
+ var tags_exports = /* @__PURE__ */ __exportAll({ TagCollection: () => TagCollection });
371
+ var TagCollection = class extends SmrtCollection {
372
+ static _itemClass = Tag;
373
+ /**
374
+ * Get or create a tag with context
375
+ *
376
+ * @param slug - Tag slug
377
+ * @param context - Tag context (default: 'global')
378
+ * @returns Tag instance
379
+ */
380
+ async getOrCreate(slug, context = "global") {
381
+ const existing = await this.list({
382
+ where: {
383
+ slug,
384
+ context
385
+ },
386
+ limit: 1
387
+ });
388
+ if (existing.length > 0) return existing[0];
389
+ return await this.create({
390
+ slug,
391
+ name: slug.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()),
392
+ context,
393
+ level: 0
394
+ });
395
+ }
396
+ /**
397
+ * Resolve a tag by slug, optionally scoped to a context.
398
+ *
399
+ * Tags are identified by `(slug, context)`. When `context` is omitted
400
+ * and the slug exists in more than one context, this throws a clear
401
+ * ambiguity error rather than silently picking the first matching row.
402
+ * Callers that know their context should pass it; callers that work in
403
+ * a single-context world can leave it off.
404
+ *
405
+ * @returns The matching Tag, or `null` if nothing matches.
406
+ * @throws Error if `context` is omitted and the slug is ambiguous.
407
+ */
408
+ async resolveBySlug(slug, context) {
409
+ const where = { slug };
410
+ if (context !== void 0) where.context = context;
411
+ const matches = await this.list({
412
+ where,
413
+ limit: 2
414
+ });
415
+ if (matches.length === 0) return null;
416
+ if (matches.length > 1) throw new Error(`Tag slug '${slug}' is ambiguous: resolves to ${matches.length}+ rows across contexts. Pass an explicit \`context\` argument.`);
417
+ return matches[0];
418
+ }
419
+ /**
420
+ * List tags by context with optional parent filtering by slug.
421
+ *
422
+ * @param context - The context to filter by
423
+ * @param parentSlug - Optional parent slug to filter children. Pass an
424
+ * empty string or `null` to find root tags; pass a slug to find that
425
+ * tag's immediate children. Typed as `string | null` so TypeScript
426
+ * callers can pass `null` without a cast — the `null` and `''` paths
427
+ * are both treated as "roots only".
428
+ * @returns Array of matching tags
429
+ */
430
+ async listByContext(context, parentSlug) {
431
+ const where = { context };
432
+ if (parentSlug === "" || parentSlug === null) where.parentId = null;
433
+ else if (parentSlug !== void 0) {
434
+ const parent = await this.get({
435
+ slug: parentSlug,
436
+ context
437
+ });
438
+ if (!parent?.id) return [];
439
+ where.parentId = parent.id;
440
+ }
441
+ return await this.list({ where });
442
+ }
443
+ /**
444
+ * Get root tags (no parent) for a context
445
+ *
446
+ * @param context - The context to filter by (default: 'global')
447
+ * @returns Array of root tags
448
+ */
449
+ async getRootTags(context = "global") {
450
+ return await this.list({ where: {
451
+ context,
452
+ parentId: null
453
+ } });
454
+ }
455
+ /**
456
+ * Get immediate children of a parent tag, looked up by slug.
457
+ *
458
+ * @param parentSlug - The parent tag slug
459
+ * @param context - Optional context for the parent lookup. When omitted,
460
+ * the parent slug must be unambiguous across contexts (throws if not).
461
+ * @returns Array of child tags, or `[]` if the parent slug doesn't
462
+ * resolve. Children are filtered to the resolved parent's context so
463
+ * cross-context children don't leak in.
464
+ */
465
+ async getChildren(parentSlug, context) {
466
+ const parent = await this.resolveBySlug(parentSlug, context);
467
+ if (!parent?.id) return [];
468
+ return await this.list({ where: {
469
+ parentId: parent.id,
470
+ context: parent.context
471
+ } });
472
+ }
473
+ /**
474
+ * Get tag hierarchy (all ancestors and descendants)
475
+ *
476
+ * @param slug - The tag slug
477
+ * @param context - Optional context for the slug lookup. When omitted,
478
+ * the slug must be unambiguous across contexts.
479
+ * @returns Object with ancestors, current tag, and descendants
480
+ */
481
+ async getHierarchy(slug, context) {
482
+ const tag = await this.resolveBySlug(slug, context);
483
+ if (!tag) throw new Error(`Tag '${slug}' not found`);
484
+ const [ancestors, descendants] = await Promise.all([tag.getAncestors(), tag.getDescendants()]);
485
+ return {
486
+ ancestors,
487
+ current: tag,
488
+ descendants
489
+ };
490
+ }
491
+ /**
492
+ * Move a tag to a new parent. Slug-based API; UUIDs resolved internally.
493
+ *
494
+ * Cycle detection is inlined here (mirroring `SmrtHierarchical.moveTo`'s
495
+ * self-loop + descendant checks) so that both `parentId` and the
496
+ * denormalised `level` field can be persisted in a single `save()`.
497
+ * Delegating to `moveTo` would write `parentId` first and `level` in a
498
+ * second save if the second save failed, the tag would be left with
499
+ * the new parent but a stale level, breaking the depth cache.
500
+ *
501
+ * After the moved tag persists, descendant levels are recalculated
502
+ * recursively via `updateDescendantLevels`.
503
+ *
504
+ * @param slug - The tag to move
505
+ * @param newParentSlug - The new parent slug (null for root)
506
+ * @param context - Optional context. When provided, both source and new
507
+ * parent are resolved within it. When omitted, both slugs must be
508
+ * unambiguous across contexts; the resolver throws otherwise.
509
+ * @throws Error if either slug fails to resolve, if either slug is
510
+ * ambiguous across contexts (no context provided), if source and new
511
+ * parent live in different contexts, or if the move would create a
512
+ * cycle.
513
+ */
514
+ async moveTag(slug, newParentSlug, context) {
515
+ const tag = await this.resolveBySlug(slug, context);
516
+ if (!tag) throw new Error(`Tag '${slug}' not found`);
517
+ let newParent = null;
518
+ if (newParentSlug) {
519
+ newParent = await this.resolveBySlug(newParentSlug, context ?? tag.context);
520
+ if (!newParent) throw new Error(`Tag '${newParentSlug}' not found`);
521
+ if (newParent.context !== tag.context) throw new Error(`Cannot move Tag '${slug}' (context '${tag.context}') under '${newParentSlug}' (context '${newParent.context}') \u2014 contexts must match.`);
522
+ }
523
+ const newParentId = newParent?.id ?? null;
524
+ if (newParentId !== null && newParentId === tag.id) throw new Error(`Cannot move Tag ${tag.id} to itself.`);
525
+ if (newParentId !== null) {
526
+ if ((await tag.getDescendants()).some((d) => d.id === newParentId)) throw new Error(`Cannot move Tag ${tag.id} under one of its own descendants (${newParentId}) \u2014 would create a cycle.`);
527
+ }
528
+ tag.parentId = newParentId;
529
+ tag.level = newParent ? newParent.level + 1 : 0;
530
+ await tag.save();
531
+ await this.updateDescendantLevels(tag);
532
+ }
533
+ /**
534
+ * Merge one tag into another (updates all references)
535
+ *
536
+ * Reparents `fromTag`'s direct children onto `toTag` and recalculates
537
+ * their `level` field plus the level of every descendant without
538
+ * this, children moved from a different depth would carry stale
539
+ * levels relative to their new parent. `TagAlias.tagSlug` references
540
+ * are also rewritten, then `fromTag` is deleted.
541
+ *
542
+ * Note: Consuming packages are responsible for updating their own
543
+ * join tables (e.g. `asset_tags`).
544
+ *
545
+ * @param fromSlug - The tag to merge from
546
+ * @param toSlug - The tag to merge into
547
+ * @param context - Optional context. When provided, both tags are
548
+ * resolved within it. When omitted, both slugs must be unambiguous
549
+ * across contexts.
550
+ * @throws Error if either slug fails to resolve, if either slug is
551
+ * ambiguous, or if the two tags live in different contexts.
552
+ */
553
+ async mergeTag(fromSlug, toSlug, context) {
554
+ const fromTag = await this.resolveBySlug(fromSlug, context);
555
+ const toTag = await this.resolveBySlug(toSlug, context ?? fromTag?.context);
556
+ if (!fromTag) throw new Error(`Source tag '${fromSlug}' not found`);
557
+ if (!toTag) throw new Error(`Target tag '${toSlug}' not found`);
558
+ if (!fromTag.id) throw new Error(`Source tag '${fromSlug}' has no id`);
559
+ if (!toTag.id) throw new Error(`Target tag '${toSlug}' has no id`);
560
+ if (fromTag.context !== toTag.context) throw new Error(`Cannot merge '${fromSlug}' (context '${fromTag.context}') into '${toSlug}' (context '${toTag.context}') \u2014 contexts must match.`);
561
+ if (fromTag.id === toTag.id) throw new Error(`Cannot merge tag '${fromSlug}' into itself.`);
562
+ if ((await fromTag.getDescendants()).some((d) => d.id === toTag.id)) throw new Error(`Cannot merge '${fromSlug}' into '${toSlug}' \u2014 target is a descendant of source (would create a cycle).`);
563
+ const children = await this.list({ where: { parentId: fromTag.id } });
564
+ const newChildLevel = toTag.level + 1;
565
+ for (const child of children) {
566
+ child.parentId = toTag.id;
567
+ child.level = newChildLevel;
568
+ await child.save();
569
+ await this.updateDescendantLevels(child);
570
+ }
571
+ const { TagAliasCollection } = await Promise.resolve().then(() => tag_aliases_exports);
572
+ const aliasCollection = await TagAliasCollection.create(this.options);
573
+ const aliasContexts = [fromTag.context];
574
+ if (fromTag.context === "global") aliasContexts.push("");
575
+ const aliases = await aliasCollection.list({ where: {
576
+ tagSlug: fromSlug,
577
+ context: aliasContexts
578
+ } });
579
+ for (const alias of aliases) {
580
+ alias.tagSlug = toSlug;
581
+ await alias.save();
582
+ }
583
+ await fromTag.delete();
584
+ }
585
+ /**
586
+ * Remove tags with no references (cleanup unused tags)
587
+ *
588
+ * Note: This requires consuming packages to provide usage information.
589
+ * By default, only removes tags with no children and no aliases.
590
+ *
591
+ * @param context - Optional context to filter cleanup
592
+ */
593
+ async cleanupUnused(context) {
594
+ const where = {};
595
+ if (context) where.context = context;
596
+ const tags = await this.list({ where });
597
+ const { TagAliasCollection } = await Promise.resolve().then(() => tag_aliases_exports);
598
+ const aliasCollection = await TagAliasCollection.create(this.options);
599
+ let deletedCount = 0;
600
+ for (const tag of tags) {
601
+ if (!tag.id) continue;
602
+ if ((await this.list({
603
+ where: { parentId: tag.id },
604
+ limit: 1
605
+ })).length > 0) continue;
606
+ if ((await aliasCollection.list({
607
+ where: { tagSlug: tag.slug },
608
+ limit: 1
609
+ })).length > 0) continue;
610
+ await tag.delete();
611
+ deletedCount++;
612
+ }
613
+ return deletedCount;
614
+ }
615
+ /**
616
+ * Calculate hierarchy level for a tag, looking the parent up by slug.
617
+ *
618
+ * @param parentSlug - The parent tag slug (null/empty for root)
619
+ * @param context - Optional context for the parent lookup. When
620
+ * omitted, the parent slug must be unambiguous across contexts.
621
+ * @returns The calculated level (root parent → 1, missing parent → 0)
622
+ */
623
+ async calculateLevel(parentSlug, context) {
624
+ if (!parentSlug) return 0;
625
+ const parent = await this.resolveBySlug(parentSlug, context);
626
+ if (!parent) return 0;
627
+ return parent.level + 1;
628
+ }
629
+ /**
630
+ * Update levels for all descendants after moving a tag
631
+ *
632
+ * @param tag - The tag that was moved
633
+ */
634
+ async updateDescendantLevels(tag) {
635
+ if (!tag.id) return;
636
+ const children = await this.list({ where: { parentId: tag.id } });
637
+ for (const child of children) {
638
+ child.level = tag.level + 1;
639
+ await child.save();
640
+ await this.updateDescendantLevels(child);
641
+ }
642
+ }
643
+ /**
644
+ * Find all tags belonging to a specific tenant
645
+ *
646
+ * @param tenantId - The tenant ID to filter by
647
+ * @returns Array of tags for the specified tenant
648
+ */
649
+ async findByTenant(tenantId) {
650
+ return this.list({ where: { tenantId } });
651
+ }
652
+ /**
653
+ * Find all global (tenant-less) tags.
654
+ *
655
+ * Routes through the shared tenant-global helper so it does not throw under
656
+ * an active tenant context (an explicit `tenant_id IS NULL` filter would be
657
+ * flagged as an isolation violation). (#1600)
658
+ *
659
+ * @returns Array of global tags with null tenantId
660
+ */
661
+ async findGlobal() {
662
+ return queryGlobal(this);
663
+ }
664
+ /**
665
+ * Find tags for a tenant including global tags.
666
+ *
667
+ * Fails closed if an active tenant context requests a different tenant's
668
+ * rows; the admin/system path keeps the cross-tenant capability. (#1600)
669
+ *
670
+ * @param tenantId - The tenant ID to filter by
671
+ * @returns Array of tags for the tenant plus all global tags
672
+ */
673
+ async findWithGlobals(tenantId) {
674
+ return queryWithGlobals(this, tenantId, "Tag.findWithGlobals");
675
+ }
746
676
  };
747
- //# sourceMappingURL=index.js.map
677
+ //#endregion
678
+ export { Tag, TagAlias, TagAliasCollection, TagCollection, calculateLevel, generateUniqueSlug, hasCircularReference, sanitizeSlug, validateSlug };
679
+
680
+ //# sourceMappingURL=index.js.map