Skip to content

feat(entities): configurable package entity ID type (bigint/uuid/ulid)#127

Merged
cjmellor merged 6 commits into
mainfrom
feat/configurable-entity-id-type
May 17, 2026
Merged

feat(entities): configurable package entity ID type (bigint/uuid/ulid)#127
cjmellor merged 6 commits into
mainfrom
feat/configurable-entity-id-type

Conversation

@cjmellor
Copy link
Copy Markdown
Owner

@cjmellor cjmellor commented May 17, 2026

Summary

Resolves the (b) half of christoph-kluge's UUID/ULID feedback on #124. Lets host apps choose how the package's own primary keys are stored: bigint (default), uuid, or ulid. Useful when you want to expose Experience or Achievement records on a public API without sequential IDs leaking row counts.

  • New config key level-up.entities.id_type (default 'bigint', accepts 'uuid' / 'ulid')
  • New Blueprint macros $table->entityId() and $table->entityForeignId('xxx_id'), registered in LevelUpServiceProvider::bootingPackage() (safe after refactor(tests): run package migrations after providers boot #125's lifecycle fix)
  • New trait LevelUp\Experience\Concerns\HasConfigurableIds applied to all 13 package models — composes Laravel's HasUniqueIds, reads config at runtime, no-ops in bigint mode
  • Polymorphic morph fix: multiplier_scopes.scopeable_id is now a string column with (string) $key casts at the three write/match sites in Multiplier.php — this is the follow-up feat(user): configurable foreign key type (bigint/uuid/ulid) #124 flagged in its "Out of scope" notes, addressed here because it's needed for (b) to work under any combination of the two ID-type knobs
  • LEVELUP_TEST_KEY_TYPE env var drives TestCase::getEnvironmentSetUp, and the CI matrix gains uuid + ulid cells on PHP 8.3 + Laravel 12.* — the same 216-test suite runs under each of the three modes

Why two knobs, not one

Coupling them forces hacks in legitimate mismatched scenarios (e.g. a bigint User table but UUID package IDs). They land as siblings.

Backwards compatibility

Default 'bigint' is byte-identical to today. Existing installs see no change on composer update:

  • HasConfigurableIds::uniqueIds() returns [] in bigint mode, so HasUniqueIds' creating hook is a no-op
  • getKeyType() returns 'int', getIncrementing() returns true
  • The entityId()/entityForeignId() macros expand to the same id() / foreignId() calls
  • multiplier_scopes.scopeable_id becomes string but SQLite/MySQL type affinity accepts ints transparently, and the (string) casts in Multiplier.php keep Postgres strict mode correct

Existing installs

This setting only changes how new migrations build their tables. Existing installs can't be flipped automatically because column types are baked in. The README's new "Customizing Identifiers" section contains a copy-paste-able AI prompt that generates the conversion migrations tailored to your schema and database driver. The v2 upgrade skill gains a Step 9 that asks the user whether to switch and points to that section.

Out of scope

  • Automated conversion for existing installs (handled by the README AI prompt)
  • A scopeable_type morph map for short aliases
  • A runtime guard checking Schema::getColumnType() matches config

cjmellor added 5 commits May 16, 2026 22:04
Proof-of-shape for the (b) half of UUID/ULID support: lets host apps
choose the primary-key column type for the package's own entities so
package IDs can be exposed as UUIDs/ULIDs on a public API surface
(christoph-kluge's ask on PR #124).

Wires:
- `level-up.entities.id_type` config (default `bigint`, accepts `uuid`/`ulid`)
- `Blueprint::entityId()` / `Blueprint::entityForeignId()` macros registered
  in `LevelUpServiceProvider::bootingPackage()`. Now safe in tests after
  PR #125 moved package migrations to run after providers boot.
- `Concerns\HasConfigurableIds` trait — composes Laravel's `HasUniqueIds`,
  reads config at runtime, no-ops in bigint mode.
- Applied to the `Experience` model + `create_experiences_table` stub as
  the shape proof; remaining 12 models + migrations follow next.

BC: with default config, the trait's `uniqueIds()` returns `[]` so
`HasUniqueIds`' creating-hook no-ops, `getKeyType()` returns `int`, and
`getIncrementing()` returns `true` — byte-identical to today. Existing
installs see no change.
…package

Completes the (b) half of UUID/ULID support after the scaffold (9471843).

Trait rollout: applies `HasConfigurableIds` to the remaining 11 package
models so every entity honours `level-up.entities.id_type` at runtime —
Achievement, Activity, Challenge, ExperienceAudit, Level, Multiplier,
MultiplierScope, Streak, StreakHistory, Tier, plus the two pivot models.

Macro rollout: swaps `\$table->id()` -> `entityId()` and FKs to package
entities -> `entityForeignId()` across 16 migration stubs. User FKs stay
as `foreignId()` (host-driven, governed by the (a) PR).

Polymorphic morph fix: `multiplier_scopes.scopeable_id` becomes a string
column so it can store both package-entity keys and host-User keys under
any combination of the two ID-type knobs. Three sites in `Multiplier.php`
cast `(string) \$key` for Postgres strict-mode correctness.

BC: with default `bigint`, byte-identical to today — trait's
`uniqueIds()` returns `[]` so the HasUniqueIds boot hook no-ops,
`getKeyType()` returns 'int', `getIncrementing()` returns true. The
scopeable_id string column accepts integers under SQLite type affinity.

Tests: 216/216 pass on default config. UUID/ULID matrix follow-up.
`HasUniqueIds` only generates IDs when its `$usesUniqueIds` flag is
true; the flag must be flipped per-instance via an `initialize*` trait
hook (this is how `HasUuids`/`HasUlids` do it).

Without this, `Model::save()` skips the `setUniqueIds()` call so
package entities in uuid/ulid mode insert with a NULL primary key
and trip a NOT NULL constraint.

Latent in the scaffold commit (9471843) — undetected because the
default bigint mode doesn't need the flag set. Surfaced once uuid/ulid
mode was actually run via LEVELUP_TEST_KEY_TYPE.
`tests/TestCase.php` reads `LEVELUP_TEST_KEY_TYPE` (default `bigint`)
and writes it to `level-up.entities.id_type` config before package
migrations run. This lets the same 216-test suite exercise all three
ID-type modes from a single invocation.

`tests/Pest.php` captures the seeded levels into `\$this->levels`
(keyed by level number) so per-test assertions can resolve a real
level id instead of hardcoding `1`/`2`/`3` — which only matched the
bigint auto-increment sequence. Test files that compared `level_id`
to integer literals now reference `\$this->levels[N]->id`.

CI: adds two extra cells to the existing matrix on PHP 8.3 + Laravel
12.* + prefer-stable, one for `uuid` and one for `ulid`. The base
matrix continues to run `bigint` across PHP 8.3/8.4/8.5 + Laravel
12/13 unchanged.

Verified locally: 216/216 pass under each of bigint, uuid, ulid.
README: adds a "Customizing Identifiers" section explaining
`level-up.entities.id_type` and a collapsible accordion with an AI
prompt that users can paste into their assistant to generate the
conversion migrations for switching an existing install from bigint
to uuid/ulid. Existing-install conversion is intentionally not
automated by the package — it depends on data volume, downtime
tolerance, and DB driver specifics.

The published-config snippet in the README also gains the new
`entities` block so users see the knob immediately on install.

UPGRADE.md: adds a brief "New: Configurable Entity ID Type" entry
under v2.0 pointing at the README section. Existing installs are
unaffected on `composer update` since the default is `bigint`.

v2 upgrade skill (resources/boost/skills/level-up-upgrade-v2):
inserts Step 9 that asks whether the user wants to switch IDs and
explicitly delegates the conversion to the README accordion rather
than attempting it inside the skill.

CHANGELOG: left untouched — handled at release time.
@cjmellor cjmellor self-assigned this May 17, 2026
- Tighten HasConfigurableIds: only flip usesUniqueIds for uuid/ulid;
  newUniqueId() throws InvalidArgumentException on unknown types
  instead of silently returning an empty string.
- Extract $resolveIdType closure in LevelUpServiceProvider so both
  Blueprint macros share a single validate-and-throw site.
- Cast (string) on scopeable_id test inserts for parity with
  production casts (Postgres-strict safe).
- Inline the multiplier_scopes clarifier next to the table list in
  the README AI prompt.
@cjmellor cjmellor merged commit fe34547 into main May 17, 2026
11 checks passed
@cjmellor cjmellor deleted the feat/configurable-entity-id-type branch May 17, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant