{"id":51317887,"url":"https://github.com/vllnt/convex-slugs","last_synced_at":"2026-07-01T09:31:12.898Z","repository":{"id":364821495,"uuid":"1269288811","full_name":"vllnt/convex-slugs","owner":"vllnt","description":"Unique slug and handle registry with rename redirects, as a Convex component","archived":false,"fork":false,"pushed_at":"2026-06-14T15:47:15.000Z","size":113,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-14T17:23:37.456Z","etag":null,"topics":["convex","convex-component","slugs","vllnt"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/@vllnt/convex-slugs","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vllnt.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":"bntvllnt"}},"created_at":"2026-06-14T14:29:23.000Z","updated_at":"2026-06-14T15:55:25.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/vllnt/convex-slugs","commit_stats":null,"previous_names":["vllnt/convex-slugs"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/vllnt/convex-slugs","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-slugs","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-slugs/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-slugs/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-slugs/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vllnt","download_url":"https://codeload.github.com/vllnt/convex-slugs/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-slugs/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35001648,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["convex","convex-component","slugs","vllnt"],"created_at":"2026-07-01T09:31:12.104Z","updated_at":"2026-07-01T09:31:12.888Z","avatar_url":"https://github.com/vllnt.png","language":"TypeScript","funding_links":["https://github.com/sponsors/bntvllnt"],"categories":[],"sub_categories":[],"readme":"\u003c!-- Badges --\u003e\n[![Convex Component](https://img.shields.io/badge/convex-component-EE342F.svg)](https://www.convex.dev/components)\n[![npm](https://img.shields.io/npm/v/@vllnt/convex-slugs.svg)](https://www.npmjs.com/package/@vllnt/convex-slugs)\n[![CI](https://github.com/vllnt/convex-slugs/actions/workflows/ci.yml/badge.svg)](https://github.com/vllnt/convex-slugs/actions/workflows/ci.yml)\n[![license](https://img.shields.io/npm/l/@vllnt/convex-slugs.svg)](./LICENSE)\n\n# @vllnt/convex-slugs\n\nUnique slug and handle registry with rename redirects, as a Convex component.\n\n```ts\nconst slugs = new Slugs(components.slugs);\nawait slugs.reserve(ctx, \"ada\", userId); // { ok: true } | { ok: false, reason }\nconst ref = await slugs.resolve(ctx, \"ada\"); // resourceRef | null\n```\n\nReserve a unique string (slug, `@handle`, username) against an opaque\n`resourceRef`, enforce uniqueness inside a scope, resolve it back, release it, and\n**redirect on rename**. Domain-neutral: article slugs, profile handles, workspace\nslugs — any public-URL key.\n\n## Features\n\n- **Atomic uniqueness** per `(scope, slug)` — rides the Convex mutation transaction, no double-reserve.\n- **Scopes** — global by default, or namespace per tenant / locale / type.\n- **Rename + redirects** — renaming records an old → new redirect for link preservation.\n- **Chain-safe redirects** — chains collapse (A→B→C ⇒ `redirectFor(A) === C`), one row per source.\n- **Reverse lookup** — find the slug currently held by a `resourceRef`.\n- **Case folding + NFC** — case-insensitive (opt-out) and Unicode NFC-normalized to kill homoglyph collisions.\n- **Configurable input rules** — length bounds, charset `pattern`, `reservedWords`; typed rejection reasons.\n- **Degrades, never throws** — reads use `.first()`; release cleans up inbound redirects; opaque `resourceRef`.\n\n## Installation\n\n```bash\npnpm add @vllnt/convex-slugs\n```\n\nPeer dependency: `convex@^1.41.0` (optionally `react@\u003e=18` for the `./react` hooks).\n\n## Usage\n\n```ts\n// convex/convex.config.ts\nimport { defineApp } from \"convex/server\";\nimport slugs from \"@vllnt/convex-slugs/convex.config\";\n\nconst app = defineApp();\napp.use(slugs);\nexport default app;\n```\n\n```ts\n// convex/handles.ts — host owns auth; pass an opaque resourceRef in.\nimport { components } from \"./_generated/api\";\nimport { mutation, query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { Slugs } from \"@vllnt/convex-slugs\";\n\nconst slugs = new Slugs(components.slugs);\n\nexport const claim = mutation({\n  args: { handle: v.string(), userId: v.string() },\n  handler: (ctx, { handle, userId }) =\u003e slugs.reserve(ctx, handle, userId),\n});\n\nexport const lookup = query({\n  args: { handle: v.string() },\n  handler: (ctx, { handle }) =\u003e slugs.resolve(ctx, handle),\n});\n```\n\nClient options: `new Slugs(component, { defaultScope = \"global\", foldCase = true, minLength = 1, maxLength = 256, pattern?, reservedWords = [] })`.\n\n## API Reference\n\n| Method | Kind | Result |\n|--------|------|--------|\n| `reserve(ctx, slug, resourceRef, scope?)` | mutation | `{ ok: true }` or `{ ok: false, reason }` (`SLUG_INVALID` \\| `SLUG_RESERVED` \\| `SLUG_TAKEN`) |\n| `release(ctx, slug, scope?)` | mutation | `null` (idempotent) |\n| `rename(ctx, fromSlug, toSlug, scope?)` | mutation | `{ ok }` (`SLUG_INVALID` \\| `SLUG_NOT_FOUND` \\| `SLUG_TAKEN`) |\n| `resolve(ctx, slug, scope?)` | query | `resourceRef \\| null` |\n| `redirectFor(ctx, slug, scope?)` | query | `toSlug \\| null` |\n| `slugForResource(ctx, resourceRef, scope?)` | query | `slug \\| null` |\n\nFull reference: [docs/API.md](docs/API.md).\n\n## React\n\nOptional, tree-shakeable hooks from `@vllnt/convex-slugs/react` — thin wrappers over\n`useQuery`; `react` is an optional peer dep. Each takes the host's re-exported\n`resolve` query reference.\n\n```tsx\nimport { useSlugAvailable } from \"@vllnt/convex-slugs/react\";\nimport { api } from \"../convex/_generated/api\";\n\n// `available` is undefined while loading, true when free, false when held.\nconst { available, resourceRef } = useSlugAvailable(api.handles.resolveHandle, { slug });\n```\n\n| Hook | Returns |\n|------|---------|\n| `useSlugAvailable(resolveRef, { slug, scope? })` | `{ available, resourceRef }` — `available` `undefined` while loading, `true` free, `false` held |\n| `useResolve(resolveRef, { slug, scope? })` | `resourceRef \\| null \\| undefined` (`undefined` while loading) |\n\n## Security\n\n- **Auth-agnostic** — the host authenticates the caller, decides who may claim a handle, and passes an opaque `resourceRef`.\n- **Opaque refs only** — `resourceRef` and `scope` are arbitrary strings; tables are sandboxed (reached only via the client).\n- **Boundary re-guard** — the component re-guards length at the trust boundary even if a caller bypasses the client.\n\nSee [docs/API.md](docs/API.md).\n\n## Testing\n\n```bash\npnpm test           # single run\npnpm test:coverage  # enforced 100% on covered files\n```\n\nTests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Author\n\nBuilt by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt)\n\nPart of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com)\n\nIf this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt).\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-slugs","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvllnt%2Fconvex-slugs","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-slugs/lists"}