@have/tags SPEC.md
Design Notes
This document outlines the database schema for the @have/tags package. The architecture is designed for flexibility, reusability, and multi-language support across all SMRT-based packages.
The key architectural decisions are:
- Context-Based Scoping: Tags are grouped by
context(e.g., 'assets', 'content', 'global'), allowing for package-specific or shared tag vocabularies. - Optional Hierarchies: Tags can have parent-child relationships via
parent_slug, enabling taxonomies and category trees. - Slug-Based Primary Keys: Human-readable
slugas the primary key for intuitive queries and URLs. - Multi-Language Aliases: A separate
tag_aliasestable supports translations, variations, and alternative spellings. - Flexible Metadata: A JSON
metadatafield allows application-specific data (colors, icons, usage counts, etc.) without schema changes. - Reusable Join Pattern: Consuming packages implement their own join tables (e.g.,
asset_tags,content_tags) for many-to-many relationships.
Core Tables
Primary Tables
tags
The central table for tag definitions.
slug: (String, Primary Key) - Unique identifier within context (e.g., 'electronics', 'user-avatar', 'featured').name: (String, Required) - Human-readable display name (e.g., 'Electronics', 'User Avatar', 'Featured').context: (String, Required, Default: 'global') - Namespace/grouping (e.g., 'assets', 'content', 'products', 'global').parent_slug: (String, FK totags.slug, Nullable) - Points to parent tag for hierarchical taxonomies.level: (Integer, Default: 0) - Hierarchy depth (0 = root, 1 = first level, etc.). Auto-calculated.description: (Text, Nullable) - Optional description or usage notes.metadata: (JSON, Nullable) - Flexible storage for application data.created_at: (Datetime) - Timestamp of creation.updated_at: (Datetime) - Timestamp of last update.
Indexes:
- Primary key on
slug - Index on
contextfor fast context filtering - Index on
parent_slugfor hierarchy queries - Composite index on
(context, parent_slug)for listing tags by context and parent
Constraints:
parent_slugmust reference a validtags.slugor be NULLlevelmust be >= 0
tag_aliases
Stores alternative names, translations, and variations for tags.
id: (UUID, Primary Key) - Unique identifier for the alias.tag_slug: (String, FK totags.slug, Required) - The tag this alias belongs to.alias: (String, Required) - The alternative name or translation.language: (String, Nullable) - ISO 639-1 language code (e.g., 'en', 'es', 'fr', 'de'). NULL for language-neutral aliases.context: (String, Nullable) - Optional context scoping for the alias.created_at: (Datetime) - Timestamp of creation.
Indexes:
- Primary key on
id - Index on
tag_slugfor reverse lookups - Index on
aliasfor search - Composite index on
(alias, language)for multi-language search
Constraints:
- Unique constraint on
(tag_slug, alias, language, context)- Prevents duplicate aliases tag_slugmust reference a validtags.slug
Join Table Pattern (Implemented by Consuming Packages)
Each package that uses tags creates its own join table following this pattern:
Example: asset_tags
CREATE TABLE asset_tags (
asset_id UUID REFERENCES assets(id) ON DELETE CASCADE,
tag_slug TEXT REFERENCES tags(slug) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (asset_id, tag_slug)
);
Example: content_tags
CREATE TABLE content_tags (
content_id UUID REFERENCES contents(id) ON DELETE CASCADE,
tag_slug TEXT REFERENCES tags(slug) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (content_id, tag_slug)
);
Key Points:
- Each consuming package creates its own join table
- Join table name follows pattern:
{singular_entity}_tags - Foreign keys reference the package's entity table and
tags.slug - Composite primary key on both IDs prevents duplicates
- ON DELETE CASCADE ensures cleanup when entities or tags are deleted
SMRT Integration
The @have/tags package will be built on the @have/smrt framework, leveraging its AI-first object model, ORM capabilities, and code generation tools.
1. SMRT Object Implementation
The database tables will be represented as SmrtObject classes.
import { SmrtObject, SmrtCollection } from '@have/smrt';
import { text, integer, datetime, foreignKey, oneToMany } from '@have/smrt/fields';
// Tag Model
class Tag extends SmrtObject {
slug = text({ primaryKey: true });
name = text({ required: true });
context = text({ default: 'global' });
parentSlug = foreignKey(Tag, { nullable: true }); // Self-referencing for hierarchy
level = integer({ default: 0 });
description = text();
metadata = text(); // JSON stored as text, parsed on access
// Relationships
aliases = oneToMany(TagAlias);
children = oneToMany(Tag, { foreignKey: 'parentSlug' });
// Timestamps
createdAt = datetime({ default: () => new Date() });
updatedAt = datetime({ default: () => new Date() });
constructor(options: any = {}) {
super(options);
Object.assign(this, options);
}
/**
* Get metadata as parsed object
*/
getMetadata(): Record<string, any> {
return this.metadata ? JSON.parse(this.metadata) : {};
}
/**
* Set metadata from object
*/
setMetadata(data: Record<string, any>): void {
this.metadata = JSON.stringify(data);
}
}
// Tag Alias Model
class TagAlias extends SmrtObject {
tagSlug = foreignKey(Tag, { required: true });
alias = text({ required: true });
language = text(); // Nullable
context = text(); // Nullable
// Timestamps
createdAt = datetime({ default: () => new Date() });
constructor(options: any = {}) {
super(options);
Object.assign(this, options);
}
}
2. Tag Collection with Context Support
import { SmrtCollection } from '@have/smrt';
class TagCollection extends SmrtCollection<Tag> {
static readonly _itemClass = Tag;
/**
* Get or create a tag with context
*/
async getOrCreate(slug: string, context: string = 'global'): Promise<Tag> {
return this.getOrUpsert({ slug, context });
}
/**
* List tags by context with optional parent filtering
*/
async listByContext(context: string, parentSlug?: string): Promise<Tag[]> {
const where: any = { context };
if (parentSlug !== undefined) {
where.parentSlug = parentSlug;
}
return this.list({ where });
}
/**
* Get root tags (no parent) for a context
*/
async getRootTags(context: string = 'global'): Promise<Tag[]> {
return this.list({
where: { context, parentSlug: null }
});
}
/**
* Get tag hierarchy (all ancestors and descendants)
*/
async getHierarchy(slug: string): Promise<{
ancestors: Tag[];
current: Tag;
descendants: Tag[];
}> {
const tag = await this.get({ slug });
if (!tag) throw new Error(`Tag '${slug}' not found`);
const ancestors = await this.getAncestors(tag);
const descendants = await this.getDescendants(tag);
return { ancestors, current: tag, descendants };
}
/**
* Get all ancestor tags (recursive)
*/
private async getAncestors(tag: Tag): Promise<Tag[]> {
const ancestors: Tag[] = [];
let current = tag;
while (current.parentSlug) {
const parent = await this.get({ slug: current.parentSlug });
if (!parent) break;
ancestors.unshift(parent); // Add to beginning
current = parent;
}
return ancestors;
}
/**
* Get all descendant tags (recursive)
*/
private async getDescendants(tag: Tag): Promise<Tag[]> {
const children = await this.list({ where: { parentSlug: tag.slug } });
const descendants: Tag[] = [...children];
for (const child of children) {
const childDescendants = await this.getDescendants(child);
descendants.push(...childDescendants);
}
return descendants;
}
}
3. Tag Alias Management
class TagAliasCollection extends SmrtCollection<TagAlias> {
static readonly _itemClass = TagAlias;
/**
* Add an alias to a tag
*/
async addAlias(
tagSlug: string,
alias: string,
language?: string,
context?: string
): Promise<TagAlias> {
return this.getOrUpsert({ tagSlug, alias, language, context });
}
/**
* Search tags by alias
*/
async searchByAlias(
alias: string,
language?: string
): Promise<Tag[]> {
const where: any = { alias };
if (language) where.language = language;
const aliases = await this.list({ where });
const tagSlugs = [...new Set(aliases.map(a => a.tagSlug))];
const tagCollection = new TagCollection(this.options);
await tagCollection.initialize();
const tags: Tag[] = [];
for (const slug of tagSlugs) {
const tag = await tagCollection.get({ slug });
if (tag) tags.push(tag);
}
return tags;
}
/**
* Get all aliases for a tag
*/
async getAliasesForTag(tagSlug: string): Promise<TagAlias[]> {
return this.list({ where: { tagSlug } });
}
}
4. Code Generation
The @have/smrt code generators will automatically create:
- A REST API: For managing tags from web applications.
- A CLI: For administrative tasks and batch operations.
- An MCP Server: To expose tagging tools to AI agents.
// Auto-generated API endpoints:
// GET /api/v1/tags - List all tags
// POST /api/v1/tags - Create tag
// GET /api/v1/tags/:slug - Get tag by slug
// PUT /api/v1/tags/:slug - Update tag
// DELETE /api/v1/tags/:slug - Delete tag
// GET /api/v1/tag-aliases - List all aliases
// POST /api/v1/tag-aliases - Create alias
// GET /api/v1/tag-aliases/:id - Get alias by ID
// DELETE /api/v1/tag-aliases/:id - Delete alias
Integration Pattern for Consuming Packages
Example: Assets Package Integration
// In @have/assets package
import { Tag, TagCollection } from '@have/tags';
import { Asset } from './models/Asset';
class Asset extends SmrtObject {
// ... existing fields
/**
* Add a tag to this asset
*/
async addTag(tagSlug: string): Promise<void> {
const tagCollection = new TagCollection(this.options);
await tagCollection.initialize();
// Ensure tag exists (get or create)
const tag = await tagCollection.getOrCreate(tagSlug, 'assets');
// Insert into asset_tags join table
const db = await this.getDb();
await db.execute(
'INSERT INTO asset_tags (asset_id, tag_slug) VALUES (?, ?) ON CONFLICT DO NOTHING',
[this.id, tag.slug]
);
}
/**
* Remove a tag from this asset
*/
async removeTag(tagSlug: string): Promise<void> {
const db = await this.getDb();
await db.execute(
'DELETE FROM asset_tags WHERE asset_id = ? AND tag_slug = ?',
[this.id, tagSlug]
);
}
/**
* Get all tags for this asset
*/
async getTags(): Promise<Tag[]> {
const db = await this.getDb();
const rows = await db.query(
'SELECT tag_slug FROM asset_tags WHERE asset_id = ?',
[this.id]
);
const tagCollection = new TagCollection(this.options);
await tagCollection.initialize();
const tags: Tag[] = [];
for (const row of rows) {
const tag = await tagCollection.get({ slug: row.tag_slug });
if (tag) tags.push(tag);
}
return tags;
}
/**
* Check if asset has a specific tag
*/
async hasTag(tagSlug: string): Promise<boolean> {
const db = await this.getDb();
const rows = await db.query(
'SELECT 1 FROM asset_tags WHERE asset_id = ? AND tag_slug = ? LIMIT 1',
[this.id, tagSlug]
);
return rows.length > 0;
}
}
Core Functions
Tag Lifecycle
createTag(data): Creates a new tag. Requiresslug,name, optionallycontext,parent_slug.getTag(slug, context): Retrieves a tag by slug and context.listTags(options): Lists tags with filtering (by context, parent, level).updateTag(slug, data): Updates a tag's properties (name, description, metadata).deleteTag(slug): Deletes a tag and all its aliases (cascades to join tables).
Hierarchy Management
getRootTags(context): Gets all top-level tags (no parent) for a context.getChildTags(parent_slug): Gets immediate children of a parent tag.getTagHierarchy(slug): Gets full hierarchy (ancestors, current, descendants).moveTag(slug, new_parent_slug): Moves a tag to a new parent (updates level automatically).
Alias Management
addAlias(tag_slug, alias, language, context): Creates an alias for a tag.removeAlias(alias_id): Deletes an alias.getAliasesForTag(tag_slug): Gets all aliases for a tag.searchByAlias(alias, language): Finds tags matching an alias.
Metadata Operations
getTagMetadata(slug): Retrieves parsed metadata object.setTagMetadata(slug, metadata): Sets metadata from object (stored as JSON).updateTagMetadata(slug, updates): Merges updates into existing metadata.
Bulk Operations
bulkCreateTags(tags): Creates multiple tags in a transaction.mergeTag(from_slug, to_slug): Merges one tag into another (updates all join tables).cleanupUnusedTags(context): Removes tags with no references in join tables.
Tag Metadata Examples
The metadata JSON field supports arbitrary application data:
UI Styling
{
"color": "#3b82f6",
"backgroundColor": "#eff6ff",
"icon": "tag",
"emoji": "🏷️"
}
Usage Statistics
{
"usageCount": 142,
"lastUsed": "2025-01-15T10:30:00Z",
"trending": true
}
Display Configuration
{
"featured": true,
"sortOrder": 10,
"showInNav": true,
"displayFormat": "badge"
}
Custom Application Data
{
"aiGenerated": true,
"confidence": 0.95,
"source": "auto-tagger",
"reviewStatus": "approved"
}
Context Examples
The context field enables flexible tag organization:
Package-Specific Contexts
'assets'- Tags specific to asset management'content'- Tags for content/articles'products'- Product categorization tags'profiles'- User/profile tags
Application-Level Contexts
'global'- Tags shared across all packages'ui'- UI/theme-related tags'workflow'- Process/status tags
Multi-Tenancy Contexts
'tenant:acme'- Tags for specific tenant'project:alpha'- Project-specific tags
Hierarchy Examples
Category Taxonomy
Electronics (slug: 'electronics', level: 0)
├─ Computers (slug: 'computers', level: 1, parent: 'electronics')
│ ├─ Laptops (slug: 'laptops', level: 2, parent: 'computers')
│ └─ Desktops (slug: 'desktops', level: 2, parent: 'computers')
└─ Mobile (slug: 'mobile', level: 1, parent: 'electronics')
├─ Phones (slug: 'phones', level: 2, parent: 'mobile')
└─ Tablets (slug: 'tablets', level: 2, parent: 'mobile')
Workflow Status
In Progress (slug: 'in-progress', level: 0)
├─ Draft (slug: 'draft', level: 1, parent: 'in-progress')
├─ Review (slug: 'review', level: 1, parent: 'in-progress')
└─ Revision (slug: 'revision', level: 1, parent: 'in-progress')
Completed (slug: 'completed', level: 0)
├─ Published (slug: 'published', level: 1, parent: 'completed')
└─ Archived (slug: 'archived', level: 1, parent: 'completed')
Multi-Language Alias Examples
Product Tag with Translations
Tag: slug='electronics', name='Electronics', context='products'
Aliases:
- 'Elektronik' (language: 'de')
- 'Électronique' (language: 'fr')
- 'Electrónica' (language: 'es')
- '电子产品' (language: 'zh')
Variation Aliases (Same Language)
Tag: slug='user-avatar', name='User Avatar', context='assets'
Aliases:
- 'profile picture' (language: 'en')
- 'profile pic' (language: 'en')
- 'avatar' (language: 'en')
- 'user image' (language: 'en')
Best Practices
Slug Naming Conventions
- Use lowercase kebab-case:
'user-avatar','featured-content' - Keep slugs concise but descriptive
- Avoid special characters except hyphens
- Use context to namespace: tags in different contexts can have same slug
Context Usage
- Use
'global'for tags shared across packages - Use package names (
'assets','content') for package-specific tags - Consider multi-tenancy:
'tenant:{id}'for isolated tag vocabularies
Hierarchy Guidelines
- Keep hierarchies shallow (3-4 levels max) for usability
- Use
levelfield for efficient queries (auto-calculated on save) - Consider circular reference prevention in parent updates
Metadata Design
- Store only application-specific data in metadata
- Use consistent key names across tags
- Document expected metadata schema for your application
- Keep metadata lightweight (avoid large objects)
Alias Strategy
- Add common misspellings as aliases
- Include abbreviations and acronyms
- Provide translations for multi-language support
- Use
language: nullfor language-neutral variations
Migration from Other Tag Systems
Simple Flat Tags → Hierarchical
// Convert flat tags to hierarchical
async function convertToHierarchy(
flatTags: string[],
categoryMap: Record<string, string> // tag -> category
) {
const tagCollection = new TagCollection(options);
// Create categories first (root level)
const categories = [...new Set(Object.values(categoryMap))];
for (const category of categories) {
await tagCollection.getOrCreate(category, 'products');
}
// Create tags with parents
for (const tag of flatTags) {
const parent = categoryMap[tag];
await tagCollection.create({
slug: tag,
name: tag,
context: 'products',
parentSlug: parent || null,
});
}
}
Adding Context to Existing Tags
// Migrate global tags to package-specific contexts
async function migrateToContext(oldContext: string, newContext: string) {
const tagCollection = new TagCollection(options);
const tags = await tagCollection.listByContext(oldContext);
for (const tag of tags) {
tag.context = newContext;
await tag.save();
}
}
Performance Considerations
Indexing Strategy
- Index on
contextfor fast filtering by package - Index on
parent_slugfor hierarchy traversal - Composite index on
(alias, language)for multi-language search - Consider full-text search index on
nameandaliasfor large datasets
Caching Recommendations
- Cache frequently used tag hierarchies
- Cache tag → aliases mapping for search
- Invalidate cache on tag updates/deletes
- Use context-scoped cache keys
Query Optimization
- Use
levelfield to limit recursion depth in hierarchy queries - Batch load tags when fetching entities with tags
- Consider materialized path for deep hierarchies (alternative to recursive queries)
Security Considerations
Input Validation
- Sanitize slug input (lowercase, alphanumeric + hyphens only)
- Validate context values against allowed list
- Prevent circular parent references
- Limit hierarchy depth to prevent abuse
Access Control
- Implement context-based permissions (e.g., only 'assets' package can create 'assets' tags)
- Consider read vs. write permissions for tags
- Audit tag creation/updates in multi-tenant environments
Future Enhancements
Potential features for future versions:
- Tag Merging UI: Visual tool for merging duplicate tags
- Auto-Tagging: AI-powered tag suggestions based on content
- Tag Analytics: Usage trends, popularity metrics
- Tag Validation Rules: Required tags, mutually exclusive tags
- Versioning: Track tag definition changes over time
- Materialized Paths: Alternative hierarchy implementation for better performance
- Tag Templates: Pre-defined tag sets for common use cases
This specification provides a solid foundation for a flexible, scalable tagging system that can be reused across all SMRT-based packages in the HAVE SDK.