Skip to content

feat(db): guarantee public.uuidv7() exists after nself start - #428

Merged
acamarata merged 2 commits into
mainfrom
feat/uuidv7-available-without-extension
Sep 15, 2026
Merged

acamarata merged 2 commits into
mainfrom
feat/uuidv7-available-without-extension

Conversation

@acamarata

Copy link
Copy Markdown
Collaborator

Requested by ummeco/ummat — inbox msg-2026-09-14-uuidv7-extension, priority high.

The trap being closed

Their migration 20260511104612 runs CREATE EXTENSION IF NOT EXISTS pg_uuidv7 and soft-fails by design: the base image does not ship it, so the migration raises a WARNING and is still recorded as applied. Its own header then instructs later DDL to use id UUID PRIMARY KEY DEFAULT public.uuidv7().

Two later migrations did exactly that and died:

ERROR: function public.uuidv7() does not exist

A soft-failing install that downstream DDL is told to trust is worse than a hard failure — it fails later, somewhere else, in someone else's migration.

Why not "add pg_uuidv7 to the base image", which is what was asked

Two concrete reasons, both of which would leave the request only half-served:

  1. It means nSelf building, publishing and maintaining its own Postgres image instead of using pgvector/pgvector:pg16. That is a distribution change — Docker Hub is one of the eight bus-factor accounts — and an owner decision, not a code fix.
  2. It would not work on the embedded pglite runtime at all, which cannot load a C extension. nself start --embedded-pg users would still hit the exact same error.

What this does instead

Guarantee the post-condition the consumers actually depend on: after InitializeDatabase, public.uuidv7() exists.

ensureUUIDv7:

  1. tries CREATE EXTENSION IF NOT EXISTS pg_uuidv7 — a C implementation is better when it is there — and tolerates its absence;
  2. then applies a guarded SQL definition that no-ops if anything already provides the function.

So an image that does ship pg_uuidv7 keeps the C version, and everything else gets a working one. If the owner later decides to ship a custom image, this becomes dead code rather than a conflict.

The SQL body is the standard community implementation, taken from ummat's already-verified fallback rather than written fresh: overlay a v4 UUID's first 6 bytes with the big-endian millisecond timestamp, then set bits 52 and 53 to turn the version nibble from 0100 into 0111. gen_random_uuid() has already set the RFC 9562 variant bits and the overlay does not touch them.

Verified live

Against postgres:16-alpine, which has no pg_uuidv7 — so these exercise the fallback, not a C implementation. The first test asserts that precondition explicitly.

--- PASS: TestEnsureUUIDv7_Integration_FallbackDefinesAConformingFunction (2.27s)
--- PASS: TestEnsureUUIDv7_Integration_IsIdempotent (2.03s)
--- PASS: TestEnsureUUIDv7_Integration_DoesNotReplaceAnExistingDefinition (1.77s)
PASS
ok  github.com/nself-org/cli/internal/database  6.379s

"The function exists" would not have been a test — a wrong implementation still defines a function and still returns a uuid. So these pin what callers actually rely on:

assertion why
version nibble is 7 RFC 9562 §5.7
variant nibble in 8/9/a/b the overlay must not disturb gen_random_uuid()'s variant bits
leading 48 bits decode within 60s of now catches wrong field order or units
strictly ordered across milliseconds the entire reason callers chose v7 over uuid_generate_v4 — keyset-pagination cursor indexes
200 distinct in one batch the random tail still varies inside a millisecond
idempotent over 3 calls nself start runs this every boot
a pre-existing definition survives otherwise an image shipping pg_uuidv7 would silently lose its C implementation

Requested by ummeco/ummat (inbox msg-2026-09-14-uuidv7-extension, high).

Their migration 20260511104612 runs `CREATE EXTENSION IF NOT EXISTS pg_uuidv7`
and SOFT-FAILS by design: the base image does not ship it, so the migration warns
and is still recorded as applied. Its own header then tells later DDL to use
`id UUID PRIMARY KEY DEFAULT public.uuidv7()`. Two later migrations did exactly
that and died with "function public.uuidv7() does not exist". A soft-failing
install that later DDL trusts is worse than a hard failure.

The ask was to add pg_uuidv7 to the base PG16 image. Not doing that, for two
concrete reasons:

  - It would mean nSelf building, publishing and maintaining its own Postgres
    image instead of using pgvector/pgvector:pg16. That is a distribution change
    (Docker Hub is one of the eight bus-factor accounts) and an owner decision,
    not a code fix.
  - It would not work at all on the embedded pglite runtime, which cannot load a
    C extension. That path would still lack the function.

Instead, guarantee the POST-CONDITION the consumers actually need: after
InitializeDatabase, public.uuidv7() exists. ensureUUIDv7 tries CREATE EXTENSION
first, because a C implementation is better when present, tolerates its absence,
and then applies a guarded SQL definition that no-ops if anything already
provides the function. An image that does ship pg_uuidv7 keeps the C version.

The SQL body is the standard community implementation, taken from ummat's own
verified fallback rather than written fresh: overlay a v4 UUID's first 6 bytes
with the big-endian millisecond timestamp, then set bits 52 and 53 to turn the
version nibble from 0100 into 0111. gen_random_uuid() has already set the RFC
9562 variant bits and the overlay does not touch them.

Verified live against postgres:16-alpine, which has no pg_uuidv7, so these
exercise the fallback path and not a C implementation:

  --- PASS: TestEnsureUUIDv7_Integration_FallbackDefinesAConformingFunction
  --- PASS: TestEnsureUUIDv7_Integration_IsIdempotent
  --- PASS: TestEnsureUUIDv7_Integration_DoesNotReplaceAnExistingDefinition
  ok  github.com/nself-org/cli/internal/database  6.379s

The first pins what consumers depend on rather than merely that a function
exists: version nibble 7, variant bits in 8/9/a/b, the leading 48 bits decoding
to within 60s of now, strict ordering across milliseconds, and 200 distinct
values in one batch. A wrong implementation still returns a uuid, so
"it exists" would not have been a test.
CI caught this, correctly:

  --- FAIL: TestFileSizeBudgetNotExceeded
      file_size_test.go:120: 1 files exceed the 300-line cap but the budget is 0.

Adding ensureUUIDv7 took internal/database/init.go from 275 to 360 lines, past
the 300-line cap the repo enforces. Raising the budget was not an option: the cap
is the rule, and a gate that gets relaxed whenever it fires is not a gate.

Split instead, which is the better shape anyway — the uuidv7 bootstrap is its own
concern, not part of the create-database/schema/extension sequence.

  init.go   360 -> 280
  uuidv7.go        89 (new)

Verified after the split:
  go test ./internal/repoqa/ -run TestFileSizeBudget          ok
  INTEGRATION=1 go test -tags integration -run TestEnsureUUIDv7  ok (6.604s)
@acamarata
acamarata merged commit dd2da69 into main Sep 15, 2026
31 checks passed
@acamarata
acamarata deleted the feat/uuidv7-available-without-extension branch September 15, 2026 01:13
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