Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Convex Component npm CI license

@vllnt/convex-slugs

Unique slug and handle registry with rename redirects, as a Convex component.

const slugs = new Slugs(components.slugs);
await slugs.reserve(ctx, "ada", userId); // { ok: true } | { ok: false, reason }
const ref = await slugs.resolve(ctx, "ada"); // resourceRef | null

Reserve a unique string (slug, @handle, username) against an opaque resourceRef, enforce uniqueness inside a scope, resolve it back, release it, and redirect on rename. Domain-neutral: article slugs, profile handles, workspace slugs — any public-URL key.

Features

  • Atomic uniqueness per (scope, slug) — rides the Convex mutation transaction, no double-reserve.
  • Scopes — global by default, or namespace per tenant / locale / type.
  • Rename + redirects — renaming records an old → new redirect for link preservation.
  • Chain-safe redirects — chains collapse (A→B→C ⇒ redirectFor(A) === C), one row per source.
  • Reverse lookup — find the slug currently held by a resourceRef.
  • Case folding + NFC — case-insensitive (opt-out) and Unicode NFC-normalized to kill homoglyph collisions.
  • Configurable input rules — length bounds, charset pattern, reservedWords; typed rejection reasons.
  • Degrades, never throws — reads use .first(); release cleans up inbound redirects; opaque resourceRef.

Installation

pnpm add @vllnt/convex-slugs

Peer dependency: convex@^1.41.0 (optionally react@>=18 for the ./react hooks).

Usage

// convex/convex.config.ts
import { defineApp } from "convex/server";
import slugs from "@vllnt/convex-slugs/convex.config";

const app = defineApp();
app.use(slugs);
export default app;
// convex/handles.ts — host owns auth; pass an opaque resourceRef in.
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Slugs } from "@vllnt/convex-slugs";

const slugs = new Slugs(components.slugs);

export const claim = mutation({
  args: { handle: v.string(), userId: v.string() },
  handler: (ctx, { handle, userId }) => slugs.reserve(ctx, handle, userId),
});

export const lookup = query({
  args: { handle: v.string() },
  handler: (ctx, { handle }) => slugs.resolve(ctx, handle),
});

Client options: new Slugs(component, { defaultScope = "global", foldCase = true, minLength = 1, maxLength = 256, pattern?, reservedWords = [] }).

API Reference

Method Kind Result
reserve(ctx, slug, resourceRef, scope?) mutation { ok: true } or { ok: false, reason } (SLUG_INVALID | SLUG_RESERVED | SLUG_TAKEN)
release(ctx, slug, scope?) mutation null (idempotent)
rename(ctx, fromSlug, toSlug, scope?) mutation { ok } (SLUG_INVALID | SLUG_NOT_FOUND | SLUG_TAKEN)
resolve(ctx, slug, scope?) query resourceRef | null
redirectFor(ctx, slug, scope?) query toSlug | null
slugForResource(ctx, resourceRef, scope?) query slug | null

Full reference: docs/API.md.

React

Optional, tree-shakeable hooks from @vllnt/convex-slugs/react — thin wrappers over useQuery; react is an optional peer dep. Each takes the host's re-exported resolve query reference.

import { useSlugAvailable } from "@vllnt/convex-slugs/react";
import { api } from "../convex/_generated/api";

// `available` is undefined while loading, true when free, false when held.
const { available, resourceRef } = useSlugAvailable(api.handles.resolveHandle, { slug });
Hook Returns
useSlugAvailable(resolveRef, { slug, scope? }) { available, resourceRef }available undefined while loading, true free, false held
useResolve(resolveRef, { slug, scope? }) resourceRef | null | undefined (undefined while loading)

Security

  • Auth-agnostic — the host authenticates the caller, decides who may claim a handle, and passes an opaque resourceRef.
  • Opaque refs onlyresourceRef and scope are arbitrary strings; tables are sandboxed (reached only via the client).
  • Boundary re-guard — the component re-guards length at the trust boundary even if a caller bypasses the client.

See docs/API.md.

Testing

pnpm test           # single run
pnpm test:coverage  # enforced 100% on covered files

Tests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.

Contributing

See CONTRIBUTING.md.

Author

Built by bntvllnt · bntvllnt.com · X @bntvllnt

Part of the @vllnt Convex component fleet — vllnt.com

If this is useful, sponsor the work.

License

MIT — see LICENSE.

About

Unique slug and handle registry with rename redirects, as a Convex component

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages