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.
---
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);
+});