Skip to content

Commit 36b228d

Browse files
feat(api): cute default box names (e.g. cozy-otter) (#853)
## Summary Boxes created via the **cloud API** without an explicit name now get a fun default like `cozy-otter` instead of showing the bare box ID — drawn from a curated **80 adjective × 120 cute-animal** vocabulary (`nimble-otter`, `cozy-quokka`, `brave-narwhal` …). Scope: **apps/api only** (TypeScript). No Rust-core / local-SDK changes. ## Design decisions (per discussion) - **Clean-first, box-id on collision.** The default is unsuffixed (`cozy-otter`). Only when it hits the per-org `@Unique(['organizationId','name'])` constraint do we fall back **exactly once** to `cozy-otter-{boxId}`. The box id is unique by construction, so: - common case = clean, no numbers, no id noise; - the fallback **can never collide** → no retry loop, no namespace to exhaust, works the same whether the org has 5 boxes or 50k. - **Names stay unique** (Docker model). The cloud addresses boxes by *id or name* (`GET /boxes/:boxIdOrName`, stop/delete/resize/…), so duplicate names would make name-based ops ambiguous. Keeping `@Unique` means **zero DB migration**. - **Hyphen separator** — also a valid DNS label, in case box names ever become subdomains. - **Both create paths covered**: fresh `insert` and warm-pool `claim`. User-provided names are unchanged (one-shot, still 409 on conflict). ## Implementation - `box-name-generator.ts`: `generateBoxName()` + `persistWithGeneratedBoxName(boxId, persist)` — encapsulates the generate → persist → id-fallback-on-23505 policy. - `box.service.ts`: both `create` (insert) and `assignWarmPoolBox` (update) use it when no caller name is given, passing the box id. ## Test plan - [x] `nx test api box-name-generator` — clean shape, variety, clean-on-first, id-appended-on-collision, no-fallback-on-unrelated-error - [x] `nx build api` typechecks · `eslint` clean - [ ] Dev smoke after merge: create a box without a name → expect e.g. `cozy-otter` in the dashboard list <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automatically generate box names in an adjective-animal format when no name is provided. * **Bug Fixes** * Improve uniqueness conflict handling during box creation, with clearer messaging for caller-provided vs auto-generated names. * Warm-pool box assignment now persists names more reliably, preventing update failures related to name collision/guard logic. * **Tests** * Added Jest coverage for name generation formatting/variety and the updated two-step collision retry behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent df41adb commit 36b228d

3 files changed

Lines changed: 165 additions & 10 deletions

File tree

apps/api/src/box/services/box.service.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ForbiddenException, Injectable, Logger, NotFoundException, ConflictExce
88
import { InjectRepository } from '@nestjs/typeorm'
99
import { Not, Repository, LessThan, In, JsonContains, FindOptionsWhere, ILike } from 'typeorm'
1010
import { Box } from '../entities/box.entity'
11+
import { persistWithGeneratedBoxName } from '../utils/box-name-generator'
1112
import { CreateBoxDto } from '../dto/create-box.dto'
1213
import { ResizeBoxDto } from '../dto/resize-box.dto'
1314
import { BoxState } from '../enums/box-state.enum'
@@ -242,7 +243,15 @@ export class BoxService {
242243
box.runnerId = runner.id
243244
box.pending = true
244245

245-
const insertedBox = await this.boxRepository.insert(box)
246+
// No caller-provided name -> assign a fun default (e.g. "cozy-otter"),
247+
// falling back to "cozy-otter-{boxId}" if it collides with the per-org
248+
// @Unique(['organizationId', 'name']) constraint.
249+
const insertedBox = createBoxDto.name
250+
? await this.boxRepository.insert(box)
251+
: await persistWithGeneratedBoxName(box.id, (name) => {
252+
box.name = name
253+
return this.boxRepository.insert(box)
254+
})
246255

247256
this.eventEmitter
248257
.emitAsync(BoxEvents.CREATED, new BoxCreatedEvent(insertedBox))
@@ -251,7 +260,11 @@ export class BoxService {
251260
return this.toBoxDto(insertedBox)
252261
} catch (error) {
253262
if (error.code === '23505') {
254-
throw new ConflictException(`Box with name ${createBoxDto.name} already exists`)
263+
throw new ConflictException(
264+
createBoxDto.name
265+
? `Box with name ${createBoxDto.name} already exists`
266+
: 'Could not allocate a unique box name, please retry',
267+
)
255268
}
256269

257270
throw error
@@ -271,10 +284,6 @@ export class BoxService {
271284
createdAt: now,
272285
}
273286

274-
if (createBoxDto.name) {
275-
updateData.name = createBoxDto.name
276-
}
277-
278287
if (createBoxDto.autoStopInterval !== undefined) {
279288
updateData.autoStopInterval = this.resolveAutoStopInterval(createBoxDto.autoStopInterval)
280289
}
@@ -310,10 +319,19 @@ export class BoxService {
310319
)
311320
}
312321

313-
const updatedBox = await this.boxRepository.update(warmPoolBox.id, {
314-
updateData,
315-
entity: warmPoolBox,
316-
})
322+
// Resolve the name at persist time. A caller-provided name updates in one
323+
// shot (reusing the pre-fetched entity). A generated default falls back to
324+
// "{name}-{boxId}" on collision and omits `entity` so each attempt re-reads
325+
// the row — reusing the mutated entity would corrupt the optimistic-update
326+
// guard.
327+
const updatedBox = createBoxDto.name
328+
? await this.boxRepository.update(warmPoolBox.id, {
329+
updateData: { ...updateData, name: createBoxDto.name },
330+
entity: warmPoolBox,
331+
})
332+
: await persistWithGeneratedBoxName(warmPoolBox.id, (name) =>
333+
this.boxRepository.update(warmPoolBox.id, { updateData: { ...updateData, name } }),
334+
)
317335

318336
// Defensive invalidation of orgId cache since the box moved from unassigned to a real organization
319337
this.boxLookupCacheInvalidationService.invalidateOrgId({
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { generateBoxName, persistWithGeneratedBoxName } from './box-name-generator'
2+
3+
const uniqueViolation = () => Object.assign(new Error('duplicate key'), { code: '23505' })
4+
5+
describe('generateBoxName', () => {
6+
it('defaults to a clean {adjective}-{animal} name (no suffix)', () => {
7+
for (let i = 0; i < 100; i++) {
8+
const name = generateBoxName()
9+
const parts = name.split('-')
10+
expect(parts).toHaveLength(2)
11+
expect(parts[0]).toMatch(/^[a-z]+$/)
12+
expect(parts[1]).toMatch(/^[a-z]+$/)
13+
}
14+
})
15+
16+
it('produces variety across many calls', () => {
17+
const seen = new Set<string>()
18+
for (let i = 0; i < 50; i++) seen.add(generateBoxName())
19+
// 9600 base combos — <40 uniques out of 50 picks would mean the generator
20+
// is stuck on a near-constant.
21+
expect(seen.size).toBeGreaterThan(40)
22+
})
23+
})
24+
25+
describe('persistWithGeneratedBoxName', () => {
26+
it('persists a clean (unsuffixed) name on the first try', async () => {
27+
const names: string[] = []
28+
const result = await persistWithGeneratedBoxName('aB3kF9mZ2pQ7', async (name) => {
29+
names.push(name)
30+
return name
31+
})
32+
expect(names).toHaveLength(1)
33+
expect(result.split('-')).toHaveLength(2) // clean: adjective-animal
34+
})
35+
36+
it('appends the box id when the clean name collides', async () => {
37+
const names: string[] = []
38+
const result = await persistWithGeneratedBoxName('aB3kF9mZ2pQ7', async (name) => {
39+
names.push(name)
40+
if (names.length === 1) throw uniqueViolation() // clean name taken
41+
return name
42+
})
43+
expect(names).toHaveLength(2)
44+
expect(names[0].split('-')).toHaveLength(2) // first attempt: clean adjective-animal
45+
expect(result).toBe(`${names[0]}-aB3kF9mZ2pQ7`) // fallback reuses the base + box id
46+
expect(result.endsWith('-aB3kF9mZ2pQ7')).toBe(true)
47+
})
48+
49+
it('does not fall back on a non-unique-violation error', async () => {
50+
let calls = 0
51+
await expect(
52+
persistWithGeneratedBoxName('aB3kF9mZ2pQ7', async () => {
53+
calls++
54+
throw new Error('connection reset')
55+
}),
56+
).rejects.toThrow('connection reset')
57+
expect(calls).toBe(1)
58+
})
59+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Default box-name generator. Shape: "{adjective}-{animal}", e.g. "cozy-otter".
2+
//
3+
// Names are kept clean in the common case. On the rare collision with the
4+
// per-org @Unique(['organizationId', 'name']) constraint, the caller falls back
5+
// to "{adjective}-{animal}-{boxId}" (see persistWithGeneratedBoxName) — the box
6+
// id is unique by construction, so that single fallback can never collide.
7+
8+
const ADJECTIVES = [
9+
'agile', 'amber', 'astute', 'bold', 'bouncy', 'brave', 'breezy', 'brilliant', 'brisk', 'calm',
10+
'cheery', 'clever', 'cosmic', 'cozy', 'crimson', 'crisp', 'dapper', 'daring', 'darting', 'deft',
11+
'dreamy', 'dusky', 'eager', 'fierce', 'fluffy', 'fresh', 'gallant', 'gentle', 'gliding', 'glossy',
12+
'golden', 'hearty', 'hovering', 'hushed', 'indigo', 'intrepid', 'ivory', 'jade', 'jaunty', 'jolly',
13+
'jovial', 'keen', 'lively', 'lofty', 'lucid', 'lunar', 'mellow', 'mighty', 'mild', 'mystic',
14+
'neat', 'nebular', 'nimble', 'opal', 'perky', 'placid', 'plucky', 'polished', 'prismatic', 'quick',
15+
'radiant', 'savvy', 'serene', 'sharp', 'silver', 'sleek', 'snappy', 'snug', 'soaring', 'solar',
16+
'sparkly', 'starry', 'swift', 'tidy', 'tranquil', 'valiant', 'vibrant', 'vivid', 'witty', 'zippy',
17+
] as const
18+
19+
const ANIMALS = [
20+
'alpaca', 'badger', 'bat', 'bear', 'beaver', 'bee', 'beluga', 'bluebird', 'bluejay', 'bobcat',
21+
'bumblebee', 'bunny', 'butterfly', 'calf', 'canary', 'capybara', 'cardinal', 'cat', 'chameleon', 'chick',
22+
'chickadee', 'chinchilla', 'chipmunk', 'cockatiel', 'corgi', 'cottontail', 'crab', 'cub', 'deer', 'dolphin',
23+
'dove', 'dragonfly', 'duck', 'duckling', 'eagle', 'falcon', 'fawn', 'ferret', 'finch', 'firefly',
24+
'flamingo', 'fox', 'frog', 'gecko', 'goldfinch', 'goose', 'gosling', 'guppy', 'hamster', 'hare',
25+
'hawk', 'hedgehog', 'heron', 'hummingbird', 'joey', 'kangaroo', 'kit', 'kitten', 'koala', 'ladybug',
26+
'lamb', 'lemur', 'llama', 'lovebird', 'lynx', 'magpie', 'manatee', 'marmot', 'meerkat', 'mole',
27+
'mouse', 'narwhal', 'nightingale', 'ocelot', 'octopus', 'otter', 'owl', 'owlet', 'panda', 'parakeet',
28+
'parrot', 'peacock', 'pelican', 'penguin', 'piglet', 'platypus', 'pony', 'porpoise', 'possum', 'puffin',
29+
'pup', 'puppy', 'quokka', 'rabbit', 'raccoon', 'raven', 'robin', 'salamander', 'seahorse', 'seal',
30+
'sheep', 'sloth', 'snail', 'sparrow', 'squirrel', 'starfish', 'stingray', 'swallow', 'swan', 'tadpole',
31+
'tortoise', 'toucan', 'turtle', 'wallaby', 'walrus', 'whale', 'wolf', 'wombat', 'woodpecker', 'wren',
32+
] as const
33+
34+
function pick<T>(arr: readonly T[]): T {
35+
return arr[Math.floor(Math.random() * arr.length)]
36+
}
37+
38+
/**
39+
* Generate a default box name like `cozy-otter`. Hyphen- (not underscore-)
40+
* separated and all-lowercase, so the clean name is a valid DNS label in case
41+
* box names ever become hostnames.
42+
*/
43+
export function generateBoxName(): string {
44+
return `${pick(ADJECTIVES)}-${pick(ANIMALS)}`
45+
}
46+
47+
// Postgres unique_violation — raised when a generated name hits the per-org
48+
// @Unique(['organizationId', 'name']) constraint.
49+
const PG_UNIQUE_VIOLATION = '23505'
50+
51+
/**
52+
* Run `persist` with a generated box name: clean first ("cozy-otter"). If that
53+
* collides with the per-org unique-name constraint, fall back exactly once to
54+
* "cozy-otter-{boxId}" — the box's own id is unique by construction, so the
55+
* fallback can never collide. No retry loop, no numeric suffix, no namespace
56+
* to exhaust.
57+
*
58+
* @param boxId - this box's id; appended verbatim on collision. It can contain
59+
* uppercase, so the fallback name (unlike the clean base) is not guaranteed
60+
* to be a DNS label — keep the id intact rather than lower-casing it, since
61+
* the suffix is meant to be the box id.
62+
* @param persist - performs the insert/update for a candidate name; must throw
63+
* a Postgres unique-violation (code 23505) when that name is already taken.
64+
*/
65+
export async function persistWithGeneratedBoxName<T>(
66+
boxId: string,
67+
persist: (name: string) => Promise<T>,
68+
): Promise<T> {
69+
const base = generateBoxName()
70+
try {
71+
return await persist(base)
72+
} catch (error) {
73+
if ((error as { code?: string })?.code !== PG_UNIQUE_VIOLATION) {
74+
throw error
75+
}
76+
return await persist(`${base}-${boxId}`)
77+
}
78+
}

0 commit comments

Comments
 (0)