Tag rename
POST /tags/<slug>/rename — atomic, transactional rename of a tag with cascade across content.tagSlugs.
Purpose
Tags rot. A user creates realtime early in the product's life; later the team standardizes on real-time. Renaming in-place keeps history intact and all cross-linked content pointing at the canonical vocabulary.
The operation must be atomic: a partial update that leaves some content referencing the old slug and some referencing the new one corrupts the vocabulary.
Contract reference: API.md §3 Rename.
Contract
POST /tags/<slug>/rename
Authorization: Bearer <access-token> (requires tag.manage)
Content-Type: application/json
{ "newSlug": "real-time" }
→ 200 { success: true, data: { oldSlug, newSlug, contentCount } }
Errors:
400 validation.failed—newSlugdoesn't match slug regex.404 tag.not_found— source slug unknown.409 slug.conflict—newSlugalready exists as another tag. (No merges — see below.)409 slug.reserved—newSlugon the reserved list.
Implementation
Why transactions are required
Step 1 renames the tag row. Step 2 updates every content.tagSlugs array that contains the old slug. If a crash or error strikes between them, we have an orphaned tag ID and content rows still pointing at the old slug — a broken vocabulary.
MongoDB transactions require a replica set. The dev setup uses a standalone Mongo, which means transactions fail with Transaction numbers are only allowed on a replica set member. See DEVELOPMENT.md §9 — switch dev Mongo to a single-node replica set for tag-rename testing, or run integration tests in a testcontainer configured as a replica set.
Service implementation
async rename(oldSlug: string, newSlug: string, actorId: string): Promise<RenameResult> {
if (!SLUG_RE.test(newSlug) || RESERVED.has(newSlug)) {
throw new BadRequestError('validation.failed', { field: 'newSlug' });
}
const session = await this.connection.startSession();
try {
return await session.withTransaction(async () => {
// Load source
const tag = await this.tagModel.findOne({ slug: oldSlug }).session(session);
if (!tag) throw new NotFoundError('tag.not_found', { slug: oldSlug });
if (tag.slug === newSlug) return { oldSlug, newSlug, contentCount: 0 };
// Reject merges
const clash = await this.tagModel.findOne({ slug: newSlug }).session(session);
if (clash) throw new ConflictError('slug.conflict', { slug: newSlug, entity: 'tag' });
// Rename the tag
tag.slug = newSlug;
await tag.save({ session });
// Cascade to content
const result = await this.contentModel.updateMany(
{ tagSlugs: oldSlug },
{ $set: { 'tagSlugs.$[el]': newSlug } },
{ arrayFilters: [{ el: oldSlug }], session },
);
// Audit
await this.auditModel.create([{
entity: 'tag',
action: 'rename',
actorUserId: new Types.ObjectId(actorId),
before: { slug: oldSlug },
after: { slug: newSlug },
affected: { contentCount: result.modifiedCount },
at: new Date(),
}], { session });
// Emit AFTER transaction commits — see below
this.pendingEvents.push({
name: 'tag.renamed',
payload: { oldSlug, newSlug, contentCount: result.modifiedCount, actorId },
});
return { oldSlug, newSlug, contentCount: result.modifiedCount };
});
} finally {
await session.endSession();
// Now flush events
for (const e of this.pendingEvents.splice(0)) this.events.emit(e.name, e.payload);
}
}
Why not merge on slug conflict
If newSlug already exists, the operator probably wants to merge: move content from the old tag to the existing one. We refuse in this endpoint because merges are:
- Irreversible. Once merged, you cannot separate which content was tagged which way.
- Easy to trigger accidentally. A typo in
newSlugthat matches an unrelated tag silently dumps all content into that tag.
A separate POST /tags/<slug>/merge-into endpoint (not in v1) would require explicit confirmation and carry a distinct audit action.
Audit record
Stored in an audit_log collection with the shape shown above. Retention: indefinite in v1. This is the only thing that survives if someone wants to know "when did realtime become real-time, and how many items did that affect?"
Event emission after commit
Emitting inside the transaction callback is a bug: handlers run at emit time with uncommitted data, and a rollback leaves them believing a rename happened. Solution above: buffer events, emit after endSession(). Only tag.renamed is affected today; establish the pattern now for future transactional writes.
Required variables and services
TagModel,ContentModel,AuditModelmongoose.Connection— forstartSessionEventEmitter2- Guard:
PermissionsGuardwithtag.manage
Gotchas
- Replica set requirement. Transactions need one. Dev must be configured accordingly; CI must run a replica-set Mongo for integration tests of this endpoint.
- Array filter syntax.
{ 'tagSlugs.$[el]': newSlug }witharrayFilters: [{ el: oldSlug }]is the modern (Mongo 3.6+) way to update an array element without reading and rewriting the whole array. Stale examples may show$positional operator withtagSlugs: oldSlugin the filter — also works but is per-match-element. Array-filters is clearer for the cascade. - Denormalized references elsewhere. If the future adds
tools.tagSlugsor another taggable entity, this service must cascade to those too. Single-source update logic: make it a method onTagsService.renamethat knows every taggable collection. - Usage count recompute. If
tag.usageCountexists (see TAXONOMY.md §6), it transfers unchanged with the rename — no recomputation needed. But document this in the rename return value for the admin UI. - Rate of use. Rename is rare (a handful per year in steady state). Don't over-index for it; correctness is more important than performance.
Testing
- Unit: validation of
newSlug— invalid format, reserved, unchanged. - Unit:
slug.conflictwhennewSlugexists. - Integration (against replica-set Mongo): rename a tag used on 5 content items → all 5 items have the new slug, old slug 404s,
tag.renamedevent fires withcontentCount: 5, audit record present. - Integration (failure): during rename, force an error on
contentModel.updateMany→ transaction aborts → tag row unchanged, event not emitted. - Integration (idempotent): rename a tag to its current slug → returns
contentCount: 0without error. - Integration (permission): user without
tag.manage→ 403.