diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8a5f881..5907267 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,70 +1,120 @@ name: Bug report -description: Report a reproducible problem -title: "[Bug]: " +description: Report reproducible incorrect or regressed behavior labels: - bug body: - type: markdown attributes: value: | - Thanks for reporting a bug. Please include enough detail to reproduce it. + Thanks for reporting a problem. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + + - type: dropdown + id: problem_type + attributes: + label: Problem type + description: Select the option that best describes the problem. + options: + - Bug + - Regression + - Not sure + validations: + required: true + - type: textarea id: summary attributes: label: Summary - description: What is wrong? - placeholder: Clear and short description of the bug. + description: Describe the incorrect behavior and its impact. + placeholder: A clear and concise description of the problem. + validations: + required: true + + - type: input + id: package_version + attributes: + label: Affected package version + placeholder: e.g. 2.4.1 or dev-main@abc1234 validations: required: true + + - type: input + id: last_known_working + attributes: + label: Last known working version or commit + description: Complete this when reporting a regression, if known. + placeholder: e.g. 2.4.0 or abc1234 + + - type: input + id: first_known_broken + attributes: + label: First known broken version or commit + description: Complete this when known. + placeholder: e.g. 2.4.1 or def5678 + - type: textarea id: reproduce attributes: - label: Steps to reproduce - description: Share exact commands, config, and steps. + label: Minimal reproduction + description: Provide the smallest code sample, command, configuration or repository that reproduces the problem. placeholder: | - 1. Run `composer ic:tests` - 2. ... + 1. Install or configure ... + 2. Run ... 3. Observe ... validations: required: true + - type: textarea id: expected attributes: label: Expected behavior - placeholder: What did you expect to happen? + placeholder: Describe what should happen. validations: required: true + - type: textarea id: actual attributes: label: Actual behavior - placeholder: What happened instead? Include full error output if possible. + placeholder: Describe what happens instead. validations: required: true + + - type: textarea + id: error_output + attributes: + label: Relevant output or errors + description: Include only the relevant, sanitized output. + render: shell + - type: input id: php_version attributes: label: PHP version - placeholder: "e.g. 8.3.8" + placeholder: e.g. 8.4.13 validations: required: true + - type: input id: composer_version attributes: label: Composer version - placeholder: "e.g. 2.9.2" + placeholder: e.g. 2.9.2 validations: required: true + - type: textarea id: environment attributes: - label: Environment details - description: OS, CI provider, shell, and anything else relevant. - placeholder: Ubuntu 24.04, GitHub Actions, bash... + label: Environment + description: Include the operating system, relevant extensions, dependency mode, runtime and CI provider when applicable. + placeholder: Ubuntu 24.04, locked dependencies, ext-json enabled, GitHub Actions... validations: required: true + - type: textarea id: additional attributes: label: Additional context - description: Links, screenshots, logs, or related issues. + description: Add related issues, screenshots, logs, workarounds or other useful context. diff --git a/.github/ISSUE_TEMPLATE/ci_failure.yml b/.github/ISSUE_TEMPLATE/ci_failure.yml index 3dcbac9..9c3883f 100644 --- a/.github/ISSUE_TEMPLATE/ci_failure.yml +++ b/.github/ISSUE_TEMPLATE/ci_failure.yml @@ -1,48 +1,101 @@ name: CI failure -description: Report a reproducible CI or workflow failure -title: "[CI]: " +description: Report a reproducible PHPForge or workflow failure labels: - ci body: - type: markdown attributes: value: | - Use this form when CI fails unexpectedly and can be reproduced. + Use this form when a CI workflow or PHPForge check fails unexpectedly. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + - type: input id: workflow attributes: - label: Workflow/job name - placeholder: security-standards / phpforge + label: Workflow and job + placeholder: e.g. CI / PHP 8.4 validations: required: true + - type: input id: run_url attributes: label: Failing run URL + description: Provide a link when the run is accessible. placeholder: https://github.com/OWNER/REPOSITORY/actions/runs/... - validations: - required: true + - type: textarea - id: command + id: failing_step attributes: - label: Failing command - description: Exact command or step that failed. + label: Failing step or command + description: Include the exact workflow step or command that failed. placeholder: composer ic:ci + render: shell validations: required: true + - type: textarea id: logs attributes: - label: Error output - description: Paste the relevant error section. + label: Relevant error output + description: Paste the smallest useful, sanitized error section. render: shell validations: required: true - - type: textarea - id: local_check + + - type: dropdown + id: local_result attributes: label: Local reproduction - description: Can you reproduce locally? If yes, include steps. - placeholder: Yes/No + details + description: Does the same failure occur when running the relevant command locally? + options: + - Yes + - No + - Not attempted + validations: + required: true + + - type: textarea + id: local_details + attributes: + label: Local reproduction details + description: Include the command, result and any differences from CI. + placeholder: composer ic:ci fails locally with the same error... + + - type: input + id: php_version + attributes: + label: PHP version + placeholder: e.g. 8.4.13 + validations: + required: true + + - type: input + id: composer_version + attributes: + label: Composer version + placeholder: e.g. 2.9.2 validations: required: true + + - type: textarea + id: environment + attributes: + label: Runner and dependency environment + description: Include the runner OS, dependency mode, relevant extensions, matrix values and PHPForge version when known. + placeholder: ubuntu-latest, prefer-lowest, PHPForge 1.x, ext-json enabled... + validations: + required: true + + - type: textarea + id: recent_changes + attributes: + label: Relevant recent changes + description: Mention dependency, configuration, workflow or source changes that may be related. + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add related issues, screenshots, logs or other useful context. diff --git a/.github/ISSUE_TEMPLATE/docs_improvement.yml b/.github/ISSUE_TEMPLATE/docs_improvement.yml index 80b9607..2ea49e9 100644 --- a/.github/ISSUE_TEMPLATE/docs_improvement.yml +++ b/.github/ISSUE_TEMPLATE/docs_improvement.yml @@ -1,34 +1,58 @@ -name: Docs improvement -description: Report missing, unclear, or incorrect documentation -title: "[Docs]: " +name: Documentation improvement +description: Report missing, outdated, unclear or incorrect documentation labels: - documentation body: - - type: textarea + - type: dropdown + id: problem_type + attributes: + label: Documentation problem + options: + - Incorrect + - Outdated + - Missing + - Unclear + - Example needed + - Other + validations: + required: true + + - type: input id: location attributes: label: Documentation location - description: File path or URL. - placeholder: README.md section "Quick Start" + description: Provide the file path, section, symbol or URL. + placeholder: README.md — Quick Start validations: required: true + - type: textarea - id: issue + id: problem attributes: - label: What is unclear or incorrect? - placeholder: This section says... + label: Problem + description: Explain what is missing, unclear, outdated or incorrect. + placeholder: The current documentation says or omits... validations: required: true + - type: textarea - id: suggestion + id: expected attributes: - label: Suggested improvement - description: Propose revised wording, structure, or examples. - placeholder: It would be clearer if... + label: Expected documentation + description: Describe what readers should be able to understand or accomplish. + placeholder: Readers should be able to... validations: required: true + + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Optionally propose wording, structure, examples or references. + placeholder: It may be clearer to... + - type: textarea id: additional attributes: label: Additional context - description: Related links, screenshots, or prior discussions. + description: Add related links, screenshots, discussions or examples. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index cc29614..bbee6d0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,44 +1,54 @@ name: Feature request -description: Suggest an improvement or new capability -title: "[Feature]: " +description: Suggest a new capability or improvement labels: - enhancement body: - type: markdown attributes: value: | - Thanks for the idea. Please describe the use case first, then the proposed solution. + Describe the problem or use case before proposing an implementation. + + For substantial public API, architectural or compatibility changes, discussion may be requested before implementation. + - type: textarea id: problem attributes: label: Problem or use case - description: What limitation are you hitting? - placeholder: I need to... + description: Explain the limitation, repeated difficulty or capability you need. + placeholder: I need to... because... validations: required: true + - type: textarea - id: proposal + id: proposed_behavior attributes: - label: Proposed solution - description: What should happen? - placeholder: Add a command/config/workflow option that... + label: Proposed behavior + description: Describe the expected user-facing behavior or outcome. + placeholder: The library should... validations: required: true + + - type: textarea + id: example + attributes: + label: Example usage + description: Optionally show the proposed API, configuration, command or workflow. + render: php + - type: textarea id: alternatives attributes: - label: Alternatives considered - description: Any workaround or alternative approach you evaluated. + label: Alternatives or workarounds + description: Describe existing approaches you considered or currently use. + - type: textarea - id: impact + id: compatibility attributes: - label: Expected impact - description: Who benefits and what changes for users/CI? - placeholder: This would improve... - validations: - required: true + label: Compatibility considerations + description: Mention possible public API, behavior, PHP-version, extension, platform or dependency implications. + - type: textarea id: additional attributes: label: Additional context - description: Related issues, links, examples, or prior art. + description: Add related issues, prior art, links, benchmarks or other supporting information. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 2ca776f..62e91a7 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,40 +1,56 @@ name: Question -description: Ask a usage or integration question -title: "[Question]: " +description: Ask about usage, behavior, integration or configuration labels: - question body: - type: markdown attributes: value: | - Use this form for usage questions. For confirmed defects, use the bug report form. + Use this form for usage and integration questions. Use the bug form for reproducible incorrect behavior. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + - type: textarea - id: context + id: goal attributes: label: What are you trying to do? - description: Describe your goal and expected outcome. + description: Describe the goal and expected outcome. placeholder: I want to... validations: required: true + - type: textarea id: attempted attributes: label: What have you tried? - description: Include commands, config snippets, or links you already checked. + description: Include relevant code, commands, configuration, documentation or approaches already checked. placeholder: I tried... validations: required: true + + - type: textarea + id: relevant_code + attributes: + label: Relevant code or configuration + description: Include a minimal sanitized example when applicable. + render: php + - type: textarea id: output attributes: - label: Current output or behavior - description: Include relevant command output, logs, or errors. + label: Relevant output or errors + description: Include sanitized output only when it helps explain the question. render: shell + - type: textarea id: environment attributes: - label: Environment details - description: PHP version, Composer version, OS, CI provider (if relevant). - placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... - validations: - required: true + label: Environment + description: Include package, PHP, Composer, OS, extensions or CI details only when relevant. + placeholder: Package 2.4.1, PHP 8.4, Composer 2.9, Ubuntu 24.04... + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add related links, screenshots or prior discussions. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 59ae734..dcab1f5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,33 +1,121 @@ ## Summary -Describe what changed and why. +Describe what changed, why it was needed and the expected behavior. -## Related Issues + -Link issues with `Closes #...` or `Relates #...`. +## Change -## Type of Change +### Type -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor -- [ ] Documentation update -- [ ] CI or tooling update -- [ ] Other (describe in summary) +* [ ] Bug fix +* [ ] New feature +* [ ] Refactor +* [ ] Performance +* [ ] Security or reliability +* [ ] Documentation or examples +* [ ] Dependency, CI or tooling +* [ ] Other + +### Behavior and Compatibility + +* [ ] No observable behavior changed +* [ ] Existing behavior was corrected +* [ ] New behavior was introduced +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + ## Validation -List the commands you ran and their result. +* [ ] `composer ic:ci` + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Performance + + + +* [ ] Relevant benchmarks were added or updated +* [ ] Results were compared against a relevant baseline +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Implementation Notes + + + +## Review Focus -```bash -composer ic:tests -``` + ## Checklist -- [ ] I followed `CONTRIBUTING.md`. -- [ ] I added or updated tests for behavior changes. -- [ ] I updated docs/config/examples when needed. -- [ ] I confirmed no security-sensitive data is exposed. +* [ ] The change is focused and excludes unrelated modifications. +* [ ] Tests cover new, corrected and regression-prone behavior. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation, examples and type information were updated where required. +* [ ] Performance claims are supported by reproducible benchmarks. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_fix.md b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md new file mode 100644 index 0000000..66759b4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md @@ -0,0 +1,94 @@ +## Problem + +Describe the incorrect behavior, its impact and how it can be reproduced. + + + +## Root Cause + + + +## Fix + +Describe how the change corrects the problem and the expected behavior after the fix. + +## Behavior and Compatibility + +* [ ] Existing documented behavior was restored +* [ ] Existing undocumented behavior was corrected +* [ ] Public API remains compatible +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] The original failure no longer reproduces +* [ ] A regression test was added or updated +* [ ] Relevant boundary and failure paths were tested + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The fix is focused and excludes unrelated changes. +* [ ] The fix addresses the root cause rather than only masking symptoms. +* [ ] Regression-prone behavior is covered by tests. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and examples were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/documentation.md b/.github/PULL_REQUEST_TEMPLATE/documentation.md new file mode 100644 index 0000000..f983b85 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/documentation.md @@ -0,0 +1,51 @@ +## Documentation Change + +Describe what is being added, corrected, clarified or removed and why. + + + +## Affected Content + +* [ ] README or getting-started guidance +* [ ] API or reference documentation +* [ ] Configuration documentation +* [ ] Examples or tutorials +* [ ] Contribution or community documentation +* [ ] Changelog or release documentation +* [ ] Other + +## Verification + +* [ ] Links and references were checked +* [ ] Code examples were executed or otherwise verified +* [ ] Commands and configuration examples match current behavior +* [ ] Terminology is consistent with the project +* [ ] `composer ic:ci` +* [ ] No executable behavior changed + + + +## Review Focus + + + +## Checklist + +* [ ] The change is focused and excludes unrelated code changes. +* [ ] Documentation reflects the current public behavior. +* [ ] Examples are minimal, accurate and safe to copy. +* [ ] Sensitive or private information is not included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/feature.md b/.github/PULL_REQUEST_TEMPLATE/feature.md new file mode 100644 index 0000000..f6ce9c6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/feature.md @@ -0,0 +1,106 @@ +## Motivation + +Describe the problem, use case or capability this feature addresses. + + + +## Solution + +Describe the proposed behavior and how consumers are expected to use it. + +## API and Compatibility + +* [ ] No new public API +* [ ] New backward-compatible public API +* [ ] Existing public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Expected behavior is covered +* [ ] Boundary and edge cases are covered +* [ ] Failure and exception paths are covered +* [ ] Public API usage is covered + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Performance + + + +* [ ] Relevant benchmarks were added or updated +* [ ] Results were compared against a relevant baseline +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Review Focus + + + +## Checklist + +* [ ] The feature is focused and excludes unrelated changes. +* [ ] Tests cover the public contract and failure behavior. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation, examples and type information were updated. +* [ ] Performance claims are supported by reproducible benchmarks. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/maintenance.md b/.github/PULL_REQUEST_TEMPLATE/maintenance.md new file mode 100644 index 0000000..a4cab0a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/maintenance.md @@ -0,0 +1,91 @@ +## Maintenance Change + +Describe what changed, why it was needed and the expected effect on development, CI or releases. + + + +## Category + +* [ ] Dependency update +* [ ] CI or workflow change +* [ ] Build or release tooling +* [ ] PHPForge configuration +* [ ] Development tooling +* [ ] Repository maintenance +* [ ] Other + +## Impact and Compatibility + +* [ ] Runtime behavior is unaffected +* [ ] Development workflow changed +* [ ] CI or release behavior changed +* [ ] Supported PHP, extension, platform or dependency requirements changed +* [ ] Generated files or configuration changed +* [ ] Backward compatibility may be affected + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Relevant workflow or job was exercised +* [ ] Supported matrix or dependency mode was considered +* [ ] Generated or published files were verified +* [ ] Failure and rollback behavior was considered + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The change is focused and excludes unrelated source refactoring. +* [ ] Dependency or workflow changes are minimal and justified. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and generated files were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/performance.md b/.github/PULL_REQUEST_TEMPLATE/performance.md new file mode 100644 index 0000000..ce2d10d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/performance.md @@ -0,0 +1,99 @@ +## Bottleneck + +Describe the measured performance problem, affected execution path and practical impact. + + + +## Optimization + +Describe the change, why it improves the measured path and any trade-offs introduced. + +## Correctness and Compatibility + +* [ ] Observable behavior remains unchanged +* [ ] Public API remains compatible +* [ ] Error and exception behavior remains compatible +* [ ] Behavior or public API changed intentionally +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Benchmark Evidence + +* [ ] Relevant benchmarks were added or updated +* [ ] Results were compared against a relevant baseline +* [ ] Multiple stable runs were considered +* [ ] Runtime impact was measured +* [ ] Memory or allocation impact was measured where relevant +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Expected behavior remains covered +* [ ] Boundary and failure paths remain covered +* [ ] Performance-sensitive behavior is covered + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The optimization targets a measured bottleneck. +* [ ] Results are reproducible in comparable environments. +* [ ] Correctness was not traded for an unverified micro-optimization. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Benchmark and documentation changes are included where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/refactor.md b/.github/PULL_REQUEST_TEMPLATE/refactor.md new file mode 100644 index 0000000..588be4f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/refactor.md @@ -0,0 +1,108 @@ +## Intent and Scope + +Describe what was restructured, why it was necessary and what remains intentionally unchanged. + + + +## Behavioral Guarantee + +* [ ] No observable behavior changed +* [ ] Public API remains unchanged +* [ ] Existing behavior was intentionally corrected +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected + + + +## Design Notes + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Existing behavior remains covered +* [ ] Relevant regression and edge cases are covered +* [ ] Public API compatibility was verified + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Performance + + + +* [ ] Results were compared against a relevant baseline +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Review Focus + + + +## Checklist + +* [ ] The refactor is focused and excludes unrelated behavior changes. +* [ ] Complexity was reduced without unnecessary abstraction or file growth. +* [ ] Existing contracts and failure behavior remain covered. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and type information were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/security_reliability.md b/.github/PULL_REQUEST_TEMPLATE/security_reliability.md new file mode 100644 index 0000000..ef2ca28 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/security_reliability.md @@ -0,0 +1,92 @@ + + +## Concern + +Describe the security weakness, reliability failure mode or defensive gap being addressed. + + + +## Mitigation + +Describe how the change reduces the risk and what assumptions or limitations remain. + +## Impact and Compatibility + +* [ ] Security hardening with no observable behavior change +* [ ] Reliability improvement with no public API change +* [ ] Failure or exception behavior changed +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Security-sensitive or failure behavior is covered +* [ ] Abuse, malformed-input or failure paths are covered +* [ ] Regression coverage was added or updated +* [ ] `composer ic:test:security` + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] Confidential vulnerability details are not exposed publicly. +* [ ] The change is focused and avoids unrelated refactoring. +* [ ] Security or reliability claims are supported by tests. +* [ ] Failure paths and backward-compatibility implications were considered. +* [ ] Documentation and upgrade guidance were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `SECURITY.md`, `CONTRIBUTING.md` and the engineering principles. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9c2638f..eff64bb 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,49 +2,71 @@ ## Our Commitment -We are committed to making participation in this project a harassment-free -experience for everyone, regardless of age, body size, disability, ethnicity, -gender identity and expression, level of experience, nationality, personal -appearance, race, religion or sexual identity and orientation. +We are committed to providing a welcoming, inclusive and harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity or expression, level of experience, nationality, personal appearance, race, religion, sexual identity or sexual orientation. ## Expected Behavior -Examples of behavior that contributes to a positive environment: +Examples of behavior that contributes to a positive environment include: -- Be respectful and constructive. -- Assume good intent and ask clarifying questions. -- Give and receive feedback professionally. -- Focus on what is best for the community and project. +* Being respectful, constructive and considerate +* Giving and receiving feedback professionally +* Disagreeing with ideas without attacking individuals +* Respecting differing viewpoints and experiences +* Accepting responsibility, apologizing when appropriate and learning from mistakes +* Focusing on what is best for the project and its community ## Unacceptable Behavior Examples of unacceptable behavior include: -- Harassment, discrimination or personal attacks. -- Trolling, insulting or derogatory comments. -- Publishing private information without consent. -- Any conduct that is inappropriate in a professional setting. - -## Enforcement Responsibilities - -Project maintainers are responsible for clarifying and enforcing this code of -conduct. They may remove, edit or reject comments, commits, code, issues, and -other contributions that violate this policy. +* Harassment, discrimination, intimidation or personal attacks +* Trolling, insults, threats or derogatory comments +* Sexualized language, imagery or unwanted attention +* Repeated disruption of discussions or project activities +* Publishing private or identifying information without permission +* Retaliating against anyone who reports an incident or participates in an investigation +* Any conduct that would reasonably be considered inappropriate in a professional setting ## Scope This code of conduct applies in all project spaces, including: -- Issue trackers -- Pull requests -- Discussions and chat related to the project -- Any public or private communication where someone represents the project +* Issues, pull requests and code reviews +* Discussions and project-related chat +* Documentation, commits and other contributions +* Public or private communication where an individual represents the project or its community ## Reporting -To report unacceptable behavior, contact project maintainers privately. +Report unacceptable behavior privately to the project maintainers. + +Do not include sensitive incident details in a public issue, discussion or pull request. When no private contact method is available, open a public issue requesting a private communication channel without describing the incident. + +Reports should include, when available: + +* A description of what occurred +* Relevant links, screenshots or other supporting information +* The approximate date and location of the incident +* Any immediate safety or confidentiality concerns + +All reports will be reviewed as confidentially and impartially as reasonably possible. Information will be shared only when necessary to investigate and respond to the report. + +## Enforcement Responsibilities + +Project maintainers are responsible for interpreting and enforcing this code of conduct. + +Maintainers may remove, edit or reject comments, commits, code, issues, pull requests and other contributions that violate this policy. Maintainers who have a conflict of interest regarding a report should not participate in its review. ## Enforcement -Maintainers may take any action they deem appropriate, including warnings, -temporary bans or permanent bans from community participation. +Actions will be based on the severity, frequency and context of the behavior and may include: + +* A private warning +* Removal or editing of inappropriate content +* Temporary restrictions on project participation +* Permanent removal from project spaces +* Reporting serious threats or unlawful conduct to the relevant platform or authorities + +Enforcement decisions should be proportionate, documented privately and applied consistently. + +Retaliation against reporters, witnesses or participants in an investigation is prohibited. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9950065..ad81ec1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,18 @@ # Contributing -Thanks for contributing. +Thanks for contributing to this project. ## Before You Start -- Review the project code of conduct. -- For security issues, use private reporting and avoid opening a public issue. -- Check existing issues and pull requests first to avoid duplicates. +* Review `CODE_OF_CONDUCT.md`. +* Report security vulnerabilities privately according to `SECURITY.md`. +* Search existing issues and pull requests to avoid duplicate work. +* An issue is not required for small fixes or improvements discovered during development. +* Discuss substantial API, architectural or compatibility changes before implementation. ## Local Setup -Requirements: - -- See `README.md` for current PHP and Composer requirements. +Review `README.md` and `composer.json` for supported PHP versions, extensions, dependencies and project-specific requirements. Install dependencies: @@ -20,49 +20,180 @@ Install dependencies: composer install ``` +Inspect the detected PHPForge configuration: + +```bash +composer ic:doctor +``` + +Do not modify files inside `vendor/`. + +## Engineering Standards + +Before changing or reviewing code, read and follow: + +```text +vendor/infocyph/phpforge/resources/engineering-principles.md +``` + +These principles apply equally to human contributors and automated coding agents. They define the expected approach to implementation decisions, scope control, architecture, performance, security, compatibility, testing and maintainability. + +Project-specific requirements may extend these principles but should not silently weaken them. + ## Development Workflow -Typical contributor workflow: +1. Create a branch from the repository’s default branch. +2. Make one focused logical change. +3. Add or update tests for changed behavior. +4. Run relevant focused checks during development. +5. Apply automated processing where appropriate. +6. Review every automatically modified file. +7. Run the complete CI suite before opening a pull request. +8. Add reproducible benchmark evidence for performance-related changes. +9. Complete the pull request template accurately. + +## Automated Processing + +Run all configured processors: + +```bash +composer ic:process +``` + +Run an individual processor when only a targeted change is needed: + +```bash +composer ic:process:refactor +composer ic:process:lint +composer ic:process:sniff +``` + +Automated processing may modify source files and `composer.json`. Review all resulting changes before committing. -1. Create a branch from `main`. -2. Make focused changes. -3. Run quality checks locally. -4. Open a pull request with context and verification notes. +## Validation -Recommended checks: +Run the complete project validation suite before opening a pull request: ```bash -composer ic:tests +composer ic:ci ``` -Useful targeted commands: +When `composer ic:ci` passes, running the same checks individually is unnecessary. + +Use focused commands while developing or when the complete suite cannot run: + +
+Focused validation commands ```bash composer ic:test:syntax composer ic:test:code composer ic:test:lint composer ic:test:sniff +composer ic:test:duplicates +composer ic:test:probe +composer ic:test:comments +composer ic:test:architecture composer ic:test:static composer ic:test:security -composer ic:test:architecture +composer ic:test:refactor +``` + +
+ +When `composer ic:ci` cannot complete, document: + +* Why it could not complete +* Which focused checks passed +* Relevant PHP, dependency, extension or platform limitations +* Any remaining validation risk + +Do not suppress, baseline, exclude or weaken a check merely to make validation pass. Any configuration or baseline change must be intentional and explained in the pull request. + +## Tests + +Test observable behavior and public contracts rather than internal implementation details. + +Include relevant coverage for: + +* New or corrected behavior +* Regression scenarios +* Boundary and edge cases +* Failure and exception paths +* Public API compatibility +* PHP-version, dependency, extension or platform-sensitive behavior + +A bug fix should normally include a regression test that fails without the fix. + +## Performance Changes + +Run benchmarks when performance is affected or claimed: + +```bash +composer ic:benchmark ``` -Auto-fix and processing helpers: +Additional benchmark commands: ```bash -composer ic:process +composer ic:bench:quick +composer ic:bench:chart +``` + +Performance claims must include reproducible before-and-after results from comparable environments. Avoid conclusions based on a single unstable run. + +Add or update benchmark coverage when existing benchmarks do not represent the changed execution path. + +## Configuration + +Inspect the active PHPForge configuration sources: + +```bash +composer ic:list-config +composer ic:list-config --json +``` + +Publish a configuration file only when the project requires rules that differ from PHPForge defaults: + +```bash +composer ic:publish-config ``` +When changing quality configuration: + +* Explain why the current rule is unsuitable +* Keep exclusions narrow +* Avoid weakening checks globally for one change +* Document compatibility or baseline implications + ## Pull Request Guidelines -- Keep pull requests scoped to one logical change. -- Include why the change is needed and what behavior changed. -- Add or update tests when behavior changes. -- Update docs when command behavior, config, or workflow behavior changes. -- Ensure CI is green before requesting review. +* Keep each pull request limited to one logical change. +* Explain what changed, why it was needed and the expected behavior. +* Identify public API, backward-compatibility, PHP, extension, platform or dependency impacts. +* Select only validation and benchmark checkboxes that reflect work actually performed. +* Add or update tests for behavior changes. +* Update documentation, examples, types and configuration where required. +* Exclude unrelated formatting, refactoring, dependency or generated-file changes. +* Ensure CI passes before requesting review. +* Address review feedback through focused follow-up changes. + +Draft pull requests are welcome for incomplete work or early design feedback, but validation claims and checklist items must remain accurate. ## Reporting Bugs and Requesting Features -- Use issue templates for bugs, regressions, CI failures, documentation updates, questions, and feature requests. -- Include reproducible steps, expected behavior, and actual behavior. -- Share environment details (PHP version, OS, Composer version). +Use the relevant issue template for bugs, regressions, CI failures, documentation problems, questions and feature requests. + +Include when relevant: + +* A clear description of the problem or proposed behavior +* A minimal reproduction +* Expected and actual behavior +* Package and dependency versions +* PHP and Composer versions +* Operating system and relevant extensions +* Logs or error output with sensitive information removed + +Small, self-contained fixes may be submitted directly as pull requests. Larger behavioral, architectural or compatibility changes should be discussed first. + +Security vulnerabilities must not be reported through public issues, discussions or pull requests. diff --git a/README.md b/README.md index 544ca7b..e2810f3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ All-in-one unique ID toolkit for PHP. - UUID (`v1`, `v3`, `v4`, `v5`, `v6`, `v7`, `v8`) - ULID (monotonic and random modes) - Snowflake, Sonyflake, Randflake, TBSL -- NanoID, CUID2, KSUID, XID +- TypeID, ObjectID, NanoID, RandomId, CUID2, KSUID, XID - Opaque and deterministic IDs - Value objects and comparator utilities - Binary conversion and base encoders (`16`, `32`, `36`, `58`, `62`) @@ -24,7 +24,7 @@ All-in-one unique ID toolkit for PHP. ## Requirements - PHP `>=8.2` -- `ext-bcmath` +- A 64-bit PHP runtime ## Installation @@ -32,7 +32,7 @@ All-in-one unique ID toolkit for PHP. composer require infocyph/uid ``` -Global helper functions are autoloaded via `src/functions.php`. +Namespaced generator functions are autoloaded via `src/functions.php`. ## Quick Usage @@ -90,17 +90,36 @@ The shared byte-level encoder is available as ## Security -Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated quality and security gate for PHP projects. +Do not disclose suspected vulnerabilities in a public issue, discussion or pull request. Review the +[security policy](SECURITY.md), then use [GitHub private vulnerability reporting](https://github.com/infocyph/UID/security/advisories/new) +to contact the maintainers confidentially. + +UID is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security gate covering +tests, static and taint analysis, dependency auditing, architecture checks, and release readiness. Automated controls reduce +risk but do not replace responsible disclosure or manual review. ---
Made with ❤️ for the PHP community
MIT Licensed
- Documentation • + DocumentationSecurityCode of Conduct • - Contributing • - Report Bug • - Request Feature + Contributing
+ 🗂️ + Bug • + Feature • + Documentation • + Question • + CI failure
+ 🔀 + General • + Bug fix • + Feature • + Refactor • + Performance • + Security & reliability • + Documentation • + Maintenance
diff --git a/SECURITY.md b/SECURITY.md index 37a355e..ca14478 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,48 +2,51 @@ ## Supported Versions -The project currently supports security updates for the latest release. +Security updates are provided for the latest stable release. + +Reports affecting older versions are welcome, but fixes may be released only for the latest version. Users should upgrade before confirming whether an issue remains present. ## Reporting a Vulnerability -Please report vulnerabilities privately. +Please report suspected vulnerabilities privately. + +1. Go to `Security` → `Advisories` → `Report a vulnerability`. +2. If private vulnerability reporting is unavailable, open a public issue requesting a private security contact. +3. Do not include vulnerability details in that issue or disclose them through public issues, discussions, pull requests or other public channels. + +Include when available: -1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`). -2. If private reporting is unavailable, contact maintainers through a private channel. -3. Do not open a public issue for security vulnerabilities. +* Affected package version and component +* PHP version and runtime environment +* Relevant extensions or dependencies +* Reproduction steps or a minimal proof of concept +* Exploitation requirements and potential impact +* Known workarounds or suggested remediation -Please include: +## Response and Disclosure -- Affected package version(s) -- PHP version and runtime environment -- Reproduction steps or proof of concept -- Impact assessment (confidentiality/integrity/availability) -- Any known workaround +The maintainers will make a best-effort attempt to: -## Response Process +* Acknowledge the report within five business days +* Validate the report and assess its severity +* Coordinate remediation and responsible disclosure +* Publish a fix, mitigation or security advisory when appropriate -- Initial acknowledgment: best effort, typically within a few days -- Triage: best effort, based on maintainer availability -- Fix and release timeline depends on severity and exploitability +Resolution timelines depend on severity, exploitability, complexity and maintainer availability. These targets are not a service-level agreement. -If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise. +Please coordinate public disclosure with the maintainers so affected users have a reasonable opportunity to update or apply mitigations. -## Protected by PHPForge +Confirmed reporters will receive credit unless they request anonymity. -This project is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security tooling layer for Infocyph PHP projects. +## PHPForge Security Controls -PHPForge helps keep the project reliable by running checks for: +This project uses [PHPForge](https://github.com/infocyph/PHPForge) to automate security and quality checks, including: -- Code style and standards -- Tests and syntax validation -- Static analysis and type safety -- Security and taint analysis -- Dependency vulnerability audit -- Architecture boundary validation -- Duplicate-code detection -- API snapshot and comment-policy checks -- Refactor safety checks -- Benchmark and release-readiness checks -- Git hooks and CI workflow protection +* Test and syntax validation +* Static and taint analysis +* Dependency vulnerability auditing +* Architecture validation +* Release-readiness checks +* Git hooks and CI enforcement -These automated gates strengthen code quality, reduce security risk and help prevent regressions before merge or release. +These controls help reduce security risk and prevent regressions, but they do not guarantee the absence of vulnerabilities or replace manual review and responsible reporting. diff --git a/benchmarks/BaseCodecBench.php b/benchmarks/BaseCodecBench.php new file mode 100644 index 0000000..d1135f3 --- /dev/null +++ b/benchmarks/BaseCodecBench.php @@ -0,0 +1,82 @@ + */ + private array $samples = []; + + public function __construct() + { + require_once __DIR__ . '/BenchBootstrap.php'; + BenchBootstrap::load(); + foreach ([8, 10, 12, 16, 20, 32] as $length) { + $this->samples[$length] = random_bytes($length); + } + } + + #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + public function benchBase16(array $params): void + { + BaseEncoder::encodeBytes($this->sample($params), 16); + } + + #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + public function benchBase32(array $params): void + { + BaseEncoder::encodeBytes($this->sample($params), 32); + } + + #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + public function benchBase36(array $params): void + { + BaseEncoder::encodeBytes($this->sample($params), 36); + } + + #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + public function benchBase58(array $params): void + { + BaseEncoder::encodeBytes($this->sample($params), 58); + } + + #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + public function benchBase62(array $params): void + { + BaseEncoder::encodeBytes($this->sample($params), 62); + } + + #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + public function benchDecimal(array $params): void + { + BaseEncoder::encodeBytes($this->sample($params), 10); + } + + /** + * @return array + */ + public function provideLengths(): array + { + return [ + '8-bytes' => ['length' => 8], + '10-bytes' => ['length' => 10], + '12-bytes' => ['length' => 12], + '16-bytes' => ['length' => 16], + '20-bytes' => ['length' => 20], + '32-bytes' => ['length' => 32], + ]; + } + + /** + * @param array{length:int} $params + */ + private function sample(array $params): string + { + return $this->samples[$params['length']]; + } +} diff --git a/benchmarks/ContentionMatrix.php b/benchmarks/ContentionMatrix.php new file mode 100644 index 0000000..b6fe2dc --- /dev/null +++ b/benchmarks/ContentionMatrix.php @@ -0,0 +1,167 @@ + $processCounts + * @param list $reservationSizes + */ + public static function run( + array $processCounts = [1, 2, 4, 8, 16], + array $reservationSizes = [1, 4, 8, 16, 32, 64], + int $idsPerProcess = 200, + ): void { + self::assertPcntl(); + fwrite(STDOUT, "processes,reservation,ids_per_second,median_us,p95_us,p99_us,duplicates,lock_errors,cpu_ms\n"); + + foreach ($processCounts as $processes) { + foreach ($reservationSizes as $reservationSize) { + fwrite(STDOUT, implode(',', self::measure($processes, $reservationSize, $idsPerProcess)) . "\n"); + } + } + } + + private static function assertPcntl(): void + { + if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { + throw new \RuntimeException('The pcntl extension is required for contention benchmarks'); + } + } + + /** + * @param array $children + * @return array{list,list,int,int} + */ + private static function collect(array $children): array + { + $ids = []; + $latencies = []; + $errors = 0; + $cpuMicros = 0; + + foreach ($children as $pid => $output) { + pcntl_waitpid($pid, $status); + $json = file_get_contents($output); + if (!is_string($json)) { + ++$errors; + + continue; + } + + $result = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $ids = [...$ids, ...$result['ids']]; + $latencies = [...$latencies, ...$result['latencies']]; + $errors += $result['errors']; + $cpuMicros += $result['cpu_micros']; + } + + return [$ids, $latencies, $errors, $cpuMicros]; + } + + private static function cpuMicros(array $usage): int + { + return (($usage['ru_utime.tv_sec'] + $usage['ru_stime.tv_sec']) * 1_000_000) + + $usage['ru_utime.tv_usec'] + + $usage['ru_stime.tv_usec']; + } + + /** + * @return array{int,int,string,string,string,string,int,int,string} + */ + private static function measure(int $processes, int $reservationSize, int $idsPerProcess): array + { + $directory = sys_get_temp_dir() . '/uid-contention-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $children = []; + $started = hrtime(true); + + try { + for ($process = 0; $process < $processes; ++$process) { + $output = $directory . '/child-' . $process . '.json'; + $pid = pcntl_fork(); + if ($pid === -1) { + throw new \RuntimeException('Unable to fork benchmark process'); + } + + if ($pid === 0) { + self::runChild($directory, $output, $reservationSize, $idsPerProcess); + } + + $children[$pid] = $output; + } + + [$ids, $latencies, $errors, $cpuMicros] = self::collect($children); + $elapsedSeconds = (hrtime(true) - $started) / 1_000_000_000; + sort($latencies, SORT_NUMERIC); + + return [ + $processes, + $reservationSize, + number_format(count($ids) / $elapsedSeconds, 0, '.', ''), + number_format(self::percentile($latencies, 0.50), 2, '.', ''), + number_format(self::percentile($latencies, 0.95), 2, '.', ''), + number_format(self::percentile($latencies, 0.99), 2, '.', ''), + count($ids) - count(array_unique($ids)), + $errors, + number_format($cpuMicros / 1000, 2, '.', ''), + ]; + } finally { + foreach (glob($directory . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($directory); + } + } + + private static function percentile(array $sorted, float $percentile): float + { + if ($sorted === []) { + return 0.0; + } + + $index = (int) ceil(count($sorted) * $percentile) - 1; + + return $sorted[max(0, $index)]; + } + + private static function runChild( + string $directory, + string $output, + int $reservationSize, + int $idsPerProcess, + ): never { + $provider = new FilesystemSequenceProvider($directory, 'matrix', reservationSize: $reservationSize); + $usageBefore = getrusage(); + $ids = []; + $latencies = []; + $errors = 0; + + for ($index = 0; $index < $idsPerProcess; ++$index) { + $started = hrtime(true); + + try { + $ids[] = $provider->next('sequence', 1, 123456789); + } catch (\Throwable) { + ++$errors; + } + $latencies[] = (hrtime(true) - $started) / 1000; + } + + $usageAfter = getrusage(); + file_put_contents($output, json_encode([ + 'ids' => $ids, + 'latencies' => $latencies, + 'errors' => $errors, + 'cpu_micros' => self::cpuMicros($usageAfter) - self::cpuMicros($usageBefore), + ], JSON_THROW_ON_ERROR)); + pcntl_exec(PHP_BINARY, ['-r', '']); + + throw new \RuntimeException('Unable to terminate benchmark child'); + } +} diff --git a/benchmarks/HotspotBench.php b/benchmarks/HotspotBench.php index deba1f8..f50f9f1 100644 --- a/benchmarks/HotspotBench.php +++ b/benchmarks/HotspotBench.php @@ -4,253 +4,171 @@ namespace Infocyph\UID\Benchmarks; -use DateTimeImmutable; +use Infocyph\UID\Configuration\RandflakeConfig; +use Infocyph\UID\Configuration\SnowflakeConfig; +use Infocyph\UID\Configuration\SonyflakeConfig; +use Infocyph\UID\Configuration\TBSLConfig; use Infocyph\UID\CUID2; use Infocyph\UID\DeterministicId; use Infocyph\UID\KSUID; use Infocyph\UID\NanoID; +use Infocyph\UID\ObjectID; use Infocyph\UID\OpaqueId; use Infocyph\UID\Randflake; +use Infocyph\UID\RandomId; +use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Snowflake; use Infocyph\UID\Sonyflake; use Infocyph\UID\TBSL; +use Infocyph\UID\TypeID; use Infocyph\UID\ULID; use Infocyph\UID\UUID; use Infocyph\UID\XID; -use InvalidArgumentException; use PhpBench\Attributes as Bench; final class HotspotBench { - private string $cuid2; + private string $opaque; - private string $ksuid; + private RandflakeConfig $randflakeConfig; - private int $leaseEnd; + private SnowflakeConfig $snowflakeConfig; - private int $leaseStart; + private SonyflakeConfig $sonyflakeConfig; - private string $nanoid; - - private string $randflake; - - private string $randflakeSecret; - - private string $snowflake; - - private string $sonyflake; - - private string $tbsl; - - private string $ulid; - - private string $uuid; - - private string $xid; + private TBSLConfig $tbslConfig; public function __construct() { require_once __DIR__ . '/BenchBootstrap.php'; BenchBootstrap::load(); - $this->prepareRandflakeContext(); - $this->uuid = UUID::v7(); - $this->ulid = ULID::generate(); - $this->snowflake = Snowflake::generate(); - $this->sonyflake = Sonyflake::generate(); - $this->tbsl = TBSL::generate(); - $this->ksuid = KSUID::generate(); - $this->xid = XID::generate(); - $this->nanoid = NanoID::generate(); - $this->cuid2 = CUID2::generate(); - $this->randflake = Randflake::generate(42, $this->leaseStart, $this->leaseEnd, $this->randflakeSecret); + $provider = new FilesystemSequenceProvider(namespace: 'phpbench'); + [$leaseStart, $leaseEnd, $secret] = BenchBootstrap::randflakeContext(); + $this->snowflakeConfig = new SnowflakeConfig(sequenceProvider: $provider); + $this->sonyflakeConfig = new SonyflakeConfig(sequenceProvider: $provider); + $this->randflakeConfig = new RandflakeConfig(1, $leaseStart, $leaseEnd, $secret, $provider); + $this->tbslConfig = new TBSLConfig(sequenceProvider: $provider); + $this->opaque = OpaqueId::fromInt(123456, 'bench'); } - #[Bench\Revs(1000)] - #[Bench\Iterations(5)] - #[Bench\ParamProviders('provideGenerationSubjects')] - public function benchGeneration(array $params): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchCuid2(): void { - $this->runSubjectBench('generation', $params); + CUID2::generate(); } - #[Bench\Revs(1000)] - #[Bench\Iterations(5)] - #[Bench\ParamProviders('provideParseSubjects')] - public function benchParse(array $params): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchDeterministicId(): void { - $this->runSubjectBench('parse', $params); + DeterministicId::fromPayload('payload', 24, 'bench'); } - #[Bench\Revs(1000)] - #[Bench\Iterations(5)] - public function benchUlidMonotonicBurstSameMs(): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchKsuid(): void { - $fixed = DateTimeImmutable::createFromFormat('U.u', '1700000000.123000'); - ULID::generate($fixed); + KSUID::generate(); } - #[Bench\Revs(1000)] - #[Bench\Iterations(5)] - #[Bench\ParamProviders('provideValidationSubjects')] - public function benchValidation(array $params): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchNanoId(): void { - $this->runSubjectBench('validation', $params); + NanoID::generate(); } - /** - * @return array - */ - public function provideGenerationSubjects(): array + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchObjectId(): void { - return [ - 'cuid2' => ['subject' => 'cuid2'], - 'deterministic' => ['subject' => 'deterministic'], - 'ksuid' => ['subject' => 'ksuid'], - 'nanoid' => ['subject' => 'nanoid'], - 'opaque' => ['subject' => 'opaque'], - 'randflake' => ['subject' => 'randflake'], - 'snowflake' => ['subject' => 'snowflake'], - 'sonyflake' => ['subject' => 'sonyflake'], - 'tbsl' => ['subject' => 'tbsl'], - 'ulid' => ['subject' => 'ulid'], - 'uuid_v7' => ['subject' => 'uuid_v7'], - 'xid' => ['subject' => 'xid'], - ]; + ObjectID::generate(); } - /** - * @return array - */ - public function provideParseSubjects(): array + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchOpaqueIdDecode(): void { - return [ - 'ksuid' => ['subject' => 'ksuid'], - 'randflake_inspect' => ['subject' => 'randflake_inspect'], - 'randflake_parse' => ['subject' => 'randflake_parse'], - 'snowflake' => ['subject' => 'snowflake'], - 'sonyflake' => ['subject' => 'sonyflake'], - 'tbsl' => ['subject' => 'tbsl'], - 'ulid_get_time' => ['subject' => 'ulid_get_time'], - 'uuid' => ['subject' => 'uuid'], - 'xid' => ['subject' => 'xid'], - ]; + OpaqueId::toInt($this->opaque, 'bench'); } - /** - * @return array - */ - public function provideValidationSubjects(): array + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchOpaqueIdEncode(): void { - return [ - 'cuid2' => ['subject' => 'cuid2'], - 'nanoid' => ['subject' => 'nanoid'], - 'randflake' => ['subject' => 'randflake'], - 'snowflake' => ['subject' => 'snowflake'], - 'sonyflake' => ['subject' => 'sonyflake'], - 'tbsl' => ['subject' => 'tbsl'], - ]; + OpaqueId::fromInt(123456, 'bench'); } - private function prepareRandflakeContext(): void + #[Bench\Revs(250), Bench\Iterations(5)] + public function benchRandflakeFilesystem(): void { - [$this->leaseStart, $this->leaseEnd, $this->randflakeSecret] = BenchBootstrap::randflakeContext(); + Randflake::generateWithConfig($this->randflakeConfig); } - /** - * @param array{subject?: mixed} $params - */ - private function runSubjectBench(string $operation, array $params): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchRandomId62(): void { - $subject = $this->subject($params); - $runner = match ($operation) { - 'generation' => [ - 'cuid2' => fn() => CUID2::generate(), - 'deterministic' => fn() => DeterministicId::fromPayload('payload', 24, 'bench'), - 'ksuid' => fn() => KSUID::generate(), - 'nanoid' => fn() => NanoID::generate(), - 'opaque' => fn() => OpaqueId::random(12), - 'randflake' => fn() => Randflake::generate(42, $this->leaseStart, $this->leaseEnd, $this->randflakeSecret), - 'snowflake' => fn() => Snowflake::generate(), - 'sonyflake' => fn() => Sonyflake::generate(), - 'tbsl' => fn() => TBSL::generate(), - 'ulid' => fn() => ULID::generate(), - 'uuid_v7' => fn() => UUID::v7(), - 'xid' => fn() => XID::generate(), - ], - 'parse' => [ - 'ksuid' => fn() => KSUID::parse($this->ksuid), - 'randflake_inspect' => fn() => Randflake::inspect($this->randflake, $this->randflakeSecret), - 'randflake_parse' => fn() => Randflake::parse($this->randflake, $this->randflakeSecret), - 'snowflake' => fn() => Snowflake::parse($this->snowflake), - 'sonyflake' => fn() => Sonyflake::parse($this->sonyflake), - 'tbsl' => fn() => TBSL::parse($this->tbsl), - 'ulid_get_time' => fn() => ULID::getTime($this->ulid), - 'uuid' => fn() => UUID::parse($this->uuid), - 'xid' => fn() => XID::parse($this->xid), - ], - 'validation' => $this->validationRunners(), - default => throw new InvalidArgumentException("Unknown benchmark operation: $operation"), - }; - $handler = $runner[$subject] ?? throw new InvalidArgumentException("Unknown {$operation} subject: $subject"); - $handler(); + RandomId::generate(21, '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'); } - /** - * @param array{subject?: mixed} $params - */ - private function subject(array $params): string + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchRandomId64(): void { - $subject = $params['subject'] ?? null; - if (!is_string($subject) || $subject === '') { - throw new InvalidArgumentException('Benchmark subject is required.'); - } + RandomId::generate(21, '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); + } + + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchRandomIdDefault61(): void + { + RandomId::generate(); + } - return $subject; + #[Bench\Revs(250), Bench\Iterations(5)] + public function benchSnowflakeFilesystem(): void + { + Snowflake::generateWithConfig($this->snowflakeConfig); + } + + #[Bench\Revs(250), Bench\Iterations(5)] + public function benchSonyflakeFilesystem(): void + { + Sonyflake::generateWithConfig($this->sonyflakeConfig); } - private function validateCuid2(): void + #[Bench\Revs(250), Bench\Iterations(5)] + public function benchTbslFilesystem(): void { - CUID2::isValid($this->cuid2); + TBSL::generateWithConfig($this->tbslConfig); } - private function validateNanoid(): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchTypeId(): void { - NanoID::isValid($this->nanoid); + TypeID::generate('user'); } - private function validateRandflake(): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchUlidMonotonic(): void { - Randflake::isValid($this->randflake); + ULID::generateMonotonic(); } - private function validateSnowflake(): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchUlidRandom(): void { - Snowflake::isValid($this->snowflake); + ULID::generateRandom(); } - private function validateSonyflake(): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchUuidV4(): void { - Sonyflake::isValid($this->sonyflake); + UUID::v4(); } - private function validateTbsl(): void + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchUuidV7(): void { - TBSL::isValid($this->tbsl); + UUID::v7(); } - /** - * @return array - */ - private function validationRunners(): array + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchXid(): void { - return [ - 'cuid2' => fn() => $this->validateCuid2(), - 'nanoid' => fn() => $this->validateNanoid(), - 'randflake' => fn() => $this->validateRandflake(), - 'snowflake' => fn() => $this->validateSnowflake(), - 'sonyflake' => fn() => $this->validateSonyflake(), - 'tbsl' => fn() => $this->validateTbsl(), - ]; + XID::generate(); } } diff --git a/benchmarks/SequenceProviderBench.php b/benchmarks/SequenceProviderBench.php index 88dec9a..3aabcb7 100644 --- a/benchmarks/SequenceProviderBench.php +++ b/benchmarks/SequenceProviderBench.php @@ -4,75 +4,59 @@ namespace Infocyph\UID\Benchmarks; +use Infocyph\UID\Configuration\RandflakeConfig; +use Infocyph\UID\Configuration\SnowflakeConfig; use Infocyph\UID\Randflake; +use Infocyph\UID\Sequence\FilesystemSequenceProvider; +use Infocyph\UID\Sequence\InMemorySequenceProvider; use Infocyph\UID\Snowflake; -use InvalidArgumentException; use PhpBench\Attributes as Bench; final class SequenceProviderBench { - private int $leaseEnd; + private RandflakeConfig $randflakeFilesystem; - private int $leaseStart; + private RandflakeConfig $randflakeInMemory; - private string $secret; + private SnowflakeConfig $snowflakeFilesystem; + + private SnowflakeConfig $snowflakeInMemory; public function __construct() { require_once __DIR__ . '/BenchBootstrap.php'; BenchBootstrap::load(); - [$this->leaseStart, $this->leaseEnd, $this->secret] = BenchBootstrap::randflakeContext(); - - Snowflake::resetSequenceProvider(); - Randflake::resetSequenceProvider(); + [$leaseStart, $leaseEnd, $secret] = BenchBootstrap::randflakeContext(); + + $filesystem = new FilesystemSequenceProvider(namespace: 'phpbench-sequence'); + $memory = new InMemorySequenceProvider(); + $this->snowflakeFilesystem = new SnowflakeConfig(1, 1, sequenceProvider: $filesystem); + $this->snowflakeInMemory = new SnowflakeConfig(1, 1, sequenceProvider: $memory); + $this->randflakeFilesystem = new RandflakeConfig(1, $leaseStart, $leaseEnd, $secret, $filesystem); + $this->randflakeInMemory = new RandflakeConfig(1, $leaseStart, $leaseEnd, $secret, $memory); } - #[Bench\Revs(500)] - #[Bench\Iterations(5)] - #[Bench\ParamProviders('provideSequenceProviders')] - public function benchSequenceProvider(array $params): void + #[Bench\Revs(500), Bench\Iterations(5)] + public function benchRandflakeFilesystem(): void { - $subject = $params['subject'] ?? null; - if (!is_string($subject) || $subject === '') { - throw new InvalidArgumentException('Benchmark subject is required.'); - } - - switch ($subject) { - case 'snowflake_filesystem': - Snowflake::useFilesystemSequenceProvider(); - Snowflake::generate(1, 1); - - return; - case 'snowflake_in_memory': - Snowflake::useInMemorySequenceProvider(); - Snowflake::generate(1, 1); - - return; - case 'randflake_filesystem': - Randflake::useFilesystemSequenceProvider(); - Randflake::generate(1, $this->leaseStart, $this->leaseEnd, $this->secret); + Randflake::generateWithConfig($this->randflakeFilesystem); + } - return; - case 'randflake_in_memory': - Randflake::useInMemorySequenceProvider(); - Randflake::generate(1, $this->leaseStart, $this->leaseEnd, $this->secret); + #[Bench\Revs(500), Bench\Iterations(5)] + public function benchRandflakeInMemory(): void + { + Randflake::generateWithConfig($this->randflakeInMemory); + } - return; - default: - throw new InvalidArgumentException("Unknown sequence provider subject: $subject"); - } + #[Bench\Revs(500), Bench\Iterations(5)] + public function benchSnowflakeFilesystem(): void + { + Snowflake::generateWithConfig($this->snowflakeFilesystem); } - /** - * @return array - */ - public function provideSequenceProviders(): array + #[Bench\Revs(500), Bench\Iterations(5)] + public function benchSnowflakeInMemory(): void { - return [ - 'snowflake_filesystem' => ['subject' => 'snowflake_filesystem'], - 'snowflake_in_memory' => ['subject' => 'snowflake_in_memory'], - 'randflake_filesystem' => ['subject' => 'randflake_filesystem'], - 'randflake_in_memory' => ['subject' => 'randflake_in_memory'], - ]; + Snowflake::generateWithConfig($this->snowflakeInMemory); } } diff --git a/composer.json b/composer.json index 0eca7db..92e9d72 100644 --- a/composer.json +++ b/composer.json @@ -1,16 +1,23 @@ { "name": "infocyph/uid", - "description": "UUID (RFC 9562), ULID, Snowflake, Sonyflake, Randflake, and TBSL generator for PHP.", + "description": "High-performance ID toolkit for PHP with UUID, ULID, TypeID, ObjectID, coordinated IDs, and compact ID utilities.", "license": "MIT", "type": "library", "keywords": [ "uuid", + "uuidv7", + "typeid", + "objectid", "ulid", "snowflake", "sonyflake", "tbsl", "ksuid", "xid", + "nanoid", + "cuid2", + "random-id", + "sortable-id", "id", "key", "unique", @@ -24,15 +31,18 @@ } ], "require": { - "php": ">=8.2", - "ext-bcmath": "*", - "psr/simple-cache": "^3.0" + "php": "^8.2", + "php-64bit": "^8.2" }, "require-dev": { - "infocyph/phpforge": "dev-main" + "infocyph/phpforge": "dev-main@dev", + "psr/simple-cache": "^3.0" }, "replace": { - "abmmhasan/uuid": "*" + "abmmhasan/uuid": "self.version" + }, + "suggest": { + "psr/simple-cache": "Required for the optional PSR-16 sequence provider." }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/docs/benchmark-report.rst b/docs/benchmark-report.rst new file mode 100644 index 0000000..541ce19 --- /dev/null +++ b/docs/benchmark-report.rst @@ -0,0 +1,93 @@ +v5 Benchmark Report +=================== + +Recorded on 2026-08-15 with PHP 8.4.24, PHPBench 1.7.0, Xdebug disabled, +and OPcache disabled. Each result below is the mode of five iterations. +Generator methods use 1000 revolutions; filesystem methods use 250 or 500. + +========================== ========== +Subject Mode +========================== ========== +UUID v4 1.782 us +UUID v7 2.067 us +ULID random 3.726 us +ULID monotonic 2.203 us +TypeID 17.063 us +ObjectID 0.662 us +NanoID 0.777 us +CUID2 178.307 us +RandomId 4.358 us +KSUID 18.894 us +XID 10.094 us +Snowflake filesystem 15.606 us +Sonyflake filesystem 16.515 us +Randflake filesystem 25.242 us +TBSL filesystem 15.823 us +DeterministicId 2.831 us +OpaqueId encode/decode 2.337 / 1.683 us +========================== ========== + +Provider Isolation +------------------ + +========================== ========== +Subject Mode +========================== ========== +Snowflake filesystem 15.562 us +Snowflake in-memory 2.355 us +Randflake filesystem 24.493 us +Randflake in-memory 11.628 us +========================== ========== + +Native Codec, 16 Bytes +---------------------- + +The checked-in codec harness covers 8, 10, 12, 16, 20, and 32-byte inputs for +each base. The 16-byte slice is shown here for a compact release baseline. + +======== ========== +Base Mode +======== ========== +16 0.287 us +32 15.113 us +36 14.452 us +58 13.079 us +62 13.026 us +decimal 22.236 us +======== ========== + +The former generation harness constructed closures and dispatch arrays inside +the measured operation, and the former sequence harness constructed providers +inside it. Those measurements are not comparable algorithm baselines, so no +misleading percentage delta is reported. This report is the first isolated v5 +baseline; future releases should compare against it using the checked-in harness. + +Correctness gates took priority over throughput: the multi-process test suite +for Snowflake, Sonyflake, Randflake, TBSL, and sequence reservations produced +zero duplicates and zero lock errors. + +Filesystem Contention +--------------------- + +``ContentionMatrix`` ran 200 allocations per process for 1, 2, 4, 8, and 16 +processes and reservation sizes 1, 4, 8, 16, 32, and 64. All 30 cases produced +zero duplicates and zero lock errors. Representative endpoints are: + +========= =========== ======== ========== ======== ======== ====== +Processes Reservation IDs/sec Median us p95 us p99 us CPU ms +========= =========== ======== ========== ======== ======== ====== +1 1 3,697 10.23 16.40 23.21 2.48 +1 64 3,865 0.59 0.84 13.34 0.39 +2 1 8,916 16.97 27.32 53.38 7.81 +2 64 10,012 0.58 0.74 13.69 0.74 +4 1 19,639 10.65 25.57 53.91 10.53 +4 64 21,036 0.58 0.95 15.12 1.53 +8 1 22,890 20.14 124.93 249.60 34.35 +8 64 25,630 1.02 1.27 23.97 3.87 +16 1 23,094 17.48 152.58 1,688.23 66.50 +16 64 26,819 1.07 1.37 23.07 8.40 +========= =========== ======== ========== ======== ======== ====== + +These host-specific numbers are diagnostic rather than universal. Reservation +size 1 remains the correctness-first default; larger reservations trade unused +allocations on process exit for substantially less lock and CPU pressure. diff --git a/docs/compatibility.rst b/docs/compatibility.rst index 5978591..614790d 100644 --- a/docs/compatibility.rst +++ b/docs/compatibility.rst @@ -1,48 +1,66 @@ -Compatibility Matrix -==================== +Compatibility, Uniqueness, and Security +======================================= -UUID Support ------------- +Format Compatibility +-------------------- -- ``v1``, ``v3``, ``v4``, ``v5``: RFC 4122 / RFC 9562 compatible layouts. -- ``v6``, ``v7``: RFC 9562 time-ordered UUIDs. -- ``v8``: custom payload strategy inside UUID v8 envelope. -- ``guid()``: Microsoft-compatible GUID text formatting helper. +- UUID v1/v3/v4/v5 follows RFC 4122-compatible layouts; UUID v6/v7 follows RFC 9562. +- UUID v8 uses an application-defined payload. Generic parsing does not infer a timestamp. +- TypeID implements the TypeID v0.3 text format over 16 UUID bytes. +- ObjectID implements the BSON 12-byte ObjectID layout and canonical lowercase hexadecimal text. +- ULID uses canonical Crockford Base32 and supports random and process-local monotonic modes. +- Snowflake uses a 41/5/5/12 signed 64-bit layout. +- Sonyflake uses a 39/16/8 signed 64-bit layout with 10 millisecond timestamps. +- Randflake uses an unsigned 64-bit 30/17/17 layout before keyed permutation. +- TBSL is a project-specific 10-byte, uppercase hexadecimal format. +- KSUID and XID retain their standard fixed-length text and binary layouts. -Non-UUID Families +Uniqueness Matrix ----------------- -- ULID: Crockford Base32 ULID with monotonic and random modes. -- Snowflake: 64-bit Twitter-style ID (41/5/5/12). -- Sonyflake: 64-bit Sonyflake-style ID (39/16/8). -- Randflake: lease-bound encrypted 64-bit ID (30/17/17 before encryption). -- TBSL: project-specific time-based sortable hex identifier. -- NanoID and CUID2: URL-safe random IDs. -- KSUID and XID: sortable short ID families. - -Algorithm Interface -------------------- +===================== ============================================================== +Format Uniqueness model +===================== ============================================================== +UUID v4 Probabilistic cryptographic randomness +UUID v7 Randomness plus process-local monotonic generation +ULID Randomness plus optional process-local monotonic generation +TypeID Inherits the encoded UUID's uniqueness properties +ObjectID Timestamp, process-random value, and process-local counter +NanoID / RandomId Cryptographic randomness +CUID2 Hashed multi-source entropy +KSUID Timestamp plus random payload +XID Timestamp, machine data, PID, and counter +Snowflake Coordinated node plus sequence provider +Sonyflake Coordinated machine plus sequence provider +Randflake Coordinated node/sequence plus keyed permutation +TBSL sequenced Coordinated machine plus sequence provider +===================== ============================================================== -``NanoID``, ``CUID2``, ``KSUID``, and ``XID`` implement -``Infocyph\\UID\\Contracts\\IdAlgorithmInterface`` with: +Probabilistic formats are not mathematically guaranteed unique. The in-memory +sequence provider is process-local and is not safe for cross-process coordination. -- ``generate()`` -- ``isValid()`` -- ``parse()`` +Security Matrix +--------------- -Binary and Alternate Encodings ------------------------------- +===================== ============================================================== +Format Security properties +===================== ============================================================== +RandomId / NanoID Unpredictable while PHP's CSPRNG remains secure +CUID2 Opaque, hashed entropy format +UUID v4/v7 Identifiers, not authorization secrets +Snowflake/Sonyflake Structured values whose metadata may be inferred +TBSL Structured value whose timestamp is recoverable +ObjectID/XID/KSUID Timestamp metadata is recoverable +Randflake Obscures fields; it is not authenticated encryption +OpaqueId Reversible obfuscation only +DeterministicId Stable deterministic token; it does not provide secrecy +===================== ============================================================== -- UUID / ULID / TBSL: ``toBytes()`` / ``fromBytes()``. -- UUID / ULID / Snowflake / Sonyflake / TBSL: ``toBase()`` / ``fromBase()``. -- Randflake: ``toBytes()`` / ``fromBytes()`` and ``toBase()`` / ``fromBase()``. -- KSUID / XID: ``toBytes()`` / ``fromBytes()``. -- Shared byte-level encoder: ``Infocyph\\UID\\Support\\BaseEncoder``. -- Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. +Always enforce authorization independently of identifier format. Runtime Requirements -------------------- -- Minimum PHP: ``8.2`` -- Required extension: ``ext-bcmath`` -- Optional sequence backends: filesystem, in-memory, PSR-16 cache, callback. +- PHP 8.2 or newer on a 64-bit runtime. +- No BCMath dependency. +- PSR-16 is optional and needed only for the PSR simple-cache sequence provider. diff --git a/docs/conf.py b/docs/conf.py index 878090b..898d326 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,6 @@ "myst_parser", "sphinx.ext.todo", "sphinx.ext.autosectionlabel", - "sphinx.ext.intersphinx", "sphinx_copybutton", "sphinx_design", "sphinxcontrib.phpdomain", @@ -35,10 +34,6 @@ autosectionlabel_prefix_document = True todo_include_todos = True -intersphinx_mapping = { - "php": ("https://www.php.net/manual/en/", None), -} - html_theme = "sphinx_book_theme" html_theme_options = { "repository_url": "https://github.com/infocyph/UID", diff --git a/docs/db-storage.rst b/docs/db-storage.rst index e5b2ebd..6ae841d 100644 --- a/docs/db-storage.rst +++ b/docs/db-storage.rst @@ -1,44 +1,40 @@ Database Storage ================ -UID includes ``Infocyph\\UID\\DbStorage`` with recommendations for UUID, ULID, and Snowflake. - -UUID ----- - -- MySQL: prefer ``BINARY(16)`` for compact indexes. -- PostgreSQL: prefer native ``UUID`` type. -- Ordering: ``UUIDv7`` provides better insertion locality than ``UUIDv4``. - -ULID ----- - -- MySQL: ``CHAR(26)`` (readable) or ``BINARY(16)`` (compact/index-friendly). -- PostgreSQL: ``CHAR(26)`` or ``BYTEA`` depending on interoperability. -- Ordering: canonical ULID text is chronologically sortable. - -Snowflake and Sonyflake ------------------------ - -- MySQL: ``BIGINT UNSIGNED``. -- PostgreSQL: ``BIGINT`` if range is safe, otherwise ``NUMERIC(20,0)``. -- Ordering: numeric sort equals time sort. - -TBSL ----- - -- Use ``CHAR(20)`` for canonical uppercase hex. -- Use ``BINARY(10)`` when compactness matters. - -Programmatic Access -------------------- - -.. code-block:: php - - isSortable(); + $config = new SnowflakeConfig(customEpoch: 1_700_000_000_000); + $value = Id::snowflakeValue($config); diff --git a/docs/index.rst b/docs/index.rst index 69ea35a..d0a05e4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,12 +7,15 @@ It supports: - UUID (v1, v3, v4, v5, v6, v7, v8) - ULID +- TypeID +- ObjectID - Snowflake - Sonyflake - Randflake - TBSL - NanoID and CUID2 - KSUID and XID +- RandomId and NanoID - Opaque and deterministic IDs .. toctree:: @@ -29,6 +32,8 @@ It supports: uuid ulid + typeid + objectid snowflake sonyflake randflake @@ -45,6 +50,7 @@ It supports: helpers db-storage compatibility + benchmark-report framework-integration exceptions references diff --git a/docs/installation.rst b/docs/installation.rst index 010e06a..b9f99de 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -4,10 +4,13 @@ Installation Requirements ------------ -- PHP ``>=8.2`` -- ``ext-bcmath`` +- PHP 8.2 or newer +- A 64-bit PHP runtime - Composer +BCMath is not required. PSR-16 is optional and is used only when selecting the +simple-cache sequence provider. + Install ------- @@ -15,33 +18,5 @@ Install composer require infocyph/uid -Autoloaded Helpers ------------------- - -UID ships global helper functions via Composer autoload from ``src/functions.php``. - -If you prefer explicit static APIs only, call the namespaced classes directly: - -- ``Infocyph\\UID\\Id`` -- ``Infocyph\\UID\\UUID`` -- ``Infocyph\\UID\\ULID`` -- ``Infocyph\\UID\\Snowflake`` -- ``Infocyph\\UID\\Sonyflake`` -- ``Infocyph\\UID\\TBSL`` -- ``Infocyph\\UID\\NanoID`` -- ``Infocyph\\UID\\CUID2`` -- ``Infocyph\\UID\\KSUID`` -- ``Infocyph\\UID\\XID`` -- ``Infocyph\\UID\\OpaqueId`` -- ``Infocyph\\UID\\DeterministicId`` - -Read the Docs Build -------------------- - -This repository already includes: - -- ``docs/conf.py`` -- ``docs/requirements.txt`` -- ``.readthedocs.yaml`` - -So you can publish directly on Read the Docs without extra Sphinx bootstrapping. +The package autoloads namespaced generator functions from ``src/functions.php``. +Class APIs remain the preferred entry point for parsing, validation, and conversion. diff --git a/docs/objectid.rst b/docs/objectid.rst new file mode 100644 index 0000000..24265f9 --- /dev/null +++ b/docs/objectid.rst @@ -0,0 +1,19 @@ +ObjectID +======== + +``ObjectID`` implements the BSON ObjectID layout: a four-byte big-endian Unix +timestamp, five process-random bytes, and a three-byte counter. + +.. code-block:: php + + toString(); - $uuidVersion = $uuid->getVersion(); - $uuidTime = $uuid->getTimestamp(); - -IdComparator ------------- - -``Infocyph\\UID\\IdComparator`` provides generic comparison/sorting: - -- ``compare(IdValueInterface|string $left, IdValueInterface|string $right): int`` -- ``sort(array $ids): array`` - -When both values are digit-only, comparator uses unsigned-decimal ordering; -otherwise it falls back to lexical comparison. - -.. code-block:: php - - self::isValid($id, $length), - 'length' => strlen($id), - ]; + self::isValid($id, $length) || throw new InvalidArgumentException('Invalid CUID2 string'); + + return ['length' => strlen($id)]; + } + + private static function ensureProcessState(): void + { + $pid = (int) getmypid(); + if (self::$sourcePid === $pid) { + return; + } + + self::$sourcePid = $pid; + self::$counter = random_int(0, self::INITIAL_COUNTER_MAX); + self::$fingerprint = null; } /** @@ -84,7 +101,7 @@ private static function fingerprint(): string $host = gethostname(); $source = ($host === false ? '' : $host) . "\0" - . getmypid() + . (int) getmypid() . random_bytes(32); return self::$fingerprint = substr( diff --git a/src/Configuration/RandflakeConfig.php b/src/Configuration/RandflakeConfig.php index 0c02e3a..0cbf577 100644 --- a/src/Configuration/RandflakeConfig.php +++ b/src/Configuration/RandflakeConfig.php @@ -4,7 +4,6 @@ namespace Infocyph\UID\Configuration; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class RandflakeConfig @@ -15,6 +14,5 @@ public function __construct( public int $leaseEnd, public string $secret, public ?SequenceProviderInterface $sequenceProvider = null, - public IdOutputType $outputType = IdOutputType::STRING, ) {} } diff --git a/src/Configuration/ResolvesCustomEpoch.php b/src/Configuration/ResolvesCustomEpoch.php index e3b2184..1d457e9 100644 --- a/src/Configuration/ResolvesCustomEpoch.php +++ b/src/Configuration/ResolvesCustomEpoch.php @@ -13,7 +13,7 @@ public function resolveCustomEpochMs(): ?int return self::resolveEpochValue($this->customEpoch); } - private static function resolveEpochValue(DateTimeInterface|int|string|null $customEpoch): ?int + private static function resolveEpochValue(DateTimeInterface|int|null $customEpoch): ?int { if ($customEpoch === null) { return null; @@ -23,15 +23,6 @@ private static function resolveEpochValue(DateTimeInterface|int|string|null $cus return (int) $customEpoch->format('Uv'); } - if (is_int($customEpoch)) { - return $customEpoch; - } - - $epoch = strtotime($customEpoch); - if ($epoch === false) { - throw new \InvalidArgumentException('Custom epoch must be a valid date string'); - } - - return $epoch * 1000; + return $customEpoch; } } diff --git a/src/Configuration/SnowflakeConfig.php b/src/Configuration/SnowflakeConfig.php index 92bbd35..9e8f01a 100644 --- a/src/Configuration/SnowflakeConfig.php +++ b/src/Configuration/SnowflakeConfig.php @@ -7,7 +7,6 @@ use Closure; use DateTimeInterface; use Infocyph\UID\Enums\ClockBackwardPolicy; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class SnowflakeConfig @@ -18,16 +17,15 @@ /** * @param callable():mixed|null $nodeResolver - * @param DateTimeInterface|int|string|null $customEpoch Epoch in ms (int), parseable date string, or DateTime. + * @param DateTimeInterface|int|null $customEpoch Epoch in milliseconds or a date-time value. */ public function __construct( public int $datacenterId = 0, public int $workerId = 0, ?callable $nodeResolver = null, - public DateTimeInterface|int|string|null $customEpoch = null, + public DateTimeInterface|int|null $customEpoch = null, public ?SequenceProviderInterface $sequenceProvider = null, public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT, - public IdOutputType $outputType = IdOutputType::STRING, ) { $this->nodeResolver = $nodeResolver ? $nodeResolver(...) : null; } diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php index e6935d5..03aadab 100644 --- a/src/Configuration/SonyflakeConfig.php +++ b/src/Configuration/SonyflakeConfig.php @@ -6,7 +6,6 @@ use DateTimeInterface; use Infocyph\UID\Enums\ClockBackwardPolicy; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class SonyflakeConfig @@ -20,10 +19,9 @@ public function __construct( public int $machineId = 0, ?callable $machineIdResolver = null, - public DateTimeInterface|int|string|null $customEpoch = null, + public DateTimeInterface|int|null $customEpoch = null, public ?SequenceProviderInterface $sequenceProvider = null, public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT, - public IdOutputType $outputType = IdOutputType::STRING, ) { $this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; } diff --git a/src/Configuration/TBSLConfig.php b/src/Configuration/TBSLConfig.php index 78b3d53..c3200c0 100644 --- a/src/Configuration/TBSLConfig.php +++ b/src/Configuration/TBSLConfig.php @@ -5,7 +5,6 @@ namespace Infocyph\UID\Configuration; use Infocyph\UID\Enums\ClockBackwardPolicy; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class TBSLConfig @@ -17,11 +16,10 @@ */ public function __construct( public int $machineId = 0, - public bool $sequenced = false, + public bool $sequenced = true, ?callable $machineIdResolver = null, public ?SequenceProviderInterface $sequenceProvider = null, public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT, - public IdOutputType $outputType = IdOutputType::STRING, ) { $this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; } diff --git a/src/Contracts/IdAlgorithmInterface.php b/src/Contracts/IdAlgorithmInterface.php deleted file mode 100644 index 11f69a6..0000000 --- a/src/Contracts/IdAlgorithmInterface.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ - public static function parse(string $id): array; -} diff --git a/src/DbStorage.php b/src/DbStorage.php deleted file mode 100644 index 5a7b6a5..0000000 --- a/src/DbStorage.php +++ /dev/null @@ -1,50 +0,0 @@ - 'Use BIGINT UNSIGNED for numeric operations and compact indexing.', - 'postgres' => 'Use BIGINT when value fits signed range, otherwise NUMERIC(20,0).', - 'ordering' => 'Snowflake IDs are time-sortable by numeric order.', - ]; - } - - /** - * ULID storage recommendations. - * - * @return array{mysql:string, postgres:string, ordering:string} - */ - public static function ulid(): array - { - return [ - 'mysql' => 'Use CHAR(26) for readability or BINARY(16) when compactness/performance is primary.', - 'postgres' => 'Use CHAR(26) or BYTEA depending on interoperability needs.', - 'ordering' => 'ULID lexical order preserves chronological order in canonical 26-char text.', - ]; - } - - /** - * UUID storage recommendations. - * - * @return array{mysql:string, postgres:string, ordering:string} - */ - public static function uuid(): array - { - return [ - 'mysql' => 'Use BINARY(16) for compact storage; keep generated columns for textual debugging if needed.', - 'postgres' => 'Use native UUID type. It is space-efficient and indexed well.', - 'ordering' => 'UUIDv7 provides better temporal locality for B-Tree indexes than v4.', - ]; - } -} diff --git a/src/DeterministicId.php b/src/DeterministicId.php index ccecb6a..7ad9e6c 100644 --- a/src/DeterministicId.php +++ b/src/DeterministicId.php @@ -4,11 +4,14 @@ namespace Infocyph\UID; -use Infocyph\UID\Support\BaseEncoder; use InvalidArgumentException; final class DeterministicId { + private const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + + private const DOMAIN = "infocyph.uid.deterministic.v5\0"; + private const MAX_LENGTH = 43; /** @@ -20,17 +23,33 @@ public static function fromPayload(string $payload, int $length = 24, string $na throw new InvalidArgumentException('length must be greater than zero'); } - if (str_contains($namespace, '|')) { - throw new InvalidArgumentException('namespace must not contain the reserved delimiter'); - } - if ($length > self::MAX_LENGTH) { throw new InvalidArgumentException('length must not exceed 43 characters'); } - $hash = hash('sha3-256', $namespace . '|' . $payload, true); - $encoded = str_pad(BaseEncoder::encodeBytes($hash, 62), self::MAX_LENGTH, '0'); + $input = self::DOMAIN + . pack('N', strlen($namespace)) + . $namespace + . pack('N', strlen($payload)) + . $payload; + $encoded = ''; + $counter = 0; + + while (strlen($encoded) < $length) { + $bytes = hash('sha3-512', $input . pack('N', $counter++), true); + for ($index = 0; $index < 64; ++$index) { + $value = ord($bytes[$index]); + if ($value >= 248) { + continue; + } + + $encoded .= self::ALPHABET[$value % 62]; + if (strlen($encoded) === $length) { + break; + } + } + } - return substr($encoded, 0, $length); + return $encoded; } } diff --git a/src/Enums/IdOutputType.php b/src/Enums/IdOutputType.php deleted file mode 100644 index fd978d0..0000000 --- a/src/Enums/IdOutputType.php +++ /dev/null @@ -1,14 +0,0 @@ -nodeId, - leaseStart: $config->leaseStart, - leaseEnd: $config->leaseEnd, - secret: $config->secret, - sequenceProvider: $config->sequenceProvider, - outputType: IdOutputType::STRING, - ), - ); - - return Randflake::encodeString((string) $id); + public static function random( + int $length = 21, + string $alphabet = RandomId::DEFAULT_ALPHABET, + ): string { + return RandomId::generate($length, $alphabet); } - /** - * @throws Exception - */ - public static function snowflake(?SnowflakeConfig $config = null): int|string + public static function snowflake(?SnowflakeConfig $config = null): string { - if ($config === null) { - return Snowflake::generate(); - } - - return Snowflake::generateWithConfig($config); + return $config === null ? Snowflake::generate() : Snowflake::generateWithConfig($config); } - /** - * @throws Exception - */ public static function snowflakeValue(?SnowflakeConfig $config = null): SnowflakeValue { - return new SnowflakeValue((string) self::snowflake($config)); + return new SnowflakeValue(self::snowflake($config), $config?->resolveCustomEpochMs()); } - /** - * @throws Exception - */ - public static function sonyflake(?SonyflakeConfig $config = null): int|string + public static function sonyflake(?SonyflakeConfig $config = null): string { - if ($config === null) { - return Sonyflake::generate(); - } - - return Sonyflake::generateWithConfig($config); + return $config === null ? Sonyflake::generate() : Sonyflake::generateWithConfig($config); } - /** - * @throws Exception - */ public static function sonyflakeValue(?SonyflakeConfig $config = null): SonyflakeValue { - return new SonyflakeValue((string) self::sonyflake($config)); + return new SonyflakeValue(self::sonyflake($config), $config?->resolveCustomEpochMs()); } - /** - * @throws Exception - */ - public static function tbsl(?TBSLConfig $config = null): int|string + public static function tbsl(?TBSLConfig $config = null): string { - if ($config === null) { - return TBSL::generate(); - } - - return TBSL::generateWithConfig($config); + return $config === null ? TBSL::generate() : TBSL::generateWithConfig($config); } - /** - * @throws Exception - */ - public static function tbslValue(?TBSLConfig $config = null): TbslValue + public static function typeId(string $type = ''): string { - return new TbslValue((string) self::tbsl($config)); + return TypeID::generate($type); } - /** - * @throws Exception - */ public static function ulid( ?DateTimeInterface $dateTime = null, UlidGenerationMode $mode = UlidGenerationMode::MONOTONIC, @@ -180,244 +89,46 @@ public static function ulid( return ULID::generate($dateTime, $mode); } - /** - * @throws Exception - */ - public static function ulidValue( - ?DateTimeInterface $dateTime = null, - UlidGenerationMode $mode = UlidGenerationMode::MONOTONIC, - ): UlidValue { - return new UlidValue(self::ulid($dateTime, $mode)); - } - - /** - * Default UUID strategy (v7). - * - * @throws Exception - */ - public static function uuid(?DateTimeInterface $dateTime = null, ?string $node = null): string + public static function uuid(?DateTimeInterface $dateTime = null): string { - return self::uuid7($dateTime, $node); + return UUID::v7($dateTime); } - /** - * @throws Exception - */ public static function uuid1(?string $node = null): string { return UUID::v1($node); } - /** - * @throws Exception - */ - public static function uuid1Value(?string $node = null): UuidValue - { - return new UuidValue(self::uuid1($node)); - } - - /** - * @throws Exception - */ public static function uuid3(string $namespace, string $string): string { return UUID::v3($namespace, $string); } - /** - * @throws Exception - */ - public static function uuid3Value(string $namespace, string $string): UuidValue - { - return new UuidValue(self::uuid3($namespace, $string)); - } - - /** - * @throws Exception - */ public static function uuid4(): string { return UUID::v4(); } - /** - * @throws Exception - */ - public static function uuid4Value(): UuidValue - { - return new UuidValue(self::uuid4()); - } - - /** - * @throws Exception - */ public static function uuid5(string $namespace, string $string): string { return UUID::v5($namespace, $string); } - /** - * @throws Exception - */ - public static function uuid5Value(string $namespace, string $string): UuidValue - { - return new UuidValue(self::uuid5($namespace, $string)); - } - - /** - * @throws Exception - */ public static function uuid6(?string $node = null): string { return UUID::v6($node); } - /** - * @throws Exception - */ - public static function uuid6Value(?string $node = null): UuidValue - { - return new UuidValue(self::uuid6($node)); - } - - /** - * @throws Exception - */ - public static function uuid7(?DateTimeInterface $dateTime = null, ?string $node = null): string + public static function uuid7(?DateTimeInterface $dateTime = null): string { - return UUID::v7($dateTime, $node); + return UUID::v7($dateTime); } - /** - * @throws Exception - */ - public static function uuid7Value(?DateTimeInterface $dateTime = null, ?string $node = null): UuidValue - { - return new UuidValue(self::uuid7($dateTime, $node)); - } - - /** - * @throws Exception - */ public static function uuid8(?string $node = null): string { return UUID::v8($node); } - /** - * @throws Exception - */ - public static function uuid8Value(?string $node = null): UuidValue - { - return new UuidValue(self::uuid8($node)); - } - - /** - * @throws Exception - */ - public static function uuidBraces(string $uuid): string - { - return UUID::toBraces($uuid); - } - - /** - * @throws Exception - */ - public static function uuidCompact(string $uuid): string - { - return UUID::compact($uuid); - } - - /** - * @throws Exception - */ - public static function uuidFromBase(string $encoded, int $base): string - { - return UUID::fromBase($encoded, $base); - } - - /** - * @throws Exception - */ - public static function uuidFromBytes(string $bytes): string - { - return UUID::fromBytes($bytes); - } - - public static function uuidIsMax(string $uuid): bool - { - return UUID::isMax($uuid); - } - - public static function uuidIsNil(string $uuid): bool - { - return UUID::isNil($uuid); - } - - public static function uuidIsValid(string $uuid): bool - { - return UUID::isValid($uuid); - } - - public static function uuidMax(): string - { - return UUID::max(); - } - - public static function uuidNil(): string - { - return UUID::nil(); - } - - /** - * @throws Exception - */ - public static function uuidNormalize(string $uuid): string - { - return UUID::normalize($uuid); - } - - /** - * @return array{isValid: bool, version: int|null, variant: string|null, time: \DateTimeInterface|null, node: string|null, tail: string|null} - * @throws Exception - */ - public static function uuidParse(string $uuid): array - { - return UUID::parse($uuid); - } - - /** - * @throws Exception - */ - public static function uuidToBase(string $uuid, int $base): string - { - return UUID::toBase($uuid, $base); - } - - /** - * @throws Exception - */ - public static function uuidToBytes(string $uuid): string - { - return UUID::toBytes($uuid); - } - - /** - * @throws Exception - */ - public static function uuidUrn(string $uuid): string - { - return UUID::toUrn($uuid); - } - - public static function uuidValue(string $uuid): UuidValue - { - return new UuidValue($uuid); - } - - /** - * @throws Exception - */ public static function xid(): string { return XID::generate(); diff --git a/src/KSUID.php b/src/KSUID.php index 060e8a0..613daf2 100644 --- a/src/KSUID.php +++ b/src/KSUID.php @@ -7,11 +7,11 @@ use DateTimeImmutable; use DateTimeInterface; use Exception; -use Infocyph\UID\Contracts\IdAlgorithmInterface; +use Infocyph\UID\Exceptions\UIDException; use Infocyph\UID\Support\BaseEncoder; use Infocyph\UID\Support\BinaryUnpack; -final class KSUID implements IdAlgorithmInterface +final class KSUID { private const EPOCH = 1_400_000_000; @@ -25,7 +25,7 @@ final class KSUID implements IdAlgorithmInterface public static function fromBytes(string $bytes): string { if (strlen($bytes) !== 20) { - throw new Exception('KSUID binary data must be exactly 20 bytes'); + throw new UIDException('KSUID binary data must be exactly 20 bytes'); } return str_pad(BaseEncoder::encodeBytes($bytes, 62), 27, '0', STR_PAD_LEFT); @@ -57,22 +57,22 @@ public static function isValid(string $ksuid): bool } /** - * @return array{isValid: bool, time: DateTimeImmutable|null, payload: string|null} + * @return array{time: DateTimeImmutable, payload: string} * @throws Exception */ public static function parse(string $ksuid): array { - $data = ['isValid' => self::isValid($ksuid), 'time' => null, 'payload' => null]; - if (!$data['isValid']) { - return $data; + if (!self::isValid($ksuid)) { + throw new UIDException('Invalid KSUID string'); } $bytes = self::toBytes($ksuid); $timestamp = BinaryUnpack::u32(substr($bytes, 0, 4), 'Unable to parse KSUID timestamp') + self::EPOCH; - $data['time'] = new DateTimeImmutable('@' . $timestamp); - $data['payload'] = bin2hex(substr($bytes, 4)); - return $data; + return [ + 'time' => new DateTimeImmutable('@' . $timestamp), + 'payload' => bin2hex(substr($bytes, 4)), + ]; } /** @@ -81,7 +81,7 @@ public static function parse(string $ksuid): array public static function toBytes(string $ksuid): string { if (!self::isValid($ksuid)) { - throw new Exception('Invalid KSUID string'); + throw new UIDException('Invalid KSUID string'); } return BaseEncoder::decodeToBytes($ksuid, 62, 20); diff --git a/src/NanoID.php b/src/NanoID.php index 85c9ebb..75df183 100644 --- a/src/NanoID.php +++ b/src/NanoID.php @@ -5,12 +5,11 @@ namespace Infocyph\UID; use Exception; -use Infocyph\UID\Contracts\IdAlgorithmInterface; use InvalidArgumentException; -final class NanoID implements IdAlgorithmInterface +final class NanoID { - private const MAX_LENGTH = 1_048_576; + private const MAX_LENGTH = 1024; /** * Generates a NanoID string with the requested size. @@ -20,7 +19,7 @@ final class NanoID implements IdAlgorithmInterface public static function generate(int $length = 21): string { if ($length < 1 || $length > self::MAX_LENGTH) { - throw new InvalidArgumentException('length must be between 1 and 1048576'); + throw new InvalidArgumentException('length must be between 1 and 1024'); } $byteLength = intdiv(($length * 3) + 3, 4); @@ -50,12 +49,15 @@ public static function isValid(string $id, ?int $length = null): bool /** * Parses NanoID information. * - * @return array{isValid: bool, length: int, alphabet: string} + * @return array{length: int, alphabet: string} */ public static function parse(string $id, ?int $length = null): array { + if (!self::isValid($id, $length)) { + throw new InvalidArgumentException('Invalid NanoID string'); + } + return [ - 'isValid' => self::isValid($id, $length), 'length' => strlen($id), 'alphabet' => 'base64url', ]; diff --git a/src/ObjectID.php b/src/ObjectID.php new file mode 100644 index 0000000..7f17de0 --- /dev/null +++ b/src/ObjectID.php @@ -0,0 +1,86 @@ +format('U'); + if ($timestamp < 0 || $timestamp > 0xffffffff) { + throw new ObjectIDException('ObjectID timestamp must fit in an unsigned 32-bit integer'); + } + + $counter = self::$counter; + self::$counter = (self::$counter + 1) & 0xffffff; + + return bin2hex(pack('N', $timestamp) . self::$processRandom . substr(pack('N', $counter), 1)); + } + + public static function isValid(string $id): bool + { + return strlen($id) === 24 && ctype_xdigit($id) && strtolower($id) === $id; + } + + /** + * @return array{time:DateTimeImmutable, process_random:string, counter:int} + */ + public static function parse(string $id): array + { + $bytes = self::toBytes($id); + $timestamp = BinaryUnpack::u32(substr($bytes, 0, 4), 'Unable to parse ObjectID timestamp'); + + return [ + 'time' => new DateTimeImmutable('@' . $timestamp), + 'process_random' => bin2hex(substr($bytes, 4, 5)), + 'counter' => BinaryUnpack::u24(substr($bytes, 9, 3), 'Unable to parse ObjectID counter'), + ]; + } + + public static function toBytes(string $id): string + { + if (!self::isValid($id)) { + throw new ObjectIDException('Invalid ObjectID string'); + } + + $bytes = hex2bin($id); + $bytes !== false || throw new ObjectIDException('Unable to decode ObjectID'); + + return $bytes; + } + + private static function ensureProcessState(): void + { + $pid = (int) getmypid(); + if (self::$sourcePid === $pid) { + return; + } + + self::$sourcePid = $pid; + self::$processRandom = random_bytes(5); + self::$counter = random_int(0, 0xffffff); + } +} diff --git a/src/OpaqueId.php b/src/OpaqueId.php index 89fc24f..20a62a2 100644 --- a/src/OpaqueId.php +++ b/src/OpaqueId.php @@ -6,19 +6,18 @@ use Exception; use Infocyph\UID\Support\BaseEncoder; -use InvalidArgumentException; final class OpaqueId { - private const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - - private const MAX_RANDOM_LENGTH = 1024; - /** * Encodes an integer in a hashid-style opaque token. */ public static function fromInt(int $value, string $salt = ''): string { + if ($value < 0) { + throw new \InvalidArgumentException('Opaque ID values must not be negative'); + } + $saltMask = crc32($salt); $mixed = $value ^ $saltMask; $bytes = pack('J', $mixed); @@ -26,45 +25,6 @@ public static function fromInt(int $value, string $salt = ''): string return BaseEncoder::encodeBytes($bytes, 62); } - /** - * Generates a short opaque random ID. - * - * @throws Exception - */ - public static function random(int $length = 12): string - { - if ($length < 1 || $length > self::MAX_RANDOM_LENGTH) { - throw new InvalidArgumentException('length must be between 1 and 1024'); - } - - $id = ''; - $idLength = 0; - while ($idLength < $length) { - $remaining = $length - $idLength; - $chunkLength = intdiv(($remaining * 256) + 247, 248); - if ($chunkLength < 1) { - throw new \LogicException('Unable to calculate opaque ID entropy length'); - } - - $bytes = random_bytes($chunkLength); - $byteLength = strlen($bytes); - for ($index = 0; $index < $byteLength; ++$index) { - $value = ord($bytes[$index]); - if ($value >= 248) { - continue; - } - - $id .= self::ALPHABET[$value % 62]; - ++$idLength; - if ($idLength === $length) { - break; - } - } - } - - return $id; - } - /** * Decodes an opaque token back to integer. * diff --git a/src/Randflake.php b/src/Randflake.php index 98bc701..081603b 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -7,7 +7,6 @@ use DateTimeImmutable; use Exception; use Infocyph\UID\Configuration\RandflakeConfig; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\RandflakeException; use Infocyph\UID\Exceptions\SequenceTimestampException; @@ -17,7 +16,6 @@ use Infocyph\UID\Support\DecimalBytes; use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericConversion; -use Infocyph\UID\Support\OutputFormatter; use Infocyph\UID\Support\UnsignedDecimal; final class Randflake @@ -43,6 +41,9 @@ final class Randflake /** @var \WeakMap>|null */ private static ?\WeakMap $lastTimestampByProvider = null; + /** @var array> */ + private static array $roundKeyCache = []; + /** * @throws RandflakeException */ @@ -103,12 +104,11 @@ public static function fromBytes(string $bytes): string */ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string { - return (string) self::generateInternal( + return self::generateInternal( $nodeId, $leaseStart, $leaseEnd, $secret, - IdOutputType::STRING, null, ); } @@ -124,14 +124,13 @@ public static function generateString(int $nodeId, int $leaseStart, int $leaseEn /** * @throws RandflakeException|FileLockException */ - public static function generateWithConfig(RandflakeConfig $config): int|string + public static function generateWithConfig(RandflakeConfig $config): string { return self::generateInternal( $config->nodeId, $config->leaseStart, $config->leaseEnd, $config->secret, - $config->outputType, $config->sequenceProvider, ); } @@ -236,9 +235,8 @@ private static function generateInternal( int $leaseStart, int $leaseEnd, string $secret, - IdOutputType $outputType, ?SequenceProviderInterface $sequenceProvider, - ): int|string { + ): string { self::validateNode($nodeId); self::validateLeaseWindow($leaseStart, $leaseEnd); $secret = self::validateSecret($secret); @@ -289,9 +287,8 @@ private static function generateInternal( $plain = self::packPayload($now, $nodeId, $sequence); $cipher = self::permute($plain, $secret, false); - $decimalId = DecimalBytes::fromBytes($cipher); - return OutputFormatter::formatNumeric($decimalId, $outputType); + return DecimalBytes::fromBytes($cipher); } /** @@ -379,6 +376,11 @@ private static function roundFunction(int $value, int $key): int */ private static function roundKeys(string $secret): array { + $fingerprint = hash('sha256', $secret); + if (isset(self::$roundKeyCache[$fingerprint])) { + return self::$roundKeyCache[$fingerprint]; + } + $keys = []; for ($round = 0; $round < 8; ++$round) { $material = hash('sha256', $secret . ':' . $round, true); @@ -386,7 +388,11 @@ private static function roundKeys(string $secret): array $keys[] = self::unpackedInt($parts, 'key'); } - return $keys; + if (count(self::$roundKeyCache) === 16) { + array_shift(self::$roundKeyCache); + } + + return self::$roundKeyCache[$fingerprint] = $keys; } /** diff --git a/src/RandomId.php b/src/RandomId.php new file mode 100644 index 0000000..799cf1d --- /dev/null +++ b/src/RandomId.php @@ -0,0 +1,29 @@ + */ + private array $pathCache = []; + + /** @var array */ + private array $reservations = []; + + private ?int $sourcePid = null; public function __construct( ?string $baseDirectory = null, - private int $waitTime = 1_000, - private int $maxAttempts = 1_000, + private readonly string $namespace = '', + private readonly ?int $lockTimeoutMicros = null, + private readonly int $reservationSize = 1, ) { $this->baseDirectory = $baseDirectory ?: sys_get_temp_dir(); + + if ($namespace !== '' && preg_match('/^[A-Za-z0-9_-]+$/D', $namespace) !== 1) { + throw new InvalidArgumentException('Sequence namespace may contain only letters, numbers, underscores, and hyphens'); + } + + if ($lockTimeoutMicros !== null && $lockTimeoutMicros < 0) { + throw new InvalidArgumentException('Lock timeout must not be negative'); + } + + if ($reservationSize < 1) { + throw new InvalidArgumentException('Reservation size must be a positive integer'); + } } - /** - * @throws FileLockException - */ public function next(string $type, int $machineId, int $timestamp): int { $fileLocation = $this->sequenceFileLocation($type, $machineId); - $handle = $this->acquireLock($fileLocation); - - try { - return $this->updateSequence($handle, $timestamp); - } finally { - flock($handle, LOCK_UN); - fclose($handle); + $this->resetAfterFork(); + $reservation = $this->reservations[$fileLocation] ?? null; + + if ( + $reservation !== null + && $reservation['timestamp'] === $timestamp + && $reservation['next'] <= $reservation['end'] + ) { + $allocation = $reservation['next']; + $this->reservations[$fileLocation]['next'] = $allocation + 1; + + return $allocation; } - } - /** - * @return resource - * @throws FileLockException - */ - private function acquireLock(string $fileLocation) - { - return FileLock::acquire( + $handle = FileLock::acquire( $fileLocation, - $this->waitTime, - $this->maxAttempts, + $this->lockTimeoutMicros, 'Failed to open sequence file: ' . $fileLocation, 'Unable to acquire sequence lock: ' . $fileLocation, ); - } - private function sequenceFileLocation(string $type, int $machineId): string - { - if (preg_match('/^[A-Za-z0-9_-]+$/D', $type) !== 1) { - throw new InvalidArgumentException('Sequence type may contain only letters, numbers, underscores, and hyphens'); + try { + [$lastTimestamp, $lastAllocation, $oldLength] = $this->readState($handle); + if ($lastTimestamp > $timestamp) { + throw new SequenceTimestampException($lastTimestamp, $timestamp); + } + + $allocation = $lastTimestamp === $timestamp ? $lastAllocation + 1 : 1; + if ($allocation > PHP_INT_MAX - $this->reservationSize + 1) { + throw new FileLockException('Sequence value exhausted'); + } + + $reservedEnd = $allocation + $this->reservationSize - 1; + $state = $timestamp . ',' . $reservedEnd; + $this->writeState($handle, $state, $oldLength); + $this->reservations[$fileLocation] = [ + 'timestamp' => $timestamp, + 'next' => $allocation + 1, + 'end' => $reservedEnd, + ]; + + return $allocation; + } finally { + flock($handle, LOCK_UN); + fclose($handle); } + } - return $this->baseDirectory . DIRECTORY_SEPARATOR . "uid-$type-$machineId.seq"; + private static function isCanonicalInteger(string $value): bool + { + return $value !== '' + && ctype_digit($value) + && ($value === '0' || $value[0] !== '0'); } /** * @param resource $handle - * @throws FileLockException + * @return array{0:int,1:int,2:int} */ - private function updateSequence($handle, int $timestamp): int + private function readState($handle): array { - $sequence = 0; - $line = stream_get_contents($handle); - if ($line === false) { + $state = stream_get_contents($handle, self::MAX_SEQUENCE_STATE_BYTES + 1); + if ($state === false) { throw new FileLockException('Unable to read sequence state'); } - $line = trim($line); - if ($line !== '') { - if (preg_match('/^(0|[1-9]\d*),(0|[1-9]\d*)$/D', $line) !== 1) { - throw new FileLockException('Sequence state is malformed'); - } + $oldLength = strlen($state); + if ($oldLength > self::MAX_SEQUENCE_STATE_BYTES) { + throw new FileLockException('Sequence state exceeds the maximum size'); + } - $parts = explode(',', $line, 2); - $lastTimestamp = filter_var($parts[0], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); - $lastSequence = filter_var($parts[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); - if ($lastTimestamp === false || $lastSequence === false) { - throw new FileLockException('Sequence state is malformed'); - } + if ($state === '') { + return [0, 0, 0]; + } - if ($lastTimestamp > $timestamp) { - throw new SequenceTimestampException($lastTimestamp, $timestamp); - } + $comma = strpos($state, ','); + if ($comma === false || str_contains(substr($state, $comma + 1), ',')) { + throw new FileLockException('Sequence state is malformed'); + } + + $timestamp = substr($state, 0, $comma); + $allocation = substr($state, $comma + 1); + if (!self::isCanonicalInteger($timestamp) || !self::isCanonicalInteger($allocation)) { + throw new FileLockException('Sequence state is malformed'); + } + + if ( + strlen($timestamp) > 19 + || strlen($allocation) > 19 + || (strlen($timestamp) === 19 && $timestamp > (string) PHP_INT_MAX) + || (strlen($allocation) === 19 && $allocation > (string) PHP_INT_MAX) + ) { + throw new FileLockException('Sequence state is malformed'); + } + + return [(int) $timestamp, (int) $allocation, $oldLength]; + } + + private function resetAfterFork(): void + { + $pid = (int) getmypid(); + if ($pid === $this->sourcePid) { + return; + } - $sequence = $lastTimestamp === $timestamp ? $lastSequence : 0; + $this->sourcePid = $pid; + $this->reservations = []; + } + + private function sequenceFileLocation(string $type, int $machineId): string + { + $cacheKey = $type . ':' . $machineId; + if (isset($this->pathCache[$cacheKey])) { + return $this->pathCache[$cacheKey]; + } + + if (preg_match('/^[A-Za-z0-9_-]+$/D', $type) !== 1) { + throw new InvalidArgumentException('Sequence type may contain only letters, numbers, underscores, and hyphens'); } - if ($sequence === PHP_INT_MAX) { - throw new FileLockException('Sequence value exhausted'); + $name = 'uid-' . ($this->namespace === '' ? '' : $this->namespace . '-') . $type . '-' . $machineId . '.seq'; + if (count($this->pathCache) === self::MAX_PATH_CACHE) { + array_shift($this->pathCache); } - ++$sequence; - $state = "$timestamp,$sequence"; + return $this->pathCache[$cacheKey] = $this->baseDirectory . DIRECTORY_SEPARATOR . $name; + } + /** + * @param resource $handle + */ + private function writeState($handle, string $state, int $oldLength): void + { rewind($handle) || throw new FileLockException('Unable to rewind sequence file'); $written = fwrite($handle, $state); if ($written === false || $written !== strlen($state)) { throw new FileLockException('Unable to write complete sequence state'); } - ftruncate($handle, $written) || throw new FileLockException('Unable to truncate sequence file'); - fflush($handle) || throw new FileLockException('Unable to flush sequence state'); + if ($written < $oldLength) { + ftruncate($handle, $written) || throw new FileLockException('Unable to truncate sequence file'); + } - return $sequence; + fflush($handle) || throw new FileLockException('Unable to flush sequence state'); } } diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 9d386bf..037041f 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -91,8 +91,7 @@ private function acquireLock(string $key) return FileLock::acquire( $lockFile, - $this->waitTime, - $this->maxAttempts, + $this->waitTime * $this->maxAttempts, 'Unable to open sequence cache lock file: ' . $lockFile, 'Unable to acquire sequence cache lock for key: ' . $key, ); diff --git a/src/Sequence/SequenceProviderInterface.php b/src/Sequence/SequenceProviderInterface.php index 1a966c3..cbdc1fa 100644 --- a/src/Sequence/SequenceProviderInterface.php +++ b/src/Sequence/SequenceProviderInterface.php @@ -7,7 +7,7 @@ interface SequenceProviderInterface { /** - * Returns the next positive sequence for a given type/machine/timestamp key. + * Returns the next positive allocation, beginning at one, for a key. */ public function next(string $type, int $machineId, int $timestamp): int; } diff --git a/src/Snowflake.php b/src/Snowflake.php index bde8127..1f46468 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -8,37 +8,32 @@ use Exception; use Infocyph\UID\Configuration\SnowflakeConfig; use Infocyph\UID\Enums\ClockBackwardPolicy; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\SnowflakeException; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; -use Infocyph\UID\Support\EpochGuard; use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericConversion; -use Infocyph\UID\Support\OutputFormatter; use Infocyph\UID\Support\UnsignedDecimal; final class Snowflake { use GetSequence; - /** @var \WeakMap>|null */ - private static ?\WeakMap $lastStateByProvider = null; - - private static int $lastTimestamp = 0; + private const DATACENTER_BITS = 5; - private static int $maxDatacenterLength = 5; + private const DEFAULT_EPOCH = 1_577_836_800_000; - private static int $maxSequenceLength = 12; + private const SEQUENCE_BITS = 12; - private static int $maxTimestampLength = 41; + private const TIMESTAMP_BITS = 41; - private static int $maxWorkIdLength = 5; + private const WORKER_BITS = 5; - private static ?int $startTime = null; + /** @var \WeakMap>|null */ + private static ?\WeakMap $lastStateByProvider = null; /** * Decodes one of bases: 16, 32, 36, 58, 62 into Snowflake decimal. @@ -70,12 +65,11 @@ public static function fromBytes(string $bytes): string */ public static function generate(int $datacenter = 0, int $workerId = 0): string { - return (string) self::generateInternal( + return self::generateInternal( $datacenter, $workerId, self::getStartTimeStamp(), ClockBackwardPolicy::WAIT, - IdOutputType::STRING, ); } @@ -84,7 +78,7 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string * * @throws SnowflakeException|FileLockException */ - public static function generateWithConfig(SnowflakeConfig $config): int|string + public static function generateWithConfig(SnowflakeConfig $config): string { [$datacenterId, $workerId] = $config->resolveNode(); $customEpoch = $config->resolveCustomEpochMs(); @@ -94,7 +88,6 @@ public static function generateWithConfig(SnowflakeConfig $config): int|string $workerId, $customEpoch ?? self::getStartTimeStamp(), $config->clockBackwardPolicy, - $config->outputType, $config->sequenceProvider, ); } @@ -105,7 +98,6 @@ public static function generateWithConfig(SnowflakeConfig $config): int|string public static function isValid(string $id): bool { return $id !== '' - && $id !== '0' && ctype_digit($id) && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; } @@ -137,8 +129,8 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array throw new SnowflakeException('Invalid Snowflake ID string'); } - $binaryId = decbin((int) $id); - $timestamp = (int) bindec(substr($binaryId, 0, -22)) + $startTimestamp; + $numericId = (int) $id; + $timestamp = ($numericId >> 22) + $startTimestamp; [$seconds, $fraction] = self::timestampParts($timestamp); return [ @@ -148,44 +140,12 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array . '.' . str_pad($fraction, 6, '0', STR_PAD_LEFT), ), - 'sequence' => (int) bindec(substr($binaryId, -12)), - 'worker_id' => (int) bindec(substr($binaryId, -17, 5)), - 'datacenter_id' => (int) bindec(substr($binaryId, -22, 5)), + 'sequence' => $numericId & 0xfff, + 'worker_id' => ($numericId >> 12) & 0x1f, + 'datacenter_id' => ($numericId >> 17) & 0x1f, ]; } - /** - * Sets the start timestamp for the Snowflake algorithm. - * - * @param string $timeString The start time in string format. - * @throws SnowflakeException - */ - public static function setStartTimeStamp(string $timeString): void - { - try { - $resolved = EpochGuard::resolveStartTime( - $timeString, - 'Invalid start time format', - 'The start time cannot be in the future', - ); - } catch (\InvalidArgumentException $exception) { - throw new SnowflakeException($exception->getMessage(), 0, $exception); - } - $time = $resolved['time']; - $current = $resolved['current']; - - if (($current - $time) > (-1 ^ (-1 << self::$maxTimestampLength))) { - throw new SnowflakeException( - sprintf( - 'The current microtime - start_time is not allowed to exceed -1 ^ (-1 << %d),\n You can reset the start time to fix this', - self::$maxTimestampLength, - ), - ); - } - - self::$startTime = $time * 1000; - } - /** * Encodes Snowflake bytes into one of bases: 16, 32, 36, 58, 62. * @@ -211,8 +171,8 @@ public static function toBytes(string $id): string */ private static function assertNodeIds(int $datacenter, int $workerId): void { - $maxDataCenter = -1 ^ (-1 << self::$maxDatacenterLength); - $maxWorkId = -1 ^ (-1 << self::$maxWorkIdLength); + $maxDataCenter = -1 ^ (-1 << self::DATACENTER_BITS); + $maxWorkId = -1 ^ (-1 << self::WORKER_BITS); if ($datacenter > $maxDataCenter || $datacenter < 0) { throw new SnowflakeException("Invalid datacenter ID, must be between 0 ~ $maxDataCenter."); @@ -229,7 +189,7 @@ private static function assertNodeIds(int $datacenter, int $workerId): void private static function assertTimestampRange(int $currentTime, int $startTimestamp): void { $elapsed = $currentTime - $startTimestamp; - $maxTimestamp = -1 ^ (-1 << self::$maxTimestampLength); + $maxTimestamp = -1 ^ (-1 << self::TIMESTAMP_BITS); if ($elapsed < 0) { throw new SnowflakeException('Snowflake epoch must not be in the future'); } @@ -279,28 +239,22 @@ private static function generateInternal( int $workerId, int $startTimestamp, ClockBackwardPolicy $clockBackwardPolicy, - IdOutputType $outputType, ?SequenceProviderInterface $sequenceProvider = null, - ): int|string { + ): string { self::assertNodeIds($datacenter, $workerId); $currentTime = (int) floor(microtime(true) * 1000); self::assertTimestampRange($currentTime, $startTimestamp); - if ($currentTime < self::$lastTimestamp) { - if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { - throw new SnowflakeException('Clock moved backwards while generating Snowflake ID'); - } - - $currentTime = self::waitUntil(self::$lastTimestamp); - self::assertTimestampRange($currentTime, $startTimestamp); - } - $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); - $sequenceKey = ($datacenter << self::$maxWorkIdLength) | $workerId; + $sequenceKey = ($datacenter << self::WORKER_BITS) | $workerId; + $stateKey = $startTimestamp . ':' . $sequenceKey; self::$lastStateByProvider ??= new \WeakMap(); $providerState = self::$lastStateByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); - $maxSequence = -1 ^ (-1 << self::$maxSequenceLength); + $maxSequence = -1 ^ (-1 << self::SEQUENCE_BITS); + $sequenceType = $startTimestamp === self::DEFAULT_EPOCH + ? 'snowflake' + : 'snowflake_' . $startTimestamp; while (true) { [$currentTime, $sequence] = self::nextSequenceAtValidTimestamp( @@ -310,9 +264,10 @@ private static function generateInternal( $maxSequence, $clockBackwardPolicy, $resolvedSequenceProvider, + $sequenceType, ); - $lastState = $providerState[$sequenceKey] ?? null; + $lastState = $providerState[$stateKey] ?? null; if ($lastState === null) { break; @@ -333,22 +288,19 @@ private static function generateInternal( break; } - self::$lastTimestamp = $currentTime; - $providerState[$sequenceKey] = [ + $providerState[$stateKey] = [ 'timestamp' => $currentTime, 'sequence' => $sequence, ]; - $workerLeftMoveLength = self::$maxSequenceLength; - $datacenterLeftMoveLength = self::$maxWorkIdLength + $workerLeftMoveLength; - $timestampLeftMoveLength = self::$maxDatacenterLength + $datacenterLeftMoveLength; + $workerLeftMoveLength = self::SEQUENCE_BITS; + $datacenterLeftMoveLength = self::WORKER_BITS + $workerLeftMoveLength; + $timestampLeftMoveLength = self::DATACENTER_BITS + $datacenterLeftMoveLength; - $id = (string) ((($currentTime - $startTimestamp) << $timestampLeftMoveLength) + return (string) ((($currentTime - $startTimestamp) << $timestampLeftMoveLength) | ($datacenter << $datacenterLeftMoveLength) | ($workerId << $workerLeftMoveLength) | ($sequence)); - - return OutputFormatter::formatNumeric($id, $outputType); } /** @@ -356,7 +308,7 @@ private static function generateInternal( */ private static function getStartTimeStamp(): int { - return self::$startTime ??= 1_577_836_800_000; + return self::DEFAULT_EPOCH; } /** @@ -370,10 +322,11 @@ private static function nextSequenceAtValidTimestamp( int $maxSequence, ClockBackwardPolicy $clockBackwardPolicy, SequenceProviderInterface $sequenceProvider, + string $sequenceType, ): array { while (true) { try { - $sequence = self::sequence($currentTime, $sequenceKey, 'snowflake', $sequenceProvider); + $allocation = self::sequence($currentTime, $sequenceKey, $sequenceType, $sequenceProvider); } catch (SequenceTimestampException $exception) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new SnowflakeException( @@ -389,8 +342,12 @@ private static function nextSequenceAtValidTimestamp( continue; } - if ($sequence <= $maxSequence) { - return [$currentTime, $sequence]; + if ($allocation < 1) { + throw new SnowflakeException('Snowflake sequence provider must return a positive allocation'); + } + + if ($allocation <= $maxSequence + 1) { + return [$currentTime, $allocation - 1]; } $currentTime = self::waitUntil($currentTime + 1); diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 7cfa5c5..4b8b002 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -8,31 +8,30 @@ use Exception; use Infocyph\UID\Configuration\SonyflakeConfig; use Infocyph\UID\Enums\ClockBackwardPolicy; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\SonyflakeException; +use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; -use Infocyph\UID\Support\EpochGuard; use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericIdCodec; -use Infocyph\UID\Support\OutputFormatter; use Infocyph\UID\Support\UnsignedDecimal; final class Sonyflake { use GetSequence; - private static int $lastWallTime = 0; + private const DEFAULT_EPOCH = 1_577_836_800_000; - private static int $maxMachineIdLength = 16; + private const MACHINE_BITS = 16; - private static int $maxSequenceLength = 8; + private const SEQUENCE_BITS = 8; - private static int $maxTimestampLength = 39; + private const TIMESTAMP_BITS = 39; - private static ?int $startTime = null; + /** @var array */ + private static array $lastWallTimeByDomain = []; /** * Decodes one of bases: 16, 32, 36, 58, 62 into Sonyflake decimal. @@ -69,11 +68,10 @@ public static function fromBytes(string $bytes): string */ public static function generate(int $machineId = 0): string { - return (string) self::generateInternal( + return self::generateInternal( $machineId, self::getStartTimeStamp(), ClockBackwardPolicy::WAIT, - IdOutputType::STRING, ); } @@ -82,13 +80,12 @@ public static function generate(int $machineId = 0): string * * @throws SonyflakeException|FileLockException */ - public static function generateWithConfig(SonyflakeConfig $config): int|string + public static function generateWithConfig(SonyflakeConfig $config): string { return self::generateInternal( $config->resolveMachineId(), $config->resolveCustomEpochMs() ?? self::getStartTimeStamp(), $config->clockBackwardPolicy, - $config->outputType, $config->sequenceProvider, ); } @@ -99,7 +96,6 @@ public static function generateWithConfig(SonyflakeConfig $config): int|string public static function isValid(string $id): bool { return $id !== '' - && $id !== '0' && ctype_digit($id) && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; } @@ -142,30 +138,6 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array ]; } - /** - * Sets the start timestamp for the SonyFlake algorithm. - * - * @param string $timeString The start time in string format. - * @throws SonyflakeException - */ - public static function setStartTimeStamp(string $timeString): void - { - try { - $resolved = EpochGuard::resolveStartTime( - $timeString, - 'Invalid start time format', - 'The start time cannot be in the future', - ); - } catch (\InvalidArgumentException $exception) { - throw new SonyflakeException($exception->getMessage(), 0, $exception); - } - $time = $resolved['time']; - $current = $resolved['current']; - - self::ensureEffectiveRuntime(floor(($current - $time) / 10) | 0); - self::$startTime = $time * 1000; - } - /** * Encodes Sonyflake bytes into one of bases: 16, 32, 36, 58, 62. * @@ -227,7 +199,7 @@ private static function ensureEffectiveRuntime(int $elapsedTime): void throw new SonyflakeException('Sonyflake epoch must not be in the future'); } - if ($elapsedTime > (-1 ^ (-1 << self::$maxTimestampLength))) { + if ($elapsedTime > (-1 ^ (-1 << self::TIMESTAMP_BITS))) { throw new SonyflakeException('Exceeding the maximum life cycle of the algorithm'); } } @@ -237,17 +209,15 @@ private static function ensureEffectiveRuntime(int $elapsedTime): void */ private static function extractParts(string $id, int $startTimestamp): array { - $binary = decbin((int) $id); - $tailBitLength = self::$maxMachineIdLength + self::$maxSequenceLength; - $elapsed = bindec(substr($binary, 0, strlen($binary) - $tailBitLength)); - $timestamp = (string) ($startTimestamp + ($elapsed * 10)); - $timeParts = str_split($timestamp, 10); + $numericId = (int) $id; + $elapsed = $numericId >> 24; + $timestamp = $startTimestamp + ($elapsed * 10); return [ - 'seconds' => $timeParts[0], - 'fraction' => $timeParts[1] ?? '0', - 'sequence' => (int) bindec(substr($binary, -1 * self::$maxSequenceLength)), - 'machine_id' => (int) bindec(substr($binary, -1 * $tailBitLength, self::$maxMachineIdLength)), + 'seconds' => (string) intdiv($timestamp, 1000), + 'fraction' => (string) (($timestamp % 1000) * 1000), + 'sequence' => $numericId & 0xff, + 'machine_id' => ($numericId >> 8) & 0xffff, ]; } @@ -258,21 +228,23 @@ private static function generateInternal( int $machineId, int $startTimestamp, ClockBackwardPolicy $clockBackwardPolicy, - IdOutputType $outputType, ?SequenceProviderInterface $sequenceProvider = null, - ): int|string { - $maxMachineID = -1 ^ (-1 << self::$maxMachineIdLength); + ): string { + $maxMachineID = -1 ^ (-1 << self::MACHINE_BITS); if ($machineId < 0 || $machineId > $maxMachineID) { throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maxMachineID."); } + $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); $currentTime = (int) floor(microtime(true) * 1000); - if ($currentTime < self::$lastWallTime) { + $domainKey = $startTimestamp . ':' . $machineId . ':' . spl_object_id($resolvedSequenceProvider); + $lastWallTime = self::$lastWallTimeByDomain[$domainKey] ?? 0; + if ($currentTime < $lastWallTime) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID'); } - $currentTime = self::waitUntilWallTime(self::$lastWallTime); + $currentTime = self::waitUntilWallTime($lastWallTime); } $elapsedTime = self::elapsedTime($currentTime, $startTimestamp); @@ -285,7 +257,7 @@ private static function generateInternal( $elapsedTime, $machineId, $sequenceType, - $sequenceProvider, + $resolvedSequenceProvider, ); } catch (SequenceTimestampException $exception) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { @@ -301,21 +273,25 @@ private static function generateInternal( continue; } - if ($sequence <= (-1 ^ (-1 << self::$maxSequenceLength))) { + if ($sequence < 1) { + throw new SonyflakeException('Sonyflake sequence provider must return a positive allocation'); + } + + if ($sequence <= (-1 ^ (-1 << self::SEQUENCE_BITS)) + 1) { + --$sequence; + break; } $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp); } - self::$lastWallTime = max($currentTime, $startTimestamp + ($elapsedTime * 10)); + self::$lastWallTimeByDomain[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10)); self::ensureEffectiveRuntime($elapsedTime); - $id = (string) ($elapsedTime << (self::$maxMachineIdLength + self::$maxSequenceLength) - | ($machineId << self::$maxSequenceLength) + return (string) ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS) + | ($machineId << self::SEQUENCE_BITS) | ($sequence)); - - return OutputFormatter::formatNumeric($id, $outputType); } /** @@ -323,7 +299,12 @@ private static function generateInternal( */ private static function getStartTimeStamp(): int { - return self::$startTime ??= 1_577_836_800_000; + return self::DEFAULT_EPOCH; + } + + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + { + return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } private static function waitUntilElapsed(int $elapsedTime, int $startTimestamp): int diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 5dfece5..87299b3 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -9,6 +9,7 @@ final class BaseEncoder { private const ALPHABETS = [ + 10 => '0123456789', 16 => '0123456789abcdef', 32 => '0123456789abcdefghijklmnopqrstuv', 36 => '0123456789abcdefghijklmnopqrstuvwxyz', @@ -16,7 +17,7 @@ final class BaseEncoder 62 => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', ]; - private const MAX_BYTE_LENGTH = 1_048_576; + private const MAX_BYTE_LENGTH = 1024; /** * Decodes one of supported bases (16/32/36/58/62) into bytes. @@ -28,7 +29,18 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt } if ($bytesLength < 1 || $bytesLength > self::MAX_BYTE_LENGTH) { - throw new InvalidArgumentException('Byte length must be between 1 and 1048576'); + throw new InvalidArgumentException('Byte length must be between 1 and 1024'); + } + + if ($base === 16) { + if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { + throw new InvalidArgumentException('Invalid character for base 16'); + } + + $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); + $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); + + return $decoded; } $alphabet = self::alphabet($base); @@ -37,17 +49,38 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt throw new InvalidArgumentException('Encoded value exceeds target byte length'); } - $decimal = '0'; + $bytes = [0]; $encodedLength = strlen($encoded); for ($index = 0; $index < $encodedLength; ++$index) { $char = $encoded[$index]; $alphabetIndex = strpos($alphabet, $char); $alphabetIndex !== false || throw new InvalidArgumentException('Invalid character for base ' . $base); - $decimal = bcadd(bcmul($decimal, (string) $base), (string) $alphabetIndex); + + $carry = $alphabetIndex; + $byteCount = count($bytes); + for ($byteIndex = $byteCount - 1; $byteIndex >= 0; --$byteIndex) { + $value = ($bytes[$byteIndex] * $base) + $carry; + $bytes[$byteIndex] = $value & 0xff; + $carry = $value >> 8; + } + + while ($carry > 0) { + array_unshift($bytes, $carry & 0xff); + $carry >>= 8; + } + + if (count($bytes) > $bytesLength) { + throw new InvalidArgumentException('Encoded value exceeds target byte length'); + } + } + + $decoded = ''; + foreach ($bytes as $byte) { + $decoded .= chr($byte); } - return DecimalBytes::toFixedBytes($decimal, $bytesLength); + return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } /** @@ -57,21 +90,41 @@ public static function encodeBytes(string $bytes, int $base): string { $byteLength = strlen($bytes); if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { - throw new InvalidArgumentException('Byte length must be between 1 and 1048576'); + throw new InvalidArgumentException('Byte length must be between 1 and 1024'); + } + + if ($base === 16) { + return ltrim(bin2hex($bytes), '0') ?: '0'; } $alphabet = self::alphabet($base); - $decimal = self::bytesToDecimal($bytes); + $unpacked = unpack('C*', $bytes); + $unpacked !== false || throw new \LogicException('Unable to unpack byte value'); + $number = []; + foreach ($unpacked as $byte) { + is_int($byte) || throw new \LogicException('Unable to unpack byte value'); + $number[] = $byte; + } - if ($decimal === '0') { - return '0'; + if (trim($bytes, "\0") === '') { + return $alphabet[0]; } $encoded = ''; - while ($decimal !== '0') { - $remainder = (int) bcmod($decimal, (string) $base); + while ($number !== []) { + $quotient = []; + $remainder = 0; + foreach ($number as $byte) { + $value = ($remainder << 8) | $byte; + $digit = intdiv($value, $base); + $remainder = $value % $base; + if ($quotient !== [] || $digit !== 0) { + $quotient[] = $digit; + } + } + $encoded = $alphabet[$remainder] . $encoded; - $decimal = bcdiv($decimal, (string) $base, 0); + $number = $quotient; } return $encoded; @@ -81,9 +134,4 @@ private static function alphabet(int $base): string { return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); } - - private static function bytesToDecimal(string $bytes): string - { - return DecimalBytes::fromBytes($bytes); - } } diff --git a/src/Support/DecimalBytes.php b/src/Support/DecimalBytes.php index 6383d34..b433fb1 100644 --- a/src/Support/DecimalBytes.php +++ b/src/Support/DecimalBytes.php @@ -6,17 +6,11 @@ final class DecimalBytes { - private const MAX_BYTE_LENGTH = 1_048_576; + private const MAX_BYTE_LENGTH = 1024; public static function fromBytes(string $bytes): string { - $decimal = '0'; - $length = strlen($bytes); - for ($index = 0; $index < $length; ++$index) { - $decimal = bcadd(bcmul($decimal, '256'), (string) ord($bytes[$index])); - } - - return $decimal; + return BaseEncoder::encodeBytes($bytes, 10); } /** @@ -25,35 +19,13 @@ public static function fromBytes(string $bytes): string public static function toFixedBytes(string $decimal, int $byteLength): string { if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { - throw new \InvalidArgumentException('Byte length must be between 1 and 1048576'); + throw new \InvalidArgumentException('Byte length must be between 1 and 1024'); } - if (preg_match('/^\d+$/', $decimal) !== 1) { + if ($decimal === '' || !ctype_digit($decimal)) { throw new \InvalidArgumentException('Decimal value must contain only digits'); } - $value = UnsignedDecimal::normalize($decimal); - $maxDecimalDigits = (int) ceil($byteLength * log10(256)); - if (strlen($value) > $maxDecimalDigits) { - throw new \InvalidArgumentException('Decimal value exceeds target byte length'); - } - - $bytes = ''; - while ($value !== '0') { - $remainder = (int) bcmod($value, '256'); - if ($remainder < 0 || $remainder > 255) { - throw new \LogicException('Decimal byte remainder is outside the byte range'); - } - - $bytes = chr($remainder) . $bytes; - $value = bcdiv($value, '256', 0); - } - - $valueLength = strlen($bytes); - if ($valueLength > $byteLength) { - throw new \InvalidArgumentException('Decimal value exceeds target byte length'); - } - - return str_repeat("\0", $byteLength - $valueLength) . $bytes; + return BaseEncoder::decodeToBytes(UnsignedDecimal::normalize($decimal), 10, $byteLength); } } diff --git a/src/Support/EpochGuard.php b/src/Support/EpochGuard.php deleted file mode 100644 index ae28bcd..0000000 --- a/src/Support/EpochGuard.php +++ /dev/null @@ -1,30 +0,0 @@ - $current) { - throw new \InvalidArgumentException($futureMessage); - } - - return ['time' => $time, 'current' => $current]; - } -} diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 4461f10..2daee3e 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -14,24 +14,38 @@ final class FileLock */ public static function acquire( string $path, - int $waitTime, - int $maxAttempts, + ?int $timeoutMicros, string $openErrorMessage, string $lockErrorMessage, ) { - $waitTime = max(100, $waitTime); - $maxAttempts = max(1, $maxAttempts); - ($handle = fopen($path, 'c+')) || throw new FileLockException($openErrorMessage); - for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { - if (flock($handle, LOCK_EX | LOCK_NB)) { + if ($timeoutMicros === null) { + if (flock($handle, LOCK_EX)) { return $handle; } - usleep($waitTime); + fclose($handle); + + throw new FileLockException($lockErrorMessage); } + $deadline = hrtime(true) + ($timeoutMicros * 1000); + do { + $wouldBlock = 0; + if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { + return $handle; + } + + if ($wouldBlock !== 1) { + fclose($handle); + + throw new FileLockException($lockErrorMessage); + } + + usleep(1000); + } while (hrtime(true) < $deadline); + fclose($handle); throw new FileLockException($lockErrorMessage); diff --git a/src/Support/GetSequence.php b/src/Support/GetSequence.php index 060b985..0087315 100644 --- a/src/Support/GetSequence.php +++ b/src/Support/GetSequence.php @@ -36,10 +36,16 @@ public static function setSequenceProvider(SequenceProviderInterface $provider): */ public static function useFilesystemSequenceProvider( ?string $baseDirectory = null, - int $waitTime = 1_000, - int $maxAttempts = 1_000, + string $namespace = '', + ?int $lockTimeoutMicros = null, + int $reservationSize = 1, ): void { - self::$sequenceProvider = new FilesystemSequenceProvider($baseDirectory, $waitTime, $maxAttempts); + self::$sequenceProvider = new FilesystemSequenceProvider( + $baseDirectory, + $namespace, + $lockTimeoutMicros, + $reservationSize, + ); } /** @@ -66,10 +72,17 @@ public static function useSequenceCallback(callable $callback): void public static function useSimpleCacheSequenceProvider( CacheInterface $cache, string $prefix = 'uid.seq.', + ?callable $synchronizer = null, int $waitTime = 1_000, int $maxAttempts = 1_000, ): void { - self::$sequenceProvider = new PsrSimpleCacheSequenceProvider($cache, $prefix, $waitTime, $maxAttempts); + self::$sequenceProvider = new PsrSimpleCacheSequenceProvider( + $cache, + $prefix, + $waitTime, + $maxAttempts, + $synchronizer, + ); } /** diff --git a/src/Support/OutputFormatter.php b/src/Support/OutputFormatter.php deleted file mode 100644 index 650137f..0000000 --- a/src/Support/OutputFormatter.php +++ /dev/null @@ -1,51 +0,0 @@ - $decimal, - IdOutputType::INT => self::toInt($decimal), - IdOutputType::BINARY => self::toBinary64($decimal), - }; - } - - /** - * @throws UIDException - */ - private static function toBinary64(string $decimal): string - { - try { - return DecimalBytes::toFixedBytes($decimal, 8); - } catch (\InvalidArgumentException $exception) { - throw new UIDException('Unable to convert numeric ID to binary', 0, $exception); - } - } - - /** - * @throws UIDException - */ - private static function toInt(string $decimal): int - { - if (UnsignedDecimal::compare($decimal, (string) PHP_INT_MAX) === 1) { - throw new UIDException('Numeric ID exceeds PHP_INT_MAX; use string or binary output'); - } - - return (int) $decimal; - } -} diff --git a/src/Support/RandomSampler.php b/src/Support/RandomSampler.php new file mode 100644 index 0000000..a058c5f --- /dev/null +++ b/src/Support/RandomSampler.php @@ -0,0 +1,104 @@ + 256) { + throw new InvalidArgumentException('alphabet must contain between 2 and 256 symbols'); + } + + $seen = []; + for ($index = 0; $index < $length; ++$index) { + $symbol = $alphabet[$index]; + if (isset($seen[$symbol])) { + throw new InvalidArgumentException('alphabet must not contain duplicate symbols'); + } + + $seen[$symbol] = true; + } + } + + public static function containsOnly(string $value, string $alphabet): bool + { + self::assertAlphabet($alphabet); + $allowed = []; + $alphabetLength = strlen($alphabet); + for ($index = 0; $index < $alphabetLength; ++$index) { + $allowed[$alphabet[$index]] = true; + } + + $length = strlen($value); + for ($index = 0; $index < $length; ++$index) { + if (!isset($allowed[$value[$index]])) { + return false; + } + } + + return true; + } + + public static function generate(int $length, string $alphabet): string + { + self::assertAlphabet($alphabet); + + if ($length < 1 || $length > 1024) { + throw new InvalidArgumentException('length must be between 1 and 1024'); + } + + $alphabetLength = strlen($alphabet); + $limit = intdiv(256, $alphabetLength) * $alphabetLength; + $result = ''; + $resultLength = 0; + + while ($resultLength < $length) { + $remaining = $length - $resultLength; + $chunkLength = max(1, intdiv(($remaining * 256) + $limit - 1, $limit)); + $bytes = random_bytes($chunkLength); + $sample = self::mapAcceptedBytes($bytes, $alphabet, $alphabetLength, $limit); + $accepted = min(strlen($sample), $remaining); + $result .= substr($sample, 0, $accepted); + $resultLength += $accepted; + } + + return $result; + } + + public static function mapBytes(string $bytes, string $alphabet): string + { + self::assertAlphabet($alphabet); + $alphabetLength = strlen($alphabet); + + return self::mapAcceptedBytes( + $bytes, + $alphabet, + $alphabetLength, + intdiv(256, $alphabetLength) * $alphabetLength, + ); + } + + private static function mapAcceptedBytes( + string $bytes, + string $alphabet, + int $alphabetLength, + int $limit, + ): string { + $result = ''; + $byteLength = strlen($bytes); + for ($index = 0; $index < $byteLength; ++$index) { + $value = ord($bytes[$index]); + if ($value < $limit) { + $result .= $alphabet[$value % $alphabetLength]; + } + } + + return $result; + } +} diff --git a/src/Support/TypeIdCodec.php b/src/Support/TypeIdCodec.php new file mode 100644 index 0000000..e0f70a2 --- /dev/null +++ b/src/Support/TypeIdCodec.php @@ -0,0 +1,82 @@ + '7') { + throw new InvalidArgumentException('TypeID suffix must be a canonical 128-bit value'); + } + + $bytes = [0]; + for ($index = 0; $index < 26; ++$index) { + $digit = strpos(self::ALPHABET, $suffix[$index]); + $digit !== false || throw new InvalidArgumentException('TypeID suffix contains an invalid character'); + $carry = $digit; + + for ($byteIndex = count($bytes) - 1; $byteIndex >= 0; --$byteIndex) { + $value = ($bytes[$byteIndex] << 5) + $carry; + $bytes[$byteIndex] = $value & 0xff; + $carry = $value >> 8; + } + + while ($carry > 0) { + array_unshift($bytes, $carry & 0xff); + $carry >>= 8; + } + } + + if (count($bytes) > 16) { + throw new InvalidArgumentException('TypeID suffix exceeds 128 bits'); + } + + $decoded = ''; + foreach ($bytes as $byte) { + $decoded .= chr($byte); + } + + return str_repeat("\0", 16 - strlen($decoded)) . $decoded; + } + + public static function encode(string $bytes): string + { + if (strlen($bytes) !== 16) { + throw new InvalidArgumentException('TypeID encoding requires exactly 16 bytes'); + } + + $unpacked = unpack('C*', $bytes); + $unpacked !== false || throw new \LogicException('Unable to unpack TypeID bytes'); + $number = []; + foreach ($unpacked as $byte) { + is_int($byte) || throw new \LogicException('Unable to unpack TypeID bytes'); + $number[] = $byte; + } + $encoded = ''; + + while ($number !== []) { + $quotient = []; + $remainder = 0; + foreach ($number as $byte) { + $value = ($remainder << 8) | $byte; + $digit = intdiv($value, 32); + $remainder = $value % 32; + if ($quotient !== [] || $digit !== 0) { + $quotient[] = $digit; + } + } + + $encoded = self::ALPHABET[$remainder] . $encoded; + $number = $quotient; + } + + return str_pad($encoded, 26, '0', STR_PAD_LEFT); + } +} diff --git a/src/TBSL.php b/src/TBSL.php index 9ae3eb6..a8fdfa8 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -8,14 +8,11 @@ use Exception; use Infocyph\UID\Configuration\TBSLConfig; use Infocyph\UID\Enums\ClockBackwardPolicy; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\UIDException; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; -use Infocyph\UID\Support\DecimalBytes; use Infocyph\UID\Support\GetSequence; -use Infocyph\UID\Support\UnsignedDecimal; final class TBSL { @@ -55,13 +52,12 @@ public static function fromBytes(string $bytes): string * @return string The generated unique identifier. * @throws Exception */ - public static function generate(int $machineId = 0, bool $sequenced = false): string + public static function generate(int $machineId = 0, bool $sequenced = true): string { - return (string) self::generateInternal( + return self::generateInternal( $machineId, $sequenced, ClockBackwardPolicy::WAIT, - IdOutputType::STRING, ); } @@ -70,13 +66,17 @@ public static function generate(int $machineId = 0, bool $sequenced = false): st * * @throws Exception */ - public static function generateWithConfig(TBSLConfig $config): int|string + public static function generateRandom(int $machineId = 0): string + { + return self::generate($machineId, false); + } + + public static function generateWithConfig(TBSLConfig $config): string { return self::generateInternal( $config->resolveMachineId(), $config->sequenced, $config->clockBackwardPolicy, - $config->outputType, $config->sequenceProvider, ); } @@ -93,19 +93,13 @@ public static function isValid(string $tbsl): bool * Parses a TBSL string and returns an array with its components. * * @param string $tbsl The TBSL string to parse. - * @return array{isValid: bool, time: DateTimeImmutable|null, machineId: int|null} + * @return array{time: DateTimeImmutable, machineId: int} * @throws Exception */ public static function parse(string $tbsl): array { - $data = [ - 'isValid' => self::isValid($tbsl), - 'time' => null, - 'machineId' => null, - ]; - - if (!$data['isValid']) { - return $data; + if (!self::isValid($tbsl)) { + throw new UIDException('Invalid TBSL string'); } $storeBytes = hex2bin('0' . substr($tbsl, 0, 15)); @@ -114,10 +108,11 @@ public static function parse(string $tbsl): array $storeValue = $storeParts['value'] ?? null; is_int($storeValue) || throw new Exception('Unable to parse TBSL timestamp'); $storeData = str_pad((string) $storeValue, 18, '0', STR_PAD_LEFT); - $data['time'] = new DateTimeImmutable('@' . substr($storeData, 0, 10) . '.' . substr($storeData, 10, 6)); - $data['machineId'] = (int) substr($storeData, -2); - return $data; + return [ + 'time' => new DateTimeImmutable('@' . substr($storeData, 0, 10) . '.' . substr($storeData, 10, 6)), + 'machineId' => (int) substr($storeData, -2), + ]; } /** @@ -157,15 +152,6 @@ private static function assertMachineId(int $machineId): void } } - private static function formatOutput(string $id, IdOutputType $outputType): int|string - { - return match ($outputType) { - IdOutputType::STRING => $id, - IdOutputType::BINARY => self::toBytes($id), - IdOutputType::INT => self::hexToDecimal($id), - }; - } - /** * @throws Exception */ @@ -173,9 +159,8 @@ private static function generateInternal( int $machineId, bool $sequenced, ClockBackwardPolicy $clockBackwardPolicy, - IdOutputType $outputType, ?SequenceProviderInterface $sequenceProvider = null, - ): int|string { + ): string { self::assertMachineId($machineId); [$micro, $seconds] = explode(' ', microtime()); @@ -203,26 +188,11 @@ private static function generateInternal( throw new UIDException('TBSL timestamp exceeds its 60-bit field'); } - $id = strtoupper(sprintf( + return strtoupper(sprintf( '%015s%05s', $storeData, $tail, )); - - return self::formatOutput($id, $outputType); - } - - private static function hexToDecimal(string $hex): int - { - $bytes = hex2bin(strtolower($hex)); - $bytes !== false || throw new UIDException('Unable to convert TBSL hex to bytes'); - $decimal = DecimalBytes::fromBytes($bytes); - - if (UnsignedDecimal::compare($decimal, (string) PHP_INT_MAX) === 1) { - throw new UIDException('TBSL integer output exceeds PHP_INT_MAX; use string or binary output'); - } - - return (int) $decimal; } /** @@ -266,8 +236,8 @@ private static function resolveTail( throw new UIDException('TBSL sequence provider must return a positive integer'); } - if ($sequence <= 0xfffff) { - return [$timeSequence, str_pad(dechex($sequence), 5, '0', STR_PAD_LEFT)]; + if ($sequence <= 0x100000) { + return [$timeSequence, str_pad(dechex($sequence - 1), 5, '0', STR_PAD_LEFT)]; } $timeSequence = self::waitUntilNextTimeSequence($timeSequence); diff --git a/src/TypeID.php b/src/TypeID.php new file mode 100644 index 0000000..c41ae0c --- /dev/null +++ b/src/TypeID.php @@ -0,0 +1,121 @@ +> 4) === 7 && (ord($bytes[8]) & 0xc0) === 0x80; + + return [ + 'type' => $type, + 'uuid' => $uuid, + 'time' => $isUuidV7 ? self::uuidV7Time($bytes) : null, + ]; + } + + public static function toUuid(string $typeId): string + { + [, $suffix] = self::split($typeId); + + return UUID::fromBytes(TypeIdCodec::decode($suffix)); + } + + private static function assertPrefix(string $type): void + { + $length = strlen($type); + if ($length > 63 || ($type !== '' && preg_match('/^[a-z](?:[a-z_]*[a-z])?$/D', $type) !== 1)) { + throw new TypeIDException('TypeID prefix must contain 0..63 lowercase letters or underscores and start/end with a letter'); + } + } + + /** + * @return array{0:string,1:string} + */ + private static function split(string $typeId): array + { + if (strlen($typeId) < 26) { + throw new TypeIDException('TypeID must contain a 26-character suffix'); + } + + $suffix = substr($typeId, -26); + $type = substr($typeId, 0, -26); + if ($type !== '') { + if (!str_ends_with($type, '_')) { + throw new TypeIDException('TypeID prefix must be separated from its suffix'); + } + + $type = substr($type, 0, -1); + if ($type === '') { + throw new TypeIDException('An empty TypeID prefix must omit the separator'); + } + } + + self::assertPrefix($type); + + try { + TypeIdCodec::decode($suffix); + } catch (\InvalidArgumentException $exception) { + throw new TypeIDException($exception->getMessage(), 0, $exception); + } + + return [$type, $suffix]; + } + + private static function uuidV7Time(string $bytes): DateTimeImmutable + { + $milliseconds = 0; + for ($index = 0; $index < 6; ++$index) { + $milliseconds = ($milliseconds << 8) | ord($bytes[$index]); + } + + return new DateTimeImmutable( + '@' + . intdiv($milliseconds, 1000) + . '.' + . str_pad((string) (($milliseconds % 1000) * 1000), 6, '0', STR_PAD_LEFT), + ); + } +} diff --git a/src/ULID.php b/src/ULID.php index 20b56ea..dafccde 100644 --- a/src/ULID.php +++ b/src/ULID.php @@ -13,20 +13,22 @@ final class ULID { + private const ENCODING_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + + private const ENCODING_LENGTH = 32; + private const MAX_TIMESTAMP = 281_474_976_710_655; - private static string $encodingChars = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + private const RANDOM_LENGTH = 16; - private static int $encodingLength = 32; + private const TIME_LENGTH = 10; private static int $lastGenTime = 0; /** @var array */ private static array $lastRandChars = []; - private static int $randomLength = 16; - - private static int $timeLength = 10; + private static ?int $sourcePid = null; /** * Decodes one of bases: 16, 32, 36, 58, 62 into canonical ULID. @@ -61,7 +63,7 @@ public static function fromBytes(string $bytes): string $bits += 8; while ($bits >= 5) { $bits -= 5; - $ulid .= self::$encodingChars[($buffer >> $bits) & 31]; + $ulid .= self::ENCODING_CHARS[($buffer >> $bits) & 31]; $buffer &= $bits === 0 ? 0 : (1 << $bits) - 1; } } @@ -78,6 +80,7 @@ public static function generate( ?DateTimeInterface $dateTime = null, UlidGenerationMode $mode = UlidGenerationMode::MONOTONIC, ): string { + self::resetAfterFork(); $time = $dateTime === null ? (int) floor(microtime(true) * 1000) : (int) $dateTime->format('Uv'); @@ -94,7 +97,7 @@ public static function generate( } $timeChars = self::encodeTime($time); - if (!$isMonotonic || !$isDuplicate || count(self::$lastRandChars) !== self::$randomLength) { + if (!$isMonotonic || !$isDuplicate || count(self::$lastRandChars) !== self::RANDOM_LENGTH) { self::resetRandomState(); } elseif (!self::incrementRandomState()) { if ($dateTime !== null) { @@ -144,10 +147,10 @@ public static function getTime(string $ulid): DateTimeImmutable } $time = 0; - for ($index = 0; $index < self::$timeLength; ++$index) { - $encodingIndex = strpos(self::$encodingChars, $ulid[$index]); + for ($index = 0; $index < self::TIME_LENGTH; ++$index) { + $encodingIndex = strpos(self::ENCODING_CHARS, $ulid[$index]); $encodingIndex !== false || throw new ULIDException('Invalid ULID character'); - $time = ($time * self::$encodingLength) + $encodingIndex; + $time = ($time * self::ENCODING_LENGTH) + $encodingIndex; } return new DateTimeImmutable( @@ -193,7 +196,7 @@ public static function toBytes(string $ulid): string $buffer = 0; $bits = -2; for ($index = 0; $index < 26; ++$index) { - $alphabetIndex = strpos(self::$encodingChars, $ulid[$index]); + $alphabetIndex = strpos(self::ENCODING_CHARS, $ulid[$index]); $alphabetIndex !== false || throw new ULIDException('Invalid ULID character'); $buffer = ($buffer << 5) | $alphabetIndex; $bits += 5; @@ -222,10 +225,10 @@ private static function assertTimestamp(int $timestamp): void private static function encodeTime(int $time): string { $timeChars = ''; - for ($i = self::$timeLength - 1; $i >= 0; --$i) { - $mod = $time % self::$encodingLength; - $timeChars = self::$encodingChars[$mod] . $timeChars; - $time = intdiv($time, self::$encodingLength); + for ($i = self::TIME_LENGTH - 1; $i >= 0; --$i) { + $mod = $time % self::ENCODING_LENGTH; + $timeChars = self::ENCODING_CHARS[$mod] . $timeChars; + $time = intdiv($time, self::ENCODING_LENGTH); } return $timeChars; @@ -233,7 +236,7 @@ private static function encodeTime(int $time): string private static function incrementRandomState(): bool { - for ($index = self::$randomLength - 1; $index >= 0; --$index) { + for ($index = self::RANDOM_LENGTH - 1; $index >= 0; --$index) { if (self::$lastRandChars[$index] < 31) { self::$lastRandChars[$index]++; @@ -249,13 +252,25 @@ private static function incrementRandomState(): bool private static function randomCharsFromState(): string { $randChars = ''; - for ($index = 0; $index < self::$randomLength; $index++) { - $randChars .= self::$encodingChars[self::$lastRandChars[$index]]; + for ($index = 0; $index < self::RANDOM_LENGTH; $index++) { + $randChars .= self::ENCODING_CHARS[self::$lastRandChars[$index]]; } return $randChars; } + private static function resetAfterFork(): void + { + $pid = (int) getmypid(); + if (self::$sourcePid === $pid) { + return; + } + + self::$sourcePid = $pid; + self::$lastGenTime = 0; + self::$lastRandChars = []; + } + /** * @throws Exception */ diff --git a/src/UUID.php b/src/UUID.php index eb349e1..00fefc9 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -14,28 +14,26 @@ final class UUID { - private const MAX_V7_NODE_STATES = 1024; - private const MAX_V7_TIMESTAMP = 281_474_976_710_655; - /** @var array */ - private static array $nsList = [ + private const NS_LIST = [ 'dns' => 0, 'url' => 1, 'oid' => 2, 'x500' => 4, ]; - /** @var array */ - private static array $randomLength = [ + private const RANDOM_LENGTH = [ 6 => 2, 7 => 4, 8 => 1, ]; - private static int $secondIntervals = 10_000_000; + private const SECOND_INTERVALS = 10_000_000; + + private const SECOND_INTERVALS_78 = 10_000; - private static int $secondIntervals78 = 10_000; + private const TIME_OFFSET = 0x01b21dd213814000; /** @var array */ private static array $subSec = [ @@ -45,8 +43,6 @@ final class UUID 8 => 0, ]; - private static int $timeOffset = 0x01b21dd213814000; - /** @var array */ private static array $unixTs = [ 1 => 0, @@ -55,11 +51,16 @@ final class UUID 8 => 0, ]; + private static int $v1ClockSequence; + + private static int $v1LastTimestamp = 0; + + private static ?int $v1SourcePid = null; + /** @var array{timestamp: int, tail: string}|null */ private static ?array $v7DefaultState = null; - /** @var array */ - private static array $v7NodeState = []; + private static ?int $v7SourcePid = null; /** * Converts a UUID to compact (32 hex chars, no dashes) format. @@ -107,7 +108,10 @@ public static function fromBytes(string $bytes): string */ public static function getNode(): string { - return bin2hex(random_bytes(6)); + $node = random_bytes(6); + $node[0] = $node[0] | "\x01"; + + return bin2hex($node); } /** @@ -119,7 +123,7 @@ public static function getNode(): string */ public static function guid(bool $trim = true): string { - if (function_exists('com_create_guid') === true) { + if (function_exists('com_create_guid')) { $data = com_create_guid(); if (!is_string($data)) { throw new UUIDException('Failed to generate GUID'); @@ -217,14 +221,17 @@ public static function normalize(string $uuid): string * Parses a UUID string and returns an array with information about the UUID. * * @param string $uuid The UUID string to parse. - * @return array{isValid: bool, version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null} + * @return array{version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null} * @throws Exception */ public static function parse(string $uuid): array { $uuid = trim($uuid, '{}'); + if (!self::isValid($uuid)) { + throw new UUIDException('Invalid UUID string'); + } + $data = [ - 'isValid' => self::isValid($uuid), 'version' => null, 'variant' => null, 'time' => null, @@ -232,10 +239,6 @@ public static function parse(string $uuid): array 'tail' => null, ]; - if (!$data['isValid']) { - return $data; - } - if (strcasecmp($uuid, self::nil()) === 0 || strcasecmp($uuid, self::max()) === 0) { return $data; } @@ -246,7 +249,7 @@ public static function parse(string $uuid): array } $variantN = hexdec($uuidData[3][0]); $data['version'] = (int) $uuidData[2][0]; - $data['time'] = in_array($data['version'], [1, 6, 7, 8], true) ? self::getTime($uuidData, $data['version']) : null; + $data['time'] = in_array($data['version'], [1, 6, 7], true) ? self::getTime($uuidData, $data['version']) : null; $data['tail'] = $uuidData[4]; $data['node'] = in_array($data['version'], [7, 8], true) ? null : $uuidData[4]; $data['variant'] = match (true) { @@ -326,14 +329,15 @@ public static function uppercase(string $uuid): string public static function v1(?string $node = null): string { [$unixTs, $subSec] = self::getUnixTimeSubSec(); - $time = str_pad(dechex((int) ($unixTs . $subSec) + self::$timeOffset), 16, '0', STR_PAD_LEFT); + [$timestamp, $clockSequence] = self::nextV1State((int) ($unixTs . $subSec) + self::TIME_OFFSET); + $time = str_pad(dechex($timestamp), 16, '0', STR_PAD_LEFT); return sprintf( '%08s-%04s-1%03s-%04x-%012s', substr($time, -8), substr($time, -12, 4), substr($time, -15, 3), - random_int(0, 0x3fff) & 0x3fff | 0x8000, + $clockSequence | 0x8000, $node === null ? self::getNode() : self::normalizeNode($node), ); } @@ -386,8 +390,8 @@ public static function v6(?string $node = null): string [$unixTs, $subSec] = self::getUnixTimeSubSec(6); $unixTs = (int) $unixTs; $subSec = (int) $subSec; - $timestamp = $unixTs * self::$secondIntervals + $subSec; - $timeHex = str_pad(dechex($timestamp + self::$timeOffset), 15, '0', STR_PAD_LEFT); + $timestamp = $unixTs * self::SECOND_INTERVALS + $subSec; + $timeHex = str_pad(dechex($timestamp + self::TIME_OFFSET), 15, '0', STR_PAD_LEFT); $string = substr_replace( substr($timeHex, -15), '6', @@ -402,10 +406,9 @@ public static function v6(?string $node = null): string * Generates a version 7 UUID. * * @param DateTimeInterface|null $dateTime An optional DateTimeInterface object to create the UUID. - * @param string|null $node The node identifier. Defaults to null. * @throws Exception */ - public static function v7(?DateTimeInterface $dateTime = null, ?string $node = null): string + public static function v7(?DateTimeInterface $dateTime = null): string { $unixTsMs = $dateTime === null ? (int) floor(microtime(true) * 1000) @@ -415,14 +418,7 @@ public static function v7(?DateTimeInterface $dateTime = null, ?string $node = n } $isExplicitTimestamp = $dateTime !== null; - $node = $node === null ? null : self::normalizeNode($node); - - if ($node === null) { - [$unixTsMs, $tail] = self::nextV7DefaultState($unixTsMs, $isExplicitTimestamp); - } else { - [$unixTsMs, $randomPart] = self::nextV7NodeState($node, $unixTsMs, $isExplicitTimestamp); - $tail = $randomPart . $node; - } + [$unixTsMs, $tail] = self::nextV7DefaultState($unixTsMs, $isExplicitTimestamp); $string = substr(str_pad(dechex($unixTsMs), 12, '0', STR_PAD_LEFT), -12) . $tail; @@ -441,8 +437,8 @@ public static function v8(?string $node = null): string [$unixTs, $subSec] = self::getUnixTimeSubSec(8); $unixTs = (int) $unixTs; $subSec = (int) $subSec; - $unixTsMs = $unixTs * 1000 + intdiv($subSec, self::$secondIntervals78); - $subSec = intdiv(($subSec % self::$secondIntervals78) << 14, self::$secondIntervals78); + $unixTsMs = $unixTs * 1000 + intdiv($subSec, self::SECOND_INTERVALS_78); + $subSec = intdiv(($subSec % self::SECOND_INTERVALS_78) << 14, self::SECOND_INTERVALS_78); $subSecA = $subSec >> 2; $subSecByte = ((ord(random_bytes(1)) & 0x0f) | (($subSec & 0x03) << 4)) & 0xff; $string = substr(str_pad(dechex($unixTsMs), 12, '0', STR_PAD_LEFT), -12) @@ -480,9 +476,9 @@ private static function getTime(array $uuid, int $version): DateTimeInterface { $timestamp = match ($version) { 1 => substr((string) $uuid[2], -3) . $uuid[1] . $uuid[0], - 6, 8 => $uuid[0] . $uuid[1] . substr((string) $uuid[2], -3), + 6 => $uuid[0] . $uuid[1] . substr((string) $uuid[2], -3), 7 => $uuid[0] . $uuid[1], - default => throw new UUIDException('Invalid version (applicable: 1, 6, 7, 8)'), + default => throw new UUIDException('Invalid version (applicable: 1, 6, 7)'), }; switch ($version) { @@ -493,23 +489,18 @@ private static function getTime(array $uuid, int $version): DateTimeInterface str_pad((string) (($unixTsMs % 1000) * 1000), 6, '0', STR_PAD_LEFT), ]; - break; - case 8: - $unixTs = hexdec(substr('0' . $timestamp, 0, 13)); - $subSec = -( - -( - (hexdec(substr('0' . $timestamp, 13)) << 2) - + (hexdec((string) $uuid[3][0]) & 0x03) - ) * self::$secondIntervals78 >> 14 - ); - $time = str_split((string) ($unixTs * self::$secondIntervals78 + $subSec), 10); - $time[1] = substr($time[1], 0, 6); - break; default: - $timestamp = self::hexToDecimal($timestamp); - $epochNanoseconds = bcsub($timestamp, (string) self::$timeOffset); - $time = explode('.', bcdiv($epochNanoseconds, (string) self::$secondIntervals, 6)); + $unixIntervals = (int) hexdec($timestamp) - self::TIME_OFFSET; + $time = [ + (string) intdiv($unixIntervals, self::SECOND_INTERVALS), + str_pad( + (string) intdiv($unixIntervals % self::SECOND_INTERVALS, 10), + 6, + '0', + STR_PAD_LEFT, + ), + ]; } return new DateTimeImmutable( @@ -541,7 +532,7 @@ private static function getUnixTimeSubSec(int $version = 1): array ) { $unixTs = self::$unixTs[$version]; $subSec = self::$subSec[$version]; - if ($subSec >= self::$secondIntervals - 1) { + if ($subSec >= self::SECOND_INTERVALS - 1) { $subSec = 0; $unixTs++; } else { @@ -554,28 +545,6 @@ private static function getUnixTimeSubSec(int $version = 1): array return [$unixTs, $subSec]; } - /** - * @return numeric-string - */ - private static function hexToDecimal(string $hex): string - { - $decimal = '0'; - $hex = strtolower(ltrim($hex, '0')); - if ($hex === '') { - return '0'; - } - - $length = strlen($hex); - for ($index = 0; $index < $length; ++$index) { - $decimal = bcadd( - bcmul($decimal, '16'), - (string) hexdec($hex[$index]), - ); - } - - return $decimal; - } - /** * Increments a hexadecimal counter string by one. * @@ -639,12 +608,40 @@ private static function nameBased(string $namespace, string $string, int $versio return self::output($version, hash($algorithm, $binaryNamespace . $string)); } + /** + * @return array{0:int,1:int} + */ + private static function nextV1State(int $timestamp): array + { + $pid = (int) getmypid(); + if (self::$v1SourcePid !== $pid) { + self::$v1SourcePid = $pid; + self::$v1ClockSequence = random_int(0, 0x3fff); + self::$v1LastTimestamp = 0; + } + + if ($timestamp <= self::$v1LastTimestamp) { + self::$v1ClockSequence = (self::$v1ClockSequence + 1) & 0x3fff; + $timestamp = self::$v1LastTimestamp + 1; + } + + self::$v1LastTimestamp = $timestamp; + + return [$timestamp, self::$v1ClockSequence]; + } + /** * @return array{0: int, 1: string} * @throws Exception */ private static function nextV7DefaultState(int $unixTsMs, bool $isExplicitTimestamp): array { + $pid = (int) getmypid(); + if (self::$v7SourcePid !== $pid) { + self::$v7SourcePid = $pid; + self::$v7DefaultState = null; + } + $state = self::$v7DefaultState; if ($state === null || $unixTsMs > $state['timestamp']) { @@ -677,50 +674,6 @@ private static function nextV7DefaultState(int $unixTsMs, bool $isExplicitTimest return [$unixTsMs, $tail]; } - /** - * @return array{0: int, 1: string} - * @throws Exception - */ - private static function nextV7NodeState(string $node, int $unixTsMs, bool $isExplicitTimestamp): array - { - $stateKey = 'node:' . $node; - $state = self::$v7NodeState[$stateKey] ?? null; - - if ($state === null || $unixTsMs > $state['timestamp']) { - $randomPart = self::randomV7NodePart(); - if ($state === null && count(self::$v7NodeState) >= self::MAX_V7_NODE_STATES) { - $oldestKey = array_key_first(self::$v7NodeState); - unset(self::$v7NodeState[$oldestKey]); - } - - self::$v7NodeState[$stateKey] = ['timestamp' => $unixTsMs, 'random' => $randomPart]; - - return [$unixTsMs, $randomPart]; - } - - if ($isExplicitTimestamp && $state['timestamp'] !== $unixTsMs) { - $randomPart = self::randomV7NodePart(); - self::$v7NodeState[$stateKey] = ['timestamp' => $unixTsMs, 'random' => $randomPart]; - - return [$unixTsMs, $randomPart]; - } - - $unixTsMs = $state['timestamp']; - $randomPart = self::incrementHexCounter($state['random']); - if ($randomPart === null) { - if ($isExplicitTimestamp) { - throw new UUIDException('Monotonic UUID v7 overflow for the provided timestamp'); - } - - $unixTsMs = self::nextV7Timestamp($state['timestamp']); - $randomPart = self::randomV7NodePart(); - } - - self::$v7NodeState[$stateKey] = ['timestamp' => $unixTsMs, 'random' => $randomPart]; - - return [$unixTsMs, $randomPart]; - } - /** * Waits until the system clock moves to the next millisecond. */ @@ -779,9 +732,16 @@ private static function nsResolve(string $namespace): string if (self::isValid($namespace)) { return str_replace('-', '', $namespace); } - $namespace = str_replace(['namespace', 'ns', '_'], '', strtolower($namespace)); - if (isset(self::$nsList[$namespace])) { - return '6ba7b81' . self::$nsList[$namespace] . '9dad11d180b400c04fd430c8'; + $namespace = strtolower($namespace); + foreach (['namespace_', 'namespace-', 'ns_', 'ns-'] as $prefix) { + if (str_starts_with($namespace, $prefix)) { + $namespace = substr($namespace, strlen($prefix)); + + break; + } + } + if (array_key_exists($namespace, self::NS_LIST)) { + return '6ba7b81' . self::NS_LIST[$namespace] . '9dad11d180b400c04fd430c8'; } return ''; @@ -817,7 +777,11 @@ private static function output(int $version, string $id): string private static function prepareNode(int $version, ?string $node = null): string { if ($node === null) { - return bin2hex(random_bytes(self::randomLengthFor($version) + 6)); + $randomLength = self::randomLengthFor($version); + $random = random_bytes($randomLength + 6); + $random[$randomLength] = $random[$randomLength] | "\x01"; + + return bin2hex($random); } return bin2hex(random_bytes(self::randomLengthFor($version))) . self::normalizeNode($node); @@ -828,20 +792,7 @@ private static function prepareNode(int $version, ?string $node = null): string */ private static function randomLengthFor(int $version): int { - $length = self::$randomLength[$version] ?? throw new UUIDException('Unsupported UUID version for random length'); - if ($length < 1) { - throw new UUIDException('Random length must be greater than zero'); - } - - return $length; - } - - /** - * @throws Exception - */ - private static function randomV7NodePart(): string - { - return bin2hex(random_bytes(self::randomLengthFor(7))); + return self::RANDOM_LENGTH[$version] ?? throw new UUIDException('Unsupported UUID version for random length'); } /** diff --git a/src/Value/AbstractParsedIdValue.php b/src/Value/AbstractParsedIdValue.php index 7bb633c..e6cb017 100644 --- a/src/Value/AbstractParsedIdValue.php +++ b/src/Value/AbstractParsedIdValue.php @@ -16,7 +16,7 @@ /** @var TParsed */ protected array $parsed; - final public function __construct(string $value) + public function __construct(string $value) { $this->parsed = $this->initializeComparableValue( $value, diff --git a/src/Value/SnowflakeValue.php b/src/Value/SnowflakeValue.php index 6b50e0d..fd53e09 100644 --- a/src/Value/SnowflakeValue.php +++ b/src/Value/SnowflakeValue.php @@ -12,6 +12,11 @@ */ final readonly class SnowflakeValue extends AbstractParsedIdValue { + public function __construct(string $value, private ?int $customEpoch = null) + { + parent::__construct($value); + } + public function getDatacenterId(): int { return $this->parsed['datacenter_id']; @@ -39,7 +44,9 @@ protected function invalidMessage(): string protected function parser(): callable { - return Snowflake::parse(...); + return $this->customEpoch === null + ? Snowflake::parse(...) + : fn(string $id): array => Snowflake::parseWithEpoch($id, $this->customEpoch); } protected function validator(): callable diff --git a/src/Value/SonyflakeValue.php b/src/Value/SonyflakeValue.php index c30da79..a67b11b 100644 --- a/src/Value/SonyflakeValue.php +++ b/src/Value/SonyflakeValue.php @@ -12,6 +12,11 @@ */ final readonly class SonyflakeValue extends AbstractParsedIdValue { + public function __construct(string $value, private ?int $customEpoch = null) + { + parent::__construct($value); + } + public function getMachineId(): int { return $this->parsed['machine_id']; @@ -29,7 +34,9 @@ protected function invalidMessage(): string protected function parser(): callable { - return Sonyflake::parse(...); + return $this->customEpoch === null + ? Sonyflake::parse(...) + : fn(string $id): array => Sonyflake::parseWithEpoch($id, $this->customEpoch); } protected function validator(): callable diff --git a/src/Value/TbslValue.php b/src/Value/TbslValue.php index 95eeb4d..9844b5e 100644 --- a/src/Value/TbslValue.php +++ b/src/Value/TbslValue.php @@ -8,16 +8,16 @@ use Infocyph\UID\TBSL; /** - * @extends AbstractParsedIdValue + * @extends AbstractParsedIdValue */ final readonly class TbslValue extends AbstractParsedIdValue { - public function getMachineId(): ?int + public function getMachineId(): int { return $this->parsed['machineId']; } - public function getTimestamp(): ?DateTimeImmutable + public function getTimestamp(): DateTimeImmutable { return $this->parsed['time']; } diff --git a/src/Value/UuidValue.php b/src/Value/UuidValue.php index d7ee5fc..7b383c6 100644 --- a/src/Value/UuidValue.php +++ b/src/Value/UuidValue.php @@ -13,7 +13,7 @@ final readonly class UuidValue implements IdValueInterface { /** - * @var array{isValid: bool, version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null} + * @var array{version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null} */ private array $parsed; @@ -23,9 +23,6 @@ public function __construct(string $value) { $this->value = UUID::normalize($value); $this->parsed = UUID::parse($this->value); - if (!$this->parsed['isValid']) { - throw new \InvalidArgumentException('Invalid UUID string'); - } } public function __toString(): string @@ -70,7 +67,7 @@ public function getVersion(): ?int public function isSortable(): bool { - return in_array($this->getVersion(), [6, 7, 8], true); + return in_array($this->getVersion(), [6, 7], true); } public function toString(): string diff --git a/src/XID.php b/src/XID.php index 621a8a3..92830b0 100644 --- a/src/XID.php +++ b/src/XID.php @@ -6,28 +6,31 @@ use DateTimeImmutable; use Exception; -use Infocyph\UID\Contracts\IdAlgorithmInterface; -use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Exceptions\UIDException; use Infocyph\UID\Support\BinaryUnpack; -final class XID implements IdAlgorithmInterface +final class XID { + private const ALPHABET = '0123456789abcdefghijklmnopqrstuv'; + private static ?int $counter = null; private static ?string $machine = null; private static ?string $pid = null; + private static ?int $sourcePid = null; + /** * @throws Exception */ public static function fromBytes(string $bytes): string { if (strlen($bytes) !== 12) { - throw new Exception('XID binary data must be exactly 12 bytes'); + throw new UIDException('XID binary data must be exactly 12 bytes'); } - return str_pad(BaseEncoder::encodeBytes($bytes, 32), 20, '0', STR_PAD_LEFT); + return self::encodeBytes($bytes); } /** @@ -37,6 +40,7 @@ public static function fromBytes(string $bytes): string */ public static function generate(): string { + self::ensureProcessState(); $time = pack('N', time()); $machine = self::machine(); $pid = self::pidBytes(); @@ -44,33 +48,33 @@ public static function generate(): string $bytes = $time . $machine . $pid . $counter; // 12 bytes - return str_pad(BaseEncoder::encodeBytes($bytes, 32), 20, '0', STR_PAD_LEFT); + return self::encodeBytes($bytes); } public static function isValid(string $xid): bool { - return preg_match('/^[01][0-9a-v]{19}$/D', $xid) === 1; + return preg_match('/^[0-9a-v]{19}[0g]$/D', $xid) === 1; } /** - * @return array{isValid: bool, time: DateTimeImmutable|null, machine: string|null, pid: int|null, counter: int|null} + * @return array{time: DateTimeImmutable, machine: string, pid: int, counter: int} * @throws Exception */ public static function parse(string $xid): array { - $data = ['isValid' => self::isValid($xid), 'time' => null, 'machine' => null, 'pid' => null, 'counter' => null]; - if (!$data['isValid']) { - return $data; + if (!self::isValid($xid)) { + throw new UIDException('Invalid XID string'); } $bytes = self::toBytes($xid); $timestamp = BinaryUnpack::u32(substr($bytes, 0, 4), 'Unable to parse XID timestamp'); - $data['time'] = new DateTimeImmutable('@' . $timestamp); - $data['machine'] = bin2hex(substr($bytes, 4, 3)); - $data['pid'] = BinaryUnpack::u16(substr($bytes, 7, 2), 'Unable to parse XID pid'); - $data['counter'] = BinaryUnpack::u24(substr($bytes, 9, 3), 'Unable to parse XID counter'); - return $data; + return [ + 'time' => new DateTimeImmutable('@' . $timestamp), + 'machine' => bin2hex(substr($bytes, 4, 3)), + 'pid' => BinaryUnpack::u16(substr($bytes, 7, 2), 'Unable to parse XID pid'), + 'counter' => BinaryUnpack::u24(substr($bytes, 9, 3), 'Unable to parse XID counter'), + ]; } /** @@ -79,10 +83,10 @@ public static function parse(string $xid): array public static function toBytes(string $xid): string { if (!self::isValid($xid)) { - throw new Exception('Invalid XID string'); + throw new UIDException('Invalid XID string'); } - return BaseEncoder::decodeToBytes($xid, 32, 12); + return self::decodeText($xid); } private static function counterBytes(): string @@ -93,9 +97,67 @@ private static function counterBytes(): string return substr(pack('N', self::$counter), 1, 3); } + private static function decodeText(string $xid): string + { + $buffer = 0; + $bits = 0; + $bytes = ''; + + for ($index = 0; $index < 20; ++$index) { + $digit = strpos(self::ALPHABET, $xid[$index]); + $digit !== false || throw new \LogicException('Validated XID contains an invalid character'); + $buffer = ($buffer << 5) | $digit; + $bits += 5; + + if ($bits >= 8) { + $bits -= 8; + $bytes .= chr(($buffer >> $bits) & 0xff); + $buffer &= (1 << $bits) - 1; + } + } + + return $bytes; + } + + private static function encodeBytes(string $bytes): string + { + $buffer = 0; + $bits = 0; + $encoded = ''; + + for ($index = 0; $index < 12; ++$index) { + $buffer = ($buffer << 8) | ord($bytes[$index]); + $bits += 8; + + while ($bits >= 5) { + $bits -= 5; + $encoded .= self::ALPHABET[($buffer >> $bits) & 0x1f]; + $buffer &= (1 << $bits) - 1; + } + } + + if ($bits > 0) { + $encoded .= self::ALPHABET[($buffer << (5 - $bits)) & 0x1f]; + } + + return $encoded; + } + + private static function ensureProcessState(): void + { + $pid = (int) getmypid(); + if (self::$sourcePid === $pid) { + return; + } + + self::$sourcePid = $pid; + self::$pid = pack('n', $pid % 0x10000); + self::$counter = random_int(0, 0xffffff); + } + private static function machine(): string { - return self::$machine ??= substr(hash('sha1', gethostname() ?: 'localhost', true), 0, 3); + return self::$machine ??= substr(hash('sha256', gethostname() ?: 'localhost', true), 0, 3); } private static function pidBytes(): string @@ -104,6 +166,6 @@ private static function pidBytes(): string return self::$pid; } - return self::$pid = pack('n', getmypid() % 0x10000); + return self::$pid = pack('n', (int) getmypid() % 0x10000); } } diff --git a/src/functions.php b/src/functions.php index df702b3..69ca20e 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,539 +2,108 @@ declare(strict_types=1); -use Infocyph\UID\CUID2; -use Infocyph\UID\DeterministicId; -use Infocyph\UID\Enums\UlidGenerationMode; -use Infocyph\UID\Exceptions\FileLockException; -use Infocyph\UID\Exceptions\SnowflakeException; -use Infocyph\UID\Exceptions\SonyflakeException; -use Infocyph\UID\KSUID; -use Infocyph\UID\NanoID; -use Infocyph\UID\OpaqueId; -use Infocyph\UID\Randflake; -use Infocyph\UID\Snowflake; -use Infocyph\UID\Sonyflake; -use Infocyph\UID\TBSL; -use Infocyph\UID\ULID; -use Infocyph\UID\UUID; -use Infocyph\UID\XID; - -if (!function_exists('__uid_base_call')) { - function __uid_base_call(string $family, string $method, string $value, int $base): string - { - return match ($method . ':' . $family) { - 'toBase:uuid' => UUID::toBase($value, $base), - 'toBase:ulid' => ULID::toBase($value, $base), - 'toBase:randflake' => Randflake::toBase($value, $base), - 'toBase:snowflake' => Snowflake::toBase($value, $base), - 'toBase:sonyflake' => Sonyflake::toBase($value, $base), - 'toBase:tbsl' => TBSL::toBase($value, $base), - 'fromBase:uuid' => UUID::fromBase($value, $base), - 'fromBase:ulid' => ULID::fromBase($value, $base), - 'fromBase:randflake' => Randflake::fromBase($value, $base), - 'fromBase:snowflake' => Snowflake::fromBase($value, $base), - 'fromBase:sonyflake' => Sonyflake::fromBase($value, $base), - 'fromBase:tbsl' => TBSL::fromBase($value, $base), - default => throw new InvalidArgumentException('Unsupported base operation or ID family'), - }; - } -} - -if (!function_exists('__uid_is_valid')) { - function __uid_is_valid(string $family, string $id): bool - { - return match ($family) { - 'snowflake' => Snowflake::isValid($id), - 'randflake' => Randflake::isValid($id), - 'sonyflake' => Sonyflake::isValid($id), - 'tbsl' => TBSL::isValid($id), - default => throw new InvalidArgumentException('Unsupported ID family for validation'), - }; - } -} - -if (!function_exists('uuid1')) { - /** - * Generates a version 1 UUID - * - * @param string|null $node The node value to use in the UUID. - * @return string The generated UUID. - * @throws Exception - */ - function uuid1(?string $node = null): string - { - return UUID::v1($node); - } -} - -if (!function_exists('ksuid')) { - /** - * Generates KSUID. - * - * @throws Exception - */ - function ksuid(?DateTimeInterface $dateTime = null): string - { - return KSUID::generate($dateTime); - } -} - -if (!function_exists('xid')) { - /** - * Generates XID. - * - * @throws Exception - */ - function xid(): string - { - return XID::generate(); - } -} - -if (!function_exists('uuid3')) { - /** - * Generate a Version 3 UUID. - * - * @param string $namespace The namespace to use for the UUID generation. - * @param string $string The string to generate the UUID from. - * @return string The generated UUID. - * @throws Exception - */ - function uuid3(string $namespace, string $string): string - { - return UUID::v3($namespace, $string); - } -} - -if (!function_exists('uuid4')) { - /** - * Generates a version 4 UUID. - * - * @return string The generated UUID. - * @throws Exception - */ - function uuid4(): string - { - return UUID::v4(); - } -} - -if (!function_exists('uuid5')) { - /** - * Generate a Version 5 UUID. - * - * @param string $namespace The namespace to use for the UUID generation. - * @param string $string The string to generate the UUID from. - * @return string The generated UUID. - * @throws Exception - */ - function uuid5(string $namespace, string $string): string - { - return UUID::v5($namespace, $string); - } -} - -if (!function_exists('uuid6')) { - /** - * Generates a Version 6 UUID. - * - * @param string|null $node The node identifier. Defaults to null. - * @throws Exception - */ - function uuid6(?string $node = null): string - { - return UUID::v6($node); - } -} - -if (!function_exists('uuid7')) { - /** - * Generates a version 7 UUID. - * - * @param DateTimeInterface|null $dateTime An optional DateTimeInterface object to create the UUID. - * @param string|null $node The node identifier. Defaults to null. - * @throws Exception - */ - function uuid7(?DateTimeInterface $dateTime = null, ?string $node = null): string - { - return UUID::v7($dateTime, $node); - } -} +namespace Infocyph\UID; -if (!function_exists('uuid8')) { - /** - * Generates a Version 8 UUID. - * - * @param string|null $node The node identifier. Defaults to null. - * @throws Exception - */ - function uuid8(?string $node = null): string - { - return UUID::v8($node); - } -} - -if (!function_exists('uuid_nil')) { - /** - * Returns UUID NIL value. - */ - function uuid_nil(): string - { - return UUID::nil(); - } -} - -if (!function_exists('uuid_max')) { - /** - * Returns UUID MAX value. - */ - function uuid_max(): string - { - return UUID::max(); - } -} - -if (!function_exists('uuid_is_nil')) { - /** - * Checks whether the UUID is NIL. - */ - function uuid_is_nil(string $uuid): bool - { - return UUID::isNil($uuid); - } -} - -if (!function_exists('uuid_is_max')) { - /** - * Checks whether the UUID is MAX. - */ - function uuid_is_max(string $uuid): bool - { - return UUID::isMax($uuid); - } -} - -if (!function_exists('uuid_normalize')) { - /** - * Normalizes UUID to canonical lowercase format. - * - * @throws Exception - */ - function uuid_normalize(string $uuid): string - { - return UUID::normalize($uuid); - } -} - -if (!function_exists('uuid_compact')) { - /** - * Converts UUID to compact format. - * - * @throws Exception - */ - function uuid_compact(string $uuid): string - { - return UUID::compact($uuid); - } -} - -if (!function_exists('uuid_urn')) { - function uuid_urn(string $uuid): string - { - return UUID::toUrn($uuid); - } -} -if (!function_exists('uuid_braces')) { - function uuid_braces(string $uuid): string - { - return UUID::toBraces($uuid); - } -} -if (!function_exists('uuid_to_base')) { - function uuid_to_base(string $uuid, int $base): string - { - return UUID::toBase($uuid, $base); - } -} -if (!function_exists('uuid_from_base')) { - function uuid_from_base(string $encoded, int $base): string - { - return UUID::fromBase($encoded, $base); - } -} -if (!function_exists('guid')) { - function guid(bool $trim = true): string - { - return UUID::guid($trim); - } -} - -if (!function_exists('ulid')) { - function ulid(?DateTimeInterface $dateTime = null): string - { - return ULID::generate($dateTime); - } -} -if (!function_exists('ulid_monotonic')) { - function ulid_monotonic(?DateTimeInterface $dateTime = null): string - { - return ULID::generate($dateTime, UlidGenerationMode::MONOTONIC); - } -} -if (!function_exists('ulid_random')) { - function ulid_random(?DateTimeInterface $dateTime = null): string - { - return ULID::generate($dateTime, UlidGenerationMode::RANDOM); - } -} -if (!function_exists('ulid_to_base')) { - function ulid_to_base(string $ulid, int $base): string - { - return __uid_base_call('ulid', 'toBase', $ulid, $base); - } -} -if (!function_exists('ulid_from_base')) { - function ulid_from_base(string $encoded, int $base): string - { - return __uid_base_call('ulid', 'fromBase', $encoded, $base); - } -} +use DateTimeInterface; +use Infocyph\UID\Configuration\RandflakeConfig; +use Infocyph\UID\Configuration\SnowflakeConfig; +use Infocyph\UID\Configuration\SonyflakeConfig; +use Infocyph\UID\Configuration\TBSLConfig; +use Infocyph\UID\Enums\UlidGenerationMode; -if (!function_exists('snowflake')) { - /** @throws SnowflakeException|FileLockException */ - function snowflake(int $datacenter = 0, int $workerId = 0): string - { - return Snowflake::generate($datacenter, $workerId); - } +function cuid2(int $length = 24): string +{ + return CUID2::generate($length); } -if (!function_exists('randflake')) { - /** - * @throws \Infocyph\UID\Exceptions\RandflakeException|\Infocyph\UID\Exceptions\FileLockException - */ - function randflake(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string - { - return Randflake::generate($nodeId, $leaseStart, $leaseEnd, $secret); - } +function ksuid(?DateTimeInterface $dateTime = null): string +{ + return KSUID::generate($dateTime); } -if (!function_exists('randflake_string')) { - /** - * @throws \Infocyph\UID\Exceptions\RandflakeException|\Infocyph\UID\Exceptions\FileLockException - */ - function randflake_string(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string - { - return Randflake::generateString($nodeId, $leaseStart, $leaseEnd, $secret); - } +function nano_id(int $length = 21): string +{ + return NanoID::generate($length); } -if (!function_exists('randflake_parse')) { - /** - * @return array{time: DateTimeImmutable, node_id: int, sequence: int} - * @throws Exception - */ - function randflake_parse(string $id, string $secret): array - { - return Randflake::parse($id, $secret); - } +function object_id(?DateTimeInterface $dateTime = null): string +{ + return ObjectID::generate($dateTime); } -if (!function_exists('randflake_parse_string')) { - /** - * @return array{time: DateTimeImmutable, node_id: int, sequence: int} - * @throws Exception - */ - function randflake_parse_string(string $id, string $secret): array - { - return Randflake::parseString($id, $secret); - } +function randflake(RandflakeConfig $config): string +{ + return Randflake::generateWithConfig($config); } -if (!function_exists('randflake_inspect')) { - /** - * @return array{timestamp: int, node_id: int, sequence: int} - * @throws \Infocyph\UID\Exceptions\RandflakeException - */ - function randflake_inspect(string $id, string $secret): array - { - return Randflake::inspect($id, $secret); - } +function random_id(int $length = 21, string $alphabet = RandomId::DEFAULT_ALPHABET): string +{ + return RandomId::generate($length, $alphabet); } -if (!function_exists('randflake_inspect_string')) { - /** - * @return array{timestamp: int, node_id: int, sequence: int} - * @throws \Infocyph\UID\Exceptions\RandflakeException - */ - function randflake_inspect_string(string $id, string $secret): array - { - return Randflake::inspectString($id, $secret); - } +function snowflake(?SnowflakeConfig $config = null): string +{ + return $config === null ? Snowflake::generate() : Snowflake::generateWithConfig($config); } -if (!function_exists('sonyflake')) { - /** @throws SonyflakeException|FileLockException */ - function sonyflake(int $machineId = 0): string - { - return Sonyflake::generate($machineId); - } -} -if (!function_exists('tbsl')) { - function tbsl(int $machineId = 0, bool $sequenced = false): string - { - return TBSL::generate($machineId, $sequenced); - } +function sonyflake(?SonyflakeConfig $config = null): string +{ + return $config === null ? Sonyflake::generate() : Sonyflake::generateWithConfig($config); } -if (!function_exists('tbsl_to_base')) { - function tbsl_to_base(string $id, int $base): string - { - return TBSL::toBase($id, $base); - } -} -if (!function_exists('tbsl_from_base')) { - function tbsl_from_base(string $encoded, int $base): string - { - return TBSL::fromBase($encoded, $base); - } +function tbsl(?TBSLConfig $config = null): string +{ + return $config === null ? TBSL::generate() : TBSL::generateWithConfig($config); } -if (!function_exists('snowflake_is_valid')) { - function snowflake_is_valid(string $id): bool - { - return Snowflake::isValid($id); - } -} -if (!function_exists('randflake_is_valid')) { - function randflake_is_valid(string $id): bool - { - return Randflake::isValid($id); - } -} -if (!function_exists('sonyflake_is_valid')) { - function sonyflake_is_valid(string $id): bool - { - return Sonyflake::isValid($id); - } -} -if (!function_exists('tbsl_is_valid')) { - function tbsl_is_valid(string $id): bool - { - return TBSL::isValid($id); - } +function type_id(string $type = ''): string +{ + return TypeID::generate($type); } -if (!function_exists('snowflake_to_base')) { - function snowflake_to_base(string $id, int $base): string - { - return __uid_base_call( - family: 'snowflake', - method: 'toBase', - value: $id, - base: $base, - ); - } -} -if (!function_exists('snowflake_from_base')) { - function snowflake_from_base(string $encoded, int $base): string - { - return __uid_base_call( - family: 'snowflake', - method: 'fromBase', - value: $encoded, - base: $base, - ); - } +function ulid( + ?DateTimeInterface $dateTime = null, + UlidGenerationMode $mode = UlidGenerationMode::MONOTONIC, +): string { + return ULID::generate($dateTime, $mode); } -if (!function_exists('randflake_to_base')) { - function randflake_to_base(string $id, int $base): string - { - return Randflake::toBase($id, $base); - } -} -if (!function_exists('randflake_from_base')) { - function randflake_from_base(string $encoded, int $base): string - { - return Randflake::fromBase($encoded, $base); - } +function uuid1(?string $node = null): string +{ + return UUID::v1($node); } -if (!function_exists('sonyflake_to_base')) { - function sonyflake_to_base(string $id, int $base): string - { - return Sonyflake::toBase($id, $base); - } -} -if (!function_exists('sonyflake_from_base')) { - function sonyflake_from_base(string $encoded, int $base): string - { - return Sonyflake::fromBase($encoded, $base); - } +function uuid3(string $namespace, string $string): string +{ + return UUID::v3($namespace, $string); } -if (!function_exists('nanoid')) { - /** - * Generates Nano ID. - * - * @throws Exception - */ - function nanoid(int $size = 21): string - { - return NanoID::generate($size); - } +function uuid4(): string +{ + return UUID::v4(); } -if (!function_exists('nanoid_is_valid')) { - /** - * Checks whether NanoID string is valid. - */ - function nanoid_is_valid(string $id, ?int $size = null): bool - { - return NanoID::isValid($id, $size); - } +function uuid5(string $namespace, string $string): string +{ + return UUID::v5($namespace, $string); } -if (!function_exists('cuid2')) { - /** - * Generates CUID2. - * - * @throws Exception - */ - function cuid2(int $maxLength = 24): string - { - return CUID2::generate($maxLength); - } +function uuid6(?string $node = null): string +{ + return UUID::v6($node); } -if (!function_exists('cuid2_is_valid')) { - /** - * Checks whether CUID2 string is valid. - */ - function cuid2_is_valid(string $id): bool - { - return CUID2::isValid($id); - } +function uuid7(?DateTimeInterface $dateTime = null): string +{ + return UUID::v7($dateTime); } -if (!function_exists('opaque_id')) { - /** - * Generates short opaque random ID. - * - * @throws Exception - */ - function opaque_id(int $length = 12): string - { - return OpaqueId::random($length); - } +function uuid8(?string $node = null): string +{ + return UUID::v8($node); } -if (!function_exists('deterministic_id')) { - /** - * Generates deterministic ID from payload. - */ - function deterministic_id(string $payload, int $length = 24, string $namespace = 'default'): string - { - return DeterministicId::fromPayload($payload, $length, $namespace); - } +function xid(): string +{ + return XID::generate(); } diff --git a/tests/AdditionalIdsTest.php b/tests/AdditionalIdsTest.php index 499f850..2ca4680 100644 --- a/tests/AdditionalIdsTest.php +++ b/tests/AdditionalIdsTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -use Infocyph\UID\Contracts\IdAlgorithmInterface; use Infocyph\UID\DeterministicId; use Infocyph\UID\IdComparator; use Infocyph\UID\KSUID; use Infocyph\UID\OpaqueId; +use Infocyph\UID\RandomId; use Infocyph\UID\XID; test('KSUID generation and parsing', function () { @@ -15,22 +15,34 @@ expect(KSUID::isValid($id))->toBeTrue() ->and($id)->toHaveLength(27) - ->and($parsed['isValid'])->toBeTrue() ->and($parsed['time'])->not()->toBeNull(); }); +test('KSUID matches the reference text and binary vector', function () { + $bytes = hex2bin('0669f7efb5a1cd34b5f99d1154fb6853345c9735'); + expect($bytes)->toBeString() + ->and(KSUID::fromBytes($bytes))->toBe('0ujtsYcgvSTl8PAuAdqWYSMnLOv') + ->and(KSUID::toBytes('0ujtsYcgvSTl8PAuAdqWYSMnLOv'))->toBe($bytes); +}); + test('XID generation and parsing', function () { $id = XID::generate(); $parsed = XID::parse($id); expect(XID::isValid($id))->toBeTrue() ->and($id)->toHaveLength(20) - ->and($parsed['isValid'])->toBeTrue() ->and($parsed['time'])->not()->toBeNull(); }); +test('XID matches the upstream text and binary vector', function () { + $bytes = hex2bin('4d88e15b60f486e428412dc9'); + expect($bytes)->toBeString() + ->and(XID::fromBytes($bytes))->toBe('9m4e2mr0ui3e8a215n4g') + ->and(XID::toBytes('9m4e2mr0ui3e8a215n4g'))->toBe($bytes); +}); + test('Opaque and deterministic IDs', function () { - $opaque = OpaqueId::random(14); + $opaque = RandomId::generate(14); $det1 = DeterministicId::fromPayload('payload', 20, 'ns'); $det2 = DeterministicId::fromPayload('payload', 20, 'ns'); @@ -39,10 +51,10 @@ ->and($det1)->toBe($det2); }); -test('Opaque ID rejects non-positive lengths', function () { - expect(fn () => OpaqueId::random(0))->toThrow(\InvalidArgumentException::class) - ->and(fn () => OpaqueId::random(-1))->toThrow(\InvalidArgumentException::class) - ->and(fn () => OpaqueId::random(1025))->toThrow(\InvalidArgumentException::class); +test('Random ID rejects lengths outside the ID boundary', function () { + expect(fn () => RandomId::generate(0))->toThrow(\InvalidArgumentException::class) + ->and(fn () => RandomId::generate(-1))->toThrow(\InvalidArgumentException::class) + ->and(fn () => RandomId::generate(1025))->toThrow(\InvalidArgumentException::class); }); test('IdComparator sorts numeric and lexical values', function () { @@ -56,7 +68,7 @@ test('KSUID and XID reject text values outside their binary ranges', function () { expect(KSUID::isValid(str_repeat('z', 27)))->toBeFalse() ->and(fn () => KSUID::toBytes(str_repeat('z', 27)))->toThrow(\Exception::class) - ->and(XID::isValid('2' . str_repeat('0', 19)))->toBeFalse(); + ->and(XID::isValid(str_repeat('0', 19) . '1'))->toBeFalse(); }); test('KSUID rejects timestamps outside its unsigned 32-bit lifetime', function () { @@ -71,6 +83,14 @@ ->toThrow(\InvalidArgumentException::class) ->and(fn () => DeterministicId::fromPayload('payload', 44)) ->toThrow(\InvalidArgumentException::class) - ->and(fn () => DeterministicId::fromPayload('payload', 24, 'invalid|namespace')) - ->toThrow(\InvalidArgumentException::class); + ->and(DeterministicId::fromPayload('payload', 24, 'namespace|is|unambiguous')) + ->toHaveLength(24); +}); + +test('Opaque IDs support the complete signed non-negative domain', function () { + foreach ([0, 1, PHP_INT_MAX] as $value) { + expect(OpaqueId::toInt(OpaqueId::fromInt($value, 'salt'), 'salt'))->toBe($value); + } + + expect(fn () => OpaqueId::fromInt(-1))->toThrow(\InvalidArgumentException::class); }); diff --git a/tests/FilesystemSequenceV5Test.php b/tests/FilesystemSequenceV5Test.php new file mode 100644 index 0000000..b29a04c --- /dev/null +++ b/tests/FilesystemSequenceV5Test.php @@ -0,0 +1,170 @@ +next('snowflake', 7, 100), + $provider->next('snowflake', 7, 100), + $provider->next('snowflake', 7, 100), + $provider->next('snowflake', 7, 100), + ])->toBe([1, 2, 3, 4]); + + $stateFile = $directory . DIRECTORY_SEPARATOR . 'uid-billing-snowflake-7.seq'; + expect(file_get_contents($stateFile))->toBe('100,4') + ->and((new FilesystemSequenceProvider($directory, 'billing', reservationSize: 4)) + ->next('snowflake', 7, 100))->toBe(5); + } finally { + foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + unlink($file); + } + rmdir($directory); + } +}); + +test('filesystem sequences fail closed for oversized and malformed state', function () { + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-v5-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $stateFile = $directory . DIRECTORY_SEPARATOR . 'uid-test-1.seq'; + + try { + file_put_contents($stateFile, str_repeat('1', 65)); + $provider = new FilesystemSequenceProvider($directory); + expect(fn () => $provider->next('test', 1, 100)) + ->toThrow(\Infocyph\UID\Exceptions\FileLockException::class); + + file_put_contents($stateFile, '100,01'); + expect(fn () => $provider->next('test', 1, 100)) + ->toThrow(\Infocyph\UID\Exceptions\FileLockException::class); + } finally { + if (file_exists($stateFile)) { + unlink($stateFile); + } + rmdir($directory); + } +}); + +test('filesystem reservation ranges never overlap across processes', function () { + if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { + $this->markTestSkipped('The pcntl extension is required for multi-process coverage'); + } + + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-v5-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $children = []; + + try { + for ($process = 0; $process < 4; ++$process) { + $output = $directory . DIRECTORY_SEPARATOR . 'child-' . $process . '.json'; + $pid = pcntl_fork(); + expect($pid)->toBeGreaterThanOrEqual(0); + if ($pid === 0) { + $provider = new FilesystemSequenceProvider($directory, 'shared', reservationSize: 16); + $allocations = []; + for ($index = 0; $index < 100; ++$index) { + $allocations[] = $provider->next('sequence', 1, 123456); + } + + file_put_contents($output, json_encode($allocations, JSON_THROW_ON_ERROR)); + pcntl_exec(PHP_BINARY, ['-r', '']); + throw new RuntimeException('Unable to terminate fork child'); + } + + $children[$pid] = $output; + } + + $allocations = []; + foreach ($children as $pid => $output) { + pcntl_waitpid($pid, $status); + $json = file_get_contents($output); + expect($json)->toBeString(); + $allocations = [...$allocations, ...json_decode($json, true, 512, JSON_THROW_ON_ERROR)]; + } + + expect($allocations)->toHaveCount(400) + ->and(array_unique($allocations))->toHaveCount(400); + } finally { + foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + unlink($file); + } + rmdir($directory); + } +}); + +test('coordinated generators remain unique across processes', function (string $algorithm) { + if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { + $this->markTestSkipped('The pcntl extension is required for multi-process coverage'); + } + + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-v5-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $children = []; + + try { + for ($process = 0; $process < 4; ++$process) { + $output = $directory . DIRECTORY_SEPARATOR . 'child-' . $process . '.json'; + $pid = pcntl_fork(); + expect($pid)->toBeGreaterThanOrEqual(0); + if ($pid === 0) { + $provider = new FilesystemSequenceProvider($directory, $algorithm, reservationSize: 8); + $ids = []; + for ($index = 0; $index < 100; ++$index) { + $ids[] = match ($algorithm) { + 'snowflake' => Snowflake::generateWithConfig(new SnowflakeConfig(sequenceProvider: $provider)), + 'sonyflake' => Sonyflake::generateWithConfig(new SonyflakeConfig(sequenceProvider: $provider)), + 'randflake' => Randflake::generateWithConfig(new RandflakeConfig( + nodeId: 1, + leaseStart: time() - 60, + leaseEnd: time() + 3600, + secret: '0123456789abcdef', + sequenceProvider: $provider, + )), + 'tbsl' => TBSL::generateWithConfig(new TBSLConfig(sequenceProvider: $provider)), + }; + } + + file_put_contents($output, json_encode($ids, JSON_THROW_ON_ERROR)); + pcntl_exec(PHP_BINARY, ['-r', '']); + throw new RuntimeException('Unable to terminate fork child'); + } + + $children[$pid] = $output; + } + + $ids = []; + foreach ($children as $pid => $output) { + pcntl_waitpid($pid, $status); + $json = file_get_contents($output); + expect($json)->toBeString(); + $ids = [...$ids, ...json_decode($json, true, 512, JSON_THROW_ON_ERROR)]; + } + + expect($ids)->toHaveCount(400) + ->and(array_unique($ids))->toHaveCount(400); + } finally { + foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + unlink($file); + } + rmdir($directory); + } +})->with(['snowflake', 'sonyflake', 'randflake', 'tbsl']); diff --git a/tests/ForkSafetyTest.php b/tests/ForkSafetyTest.php new file mode 100644 index 0000000..0233587 --- /dev/null +++ b/tests/ForkSafetyTest.php @@ -0,0 +1,41 @@ +markTestSkipped('The pcntl extension is required for fork-safety coverage'); + } + + $generator(); + $resultFile = sys_get_temp_dir() . '/uid-fork-' . bin2hex(random_bytes(12)); + + $pid = pcntl_fork(); + expect($pid)->toBeGreaterThanOrEqual(0); + if ($pid === 0) { + file_put_contents($resultFile, $generator()); + pcntl_exec(PHP_BINARY, ['-r', '']); + throw new RuntimeException('Unable to terminate fork child'); + } + + $parentId = $generator(); + pcntl_waitpid($pid, $status); + $childId = file_get_contents($resultFile); + unlink($resultFile); + + expect($childId)->toBeString()->not()->toBe('') + ->and($parentId)->not()->toBe($childId); +})->with([ + 'uuid-v1' => [fn(): string => UUID::v1()], + 'uuid-v7' => [fn(): string => UUID::v7(new DateTimeImmutable('@1700000000.123'))], + 'ulid' => [fn(): string => ULID::generateMonotonic(new DateTimeImmutable('@1700000000.123'))], + 'cuid2' => [fn(): string => CUID2::generate()], + 'xid' => [fn(): string => XID::generate()], + 'object-id' => [fn(): string => ObjectID::generate(new DateTimeImmutable('@1700000000'))], +]); diff --git a/tests/IdFactoryTest.php b/tests/IdFactoryTest.php index fe4d2fc..4c816e5 100644 --- a/tests/IdFactoryTest.php +++ b/tests/IdFactoryTest.php @@ -6,14 +6,16 @@ use Infocyph\UID\Configuration\SonyflakeConfig; use Infocyph\UID\Configuration\TBSLConfig; use Infocyph\UID\Configuration\RandflakeConfig; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Enums\UlidGenerationMode; use Infocyph\UID\Id; +use Infocyph\UID\RandomId; use Infocyph\UID\Value\UuidValue; test('Id factory basic methods', function () { $ksuid = Id::ksuid(); $xid = Id::xid(); + $typeId = Id::typeId('user'); + $objectId = Id::objectId(); $uuid1 = Id::uuid1(); $namespace = Id::uuid4(); $uuid3 = Id::uuid3($namespace, 'id-factory'); @@ -36,6 +38,8 @@ expect($ksuid)->toBeString()->toHaveLength(27) ->and($xid)->toBeString()->toHaveLength(20) + ->and($typeId)->toStartWith('user_')->toHaveLength(31) + ->and($objectId)->toHaveLength(24) ->and($uuid1)->toBeString()->toHaveLength(36) ->and($uuid3)->toBeString()->toHaveLength(36) ->and($uuid4)->toBeString()->toHaveLength(36) @@ -50,8 +54,8 @@ ->and((string)$randflake)->toBeString()->not()->toBeEmpty(); }); -test('Id factory value objects', function () { - $uuidValue = Id::uuid7Value(); +test('value objects remain available from their owning type', function () { + $uuidValue = new UuidValue(Id::uuid7()); expect($uuidValue)->toBeInstanceOf(UuidValue::class) ->and($uuidValue->toString())->toHaveLength(36) ->and($uuidValue->getVersion())->toBe(7); @@ -60,34 +64,29 @@ test('Id factory random strategy', function () { $nano = Id::nanoId(10); $cuid2 = Id::cuid2(24); - $opaque = Id::opaque(10); + $random = Id::random(10); $deterministic = Id::deterministic('payload', 16, 'ns'); expect($nano)->toHaveLength(10) ->and($cuid2)->toHaveLength(24) - ->and(Id::nanoIdIsValid($nano, 10))->toBeTrue() - ->and(Id::cuid2IsValid($cuid2))->toBeTrue() - ->and($opaque)->toHaveLength(10) + ->and(RandomId::isValid($random, 10))->toBeTrue() ->and($deterministic)->toHaveLength(16); }); -test('configuration objects apply output modes', function () { - $snowflake = Id::snowflake(new SnowflakeConfig(outputType: IdOutputType::INT)); - $sonyflake = Id::sonyflake(new SonyflakeConfig(outputType: IdOutputType::INT)); - $tbsl = Id::tbsl(new TBSLConfig(outputType: IdOutputType::BINARY)); +test('configuration objects keep generation policy separate from representation', function () { + $snowflake = Id::snowflake(new SnowflakeConfig()); + $sonyflake = Id::sonyflake(new SonyflakeConfig()); + $tbsl = Id::tbsl(new TBSLConfig()); $now = time(); $randflake = Id::randflake(new RandflakeConfig( nodeId: 1, leaseStart: $now - 5, leaseEnd: $now + 300, secret: 'super-secret-key', - outputType: IdOutputType::BINARY, )); - expect($snowflake)->toBeInt() - ->and($sonyflake)->toBeInt() - ->and($tbsl)->toBeString() - ->and(strlen($tbsl))->toBe(10) - ->and($randflake)->toBeString() - ->and(strlen($randflake))->toBe(8); + expect($snowflake)->toBeString() + ->and($sonyflake)->toBeString() + ->and($tbsl)->toHaveLength(20) + ->and($randflake)->toBeString(); }); diff --git a/tests/RandflakeTest.php b/tests/RandflakeTest.php index 21f6bde..b6e781f 100644 --- a/tests/RandflakeTest.php +++ b/tests/RandflakeTest.php @@ -3,9 +3,10 @@ declare(strict_types=1); use Infocyph\UID\Configuration\RandflakeConfig; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Randflake; +use function Infocyph\UID\randflake; + test('Randflake basic generation and parsing', function () { $now = time(); $leaseStart = $now - 5; @@ -81,7 +82,7 @@ ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class); }); -test('Randflake config supports output modes', function () { +test('Randflake config returns its canonical decimal representation', function () { $now = time(); $leaseStart = $now - 5; $leaseEnd = $now + 300; @@ -93,42 +94,20 @@ leaseStart: $leaseStart, leaseEnd: $leaseEnd, secret: $secret, - outputType: IdOutputType::STRING, ), ); - $binaryId = Randflake::generateWithConfig( - new RandflakeConfig( - nodeId: 2, - leaseStart: $leaseStart, - leaseEnd: $leaseEnd, - secret: $secret, - outputType: IdOutputType::BINARY, - ), - ); - - expect($stringId)->toBeString() - ->and($binaryId)->toBeString() - ->and(strlen($binaryId))->toBe(8); + expect($stringId)->toBeString()->toMatch('/^\d+$/'); }); -test('Randflake global helper functions', function () { +test('Randflake namespaced helper delegates to the generator', function () { $now = time(); $leaseStart = $now - 5; $leaseEnd = $now + 300; $secret = 'super-secret-key'; - $id = randflake(4, $leaseStart, $leaseEnd, $secret); - $stringId = randflake_string(4, $leaseStart, $leaseEnd, $secret); - $parsed = randflake_parse($id, $secret); - $parsedString = randflake_parse_string($stringId, $secret); - $inspected = randflake_inspect($id, $secret); - $inspectedString = randflake_inspect_string($stringId, $secret); - - expect(randflake_is_valid($id))->toBeTrue() - ->and(randflake_from_base(randflake_to_base($id, 36), 36))->toBe($id) - ->and($parsed['node_id'])->toBe(4) - ->and($parsedString['node_id'])->toBe(4) - ->and($inspected['node_id'])->toBe(4) - ->and($inspectedString['node_id'])->toBe(4); + $id = randflake(new RandflakeConfig(4, $leaseStart, $leaseEnd, $secret)); + + expect(Randflake::isValid($id))->toBeTrue() + ->and(Randflake::parse($id, $secret)['node_id'])->toBe(4); }); diff --git a/tests/ShortIdTest.php b/tests/ShortIdTest.php index bd4c9cb..02420b3 100644 --- a/tests/ShortIdTest.php +++ b/tests/ShortIdTest.php @@ -2,10 +2,12 @@ declare(strict_types=1); -use Infocyph\UID\Contracts\IdAlgorithmInterface; use Infocyph\UID\CUID2; use Infocyph\UID\NanoID; +use function Infocyph\UID\cuid2; +use function Infocyph\UID\nano_id; + test('CUID2', function () { $string = CUID2::generate(); expect($string) @@ -25,8 +27,8 @@ expect($string)->toBeString()->not()->toBeEmpty()->toHaveLength(21); }); -test('global helper functions for NanoID and CUID2', function () { - expect(nanoid(10))->toHaveLength(10) +test('namespaced helper functions for NanoID and CUID2', function () { + expect(nano_id(10))->toHaveLength(10) ->and(cuid2(24))->toHaveLength(24); }); @@ -38,14 +40,10 @@ $cuidParsed = CUID2::parse($cuid); expect(NanoID::isValid($nano, 12))->toBeTrue() - ->and($nanoParsed['isValid'])->toBeTrue() ->and($nanoParsed['length'])->toBe(12) ->and($nanoParsed['alphabet'])->toBe('base64url') ->and(CUID2::isValid($cuid))->toBeTrue() - ->and($cuidParsed['isValid'])->toBeTrue() - ->and($cuidParsed['length'])->toBe(24) - ->and(nanoid_is_valid($nano, 12))->toBeTrue() - ->and(cuid2_is_valid($cuid))->toBeTrue(); + ->and($cuidParsed['length'])->toBe(24); }); test('CUID2 uses canonical first-letter and length boundaries', function () { diff --git a/tests/SnowflakeTest.php b/tests/SnowflakeTest.php index 25eba0f..1396212 100644 --- a/tests/SnowflakeTest.php +++ b/tests/SnowflakeTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Infocyph\UID\Configuration\SnowflakeConfig; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Snowflake; test('Snowflake Basic Functionality', function () { @@ -119,12 +118,7 @@ expect(Snowflake::isValid($id))->toBeTrue() ->and(Snowflake::isValid('abc'))->toBeFalse() - ->and(Snowflake::isValid('0'))->toBeFalse(); -}); - -test('Snowflake rejects invalid start timestamp format', function () { - expect(fn () => Snowflake::setStartTimeStamp('not-a-date')) - ->toThrow(\Infocyph\UID\Exceptions\SnowflakeException::class); + ->and(Snowflake::isValid('0'))->toBeTrue(); }); test('Snowflake bytes and base conversion roundtrip', function () { @@ -137,22 +131,15 @@ ->and(Snowflake::fromBase($encoded, 36))->toBe($id); }); -test('Snowflake config supports output modes', function () { - $intId = Snowflake::generateWithConfig(new SnowflakeConfig(outputType: IdOutputType::INT)); - $binaryId = Snowflake::generateWithConfig(new SnowflakeConfig(outputType: IdOutputType::BINARY)); - - expect($intId)->toBeInt() - ->and($binaryId)->toBeString() - ->and(strlen($binaryId))->toBe(8); +test('Snowflake config returns a canonical decimal string', function () { + expect(Snowflake::generateWithConfig(new SnowflakeConfig()))->toMatch('/^\d+$/'); }); test('Snowflake config rejects invalid epochs and resolver output', function () { $invalidResolver = new SnowflakeConfig(nodeResolver: fn (): string => 'invalid'); $futureEpoch = ((int) floor(microtime(true) * 1000)) + 60_000; - expect(fn () => Snowflake::generateWithConfig(new SnowflakeConfig(customEpoch: 'not-a-date'))) - ->toThrow(\InvalidArgumentException::class) - ->and(fn () => Snowflake::generateWithConfig(new SnowflakeConfig(customEpoch: $futureEpoch))) + expect(fn () => Snowflake::generateWithConfig(new SnowflakeConfig(customEpoch: $futureEpoch))) ->toThrow(\Infocyph\UID\Exceptions\SnowflakeException::class) ->and(fn () => Snowflake::generateWithConfig($invalidResolver)) ->toThrow(\UnexpectedValueException::class); diff --git a/tests/SonyflakeTest.php b/tests/SonyflakeTest.php index ba68ae0..6b6a6ee 100644 --- a/tests/SonyflakeTest.php +++ b/tests/SonyflakeTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Infocyph\UID\Configuration\SonyflakeConfig; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Sonyflake; beforeEach(function () { @@ -65,7 +64,7 @@ if ($firstTimestamp === null) { $firstTimestamp = $timestamp; - return 256; + return 257; } if ($timestamp === $firstTimestamp) { @@ -73,7 +72,7 @@ throw new \RuntimeException('Sonyflake did not advance timestamp after sequence overflow'); } - return 256; + return 257; } return 1; @@ -82,7 +81,7 @@ $id = Sonyflake::generate(); $parsed = Sonyflake::parse($id); - expect($parsed['sequence'])->toBe(1) + expect($parsed['sequence'])->toBe(0) ->and($attempts)->toBeGreaterThan(1); }); @@ -91,12 +90,7 @@ expect(Sonyflake::isValid($id))->toBeTrue() ->and(Sonyflake::isValid('abc'))->toBeFalse() - ->and(Sonyflake::isValid('0'))->toBeFalse(); -}); - -test('Sonyflake rejects invalid start timestamp format', function () { - expect(fn () => Sonyflake::setStartTimeStamp('not-a-date')) - ->toThrow(\Infocyph\UID\Exceptions\SonyflakeException::class); + ->and(Sonyflake::isValid('0'))->toBeTrue(); }); test('Sonyflake bytes and base conversion roundtrip', function () { @@ -109,22 +103,15 @@ ->and(Sonyflake::fromBase($encoded, 58))->toBe($id); }); -test('Sonyflake config supports output modes', function () { - $intId = Sonyflake::generateWithConfig(new SonyflakeConfig(outputType: IdOutputType::INT)); - $binaryId = Sonyflake::generateWithConfig(new SonyflakeConfig(outputType: IdOutputType::BINARY)); - - expect($intId)->toBeInt() - ->and($binaryId)->toBeString() - ->and(strlen($binaryId))->toBe(8); +test('Sonyflake config returns a canonical decimal string', function () { + expect(Sonyflake::generateWithConfig(new SonyflakeConfig()))->toMatch('/^\d+$/'); }); test('Sonyflake config rejects invalid epochs and resolver output', function () { $invalidResolver = new SonyflakeConfig(machineIdResolver: fn (): string => '1'); $futureEpoch = ((int) floor(microtime(true) * 1000)) + 60_000; - expect(fn () => Sonyflake::generateWithConfig(new SonyflakeConfig(customEpoch: 'not-a-date'))) - ->toThrow(\InvalidArgumentException::class) - ->and(fn () => Sonyflake::generateWithConfig(new SonyflakeConfig(customEpoch: $futureEpoch))) + expect(fn () => Sonyflake::generateWithConfig(new SonyflakeConfig(customEpoch: $futureEpoch))) ->toThrow(\Infocyph\UID\Exceptions\SonyflakeException::class) ->and(fn () => Sonyflake::generateWithConfig($invalidResolver)) ->toThrow(\UnexpectedValueException::class); diff --git a/tests/TBSLTest.php b/tests/TBSLTest.php index 993e7be..bf3cafc 100644 --- a/tests/TBSLTest.php +++ b/tests/TBSLTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Infocyph\UID\Configuration\TBSLConfig; -use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\TBSL; test('TBSL Basic Functionality', function () { @@ -11,8 +10,7 @@ $sf = TBSL::generate(); $finishedAt = time() + 1; $parsed = TBSL::parse($sf); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt) + expect($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt) ->and($parsed['machineId'])->toBe(0); }); @@ -67,11 +65,8 @@ expect(TBSL::fromBase($encoded, 62))->toBe($id); }); -test('TBSL config supports output mode', function () { - $binary = TBSL::generateWithConfig(new TBSLConfig(outputType: IdOutputType::BINARY)); - - expect($binary)->toBeString() - ->and(strlen($binary))->toBe(10); +test('TBSL config returns the canonical text representation', function () { + expect(TBSL::generateWithConfig(new TBSLConfig()))->toHaveLength(20); }); test('TBSL advances time when a sequence exceeds its 20-bit field', function () { @@ -81,12 +76,12 @@ unset($type, $machineId); $firstTimestamp ??= $timestamp; - return $timestamp === $firstTimestamp ? 0x100000 : 1; + return $timestamp === $firstTimestamp ? 0x100001 : 1; }); try { $id = TBSL::generate(0, true); - expect(substr($id, -5))->toBe('00001'); + expect(substr($id, -5))->toBe('00000'); } finally { TBSL::resetSequenceProvider(); } diff --git a/tests/UUIDTest.php b/tests/UUIDTest.php index 0cedac9..ae7a2cd 100644 --- a/tests/UUIDTest.php +++ b/tests/UUIDTest.php @@ -4,14 +4,15 @@ use Infocyph\UID\UUID; +use function Infocyph\UID\uuid7; + test('UUID v1', function () { $startedAt = time() - 1; $uid = UUID::v1(); $finishedAt = time() + 1; expect($uid)->toBeString(); $parsed = UUID::parse($uid); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['version'])->toBe(1) + expect($parsed['version'])->toBe(1) ->and($parsed['time'])->not()->toBeNull() ->and($parsed['node'])->toBeString()->not()->toBeNull() ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); @@ -23,8 +24,7 @@ $uid = UUID::v3($ns, 'my-string'); expect($uid)->toBeString(); $parsed = UUID::parse($uid); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['version'])->toBe(3) + expect($parsed['version'])->toBe(3) ->and($parsed['time'])->toBeNull() ->and($parsed['node'])->toBeString()->not()->toBeNull(); }); @@ -33,8 +33,7 @@ $uid = UUID::v4(); expect($uid)->toBeString(); $parsed = UUID::parse($uid); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['version'])->toBe(4) + expect($parsed['version'])->toBe(4) ->and($parsed['time'])->toBeNull() ->and($parsed['node'])->toBeString()->not()->toBeNull(); }); @@ -43,8 +42,7 @@ $uid = UUID::v5($ns, 'my-string'); expect($uid)->toBeString(); $parsed = UUID::parse($uid); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['version'])->toBe(5) + expect($parsed['version'])->toBe(5) ->and($parsed['time'])->toBeNull() ->and($parsed['node'])->toBeString()->not()->toBeNull(); }); @@ -55,8 +53,7 @@ $finishedAt = time() + 1; expect($uid)->toBeString(); $parsed = UUID::parse($uid); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['version'])->toBe(6) + expect($parsed['version'])->toBe(6) ->and($parsed['time'])->not()->toBeNull() ->and($parsed['node'])->toBeString()->not()->toBeNull() ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); @@ -68,8 +65,7 @@ $finishedAt = time() + 1; expect($uid)->toBeString(); $parsed = UUID::parse($uid); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['version'])->toBe(7) + expect($parsed['version'])->toBe(7) ->and($parsed['time'])->not()->toBeNull() ->and($parsed['node'])->toBeNull() ->and($parsed['tail'])->toBeString()->not()->toBeNull() @@ -77,23 +73,18 @@ }); test('UUID v8', function () { - $startedAt = time() - 1; $uid = UUID::v8(); - $finishedAt = time() + 1; expect($uid)->toBeString(); $parsed = UUID::parse($uid); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['version'])->toBe(8) - ->and($parsed['time'])->not()->toBeNull() + expect($parsed['version'])->toBe(8) + ->and($parsed['time'])->toBeNull() ->and($parsed['node'])->toBeNull() - ->and($parsed['tail'])->toBeString()->not()->toBeNull() - ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); + ->and($parsed['tail'])->toBeString()->not()->toBeNull(); }); test('UUID node must be exactly 12 hex characters when provided', function () { expect(fn () => UUID::v1('zzzzzzzzzzzz'))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class) ->and(fn () => UUID::v6('0123456789abcdef'))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class) - ->and(fn () => UUID::v7(null, 'nothex123456'))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class) ->and(fn () => UUID::v8('123'))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class); }); @@ -104,7 +95,6 @@ $ids = [ UUID::v1($node), UUID::v6($node), - UUID::v7(null, $node), UUID::v8($node), ]; @@ -114,12 +104,21 @@ } }); +test('generated UUID nodes set the multicast bit', function () { + $node = UUID::getNode(); + $uuid = UUID::v1(); + $parsedNode = UUID::parse($uuid)['node']; + + expect(hexdec(substr($node, 0, 2)) & 0x01)->toBe(0x01) + ->and($parsedNode)->toBeString() + ->and(hexdec(substr($parsedNode, 0, 2)) & 0x01)->toBe(0x01); +}); + test('GUID', function () { $uid = UUID::guid(); expect($uid)->toBeString(); $parsed = UUID::parse($uid); - expect($parsed['isValid'])->toBeTrue() - ->and($parsed['version'])->toBe(4) + expect($parsed['version'])->toBe(4) ->and($parsed['time'])->toBeNull() ->and($parsed['node'])->toBeString()->not()->toBeNull(); }); @@ -169,17 +168,22 @@ ->and(UUID::fromBytes($bytes))->toBe(strtolower($uuid)); }); -test('global UUID helper transformations', function () { - $uuid = UUID::v4(); +test('namespaced UUID helper delegates directly to UUID', function () { + expect(UUID::parse(uuid7())['version'])->toBe(7); +}); - expect(uuid_nil())->toBe(UUID::nil()) - ->and(uuid_max())->toBe(UUID::max()) - ->and(uuid_is_nil(UUID::nil()))->toBeTrue() - ->and(uuid_is_max(UUID::max()))->toBeTrue() - ->and(uuid_normalize(strtoupper($uuid)))->toBe(strtolower($uuid)) - ->and(uuid_compact($uuid))->toHaveLength(32) - ->and(uuid_urn($uuid))->toBe('urn:uuid:' . strtolower($uuid)) - ->and(uuid_braces($uuid))->toBe('{' . strtolower($uuid) . '}'); +test('UUID RFC name-based vectors match independently published values', function () { + expect(UUID::v3('dns', 'www.widgets.com'))->toBe('3d813cbb-47fb-32ba-91df-831e1593ac29') + ->and(UUID::v5('dns', 'www.widgets.com'))->toBe('21f7f8de-8051-5b89-8680-0195ef798b6a'); +}); + +test('generic UUID v8 parsing does not infer an application timestamp', function () { + $parsed = UUID::parse('00000000-0000-8000-8000-000000000000'); + + expect($parsed['version'])->toBe(8) + ->and($parsed['time'])->toBeNull() + ->and((new \Infocyph\UID\Value\UuidValue('00000000-0000-8000-8000-000000000000'))->isSortable()) + ->toBeFalse(); }); test('UUID base conversion roundtrip', function () { diff --git a/tests/V5CompatibilityTest.php b/tests/V5CompatibilityTest.php new file mode 100644 index 0000000..bbed3c2 --- /dev/null +++ b/tests/V5CompatibilityTest.php @@ -0,0 +1,127 @@ +toBeTrue() + ->and(TypeID::toUuid($typeId))->toBe($uuid) + ->and(TypeID::fromUuid($prefix, $uuid))->toBe($typeId) + ->and(TypeID::parse($typeId)['type'])->toBe($prefix); +})->with([ + ['00000000000000000000000000', '', UUID::nil()], + ['00000000000000000000000001', '', '00000000-0000-0000-0000-000000000001'], + ['0000000000000000000000000a', '', '00000000-0000-0000-0000-00000000000a'], + ['0000000000000000000000000g', '', '00000000-0000-0000-0000-000000000010'], + ['00000000000000000000000010', '', '00000000-0000-0000-0000-000000000020'], + ['7zzzzzzzzzzzzzzzzzzzzzzzzz', '', UUID::max()], + ['prefix_0123456789abcdefghjkmnpqrs', 'prefix', '0110c853-1d09-52d8-d73e-1194e95b5f19'], + ['prefix_01h455vb4pex5vsknk084sn02q', 'prefix', '01890a5d-ac96-774b-bcce-b302099a8057'], + ['pre_fix_00000000000000000000000000', 'pre_fix', UUID::nil()], +]); + +test('TypeID rejects the official v0.3 invalid fixtures', function (string $typeId) { + expect(TypeID::isValid($typeId))->toBeFalse() + ->and(fn () => TypeID::parse($typeId))->toThrow(\Infocyph\UID\Exceptions\TypeIDException::class); +})->with([ + 'PREFIX_00000000000000000000000000', + '12345_00000000000000000000000000', + 'pre.fix_00000000000000000000000000', + 'préfix_00000000000000000000000000', + ' prefix_00000000000000000000000000', + 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl_00000000000000000000000000', + '_00000000000000000000000000', + '_', + 'prefix_1234567890123456789012345', + 'prefix_123456789012345678901234567', + 'prefix_1234567890123456789012345 ', + 'prefix_0123456789ABCDEFGHJKMNPQRS', + 'prefix_123456789-123456789-123456', + 'prefix_ooooooiiiiiiuuuuuuulllllll', + 'prefix_i23456789ol23456789oi23456', + 'prefix_123456789-0123456789-0123456', + 'prefix_8zzzzzzzzzzzzzzzzzzzzzzzzz', + '_prefix_00000000000000000000000000', + 'prefix__00000000000000000000000000', + '', + 'prefix_', +]); + +test('generated TypeIDs contain ordered UUIDv7 values', function () { + $first = TypeID::generate('event'); + $second = TypeID::generate('event'); + + expect(UUID::parse(TypeID::toUuid($first))['version'])->toBe(7) + ->and(strcmp($second, $first))->toBeGreaterThan(0); +}); + +test('ObjectID matches unsigned timestamp boundary vectors', function (string $hex, string $expected) { + $id = ObjectID::fromBytes(hex2bin($hex . str_repeat('00', 8)) ?: ''); + + expect(ObjectID::parse($id)['time']->format('Y-m-d H:i:s'))->toBe($expected) + ->and(ObjectID::toBytes($id))->toBe(hex2bin($hex . str_repeat('00', 8))); +})->with([ + ['00000000', '1970-01-01 00:00:00'], + ['7fffffff', '2038-01-19 03:14:07'], + ['80000000', '2038-01-19 03:14:08'], + ['ffffffff', '2106-02-07 06:28:15'], +]); + +test('ObjectID generation is canonical and increments its counter', function () { + $first = ObjectID::generate(); + $second = ObjectID::generate(); + + expect($first)->toMatch('/^[0-9a-f]{24}$/') + ->and(ObjectID::parse($second)['counter']) + ->toBe((ObjectID::parse($first)['counter'] + 1) & 0xffffff); +}); + +test('random sampler maps every accepted byte uniformly', function (int $size) { + $alphabet = ''; + for ($index = 0; $index < $size; ++$index) { + $alphabet .= chr($index); + } + + $bytes = ''; + for ($index = 0; $index < 256; ++$index) { + $bytes .= chr($index); + } + + $mapped = RandomSampler::mapBytes($bytes, $alphabet); + $expectedPerSymbol = intdiv(256, $size); + $counts = count_chars($mapped, 1); + for ($index = 0; $index < $size; ++$index) { + expect($counts[$index] ?? 0)->toBe($expectedPerSymbol); + } +})->with([2, 3, 16, 32, 61, 62, 64, 255, 256]); + +test('RandomId validates alphabets and output bounds', function () { + $id = RandomId::generate(1024); + + expect(RandomId::isValid($id, 1024))->toBeTrue() + ->and(fn () => RandomId::generate(10, 'aa'))->toThrow(\InvalidArgumentException::class) + ->and(fn () => RandomId::generate(10, 'a'))->toThrow(\InvalidArgumentException::class); +}); + +test('byte radix codecs round trip zero and fixed-width boundary values', function () { + foreach ([8, 10, 12, 16, 20, 32] as $length) { + foreach ([str_repeat("\0", $length), str_repeat("\xff", $length)] as $bytes) { + foreach ([16, 32, 36, 58, 62] as $base) { + $encoded = BaseEncoder::encodeBytes($bytes, $base); + expect(BaseEncoder::decodeToBytes($encoded, $base, $length))->toBe($bytes); + } + } + } + + expect(BaseEncoder::encodeBytes(str_repeat("\0", 16), 58))->toBe('1'); +}); + +test('the installed runtime satisfies the 64-bit package contract', function () { + expect(PHP_INT_SIZE)->toBe(8); +});