diff --git a/.changeset/bright-rivers-release.md b/.changeset/bright-rivers-release.md new file mode 100644 index 00000000..0a4eb8a6 --- /dev/null +++ b/.changeset/bright-rivers-release.md @@ -0,0 +1,5 @@ +--- +"uniku": major +--- + +Finalize the v1 public API: use only `uniku/cuid/v2` with `cuidv2`, replace second-based `secs` options with millisecond-based `msecs`, replace UUID v7 and TypeID `seq` with `counter`, and use `length` for Nanoid's object-form option. Existing identifier data remains valid; see the v1 migration guide for mechanical before-and-after examples. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..80be1513 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,12 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@uniku/docs": "0.0.0", + "@uniku/examples": "0.0.1", + "@uniku/cli": "0.7.0", + "uniku": "0.6.0", + "@uniku/e2e-cloudflare": "0.0.5" + }, + "changesets": ["bright-rivers-release", "quiet-aliases-retire"] +} diff --git a/.changeset/quiet-aliases-retire.md b/.changeset/quiet-aliases-retire.md new file mode 100644 index 00000000..34fe28a5 --- /dev/null +++ b/.changeset/quiet-aliases-retire.md @@ -0,0 +1,5 @@ +--- +"uniku": patch +--- + +Remove the deprecated `secs`, `seq`, and Nanoid object-form `size` aliases after their final migration release in `0.6.0`. Use `msecs`, `counter`, and `length` instead. `CONFLICTING_OPTIONS` is also removed from the v1 error-code catalog because no supported option pair conflicts after these aliases are gone. diff --git a/.github/workflows/publish-uniku-v1-codemod.yml b/.github/workflows/publish-uniku-v1-codemod.yml new file mode 100644 index 00000000..fa7207b5 --- /dev/null +++ b/.github/workflows/publish-uniku-v1-codemod.yml @@ -0,0 +1,114 @@ +name: Publish uniku v1 Codemod + +on: + push: + tags: + - "uniku-v1-codemod@*" + +concurrency: + group: publish-uniku-v1-codemod + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + name: Publish Registry package + if: github.repository == 'jkomyno/uniku' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: codemod-registry + permissions: + contents: read + id-token: write + steps: + - name: Checkout approved tag source + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.ref }} + + - name: Verify immutable source and version + id: source + shell: bash + run: | + set -euo pipefail + + expected_workflow_ref="$GITHUB_REPOSITORY/.github/workflows/publish-uniku-v1-codemod.yml@" + if [[ "$GITHUB_WORKFLOW_REF" != "$expected_workflow_ref"* ]]; then + echo "::error::Unexpected workflow identity: $GITHUB_WORKFLOW_REF" + exit 1 + fi + if [[ "$GITHUB_REF" != refs/tags/uniku-v1-codemod@* ]]; then + echo "::error::Unexpected publication ref: $GITHUB_REF" + exit 1 + fi + + tag_version="${GITHUB_REF_NAME#uniku-v1-codemod@}" + package_version="$(jq -r '.version' codemods/v1/package.json)" + manifest_version="$(awk -F '"' '/^version:/ { print $2; exit }' codemods/v1/codemod.yaml)" + if [[ ! "$tag_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Tag version is not an exact stable semantic version: $tag_version" + exit 1 + fi + if [[ "$tag_version" != "$package_version" ]] || [[ "$tag_version" != "$manifest_version" ]]; then + echo "::error::Tag, package, and manifest versions must match" + exit 1 + fi + + git fetch --no-tags origin main:refs/remotes/origin/main + tag_source_sha="$(git rev-parse "$GITHUB_REF^{commit}")" + approved_source_sha="$(git rev-parse "$GITHUB_SHA^{commit}")" + checkout_source_sha="$(git rev-parse HEAD)" + main_source_sha="$(git rev-parse origin/main)" + if [[ "$tag_source_sha" != "$approved_source_sha" || "$checkout_source_sha" != "$approved_source_sha" || "$main_source_sha" != "$approved_source_sha" ]]; then + echo "::error::Tag, checkout, approved source, and current origin/main must identify the same commit" + exit 1 + fi + + echo "sha=$approved_source_sha" >> "$GITHUB_OUTPUT" + echo "version=$tag_version" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: ./.github/actions/setup-node + + - name: Verify locked Codemod CLI + run: test "$(pnpm --filter @jkomyno/uniku-v1 exec codemod --version)" = "codemod 1.14.0" + + - name: Validate package + run: | + pnpm --filter @jkomyno/uniku-v1 typecheck + pnpm --filter @jkomyno/uniku-v1 test + pnpm --filter @jkomyno/uniku-v1 workflow:validate + + - name: Request Codemod OIDC token + id: oidc + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const token = await core.getIDToken('https://codemod.com') + core.setSecret(token) + core.setOutput('token', token) + + - name: Publish exact Registry version + working-directory: codemods/v1 + env: + CODEMOD_AUTH_TOKEN: ${{ steps.oidc.outputs.token }} + run: pnpm exec codemod publish . --disable-analytics + + - name: Record publication evidence + env: + PACKAGE_VERSION: ${{ steps.source.outputs.version }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + run: | + { + echo "### Codemod Registry publication" + echo + echo "- Package: \`@jkomyno/uniku-v1@$PACKAGE_VERSION\`" + echo "- Tag: \`$GITHUB_REF_NAME\`" + echo "- Source SHA: \`$SOURCE_SHA\`" + echo "- Workflow run: $RUN_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index c1a01664..4084a330 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ pnpm changeset # Create changeset for versioning - **Monorepo**: pnpm workspaces + Turborepo - **Toolchain**: `mise.toml` is the canonical runtime version source; shared CI setup reads its Node.js, Bun, Deno, and compatibility pins, while compatibility jobs may switch runtimes after dependency installation - **Published packages**: `packages/uniku` (ID generators) and `packages/cli` (the `uniku` command) +- **Registry codemods**: `codemods/v1` is a private workspace published only through the Codemod Registry; verify it with `pnpm --filter @jkomyno/uniku-v1 test` and `pnpm --filter @jkomyno/uniku-v1 workflow:validate` - **Release scope**: Changesets versions and tags only the published packages; keep private workspaces such as `examples` and the Cloudflare E2E workspace out of generated versions, tags, and changelogs - **Runnable docs examples**: `examples` contains Bun tests for the typechecked Hono, Drizzle v1, and Effect v4 examples rendered by the docs site - **E2E workspace**: Cloudflare Workers tests live under `packages/uniku/__tests__/e2e/runtimes/cloudflare` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ac59a02..b7579ae2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,7 +122,6 @@ packages/ src/ common/ bytes.ts # Byte manipulation (increment, timestamp writing) - random-pool.ts # Thread-safe random byte pooling (for CUID2) random.ts # Simple random pool for UUID/ULID/KSUID uuid/ v4.ts # UUID v4 implementation @@ -133,8 +132,8 @@ packages/ ulid/ ulid.ts # ULID implementation crockford.ts # Crockford Base32 encoding - cuid2/ - cuid2.ts # CUID2 implementation (SHA3-512 based) + cuid/ + v2.ts # CUID v2 implementation (SHA3-512 based) nanoid/ nanoid.ts # Nanoid implementation ksuid/ diff --git a/apps/docs/content/docs/guides/performance.mdx b/apps/docs/content/docs/guides/performance.mdx index 153a19d5..590ea34d 100644 --- a/apps/docs/content/docs/guides/performance.mdx +++ b/apps/docs/content/docs/guides/performance.mdx @@ -45,9 +45,8 @@ Every entry point is independently importable and tree-shakeable: importing `uni | `uniku/xid` | ~1.9 KB | | `uniku/nanoid` | ~1.2 KB | | `uniku/uuid/v4` | ~1.2 KB | -| `uniku/cuid/v2` | ~1015 B | -| `uniku/cuid2` | ~1.0 KB | -| `uniku/errors` | ~184 B | +| `uniku/cuid/v2` | ~1.0 KB | +| `uniku/errors` | ~445 B | | `uniku/generators` | ~101 B | The CUID v2 entry point imports SHA3-512 from `@noble/hashes`, uniku's one runtime dependency; the size above excludes that external dependency's own weight. diff --git a/apps/docs/content/docs/migration/v1.mdx b/apps/docs/content/docs/migration/v1.mdx index 68ff3af8..72141d0f 100644 --- a/apps/docs/content/docs/migration/v1.mdx +++ b/apps/docs/content/docs/migration/v1.mdx @@ -8,7 +8,7 @@ uniku v1 keeps its canonical identifier formats stable while finalizing its pre- ## Move CUID v2 to the versioned entry point -`uniku/cuid2` is a pre-v1 alias. Use the canonical versioned import before upgrading: +V1 removes the pre-v1 `uniku/cuid2` alias. Use the canonical versioned import: ```diff - import { cuid2 } from 'uniku/cuid2' @@ -17,6 +17,51 @@ uniku v1 keeps its canonical identifier formats stable while finalizing its pre- The generator behavior is the same. This is an import migration, not a data migration: existing CUID v2 values remain valid. +## Convert second-based timestamp options to milliseconds + +KSUID, ObjectID, and XID now expose only the shared `msecs` timestamp option. Convert an existing Unix-seconds value before passing it: + +```diff +- const ksuidValue = ksuid({ secs }) +- const objectIdValue = objectid({ secs }) +- const xidValue = xid({ secs }) ++ const ksuidValue = ksuid({ msecs: secs * 1000 }) ++ const objectIdValue = objectid({ msecs: secs * 1000 }) ++ const xidValue = xid({ msecs: secs * 1000 }) +``` + +The conversion is `msecs = secs * 1000`; these formats still store whole seconds, so equivalent deterministic inputs produce the same identifier data. + +## Rename UUID v7 and TypeID counters + +The pre-v1 `seq` spelling is now `counter` for UUID v7 and for TypeID, which inherits the UUID v7 options: + +```diff +- const uuid = uuidv7({ seq: 42 }) +- const userId = typeid('user', { seq: 42 }) ++ const uuid = uuidv7({ counter: 42 }) ++ const userId = typeid('user', { counter: 42 }) +``` + +Only the option name changes. The counter value is packed identically. + +## Rename Nanoid's object-form length + +Use `length` instead of `size` in Nanoid's object form: + +```diff +- const id = nanoid({ alphabet: '0123456789abcdef', size: 12 }) ++ const id = nanoid({ alphabet: '0123456789abcdef', length: 12 }) +``` + +The positional `nanoid(number)` overload is unchanged: + +```ts +const id = nanoid(12) +``` + +These migrations change imports and option names, not identifier formats. Existing persisted UUID v7, TypeID, KSUID, ObjectID, XID, Nanoid, and CUID v2 values require no data migration. + ## Update error-code matches Pre-v1 error codes included the generator name. V1 uses strategy-agnostic codes and reports the generator separately through `error.strategy`: diff --git a/apps/docs/content/docs/reference/errors.mdx b/apps/docs/content/docs/reference/errors.mdx index 03cbbd3d..12bcea8f 100644 --- a/apps/docs/content/docs/reference/errors.mdx +++ b/apps/docs/content/docs/reference/errors.mdx @@ -57,7 +57,6 @@ The following codes form the v1 public contract: | Code | Error class(es) | Strategies | Meaning | | --- | --- | --- | --- | | `TIMESTAMP_OUT_OF_RANGE` | `InvalidInputError`, `ParseError` | `uuid`, `ulid`, `typeid`, `ksuid`, `objectid`, `tsid`, `xid` | A timestamp option is not an integer in the format's range, or a decoded timestamp overflows. | -| `CONFLICTING_OPTIONS` | `InvalidInputError` | `uuid`, `typeid`, `nanoid`, `ksuid`, `objectid`, `xid` | Mutually exclusive compatibility and canonical options were supplied together. | | `COUNTER_OUT_OF_RANGE` | `InvalidInputError` | `uuid`, `typeid`, `objectid`, `tsid`, `xid` | A counter option is outside the format's supported bit width. | | `NODE_OUT_OF_RANGE` | `InvalidInputError` | `tsid` | A TSID node value is outside the configured node-bit range. | | `NODE_BITS_OUT_OF_RANGE` | `InvalidInputError` | `tsid` | A TSID node-bit allocation is invalid. | diff --git a/apps/docs/scripts/generate-api-reference.ts b/apps/docs/scripts/generate-api-reference.ts index 6cdbb058..55176fab 100644 --- a/apps/docs/scripts/generate-api-reference.ts +++ b/apps/docs/scripts/generate-api-reference.ts @@ -12,7 +12,6 @@ type GeneratorDefinition = { examples: Record id: string exportName: string - sourceExportName?: string sourcePath: string typeName: string } @@ -168,9 +167,8 @@ const generators: GeneratorDefinition[] = [ }, id: 'cuid-v2', exportName: 'cuidv2', - sourceExportName: 'cuid2', - sourcePath: 'packages/uniku/src/cuid2/cuid2.ts', - typeName: 'Cuid2', + sourcePath: 'packages/uniku/src/cuid/v2.ts', + typeName: 'CuidV2', }, { examples: { @@ -451,7 +449,7 @@ function generate(): Record { const typeAlias = findTypeAlias(sourceFile, generator.typeName) apis[generator.id] = { - description: findExportDescription(sourceFile, generator.sourceExportName ?? generator.exportName), + description: findExportDescription(sourceFile, generator.exportName), members: membersFor(program, typeAlias, generator.exportName, generator.examples, sourceFile), sourcePath: relative(repositoryRoot, absoluteSourcePath).replaceAll('\\', '/'), } diff --git a/apps/docs/src/generated/api-reference.ts b/apps/docs/src/generated/api-reference.ts index e6fa872b..7d8cf6ec 100644 --- a/apps/docs/src/generated/api-reference.ts +++ b/apps/docs/src/generated/api-reference.ts @@ -169,12 +169,6 @@ export const generatorApis: Record = { "name": "counter", "optional": true, "type": "number" - }, - { - "description": "Deprecated alias for `counter`; unsigned 32-bit sequence value.", - "name": "seq", - "optional": true, - "type": "number" } ], "typeName": "UuidV7Options" @@ -425,12 +419,6 @@ export const generatorApis: Record = { "name": "counter", "optional": true, "type": "number" - }, - { - "description": "Deprecated alias for `counter`; unsigned 32-bit sequence value.", - "name": "seq", - "optional": true, - "type": "number" } ], "typeName": "TypeidOptions" @@ -581,12 +569,12 @@ export const generatorApis: Record = { "type": "Uint8Array" } ], - "typeName": "Cuid2Options" + "typeName": "CuidV2Options" }, "signatures": [ { "description": "Generate a CUID v2 string.", - "text": "cuidv2(options?: Cuid2Options): string" + "text": "cuidv2(options?: CuidV2Options): string" } ] }, @@ -604,7 +592,7 @@ export const generatorApis: Record = { ] } ], - "sourcePath": "packages/uniku/src/cuid2/cuid2.ts" + "sourcePath": "packages/uniku/src/cuid/v2.ts" }, "nanoid": { "description": "Generate a URL-friendly unique string ID. Nanoid is a tiny, secure, URL-friendly unique string ID generator. It uses a URL-safe alphabet (A-Za-z0-9_-) and generates 21-character IDs by default with 126 bits of entropy. Unlike UUID v7 or ULID, nanoid is NOT time-ordered. Use it for: - URL shorteners - Session tokens - Invite codes - Any case where you need short, random IDs", @@ -634,12 +622,6 @@ export const generatorApis: Record = { "name": "length", "optional": true, "type": "number" - }, - { - "description": "Deprecated alias for `length`.", - "name": "size", - "optional": true, - "type": "number" } ], "typeName": "NanoidOptions" @@ -697,12 +679,6 @@ export const generatorApis: Record = { "name": "msecs", "optional": true, "type": "number" - }, - { - "description": "Deprecated alias for `msecs`; timestamp in seconds since the Unix epoch.", - "name": "secs", - "optional": true, - "type": "number" } ], "typeName": "KsuidOptions" @@ -826,12 +802,6 @@ export const generatorApis: Record = { "optional": true, "type": "number" }, - { - "description": "Deprecated alias for `msecs`; timestamp in seconds since the Unix epoch.", - "name": "secs", - "optional": true, - "type": "number" - }, { "description": "24-bit counter value (0 to 0xFFFFFF).", "name": "counter", @@ -1132,12 +1102,6 @@ export const generatorApis: Record = { "optional": true, "type": "number" }, - { - "description": "Deprecated alias for `msecs`; Unix timestamp in seconds.", - "name": "secs", - "optional": true, - "type": "number" - }, { "description": "24-bit counter. Explicit values do not consume shared state.", "name": "counter", diff --git a/biome.jsonc b/biome.jsonc index da5f6cc1..da75e59c 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -21,6 +21,12 @@ "!apps/docs/.source", "!apps/docs/src/generated", "!apps/docs/src/routeTree.gen.ts", + // Codemod fixtures intentionally contain pre-migration and unsupported syntax. + "!codemods/v1/tests/**/input.*", + "!codemods/v1/tests/**/expected.*", + "!codemods/v1/tests/**/metrics.json", + "!codemods/v1/tests/integration/consumer", + "!codemods/v1/tests/integration/expected", "!**/.turbo", "!public", // Read-only source clone of Effect (git submodule) — never lint. diff --git a/codemods/v1/README.md b/codemods/v1/README.md new file mode 100644 index 00000000..59d8a127 --- /dev/null +++ b/codemods/v1/README.md @@ -0,0 +1,46 @@ +# uniku v1 codemod + +This private workspace contains the deterministic source migration from `uniku` 0.6 to v1. It is prepared for distribution through the Codemod Registry, independently from the `uniku` and `@uniku/cli` npm packages. Registry publication remains a separate acceptance gate, so this document intentionally provides no Registry invocation yet. + +## Local development + +From the repository root: + +```sh +pnpm --filter @jkomyno/uniku-v1 typecheck +pnpm --filter @jkomyno/uniku-v1 test +pnpm --filter @jkomyno/uniku-v1 workflow:validate +``` + +To preview the local workflow against a consumer checkout: + +```sh +cd codemods/v1 +pnpm exec codemod workflow run -w . -t /absolute/path/to/consumer --dry-run +``` + +Remove `--dry-run` to apply the edits. The runner refuses a dirty Git worktree by default; use `--allow-dirty` only after preserving unrelated work. Review the source diff and every `uniku-v1-audit` record, run the consumer's own checks, then run the workflow again to confirm that it produces no further diff. + +## Supported migrations + +- Rename supported `cuid2` imports, direct re-exports, immediate dynamic imports, and references to `cuidv2` from `uniku/cuid/v2`. +- Rename `seq` to `counter` for imported UUID v7 and TypeID calls. +- Rename Nanoid object-form `size` to `length`. +- Rename `secs` to `msecs` for imported KSUID, ObjectID, and XID calls while multiplying the original expression by `1000` once. +- Rewrite direct strict positive legacy error-code comparisons to the v1 code plus the matching `strategy` condition. + +The transform supports `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, and `.cts`. It uses file-scoped semantic analysis and requests no network, unrestricted filesystem, or child-process capability. + +## Manual migration findings + +Ambiguous constructs remain unchanged and emit deterministic `uniku-v1-audit` JSON records with a rule ID, location, reason, and migration-guide URL. They also increment the `uniku-v1-manual-migrations` report metric. + +Manual review is required for unsupported CUID import shapes, static `require()`, imports behind local re-export chains, option objects with conflicting destination keys, spreads or computed keys, and option arguments that are not inline objects. Error-code cases that use loose or negative comparisons, conflicting strategy checks, optional or computed access, switch labels, lookup data, destructured values, or indirect variables are also reported without edits. + +Independent supported edits in the same file may still be applied when another construct needs manual work. + +## Publication + +The publication gate requires `.github/workflows/publish-uniku-v1-codemod.yml`, the protected `codemod-registry` environment, and `refs/tags/uniku-v1-codemod@*`. The trusted publisher must be configured for repository `jkomyno/uniku` and package `@jkomyno/uniku-v1`. The workflow requires the tag, checked-out commit, approved source SHA, and current `origin/main` commit to agree before requesting a short-lived OIDC token. + +`@uniku/cli` remains an ID operations tool and does not include source migrations. diff --git a/codemods/v1/codemod.yaml b/codemods/v1/codemod.yaml new file mode 100644 index 00000000..86c5cdec --- /dev/null +++ b/codemods/v1/codemod.yaml @@ -0,0 +1,15 @@ +schema_version: "1.0" +name: "@jkomyno/uniku-v1" +version: "0.1.0" +description: "Migrate supported uniku 0.6 source contracts to v1" +author: "Alberto Schiabel " +license: "MIT" +workflow: "workflow.yaml" +repository: "https://github.com/jkomyno/uniku" +category: "migration" +targets: + languages: ["javascript", "typescript", "tsx"] +keywords: ["uniku", "v1", "migration"] +registry: + access: "public" + visibility: "public" diff --git a/codemods/v1/package.json b/codemods/v1/package.json new file mode 100644 index 00000000..057334db --- /dev/null +++ b/codemods/v1/package.json @@ -0,0 +1,24 @@ +{ + "name": "@jkomyno/uniku-v1", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Deterministic source migration from uniku 0.6 to v1", + "license": "MIT", + "scripts": { + "test": "pnpm test:unit && pnpm test:fixtures", + "test:fixtures": "pnpm test:javascript && pnpm test:typescript && pnpm test:tsx", + "test:javascript": "codemod jssg test -l javascript $PWD/scripts/transform.ts $PWD/tests/javascript --strictness strict --sequential", + "test:tsx": "codemod jssg test -l tsx $PWD/scripts/transform.ts $PWD/tests/tsx --strictness strict --sequential", + "test:typescript": "codemod jssg test -l typescript $PWD/scripts/transform.ts $PWD/tests/typescript --strictness strict --sequential", + "test:unit": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit", + "workflow:validate": "codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.3", + "codemod": "1.14.0", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/codemods/v1/scripts/audit.ts b/codemods/v1/scripts/audit.ts new file mode 100644 index 00000000..c6e75f28 --- /dev/null +++ b/codemods/v1/scripts/audit.ts @@ -0,0 +1,48 @@ +import type { MigrationRuleId } from './mappings.ts' + +export interface AuditFinding { + readonly ruleId: MigrationRuleId + readonly path: string + readonly line: number + readonly column: number + readonly reason: string + readonly guideUrl: string +} + +export interface AuditCandidate extends AuditFinding { + readonly startPos: number + readonly endPos: number +} + +const compareCandidates = (left: AuditCandidate, right: AuditCandidate): number => + left.startPos - right.startPos || + left.endPos - right.endPos || + left.ruleId.localeCompare(right.ruleId) || + left.reason.localeCompare(right.reason) + +const candidateKey = (candidate: AuditCandidate): string => + `${candidate.startPos}:${candidate.endPos}:${candidate.ruleId}:${candidate.reason}` + +export const classifyAuditCandidates = (candidates: readonly AuditCandidate[]): readonly AuditFinding[] => { + const seen = new Set() + const findings: AuditFinding[] = [] + + for (const candidate of [...candidates].sort(compareCandidates)) { + const key = candidateKey(candidate) + if (seen.has(key)) continue + seen.add(key) + + const { startPos: _startPos, endPos: _endPos, ...finding } = candidate + findings.push(finding) + } + + return findings +} + +export const renderAuditFinding = (finding: AuditFinding): string => + JSON.stringify({ + type: 'uniku-v1-audit', + manualMigrationRequired: true, + message: `Manual migration required: ${finding.reason}`, + ...finding, + }) diff --git a/codemods/v1/scripts/edit-plan.ts b/codemods/v1/scripts/edit-plan.ts new file mode 100644 index 00000000..b6db6e71 --- /dev/null +++ b/codemods/v1/scripts/edit-plan.ts @@ -0,0 +1,54 @@ +import type { Edit } from 'codemod:ast-grep' + +import type { MigrationRuleId } from './mappings.ts' + +export interface PlannedEdit extends Edit { + readonly atomicGroup: string + readonly ruleId: MigrationRuleId +} + +export interface EditPlan { + readonly edits: readonly PlannedEdit[] + readonly rejectedGroupIds: readonly string[] +} + +const compareEdits = (left: PlannedEdit, right: PlannedEdit): number => + left.startPos - right.startPos || + left.endPos - right.endPos || + left.insertedText.localeCompare(right.insertedText) || + left.atomicGroup.localeCompare(right.atomicGroup) || + left.ruleId.localeCompare(right.ruleId) + +const isIdenticalEdit = (left: PlannedEdit, right: PlannedEdit): boolean => + left.startPos === right.startPos && left.endPos === right.endPos && left.insertedText === right.insertedText + +const editsOverlap = (left: PlannedEdit, right: PlannedEdit): boolean => + left.startPos < right.endPos && right.startPos < left.endPos + +export const planEdits = (candidates: readonly PlannedEdit[]): EditPlan => { + const sorted = [...candidates].sort(compareEdits) + const deduplicated = sorted.filter( + (candidate, index) => index === 0 || !isIdenticalEdit(sorted[index - 1]!, candidate), + ) + const rejectedGroupIds = new Set() + + for (let leftIndex = 0; leftIndex < deduplicated.length; leftIndex += 1) { + const left = deduplicated[leftIndex]! + + for (let rightIndex = leftIndex + 1; rightIndex < deduplicated.length; rightIndex += 1) { + const right = deduplicated[rightIndex]! + if (right.startPos >= left.endPos) break + if (!editsOverlap(left, right)) continue + + rejectedGroupIds.add(left.atomicGroup) + rejectedGroupIds.add(right.atomicGroup) + } + } + + const rejected = [...rejectedGroupIds].sort() + + return { + edits: deduplicated.filter((edit) => !rejectedGroupIds.has(edit.atomicGroup)), + rejectedGroupIds: rejected, + } +} diff --git a/codemods/v1/scripts/mappings.ts b/codemods/v1/scripts/mappings.ts new file mode 100644 index 00000000..c197318f --- /dev/null +++ b/codemods/v1/scripts/mappings.ts @@ -0,0 +1,246 @@ +export const GUIDE_BASE_URL = 'https://jkomyno.github.io/uniku/docs/migration/v1/' + +export const RULES = { + cuid: { + id: 'uniku-v1/cuid-entry-point', + guideUrl: `${GUIDE_BASE_URL}#move-cuid-v2-to-the-versioned-entry-point`, + }, + timestamp: { + id: 'uniku-v1/timestamp-options', + guideUrl: `${GUIDE_BASE_URL}#convert-second-based-timestamp-options-to-milliseconds`, + }, + counter: { + id: 'uniku-v1/counter-options', + guideUrl: `${GUIDE_BASE_URL}#rename-uuid-v7-and-typeid-counters`, + }, + nanoid: { + id: 'uniku-v1/nanoid-length', + guideUrl: `${GUIDE_BASE_URL}#rename-nanoids-object-form-length`, + }, + error: { + id: 'uniku-v1/error-codes', + guideUrl: `${GUIDE_BASE_URL}#update-error-code-matches`, + }, + overlap: { + id: 'uniku-v1/edit-overlap', + guideUrl: GUIDE_BASE_URL, + }, +} as const + +export type MigrationRule = (typeof RULES)[keyof typeof RULES] +export type MigrationRuleId = MigrationRule['id'] + +export interface GeneratorMigration { + readonly moduleName: string + readonly exportName: string + readonly optionIndex: number + readonly sourceKey: 'secs' | 'seq' | 'size' + readonly targetKey: 'msecs' | 'counter' | 'length' + readonly multiplyByThousand: boolean + readonly rule: MigrationRule +} + +export const GENERATOR_MIGRATIONS = [ + { + moduleName: 'uniku/uuid/v7', + exportName: 'uuidv7', + optionIndex: 0, + sourceKey: 'seq', + targetKey: 'counter', + multiplyByThousand: false, + rule: RULES.counter, + }, + { + moduleName: 'uniku/typeid', + exportName: 'typeid', + optionIndex: 1, + sourceKey: 'seq', + targetKey: 'counter', + multiplyByThousand: false, + rule: RULES.counter, + }, + { + moduleName: 'uniku/nanoid', + exportName: 'nanoid', + optionIndex: 0, + sourceKey: 'size', + targetKey: 'length', + multiplyByThousand: false, + rule: RULES.nanoid, + }, + ...(['ksuid', 'objectid', 'xid'] as const).map( + (name): GeneratorMigration => ({ + moduleName: `uniku/${name}`, + exportName: name, + optionIndex: 0, + sourceKey: 'secs', + targetKey: 'msecs', + multiplyByThousand: true, + rule: RULES.timestamp, + }), + ), +] as const satisfies readonly GeneratorMigration[] + +export const GENERATOR_BY_MODULE = new Map( + GENERATOR_MIGRATIONS.map((migration): readonly [string, GeneratorMigration] => [migration.moduleName, migration]), +) + +export type ErrorStrategy = 'uuid' | 'ulid' | 'ksuid' | 'objectid' | 'xid' | 'tsid' | 'typeid' | 'nanoid' | 'cuid' + +export interface ErrorCodeMigration { + readonly sourceCode: string + readonly targetCode: string + readonly strategy: ErrorStrategy +} + +const ERROR_CODE_GROUPS = [ + { + targetCode: 'TIMESTAMP_OUT_OF_RANGE', + sourceCodes: [ + 'UUID_TIMESTAMP_OUT_OF_RANGE', + 'ULID_TIMESTAMP_OUT_OF_RANGE', + 'ULID_TIMESTAMP_OVERFLOW', + 'KSUID_TIMESTAMP_TOO_LOW', + 'KSUID_TIMESTAMP_TOO_HIGH', + 'OBJECTID_TIMESTAMP_OUT_OF_RANGE', + 'XID_TIMESTAMP_OUT_OF_RANGE', + 'TSID_TIMESTAMP_INVALID', + 'TSID_TIMESTAMP_OUT_OF_RANGE', + ], + }, + { + targetCode: 'INVALID_CHAR', + sourceCodes: [ + 'UUID_INVALID_HEX_CHAR', + 'ULID_INVALID_CHAR', + 'KSUID_INVALID_CHAR', + 'OBJECTID_INVALID_CHAR', + 'XID_INVALID_CHAR', + 'TSID_INVALID_CHAR', + 'TYPEID_SUFFIX_INVALID_CHARACTER', + ], + }, + { + targetCode: 'INVALID_LENGTH', + sourceCodes: [ + 'UUID_INVALID_LENGTH', + 'ULID_INVALID_LENGTH', + 'KSUID_INVALID_LENGTH', + 'OBJECTID_INVALID_LENGTH', + 'XID_INVALID_LENGTH', + 'TSID_INVALID_LENGTH', + 'TYPEID_SUFFIX_INVALID_LENGTH', + ], + }, + { + targetCode: 'INVALID_FORMAT', + sourceCodes: ['UUID_INVALID_SEPARATORS', 'TYPEID_INVALID_FORMAT'], + }, + { + targetCode: 'VALUE_OUT_OF_RANGE', + sourceCodes: [ + 'KSUID_OVERFLOW', + 'TYPEID_SUFFIX_OVERFLOW', + 'TSID_LEADING_CHAR_OUT_OF_RANGE', + 'TSID_VALUE_OUT_OF_RANGE', + ], + }, + { targetCode: 'NON_CANONICAL', sourceCodes: ['XID_NON_CANONICAL'] }, + { + targetCode: 'BYTES_INVALID_LENGTH', + sourceCodes: [ + 'UUID_BYTES_INVALID_LENGTH', + 'ULID_BYTES_INVALID_LENGTH', + 'KSUID_BYTES_INVALID_LENGTH', + 'KSUID_BYTES_TOO_SHORT', + 'OBJECTID_BYTES_INVALID_LENGTH', + 'OBJECTID_BYTES_TOO_SHORT', + 'XID_BYTES_INVALID_LENGTH', + 'TSID_BYTES_INVALID_LENGTH', + 'TYPEID_UUID_BYTES_INVALID_LENGTH', + ], + }, + { + targetCode: 'BUFFER_OUT_OF_BOUNDS', + sourceCodes: [ + 'UUID_BUFFER_OUT_OF_BOUNDS', + 'ULID_BUFFER_OUT_OF_BOUNDS', + 'KSUID_BUFFER_OUT_OF_BOUNDS', + 'OBJECTID_BUFFER_OUT_OF_BOUNDS', + 'XID_BUFFER_OUT_OF_BOUNDS', + 'TSID_BUFFER_OUT_OF_BOUNDS', + ], + }, + { + targetCode: 'RANDOM_BYTES_TOO_SHORT', + sourceCodes: [ + 'UUID_RANDOM_BYTES_TOO_SHORT', + 'ULID_RANDOM_BYTES_TOO_SHORT', + 'KSUID_RANDOM_BYTES_TOO_SHORT', + 'OBJECTID_RANDOM_BYTES_TOO_SHORT', + 'NANOID_RANDOM_BYTES_INSUFFICIENT', + 'CUID2_RANDOM_BYTES_EMPTY', + ], + }, + { targetCode: 'RANDOM_OVERFLOW', sourceCodes: ['ULID_RANDOM_OVERFLOW'] }, + { + targetCode: 'COUNTER_OUT_OF_RANGE', + sourceCodes: [ + 'UUID_SEQUENCE_OUT_OF_RANGE', + 'OBJECTID_COUNTER_OUT_OF_RANGE', + 'XID_COUNTER_OUT_OF_RANGE', + 'TSID_COUNTER_OUT_OF_RANGE', + ], + }, + { targetCode: 'NODE_OUT_OF_RANGE', sourceCodes: ['TSID_NODE_OUT_OF_RANGE'] }, + { targetCode: 'NODE_BITS_OUT_OF_RANGE', sourceCodes: ['TSID_NODE_BITS_OUT_OF_RANGE'] }, + { targetCode: 'EPOCH_INVALID', sourceCodes: ['TSID_EPOCH_INVALID'] }, + { targetCode: 'PROCESS_ID_OUT_OF_RANGE', sourceCodes: ['XID_PROCESS_ID_OUT_OF_RANGE'] }, + { targetCode: 'MACHINE_ID_BYTES_TOO_SHORT', sourceCodes: ['XID_MACHINE_ID_BYTES_TOO_SHORT'] }, + { targetCode: 'PREFIX_TOO_LONG', sourceCodes: ['TYPEID_PREFIX_TOO_LONG'] }, + { targetCode: 'PREFIX_INVALID_CHAR', sourceCodes: ['TYPEID_PREFIX_INVALID_CHARACTER'] }, + { targetCode: 'PREFIX_INVALID_BOUNDARY', sourceCodes: ['TYPEID_PREFIX_INVALID_BOUNDARY'] }, + { targetCode: 'UUID_NOT_V7', sourceCodes: ['TYPEID_UUID_NOT_V7'] }, + { + targetCode: 'ALPHABET_OUT_OF_RANGE', + sourceCodes: ['NANOID_ALPHABET_TOO_SHORT', 'NANOID_ALPHABET_TOO_LONG'], + }, + { targetCode: 'ALPHABET_INVALID_CHAR', sourceCodes: ['NANOID_ALPHABET_INVALID_CHAR'] }, + { targetCode: 'ALPHABET_DUPLICATE', sourceCodes: ['NANOID_ALPHABET_DUPLICATE'] }, + { + targetCode: 'LENGTH_OUT_OF_RANGE', + sourceCodes: ['NANOID_SIZE_INVALID', 'NANOID_SIZE_TOO_LARGE', 'CUID2_LENGTH_OUT_OF_RANGE'], + }, +] as const + +const strategyForLegacyCode = (sourceCode: string): ErrorStrategy => { + const prefix = sourceCode.slice(0, sourceCode.indexOf('_')) + switch (prefix) { + case 'CUID2': + return 'cuid' + case 'UUID': + return 'uuid' + case 'ULID': + case 'KSUID': + case 'OBJECTID': + case 'XID': + case 'TSID': + case 'TYPEID': + case 'NANOID': + return prefix.toLowerCase() as ErrorStrategy + default: + throw new Error(`Unknown legacy error-code prefix: ${prefix}`) + } +} + +export const ERROR_CODE_MIGRATIONS: readonly ErrorCodeMigration[] = ERROR_CODE_GROUPS.flatMap((group) => + group.sourceCodes.map((sourceCode) => ({ + sourceCode, + targetCode: group.targetCode, + strategy: strategyForLegacyCode(sourceCode), + })), +) + +export const ERROR_CODE_BY_SOURCE = new Map( + ERROR_CODE_MIGRATIONS.map((migration): readonly [string, ErrorCodeMigration] => [migration.sourceCode, migration]), +) diff --git a/codemods/v1/scripts/transform.ts b/codemods/v1/scripts/transform.ts new file mode 100644 index 00000000..1fe51172 --- /dev/null +++ b/codemods/v1/scripts/transform.ts @@ -0,0 +1,839 @@ +import type { Codemod, SgNode, TypesMap } from 'codemod:ast-grep' +import { useMetricAtom } from 'codemod:metrics' + +import { type AuditCandidate, classifyAuditCandidates, renderAuditFinding } from './audit.ts' +import { type PlannedEdit, planEdits } from './edit-plan.ts' +import { + ERROR_CODE_BY_SOURCE, + type ErrorCodeMigration, + GENERATOR_BY_MODULE, + GENERATOR_MIGRATIONS, + type GeneratorMigration, + type MigrationRule, + RULES, +} from './mappings.ts' + +type Node = SgNode + +interface Binding { + readonly kind: 'named' | 'namespace' + readonly localName: string + readonly localNode: Node + readonly definitionStart: number + readonly definitionEnd: number + readonly referenceStarts: ReadonlySet + readonly migration: GeneratorMigration +} + +interface GroupMetadata { + readonly node: Node + readonly rule: MigrationRule +} + +/** + * Per-file traversal results and accumulators shared by every migration pass. + * The node lists are gathered once because each pass would otherwise re-walk + * the whole tree looking for the same node kinds. + */ +interface MigrationContext { + readonly rootNode: Node + readonly filename: string + readonly path: string + readonly importStatements: readonly Node[] + readonly callExpressions: readonly Node[] + readonly stringLiterals: readonly Node[] + /** Memoized: only files importing the legacy module ever need this scan. */ + readonly hasCuidv2Collision: () => boolean + readonly candidates: PlannedEdit[] + readonly groups: Map + readonly audits: AuditCandidate[] + readonly scheduledSourceStarts: Set + readonly handledLegacyStarts: Set +} + +const manualMigrationMetric = useMetricAtom('uniku-v1-manual-migrations') + +const findAll = (node: Node, kind: string): Node[] => node.findAll({ rule: { kind } }) as Node[] + +const nodeField = (node: Node, name: string): Node | null => node.field(name) as Node | null + +const nodeKind = (node: Node): string => String(node.kind()) + +const stringValue = (node: Node | null): string | null => { + if (node?.kind() !== 'string') return null + const text = node.text() + const quote = text[0] + if ((quote !== "'" && quote !== '"') || text.at(-1) !== quote) return null + return text.slice(1, -1) +} + +const replacementForString = (node: Node, value: string): string => `${node.text()[0]}${value}${node.text().at(-1)}` + +const sameRange = (left: Node, right: Node): boolean => { + const leftRange = left.range() + const rightRange = right.range() + return leftRange.start.index === rightRange.start.index && leftRange.end.index === rightRange.end.index +} + +const isInsideRange = (node: Node, start: number, end: number): boolean => { + const range = node.range() + return range.start.index >= start && range.end.index <= end +} + +const referencesInFile = (localNode: Node, filename: string): Node[] => + localNode + .references() + .filter((fileReferences) => fileReferences.root.filename() === filename) + .flatMap((fileReferences) => fileReferences.nodes) + +const collectReferences = (localNode: Node, filename: string): ReadonlySet => + new Set(referencesInFile(localNode, filename).map((reference) => reference.range().start.index)) + +const createBinding = ( + kind: Binding['kind'], + localNode: Node, + definitionNode: Node, + migration: GeneratorMigration, + filename: string, +): Binding => ({ + kind, + localName: localNode.text(), + localNode, + definitionStart: definitionNode.range().start.index, + definitionEnd: definitionNode.range().end.index, + referenceStarts: collectReferences(localNode, filename), + migration, +}) + +const resolvesToBinding = (node: Node, binding: Binding): boolean => { + if (node.text() !== binding.localName) return false + if (binding.referenceStarts.has(node.range().start.index)) return true + if (sameRange(node, binding.localNode)) return true + + const definition = node.definition({ resolveExternal: false }) + if (!definition) return false + + const definitionRange = definition.node.range() + return ( + definitionRange.start.index === binding.definitionStart || + (definition.kind === 'local' && isInsideRange(definition.node, binding.definitionStart, binding.definitionEnd)) + ) +} + +const addAudit = (context: MigrationContext, node: Node, rule: MigrationRule, reason: string): void => { + const range = node.range() + context.audits.push({ + startPos: range.start.index, + endPos: range.end.index, + ruleId: rule.id, + path: context.path, + line: range.start.line + 1, + column: range.start.column + 1, + reason, + guideUrl: rule.guideUrl, + }) +} + +const addEdit = ( + context: MigrationContext, + node: Node, + insertedText: string, + atomicGroup: string, + rule: MigrationRule, +): void => { + const range = node.range() + context.candidates.push({ + startPos: range.start.index, + endPos: range.end.index, + insertedText, + atomicGroup, + ruleId: rule.id, + }) + context.groups.set(atomicGroup, { node, rule }) +} + +/** + * Wrappers an ancestor walk may step through while still describing the same + * value. Note this set omits `type_assertion`, which is only ever unwrapped + * downwards by `transparentExpression`. + */ +const TRANSPARENT_ANCESTOR_KINDS = new Set([ + 'await_expression', + 'parenthesized_expression', + 'as_expression', + 'satisfies_expression', +]) + +const TRANSPARENT_EXPRESSION_KINDS = new Set([...TRANSPARENT_ANCESTOR_KINDS, 'type_assertion']) + +const transparentExpression = (node: Node): Node => { + let current = node + + while (TRANSPARENT_EXPRESSION_KINDS.has(nodeKind(current))) { + const next = + nodeField(current, 'expression') ?? + nodeField(current, 'argument') ?? + current.children().find((child) => child.isNamed()) ?? + null + if (!next) break + current = next + } + + return current +} + +const dynamicImportSource = (node: Node): Node | null => { + const unwrapped = transparentExpression(node) + if (unwrapped.kind() !== 'call_expression') return null + const callee = nodeField(unwrapped, 'function') + if (callee?.kind() !== 'import') return null + const args = nodeField(unwrapped, 'arguments') + if (!args) return null + return args.children().find((child) => child.kind() === 'string') ?? null +} + +const callArguments = (call: Node): Node[] => { + const args = nodeField(call, 'arguments') + return args ? args.children().filter((child) => child.isNamed()) : [] +} + +const bindingLocalNode = (pattern: Node): Node | null => { + if (pattern.kind() === 'shorthand_property_identifier_pattern' || pattern.kind() === 'identifier') return pattern + if (pattern.kind() !== 'pair_pattern') return null + + let value = nodeField(pattern, 'value') + if (!value) return null + if (value.kind() === 'assignment_pattern') value = nodeField(value, 'left') + return value && (value.kind() === 'identifier' || value.kind() === 'shorthand_property_identifier_pattern') + ? value + : null +} + +const patternImportedName = (pattern: Node): string | null => { + if (pattern.kind() === 'shorthand_property_identifier_pattern') return pattern.text() + if (pattern.kind() !== 'pair_pattern') return null + return nodeField(pattern, 'key')?.text() ?? null +} + +const discoverGeneratorBindings = (context: MigrationContext): Binding[] => { + const bindings: Binding[] = [] + + for (const statement of context.importStatements) { + const migration = GENERATOR_BY_MODULE.get(stringValue(nodeField(statement, 'source')) ?? '') + if (!migration) continue + + for (const specifier of findAll(statement, 'import_specifier')) { + if (nodeField(specifier, 'name')?.text() !== migration.exportName) continue + const localNode = nodeField(specifier, 'alias') ?? nodeField(specifier, 'name') + if (localNode) bindings.push(createBinding('named', localNode, statement, migration, context.filename)) + } + + for (const namespaceImport of findAll(statement, 'namespace_import')) { + const localNode = namespaceImport.children().find((child) => child.kind() === 'identifier') + if (localNode) bindings.push(createBinding('namespace', localNode, statement, migration, context.filename)) + } + } + + for (const declarator of findAll(context.rootNode, 'variable_declarator')) { + const value = nodeField(declarator, 'value') + const source = value ? dynamicImportSource(value) : null + const migration = GENERATOR_BY_MODULE.get(stringValue(source) ?? '') + const name = nodeField(declarator, 'name') + if (!migration || !name || name.kind() !== 'object_pattern') continue + + for (const pattern of name.children()) { + if (patternImportedName(pattern) !== migration.exportName) continue + const localNode = bindingLocalNode(pattern) + if (localNode) bindings.push(createBinding('named', localNode, declarator, migration, context.filename)) + } + } + + return bindings +} + +const migrationForCallee = (callee: Node, bindings: readonly Binding[]): GeneratorMigration | null => { + if (callee.kind() === 'identifier') { + return bindings.find((binding) => binding.kind === 'named' && resolvesToBinding(callee, binding))?.migration ?? null + } + + if (callee.kind() !== 'member_expression') return null + const object = nodeField(callee, 'object') + const property = nodeField(callee, 'property') + if (!object || !property) return null + + const namespaceBinding = bindings.find( + (binding) => + binding.kind === 'namespace' && + property.text() === binding.migration.exportName && + resolvesToBinding(object, binding), + ) + if (namespaceBinding) return namespaceBinding.migration + + const source = dynamicImportSource(object) + const migration = GENERATOR_BY_MODULE.get(stringValue(source) ?? '') + return migration && property.text() === migration.exportName ? migration : null +} + +const propertyKey = (property: Node): Node | null => { + if (property.kind() === 'pair') return nodeField(property, 'key') + return property.kind() === 'shorthand_property_identifier' ? property : null +} + +const staticPropertyName = (property: Node): string | null => { + const key = propertyKey(property) + if (!key || key.kind() === 'computed_property_name') return null + return stringValue(key) ?? key.text() +} + +const computedLiteralPropertyName = (property: Node): string | null => { + const key = propertyKey(property) + if (key?.kind() !== 'computed_property_name') return null + const expression = nodeField(key, 'expression') ?? key.children().find((child) => child.isNamed()) ?? null + return expression ? stringValue(transparentExpression(expression)) : null +} + +const TIMESTAMP_MULTIPLICAND_KINDS = new Set([ + 'call_expression', + 'identifier', + 'member_expression', + 'number', + 'parenthesized_expression', + 'subscript_expression', + 'unary_expression', +]) + +const needsTimestampParentheses = (value: Node): boolean => !TIMESTAMP_MULTIPLICAND_KINDS.has(nodeKind(value)) + +const processOptionCall = (call: Node, migration: GeneratorMigration, context: MigrationContext): void => { + const argument = callArguments(call)[migration.optionIndex] + if (!argument) return + if (argument.kind() !== 'object') { + if (migration.exportName !== 'nanoid') { + addAudit( + context, + argument, + migration.rule, + `The ${migration.exportName} options are not an inline object, so ${migration.sourceKey} cannot be migrated safely.`, + ) + } + return + } + + const properties = argument.children().filter((child) => child.isNamed()) + const sourceProperties = properties.filter((property) => staticPropertyName(property) === migration.sourceKey) + const computedSourceProperties = properties.filter( + (property) => computedLiteralPropertyName(property) === migration.sourceKey, + ) + const affectedSourceProperties = [...sourceProperties, ...computedSourceProperties] + if (affectedSourceProperties.length === 0) return + + const hasDestination = properties.some((property) => staticPropertyName(property) === migration.targetKey) + const hasComputedKey = properties.some((property) => propertyKey(property)?.kind() === 'computed_property_name') + const hasSpread = properties.some((property) => property.kind() === 'spread_element') + + if (hasDestination || hasComputedKey || hasSpread) { + const reason = hasDestination + ? `The options already contain ${migration.targetKey}; changing ${migration.sourceKey} could change precedence.` + : hasComputedKey + ? `A computed option key makes ${migration.sourceKey} precedence ambiguous.` + : `An object spread makes ${migration.sourceKey} precedence ambiguous.` + + for (const property of affectedSourceProperties) addAudit(context, propertyKey(property)!, migration.rule, reason) + return + } + + for (const property of sourceProperties) { + const group = `option:${call.range().start.index}:${property.range().start.index}` + const key = propertyKey(property)! + + if (property.kind() === 'shorthand_property_identifier') { + const multiplier = migration.multiplyByThousand ? ' * 1000' : '' + addEdit(context, property, `${migration.targetKey}: ${migration.sourceKey}${multiplier}`, group, migration.rule) + continue + } + + const value = nodeField(property, 'value') + if (!value) { + addAudit(context, property, migration.rule, `The ${migration.sourceKey} value could not be read safely.`) + continue + } + + const targetKey = + stringValue(key) === migration.sourceKey ? replacementForString(key, migration.targetKey) : migration.targetKey + addEdit(context, key, targetKey, group, migration.rule) + if (migration.multiplyByThousand) { + const original = value.text() + const operand = needsTimestampParentheses(value) ? `(${original})` : original + addEdit(context, value, `${operand} * 1000`, group, migration.rule) + } + } +} + +const cuidv2CollisionExists = (rootNode: Node): boolean => + rootNode.findAll({ rule: { regex: '^cuidv2$' } }).some((node) => node.text() === 'cuidv2') + +const referencesForRename = (localNode: Node, filename: string): Node[] => + referencesInFile(localNode, filename).filter((reference) => reference.text() === localNode.text()) + +const CUID_COLLISION_REASON = + 'The file already declares or references cuidv2, so renaming cuid2 could capture a different binding.' + +/** + * Rewrite one legacy CUID binding — its module specifier, its imported name, + * and (when the binding is not aliased) every reference to it. + */ +const migrateCuidBinding = ( + context: MigrationContext, + group: string, + source: Node, + importedNode: Node, + localNode: Node, + isAliased: boolean, + collisionAuditNode: Node, +): void => { + context.scheduledSourceStarts.add(source.range().start.index) + + if (!isAliased && context.hasCuidv2Collision()) { + addAudit(context, collisionAuditNode, RULES.cuid, CUID_COLLISION_REASON) + return + } + + addEdit(context, source, replacementForString(source, 'uniku/cuid/v2'), group, RULES.cuid) + addEdit(context, importedNode, 'cuidv2', group, RULES.cuid) + + if (!isAliased) { + for (const reference of referencesForRename(localNode, context.filename)) { + addEdit(context, reference, 'cuidv2', group, RULES.cuid) + } + } +} + +const processCuidImports = (context: MigrationContext): void => { + for (const statement of context.importStatements) { + const source = nodeField(statement, 'source') + if (!source || stringValue(source) !== 'uniku/cuid2') continue + const group = `cuid-import:${statement.range().start.index}` + const specifiers = findAll(statement, 'import_specifier') + const namespaces = findAll(statement, 'namespace_import') + + if (specifiers.length === 1 && namespaces.length === 0 && nodeField(specifiers[0]!, 'name')?.text() === 'cuid2') { + const specifier = specifiers[0]! + const importedName = nodeField(specifier, 'name')! + const alias = nodeField(specifier, 'alias') + + migrateCuidBinding(context, group, source, importedName, alias ?? importedName, Boolean(alias), importedName) + continue + } + + if (specifiers.length === 0 && namespaces.length === 1) { + const namespace = namespaces[0]! + const localNode = namespace.children().find((child) => child.kind() === 'identifier') + if (!localNode) continue + const references = referencesForRename(localNode, context.filename) + const propertyNodes: Node[] = [] + let unsupportedReference: Node | null = null + + for (const reference of references) { + const parent = reference.parent() + const object = parent ? nodeField(parent, 'object') : null + if (parent?.kind() !== 'member_expression' || !object || !sameRange(object, reference)) { + unsupportedReference = reference + break + } + const property = nodeField(parent, 'property') + if (property?.text() !== 'cuid2') { + unsupportedReference = reference + break + } + propertyNodes.push(property) + } + + context.scheduledSourceStarts.add(source.range().start.index) + + if (unsupportedReference) { + addAudit( + context, + unsupportedReference, + RULES.cuid, + 'The legacy CUID namespace has a use other than direct .cuid2 access.', + ) + continue + } + + addEdit(context, source, replacementForString(source, 'uniku/cuid/v2'), group, RULES.cuid) + for (const property of propertyNodes) addEdit(context, property, 'cuidv2', group, RULES.cuid) + continue + } + + context.scheduledSourceStarts.add(source.range().start.index) + addAudit(context, source, RULES.cuid, 'This legacy CUID import shape cannot be migrated atomically.') + } +} + +const processCuidReexports = (context: MigrationContext): void => { + for (const statement of findAll(context.rootNode, 'export_statement')) { + const source = nodeField(statement, 'source') + if (!source || stringValue(source) !== 'uniku/cuid2') continue + const specifiers = findAll(statement, 'export_specifier') + context.scheduledSourceStarts.add(source.range().start.index) + + if (specifiers.length !== 1 || nodeField(specifiers[0]!, 'name')?.text() !== 'cuid2') { + addAudit(context, source, RULES.cuid, 'This legacy CUID re-export shape cannot be migrated atomically.') + continue + } + + const group = `cuid-reexport:${statement.range().start.index}` + addEdit(context, source, replacementForString(source, 'uniku/cuid/v2'), group, RULES.cuid) + addEdit(context, nodeField(specifiers[0]!, 'name')!, 'cuidv2', group, RULES.cuid) + } +} + +const dynamicImportDeclarator = (call: Node): Node | null => { + for (const ancestor of call.ancestors()) { + if (ancestor.kind() === 'variable_declarator') { + const value = nodeField(ancestor, 'value') + return value && sameRange(transparentExpression(value), call) ? ancestor : null + } + if (!TRANSPARENT_ANCESTOR_KINDS.has(nodeKind(ancestor))) break + } + return null +} + +const immediateDynamicMember = (call: Node): Node | null => { + let current = call + for (const ancestor of call.ancestors()) { + if (TRANSPARENT_ANCESTOR_KINDS.has(nodeKind(ancestor))) { + current = ancestor + continue + } + return ancestor.kind() === 'member_expression' && sameRange(nodeField(ancestor, 'object')!, current) + ? ancestor + : null + } + return null +} + +const processCuidDynamicImports = (context: MigrationContext): void => { + for (const call of context.callExpressions) { + const source = dynamicImportSource(call) + if (!source || stringValue(source) !== 'uniku/cuid2') continue + const group = `cuid-dynamic:${call.range().start.index}` + const member = immediateDynamicMember(call) + + if (member && nodeField(member, 'property')?.text() === 'cuid2') { + addEdit(context, source, replacementForString(source, 'uniku/cuid/v2'), group, RULES.cuid) + addEdit(context, nodeField(member, 'property')!, 'cuidv2', group, RULES.cuid) + context.scheduledSourceStarts.add(source.range().start.index) + continue + } + + const declarator = dynamicImportDeclarator(call) + const pattern = declarator ? nodeField(declarator, 'name') : null + const entries = pattern?.kind() === 'object_pattern' ? pattern.children().filter((child) => child.isNamed()) : [] + + if (declarator && entries.length === 1 && patternImportedName(entries[0]!) === 'cuid2') { + const entry = entries[0]! + const localNode = bindingLocalNode(entry) + if (!localNode) continue + const isAliased = entry.kind() === 'pair_pattern' + const importedNode = isAliased ? nodeField(entry, 'key')! : entry + + migrateCuidBinding(context, group, source, importedNode, localNode, isAliased, entry) + continue + } + + context.scheduledSourceStarts.add(source.range().start.index) + addAudit(context, source, RULES.cuid, 'This dynamic CUID import is not immediate member access or destructuring.') + } +} + +interface PropertyAccess { + readonly member: Node + readonly receiver: Node +} + +const binaryOperator = (node: Node): string | null => nodeField(node, 'operator')?.text() ?? null + +const nonComputedPropertyAccess = (node: Node, propertyName: string): PropertyAccess | null => { + const member = transparentExpression(node) + if (member.kind() !== 'member_expression' || member.text().includes('?.')) return null + + const property = nodeField(member, 'property') + const receiver = nodeField(member, 'object') + if (!property || property.text() !== propertyName || !receiver) return null + if ( + receiver.kind() === 'call_expression' || + findAll(receiver, 'call_expression').length > 0 || + findAll(receiver, 'subscript_expression').length > 0 || + receiver.text().includes('?.') + ) { + return null + } + if (!['identifier', 'member_expression', 'parenthesized_expression', 'this'].includes(nodeKind(receiver))) return null + + return { member, receiver } +} + +const equalityLiteral = (node: Node): { readonly access: PropertyAccess; readonly literal: Node } | null => { + if (node.kind() !== 'binary_expression') return null + const left = nodeField(node, 'left') + const right = nodeField(node, 'right') + if (!left || !right) return null + + const leftAccess = nonComputedPropertyAccess(left, 'strategy') + const rightAccess = nonComputedPropertyAccess(right, 'strategy') + if (leftAccess && stringValue(transparentExpression(right)) !== null) { + return { access: leftAccess, literal: transparentExpression(right) } + } + if (rightAccess && stringValue(transparentExpression(left)) !== null) { + return { access: rightAccess, literal: transparentExpression(left) } + } + return null +} + +const flattenConjuncts = (node: Node): Node[] => { + const expression = transparentExpression(node) + if (expression.kind() !== 'binary_expression' || binaryOperator(expression) !== '&&') return [expression] + const left = nodeField(expression, 'left') + const right = nodeField(expression, 'right') + return left && right ? [...flattenConjuncts(left), ...flattenConjuncts(right)] : [expression] +} + +const containingConjunction = (comparison: Node): Node | null => { + let current = comparison + let conjunction: Node | null = null + + for (const ancestor of comparison.ancestors()) { + if (ancestor.kind() === 'parenthesized_expression') { + current = ancestor + continue + } + if (ancestor.kind() !== 'binary_expression' || binaryOperator(ancestor) !== '&&') break + const left = nodeField(ancestor, 'left') + const right = nodeField(ancestor, 'right') + if ( + (!left || !isInsideRange(current, left.range().start.index, left.range().end.index)) && + (!right || !isInsideRange(current, right.range().start.index, right.range().end.index)) + ) { + break + } + conjunction = ancestor + current = ancestor + } + + return conjunction +} + +const strategyConjunctState = ( + comparison: Node, + receiverText: string, + expectedStrategy: string, +): 'absent' | 'matching' | 'conflicting' => { + const conjunction = containingConjunction(comparison) + if (!conjunction) return 'absent' + + let matching = false + for (const conjunct of flattenConjuncts(conjunction)) { + if (sameRange(conjunct, comparison)) continue + const predicate = equalityLiteral(conjunct) + if (!predicate || predicate.access.receiver.text() !== receiverText) continue + + if (binaryOperator(conjunct) === '===' && stringValue(predicate.literal) === expectedStrategy) matching = true + else return 'conflicting' + } + + return matching ? 'matching' : 'absent' +} + +const comparisonWithTargetCode = (comparison: Node, literal: Node, targetCode: string): string => { + const comparisonRange = comparison.range() + const literalRange = literal.range() + const start = literalRange.start.index - comparisonRange.start.index + const end = literalRange.end.index - comparisonRange.start.index + return `${comparison.text().slice(0, start)}${replacementForString(literal, targetCode)}${comparison.text().slice(end)}` +} + +const processErrorComparisons = (context: MigrationContext): void => { + for (const comparison of findAll(context.rootNode, 'binary_expression')) { + if (binaryOperator(comparison) !== '===') continue + const left = nodeField(comparison, 'left') + const right = nodeField(comparison, 'right') + if (!left || !right) continue + + const leftLiteral = transparentExpression(left) + const rightLiteral = transparentExpression(right) + const leftMigration = ERROR_CODE_BY_SOURCE.get(stringValue(leftLiteral) ?? '') + const rightMigration = ERROR_CODE_BY_SOURCE.get(stringValue(rightLiteral) ?? '') + const literal = leftMigration ? leftLiteral : rightMigration ? rightLiteral : null + const migration = leftMigration ?? rightMigration + if (!literal || !migration) continue + + const otherOperand = leftMigration ? right : left + const codeAccess = nonComputedPropertyAccess(otherOperand, 'code') + context.handledLegacyStarts.add(literal.range().start.index) + if (!codeAccess) { + addAudit( + context, + literal, + RULES.error, + 'The legacy code is not compared with a recoverable, non-computed .code receiver.', + ) + continue + } + + const strategyState = strategyConjunctState(comparison, codeAccess.receiver.text(), migration.strategy) + if (strategyState === 'conflicting') { + addAudit( + context, + literal, + RULES.error, + 'The surrounding conjunction already checks a different strategy for this receiver.', + ) + continue + } + + const group = `error-comparison:${comparison.range().start.index}` + if (strategyState === 'matching') { + addEdit(context, literal, replacementForString(literal, migration.targetCode), group, RULES.error) + continue + } + + const quote = literal.text()[0] + const codeComparison = comparisonWithTargetCode(comparison, literal, migration.targetCode) + const replacement = `(${codeComparison} && ${codeAccess.receiver.text()}.strategy === ${quote}${migration.strategy}${quote})` + addEdit(context, comparison, replacement, group, RULES.error) + } +} + +const unsupportedLegacyReason = (literal: Node): string => { + const ancestors = literal.ancestors() + const binary = ancestors.find((ancestor) => ancestor.kind() === 'binary_expression') + if (binary) { + const operator = binaryOperator(binary) + if (operator !== '===') return `The ${operator ?? 'unknown'} comparison is not a strict positive equality.` + } + if (ancestors.some((ancestor) => ancestor.kind() === 'switch_case')) { + return 'Switch labels require case-specific manual migration.' + } + if (ancestors.some((ancestor) => ancestor.kind() === 'array')) { + return 'Legacy codes stored in arrays require data-flow-aware manual migration.' + } + if (ancestors.some((ancestor) => ['pair', 'object'].includes(nodeKind(ancestor)))) { + return 'Legacy codes stored in lookup objects require data-flow-aware manual migration.' + } + if (ancestors.some((ancestor) => ['object_pattern', 'pair_pattern'].includes(nodeKind(ancestor)))) { + return 'Destructured legacy codes require data-flow-aware manual migration.' + } + if (ancestors.some((ancestor) => ancestor.kind() === 'variable_declarator')) { + return 'Legacy codes stored in variables require data-flow-aware manual migration.' + } + return 'This legacy code is not used in a supported direct .code equality.' +} + +const auditUnsupportedErrorCodes = (context: MigrationContext): void => { + for (const literal of context.stringLiterals) { + const migration: ErrorCodeMigration | undefined = ERROR_CODE_BY_SOURCE.get(stringValue(literal) ?? '') + if (!migration || context.handledLegacyStarts.has(literal.range().start.index)) continue + addAudit(context, literal, RULES.error, unsupportedLegacyReason(literal)) + } +} + +const auditUnsupportedModuleUses = (context: MigrationContext): void => { + for (const source of context.stringLiterals) { + const value = stringValue(source) + if (!value || context.scheduledSourceStarts.has(source.range().start.index)) continue + + if (value === 'uniku/cuid2') { + addAudit(context, source, RULES.cuid, 'The retired uniku/cuid2 module remains in an unsupported construct.') + continue + } + + const migration = GENERATOR_BY_MODULE.get(value) + if (!migration) continue + const call = source.ancestors().find((ancestor) => ancestor.kind() === 'call_expression') + if (call && nodeField(call, 'function')?.text() === 'require') { + addAudit(context, source, migration.rule, 'Static require() is unsupported because uniku is ESM-only.') + } + } + + for (const statement of context.importStatements) { + const source = stringValue(nodeField(statement, 'source')) + if (!source?.startsWith('.')) continue + for (const specifier of findAll(statement, 'import_specifier')) { + if (nodeField(specifier, 'name')?.text() === 'cuid2') { + addAudit( + context, + nodeField(specifier, 'name')!, + RULES.cuid, + 'A cuid2 import behind a local re-export is not followed across files.', + ) + } + } + } +} + +const createMigrationContext = (rootNode: Node, filename: string, path: string): MigrationContext => { + let cuidv2Collision: boolean | undefined + + return { + rootNode, + filename, + path, + importStatements: findAll(rootNode, 'import_statement'), + callExpressions: findAll(rootNode, 'call_expression'), + stringLiterals: findAll(rootNode, 'string'), + hasCuidv2Collision: () => (cuidv2Collision ??= cuidv2CollisionExists(rootNode)), + candidates: [], + groups: new Map(), + audits: [], + scheduledSourceStarts: new Set(), + handledLegacyStarts: new Set(), + } +} + +const codemod: Codemod = async (root) => { + const rootNode = root.root() + const context = createMigrationContext(rootNode, root.filename(), root.relativeFilename()) + + processCuidImports(context) + processCuidReexports(context) + processCuidDynamicImports(context) + + const bindings = discoverGeneratorBindings(context) + for (const call of context.callExpressions) { + const callee = nodeField(call, 'function') + if (!callee) continue + const migration = migrationForCallee(callee, bindings) + if (migration) processOptionCall(call, migration, context) + } + + processErrorComparisons(context) + + auditUnsupportedModuleUses(context) + auditUnsupportedErrorCodes(context) + + const editPlan = planEdits(context.candidates) + for (const groupId of editPlan.rejectedGroupIds) { + const metadata = context.groups.get(groupId) + if (metadata) { + addAudit( + context, + metadata.node, + RULES.overlap, + `Overlapping edits made the ${metadata.rule.id} atomic migration unsafe.`, + ) + } + } + + const findings = classifyAuditCandidates(context.audits) + for (const finding of findings) { + manualMigrationMetric.increment({ ruleId: finding.ruleId }) + console.log(renderAuditFinding(finding)) + } + + return editPlan.edits.length > 0 ? rootNode.commitEdits([...editPlan.edits]) : null +} + +export default codemod + +export const supportedGeneratorMigrations: readonly GeneratorMigration[] = GENERATOR_MIGRATIONS diff --git a/codemods/v1/tests/integration/consumer/component.tsx b/codemods/v1/tests/integration/consumer/component.tsx new file mode 100644 index 00000000..3392b995 --- /dev/null +++ b/codemods/v1/tests/integration/consumer/component.tsx @@ -0,0 +1,3 @@ +import { nanoid } from 'uniku/nanoid' + +export const value = {nanoid({ size: 8 })} diff --git a/codemods/v1/tests/integration/consumer/globals.d.ts b/codemods/v1/tests/integration/consumer/globals.d.ts new file mode 100644 index 00000000..57123757 --- /dev/null +++ b/codemods/v1/tests/integration/consumer/globals.d.ts @@ -0,0 +1,5 @@ +declare namespace JSX { + interface IntrinsicElements { + code: { children?: unknown } + } +} diff --git a/codemods/v1/tests/integration/consumer/index.ts b/codemods/v1/tests/integration/consumer/index.ts new file mode 100644 index 00000000..c0a79291 --- /dev/null +++ b/codemods/v1/tests/integration/consumer/index.ts @@ -0,0 +1,7 @@ +import { cuid2 } from 'uniku/cuid2' +import type { UniqueIdError } from 'uniku/errors' +import { uuidv7 } from 'uniku/uuid/v7' + +export const matchesUlidCharacterError = (error: UniqueIdError): boolean => error.code === 'ULID_INVALID_CHAR' + +export const values = [cuid2(), uuidv7({ seq: 4 })] diff --git a/codemods/v1/tests/integration/consumer/legacy.cjs b/codemods/v1/tests/integration/consumer/legacy.cjs new file mode 100644 index 00000000..6ff6da4e --- /dev/null +++ b/codemods/v1/tests/integration/consumer/legacy.cjs @@ -0,0 +1,6 @@ +async function create() { + const { objectid } = await import('uniku/objectid') + return objectid({ secs: 2 }) +} + +module.exports = { create } diff --git a/codemods/v1/tests/integration/consumer/package.json b/codemods/v1/tests/integration/consumer/package.json new file mode 100644 index 00000000..39737352 --- /dev/null +++ b/codemods/v1/tests/integration/consumer/package.json @@ -0,0 +1,5 @@ +{ + "name": "uniku-v1-codemod-consumer", + "private": true, + "type": "module" +} diff --git a/codemods/v1/tests/integration/consumer/runtime.mjs b/codemods/v1/tests/integration/consumer/runtime.mjs new file mode 100644 index 00000000..5def6a0e --- /dev/null +++ b/codemods/v1/tests/integration/consumer/runtime.mjs @@ -0,0 +1,12 @@ +import { cuid2 } from 'uniku/cuid2' +import { ParseError } from 'uniku/errors' +import { uuidv7 } from 'uniku/uuid/v7' +import legacy from './legacy.cjs' + +const error = new ParseError('INVALID_CHAR', 'invalid ULID', { strategy: 'ulid' }) +if (!(error.code === 'ULID_INVALID_CHAR')) throw new Error('legacy error comparison did not match') + +const values = [cuid2(), uuidv7({ seq: 7 }), await legacy.create()] +if (values.some((value) => typeof value !== 'string' || value.length === 0)) { + throw new Error('migrated generators did not return IDs') +} diff --git a/codemods/v1/tests/integration/consumer/tsconfig.json b/codemods/v1/tests/integration/consumer/tsconfig.json new file mode 100644 index 00000000..2cf0f0d9 --- /dev/null +++ b/codemods/v1/tests/integration/consumer/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "strict": true, + "noEmit": true, + "jsx": "preserve", + "skipLibCheck": true + }, + "include": ["*.ts", "*.tsx", "*.d.ts"] +} diff --git a/codemods/v1/tests/integration/consumer/unsupported.mjs b/codemods/v1/tests/integration/consumer/unsupported.mjs new file mode 100644 index 00000000..a24d896f --- /dev/null +++ b/codemods/v1/tests/integration/consumer/unsupported.mjs @@ -0,0 +1,6 @@ +import { uuidv7 } from 'uniku/uuid/v7' + +const shared = { stable: true } + +export const value = uuidv7({ ...shared, seq: 5 }) +export const legacyCodes = ['XID_NON_CANONICAL'] diff --git a/codemods/v1/tests/integration/expected-findings.json b/codemods/v1/tests/integration/expected-findings.json new file mode 100644 index 00000000..73964ab7 --- /dev/null +++ b/codemods/v1/tests/integration/expected-findings.json @@ -0,0 +1,18 @@ +[ + { + "ruleId": "uniku-v1/counter-options", + "path": "unsupported.mjs", + "line": 5, + "column": 42, + "reason": "An object spread makes seq precedence ambiguous.", + "guideUrl": "https://jkomyno.github.io/uniku/docs/migration/v1/#rename-uuid-v7-and-typeid-counters" + }, + { + "ruleId": "uniku-v1/error-codes", + "path": "unsupported.mjs", + "line": 6, + "column": 29, + "reason": "Legacy codes stored in arrays require data-flow-aware manual migration.", + "guideUrl": "https://jkomyno.github.io/uniku/docs/migration/v1/#update-error-code-matches" + } +] diff --git a/codemods/v1/tests/integration/expected/component.tsx b/codemods/v1/tests/integration/expected/component.tsx new file mode 100644 index 00000000..3c915f39 --- /dev/null +++ b/codemods/v1/tests/integration/expected/component.tsx @@ -0,0 +1,3 @@ +import { nanoid } from 'uniku/nanoid' + +export const value = {nanoid({ length: 8 })} diff --git a/codemods/v1/tests/integration/expected/globals.d.ts b/codemods/v1/tests/integration/expected/globals.d.ts new file mode 100644 index 00000000..57123757 --- /dev/null +++ b/codemods/v1/tests/integration/expected/globals.d.ts @@ -0,0 +1,5 @@ +declare namespace JSX { + interface IntrinsicElements { + code: { children?: unknown } + } +} diff --git a/codemods/v1/tests/integration/expected/index.ts b/codemods/v1/tests/integration/expected/index.ts new file mode 100644 index 00000000..c04b6804 --- /dev/null +++ b/codemods/v1/tests/integration/expected/index.ts @@ -0,0 +1,7 @@ +import { cuidv2 } from 'uniku/cuid/v2' +import type { UniqueIdError } from 'uniku/errors' +import { uuidv7 } from 'uniku/uuid/v7' + +export const matchesUlidCharacterError = (error: UniqueIdError): boolean => (error.code === 'INVALID_CHAR' && error.strategy === 'ulid') + +export const values = [cuidv2(), uuidv7({ counter: 4 })] diff --git a/codemods/v1/tests/integration/expected/legacy.cjs b/codemods/v1/tests/integration/expected/legacy.cjs new file mode 100644 index 00000000..124bae73 --- /dev/null +++ b/codemods/v1/tests/integration/expected/legacy.cjs @@ -0,0 +1,6 @@ +async function create() { + const { objectid } = await import('uniku/objectid') + return objectid({ msecs: 2 * 1000 }) +} + +module.exports = { create } diff --git a/codemods/v1/tests/integration/expected/package.json b/codemods/v1/tests/integration/expected/package.json new file mode 100644 index 00000000..39737352 --- /dev/null +++ b/codemods/v1/tests/integration/expected/package.json @@ -0,0 +1,5 @@ +{ + "name": "uniku-v1-codemod-consumer", + "private": true, + "type": "module" +} diff --git a/codemods/v1/tests/integration/expected/runtime.mjs b/codemods/v1/tests/integration/expected/runtime.mjs new file mode 100644 index 00000000..30599610 --- /dev/null +++ b/codemods/v1/tests/integration/expected/runtime.mjs @@ -0,0 +1,12 @@ +import { cuidv2 } from 'uniku/cuid/v2' +import { ParseError } from 'uniku/errors' +import { uuidv7 } from 'uniku/uuid/v7' +import legacy from './legacy.cjs' + +const error = new ParseError('INVALID_CHAR', 'invalid ULID', { strategy: 'ulid' }) +if (!((error.code === 'INVALID_CHAR' && error.strategy === 'ulid'))) throw new Error('legacy error comparison did not match') + +const values = [cuidv2(), uuidv7({ counter: 7 }), await legacy.create()] +if (values.some((value) => typeof value !== 'string' || value.length === 0)) { + throw new Error('migrated generators did not return IDs') +} diff --git a/codemods/v1/tests/integration/expected/tsconfig.json b/codemods/v1/tests/integration/expected/tsconfig.json new file mode 100644 index 00000000..2cf0f0d9 --- /dev/null +++ b/codemods/v1/tests/integration/expected/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "strict": true, + "noEmit": true, + "jsx": "preserve", + "skipLibCheck": true + }, + "include": ["*.ts", "*.tsx", "*.d.ts"] +} diff --git a/codemods/v1/tests/integration/expected/unsupported.mjs b/codemods/v1/tests/integration/expected/unsupported.mjs new file mode 100644 index 00000000..a24d896f --- /dev/null +++ b/codemods/v1/tests/integration/expected/unsupported.mjs @@ -0,0 +1,6 @@ +import { uuidv7 } from 'uniku/uuid/v7' + +const shared = { stable: true } + +export const value = uuidv7({ ...shared, seq: 5 }) +export const legacyCodes = ['XID_NON_CANONICAL'] diff --git a/codemods/v1/tests/javascript/already-migrated/expected.js b/codemods/v1/tests/javascript/already-migrated/expected.js new file mode 100644 index 00000000..e544b943 --- /dev/null +++ b/codemods/v1/tests/javascript/already-migrated/expected.js @@ -0,0 +1,7 @@ +import { cuidv2 } from 'uniku/cuid/v2' +import { uuidv7 } from 'uniku/uuid/v7' +import { nanoid } from 'uniku/nanoid' +import { ksuid } from 'uniku/ksuid' + +export const values = [cuidv2(), uuidv7({ counter: 2 }), nanoid({ length: 4 }), ksuid({ msecs: 1000 })] +export const currentErrorMatch = error.code === 'INVALID_CHAR' && error.strategy === 'ulid' diff --git a/codemods/v1/tests/javascript/already-migrated/input.js b/codemods/v1/tests/javascript/already-migrated/input.js new file mode 100644 index 00000000..e544b943 --- /dev/null +++ b/codemods/v1/tests/javascript/already-migrated/input.js @@ -0,0 +1,7 @@ +import { cuidv2 } from 'uniku/cuid/v2' +import { uuidv7 } from 'uniku/uuid/v7' +import { nanoid } from 'uniku/nanoid' +import { ksuid } from 'uniku/ksuid' + +export const values = [cuidv2(), uuidv7({ counter: 2 }), nanoid({ length: 4 }), ksuid({ msecs: 1000 })] +export const currentErrorMatch = error.code === 'INVALID_CHAR' && error.strategy === 'ulid' diff --git a/codemods/v1/tests/javascript/comments-crlf/expected.js b/codemods/v1/tests/javascript/comments-crlf/expected.js new file mode 100644 index 00000000..394d80af --- /dev/null +++ b/codemods/v1/tests/javascript/comments-crlf/expected.js @@ -0,0 +1,3 @@ +import { ksuid } from 'uniku/ksuid' + +export const id = ksuid({ msecs /* Unix seconds */: (start + offset) * 1000, }) diff --git a/codemods/v1/tests/javascript/comments-crlf/input.js b/codemods/v1/tests/javascript/comments-crlf/input.js new file mode 100644 index 00000000..3f074065 --- /dev/null +++ b/codemods/v1/tests/javascript/comments-crlf/input.js @@ -0,0 +1,3 @@ +import { ksuid } from 'uniku/ksuid' + +export const id = ksuid({ secs /* Unix seconds */: start + offset, }) diff --git a/codemods/v1/tests/javascript/cuid-alias/expected.js b/codemods/v1/tests/javascript/cuid-alias/expected.js new file mode 100644 index 00000000..fca98c3d --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-alias/expected.js @@ -0,0 +1,3 @@ +import { cuidv2 as createId } from 'uniku/cuid/v2' + +export const id = createId() diff --git a/codemods/v1/tests/javascript/cuid-alias/input.js b/codemods/v1/tests/javascript/cuid-alias/input.js new file mode 100644 index 00000000..8253f547 --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-alias/input.js @@ -0,0 +1,3 @@ +import { cuid2 as createId } from 'uniku/cuid2' + +export const id = createId() diff --git a/codemods/v1/tests/javascript/cuid-dynamic/expected.js b/codemods/v1/tests/javascript/cuid-dynamic/expected.js new file mode 100644 index 00000000..1fecdb5e --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-dynamic/expected.js @@ -0,0 +1,5 @@ +export async function create() { + const { cuidv2 } = await import('uniku/cuid/v2') + const direct = (await import("uniku/cuid/v2")).cuidv2() + return [cuidv2(), direct] +} diff --git a/codemods/v1/tests/javascript/cuid-dynamic/input.js b/codemods/v1/tests/javascript/cuid-dynamic/input.js new file mode 100644 index 00000000..9f197986 --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-dynamic/input.js @@ -0,0 +1,5 @@ +export async function create() { + const { cuid2 } = await import('uniku/cuid2') + const direct = (await import("uniku/cuid2")).cuid2() + return [cuid2(), direct] +} diff --git a/codemods/v1/tests/javascript/cuid-named/expected.js b/codemods/v1/tests/javascript/cuid-named/expected.js new file mode 100644 index 00000000..e2709eae --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-named/expected.js @@ -0,0 +1,5 @@ +import { cuidv2 } from "uniku/cuid/v2" + +const factory = { cuidv2 } +export const id = cuidv2() +export { factory } diff --git a/codemods/v1/tests/javascript/cuid-named/input.js b/codemods/v1/tests/javascript/cuid-named/input.js new file mode 100644 index 00000000..994af649 --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-named/input.js @@ -0,0 +1,5 @@ +import { cuid2 } from "uniku/cuid2" + +const factory = { cuid2 } +export const id = cuid2() +export { factory } diff --git a/codemods/v1/tests/javascript/cuid-namespace/expected.js b/codemods/v1/tests/javascript/cuid-namespace/expected.js new file mode 100644 index 00000000..14b245ae --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-namespace/expected.js @@ -0,0 +1,3 @@ +import * as cuid from 'uniku/cuid/v2' + +export const id = cuid.cuidv2() diff --git a/codemods/v1/tests/javascript/cuid-namespace/input.js b/codemods/v1/tests/javascript/cuid-namespace/input.js new file mode 100644 index 00000000..0a897806 --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-namespace/input.js @@ -0,0 +1,3 @@ +import * as cuid from 'uniku/cuid2' + +export const id = cuid.cuid2() diff --git a/codemods/v1/tests/javascript/cuid-reexport/expected.js b/codemods/v1/tests/javascript/cuid-reexport/expected.js new file mode 100644 index 00000000..f9a20cf6 --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-reexport/expected.js @@ -0,0 +1 @@ +export { cuidv2 as createId } from 'uniku/cuid/v2' diff --git a/codemods/v1/tests/javascript/cuid-reexport/input.js b/codemods/v1/tests/javascript/cuid-reexport/input.js new file mode 100644 index 00000000..2647a5cd --- /dev/null +++ b/codemods/v1/tests/javascript/cuid-reexport/input.js @@ -0,0 +1 @@ +export { cuid2 as createId } from 'uniku/cuid2' diff --git a/codemods/v1/tests/javascript/error-comparisons/expected.js b/codemods/v1/tests/javascript/error-comparisons/expected.js new file mode 100644 index 00000000..3946ebff --- /dev/null +++ b/codemods/v1/tests/javascript/error-comparisons/expected.js @@ -0,0 +1,10 @@ +export const first = (error.code === 'INVALID_CHAR' && error.strategy === 'ulid') +export const second = ("INVALID_CHAR" === result.error.code && result.error.strategy === "uuid") +export const third = ((error.code) === 'NON_CANONICAL' && error.strategy === 'xid') +export const existing = error.code === 'INVALID_LENGTH' && error.strategy === 'ulid' +export const mixedAnd = ready && (error.code === 'VALUE_OUT_OF_RANGE' && error.strategy === 'ksuid') +export const mixedOr = (error.code === 'INVALID_CHAR' && error.strategy === 'ksuid') || fallback +export const negated = !((error.code === 'NODE_OUT_OF_RANGE' && error.strategy === 'tsid')) +export const converging = + (error.code === 'TIMESTAMP_OUT_OF_RANGE' && error.strategy === 'uuid') || (error.code === 'TIMESTAMP_OUT_OF_RANGE' && error.strategy === 'ulid') +export const commented = (error.code /* keep */ === 'LENGTH_OUT_OF_RANGE' && error.strategy === 'nanoid') // outside diff --git a/codemods/v1/tests/javascript/error-comparisons/input.js b/codemods/v1/tests/javascript/error-comparisons/input.js new file mode 100644 index 00000000..785ae839 --- /dev/null +++ b/codemods/v1/tests/javascript/error-comparisons/input.js @@ -0,0 +1,10 @@ +export const first = error.code === 'ULID_INVALID_CHAR' +export const second = "UUID_INVALID_HEX_CHAR" === result.error.code +export const third = (error.code) === 'XID_NON_CANONICAL' +export const existing = error.code === 'ULID_INVALID_LENGTH' && error.strategy === 'ulid' +export const mixedAnd = ready && error.code === 'KSUID_OVERFLOW' +export const mixedOr = error.code === 'KSUID_INVALID_CHAR' || fallback +export const negated = !(error.code === 'TSID_NODE_OUT_OF_RANGE') +export const converging = + error.code === 'UUID_TIMESTAMP_OUT_OF_RANGE' || error.code === 'ULID_TIMESTAMP_OUT_OF_RANGE' +export const commented = error.code /* keep */ === 'NANOID_SIZE_INVALID' // outside diff --git a/codemods/v1/tests/javascript/error-unsupported/expected.js b/codemods/v1/tests/javascript/error-unsupported/expected.js new file mode 100644 index 00000000..c52a431e --- /dev/null +++ b/codemods/v1/tests/javascript/error-unsupported/expected.js @@ -0,0 +1,19 @@ +export const wrongStrategy = error.code === 'ULID_INVALID_CHAR' && error.strategy === 'uuid' +export const negative = error.code !== 'UUID_INVALID_LENGTH' +export const loose = error.code == 'KSUID_INVALID_CHAR' +export const looseNegative = error.code != 'XID_INVALID_LENGTH' +export const optional = error?.code === 'TSID_INVALID_CHAR' +export const computed = error['code'] === 'TYPEID_INVALID_FORMAT' + +switch (error.code) { + case 'OBJECTID_INVALID_LENGTH': + break +} + +export const handlers = { 'NANOID_ALPHABET_DUPLICATE': handleDuplicate } +export const retryable = ['ULID_RANDOM_OVERFLOW'] +const { code = 'CUID2_RANDOM_BYTES_EMPTY' } = error +export const held = 'XID_NON_CANONICAL' +export const indirect = error.code === held +const { code: destructuredCode } = error +export const destructured = destructuredCode === 'UUID_BUFFER_OUT_OF_BOUNDS' diff --git a/codemods/v1/tests/javascript/error-unsupported/input.js b/codemods/v1/tests/javascript/error-unsupported/input.js new file mode 100644 index 00000000..c52a431e --- /dev/null +++ b/codemods/v1/tests/javascript/error-unsupported/input.js @@ -0,0 +1,19 @@ +export const wrongStrategy = error.code === 'ULID_INVALID_CHAR' && error.strategy === 'uuid' +export const negative = error.code !== 'UUID_INVALID_LENGTH' +export const loose = error.code == 'KSUID_INVALID_CHAR' +export const looseNegative = error.code != 'XID_INVALID_LENGTH' +export const optional = error?.code === 'TSID_INVALID_CHAR' +export const computed = error['code'] === 'TYPEID_INVALID_FORMAT' + +switch (error.code) { + case 'OBJECTID_INVALID_LENGTH': + break +} + +export const handlers = { 'NANOID_ALPHABET_DUPLICATE': handleDuplicate } +export const retryable = ['ULID_RANDOM_OVERFLOW'] +const { code = 'CUID2_RANDOM_BYTES_EMPTY' } = error +export const held = 'XID_NON_CANONICAL' +export const indirect = error.code === held +const { code: destructuredCode } = error +export const destructured = destructuredCode === 'UUID_BUFFER_OUT_OF_BOUNDS' diff --git a/codemods/v1/tests/javascript/error-unsupported/metrics.json b/codemods/v1/tests/javascript/error-unsupported/metrics.json new file mode 100644 index 00000000..89a22b1a --- /dev/null +++ b/codemods/v1/tests/javascript/error-unsupported/metrics.json @@ -0,0 +1,10 @@ +{ + "uniku-v1-manual-migrations": [ + { + "cardinality": { + "ruleId": "uniku-v1/error-codes" + }, + "count": 12 + } + ] +} \ No newline at end of file diff --git a/codemods/v1/tests/javascript/extensions-cjs/expected.cjs b/codemods/v1/tests/javascript/extensions-cjs/expected.cjs new file mode 100644 index 00000000..7f5acfd5 --- /dev/null +++ b/codemods/v1/tests/javascript/extensions-cjs/expected.cjs @@ -0,0 +1,6 @@ +async function create() { + const { objectid: makeObjectId } = await import('uniku/objectid') + return makeObjectId({ msecs: 1 * 1000 }) +} + +module.exports = { create } diff --git a/codemods/v1/tests/javascript/extensions-cjs/input.cjs b/codemods/v1/tests/javascript/extensions-cjs/input.cjs new file mode 100644 index 00000000..dc2a6b5c --- /dev/null +++ b/codemods/v1/tests/javascript/extensions-cjs/input.cjs @@ -0,0 +1,6 @@ +async function create() { + const { objectid: makeObjectId } = await import('uniku/objectid') + return makeObjectId({ secs: 1 }) +} + +module.exports = { create } diff --git a/codemods/v1/tests/javascript/extensions-js/expected.js b/codemods/v1/tests/javascript/extensions-js/expected.js new file mode 100644 index 00000000..7000abfa --- /dev/null +++ b/codemods/v1/tests/javascript/extensions-js/expected.js @@ -0,0 +1,3 @@ +import { uuidv7 } from 'uniku/uuid/v7' + +export const id = uuidv7({ counter: 1 }) diff --git a/codemods/v1/tests/javascript/extensions-js/input.js b/codemods/v1/tests/javascript/extensions-js/input.js new file mode 100644 index 00000000..1cb5bfdb --- /dev/null +++ b/codemods/v1/tests/javascript/extensions-js/input.js @@ -0,0 +1,3 @@ +import { uuidv7 } from 'uniku/uuid/v7' + +export const id = uuidv7({ seq: 1 }) diff --git a/codemods/v1/tests/javascript/extensions-jsx/expected.jsx b/codemods/v1/tests/javascript/extensions-jsx/expected.jsx new file mode 100644 index 00000000..321c29ba --- /dev/null +++ b/codemods/v1/tests/javascript/extensions-jsx/expected.jsx @@ -0,0 +1,3 @@ +import { nanoid } from 'uniku/nanoid' + +export const view = {nanoid({ alphabet: 'abc', length: 2 })} diff --git a/codemods/v1/tests/javascript/extensions-jsx/input.jsx b/codemods/v1/tests/javascript/extensions-jsx/input.jsx new file mode 100644 index 00000000..d83167f9 --- /dev/null +++ b/codemods/v1/tests/javascript/extensions-jsx/input.jsx @@ -0,0 +1,3 @@ +import { nanoid } from 'uniku/nanoid' + +export const view = {nanoid({ alphabet: 'abc', size: 2 })} diff --git a/codemods/v1/tests/javascript/extensions-mjs/expected.mjs b/codemods/v1/tests/javascript/extensions-mjs/expected.mjs new file mode 100644 index 00000000..52cebcdd --- /dev/null +++ b/codemods/v1/tests/javascript/extensions-mjs/expected.mjs @@ -0,0 +1,3 @@ +import { ksuid as makeKsuid } from 'uniku/ksuid' + +export const id = makeKsuid({ msecs: readSeconds() * 1000 }) diff --git a/codemods/v1/tests/javascript/extensions-mjs/input.mjs b/codemods/v1/tests/javascript/extensions-mjs/input.mjs new file mode 100644 index 00000000..403bd965 --- /dev/null +++ b/codemods/v1/tests/javascript/extensions-mjs/input.mjs @@ -0,0 +1,3 @@ +import { ksuid as makeKsuid } from 'uniku/ksuid' + +export const id = makeKsuid({ secs: readSeconds() }) diff --git a/codemods/v1/tests/javascript/imports-and-options/expected.js b/codemods/v1/tests/javascript/imports-and-options/expected.js new file mode 100644 index 00000000..0f80b6ed --- /dev/null +++ b/codemods/v1/tests/javascript/imports-and-options/expected.js @@ -0,0 +1,20 @@ +import * as uuid from 'uniku/uuid/v7' +import { typeid as makeTypeId } from 'uniku/typeid' +import { nanoid } from 'uniku/nanoid' +import { xid } from 'uniku/xid' + +const seq = 4 +const size = 12 +const secs = 8 + +export const values = [ + uuid.uuidv7({ counter: seq }), + makeTypeId('user', { counter: 2 }), + nanoid({ alphabet: 'abcdef', length: size }), + nanoid({ 'length': 8 }), + nanoid(12), + xid({ msecs: secs * 1000 }), + xid({ msecs: (start + offset) * 1000 }), + xid({ msecs: (condition ? start : fallback) * 1000 }), + xid({ "msecs": 2 * 1000 }), +] diff --git a/codemods/v1/tests/javascript/imports-and-options/input.js b/codemods/v1/tests/javascript/imports-and-options/input.js new file mode 100644 index 00000000..286c2b22 --- /dev/null +++ b/codemods/v1/tests/javascript/imports-and-options/input.js @@ -0,0 +1,20 @@ +import * as uuid from 'uniku/uuid/v7' +import { typeid as makeTypeId } from 'uniku/typeid' +import { nanoid } from 'uniku/nanoid' +import { xid } from 'uniku/xid' + +const seq = 4 +const size = 12 +const secs = 8 + +export const values = [ + uuid.uuidv7({ seq }), + makeTypeId('user', { seq: 2 }), + nanoid({ alphabet: 'abcdef', size }), + nanoid({ 'size': 8 }), + nanoid(12), + xid({ secs }), + xid({ secs: start + offset }), + xid({ secs: condition ? start : fallback }), + xid({ "secs": 2 }), +] diff --git a/codemods/v1/tests/javascript/negative/expected.js b/codemods/v1/tests/javascript/negative/expected.js new file mode 100644 index 00000000..2bb21ea2 --- /dev/null +++ b/codemods/v1/tests/javascript/negative/expected.js @@ -0,0 +1,12 @@ +import { uuidv7 as makeUuid } from 'other-library' +import { nanoid } from 'uniku/nanoid' +import { xid } from 'uniku/xid' + +const settings = { secs: 1, seq: 2, size: 3 } +const words = ['uniku/cuid2-example', 'secs', 'seq', 'size'] + +function run(nanoid, xid) { + return [nanoid({ size: 4 }), xid({ secs: 5 })] +} + +export const values = [makeUuid({ seq: 6 }), nanoid(12), settings, words, run] diff --git a/codemods/v1/tests/javascript/negative/input.js b/codemods/v1/tests/javascript/negative/input.js new file mode 100644 index 00000000..2bb21ea2 --- /dev/null +++ b/codemods/v1/tests/javascript/negative/input.js @@ -0,0 +1,12 @@ +import { uuidv7 as makeUuid } from 'other-library' +import { nanoid } from 'uniku/nanoid' +import { xid } from 'uniku/xid' + +const settings = { secs: 1, seq: 2, size: 3 } +const words = ['uniku/cuid2-example', 'secs', 'seq', 'size'] + +function run(nanoid, xid) { + return [nanoid({ size: 4 }), xid({ secs: 5 })] +} + +export const values = [makeUuid({ seq: 6 }), nanoid(12), settings, words, run] diff --git a/codemods/v1/tests/javascript/unsupported/expected.js b/codemods/v1/tests/javascript/unsupported/expected.js new file mode 100644 index 00000000..496d63e8 --- /dev/null +++ b/codemods/v1/tests/javascript/unsupported/expected.js @@ -0,0 +1,15 @@ +import { cuid2 } from 'uniku/cuid2' +import { uuidv7 } from 'uniku/uuid/v7' +import { xid } from 'uniku/xid' + +const cuidv2 = () => 'occupied' +const shared = { value: 1 } + +const first = uuidv7({ seq: 1, counter: 2 }) +const second = uuidv7({ ...shared, seq: 2 }) +const third = xid({ ['other']: 1, secs: 3 }) +const fourth = require('uniku/xid').xid({ secs: 4 }) +const fifth = cuid2() +const sixth = uuidv7({ ['seq']: 3 }) + +export { first, second, third, fourth, fifth, sixth, cuidv2 } diff --git a/codemods/v1/tests/javascript/unsupported/input.js b/codemods/v1/tests/javascript/unsupported/input.js new file mode 100644 index 00000000..496d63e8 --- /dev/null +++ b/codemods/v1/tests/javascript/unsupported/input.js @@ -0,0 +1,15 @@ +import { cuid2 } from 'uniku/cuid2' +import { uuidv7 } from 'uniku/uuid/v7' +import { xid } from 'uniku/xid' + +const cuidv2 = () => 'occupied' +const shared = { value: 1 } + +const first = uuidv7({ seq: 1, counter: 2 }) +const second = uuidv7({ ...shared, seq: 2 }) +const third = xid({ ['other']: 1, secs: 3 }) +const fourth = require('uniku/xid').xid({ secs: 4 }) +const fifth = cuid2() +const sixth = uuidv7({ ['seq']: 3 }) + +export { first, second, third, fourth, fifth, sixth, cuidv2 } diff --git a/codemods/v1/tests/javascript/unsupported/metrics.json b/codemods/v1/tests/javascript/unsupported/metrics.json new file mode 100644 index 00000000..026dc808 --- /dev/null +++ b/codemods/v1/tests/javascript/unsupported/metrics.json @@ -0,0 +1,22 @@ +{ + "uniku-v1-manual-migrations": [ + { + "cardinality": { + "ruleId": "uniku-v1/counter-options" + }, + "count": 3 + }, + { + "cardinality": { + "ruleId": "uniku-v1/cuid-entry-point" + }, + "count": 1 + }, + { + "cardinality": { + "ruleId": "uniku-v1/timestamp-options" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/v1/tests/run-integration.test.ts b/codemods/v1/tests/run-integration.test.ts new file mode 100644 index 00000000..a146b189 --- /dev/null +++ b/codemods/v1/tests/run-integration.test.ts @@ -0,0 +1,140 @@ +import { execFileSync } from 'node:child_process' +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import type { AuditFinding } from '../scripts/audit.js' +import expectedFindings from './integration/expected-findings.json' + +const testDirectory = dirname(fileURLToPath(import.meta.url)) +const packageRoot = resolve(testDirectory, '..') +const repositoryRoot = resolve(packageRoot, '../..') +const codemodBinary = resolve(packageRoot, 'node_modules/.bin/codemod') +const typescriptBinary = resolve(repositoryRoot, 'node_modules/.bin/tsc') + +const run = (command: string, args: readonly string[], cwd: string): string => + execFileSync(command, [...args], { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_EMAIL: 'integration@example.test', + GIT_AUTHOR_NAME: 'uniku integration', + GIT_COMMITTER_EMAIL: 'integration@example.test', + GIT_COMMITTER_NAME: 'uniku integration', + NO_COLOR: '1', + }, + }) + +const collectFiles = (directory: string): string[] => { + const files: string[] = [] + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name === '.git') continue + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...collectFiles(path)) + else files.push(path) + } + return files.sort() +} + +const readTree = (directory: string): Record => + Object.fromEntries( + collectFiles(directory).map((path): readonly [string, string] => [ + relative(directory, path), + readFileSync(path, 'utf8'), + ]), + ) + +const findAuditRecords = (value: unknown): AuditFinding[] => { + if (typeof value === 'string') { + return value.split('\n').flatMap((line) => { + try { + return findAuditRecords(JSON.parse(line) as unknown) + } catch { + return [] + } + }) + } + if (Array.isArray(value)) return value.flatMap(findAuditRecords) + if (!value || typeof value !== 'object') return [] + + const record = value as Record + const nested = Object.values(record).flatMap(findAuditRecords) + if (record.type !== 'uniku-v1-audit') return nested + + return [ + { + ruleId: String(record.ruleId) as AuditFinding['ruleId'], + path: String(record.path), + line: Number(record.line), + column: Number(record.column), + reason: String(record.reason), + guideUrl: String(record.guideUrl), + }, + ...nested, + ] +} + +const parseAuditOutput = (output: string): AuditFinding[] => + findAuditRecords(output).sort( + (left, right) => + left.path.localeCompare(right.path) || + left.line - right.line || + left.column - right.column || + left.ruleId.localeCompare(right.ruleId), + ) + +const runWorkflow = (target: string): string => + run( + codemodBinary, + [ + 'workflow', + 'run', + '-w', + packageRoot, + '-t', + target, + '--no-interactive', + '--format', + 'jsonl', + '--disable-analytics', + ], + packageRoot, + ) + +describe('local workflow integration', () => { + it('migrates the fixture, emits structured findings, and is idempotent', () => { + const target = mkdtempSync(join(tmpdir(), 'uniku-v1-codemod-')) + const artifacts = mkdtempSync(join(tmpdir(), 'uniku-v1-artifacts-')) + + try { + cpSync(resolve(testDirectory, 'integration/consumer'), target, { recursive: true }) + run('git', ['init', '--quiet'], target) + run('git', ['add', '.'], target) + run('git', ['commit', '--quiet', '-m', 'fixture'], target) + + const output = runWorkflow(target) + + expect(readTree(target)).toEqual(readTree(resolve(testDirectory, 'integration/expected'))) + expect(parseAuditOutput(output)).toEqual(expectedFindings) + + run('git', ['add', '.'], target) + run('git', ['commit', '--quiet', '-m', 'migrated'], target) + runWorkflow(target) + expect(run('git', ['status', '--porcelain'], target)).toBe('') + + run('pnpm', ['--filter', 'uniku', 'build'], repositoryRoot) + const tarball = join(artifacts, 'uniku.tgz') + run('pnpm', ['--filter', 'uniku', 'pack', '--out', tarball], repositoryRoot) + run('pnpm', ['add', '--offline', '--ignore-scripts', '--save-exact', tarball], target) + run(typescriptBinary, ['-p', 'tsconfig.json'], target) + run(process.execPath, ['runtime.mjs'], target) + } finally { + rmSync(target, { recursive: true, force: true }) + rmSync(artifacts, { recursive: true, force: true }) + } + }, 30_000) +}) diff --git a/codemods/v1/tests/tsx/already-migrated/expected.tsx b/codemods/v1/tests/tsx/already-migrated/expected.tsx new file mode 100644 index 00000000..3c915f39 --- /dev/null +++ b/codemods/v1/tests/tsx/already-migrated/expected.tsx @@ -0,0 +1,3 @@ +import { nanoid } from 'uniku/nanoid' + +export const value = {nanoid({ length: 8 })} diff --git a/codemods/v1/tests/tsx/already-migrated/input.tsx b/codemods/v1/tests/tsx/already-migrated/input.tsx new file mode 100644 index 00000000..3c915f39 --- /dev/null +++ b/codemods/v1/tests/tsx/already-migrated/input.tsx @@ -0,0 +1,3 @@ +import { nanoid } from 'uniku/nanoid' + +export const value = {nanoid({ length: 8 })} diff --git a/codemods/v1/tests/tsx/error-comparisons/expected.tsx b/codemods/v1/tests/tsx/error-comparisons/expected.tsx new file mode 100644 index 00000000..46814115 --- /dev/null +++ b/codemods/v1/tests/tsx/error-comparisons/expected.tsx @@ -0,0 +1,7 @@ +interface Props { + error: { code: string; strategy?: string } +} + +export function ErrorBadge({ error }: Props): JSX.Element | null { + return (error.code === 'LENGTH_OUT_OF_RANGE' && error.strategy === 'cuid') ? Invalid length : null +} diff --git a/codemods/v1/tests/tsx/error-comparisons/input.tsx b/codemods/v1/tests/tsx/error-comparisons/input.tsx new file mode 100644 index 00000000..c5f3cf51 --- /dev/null +++ b/codemods/v1/tests/tsx/error-comparisons/input.tsx @@ -0,0 +1,7 @@ +interface Props { + error: { code: string; strategy?: string } +} + +export function ErrorBadge({ error }: Props): JSX.Element | null { + return error.code === 'CUID2_LENGTH_OUT_OF_RANGE' ? Invalid length : null +} diff --git a/codemods/v1/tests/tsx/error-unsupported/expected.tsx b/codemods/v1/tests/tsx/error-unsupported/expected.tsx new file mode 100644 index 00000000..1abfb51b --- /dev/null +++ b/codemods/v1/tests/tsx/error-unsupported/expected.tsx @@ -0,0 +1,7 @@ +interface Props { + error?: { code: string; strategy?: string } +} + +export function ErrorBadge({ error }: Props): JSX.Element { + return {error?.code === 'UUID_INVALID_SEPARATORS' ? 'bad' : 'ok'} +} diff --git a/codemods/v1/tests/tsx/error-unsupported/input.tsx b/codemods/v1/tests/tsx/error-unsupported/input.tsx new file mode 100644 index 00000000..1abfb51b --- /dev/null +++ b/codemods/v1/tests/tsx/error-unsupported/input.tsx @@ -0,0 +1,7 @@ +interface Props { + error?: { code: string; strategy?: string } +} + +export function ErrorBadge({ error }: Props): JSX.Element { + return {error?.code === 'UUID_INVALID_SEPARATORS' ? 'bad' : 'ok'} +} diff --git a/codemods/v1/tests/tsx/error-unsupported/metrics.json b/codemods/v1/tests/tsx/error-unsupported/metrics.json new file mode 100644 index 00000000..de03fe20 --- /dev/null +++ b/codemods/v1/tests/tsx/error-unsupported/metrics.json @@ -0,0 +1,10 @@ +{ + "uniku-v1-manual-migrations": [ + { + "cardinality": { + "ruleId": "uniku-v1/error-codes" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1/tests/tsx/extensions-tsx/expected.tsx b/codemods/v1/tests/tsx/extensions-tsx/expected.tsx new file mode 100644 index 00000000..959807d7 --- /dev/null +++ b/codemods/v1/tests/tsx/extensions-tsx/expected.tsx @@ -0,0 +1,3 @@ +import { nanoid as createNanoid } from 'uniku/nanoid' + +export const value = {createNanoid({ length: 8 })} diff --git a/codemods/v1/tests/tsx/extensions-tsx/input.tsx b/codemods/v1/tests/tsx/extensions-tsx/input.tsx new file mode 100644 index 00000000..cedb59d1 --- /dev/null +++ b/codemods/v1/tests/tsx/extensions-tsx/input.tsx @@ -0,0 +1,3 @@ +import { nanoid as createNanoid } from 'uniku/nanoid' + +export const value = {createNanoid({ size: 8 })} diff --git a/codemods/v1/tests/tsx/imports-and-options/expected.tsx b/codemods/v1/tests/tsx/imports-and-options/expected.tsx new file mode 100644 index 00000000..484ad75c --- /dev/null +++ b/codemods/v1/tests/tsx/imports-and-options/expected.tsx @@ -0,0 +1,8 @@ +import * as ids from 'uniku/uuid/v7' +import { ksuid } from 'uniku/ksuid' + +export function Preview(): JSX.Element { + const value = ids.uuidv7({ counter: 3 }) + const sortable = ksuid({ msecs: (readSeconds() + 1) * 1000 }) + return {value} +} diff --git a/codemods/v1/tests/tsx/imports-and-options/input.tsx b/codemods/v1/tests/tsx/imports-and-options/input.tsx new file mode 100644 index 00000000..09dc98d4 --- /dev/null +++ b/codemods/v1/tests/tsx/imports-and-options/input.tsx @@ -0,0 +1,8 @@ +import * as ids from 'uniku/uuid/v7' +import { ksuid } from 'uniku/ksuid' + +export function Preview(): JSX.Element { + const value = ids.uuidv7({ seq: 3 }) + const sortable = ksuid({ secs: readSeconds() + 1 }) + return {value} +} diff --git a/codemods/v1/tests/tsx/negative/expected.tsx b/codemods/v1/tests/tsx/negative/expected.tsx new file mode 100644 index 00000000..7b1bbaf1 --- /dev/null +++ b/codemods/v1/tests/tsx/negative/expected.tsx @@ -0,0 +1,5 @@ +import { xid } from 'uniku/xid' + +export function Preview({ xid }: { xid: (value: unknown) => string }): JSX.Element { + return {xid({ secs: 1 })} +} diff --git a/codemods/v1/tests/tsx/negative/input.tsx b/codemods/v1/tests/tsx/negative/input.tsx new file mode 100644 index 00000000..7b1bbaf1 --- /dev/null +++ b/codemods/v1/tests/tsx/negative/input.tsx @@ -0,0 +1,5 @@ +import { xid } from 'uniku/xid' + +export function Preview({ xid }: { xid: (value: unknown) => string }): JSX.Element { + return {xid({ secs: 1 })} +} diff --git a/codemods/v1/tests/tsx/unsupported/expected.tsx b/codemods/v1/tests/tsx/unsupported/expected.tsx new file mode 100644 index 00000000..cbf7008e --- /dev/null +++ b/codemods/v1/tests/tsx/unsupported/expected.tsx @@ -0,0 +1,5 @@ +import { nanoid } from 'uniku/nanoid' + +const options = { size: 8 } + +export const value = {nanoid({ ...options, size: 8 })} diff --git a/codemods/v1/tests/tsx/unsupported/input.tsx b/codemods/v1/tests/tsx/unsupported/input.tsx new file mode 100644 index 00000000..cbf7008e --- /dev/null +++ b/codemods/v1/tests/tsx/unsupported/input.tsx @@ -0,0 +1,5 @@ +import { nanoid } from 'uniku/nanoid' + +const options = { size: 8 } + +export const value = {nanoid({ ...options, size: 8 })} diff --git a/codemods/v1/tests/tsx/unsupported/metrics.json b/codemods/v1/tests/tsx/unsupported/metrics.json new file mode 100644 index 00000000..8d36abbc --- /dev/null +++ b/codemods/v1/tests/tsx/unsupported/metrics.json @@ -0,0 +1,10 @@ +{ + "uniku-v1-manual-migrations": [ + { + "cardinality": { + "ruleId": "uniku-v1/nanoid-length" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1/tests/typescript/already-migrated/expected.ts b/codemods/v1/tests/typescript/already-migrated/expected.ts new file mode 100644 index 00000000..06d043e5 --- /dev/null +++ b/codemods/v1/tests/typescript/already-migrated/expected.ts @@ -0,0 +1,5 @@ +import { cuidv2 } from 'uniku/cuid/v2' +import { typeid } from 'uniku/typeid' +import { objectid } from 'uniku/objectid' + +export const values = [cuidv2(), typeid('user', { counter: 2 }), objectid({ msecs: 2_000 })] diff --git a/codemods/v1/tests/typescript/already-migrated/input.ts b/codemods/v1/tests/typescript/already-migrated/input.ts new file mode 100644 index 00000000..06d043e5 --- /dev/null +++ b/codemods/v1/tests/typescript/already-migrated/input.ts @@ -0,0 +1,5 @@ +import { cuidv2 } from 'uniku/cuid/v2' +import { typeid } from 'uniku/typeid' +import { objectid } from 'uniku/objectid' + +export const values = [cuidv2(), typeid('user', { counter: 2 }), objectid({ msecs: 2_000 })] diff --git a/codemods/v1/tests/typescript/cuid-dynamic/expected.ts b/codemods/v1/tests/typescript/cuid-dynamic/expected.ts new file mode 100644 index 00000000..40d40157 --- /dev/null +++ b/codemods/v1/tests/typescript/cuid-dynamic/expected.ts @@ -0,0 +1,4 @@ +export async function create(): Promise { + const { cuidv2: createCuid } = await import('uniku/cuid/v2') + return createCuid() +} diff --git a/codemods/v1/tests/typescript/cuid-dynamic/input.ts b/codemods/v1/tests/typescript/cuid-dynamic/input.ts new file mode 100644 index 00000000..8909daf8 --- /dev/null +++ b/codemods/v1/tests/typescript/cuid-dynamic/input.ts @@ -0,0 +1,4 @@ +export async function create(): Promise { + const { cuid2: createCuid } = await import('uniku/cuid2') + return createCuid() +} diff --git a/codemods/v1/tests/typescript/cuid-reexport-chain/expected/compat.ts b/codemods/v1/tests/typescript/cuid-reexport-chain/expected/compat.ts new file mode 100644 index 00000000..ba054b20 --- /dev/null +++ b/codemods/v1/tests/typescript/cuid-reexport-chain/expected/compat.ts @@ -0,0 +1 @@ +export { cuidv2 } from 'uniku/cuid/v2' diff --git a/codemods/v1/tests/typescript/cuid-reexport-chain/expected/consumer.ts b/codemods/v1/tests/typescript/cuid-reexport-chain/expected/consumer.ts new file mode 100644 index 00000000..5f00f2f8 --- /dev/null +++ b/codemods/v1/tests/typescript/cuid-reexport-chain/expected/consumer.ts @@ -0,0 +1,3 @@ +import { cuid2 } from './compat.js' + +export const value = cuid2() diff --git a/codemods/v1/tests/typescript/cuid-reexport-chain/input/compat.ts b/codemods/v1/tests/typescript/cuid-reexport-chain/input/compat.ts new file mode 100644 index 00000000..7d28fd74 --- /dev/null +++ b/codemods/v1/tests/typescript/cuid-reexport-chain/input/compat.ts @@ -0,0 +1 @@ +export { cuid2 } from 'uniku/cuid2' diff --git a/codemods/v1/tests/typescript/cuid-reexport-chain/input/consumer.ts b/codemods/v1/tests/typescript/cuid-reexport-chain/input/consumer.ts new file mode 100644 index 00000000..5f00f2f8 --- /dev/null +++ b/codemods/v1/tests/typescript/cuid-reexport-chain/input/consumer.ts @@ -0,0 +1,3 @@ +import { cuid2 } from './compat.js' + +export const value = cuid2() diff --git a/codemods/v1/tests/typescript/cuid-reexport-chain/input/metrics.json b/codemods/v1/tests/typescript/cuid-reexport-chain/input/metrics.json new file mode 100644 index 00000000..26cec563 --- /dev/null +++ b/codemods/v1/tests/typescript/cuid-reexport-chain/input/metrics.json @@ -0,0 +1,10 @@ +{ + "uniku-v1-manual-migrations": [ + { + "cardinality": { + "ruleId": "uniku-v1/cuid-entry-point" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1/tests/typescript/error-comparisons/expected.ts b/codemods/v1/tests/typescript/error-comparisons/expected.ts new file mode 100644 index 00000000..7de128b7 --- /dev/null +++ b/codemods/v1/tests/typescript/error-comparisons/expected.ts @@ -0,0 +1,11 @@ +interface ErrorLike { + code: string + strategy?: string +} + +declare const error: ErrorLike +declare const nested: { current: ErrorLike } + +export const asserted = ((error as ErrorLike).code === 'PREFIX_TOO_LONG' && (error as ErrorLike).strategy === 'typeid') +export const memberChain = (nested.current.code === 'COUNTER_OUT_OF_RANGE' && nested.current.strategy === 'objectid') +export const existing = 'ALPHABET_INVALID_CHAR' === error.code && 'nanoid' === error.strategy diff --git a/codemods/v1/tests/typescript/error-comparisons/input.ts b/codemods/v1/tests/typescript/error-comparisons/input.ts new file mode 100644 index 00000000..40252264 --- /dev/null +++ b/codemods/v1/tests/typescript/error-comparisons/input.ts @@ -0,0 +1,11 @@ +interface ErrorLike { + code: string + strategy?: string +} + +declare const error: ErrorLike +declare const nested: { current: ErrorLike } + +export const asserted = (error as ErrorLike).code === 'TYPEID_PREFIX_TOO_LONG' +export const memberChain = nested.current.code === 'OBJECTID_COUNTER_OUT_OF_RANGE' +export const existing = 'NANOID_ALPHABET_INVALID_CHAR' === error.code && 'nanoid' === error.strategy diff --git a/codemods/v1/tests/typescript/error-unsupported/expected.ts b/codemods/v1/tests/typescript/error-unsupported/expected.ts new file mode 100644 index 00000000..c6c3a348 --- /dev/null +++ b/codemods/v1/tests/typescript/error-unsupported/expected.ts @@ -0,0 +1,5 @@ +declare const error: { code?: string; strategy?: string } + +export const optional = error?.code === 'TYPEID_UUID_NOT_V7' +export const computed = error['code'] === 'TSID_EPOCH_INVALID' +export const negative = error.code !== 'OBJECTID_BYTES_TOO_SHORT' diff --git a/codemods/v1/tests/typescript/error-unsupported/input.ts b/codemods/v1/tests/typescript/error-unsupported/input.ts new file mode 100644 index 00000000..c6c3a348 --- /dev/null +++ b/codemods/v1/tests/typescript/error-unsupported/input.ts @@ -0,0 +1,5 @@ +declare const error: { code?: string; strategy?: string } + +export const optional = error?.code === 'TYPEID_UUID_NOT_V7' +export const computed = error['code'] === 'TSID_EPOCH_INVALID' +export const negative = error.code !== 'OBJECTID_BYTES_TOO_SHORT' diff --git a/codemods/v1/tests/typescript/error-unsupported/metrics.json b/codemods/v1/tests/typescript/error-unsupported/metrics.json new file mode 100644 index 00000000..b261b484 --- /dev/null +++ b/codemods/v1/tests/typescript/error-unsupported/metrics.json @@ -0,0 +1,10 @@ +{ + "uniku-v1-manual-migrations": [ + { + "cardinality": { + "ruleId": "uniku-v1/error-codes" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/codemods/v1/tests/typescript/extensions-cts/expected.cts b/codemods/v1/tests/typescript/extensions-cts/expected.cts new file mode 100644 index 00000000..e0d3173a --- /dev/null +++ b/codemods/v1/tests/typescript/extensions-cts/expected.cts @@ -0,0 +1,4 @@ +export async function create(): Promise { + const { ksuid } = await import('uniku/ksuid') + return ksuid({ msecs: readSeconds() * 1000 }) +} diff --git a/codemods/v1/tests/typescript/extensions-cts/input.cts b/codemods/v1/tests/typescript/extensions-cts/input.cts new file mode 100644 index 00000000..141d554c --- /dev/null +++ b/codemods/v1/tests/typescript/extensions-cts/input.cts @@ -0,0 +1,4 @@ +export async function create(): Promise { + const { ksuid } = await import('uniku/ksuid') + return ksuid({ secs: readSeconds() }) +} diff --git a/codemods/v1/tests/typescript/extensions-mts/expected.mts b/codemods/v1/tests/typescript/extensions-mts/expected.mts new file mode 100644 index 00000000..44639451 --- /dev/null +++ b/codemods/v1/tests/typescript/extensions-mts/expected.mts @@ -0,0 +1,3 @@ +import { xid as createXid } from 'uniku/xid' + +export const id = createXid({ msecs: 2 * 1000 }) diff --git a/codemods/v1/tests/typescript/extensions-mts/input.mts b/codemods/v1/tests/typescript/extensions-mts/input.mts new file mode 100644 index 00000000..42ab975a --- /dev/null +++ b/codemods/v1/tests/typescript/extensions-mts/input.mts @@ -0,0 +1,3 @@ +import { xid as createXid } from 'uniku/xid' + +export const id = createXid({ secs: 2 }) diff --git a/codemods/v1/tests/typescript/extensions-ts/expected.ts b/codemods/v1/tests/typescript/extensions-ts/expected.ts new file mode 100644 index 00000000..a88e6e8f --- /dev/null +++ b/codemods/v1/tests/typescript/extensions-ts/expected.ts @@ -0,0 +1,3 @@ +import { typeid } from 'uniku/typeid' + +export const id = typeid('user', { counter: 2 }) diff --git a/codemods/v1/tests/typescript/extensions-ts/input.ts b/codemods/v1/tests/typescript/extensions-ts/input.ts new file mode 100644 index 00000000..db28e8b0 --- /dev/null +++ b/codemods/v1/tests/typescript/extensions-ts/input.ts @@ -0,0 +1,3 @@ +import { typeid } from 'uniku/typeid' + +export const id = typeid('user', { seq: 2 }) diff --git a/codemods/v1/tests/typescript/imports-and-options/expected.ts b/codemods/v1/tests/typescript/imports-and-options/expected.ts new file mode 100644 index 00000000..721c2a9c --- /dev/null +++ b/codemods/v1/tests/typescript/imports-and-options/expected.ts @@ -0,0 +1,15 @@ +import { objectid, objectid as makeObjectId } from 'uniku/objectid' +import { uuidv7, uuidv7 as makeUuid } from 'uniku/uuid/v7' + +declare const raw: unknown +declare const seq: number + +export const values = [ + objectid({ msecs: (raw as number) * 1000 }), + objectid({ msecs: -1 * 1000 }), + objectid({ msecs: source.current * 1000 }), + objectid({ msecs: (raw as number) * 1000 }), + makeObjectId({ msecs: 3 * 1000 }), + uuidv7({ counter: seq }), + makeUuid({ counter: 4 }), +] diff --git a/codemods/v1/tests/typescript/imports-and-options/input.ts b/codemods/v1/tests/typescript/imports-and-options/input.ts new file mode 100644 index 00000000..63e719da --- /dev/null +++ b/codemods/v1/tests/typescript/imports-and-options/input.ts @@ -0,0 +1,15 @@ +import { objectid, objectid as makeObjectId } from 'uniku/objectid' +import { uuidv7, uuidv7 as makeUuid } from 'uniku/uuid/v7' + +declare const raw: unknown +declare const seq: number + +export const values = [ + objectid({ secs: raw as number }), + objectid({ secs: -1 }), + objectid({ secs: source.current }), + objectid({ secs: (raw as number) }), + makeObjectId({ secs: 3 }), + uuidv7({ seq }), + makeUuid({ seq: 4 }), +] diff --git a/codemods/v1/tests/typescript/negative/expected.ts b/codemods/v1/tests/typescript/negative/expected.ts new file mode 100644 index 00000000..69e89b19 --- /dev/null +++ b/codemods/v1/tests/typescript/negative/expected.ts @@ -0,0 +1,10 @@ +import { nanoid } from 'uniku/nanoid' + +interface LegacyShape { + secs: number + seq: number + size: number +} + +const config: LegacyShape = { secs: 1, seq: 2, size: 3 } +export const values = [nanoid(10), config, 'size'] diff --git a/codemods/v1/tests/typescript/negative/input.ts b/codemods/v1/tests/typescript/negative/input.ts new file mode 100644 index 00000000..69e89b19 --- /dev/null +++ b/codemods/v1/tests/typescript/negative/input.ts @@ -0,0 +1,10 @@ +import { nanoid } from 'uniku/nanoid' + +interface LegacyShape { + secs: number + seq: number + size: number +} + +const config: LegacyShape = { secs: 1, seq: 2, size: 3 } +export const values = [nanoid(10), config, 'size'] diff --git a/codemods/v1/tests/typescript/unsupported/expected.ts b/codemods/v1/tests/typescript/unsupported/expected.ts new file mode 100644 index 00000000..d7ccfd9a --- /dev/null +++ b/codemods/v1/tests/typescript/unsupported/expected.ts @@ -0,0 +1,7 @@ +import { cuid2 } from './compat.js' +import { typeid } from 'uniku/typeid' + +declare const options: { seq: number } +declare const spread: Record + +export const values = [cuid2(), typeid('user', options), typeid('post', { ...spread, seq: 2 })] diff --git a/codemods/v1/tests/typescript/unsupported/input.ts b/codemods/v1/tests/typescript/unsupported/input.ts new file mode 100644 index 00000000..d7ccfd9a --- /dev/null +++ b/codemods/v1/tests/typescript/unsupported/input.ts @@ -0,0 +1,7 @@ +import { cuid2 } from './compat.js' +import { typeid } from 'uniku/typeid' + +declare const options: { seq: number } +declare const spread: Record + +export const values = [cuid2(), typeid('user', options), typeid('post', { ...spread, seq: 2 })] diff --git a/codemods/v1/tests/typescript/unsupported/metrics.json b/codemods/v1/tests/typescript/unsupported/metrics.json new file mode 100644 index 00000000..abc481e3 --- /dev/null +++ b/codemods/v1/tests/typescript/unsupported/metrics.json @@ -0,0 +1,16 @@ +{ + "uniku-v1-manual-migrations": [ + { + "cardinality": { + "ruleId": "uniku-v1/counter-options" + }, + "count": 2 + }, + { + "cardinality": { + "ruleId": "uniku-v1/cuid-entry-point" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/v1/tests/unit/audit.test.ts b/codemods/v1/tests/unit/audit.test.ts new file mode 100644 index 00000000..c8ed399b --- /dev/null +++ b/codemods/v1/tests/unit/audit.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { type AuditCandidate, classifyAuditCandidates, renderAuditFinding } from '../../scripts/audit.js' + +const finding = (startPos: number, line: number, reason = 'Manual migration is required.'): AuditCandidate => ({ + startPos, + endPos: startPos + 1, + ruleId: 'uniku-v1/counter-options', + path: 'src/example.ts', + line, + column: 3, + reason, + guideUrl: 'https://example.test/guide', +}) + +describe('audit findings', () => { + it('sorts by source position and removes exact duplicates', () => { + expect(classifyAuditCandidates([finding(8, 2), finding(1, 1), finding(1, 1)])).toEqual([ + { + ruleId: 'uniku-v1/counter-options', + path: 'src/example.ts', + line: 1, + column: 3, + reason: 'Manual migration is required.', + guideUrl: 'https://example.test/guide', + }, + { + ruleId: 'uniku-v1/counter-options', + path: 'src/example.ts', + line: 2, + column: 3, + reason: 'Manual migration is required.', + guideUrl: 'https://example.test/guide', + }, + ]) + }) + + it('renders one stable machine-readable record', () => { + const [classified] = classifyAuditCandidates([finding(1, 1)]) + + expect(renderAuditFinding(classified!)).toBe( + '{"type":"uniku-v1-audit","manualMigrationRequired":true,"message":"Manual migration required: Manual migration is required.","ruleId":"uniku-v1/counter-options","path":"src/example.ts","line":1,"column":3,"reason":"Manual migration is required.","guideUrl":"https://example.test/guide"}', + ) + }) +}) diff --git a/codemods/v1/tests/unit/edit-plan.test.ts b/codemods/v1/tests/unit/edit-plan.test.ts new file mode 100644 index 00000000..7b784a25 --- /dev/null +++ b/codemods/v1/tests/unit/edit-plan.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { type PlannedEdit, planEdits } from '../../scripts/edit-plan.js' + +const edit = (startPos: number, endPos: number, insertedText: string, atomicGroup: string): PlannedEdit => ({ + startPos, + endPos, + insertedText, + atomicGroup, + ruleId: 'uniku-v1/cuid-entry-point', +}) + +describe('planEdits', () => { + it('sorts and deduplicates identical edits', () => { + expect(planEdits([edit(8, 9, 'b', 'second'), edit(1, 2, 'a', 'first'), edit(1, 2, 'a', 'first')])).toEqual({ + edits: [edit(1, 2, 'a', 'first'), edit(8, 9, 'b', 'second')], + rejectedGroupIds: [], + }) + }) + + it('rejects every atomic group involved in an overlap', () => { + expect( + planEdits([edit(0, 4, 'first', 'group-a'), edit(3, 7, 'second', 'group-b'), edit(10, 11, 'kept', 'group-c')]), + ).toEqual({ + edits: [edit(10, 11, 'kept', 'group-c')], + rejectedGroupIds: ['group-a', 'group-b'], + }) + }) + + it('rejects the other edits in a conflicting atomic group', () => { + expect( + planEdits([edit(0, 4, 'first', 'group-a'), edit(3, 7, 'second', 'group-b'), edit(20, 21, 'also-a', 'group-a')]), + ).toEqual({ + edits: [], + rejectedGroupIds: ['group-a', 'group-b'], + }) + }) +}) diff --git a/codemods/v1/tests/unit/mappings.test.ts b/codemods/v1/tests/unit/mappings.test.ts new file mode 100644 index 00000000..3f806bc3 --- /dev/null +++ b/codemods/v1/tests/unit/mappings.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +import { ERROR_CODES } from '../../../../packages/uniku/src/errors.ts' +import { ID_GENERATORS } from '../../../../packages/uniku/src/generators.ts' +import { ERROR_CODE_MIGRATIONS } from '../../scripts/mappings.js' + +const migrationGuide = readFileSync( + new URL('../../../../apps/docs/content/docs/migration/v1.mdx', import.meta.url), + 'utf8', +) + +const documentedMappings = migrationGuide + .split('\n') + .filter((line) => line.startsWith('| `')) + .flatMap((line) => { + const [sourceCell, targetCell] = line + .split('|') + .slice(1, -1) + .map((cell) => cell.trim()) + const targetCode = targetCell?.match(/^`([^`]+)`$/)?.[1] + if (!sourceCell || !targetCode) throw new Error(`Could not parse migration-table row: ${line}`) + return [...sourceCell.matchAll(/`([^`]+)`/g)].map((match) => ({ sourceCode: match[1]!, targetCode })) + }) + .sort((left, right) => left.sourceCode.localeCompare(right.sourceCode)) + +const strategyForSource = (sourceCode: string): string => { + const prefix = sourceCode.slice(0, sourceCode.indexOf('_')).toLowerCase() + if (prefix === 'uuid') return 'uuid' + if (prefix === 'cuid2') return 'cuid' + return prefix +} + +describe('legacy error-code mappings', () => { + it('matches every documented source and target exactly once', () => { + const actual = ERROR_CODE_MIGRATIONS.map(({ sourceCode, targetCode }) => ({ sourceCode, targetCode })).sort( + (left, right) => left.sourceCode.localeCompare(right.sourceCode), + ) + expect(new Set(ERROR_CODE_MIGRATIONS.map(({ sourceCode }) => sourceCode)).size).toBe(ERROR_CODE_MIGRATIONS.length) + expect(actual).toEqual(documentedMappings) + }) + + it('uses current v1 codes and the generator strategy implied by each documented prefix', () => { + const currentCodes = new Set(ERROR_CODES) + const currentStrategies = new Set(ID_GENERATORS) + + for (const migration of ERROR_CODE_MIGRATIONS) { + expect(currentCodes.has(migration.targetCode), migration.sourceCode).toBe(true) + expect(currentStrategies.has(migration.strategy), migration.sourceCode).toBe(true) + expect(migration.strategy, migration.sourceCode).toBe(strategyForSource(migration.sourceCode)) + } + }) +}) diff --git a/codemods/v1/tsconfig.json b/codemods/v1/tsconfig.json new file mode 100644 index 00000000..88fd5c51 --- /dev/null +++ b/codemods/v1/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.options.json", + "compilerOptions": { + "composite": false, + "allowImportingTsExtensions": true, + "declaration": false, + "declarationMap": false, + "isolatedDeclarations": false, + "noEmit": true, + "types": ["@codemod.com/jssg-types", "vitest/globals"] + }, + "include": ["scripts/**/*.ts", "tests/unit/**/*.ts", "tests/run-integration.test.ts", "vitest.config.ts"] +} diff --git a/codemods/v1/vitest.config.ts b/codemods/v1/vitest.config.ts new file mode 100644 index 00000000..0a0d5e01 --- /dev/null +++ b/codemods/v1/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts', 'tests/run-integration.test.ts'], + }, +}) diff --git a/codemods/v1/workflow.yaml b/codemods/v1/workflow.yaml new file mode 100644 index 00000000..b3507e82 --- /dev/null +++ b/codemods/v1/workflow.yaml @@ -0,0 +1,51 @@ +version: "1" +nodes: + - id: migrate-javascript + name: Migrate JavaScript and JSX consumers + type: automatic + steps: + - name: Transform JavaScript and JSX + js-ast-grep: + js_file: scripts/transform.ts + language: javascript + semantic_analysis: file + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + exclude: &generated-and-vendor + - "**/.git/**" + - "**/.hg/**" + - "**/.svn/**" + - "**/build/**" + - "**/coverage/**" + - "**/dist/**" + - "**/generated/**" + - "**/node_modules/**" + - id: migrate-typescript + name: Migrate TypeScript consumers + type: automatic + steps: + - name: Transform TypeScript + js-ast-grep: + js_file: scripts/transform.ts + language: typescript + semantic_analysis: file + include: + - "**/*.ts" + - "**/*.mts" + - "**/*.cts" + exclude: *generated-and-vendor + - id: migrate-tsx + name: Migrate TSX consumers + type: automatic + steps: + - name: Transform TSX + js-ast-grep: + js_file: scripts/transform.ts + language: tsx + semantic_analysis: file + include: + - "**/*.tsx" + exclude: *generated-and-vendor diff --git a/docs/STABILITY.md b/docs/STABILITY.md index 6c535fdc..2e13cbce 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -29,8 +29,8 @@ generator or metadata module they use. ### CUID2 migration -`uniku/cuid2` is a pre-1.0 compatibility alias. It will be removed in -`uniku@1.0.0`; it is not part of the v1 contract. +The v1 release candidate removes the pre-1.0 `uniku/cuid2` compatibility +alias. It is not part of the v1 contract. ```ts // Before v1 @@ -43,20 +43,21 @@ import { cuidv2 } from 'uniku/cuid/v2' After v1, deprecated APIs remain available for the rest of their current major version and are removed only in the next major version. -### v1.0.0-rc.1 cleanup checklist +### v1 release candidate contract -The following deprecated surfaces remain available through `uniku@0.6.0` and -must be removed in `uniku@1.0.0-rc.1`. Code-level reminders live in -`TODO(v1-rc.1)` comments next to each deprecated surface. +`uniku@0.6.0` was the final release for the deprecated `secs`, `seq`, and +object-form Nanoid `size` aliases. `uniku@1.0.0-rc.1` removes them. The release +candidate exposes only the canonical v1 spellings: -- `uniku/cuid2` entry point (see CUID2 migration above). -- `secs` timestamp options in `uniku/ksuid`, `uniku/objectid`, and `uniku/xid` - (superseded by the unified `msecs` option); the seconds-based `--timestamp` - parsing in `@uniku/cli` is already gone. -- `seq` counter option in `uniku/uuid/v7` (superseded by `counter`, inherited - by `uniku/typeid`). -- `size` length option in `uniku/nanoid` (superseded by `length`); the CLI's - `--size` flag was renamed to `--length` at the same time. +- `uniku/cuid/v2` and its `cuidv2` export are the sole CUID v2 entry point and + generator name. +- `msecs` is the sole timestamp option in `uniku/ksuid`, `uniku/objectid`, and + `uniku/xid`. These formats truncate milliseconds to their stored + second-precision representation. +- `counter` is the sole explicit UUID v7 counter option and is inherited by + `uniku/typeid`. +- Nanoid's object form uses `length`; the positional `nanoid(number)` overload + remains supported. ## Runtime support @@ -94,8 +95,8 @@ existing entry points isolated and tree-shakeable. ### Input boundaries Public numeric inputs must be finite integers within the format's documented -range. This includes timestamps, sequence values, counters, node IDs, lengths, -sizes, and buffer offsets. Invalid values fail with a typed `UniqueIdError`; +range. This includes timestamps, counters, node IDs, lengths, and buffer +offsets. Invalid values fail with a typed `UniqueIdError`; they are never clamped, truncated, or wrapped silently. `fromBytes()` requires the format's exact canonical byte length. Buffer-writing @@ -132,7 +133,7 @@ starts. - XID keeps a random per-runtime identity and always-incrementing counter. - TSID keeps its node ID and advances virtual time if its per-millisecond counter overflows. -- Explicit timestamp, sequence, random, node, or counter options do not mutate +- Explicit timestamp, random, node, or counter options do not mutate the default generator state. Monotonicity is process-local. It does not coordinate independent machines or diff --git a/mise.toml b/mise.toml index 4fc1c21f..d3808793 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,5 @@ [tools] +actionlint = "1.7.12" bun = "1.3.14" deno = "2.9.2" node = "25.9.0" diff --git a/packages/cli/__tests__/commands/inspect.cmd.test.ts b/packages/cli/__tests__/commands/inspect.cmd.test.ts index 08a54b7b..c25fe046 100644 --- a/packages/cli/__tests__/commands/inspect.cmd.test.ts +++ b/packages/cli/__tests__/commands/inspect.cmd.test.ts @@ -89,7 +89,7 @@ describe('CLI: uniku inspect', () => { it.effect('[Given] XID [Then] auto-detects and inspects its timestamp and tail', () => Effect.gen(function* () { yield* MockOutput.reset - const id = xid({ secs: 1_720_000_000, machineId: new Uint8Array(3), processId: 0, counter: 0 }) + const id = xid({ msecs: 1_720_000_000_000, machineId: new Uint8Array(3), processId: 0, counter: 0 }) yield* cli(['inspect', id, '--json']) expect(JSON.parse((yield* MockOutput.getStdout)[0])).toMatchObject({ type: 'xid', diff --git a/packages/cli/__tests__/inspectors/inspect.test.ts b/packages/cli/__tests__/inspectors/inspect.test.ts index 459bed14..2cdde587 100644 --- a/packages/cli/__tests__/inspectors/inspect.test.ts +++ b/packages/cli/__tests__/inspectors/inspect.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from '@effect/vitest' import { assertTrue } from '@effect/vitest/utils' -import { cuid2 } from 'uniku/cuid2' +import { cuidv2 } from 'uniku/cuid/v2' import { ksuid } from 'uniku/ksuid' import { nanoid } from 'uniku/nanoid' import { objectid } from 'uniku/objectid' @@ -78,7 +78,7 @@ describe('inspectId', () => { }) it('inspects XID with second-precision timestamp and its tail bytes', () => { - const id = xid({ secs: 1_720_000_000, machineId: new Uint8Array(3), processId: 0, counter: 0 }) + const id = xid({ msecs: 1_720_000_000_000, machineId: new Uint8Array(3), processId: 0, counter: 0 }) const result = inspectId(id, 'xid') assertTrue(result !== null) expect(result).toMatchObject({ type: 'xid', timestamp_ms: 1_720_000_000_000, random: '0000000000000000' }) @@ -100,7 +100,7 @@ describe('inspectId', () => { }) it('inspects CUID with no-metadata note', () => { - const id = cuid2() + const id = cuidv2() const result = inspectId(id, 'cuid') assertTrue(result !== null) expect(result.type).toBe('cuid') diff --git a/packages/cli/__tests__/validators/validate.test.ts b/packages/cli/__tests__/validators/validate.test.ts index a7ec07dc..55c4e8e6 100644 --- a/packages/cli/__tests__/validators/validate.test.ts +++ b/packages/cli/__tests__/validators/validate.test.ts @@ -1,4 +1,4 @@ -import { cuid2 } from 'uniku/cuid2' +import { cuidv2 } from 'uniku/cuid/v2' import { ksuid } from 'uniku/ksuid' import { nanoid } from 'uniku/nanoid' import { objectid } from 'uniku/objectid' @@ -105,7 +105,7 @@ describe('validateAs', () => { }) it('validates a valid CUID', () => { - const id = cuid2() + const id = cuidv2() const result = validateAs(id, 'cuid') expect(result.valid).toBe(true) expect(result.type).toBe('cuid') @@ -200,7 +200,7 @@ describe('validateAutoDetect', () => { it('auto-detects an ObjectID starting with a letter (a-f) as objectid, not cuid (KTD6/R9)', () => { // CUID2's default validation regex (/^[a-z][0-9a-z]+$/, length 24) would also - // accept this string if objectid were checked after cuid2 - see validateAutoDetect's + // accept this string if objectid were checked after CUID v2 - see validateAutoDetect's // ordering comment. This is a fixed known value, not a generated one, to guarantee // the first character is in a-f regardless of test run. const id = 'aabbccddeeff001122334455' @@ -210,12 +210,12 @@ describe('validateAutoDetect', () => { }) it('auto-detects XID before CUID and Nanoid', () => { - const id = xid({ secs: 1, machineId: new Uint8Array(3), processId: 0, counter: 0 }) + const id = xid({ msecs: 1000, machineId: new Uint8Array(3), processId: 0, counter: 0 }) expect(validateAutoDetect(id)).toMatchObject({ valid: true, type: 'xid' }) }) it('auto-detects CUID', () => { - const id = cuid2() + const id = cuidv2() const result = validateAutoDetect(id) expect(result.valid).toBe(true) expect(result.type).toBe('cuid') diff --git a/packages/uniku/CHANGELOG.md b/packages/uniku/CHANGELOG.md index 3dd68dce..b72bf213 100644 --- a/packages/uniku/CHANGELOG.md +++ b/packages/uniku/CHANGELOG.md @@ -1,5 +1,17 @@ # uniku +## 1.0.0-rc.1 + +### Patch Changes + +- Remove the deprecated `secs`, `seq`, and Nanoid object-form `size` aliases after their final migration release in `0.6.0`. Use `msecs`, `counter`, and `length` instead. `CONFLICTING_OPTIONS` is also removed from the v1 error-code catalog because no supported option pair conflicts after these aliases are gone. + +## 1.0.0-rc.0 + +### Major Changes + +- Finalize the v1 public API: use only `uniku/cuid/v2` with `cuidv2`, replace second-based `secs` options with millisecond-based `msecs`, replace UUID v7 and TypeID `seq` with `counter`, and use `length` for Nanoid's object-form option. Existing identifier data remains valid; see the v1 migration guide for mechanical before-and-after examples. + ## 0.6.0 ### Minor Changes diff --git a/packages/uniku/__tests__/bench/compat.bench.ts b/packages/uniku/__tests__/bench/compat.bench.ts index 69411302..f6635d93 100644 --- a/packages/uniku/__tests__/bench/compat.bench.ts +++ b/packages/uniku/__tests__/bench/compat.bench.ts @@ -8,7 +8,7 @@ import { typeid as npmTypeid, fromString as npmTypeidFromString } from 'typeid-j import { ulid as npmUlid } from 'ulid' import { v4 as npmUuidV4, v7 as npmUuidV7, validate as uuidValidate, version as uuidVersion } from 'uuid' import { describe, bench as registerBench } from 'vitest' -import { cuid2 } from '@/src/cuid2/cuid2' +import { cuidv2 } from '@/src/cuid/v2' import { ksuid } from '@/src/ksuid/ksuid' import { nanoid } from '@/src/nanoid/nanoid' import { objectid } from '@/src/objectid/objectid' @@ -53,7 +53,7 @@ const testIds = { unikuUlid: ulid(), unikuTypeid: typeid('user'), unikuNanoid: nanoid(), - unikuCuid2: cuid2(), + unikuCuid2: cuidv2(), unikuKsuid: ksuid(), unikuObjectid: objectid(), unikuTsid: tsid.toString(tsid()), @@ -177,7 +177,7 @@ describe('Generation', () => { bench( 'uniku', () => { - cuid2() + cuidv2() }, benchOptions, ) @@ -354,7 +354,7 @@ describe('Validation', () => { bench( 'uniku', () => { - cuid2.isValid(testIds.npmCuid2) + cuidv2.isValid(testIds.npmCuid2) }, benchOptions, ) diff --git a/packages/uniku/__tests__/bench/id.bench.ts b/packages/uniku/__tests__/bench/id.bench.ts index 4e2ccb3f..436d509d 100644 --- a/packages/uniku/__tests__/bench/id.bench.ts +++ b/packages/uniku/__tests__/bench/id.bench.ts @@ -1,5 +1,5 @@ import { bench, describe } from 'vitest' -import { cuid2 } from '@/src/cuid2/cuid2' +import { cuidv2 } from '@/src/cuid/v2' import { ksuid } from '@/src/ksuid/ksuid' import { nanoid } from '@/src/nanoid/nanoid' import { objectid } from '@/src/objectid/objectid' @@ -52,9 +52,9 @@ describe('ID Generation', () => { ) bench( - 'cuid2', + 'cuidv2', () => { - cuid2() + cuidv2() }, benchOptions, ) @@ -78,7 +78,7 @@ describe('ID Generation', () => { bench( 'nanoid({ alphabet: hex })', () => { - nanoid({ alphabet: '0123456789abcdef', size: 12 }) + nanoid({ alphabet: '0123456789abcdef', length: 12 }) }, benchOptions, ) @@ -277,7 +277,7 @@ describe('ID isValid', () => { const v7String = uuidv7() const ulidString = ulid() const typeidString = typeid('user') - const cuid2String = cuid2() + const cuidv2String = cuidv2() const ksuidString = ksuid() const objectidString = objectid() const tsidId = tsid() @@ -316,9 +316,9 @@ describe('ID isValid', () => { ) bench( - 'cuid2.isValid', + 'cuidv2.isValid', () => { - cuid2.isValid(cuid2String) + cuidv2.isValid(cuidv2String) }, benchOptions, ) diff --git a/packages/uniku/__tests__/e2e/runtimes/cloudflare/src/worker.ts b/packages/uniku/__tests__/e2e/runtimes/cloudflare/src/worker.ts index 43457a96..a75ce59d 100644 --- a/packages/uniku/__tests__/e2e/runtimes/cloudflare/src/worker.ts +++ b/packages/uniku/__tests__/e2e/runtimes/cloudflare/src/worker.ts @@ -4,7 +4,7 @@ * and returns results as JSON for test assertions. */ import { Hono } from 'hono' -import { cuid2 } from 'uniku/cuid2' +import { cuidv2 } from 'uniku/cuid/v2' import { ksuid } from 'uniku/ksuid' import { nanoid, URL_ALPHABET } from 'uniku/nanoid' import { objectid } from 'uniku/objectid' @@ -735,7 +735,7 @@ app.get('/tsid/monotonic', (c) => { app.get('/cuid2/generate', (c) => { try { - const id = cuid2() + const id = cuidv2() return c.json({ success: true, id, length: id.length }) } catch (error) { return c.json({ success: false, error: String(error) }, 500) @@ -745,7 +745,7 @@ app.get('/cuid2/generate', (c) => { app.get('/cuid2/generate-batch', (c) => { try { const count = Number(c.req.query('count') || '1000') - const ids = Array.from({ length: count }, () => cuid2()) + const ids = Array.from({ length: count }, () => cuidv2()) return c.json({ success: true, ids, count: ids.length }) } catch (error) { return c.json({ success: false, error: String(error) }, 500) @@ -755,7 +755,7 @@ app.get('/cuid2/generate-batch', (c) => { app.get('/cuid2/generate-custom-length', (c) => { try { const length = Number(c.req.query('length') || '24') - const id = cuid2({ length }) + const id = cuidv2({ length }) return c.json({ success: true, id, @@ -770,12 +770,12 @@ app.get('/cuid2/generate-custom-length', (c) => { app.get('/cuid2/validate', (c) => { try { - const validId = cuid2() + const validId = cuidv2() return c.json({ success: true, validId, - isValidGenerated: cuid2.isValid(validId), - isValidInvalid: cuid2.isValid('123invalid'), + isValidGenerated: cuidv2.isValid(validId), + isValidInvalid: cuidv2.isValid('123invalid'), }) } catch (error) { return c.json({ success: false, error: String(error) }, 500) @@ -825,7 +825,7 @@ app.get('/nanoid/generate-custom-alphabet', (c) => { try { const alphabet = c.req.query('alphabet') || '0123456789abcdef' const size = Number(c.req.query('size') || '12') - const id = nanoid({ alphabet, size }) + const id = nanoid({ alphabet, length: size }) const allCharsValid = [...id].every((char) => alphabet.includes(char)) return c.json({ success: true, diff --git a/packages/uniku/__tests__/integration/compat.test.ts b/packages/uniku/__tests__/integration/compat.test.ts index cc7d3fca..4eac769c 100644 --- a/packages/uniku/__tests__/integration/compat.test.ts +++ b/packages/uniku/__tests__/integration/compat.test.ts @@ -8,7 +8,7 @@ import { ulid as npmUlid } from 'ulid' import { v4 as npmUuidV4, v7 as npmUuidV7, validate as uuidValidate, version as uuidVersion } from 'uuid' import { beforeAll } from 'vitest' import { Xid as npmXid } from 'xid-ts' -import { cuid2 } from '@/src/cuid2/cuid2' +import { cuidv2 } from '@/src/cuid/v2' import { ksuid } from '@/src/ksuid/ksuid' import { nanoid } from '@/src/nanoid/nanoid' import { objectid } from '@/src/objectid/objectid' @@ -95,14 +95,14 @@ describe('Cross-Validation: NanoID', () => { describe('Cross-Validation: CUID2', () => { it('uniku IDs pass npm validation', () => { - const ids = Array.from({ length: BATCH_SIZE }, () => cuid2()) + const ids = Array.from({ length: BATCH_SIZE }, () => cuidv2()) const invalid = ids.filter((id) => !npmIsCuid(id)) expect(invalid).toHaveLength(0) }) it('npm IDs pass uniku validation', () => { const ids = Array.from({ length: BATCH_SIZE }, () => npmCuid2()) - const invalid = ids.filter((id) => !cuid2.isValid(id)) + const invalid = ids.filter((id) => !cuidv2.isValid(id)) expect(invalid).toHaveLength(0) }) }) diff --git a/packages/uniku/__tests__/types/v1-removals.type-test.ts b/packages/uniku/__tests__/types/v1-removals.type-test.ts new file mode 100644 index 00000000..54f7114f --- /dev/null +++ b/packages/uniku/__tests__/types/v1-removals.type-test.ts @@ -0,0 +1,65 @@ +import { type CuidV2, type CuidV2Options, cuidv2 } from 'uniku/cuid/v2' +import { type KsuidOptions, ksuid } from 'uniku/ksuid' +import { type NanoidOptions, nanoid } from 'uniku/nanoid' +import { type ObjectIdOptions, objectid } from 'uniku/objectid' +import { type TypeidOptions, typeid } from 'uniku/typeid' +import { type UuidV7Options, uuidv7 } from 'uniku/uuid/v7' +import { type XidOptions, xid } from 'uniku/xid' + +type AssertTrue = T +type DoesNotHaveKey = Key extends keyof T ? false : true + +type KsuidHasNoSecs = AssertTrue> +type ObjectIdHasNoSecs = AssertTrue> +type XidHasNoSecs = AssertTrue> +type UuidV7HasNoSeq = AssertTrue> +type TypeidHasNoSeq = AssertTrue> +type NanoidHasNoSize = AssertTrue> +type CuidV2HasNoCuid2Value = AssertTrue> + +const cuidGenerator: CuidV2 = cuidv2 +const cuidOptions: CuidV2Options = { length: 24 } +void cuidGenerator(cuidOptions) + +ksuid({ msecs: 1_500_000_000_000 }) +objectid({ msecs: 1_700_000_000_000 }) +xid({ msecs: 1_700_000_000_000 }) +uuidv7({ counter: 1 }) +typeid('user', { counter: 1 }) +nanoid({ length: 10 }) +nanoid(10) + +// @ts-expect-error v1 removes the legacy CUID2 package subpath. +import 'uniku/cuid2' + +// @ts-expect-error v1 accepts only millisecond timestamp options. +ksuid({ secs: 1_500_000_000 }) +// @ts-expect-error v1 accepts only millisecond timestamp options. +objectid({ secs: 1_700_000_000 }) +// @ts-expect-error v1 accepts only millisecond timestamp options. +xid({ secs: 1_700_000_000 }) +// @ts-expect-error v1 names the UUID v7 sequence input counter. +uuidv7({ seq: 1 }) +// @ts-expect-error TypeID inherits the UUID v7 counter option. +typeid('user', { seq: 1 }) +// @ts-expect-error Nanoid object options use length; size remains positional only. +nanoid({ size: 10 }) + +export type V1RemovalContracts = [ + KsuidHasNoSecs, + ObjectIdHasNoSecs, + XidHasNoSecs, + UuidV7HasNoSeq, + TypeidHasNoSeq, + NanoidHasNoSize, + CuidV2HasNoCuid2Value, +] + +export type RemovedCuidNameContracts = [ + // @ts-expect-error the canonical module does not export the legacy cuid2 value. + typeof import('uniku/cuid/v2')['cuid2'], + // @ts-expect-error the canonical module does not export the legacy Cuid2 type. + import('uniku/cuid/v2').Cuid2, + // @ts-expect-error the canonical module does not export the legacy Cuid2Options type. + import('uniku/cuid/v2').Cuid2Options, +] diff --git a/packages/uniku/__tests__/unit/collision-contract.test.ts b/packages/uniku/__tests__/unit/collision-contract.test.ts index edaf1c22..e943281f 100644 --- a/packages/uniku/__tests__/unit/collision-contract.test.ts +++ b/packages/uniku/__tests__/unit/collision-contract.test.ts @@ -1,5 +1,5 @@ import { afterEach } from 'vitest' -import { cuid2 } from '@/src/cuid2/cuid2' +import { cuidv2 } from '@/src/cuid/v2' import { ksuid } from '@/src/ksuid/ksuid' import { nanoid } from '@/src/nanoid/nanoid' import { objectid } from '@/src/objectid/objectid' @@ -39,7 +39,7 @@ describe('small-timeframe collision contract', () => { () => uuidv7(), () => ulid(), () => typeid('benchmark'), - () => cuid2(), + () => cuidv2(), () => nanoid(), () => ksuid(), () => objectid(), diff --git a/packages/uniku/__tests__/unit/cuid-v2.test.ts b/packages/uniku/__tests__/unit/cuid-v2.test.ts index a9e4ab72..285189c7 100644 --- a/packages/uniku/__tests__/unit/cuid-v2.test.ts +++ b/packages/uniku/__tests__/unit/cuid-v2.test.ts @@ -1,20 +1,108 @@ import { cuidv2 } from '@/src/cuid/v2' -import { cuid2 } from '@/src/cuid2/cuid2' +import { InvalidInputError } from '@/src/errors' +import { expectValidTypeGuard } from '../helpers/assertions' +import { expectDistinctRandomSamples } from '../helpers/randomness' -describe('cuid/v2', () => { - it('re-exports the exact same implementation as uniku/cuid2', () => { - // `uniku/cuid/v2` is a versioned alias, not a second implementation. - expect(cuidv2).toBe(cuid2) +describe('cuidv2', () => { + it('generates a valid CUID2 string', () => { + const id = cuidv2() + expect(id).toMatch(/^[a-z][0-9a-z]+$/) + expect(id.length).toBe(24) }) - it('generates a valid CUID2 via the cuidv2 alias, cross-checked against cuid2.isValid', () => { + it('generates lowercase output', () => { const id = cuidv2() - expect(cuidv2.isValid(id)).toBe(true) - expect(cuid2.isValid(id)).toBe(true) + expect(id).toBe(id.toLowerCase()) + }) + + it('always starts with a letter', () => { + for (let i = 0; i < 1000; i += 1) { + const id = cuidv2() + expect(id[0]).toMatch(/[a-z]/) + } + }) + + it('generates unique ids in small sample', () => { + expectDistinctRandomSamples({ + count: 10_000, + maxDuplicateCount: 0, + generate: cuidv2, + }) }) - it('honors the same options as cuid2 (e.g. custom length)', () => { + it('handles high-throughput generation (counter stress test)', { timeout: 30000 }, () => { + expectDistinctRandomSamples({ + count: 50_000, + maxDuplicateCount: 0, + generate: cuidv2, + }) + }) + + it('supports custom length option', () => { expect(cuidv2({ length: 10 }).length).toBe(10) expect(cuidv2({ length: 32 }).length).toBe(32) + expect(cuidv2({ length: 2 }).length).toBe(2) + }) + + it('throws on invalid length', () => { + expect(() => cuidv2({ length: 1 })).toThrow(InvalidInputError) + expect(() => cuidv2({ length: 33 })).toThrow(InvalidInputError) + expect(() => cuidv2({ length: 0 })).toThrow(InvalidInputError) + expect(() => cuidv2({ length: -1 })).toThrow(InvalidInputError) + }) + + it('supports custom random option for determinism', () => { + const random = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) + const id = cuidv2({ random }) + expect(cuidv2.isValid(id)).toBe(true) + }) + + it('throws on empty random array', () => { + expect(() => cuidv2({ random: new Uint8Array(0) })).toThrow('Random byte array cannot be empty') + }) + + describe('isValid', () => { + it('returns true for valid CUID2s', () => { + expect(cuidv2.isValid(cuidv2())).toBe(true) + expect(cuidv2.isValid('pfh0haxfpzowht3oi213cqos')).toBe(true) + expect(cuidv2.isValid('ab')).toBe(true) // min length + expect(cuidv2.isValid('a'.padEnd(32, 'b'))).toBe(true) // max length + }) + + it('returns true for custom length CUID2s', () => { + const shortId = cuidv2({ length: 10 }) + expect(cuidv2.isValid(shortId)).toBe(true) + }) + + it('returns false for wrong first character', () => { + expect(cuidv2.isValid('1lq8z9za5000x8zvs7oqe5ump')).toBe(false) + expect(cuidv2.isValid('0abc')).toBe(false) + }) + + it('returns false for invalid characters', () => { + expect(cuidv2.isValid('CLQ8Z9ZA5000X8ZVS7OQE5UMP')).toBe(false) // uppercase + expect(cuidv2.isValid('clq8z9za-5000-x8zv-s7oq-e5ump')).toBe(false) // hyphens + expect(cuidv2.isValid('clq8z9za_5000_x8zv')).toBe(false) // underscores + }) + + it('returns false for wrong length', () => { + expect(cuidv2.isValid('a')).toBe(false) // too short (< 2) + expect(cuidv2.isValid('')).toBe(false) // empty + expect(cuidv2.isValid('a'.padEnd(33, 'b'))).toBe(false) // too long (> 32) + }) + + it('returns false for non-strings', () => { + expect(cuidv2.isValid(null)).toBe(false) + expect(cuidv2.isValid(undefined)).toBe(false) + expect(cuidv2.isValid(123)).toBe(false) + expect(cuidv2.isValid({})).toBe(false) + expect(cuidv2.isValid([])).toBe(false) + }) + + it('acts as type guard for unknown values', () => { + const maybeId: unknown = cuidv2() + expectValidTypeGuard(maybeId, cuidv2.isValid) + expect(maybeId.length).toBeGreaterThan(0) + }) }) }) diff --git a/packages/uniku/__tests__/unit/cuid2.test.ts b/packages/uniku/__tests__/unit/cuid2.test.ts deleted file mode 100644 index de51ea4f..00000000 --- a/packages/uniku/__tests__/unit/cuid2.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { cuid2, InvalidInputError } from '@/src/cuid2/cuid2' -import { expectValidTypeGuard } from '../helpers/assertions' -import { expectDistinctRandomSamples } from '../helpers/randomness' - -describe('cuid2', () => { - it('generates a valid CUID2 string', () => { - const id = cuid2() - expect(id).toMatch(/^[a-z][0-9a-z]+$/) - expect(id.length).toBe(24) - }) - - it('generates lowercase output', () => { - const id = cuid2() - expect(id).toBe(id.toLowerCase()) - }) - - it('always starts with a letter', () => { - for (let i = 0; i < 1000; i += 1) { - const id = cuid2() - expect(id[0]).toMatch(/[a-z]/) - } - }) - - it('generates unique ids in small sample', () => { - expectDistinctRandomSamples({ - count: 10_000, - maxDuplicateCount: 0, - generate: cuid2, - }) - }) - - it('handles high-throughput generation (counter stress test)', { timeout: 30000 }, () => { - expectDistinctRandomSamples({ - count: 50_000, - maxDuplicateCount: 0, - generate: cuid2, - }) - }) - - it('supports custom length option', () => { - expect(cuid2({ length: 10 }).length).toBe(10) - expect(cuid2({ length: 32 }).length).toBe(32) - expect(cuid2({ length: 2 }).length).toBe(2) - }) - - it('throws on invalid length', () => { - expect(() => cuid2({ length: 1 })).toThrow(InvalidInputError) - expect(() => cuid2({ length: 33 })).toThrow(InvalidInputError) - expect(() => cuid2({ length: 0 })).toThrow(InvalidInputError) - expect(() => cuid2({ length: -1 })).toThrow(InvalidInputError) - }) - - it('supports custom random option for determinism', () => { - const random = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) - const id = cuid2({ random }) - expect(cuid2.isValid(id)).toBe(true) - }) - - it('throws on empty random array', () => { - expect(() => cuid2({ random: new Uint8Array(0) })).toThrow('Random byte array cannot be empty') - }) - - describe('isValid', () => { - it('returns true for valid CUID2s', () => { - expect(cuid2.isValid(cuid2())).toBe(true) - expect(cuid2.isValid('pfh0haxfpzowht3oi213cqos')).toBe(true) - expect(cuid2.isValid('ab')).toBe(true) // min length - expect(cuid2.isValid('a'.padEnd(32, 'b'))).toBe(true) // max length - }) - - it('returns true for custom length CUID2s', () => { - const shortId = cuid2({ length: 10 }) - expect(cuid2.isValid(shortId)).toBe(true) - }) - - it('returns false for wrong first character', () => { - expect(cuid2.isValid('1lq8z9za5000x8zvs7oqe5ump')).toBe(false) - expect(cuid2.isValid('0abc')).toBe(false) - }) - - it('returns false for invalid characters', () => { - expect(cuid2.isValid('CLQ8Z9ZA5000X8ZVS7OQE5UMP')).toBe(false) // uppercase - expect(cuid2.isValid('clq8z9za-5000-x8zv-s7oq-e5ump')).toBe(false) // hyphens - expect(cuid2.isValid('clq8z9za_5000_x8zv')).toBe(false) // underscores - }) - - it('returns false for wrong length', () => { - expect(cuid2.isValid('a')).toBe(false) // too short (< 2) - expect(cuid2.isValid('')).toBe(false) // empty - expect(cuid2.isValid('a'.padEnd(33, 'b'))).toBe(false) // too long (> 32) - }) - - it('returns false for non-strings', () => { - expect(cuid2.isValid(null)).toBe(false) - expect(cuid2.isValid(undefined)).toBe(false) - expect(cuid2.isValid(123)).toBe(false) - expect(cuid2.isValid({})).toBe(false) - expect(cuid2.isValid([])).toBe(false) - }) - - it('acts as type guard for unknown values', () => { - const maybeId: unknown = cuid2() - expectValidTypeGuard(maybeId, cuid2.isValid) - expect(maybeId.length).toBeGreaterThan(0) - }) - }) -}) diff --git a/packages/uniku/__tests__/unit/errors.test.ts b/packages/uniku/__tests__/unit/errors.test.ts index 7d6dcf0a..e77d2e80 100644 --- a/packages/uniku/__tests__/unit/errors.test.ts +++ b/packages/uniku/__tests__/unit/errors.test.ts @@ -24,11 +24,6 @@ const EXPECTED_ERROR_METADATA = [ classes: ['InvalidInputError', 'ParseError'], strategies: ['uuid', 'ulid', 'typeid', 'ksuid', 'objectid', 'tsid', 'xid'], }, - { - code: 'CONFLICTING_OPTIONS', - classes: ['InvalidInputError'], - strategies: ['uuid', 'typeid', 'nanoid', 'ksuid', 'objectid', 'xid'], - }, { code: 'COUNTER_OUT_OF_RANGE', classes: ['InvalidInputError'], diff --git a/packages/uniku/__tests__/unit/ksuid.test.ts b/packages/uniku/__tests__/unit/ksuid.test.ts index e70df7a9..585b09e7 100644 --- a/packages/uniku/__tests__/unit/ksuid.test.ts +++ b/packages/uniku/__tests__/unit/ksuid.test.ts @@ -1,4 +1,4 @@ -import { BufferError, InvalidInputError, ksuid } from '@/src/ksuid/ksuid' +import { BufferError, ksuid } from '@/src/ksuid/ksuid' import { expectValidTypeGuard } from '../helpers/assertions' import { expectDistinctRandomSamples } from '../helpers/randomness' @@ -257,32 +257,6 @@ describe('ksuid', () => { }) }) - describe('deprecated secs alias', () => { - // TODO(v1-rc.1): remove this block together with the `secs` option. - it('accepts whole seconds until 1.0.0-rc.1', () => { - const id = ksuid({ secs: 1_500_000_000, random: new Uint8Array(16) }) - expect(ksuid.timestamp(id)).toBe(1_500_000_000_000) - }) - - it('validates the seconds range', () => { - expect(() => ksuid({ secs: 0 })).toThrow( - `Timestamp must be an integer between ${KSUID_EPOCH} and ${KSUID_MAX_SECS} seconds`, - ) - }) - - it('rejects passing both msecs and secs', () => { - let error: unknown - try { - ksuid({ msecs: 1_500_000_000_000, secs: 1_500_000_000 }) - } catch (caught) { - error = caught - } - - expect(error).toBeInstanceOf(InvalidInputError) - expect(error).toMatchObject({ code: 'CONFLICTING_OPTIONS', strategy: 'ksuid' }) - }) - }) - describe('Base62 encoding edge cases', () => { it('encodes minimum KSUID (all zeros)', () => { const bytes = new Uint8Array(20).fill(0) diff --git a/packages/uniku/__tests__/unit/nanoid.test.ts b/packages/uniku/__tests__/unit/nanoid.test.ts index ac733262..151f7d34 100644 --- a/packages/uniku/__tests__/unit/nanoid.test.ts +++ b/packages/uniku/__tests__/unit/nanoid.test.ts @@ -45,31 +45,6 @@ describe('nanoid', () => { expect(nanoid({ length: 10 })).toHaveLength(10) }) - describe('deprecated size alias', () => { - // TODO(v1-rc.1): remove this block together with the `size` option. - it('produces the same output as length until 1.0.0-rc.1', () => { - const random = new Uint8Array(32).fill(7) - expect(nanoid({ size: 10, random })).toBe(nanoid({ length: 10, random })) - }) - - it('validates the size range', () => { - expect(() => nanoid({ size: -1 })).toThrow(InvalidInputError) - expect(() => nanoid({ size: 3000 })).toThrow(InvalidInputError) - }) - - it('rejects passing both length and size', () => { - let error: unknown - try { - nanoid({ length: 10, size: 10 }) - } catch (caught) { - error = caught - } - - expect(error).toBeInstanceOf(InvalidInputError) - expect(error).toMatchObject({ code: 'CONFLICTING_OPTIONS', strategy: 'nanoid' }) - }) - }) - it('generates with custom alphabet', () => { const id = nanoid({ alphabet: '0123456789abcdef', length: 12 }) expect(id).toHaveLength(12) diff --git a/packages/uniku/__tests__/unit/objectid.test.ts b/packages/uniku/__tests__/unit/objectid.test.ts index 759de864..52ba836e 100644 --- a/packages/uniku/__tests__/unit/objectid.test.ts +++ b/packages/uniku/__tests__/unit/objectid.test.ts @@ -103,31 +103,6 @@ describe('objectid', () => { }) }) - describe('deprecated secs alias', () => { - // TODO(v1-rc.1): remove this block together with the `secs` option. - it('accepts whole seconds until 1.0.0-rc.1', () => { - const id = objectid({ secs: 1_700_000_000, random: new Uint8Array(5), counter: 0 }) - expect(objectid.timestamp(id)).toBe(1_700_000_000_000) - }) - - it('validates the seconds range', () => { - expect(() => objectid({ secs: -1 })).toThrow(InvalidInputError) - expect(() => objectid({ secs: 0x100000000 })).toThrow(InvalidInputError) - }) - - it('rejects passing both msecs and secs', () => { - let error: unknown - try { - objectid({ msecs: 1_700_000_000_000, secs: 1_700_000_000 }) - } catch (caught) { - error = caught - } - - expect(error).toBeInstanceOf(InvalidInputError) - expect(error).toMatchObject({ code: 'CONFLICTING_OPTIONS', strategy: 'objectid' }) - }) - }) - describe('round-trips', () => { it('round-trips through byte helpers for a hot-path generated id', () => { const id = objectid() diff --git a/packages/uniku/__tests__/unit/typeid.test.ts b/packages/uniku/__tests__/unit/typeid.test.ts index d70a33f1..bde62204 100644 --- a/packages/uniku/__tests__/unit/typeid.test.ts +++ b/packages/uniku/__tests__/unit/typeid.test.ts @@ -142,27 +142,6 @@ describe('typeid', () => { }) }) - describe('deprecated seq alias', () => { - // TODO(v1-rc.1): remove this block together with the `seq` option. - it('produces the same output as counter until 1.0.0-rc.1', () => { - const bySeq = typeid('user', { msecs: FIXED_MSECS, seq: 0x12345678, random: ZERO_RANDOM }) - const byCounter = typeid('user', { msecs: FIXED_MSECS, counter: 0x12345678, random: ZERO_RANDOM }) - expect(bySeq).toBe(byCounter) - }) - - it('rejects passing both counter and seq', () => { - let error: unknown - try { - typeid('user', { counter: 1, seq: 1 }) - } catch (caught) { - error = caught - } - - expect(error).toBeInstanceOf(InvalidInputError) - expect(error).toMatchObject({ code: 'CONFLICTING_OPTIONS', strategy: 'typeid' }) - }) - }) - describe('prefix validation', () => { it('accepts lowercase snake_case prefixes', () => { expect(typeid.isValid(typeid('api_key'))).toBe(true) diff --git a/packages/uniku/__tests__/unit/uuid.v7.test.ts b/packages/uniku/__tests__/unit/uuid.v7.test.ts index 15f14324..e4569741 100644 --- a/packages/uniku/__tests__/unit/uuid.v7.test.ts +++ b/packages/uniku/__tests__/unit/uuid.v7.test.ts @@ -1,5 +1,5 @@ import { afterEach } from 'vitest' -import { InvalidInputError, uuidv7 } from '@/src/uuid/v7' +import { uuidv7 } from '@/src/uuid/v7' import { expectValidTypeGuard } from '../helpers/assertions' async function importFreshUuidV7Module() { @@ -151,39 +151,6 @@ describe('uuidv7', () => { } }) - describe('deprecated seq alias', () => { - // TODO(v1-rc.1): remove this block together with the `seq` option. - it('produces the same output as counter until 1.0.0-rc.1', () => { - const msecs = 1_702_387_456_789 - const random = new Uint8Array(16).fill(42) - expect(uuidv7({ msecs, seq: 0x12345678, random })).toBe(uuidv7({ msecs, counter: 0x12345678, random })) - }) - - it('validates the seq range', () => { - let error: unknown - try { - uuidv7({ seq: -1 }) - } catch (caught) { - error = caught - } - - expect(error).toBeInstanceOf(InvalidInputError) - expect(error).toMatchObject({ code: 'COUNTER_OUT_OF_RANGE', strategy: 'uuid' }) - }) - - it('rejects passing both counter and seq', () => { - let error: unknown - try { - uuidv7({ counter: 1, seq: 1 }) - } catch (caught) { - error = caught - } - - expect(error).toBeInstanceOf(InvalidInputError) - expect(error).toMatchObject({ code: 'CONFLICTING_OPTIONS', strategy: 'uuid' }) - }) - }) - describe('isValid', () => { it('returns true for valid UUID v7 strings', () => { expect(uuidv7.isValid(uuidv7())).toBe(true) diff --git a/packages/uniku/__tests__/unit/v1-boundaries.test.ts b/packages/uniku/__tests__/unit/v1-boundaries.test.ts index 08c145d9..7a5b8d85 100644 --- a/packages/uniku/__tests__/unit/v1-boundaries.test.ts +++ b/packages/uniku/__tests__/unit/v1-boundaries.test.ts @@ -18,27 +18,27 @@ describe('v1 public boundary contract', () => { const cases: ReadonlyArray<{ readonly name: string; readonly generate: () => unknown }> = [ { name: 'UUID v7 rejects negative timestamps', - generate: () => uuidv7({ msecs: -1, random: zeroes(16), seq: 0 }), + generate: () => uuidv7({ msecs: -1, random: zeroes(16), counter: 0 }), }, { name: 'UUID v7 rejects timestamps above 48 bits', - generate: () => uuidv7({ msecs: 2 ** 48, random: zeroes(16), seq: 0 }), + generate: () => uuidv7({ msecs: 2 ** 48, random: zeroes(16), counter: 0 }), }, { - name: 'UUID v7 rejects NaN sequences', - generate: () => uuidv7({ msecs: 0, random: zeroes(16), seq: Number.NaN }), + name: 'UUID v7 rejects NaN counters', + generate: () => uuidv7({ msecs: 0, random: zeroes(16), counter: Number.NaN }), }, { - name: 'UUID v7 rejects fractional sequences', - generate: () => uuidv7({ msecs: 0, random: zeroes(16), seq: 1.5 }), + name: 'UUID v7 rejects fractional counters', + generate: () => uuidv7({ msecs: 0, random: zeroes(16), counter: 1.5 }), }, { - name: 'UUID v7 rejects negative sequences', - generate: () => uuidv7({ msecs: 0, random: zeroes(16), seq: -1 }), + name: 'UUID v7 rejects negative counters', + generate: () => uuidv7({ msecs: 0, random: zeroes(16), counter: -1 }), }, { - name: 'UUID v7 rejects sequences above 32 bits', - generate: () => uuidv7({ msecs: 0, random: zeroes(16), seq: 2 ** 32 }), + name: 'UUID v7 rejects counters above 32 bits', + generate: () => uuidv7({ msecs: 0, random: zeroes(16), counter: 2 ** 32 }), }, { name: 'ULID rejects negative timestamps', @@ -90,8 +90,8 @@ describe('v1 public boundary contract', () => { expect(generate).toThrow(InvalidInputError) }) - it('UUID v7 accepts the maximum unsigned 32-bit sequence', () => { - expect(() => uuidv7({ msecs: 0, random: zeroes(16), seq: 0xffffffff })).not.toThrow() + it('UUID v7 accepts the maximum unsigned 32-bit counter', () => { + expect(() => uuidv7({ msecs: 0, random: zeroes(16), counter: 0xffffffff })).not.toThrow() }) }) @@ -362,7 +362,7 @@ describe('v1 public boundary contract', () => { code: 'RANDOM_BYTES_TOO_SHORT', strategy: 'nanoid', errorClass: InvalidInputError, - run: () => nanoid({ size: 21, random: zeroes(20) }), + run: () => nanoid({ length: 21, random: zeroes(20) }), }, { name: 'CUID2 reports RANDOM_BYTES_TOO_SHORT', @@ -585,7 +585,7 @@ describe('v1 public boundary contract', () => { { name: 'UUID v7 rejects fractional offsets', strategy: 'uuid', - generate: () => uuidv7({ msecs: 0, random: zeroes(16), seq: 0 }, zeroes(32), 0.5), + generate: () => uuidv7({ msecs: 0, random: zeroes(16), counter: 0 }, zeroes(32), 0.5), }, { name: 'ULID rejects fractional offsets', diff --git a/packages/uniku/__tests__/unit/xid.test.ts b/packages/uniku/__tests__/unit/xid.test.ts index 2884475a..64b64d51 100644 --- a/packages/uniku/__tests__/unit/xid.test.ts +++ b/packages/uniku/__tests__/unit/xid.test.ts @@ -138,31 +138,6 @@ describe('xid', () => { expect(() => xid.fromBytes(new Uint8Array(11))).toThrow(BufferError) }) - describe('deprecated secs alias', () => { - // TODO(v1-rc.1): remove this block together with the `secs` option. - it('accepts whole seconds until 1.0.0-rc.1', () => { - const id = xid({ secs: 1_700_000_000, machineId: new Uint8Array(3), processId: 0, counter: 0 }) - expect(xid.timestamp(id)).toBe(1_700_000_000_000) - }) - - it('validates the seconds range', () => { - expect(() => xid({ secs: -1 })).toThrow(InvalidInputError) - expect(() => xid({ secs: 0x100000000 })).toThrow(InvalidInputError) - }) - - it('rejects passing both msecs and secs', () => { - let error: unknown - try { - xid({ msecs: 1_700_000_000_000, secs: 1_700_000_000 }) - } catch (caught) { - error = caught - } - - expect(error).toBeInstanceOf(InvalidInputError) - expect(error).toMatchObject({ code: 'CONFLICTING_OPTIONS', strategy: 'xid' }) - }) - }) - it.each([ 'c6e52g2mrqcjl44hf179', '9M4E2MR0UI3E8A215N4G', diff --git a/packages/uniku/docs/ID_GENERATION.md b/packages/uniku/docs/ID_GENERATION.md index 7027adc7..49d8d5b4 100644 --- a/packages/uniku/docs/ID_GENERATION.md +++ b/packages/uniku/docs/ID_GENERATION.md @@ -55,10 +55,8 @@ Define an options type with these optional fields: - `random?: Uint8Array` — Custom random bytes for deterministic tests - `msecs?: number` — Custom timestamp in milliseconds since the Unix epoch (time-ordered generators). Second-precision formats (ksuid, objectid, xid) truncate sub-second - precision via `Math.floor(msecs / 1000)`; their pre-v1 `secs` aliases are deprecated - and tracked for removal at v1-rc (see `docs/STABILITY.md`). -- `counter?: number` — Counter value (uuidv7, objectid, tsid, xid). uuidv7's pre-v1 - `seq` alias is deprecated and tracked for removal at v1-rc. + precision via `Math.floor(msecs / 1000)`. +- `counter?: number` — Counter value (uuidv7, objectid, tsid, xid). ## Monotonic State (Time-Ordered Generators) @@ -158,8 +156,7 @@ Each generator has its own entry point (no barrel exports): - `uniku/uuid/v4`, `uniku/uuid/v7`, `uniku/ulid`, etc. Versioned generators use a versioned subpath: `uniku/uuid/v4`, `uniku/uuid/v7`, and -`uniku/cuid/v2` (the canonical CUID2 entry point). `uniku/cuid2` is a deprecated -alias for `uniku/cuid/v2` — it still works but should not be used in new code. +`uniku/cuid/v2` (the sole CUID v2 entry point). Two non-generator, metadata-only entry points follow the same standalone-module convention: - `uniku/errors` — the shared error classes. diff --git a/packages/uniku/package.json b/packages/uniku/package.json index 4deb1892..57172d39 100644 --- a/packages/uniku/package.json +++ b/packages/uniku/package.json @@ -1,7 +1,7 @@ { "private": false, "name": "uniku", - "version": "0.6.0", + "version": "1.0.0-rc.1", "description": "Minimal, tree-shakeable unique ID generators for every JavaScript runtime", "author": { "name": "Alberto Schiabel", @@ -70,11 +70,6 @@ "import": "./build/typeid/typeid.mjs", "default": "./build/typeid/typeid.mjs" }, - "./cuid2": { - "types": "./build/cuid2/cuid2.d.mts", - "import": "./build/cuid2/cuid2.mjs", - "default": "./build/cuid2/cuid2.mjs" - }, "./cuid/v2": { "types": "./build/cuid/v2.d.mts", "import": "./build/cuid/v2.mjs", @@ -161,14 +156,6 @@ }, "default": "./build/typeid/typeid.mjs" }, - "./cuid2": { - "types": "./build/cuid2/cuid2.d.mts", - "import": { - "@jkomyno/source": "./src/cuid2/cuid2.ts", - "default": "./build/cuid2/cuid2.mjs" - }, - "default": "./build/cuid2/cuid2.mjs" - }, "./cuid/v2": { "types": "./build/cuid/v2.d.mts", "import": { diff --git a/packages/uniku/scripts/bundle-summary.ts b/packages/uniku/scripts/bundle-summary.ts index f7a25e1e..add17520 100644 --- a/packages/uniku/scripts/bundle-summary.ts +++ b/packages/uniku/scripts/bundle-summary.ts @@ -83,7 +83,7 @@ async function calculateWithBun(entry: EntryPoint): Promise { const minifiedBytes = new Uint8Array(minifiedContent) const gzipped = Bun.gzipSync(minifiedBytes, { level: 9 }) - // Whether this entry uses external deps (cuid2 uses @noble/hashes) + // Whether this entry uses external deps (cuid/v2 uses @noble/hashes) const { hasExternal } = entry return { @@ -136,7 +136,7 @@ async function calculateWithTsdown(entry: EntryPoint): Promise { // Cleanup temp directory await Bun.spawn(['rm', '-rf', tmpDir]).exited - // Whether this entry uses external deps (cuid2 uses @noble/hashes) + // Whether this entry uses external deps (cuid/v2 uses @noble/hashes) const { hasExternal } = entry return { diff --git a/packages/uniku/scripts/cold-start-entrypoints.mjs b/packages/uniku/scripts/cold-start-entrypoints.mjs index 25d1faa4..8fe78ad3 100644 --- a/packages/uniku/scripts/cold-start-entrypoints.mjs +++ b/packages/uniku/scripts/cold-start-entrypoints.mjs @@ -5,7 +5,6 @@ const INVOKE = { 'uuid/v7': (module) => module.uuidv7(), ulid: (module) => module.ulid(), typeid: (module) => module.typeid('benchmark'), - cuid2: (module) => module.cuid2(), 'cuid/v2': (module) => module.cuidv2(), nanoid: (module) => module.nanoid(), ksuid: (module) => module.ksuid(), diff --git a/packages/uniku/scripts/entrypoints.mjs b/packages/uniku/scripts/entrypoints.mjs index 34d156d6..f5355324 100644 --- a/packages/uniku/scripts/entrypoints.mjs +++ b/packages/uniku/scripts/entrypoints.mjs @@ -17,7 +17,6 @@ const ENTRIES = [ { subpath: './uuid/v7', src: 'src/uuid/v7.ts' }, { subpath: './ulid', src: 'src/ulid/ulid.ts' }, { subpath: './typeid', src: 'src/typeid/typeid.ts' }, - { subpath: './cuid2', src: 'src/cuid2/cuid2.ts', hasExternal: true }, { subpath: './cuid/v2', src: 'src/cuid/v2.ts', hasExternal: true }, { subpath: './nanoid', src: 'src/nanoid/nanoid.ts' }, { subpath: './ksuid', src: 'src/ksuid/ksuid.ts' }, diff --git a/packages/uniku/scripts/publish-smoke.mjs b/packages/uniku/scripts/publish-smoke.mjs index 3b54591b..0542253d 100644 --- a/packages/uniku/scripts/publish-smoke.mjs +++ b/packages/uniku/scripts/publish-smoke.mjs @@ -1,12 +1,15 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, relative } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { ENTRYPOINTS } from './entrypoints.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const packageRoot = dirname(scriptDir) +const workspaceRoot = dirname(dirname(packageRoot)) +const forbiddenSubpaths = ['./cuid2'] +const typeContractPath = join(packageRoot, '__tests__', 'types', 'v1-removals.type-test.ts') // Derived from the shared manifest so every published entry point — including // objectid, tsid, and generators — is covered here; nothing can be published @@ -20,6 +23,7 @@ const expectedExports = Object.fromEntries( const runtimeSpecifiers = Object.keys(expectedExports).map((subpath) => `uniku${subpath.slice(1)}`) const sourceDir = join(packageRoot, 'src') +const packageJsonPath = join(packageRoot, 'package.json') const fail = (message) => { console.error(message) @@ -58,7 +62,17 @@ const containsSourceCondition = (value) => { return false } +const assertForbiddenSubpaths = (exportsMap, label) => { + for (const subpath of forbiddenSubpaths) { + if (Object.hasOwn(exportsMap ?? {}, subpath)) { + fail(`${label} must not expose forbidden subpath ${subpath}.`) + } + } +} + const assertPackedExports = (packedPackageJson) => { + assertForbiddenSubpaths(packedPackageJson.exports, 'Packed exports') + if (containsSourceCondition(packedPackageJson.exports)) { fail('Packed exports must not expose the @jkomyno/source condition.') } @@ -105,9 +119,9 @@ const listTypeScriptSources = (dir) => { return files } -const assertSourcesArePacked = (tarballPath) => { - const packedFiles = new Set(run('tar', ['-tf', tarballPath]).split(/\r?\n/)) +const listPackedFiles = (tarballPath) => new Set(run('tar', ['-tf', tarballPath]).split(/\r?\n/)) +const assertSourcesArePacked = (packedFiles) => { for (const sourcePath of listTypeScriptSources(sourceDir)) { const packedPath = `package/${relative(packageRoot, sourcePath)}` @@ -117,12 +131,34 @@ const assertSourcesArePacked = (tarballPath) => { } } -const assertRuntimeResolution = (tarballPath, tempDir) => { - const appDir = join(tempDir, 'app') - const packageDir = join(appDir, 'node_modules', 'uniku') - mkdirSync(packageDir, { recursive: true }) +const assertForbiddenTarEntries = (packedFiles) => { + for (const subpath of forbiddenSubpaths) { + const relativeSubpath = subpath.slice(2) + const forbiddenPrefixes = [`package/build/${relativeSubpath}/`, `package/src/${relativeSubpath}/`] - run('tar', ['-xzf', tarballPath, '-C', packageDir, '--strip-components=1']) + for (const packedFile of packedFiles) { + if (forbiddenPrefixes.some((prefix) => packedFile.startsWith(prefix))) { + fail(`Packed tarball contains forbidden legacy path ${packedFile}.`) + } + } + } +} + +const assertInstalledConsumer = (tarballPath, tempDir) => { + const appDir = join(tempDir, 'app') + mkdirSync(appDir, { recursive: true }) + + writeFileSync( + join(appDir, 'package.json'), + JSON.stringify({ + private: true, + type: 'module', + dependencies: { + uniku: `file:${tarballPath}`, + }, + }), + ) + run('pnpm', ['install', '--ignore-scripts'], { cwd: appDir }) const resolutionScript = ` const specifiers = ${JSON.stringify(runtimeSpecifiers)}; @@ -133,9 +169,19 @@ const assertRuntimeResolution = (tarballPath, tempDir) => { } } - const uuidv7 = await import('uniku/uuid/v7'); - if (typeof uuidv7.uuidv7 !== 'function') { - throw new Error('uniku/uuid/v7 did not expose uuidv7.'); + const canonicalCuid = await import('uniku/cuid/v2'); + if (typeof canonicalCuid.cuidv2 !== 'function' || !canonicalCuid.cuidv2.isValid(canonicalCuid.cuidv2())) { + throw new Error('uniku/cuid/v2 did not expose a working cuidv2 generator.'); + } + + let legacyError; + try { + await import('uniku/cuid2'); + } catch (error) { + legacyError = error; + } + if (legacyError?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') { + throw new Error(\`uniku/cuid2 should fail with ERR_PACKAGE_PATH_NOT_EXPORTED, got \${legacyError?.code}\`); } ` @@ -147,27 +193,52 @@ const assertRuntimeResolution = (tarballPath, tempDir) => { }) if (result.status !== 0) { - fail('Packed package failed to resolve under --conditions=@jkomyno/source.') + fail('Installed packed package failed its runtime export contract.') } + + writeFileSync(join(appDir, 'v1-removals.type-test.ts'), readFileSync(typeContractPath, 'utf8')) + writeFileSync( + join(appDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + noEmit: true, + strict: true, + target: 'ES2023', + types: [], + }, + include: ['v1-removals.type-test.ts'], + }), + ) + const tscPath = join(workspaceRoot, 'node_modules', 'typescript', 'bin', 'tsc') + run(process.execPath, [tscPath, '--project', join(appDir, 'tsconfig.json')], { cwd: appDir }) } +const sourcePackageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) +assertForbiddenSubpaths(sourcePackageJson.exports, 'Source exports') +assertForbiddenSubpaths(sourcePackageJson.publishConfig?.exports, 'Publish exports') + const tempDir = mkdtempSync(join(tmpdir(), 'uniku-publish-smoke-')) try { const packOutput = run('pnpm', ['pack', '--pack-destination', tempDir]) - const tarballPath = packOutput + const packedTarballPath = packOutput .split(/\r?\n/) .map((line) => line.trim()) .findLast((line) => line.endsWith('.tgz')) - if (!tarballPath) { + if (!packedTarballPath) { fail(`Could not find packed tarball path in pnpm pack output:\n${packOutput}`) } + const tarballPath = resolve(packageRoot, packedTarballPath) const packedPackageJson = JSON.parse(run('tar', ['-xOf', tarballPath, 'package/package.json'])) + const packedFiles = listPackedFiles(tarballPath) assertPackedExports(packedPackageJson) - assertSourcesArePacked(tarballPath) - assertRuntimeResolution(tarballPath, tempDir) + assertSourcesArePacked(packedFiles) + assertForbiddenTarEntries(packedFiles) + assertInstalledConsumer(tarballPath, tempDir) } finally { rmSync(tempDir, { recursive: true, force: true }) } diff --git a/packages/uniku/scripts/runtime-smoke.mjs b/packages/uniku/scripts/runtime-smoke.mjs index f7921dd1..4e65c30d 100644 --- a/packages/uniku/scripts/runtime-smoke.mjs +++ b/packages/uniku/scripts/runtime-smoke.mjs @@ -1,4 +1,4 @@ -import { cuid2 } from '../build/cuid2/cuid2.mjs' +import { cuidv2 } from '../build/cuid/v2.mjs' import { ksuid } from '../build/ksuid/ksuid.mjs' import { nanoid } from '../build/nanoid/nanoid.mjs' import { objectid } from '../build/objectid/objectid.mjs' @@ -21,7 +21,7 @@ const generators = [ ['uuidv7', uuidv7], ['ulid', ulid], ['typeid', () => typeid('user'), typeid], - ['cuid2', cuid2], + ['cuidv2', cuidv2], ['nanoid', nanoid], ['ksuid', ksuid], ['objectid', objectid], diff --git a/packages/uniku/src/common/timestamp.ts b/packages/uniku/src/common/timestamp.ts index 0b1a6f1b..f083cd1e 100644 --- a/packages/uniku/src/common/timestamp.ts +++ b/packages/uniku/src/common/timestamp.ts @@ -4,43 +4,27 @@ import { isIntegerInRange } from './validation' /** * Timestamp options accepted by second-precision generators (ksuid, objectid, - * xid) during the pre-v1 transition to unified millisecond inputs. + * xid). */ -export type TimestampSecsOptions = { +export type TimestampOptions = { /** * Timestamp in milliseconds since the Unix epoch. * The generator stores whole seconds, so sub-second precision is truncated. */ msecs?: number - /** - * Deprecated alias for `msecs`; timestamp in seconds since the Unix epoch. - * - * @deprecated Use `msecs` instead. Will be removed in `1.0.0-rc.1`. - */ - // TODO(v1-rc.1): remove this alias (tracked in docs/STABILITY.md). - secs?: number } /** - * Resolve caller-provided timestamp options to whole seconds since the Unix - * epoch, or `undefined` when neither option was supplied. - * - * `msecs` is the unified input and is truncated to whole seconds; `secs` is - * the deprecated pre-v1 alias. Passing both is an error. - * - * TODO(v1-rc.1): drop the `secs` branch once the alias is removed. + * Resolve a caller-provided millisecond timestamp to whole seconds since the + * Unix epoch, or `undefined` when no timestamp was supplied. */ export function resolveTimestampSecs( - options: TimestampSecsOptions, + options: TimestampOptions, minSecs: number, maxSecs: number, strategy: IdGenerator, ): number | undefined { - const { msecs, secs } = options - - if (msecs !== undefined && secs !== undefined) { - throw new InvalidInputError('CONFLICTING_OPTIONS', 'Pass only one of `msecs` or `secs`, not both', { strategy }) - } + const { msecs } = options if (msecs !== undefined) { const minMsecs = minSecs * 1000 @@ -55,16 +39,5 @@ export function resolveTimestampSecs( return Math.floor(msecs / 1000) } - if (secs !== undefined) { - if (!isIntegerInRange(secs, minSecs, maxSecs)) { - throw new InvalidInputError( - 'TIMESTAMP_OUT_OF_RANGE', - `Timestamp must be an integer between ${minSecs} and ${maxSecs} seconds`, - { strategy }, - ) - } - return secs - } - return undefined } diff --git a/packages/uniku/src/cuid/v2.ts b/packages/uniku/src/cuid/v2.ts index f6cb271e..e4bab666 100644 --- a/packages/uniku/src/cuid/v2.ts +++ b/packages/uniku/src/cuid/v2.ts @@ -1,10 +1,197 @@ +import { sha3_512 } from '@noble/hashes/sha3.js' +import { randomUint32 } from '../common/random' +import { isIntegerInRange } from '../common/validation' +import { InvalidInputError } from '../errors' + +export type CuidV2Options = { + /** + * Length of the generated ID (2-32 characters). + * Default: 24 + */ + length?: number + /** + * Custom random bytes for deterministic testing. + * Must be at least 1 byte. For adequate entropy, use at least 16 bytes. + * Note: The fingerprint always uses cryptographically secure random bytes, + * regardless of this option. + */ + random?: Uint8Array +} + +export type CuidV2 = { + /** Generate a CUID v2 string. */ + (options?: CuidV2Options): string + /** Return whether a value is a syntactically valid CUID v2 string. */ + isValid(id: unknown): id is string +} + +const DEFAULT_LENGTH = 24 +const MAX_LENGTH = 32 +const MIN_LENGTH = 2 + +// Maximum initial counter value from cuid2 spec - provides ~29 bits of +// initial entropy to prevent cross-process collisions at startup +const INITIAL_COUNT_MAX = 476782367 + +// Validation regex: first char must be a-z, rest can be a-z or 0-9 +const CUID2_REGEX = /^[a-z][0-9a-z]+$/ + +// Base36 alphabet +const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz' +const LETTER_ALPHABET = 'abcdefghijklmnopqrstuvwxyz' + +// Reusable TextEncoder instance (stateless, safe to share) +const textEncoder = new TextEncoder() + /** - * `uniku/cuid/v2` — the canonical, versioned entry point for the CUID2 generator. + * Module-level state for counter and fingerprint. + * Counter is initialized lazily on first call to prevent unnecessary crypto operations. + * Fingerprint is also generated lazily on first call. + */ +const state: { counter: number | undefined; fingerprint: string | undefined } = { + counter: undefined, + fingerprint: undefined, +} + +/** + * Initialize counter using crypto for consistency with other entropy sources. + */ +function initializeCounter(): number { + return randomUint32() % (INITIAL_COUNT_MAX + 1) +} + +// --- Base36 utilities --- + +function bufToBigInt(buf: Uint8Array): bigint { + let value = 0n + for (const byte of buf) { + value = value * 256n + BigInt(byte) + } + return value +} + +function bigIntToBase36(value: bigint): string { + if (value === 0n) return '0' + const chars: string[] = [] + while (value > 0n) { + chars.push(ALPHABET[Number(value % 36n)]) + value = value / 36n + } + return chars.reverse().join('') +} + +function randomLetter(random: () => number): string { + return LETTER_ALPHABET[Math.floor(random() * 26)] +} + +function createEntropy(length: number, random: () => number): string { + const chars = new Array(length) + for (let i = 0; i < length; i++) { + chars[i] = ALPHABET[Math.floor(random() * 36)] + } + return chars.join('') +} + +// --- SHA3 hash wrapper --- + +function hash(input: string): Uint8Array { + return sha3_512(textEncoder.encode(input)) +} + +// --- Fingerprint generation --- + +const BIG_LENGTH = 32 + +function createFingerprint(): string { + // Always use CSPRNG for fingerprint to ensure security regardless of custom random option + const random = getCryptoRandom + const globals = Object.keys(globalThis).toString() + const sourceString = globals + createEntropy(BIG_LENGTH, random) + const hashed = hash(sourceString) + return bigIntToBase36(bufToBigInt(hashed)).slice(1, BIG_LENGTH + 1) +} + +// --- Random function factory --- + +/** + * Get a random number in [0, 1) using CUID2's own random pool. + */ +function getCryptoRandom(): number { + return randomUint32() / 0x100000000 +} + +function getRandomFn(random?: Uint8Array): () => number { + if (random) { + if (random.length === 0) { + throw new InvalidInputError('RANDOM_BYTES_TOO_SHORT', 'Random byte array cannot be empty', { + strategy: 'cuid', + }) + } + let index = 0 + return () => { + const value = random[index % random.length] / 256 + index += 1 + return value + } + } + return getCryptoRandom +} + +// --- Main generator --- + +function cuidv2Fn(options?: CuidV2Options): string { + const requestedLength = options?.length + + if (requestedLength !== undefined && !isIntegerInRange(requestedLength, MIN_LENGTH, MAX_LENGTH)) { + throw new InvalidInputError( + 'LENGTH_OUT_OF_RANGE', + `CUID2 length must be between ${MIN_LENGTH} and ${MAX_LENGTH}. Received: ${requestedLength}`, + { strategy: 'cuid' }, + ) + } + const length = requestedLength ?? DEFAULT_LENGTH + + const random = getRandomFn(options?.random) + + // Initialize counter lazily on first call + if (state.counter === undefined) { + state.counter = initializeCounter() + } + + // Initialize fingerprint lazily on first call (always uses CSPRNG) + if (state.fingerprint === undefined) { + state.fingerprint = createFingerprint() + } + + const firstLetter = randomLetter(random) + const time = Date.now().toString(36) + state.counter += 1 + const count = state.counter.toString(36) + const salt = createEntropy(length, random) + + const hashInput = time + salt + count + state.fingerprint + const hashed = hash(hashInput) + const base36Hash = bigIntToBase36(bufToBigInt(hashed)) + + // Drop first char of hash to avoid histogram bias, prepend random letter + return firstLetter + base36Hash.slice(1, length) +} + +// --- Validation (type guard) --- + +function isValid(id: unknown): id is string { + return typeof id === 'string' && id.length >= MIN_LENGTH && id.length <= MAX_LENGTH && CUID2_REGEX.test(id) +} + +/** + * Generate a CUID v2 string. + * + * CUID v2 is a secure, collision-resistant identifier that hashes multiple + * entropy sources using SHA3-512. Unlike time-ordered IDs (ULID, UUID v7), + * CUID v2 prevents enumeration attacks by making IDs non-predictable. * - * This mirrors the versioned-subpath convention used by `uniku/uuid/v4` and - * `uniku/uuid/v7`, and supersedes the now-`@deprecated` `uniku/cuid2` entry - * point. It re-exports the single existing implementation under the `cuidv2` - * name — there is no second implementation. + * Note: CUID v2 does not provide toBytes/fromBytes because it is a string-native + * format with no canonical binary representation (unlike UUID's 16-byte format). * * @example * ```ts @@ -13,7 +200,18 @@ * const id = cuidv2() * // => "pfh0haxfpzowht3oi213cqos" * - * cuidv2.isValid(id) // true + * // Custom length + * const shortId = cuidv2({ length: 10 }) + * // => "tz4a98xxat" + * + * // Validation (type guard) + * const maybeId: unknown = getUserInput() + * if (cuidv2.isValid(maybeId)) { + * console.log(maybeId.length) // TypeScript knows maybeId is string + * } * ``` + * */ -export { type Cuid2 as CuidV2, type Cuid2Options as CuidV2Options, cuid2 as cuidv2 } from '../cuid2/cuid2' +export const cuidv2: CuidV2 = Object.assign(cuidv2Fn, { + isValid, +}) diff --git a/packages/uniku/src/cuid2/cuid2.ts b/packages/uniku/src/cuid2/cuid2.ts deleted file mode 100644 index 1ee8a3b8..00000000 --- a/packages/uniku/src/cuid2/cuid2.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { sha3_512 } from '@noble/hashes/sha3.js' -import { randomUint32 } from '../common/random' -import { InvalidInputError } from '../errors' - -export type Cuid2Options = { - /** - * Length of the generated ID (2-32 characters). - * Default: 24 - */ - length?: number - /** - * Custom random bytes for deterministic testing. - * Must be at least 1 byte. For adequate entropy, use at least 16 bytes. - * Note: The fingerprint always uses cryptographically secure random bytes, - * regardless of this option. - */ - random?: Uint8Array -} - -export type Cuid2 = { - /** Generate a CUID v2 string. */ - (options?: Cuid2Options): string - /** Return whether a value is a syntactically valid CUID v2 string. */ - isValid(id: unknown): id is string -} - -const DEFAULT_LENGTH = 24 -const MAX_LENGTH = 32 -const MIN_LENGTH = 2 - -// Maximum initial counter value from cuid2 spec - provides ~29 bits of -// initial entropy to prevent cross-process collisions at startup -const INITIAL_COUNT_MAX = 476782367 - -// Validation regex: first char must be a-z, rest can be a-z or 0-9 -const CUID2_REGEX = /^[a-z][0-9a-z]+$/ - -// Base36 alphabet -const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz' -const LETTER_ALPHABET = 'abcdefghijklmnopqrstuvwxyz' - -// Reusable TextEncoder instance (stateless, safe to share) -const textEncoder = new TextEncoder() - -/** - * Module-level state for counter and fingerprint. - * Counter is initialized lazily on first call to prevent unnecessary crypto operations. - * Fingerprint is also generated lazily on first call. - */ -const state: { counter: number | undefined; fingerprint: string | undefined } = { - counter: undefined, - fingerprint: undefined, -} - -/** - * Initialize counter using crypto for consistency with other entropy sources. - */ -function initializeCounter(): number { - return randomUint32() % (INITIAL_COUNT_MAX + 1) -} - -// --- Base36 utilities --- - -function bufToBigInt(buf: Uint8Array): bigint { - let value = 0n - for (const byte of buf) { - value = value * 256n + BigInt(byte) - } - return value -} - -function bigIntToBase36(value: bigint): string { - if (value === 0n) return '0' - const chars: string[] = [] - while (value > 0n) { - chars.push(ALPHABET[Number(value % 36n)]) - value = value / 36n - } - return chars.reverse().join('') -} - -function randomLetter(random: () => number): string { - return LETTER_ALPHABET[Math.floor(random() * 26)] -} - -function createEntropy(length: number, random: () => number): string { - const chars = new Array(length) - for (let i = 0; i < length; i++) { - chars[i] = ALPHABET[Math.floor(random() * 36)] - } - return chars.join('') -} - -// --- SHA3 hash wrapper --- - -function hash(input: string): Uint8Array { - return sha3_512(textEncoder.encode(input)) -} - -// --- Fingerprint generation --- - -const BIG_LENGTH = 32 - -function createFingerprint(): string { - // Always use CSPRNG for fingerprint to ensure security regardless of custom random option - const random = getCryptoRandom - const globals = Object.keys(globalThis).toString() - const sourceString = globals + createEntropy(BIG_LENGTH, random) - const hashed = hash(sourceString) - return bigIntToBase36(bufToBigInt(hashed)).slice(1, BIG_LENGTH + 1) -} - -// --- Random function factory --- - -/** - * Get a random number in [0, 1) using CUID2's own random pool. - */ -function getCryptoRandom(): number { - return randomUint32() / 0x100000000 -} - -function getRandomFn(random?: Uint8Array): () => number { - if (random) { - if (random.length === 0) { - throw new InvalidInputError('RANDOM_BYTES_TOO_SHORT', 'Random byte array cannot be empty', { - strategy: 'cuid', - }) - } - let index = 0 - return () => { - const value = random[index % random.length] / 256 - index += 1 - return value - } - } - return getCryptoRandom -} - -// --- Main generator --- - -function cuid2Fn(options?: Cuid2Options): string { - const requestedLength = options?.length - - if ( - requestedLength !== undefined && - (!Number.isInteger(requestedLength) || requestedLength < MIN_LENGTH || requestedLength > MAX_LENGTH) - ) { - throw new InvalidInputError( - 'LENGTH_OUT_OF_RANGE', - `CUID2 length must be between ${MIN_LENGTH} and ${MAX_LENGTH}. Received: ${requestedLength}`, - { strategy: 'cuid' }, - ) - } - const length = requestedLength ?? DEFAULT_LENGTH - - const random = getRandomFn(options?.random) - - // Initialize counter lazily on first call - if (state.counter === undefined) { - state.counter = initializeCounter() - } - - // Initialize fingerprint lazily on first call (always uses CSPRNG) - if (state.fingerprint === undefined) { - state.fingerprint = createFingerprint() - } - - const firstLetter = randomLetter(random) - const time = Date.now().toString(36) - state.counter += 1 - const count = state.counter.toString(36) - const salt = createEntropy(length, random) - - const hashInput = time + salt + count + state.fingerprint - const hashed = hash(hashInput) - const base36Hash = bigIntToBase36(bufToBigInt(hashed)) - - // Drop first char of hash to avoid histogram bias, prepend random letter - return firstLetter + base36Hash.slice(1, length) -} - -// --- Validation (type guard) --- - -function isValid(id: unknown): id is string { - return typeof id === 'string' && id.length >= MIN_LENGTH && id.length <= MAX_LENGTH && CUID2_REGEX.test(id) -} - -/** - * Generate a CUID v2 string. - * - * CUID v2 is a secure, collision-resistant identifier that hashes multiple - * entropy sources using SHA3-512. Unlike time-ordered IDs (ULID, UUID v7), - * CUID v2 prevents enumeration attacks by making IDs non-predictable. - * - * Note: CUID v2 does not provide toBytes/fromBytes because it is a string-native - * format with no canonical binary representation (unlike UUID's 16-byte format). - * - * @example - * ```ts - * import { cuid2 } from 'uniku/cuid2' - * - * const id = cuid2() - * // => "pfh0haxfpzowht3oi213cqos" - * - * // Custom length - * const shortId = cuid2({ length: 10 }) - * // => "tz4a98xxat" - * - * // Validation (type guard) - * const maybeId: unknown = getUserInput() - * if (cuid2.isValid(maybeId)) { - * console.log(maybeId.length) // TypeScript knows maybeId is string - * } - * ``` - * - * @deprecated Use `cuidv2` from `uniku/cuid/v2` instead. This entry point keeps - * working unchanged, but `uniku/cuid/v2` is the canonical versioned subpath - * (mirroring `uniku/uuid/v4` / `uniku/uuid/v7`). - */ -export const cuid2: Cuid2 = Object.assign(cuid2Fn, { - isValid, -}) - -export { InvalidInputError, UniqueIdError } from '../errors' diff --git a/packages/uniku/src/errors.ts b/packages/uniku/src/errors.ts index 8a766e01..92e6d334 100644 --- a/packages/uniku/src/errors.ts +++ b/packages/uniku/src/errors.ts @@ -11,7 +11,6 @@ import type { IdGenerator } from './generators' // `isolatedDeclarations`; every member is already a string literal. export const ERROR_CODES = [ 'TIMESTAMP_OUT_OF_RANGE', - 'CONFLICTING_OPTIONS', 'COUNTER_OUT_OF_RANGE', 'NODE_OUT_OF_RANGE', 'NODE_BITS_OUT_OF_RANGE', diff --git a/packages/uniku/src/ksuid/ksuid.ts b/packages/uniku/src/ksuid/ksuid.ts index 613c366f..a49a3fcf 100644 --- a/packages/uniku/src/ksuid/ksuid.ts +++ b/packages/uniku/src/ksuid/ksuid.ts @@ -43,13 +43,6 @@ export type KsuidOptions = { * KSUID stores whole seconds, so sub-second precision is truncated. */ msecs?: number - /** - * Deprecated alias for `msecs`; timestamp in seconds since the Unix epoch. - * - * @deprecated Use `msecs` instead. Will be removed in `1.0.0-rc.1`. - */ - // TODO(v1-rc.1): remove this alias (tracked in docs/STABILITY.md). - secs?: number } export type Ksuid = { diff --git a/packages/uniku/src/nanoid/nanoid.ts b/packages/uniku/src/nanoid/nanoid.ts index 075c1281..abe49a43 100644 --- a/packages/uniku/src/nanoid/nanoid.ts +++ b/packages/uniku/src/nanoid/nanoid.ts @@ -54,13 +54,6 @@ export type NanoidOptions = { * Length of generated ID. Default: 21. Maximum: 2048. */ length?: number - /** - * Deprecated alias for `length`. - * - * @deprecated Use `length` instead. Will be removed in `1.0.0-rc.1`. - */ - // TODO(v1-rc.1): remove this alias (tracked in docs/STABILITY.md). - size?: number } export type Nanoid = { @@ -142,12 +135,7 @@ function nanoidFn(sizeOrOptions?: number | NanoidOptions): string { if (typeof sizeOrOptions === 'number') { size = sizeOrOptions } else { - if (sizeOrOptions.length !== undefined && sizeOrOptions.size !== undefined) { - throw new InvalidInputError('CONFLICTING_OPTIONS', 'Pass only one of `length` or `size`, not both', { - strategy: 'nanoid', - }) - } - size = sizeOrOptions.length ?? sizeOrOptions.size ?? DEFAULT_SIZE + size = sizeOrOptions.length ?? DEFAULT_SIZE alphabet = sizeOrOptions.alphabet ?? URL_ALPHABET randomBytes = sizeOrOptions.random if (sizeOrOptions.alphabet !== undefined) { diff --git a/packages/uniku/src/objectid/objectid.ts b/packages/uniku/src/objectid/objectid.ts index 1b76ee97..279a2569 100644 --- a/packages/uniku/src/objectid/objectid.ts +++ b/packages/uniku/src/objectid/objectid.ts @@ -37,13 +37,6 @@ export type ObjectIdOptions = { * ObjectID stores whole seconds, so sub-second precision is truncated. */ msecs?: number - /** - * Deprecated alias for `msecs`; timestamp in seconds since the Unix epoch. - * - * @deprecated Use `msecs` instead. Will be removed in `1.0.0-rc.1`. - */ - // TODO(v1-rc.1): remove this alias (tracked in docs/STABILITY.md). - secs?: number /** * 24-bit counter value (0 to 0xFFFFFF). */ diff --git a/packages/uniku/src/typeid/typeid.ts b/packages/uniku/src/typeid/typeid.ts index 14e28d72..9f8e165d 100644 --- a/packages/uniku/src/typeid/typeid.ts +++ b/packages/uniku/src/typeid/typeid.ts @@ -255,12 +255,7 @@ function validateTypeidOptions(options: TypeidOptions): void { }) } - if (options.counter !== undefined && options.seq !== undefined) { - throw new InvalidInputError('CONFLICTING_OPTIONS', 'Pass only one of `counter` or `seq`, not both', { - strategy: 'typeid', - }) - } - const counter = options.counter ?? options.seq + const counter = options.counter if (counter !== undefined && !isIntegerInRange(counter, 0, MAX_COUNTER)) { throw new InvalidInputError('COUNTER_OUT_OF_RANGE', `Counter must be an integer between 0 and ${MAX_COUNTER}`, { strategy: 'typeid', diff --git a/packages/uniku/src/uuid/v7.ts b/packages/uniku/src/uuid/v7.ts index 84958f8a..7047315e 100644 --- a/packages/uniku/src/uuid/v7.ts +++ b/packages/uniku/src/uuid/v7.ts @@ -18,13 +18,6 @@ export type UuidV7Options = { * Unsigned 32-bit counter value. */ counter?: number - /** - * Deprecated alias for `counter`; unsigned 32-bit sequence value. - * - * @deprecated Use `counter` instead. Will be removed in `1.0.0-rc.1`. - */ - // TODO(v1-rc.1): remove this alias (tracked in docs/STABILITY.md). - seq?: number } export type UuidV7 = { @@ -67,7 +60,7 @@ type V7State = { * * IMPORTANT: This state persists across all uuidv7() calls in the module's lifetime. * - In serverless/edge functions with warm starts, state persists between invocations. - * - For isolated state, pass explicit `msecs` and `seq` via options. + * - For isolated state, pass explicit `msecs` and `counter` via options. * - Tests should mock Date.now() or provide explicit options for deterministic behavior. */ const state: V7State = { msecs: -Infinity, seq: 0 } @@ -118,13 +111,8 @@ function v7WithOptions( strategy: 'uuid', }) } - if (options.counter !== undefined && options.seq !== undefined) { - throw new InvalidInputError('CONFLICTING_OPTIONS', 'Pass only one of `counter` or `seq`, not both', { - strategy: 'uuid', - }) - } - const optSeq = options.counter ?? options.seq - if (optSeq !== undefined && !isIntegerInRange(optSeq, 0, MAX_SEQ)) { + const counter = options.counter + if (counter !== undefined && !isIntegerInRange(counter, 0, MAX_SEQ)) { throw new InvalidInputError('COUNTER_OUT_OF_RANGE', `Counter must be an integer between 0 and ${MAX_SEQ}`, { strategy: 'uuid', }) @@ -139,7 +127,7 @@ function v7WithOptions( const rnds = optRandom ?? rng() const msecs = optMsecs ?? Date.now() // Derive a 31-bit sequence if not provided by the caller, matching the default hot path. - const seq = optSeq ?? (rnds[6] << 23) | (rnds[7] << 16) | (rnds[8] << 8) | rnds[9] + const seq = counter ?? (rnds[6] << 23) | (rnds[7] << 16) | (rnds[8] << 8) | rnds[9] if (buf) { writeV7Bytes(rnds, msecs, seq, buf, offset) diff --git a/packages/uniku/src/xid/xid.ts b/packages/uniku/src/xid/xid.ts index b328b622..ba9b3fc0 100644 --- a/packages/uniku/src/xid/xid.ts +++ b/packages/uniku/src/xid/xid.ts @@ -25,13 +25,6 @@ export type XidOptions = { * XID stores whole seconds, so sub-second precision is truncated. */ msecs?: number - /** - * Deprecated alias for `msecs`; Unix timestamp in seconds. - * - * @deprecated Use `msecs` instead. Will be removed in `1.0.0-rc.1`. - */ - // TODO(v1-rc.1): remove this alias (tracked in docs/STABILITY.md). - secs?: number /** 24-bit counter. Explicit values do not consume shared state. */ counter?: number } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30bb7e58..440370a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,21 @@ importers: specifier: 8.1.4 version: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + codemods/v1: + devDependencies: + '@codemod.com/jssg-types': + specifier: 1.6.3 + version: 1.6.3 + codemod: + specifier: 1.14.0 + version: 1.14.0 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + examples: dependencies: '@electric-sql/pglite': @@ -539,6 +554,41 @@ packages: '@cloudflare/workers-types@5.20260706.1': resolution: {integrity: sha512-PdGrKyJMmKjrZs6xPN/LYN2zpKo9bgn8Iq3JLYho1/76t0HEfUYnssgUljqFxQYPzH4SZ8wsIThaGHmW5GZkLg==} + '@codemod.com/cli-darwin-arm64@1.14.0': + resolution: {integrity: sha512-SMvXHaMBdgMRW9PEQMsXIblbhC4T5QfErXwLf4h7yH7mPZXHKcgelv++bRQ4mN9t2gqg3MIgi9kHhXP8ObFUog==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@codemod.com/cli-darwin-x64@1.14.0': + resolution: {integrity: sha512-TZinfHCzKvInjac/L7J3YG32BRp1dVObu3XlHCcEmWowbNmdGsPV0aIsEIua5af8BqKOzQ5c0I/meauGb3R8cA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@codemod.com/cli-linux-arm64-gnu@1.14.0': + resolution: {integrity: sha512-bqKx3bSjOVKnVvo5MtDlfRzAjRJLEPwVgQBL09wwOhgRPuPAP7gBSitnLSeIIP7MjVgtA5ES+FPyBCf6ufIkSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@codemod.com/cli-linux-x64-gnu@1.14.0': + resolution: {integrity: sha512-eJC5+zgnYALdv6ahycEl4hhmqSC7Q9ziQsYSZidQaqN2chXle6V2xveqcm91kmrXJ3KwzfBLrLZfyAYrWgngkQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@codemod.com/cli-win32-x64-msvc@1.14.0': + resolution: {integrity: sha512-9Z/CJf/taLvbDIhioCwQ4IyBoK9yhBRoKc+Wvk2tozdIl1AtBR+hxpWnnPCygB60btWyqNU1TLcgVatMOoMb/Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@codemod.com/jssg-types@1.6.3': + resolution: {integrity: sha512-b5829ixO5TdGL5xg5YcL9YfiLC3WwFZU+8Zx78NpsHlJ1iySbqfA0g0psqNXv8ss/r7hdpy2KiBXm3GE6bN5bQ==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -2377,6 +2427,11 @@ packages: resolution: {integrity: sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q==} hasBin: true + codemod@1.14.0: + resolution: {integrity: sha512-aTCct/cVX3o8aiz+7DtPbyPjKY6LW2OKYduV5pR73iSpJ3Hk+b6vC3kZe7fUfsDX2TTmP59elonz+bG1W3M4DQ==} + engines: {node: '>= 16.0.0'} + hasBin: true + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -4791,6 +4846,23 @@ snapshots: '@cloudflare/workers-types@5.20260706.1': {} + '@codemod.com/cli-darwin-arm64@1.14.0': + optional: true + + '@codemod.com/cli-darwin-x64@1.14.0': + optional: true + + '@codemod.com/cli-linux-arm64-gnu@1.14.0': + optional: true + + '@codemod.com/cli-linux-x64-gnu@1.14.0': + optional: true + + '@codemod.com/cli-win32-x64-msvc@1.14.0': + optional: true + + '@codemod.com/jssg-types@1.6.3': {} + '@colors/colors@1.5.0': optional: true @@ -6403,6 +6475,16 @@ snapshots: cnfast@0.0.8: {} + codemod@1.14.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@codemod.com/cli-darwin-arm64': 1.14.0 + '@codemod.com/cli-darwin-x64': 1.14.0 + '@codemod.com/cli-linux-arm64-gnu': 1.14.0 + '@codemod.com/cli-linux-x64-gnu': 1.14.0 + '@codemod.com/cli-win32-x64-msvc': 1.14.0 + collapse-white-space@2.1.0: {} color-convert@2.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4d1920af..2065f068 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'apps/*' + - 'codemods/*' - 'examples' - 'packages/*' - 'packages/uniku/__tests__/e2e/runtimes/cloudflare' diff --git a/rfcs/cuid2.txt b/rfcs/cuid2.txt index 9c2d23d5..797ab8c0 100644 --- a/rfcs/cuid2.txt +++ b/rfcs/cuid2.txt @@ -84,13 +84,13 @@ ab (2 chars, minimum) ### Type Definitions ```typescript -type Cuid2Options = { +type CuidV2Options = { length?: number // 2-32 characters (default: 24) random?: Uint8Array // Custom random bytes for deterministic testing } -type Cuid2 = { - (options?: Cuid2Options): string +type CuidV2 = { + (options?: CuidV2Options): string isValid(id: unknown): id is string } ``` @@ -98,21 +98,21 @@ type Cuid2 = { ### Function Signatures ``` -cuid2() -> string +cuidv2() -> string ``` Generate a 24-character CUID2 using default settings. ``` -cuid2(options) -> string +cuidv2(options) -> string ``` Generate a CUID2 with custom options. - `options.length`: Length of ID (default: 24, range: 2-32) - `options.random`: Deterministic random bytes for testing -Throws RangeError if length < 2 or > 32. +Throws `InvalidInputError` if length < 2 or > 32. ``` -cuid2.isValid(id) -> boolean +cuidv2.isValid(id) -> boolean ``` Validate CUID2 format. Returns true if: string, length 2-32, starts with a-z, contains only a-z and 0-9. @@ -270,34 +270,34 @@ due to SHA3-512 hashing. This provides: ### Example 1: Default Generation ```typescript -const id = cuid2() +const id = cuidv2() // => "pfh0haxfpzowht3oi213cqos" (24 characters, starts with letter) ``` ### Example 2: Custom Length ```typescript -const shortId = cuid2({ length: 10 }) +const shortId = cuidv2({ length: 10 }) // => "tz4a98xxat" (10 characters) -const longId = cuid2({ length: 32 }) +const longId = cuidv2({ length: 32 }) // => "abcdefghij0123456789abcdefghijkl" (32 characters) ``` ### Example 3: Validation ```typescript -cuid2.isValid("pfh0haxfpzowht3oi213cqos") // true -cuid2.isValid("ab") // true (minimum valid length) -cuid2.isValid("a") // false (too short) -cuid2.isValid("1abc") // false (starts with digit) -cuid2.isValid("ABCD") // false (uppercase not allowed) -cuid2.isValid("ab-cd") // false (hyphen not allowed) +cuidv2.isValid("pfh0haxfpzowht3oi213cqos") // true +cuidv2.isValid("ab") // true (minimum valid length) +cuidv2.isValid("a") // false (too short) +cuidv2.isValid("1abc") // false (starts with digit) +cuidv2.isValid("ABCD") // false (uppercase not allowed) +cuidv2.isValid("ab-cd") // false (hyphen not allowed) ``` ### Example 4: Invalid Inputs | Input | Reason Invalid | |-------|----------------| -| `cuid2({ length: 1 })` | Length < 2 throws RangeError | -| `cuid2({ length: 33 })` | Length > 32 throws RangeError | +| `cuidv2({ length: 1 })` | Length < 2 throws `InvalidInputError` | +| `cuidv2({ length: 33 })` | Length > 32 throws `InvalidInputError` | | `""` | Empty string fails validation | | `"1abcdefghij"` | Starts with digit | | `"UPPERCASE"` | Contains uppercase letters | @@ -305,13 +305,13 @@ cuid2.isValid("ab-cd") // false (hyphen not allowed) ### Example 5: Deterministic Testing ```typescript // For testing with predictable output -const deterministicId = cuid2({ random: new Uint8Array(64).fill(42) }) +const deterministicId = cuidv2({ random: new Uint8Array(64).fill(42) }) // Output is deterministic given same random bytes ``` ### Example 6: Format Verification ```typescript -const id = cuid2() +const id = cuidv2() // Properties that always hold: // - typeof id === 'string' // - id.length === 24 (default) diff --git a/rfcs/ksuid.txt b/rfcs/ksuid.txt index 909dfafb..6d2fee3b 100644 --- a/rfcs/ksuid.txt +++ b/rfcs/ksuid.txt @@ -92,7 +92,7 @@ String: aWgEPTl1tmebfsQzFP4bxwgy80V ```typescript type KsuidOptions = { random?: Uint8Array // 16 bytes for payload - secs?: number // Unix timestamp in seconds + msecs?: number // Unix timestamp in milliseconds } type Ksuid = { @@ -123,21 +123,21 @@ Maintains monotonic ordering within the same second. ``` ksuid(options) -> string ``` -Generate KSUID with optional custom timestamp (secs) and/or payload (random). +Generate KSUID with optional custom timestamp (msecs) and/or payload (random). Bypasses monotonic state when options provided. ``` ksuid(options, buf, offset) -> buf ``` Write 20-byte KSUID to provided buffer at offset. -Throws RangeError if buffer too small. +Throws `BufferError` if the buffer is too small. ``` ksuid.toBytes(id) -> Uint8Array ``` Convert 27-character Base62 string to 20-byte Uint8Array. Case-insensitive (accepts uppercase, lowercase, or mixed). -Throws Error on invalid input. +Throws `ParseError` on invalid input. ``` ksuid.fromBytes(bytes) -> string @@ -170,12 +170,12 @@ ksuid.MAX = "aWgEPTl1tmebfsQzFP4bxwgy80V" ### Timestamp Handling KSUID uses native second precision: -- Options accept `secs` (seconds since Unix epoch) +- Options accept `msecs` (milliseconds since Unix epoch) and truncate sub-second precision - timestamp() returns milliseconds for API consistency with ulid/uuidv7 Example: ```typescript -const id = ksuid({ secs: 1702387456 }) +const id = ksuid({ msecs: 1702387456000 }) ksuid.timestamp(id) === 1702387456000 // Returns milliseconds ``` @@ -306,7 +306,7 @@ const restored = ksuid.fromBytes(bytes) ### Example 6: Timestamp Extraction ```typescript -const id = ksuid({ secs: 1702387456 }) +const id = ksuid({ msecs: 1702387456000 }) const ts = ksuid.timestamp(id) // ts === 1702387456000 (milliseconds for API consistency) ``` diff --git a/rfcs/nanoid.txt b/rfcs/nanoid.txt index 71901a8a..cbe047ed 100644 --- a/rfcs/nanoid.txt +++ b/rfcs/nanoid.txt @@ -16,7 +16,7 @@ Nanoid generates purely random identifiers with no embedded timestamp. - **Compact**: 21 characters vs 36 for UUID - **URL-safe**: Uses A-Za-z0-9_- alphabet by default - **Secure**: Uses cryptographically strong random APIs -- **Customizable**: Supports custom alphabets and sizes +- **Customizable**: Supports custom alphabets and lengths - **Edge-compatible**: Works with Web Crypto API (Cloudflare Workers) - **No modulo bias**: Bitmask rejection sampling ensures uniform distribution @@ -55,7 +55,7 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_- ### Default Configuration -- **Size**: 21 characters (max: 2048) +- **Length**: 21 characters (max: 2048) - **Alphabet**: 64 characters (A-Za-z0-9_-) - **Entropy**: 126 bits (21 * log2(64) = 21 * 6) @@ -74,7 +74,7 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_- type NanoidOptions = { random?: Uint8Array // Custom random bytes for deterministic output alphabet?: string // Custom alphabet (2-256 printable ASCII chars) - size?: number // Length of generated ID (default: 21, max: 2048) + length?: number // Length of generated ID (default: 21, max: 2048) } type Nanoid = { @@ -99,13 +99,13 @@ Generate a 21-character URL-safe ID using the default alphabet. nanoid(size) -> string ``` Generate an ID of specified length using the default alphabet. -Throws RangeError if size < 0 or > 2048. +Throws `InvalidInputError` if size < 0 or > 2048. ``` nanoid(options) -> string ``` -Generate an ID with custom size and/or alphabet. -- `options.size`: Length of ID (default: 21) +Generate an ID with custom length and/or alphabet. +- `options.length`: Length of ID (default: 21) - `options.alphabet`: Custom character set (default: URL_ALPHABET) - `options.random`: Deterministic random bytes for testing @@ -183,7 +183,7 @@ function isValid(id: unknown): id is string { - Must be a non-negative integer - `nanoid(0)` returns empty string - Maximum: 2048 (prevents memory issues) -- Negative, non-integer, or exceeding max throws `RangeError` +- Negative, non-integer, or exceeding max throws `InvalidInputError` **Alphabet parameter:** - Minimum 2 characters @@ -210,7 +210,7 @@ and add complexity without value. Pre-computing mask/step provides negligible performance gain (two arithmetic ops). Adding a factory would duplicate generation logic and increase cognitive load. -Users should call `nanoid({ alphabet, size })` directly. +Users should call `nanoid({ alphabet, length })` directly. **4. `isValid` validates default alphabet only** @@ -242,14 +242,14 @@ validation checks. | Time-sortable | No | No | Yes | No | | URL-safe | Yes | No | Yes | Yes | | Custom alphabet | Yes | No | No | No | -| Custom size | Yes | No | No | Yes | +| Custom length | Yes | No | No | Yes | | Entropy (default)| 126 bits | 122 bits | 80 bits | ~144 bits | | RFC Standard | No | Yes | No | No | When to choose Nanoid: - Need short, URL-safe IDs - Time-ordering not required -- Want customizable alphabet/size +- Want customizable alphabet/length - Simple, fast generation ## Performance @@ -282,18 +282,18 @@ const id = nanoid({ random: new Uint8Array(21).fill(0) }) ### Example 4: Custom Alphabet ```typescript -const hexId = nanoid({ alphabet: '0123456789abcdef', size: 12 }) +const hexId = nanoid({ alphabet: '0123456789abcdef', length: 12 }) // => "4f90d13a42bc" (12 hex characters) ``` ### Example 5: Invalid Inputs | Input | Reason Invalid | |-------|----------------| -| `nanoid(-1)` | Negative size throws RangeError | -| `nanoid(3000)` | Exceeds MAX_SIZE (2048) throws RangeError | -| `nanoid({ alphabet: 'a' })` | Alphabet < 2 chars throws Error | -| `nanoid({ alphabet: 'aa' })` | Duplicate chars throws Error | -| `nanoid({ alphabet: 'a\x00b' })` | Non-printable char throws Error | +| `nanoid(-1)` | Negative size throws `InvalidInputError` | +| `nanoid(3000)` | Exceeds MAX_SIZE (2048) throws `InvalidInputError` | +| `nanoid({ alphabet: 'a' })` | Alphabet < 2 chars throws `InvalidInputError` | +| `nanoid({ alphabet: 'aa' })` | Duplicate chars throw `InvalidInputError` | +| `nanoid({ alphabet: 'a\x00b' })` | Non-printable chars throw `InvalidInputError` | ### Example 6: Validation ```typescript diff --git a/rfcs/objectid.txt b/rfcs/objectid.txt index d1802cca..e2cb14ec 100644 --- a/rfcs/objectid.txt +++ b/rfcs/objectid.txt @@ -133,10 +133,10 @@ type ObjectIdOptions = { */ random?: Uint8Array /** - * Timestamp in seconds since Unix epoch. - * Defaults to Math.floor(Date.now() / 1000). + * Timestamp in milliseconds since Unix epoch. + * Defaults to Date.now(); ObjectID stores whole seconds. */ - secs?: number + msecs?: number /** * 24-bit counter value (0 to 0xFFFFFF). */ @@ -169,7 +169,7 @@ persistent random value, and the next value of the persistent counter. ``` objectid(options) -> string ``` -Generate ObjectID with optional custom timestamp (secs), random bytes +Generate ObjectID with optional custom timestamp (msecs), random bytes (random), and/or counter value. Any field supplied via options bypasses persistent module state entirely for that field. @@ -251,13 +251,13 @@ sequence number tied to a particular timestamp. ### Options Bypass State Entirely -Passing any of `random` (must be `>= 5` bytes), `secs` (`[0, -0xFFFFFFFF]`), or `counter` (`[0, 0xFFFFFF]`) sources *every* field +Passing any of `random` (must be `>= 5` bytes), `msecs` (`[0, +0xFFFFFFFF * 1000 + 999]`), or `counter` (`[0, 0xFFFFFF]`) sources *every* field fresh — from the given value, or from an independent default — and never reads from or writes to persistent state: ```typescript -secs = optSecs ?? Math.floor(Date.now() / 1000) +secs = optMsecs === undefined ? Math.floor(Date.now() / 1000) : Math.floor(optMsecs / 1000) random = optRandom ?? rng().subarray(0, RANDOM_BYTES) counter = optCounter ?? randomUint32() & MAX_COUNTER ``` @@ -370,7 +370,7 @@ When to choose ObjectID: ### Example 2: Known Deterministic Output (hand-computed test vector) ```typescript const random = new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]) -const id = objectid({ secs: 1_700_000_000, random, counter: 0x010203 }) +const id = objectid({ msecs: 1_700_000_000_000, random, counter: 0x010203 }) // id === '6553f1000102030405010203' ``` - **Timestamp**: 1,700,000,000 -> big-endian hex `6553f100` @@ -396,7 +396,7 @@ const id = objectid({ secs: 1_700_000_000, random, counter: 0x010203 }) ### Example 5: Round-Trip Verification ```typescript -const id = objectid({ secs: 1_700_000_000, random: new Uint8Array([9, 8, 7, 6, 5]), counter: 42 }) +const id = objectid({ msecs: 1_700_000_000_000, random: new Uint8Array([9, 8, 7, 6, 5]), counter: 42 }) const bytes = objectid.toBytes(id) const restored = objectid.fromBytes(bytes) // restored === id @@ -404,9 +404,9 @@ const restored = objectid.fromBytes(bytes) ### Example 6: Timestamp Extraction ```typescript -const secs = 1_702_387_456 -const id = objectid({ secs, random: new Uint8Array(5), counter: 0 }) -objectid.timestamp(id) === secs * 1000 // Returns milliseconds +const msecs = 1_702_387_456_000 +const id = objectid({ msecs, random: new Uint8Array(5), counter: 0 }) +objectid.timestamp(id) === msecs // Returns milliseconds ``` ## Prior Art diff --git a/rfcs/ulid.txt b/rfcs/ulid.txt index 073e882f..90d7d858 100644 --- a/rfcs/ulid.txt +++ b/rfcs/ulid.txt @@ -147,13 +147,13 @@ Bypasses monotonic state when options provided. ulid(options, buf, offset) -> buf ``` Write 16-byte ULID to provided buffer at offset. -Throws RangeError if buffer too small. +Throws `BufferError` if the buffer is too small. ``` ulid.toBytes(id) -> Uint8Array ``` Convert 26-character Base32 string to 16-byte Uint8Array. -Case-insensitive. Throws Error on invalid input. +Case-insensitive. Throws `ParseError` on invalid input. ``` ulid.fromBytes(bytes) -> string