Domain ID types

For @arki/[email protected] · source docs/upgrades/domain-ids.md

Use one browser-safe declaration for each domain kind:

import type { IdOf } from '@arki/contracts';
import { defineId } from '@arki/contracts';
 
export const refundId = defineId('id_', { kind: 'billing.refund' });
export const paymentId = defineId('id_', { kind: 'billing.payment' });
export type RefundId = IdOf<typeof refundId>;
export type PaymentId = IdOf<typeof paymentId>;
 
const restored: RefundId = refundId.schema.parse('id_existing');
if (String(restored) !== 'id_existing') throw new Error('Stored ID changed');

The brand is defined once on the canonical schema. IdOf, create, is, DB columns and contracts infer it. Generation still returns the exact IdGenerationFailure union. A payment ID and a raw string cannot satisfy a RefundId parameter. One literal kind names one domain; widened strings and unions are rejected. Reuse the declaration wherever that domain is shared.

The prefix and suffix validation stay unchanged. Existing rows need no rewrite, and serialization produces the same string. Validate deserialized input with the owning schema. A brand is a TypeScript distinction: when two domains use the same format, their schemas both accept the same bytes. The owning route, operation or field determines which schema to apply; authorization still checks ownership. assertUniqueIdPrefixes continues to reject overlapping wire formats.

On the server, use idColumns(refundId) from @arki/db/id. Select and insert columns retain the brand. When constructing table validators, use the canonical schema refinement ({ id: () => refundId.schema }) for exact static schema inference as well as runtime validation.

The existing defineId(prefix) form retains its prefix-only output type so migrations can proceed one domain and its consumers at a time. Add the kind at the declaration, then replace copied string aliases with IdOf. Parse actual raw boundaries; do not cast strings or reparse already typed application values to silence type errors. Update successful test fixtures through the schema or checked generator, keeping deliberate refusal assertions intact.

External and numeric IDs use their provider's canonical validator and a brand; they do not need an Arki generator:

import type { IdOf } from '@arki/contracts';
import { z } from '@arki/contracts';
 
export const providerUserId = { schema: z.number().int().positive().brand<'provider.user'>() };
export type ProviderUserId = IdOf<typeof providerUserId>;
const id: ProviderUserId = providerUserId.schema.parse(42);
if (id !== 42) throw new Error('Provider ID changed');

Apply the same schema-first pattern to meaningful domain strings. Free text and existing literal unions do not need invented identity brands.

UUID-v4 domains use the same declaration and failure union:

import { defineId } from '@arki/contracts';
import { orThrow } from '@arki/failure';
 
const jobId = defineId('', { kind: 'queue.job', format: 'uuid' });
const generated = orThrow(jobId.create());
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(generated)) {
  throw new Error('Expected UUID v4');
}
if (jobId.schema.parse('customer:job/42') !== 'customer:job/42') throw new Error('External key changed');

format controls generation only; schemas keep prefix-only validation so explicit external job keys remain valid. Existing UUID bytes are unchanged. Native Web Crypto randomUUID takes precedence. If it is not callable, generation uses 16 secure bytes from getRandomValues, setting the RFC 9562 UUID-v4 version and variant bits. Missing both providers returns RandomSourceUnavailable; thrown faults, invalid output and asynchronous protocol violations return UnexpectedDefect with private causes. A broken available native UUID provider never triggers a fallback. create() retains its exact canonical failure union. Omitting format preserves the existing 24-character generation algorithm.

Existing nanoid-style values can retain their exact stored alphabet and length:

import { defineId } from '@arki/contracts';
import { orThrow } from '@arki/failure';
 
const recordId = defineId('', {
  kind: 'offline.idempotency-record',
  format: { alphabet: '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', length: 21 },
});
const lowerId = defineId('', {
  kind: 'legacy.lower-id',
  format: { alphabet: '0123456789abcdefghijklmnopqrstuvwxyz', length: 21 },
});
if (!/^[_\-\da-zA-Z]{21}$/.test(orThrow(recordId.create()))) throw new Error('Nanoid format changed');
if (!/^[\da-z]{21}$/.test(orThrow(lowerId.create()))) throw new Error('Lowercase format changed');

Choose the alphabet order and length used by the existing generator. Each output position is uniformly sampled from that alphabet, including the first; this preserves the distribution of a uniform nanoid generator without a generator hook. Formats require 2–256 distinct non-surrogate UTF-16 characters and an integer length of 1–256. Characters outside the BMP are rejected because each output position must occupy one UTF-16 code unit. Invalid formats (including extra keys) throw ContractsError with ARKI_CONTRACTS_E010 at definition time. The format is copied and frozen; mutating the supplied object later cannot change output.

Custom generation uses the same supervised Web Crypto byte provider and exact IdGenerationFailure union. Rejection sampling runs at most 32 batches of twice the requested length (up to 16,384 bytes total). Missing providers return RandomSourceUnavailable; thrown faults, different/detached buffers, asynchronous output or an exhausted budget return UnexpectedDefect. Stored schema validation stays prefix-only; formatting generation does not invalidate older or externally assigned keys.

An application on a runtime without Web Crypto must initialize its own provider backed by secure platform entropy before generation. It must fail closed when secure entropy is unavailable in every supported mode, including debugging. A secure byte provider supports UUID generation even without randomUUID. Contracts checks the synchronous provider protocol; it cannot certify entropy quality from returned bytes. It does not install global polyfills or use weak randomness. Missing providers return RandomSourceUnavailable; a provider that throws when entropy is unavailable returns UnexpectedDefect with its private cause.

All docs for @arki/[email protected]