Skip to main content

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.failednewSlug doesn't match slug regex.
  • 404 tag.not_found — source slug unknown.
  • 409 slug.conflictnewSlug already exists as another tag. (No merges — see below.)
  • 409 slug.reservednewSlug on 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 newSlug that 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, AuditModel
  • mongoose.Connection — for startSession
  • EventEmitter2
  • Guard: PermissionsGuard with tag.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 } with arrayFilters: [{ 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 with tagSlugs: oldSlug in the filter — also works but is per-match-element. Array-filters is clearer for the cascade.
  • Denormalized references elsewhere. If the future adds tools.tagSlugs or another taggable entity, this service must cascade to those too. Single-source update logic: make it a method on TagsService.rename that knows every taggable collection.
  • Usage count recompute. If tag.usageCount exists (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.conflict when newSlug exists.
  • Integration (against replica-set Mongo): rename a tag used on 5 content items → all 5 items have the new slug, old slug 404s, tag.renamed event fires with contentCount: 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: 0 without error.
  • Integration (permission): user without tag.manage → 403.