@happyvertical/smrt-tags 0.30.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/dist/index.js ADDED
@@ -0,0 +1,739 @@
1
+ import { ObjectRegistry, field, smrt, SmrtHierarchical, SmrtObject, SmrtCollection } from "@happyvertical/smrt-core";
2
+ import { tenantId, TenantScoped } from "@happyvertical/smrt-tenancy";
3
+ import { calculateLevel, generateUniqueSlug, hasCircularReference, sanitizeSlug, validateSlug } from "./utils.js";
4
+ ObjectRegistry.registerPackageManifest(
5
+ new URL("./manifest.json", import.meta.url)
6
+ );
7
+ var __defProp$1 = Object.defineProperty;
8
+ var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
9
+ 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;
16
+ };
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
+ }
125
+ };
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);
141
+ var __defProp = Object.defineProperty;
142
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
143
+ 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;
150
+ };
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
+ }
206
+ };
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
+ * @returns Array of global tag aliases with null tenantId
365
+ */
366
+ async findGlobal() {
367
+ return this.list({ where: { tenantId: null } });
368
+ }
369
+ /**
370
+ * Find tag aliases for a tenant including global aliases
371
+ *
372
+ * @param tenantId - The tenant ID to filter by
373
+ * @returns Array of tag aliases for the tenant plus all global aliases
374
+ */
375
+ async findWithGlobals(tenantId2) {
376
+ return this.query(
377
+ `SELECT * FROM ${this.tableName} WHERE tenant_id = ? OR tenant_id IS NULL`,
378
+ [tenantId2]
379
+ );
380
+ }
381
+ }
382
+ const tagAliases = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
383
+ __proto__: null,
384
+ TagAliasCollection
385
+ }, Symbol.toStringTag, { value: "Module" }));
386
+ class TagCollection extends SmrtCollection {
387
+ static _itemClass = Tag;
388
+ /**
389
+ * Get or create a tag with context
390
+ *
391
+ * @param slug - Tag slug
392
+ * @param context - Tag context (default: 'global')
393
+ * @returns Tag instance
394
+ */
395
+ async getOrCreate(slug, context = "global") {
396
+ const existing = await this.list({
397
+ where: { slug, context },
398
+ limit: 1
399
+ });
400
+ if (existing.length > 0) {
401
+ return existing[0];
402
+ }
403
+ return await this.create({
404
+ slug,
405
+ name: slug.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()),
406
+ context,
407
+ level: 0
408
+ });
409
+ }
410
+ /**
411
+ * Resolve a tag by slug, optionally scoped to a context.
412
+ *
413
+ * Tags are identified by `(slug, context)`. When `context` is omitted
414
+ * and the slug exists in more than one context, this throws a clear
415
+ * ambiguity error rather than silently picking the first matching row.
416
+ * Callers that know their context should pass it; callers that work in
417
+ * a single-context world can leave it off.
418
+ *
419
+ * @returns The matching Tag, or `null` if nothing matches.
420
+ * @throws Error if `context` is omitted and the slug is ambiguous.
421
+ */
422
+ async resolveBySlug(slug, context) {
423
+ const where = { slug };
424
+ if (context !== void 0) where.context = context;
425
+ const matches = await this.list({ where, limit: 2 });
426
+ if (matches.length === 0) return null;
427
+ if (matches.length > 1) {
428
+ throw new Error(
429
+ `Tag slug '${slug}' is ambiguous: resolves to ${matches.length}+ rows across contexts. Pass an explicit \`context\` argument.`
430
+ );
431
+ }
432
+ return matches[0];
433
+ }
434
+ /**
435
+ * List tags by context with optional parent filtering by slug.
436
+ *
437
+ * @param context - The context to filter by
438
+ * @param parentSlug - Optional parent slug to filter children. Pass an
439
+ * empty string or `null` to find root tags; pass a slug to find that
440
+ * tag's immediate children. Typed as `string | null` so TypeScript
441
+ * callers can pass `null` without a cast — the `null` and `''` paths
442
+ * are both treated as "roots only".
443
+ * @returns Array of matching tags
444
+ */
445
+ async listByContext(context, parentSlug) {
446
+ const where = { context };
447
+ if (parentSlug === "" || parentSlug === null) {
448
+ where.parentId = null;
449
+ } else if (parentSlug !== void 0) {
450
+ const parent = await this.get({ slug: parentSlug, context });
451
+ if (!parent?.id) return [];
452
+ where.parentId = parent.id;
453
+ }
454
+ return await this.list({ where });
455
+ }
456
+ /**
457
+ * Get root tags (no parent) for a context
458
+ *
459
+ * @param context - The context to filter by (default: 'global')
460
+ * @returns Array of root tags
461
+ */
462
+ async getRootTags(context = "global") {
463
+ return await this.list({
464
+ where: { context, parentId: null }
465
+ });
466
+ }
467
+ /**
468
+ * Get immediate children of a parent tag, looked up by slug.
469
+ *
470
+ * @param parentSlug - The parent tag slug
471
+ * @param context - Optional context for the parent lookup. When omitted,
472
+ * the parent slug must be unambiguous across contexts (throws if not).
473
+ * @returns Array of child tags, or `[]` if the parent slug doesn't
474
+ * resolve. Children are filtered to the resolved parent's context so
475
+ * cross-context children don't leak in.
476
+ */
477
+ async getChildren(parentSlug, context) {
478
+ const parent = await this.resolveBySlug(parentSlug, context);
479
+ if (!parent?.id) return [];
480
+ return await this.list({
481
+ where: { parentId: parent.id, context: parent.context }
482
+ });
483
+ }
484
+ /**
485
+ * Get tag hierarchy (all ancestors and descendants)
486
+ *
487
+ * @param slug - The tag slug
488
+ * @param context - Optional context for the slug lookup. When omitted,
489
+ * the slug must be unambiguous across contexts.
490
+ * @returns Object with ancestors, current tag, and descendants
491
+ */
492
+ async getHierarchy(slug, context) {
493
+ const tag = await this.resolveBySlug(slug, context);
494
+ if (!tag) throw new Error(`Tag '${slug}' not found`);
495
+ const [ancestors, descendants] = await Promise.all([
496
+ tag.getAncestors(),
497
+ tag.getDescendants()
498
+ ]);
499
+ return { ancestors, current: tag, descendants };
500
+ }
501
+ /**
502
+ * Move a tag to a new parent. Slug-based API; UUIDs resolved internally.
503
+ *
504
+ * Cycle detection is inlined here (mirroring `SmrtHierarchical.moveTo`'s
505
+ * self-loop + descendant checks) so that both `parentId` and the
506
+ * denormalised `level` field can be persisted in a single `save()`.
507
+ * Delegating to `moveTo` would write `parentId` first and `level` in a
508
+ * second save — if the second save failed, the tag would be left with
509
+ * the new parent but a stale level, breaking the depth cache.
510
+ *
511
+ * After the moved tag persists, descendant levels are recalculated
512
+ * recursively via `updateDescendantLevels`.
513
+ *
514
+ * @param slug - The tag to move
515
+ * @param newParentSlug - The new parent slug (null for root)
516
+ * @param context - Optional context. When provided, both source and new
517
+ * parent are resolved within it. When omitted, both slugs must be
518
+ * unambiguous across contexts; the resolver throws otherwise.
519
+ * @throws Error if either slug fails to resolve, if either slug is
520
+ * ambiguous across contexts (no context provided), if source and new
521
+ * parent live in different contexts, or if the move would create a
522
+ * cycle.
523
+ */
524
+ async moveTag(slug, newParentSlug, context) {
525
+ const tag = await this.resolveBySlug(slug, context);
526
+ if (!tag) throw new Error(`Tag '${slug}' not found`);
527
+ let newParent = null;
528
+ if (newParentSlug) {
529
+ newParent = await this.resolveBySlug(
530
+ newParentSlug,
531
+ context ?? tag.context
532
+ );
533
+ if (!newParent) {
534
+ throw new Error(`Tag '${newParentSlug}' not found`);
535
+ }
536
+ if (newParent.context !== tag.context) {
537
+ throw new Error(
538
+ `Cannot move Tag '${slug}' (context '${tag.context}') under '${newParentSlug}' (context '${newParent.context}') — contexts must match.`
539
+ );
540
+ }
541
+ }
542
+ const newParentId = newParent?.id ?? null;
543
+ if (newParentId !== null && newParentId === tag.id) {
544
+ throw new Error(`Cannot move Tag ${tag.id} to itself.`);
545
+ }
546
+ if (newParentId !== null) {
547
+ const descendants = await tag.getDescendants();
548
+ if (descendants.some((d) => d.id === newParentId)) {
549
+ throw new Error(
550
+ `Cannot move Tag ${tag.id} under one of its own descendants (${newParentId}) — would create a cycle.`
551
+ );
552
+ }
553
+ }
554
+ tag.parentId = newParentId;
555
+ tag.level = newParent ? newParent.level + 1 : 0;
556
+ await tag.save();
557
+ await this.updateDescendantLevels(tag);
558
+ }
559
+ /**
560
+ * Merge one tag into another (updates all references)
561
+ *
562
+ * Reparents `fromTag`'s direct children onto `toTag` and recalculates
563
+ * their `level` field plus the level of every descendant — without
564
+ * this, children moved from a different depth would carry stale
565
+ * levels relative to their new parent. `TagAlias.tagSlug` references
566
+ * are also rewritten, then `fromTag` is deleted.
567
+ *
568
+ * Note: Consuming packages are responsible for updating their own
569
+ * join tables (e.g. `asset_tags`).
570
+ *
571
+ * @param fromSlug - The tag to merge from
572
+ * @param toSlug - The tag to merge into
573
+ * @param context - Optional context. When provided, both tags are
574
+ * resolved within it. When omitted, both slugs must be unambiguous
575
+ * across contexts.
576
+ * @throws Error if either slug fails to resolve, if either slug is
577
+ * ambiguous, or if the two tags live in different contexts.
578
+ */
579
+ async mergeTag(fromSlug, toSlug, context) {
580
+ const fromTag = await this.resolveBySlug(fromSlug, context);
581
+ const toTag = await this.resolveBySlug(toSlug, context ?? fromTag?.context);
582
+ if (!fromTag) throw new Error(`Source tag '${fromSlug}' not found`);
583
+ if (!toTag) throw new Error(`Target tag '${toSlug}' not found`);
584
+ if (!fromTag.id) throw new Error(`Source tag '${fromSlug}' has no id`);
585
+ if (!toTag.id) throw new Error(`Target tag '${toSlug}' has no id`);
586
+ if (fromTag.context !== toTag.context) {
587
+ throw new Error(
588
+ `Cannot merge '${fromSlug}' (context '${fromTag.context}') into '${toSlug}' (context '${toTag.context}') — contexts must match.`
589
+ );
590
+ }
591
+ if (fromTag.id === toTag.id) {
592
+ throw new Error(`Cannot merge tag '${fromSlug}' into itself.`);
593
+ }
594
+ const fromDescendants = await fromTag.getDescendants();
595
+ if (fromDescendants.some((d) => d.id === toTag.id)) {
596
+ throw new Error(
597
+ `Cannot merge '${fromSlug}' into '${toSlug}' — target is a descendant of source (would create a cycle).`
598
+ );
599
+ }
600
+ const children = await this.list({
601
+ where: { parentId: fromTag.id }
602
+ });
603
+ const newChildLevel = toTag.level + 1;
604
+ for (const child of children) {
605
+ child.parentId = toTag.id;
606
+ child.level = newChildLevel;
607
+ await child.save();
608
+ await this.updateDescendantLevels(child);
609
+ }
610
+ const { TagAliasCollection: TagAliasCollection2 } = await Promise.resolve().then(() => tagAliases);
611
+ const aliasCollection = await TagAliasCollection2.create(
612
+ this.options
613
+ );
614
+ const aliasContexts = [fromTag.context];
615
+ if (fromTag.context === "global") {
616
+ aliasContexts.push("");
617
+ }
618
+ const aliases = await aliasCollection.list({
619
+ where: { tagSlug: fromSlug, context: aliasContexts }
620
+ });
621
+ for (const alias of aliases) {
622
+ alias.tagSlug = toSlug;
623
+ await alias.save();
624
+ }
625
+ await fromTag.delete();
626
+ }
627
+ /**
628
+ * Remove tags with no references (cleanup unused tags)
629
+ *
630
+ * Note: This requires consuming packages to provide usage information.
631
+ * By default, only removes tags with no children and no aliases.
632
+ *
633
+ * @param context - Optional context to filter cleanup
634
+ */
635
+ async cleanupUnused(context) {
636
+ const where = {};
637
+ if (context) where.context = context;
638
+ const tags2 = await this.list({ where });
639
+ const { TagAliasCollection: TagAliasCollection2 } = await Promise.resolve().then(() => tagAliases);
640
+ const aliasCollection = await TagAliasCollection2.create(
641
+ this.options
642
+ );
643
+ let deletedCount = 0;
644
+ for (const tag of tags2) {
645
+ if (!tag.id) continue;
646
+ const children = await this.list({
647
+ where: { parentId: tag.id },
648
+ limit: 1
649
+ });
650
+ if (children.length > 0) continue;
651
+ const aliases = await aliasCollection.list({
652
+ where: { tagSlug: tag.slug },
653
+ limit: 1
654
+ });
655
+ if (aliases.length > 0) continue;
656
+ await tag.delete();
657
+ deletedCount++;
658
+ }
659
+ return deletedCount;
660
+ }
661
+ /**
662
+ * Calculate hierarchy level for a tag, looking the parent up by slug.
663
+ *
664
+ * @param parentSlug - The parent tag slug (null/empty for root)
665
+ * @param context - Optional context for the parent lookup. When
666
+ * omitted, the parent slug must be unambiguous across contexts.
667
+ * @returns The calculated level (root parent → 1, missing parent → 0)
668
+ */
669
+ async calculateLevel(parentSlug, context) {
670
+ if (!parentSlug) return 0;
671
+ const parent = await this.resolveBySlug(parentSlug, context);
672
+ if (!parent) return 0;
673
+ return parent.level + 1;
674
+ }
675
+ /**
676
+ * Update levels for all descendants after moving a tag
677
+ *
678
+ * @param tag - The tag that was moved
679
+ */
680
+ async updateDescendantLevels(tag) {
681
+ if (!tag.id) return;
682
+ const children = await this.list({
683
+ where: { parentId: tag.id }
684
+ });
685
+ for (const child of children) {
686
+ child.level = tag.level + 1;
687
+ await child.save();
688
+ await this.updateDescendantLevels(child);
689
+ }
690
+ }
691
+ // =========================================================================
692
+ // Tenant Helper Methods
693
+ // =========================================================================
694
+ /**
695
+ * Find all tags belonging to a specific tenant
696
+ *
697
+ * @param tenantId - The tenant ID to filter by
698
+ * @returns Array of tags for the specified tenant
699
+ */
700
+ async findByTenant(tenantId2) {
701
+ return this.list({ where: { tenantId: tenantId2 } });
702
+ }
703
+ /**
704
+ * Find all global (tenant-less) tags
705
+ *
706
+ * @returns Array of global tags with null tenantId
707
+ */
708
+ async findGlobal() {
709
+ return this.list({ where: { tenantId: null } });
710
+ }
711
+ /**
712
+ * Find tags for a tenant including global tags
713
+ *
714
+ * @param tenantId - The tenant ID to filter by
715
+ * @returns Array of tags for the tenant plus all global tags
716
+ */
717
+ async findWithGlobals(tenantId2) {
718
+ return this.query(
719
+ `SELECT * FROM ${this.tableName} WHERE tenant_id = ? OR tenant_id IS NULL`,
720
+ [tenantId2]
721
+ );
722
+ }
723
+ }
724
+ const tags = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
725
+ __proto__: null,
726
+ TagCollection
727
+ }, Symbol.toStringTag, { value: "Module" }));
728
+ export {
729
+ Tag,
730
+ TagAlias,
731
+ TagAliasCollection,
732
+ TagCollection,
733
+ calculateLevel,
734
+ generateUniqueSlug,
735
+ hasCircularReference,
736
+ sanitizeSlug,
737
+ validateSlug
738
+ };
739
+ //# sourceMappingURL=index.js.map