Skip to content

Commit 0124f76

Browse files
Merge pull request #104 from phucnguyen1707/seedbox-lifecycle-state
Add seedbox lifecycle state machine
1 parent 4e9e1ce commit 0124f76

3 files changed

Lines changed: 176 additions & 0 deletions

File tree

src/lib/seedbox/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export {
2+
SEEDBOX_RESOURCE_STATUSES,
3+
SEEDBOX_TERMINAL_STATUSES,
4+
canTransitionSeedboxStatus,
5+
getAllowedSeedboxStatusTargets,
6+
isSeedboxTerminalStatus,
7+
requireSeedboxStatusTransition,
8+
transitionSeedboxResource,
9+
} from './lifecycle';
10+
11+
export type {
12+
SeedboxProvider,
13+
SeedboxResource,
14+
SeedboxResourceKind,
15+
SeedboxResourceStatus,
16+
} from './lifecycle';

src/lib/seedbox/lifecycle.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
SEEDBOX_TERMINAL_STATUSES,
4+
canTransitionSeedboxStatus,
5+
getAllowedSeedboxStatusTargets,
6+
isSeedboxTerminalStatus,
7+
requireSeedboxStatusTransition,
8+
transitionSeedboxResource,
9+
type SeedboxResource,
10+
} from './lifecycle';
11+
12+
function resource(status: SeedboxResource['status']): SeedboxResource {
13+
return {
14+
id: 'resource-1',
15+
userId: 'user-1',
16+
kind: 'managed',
17+
provider: 'digitalocean',
18+
providerRef: 'droplet-1',
19+
plan: 'starter',
20+
status,
21+
host: null,
22+
createdAt: new Date('2026-06-15T00:00:00.000Z'),
23+
activatedAt: null,
24+
terminatedAt: null,
25+
metadata: {},
26+
};
27+
}
28+
29+
describe('seedbox lifecycle', () => {
30+
it('allows the managed seedbox happy path from order to teardown', () => {
31+
expect(canTransitionSeedboxStatus('pending', 'provisioning')).toBe(true);
32+
expect(canTransitionSeedboxStatus('provisioning', 'active')).toBe(true);
33+
expect(canTransitionSeedboxStatus('active', 'suspended')).toBe(true);
34+
expect(canTransitionSeedboxStatus('suspended', 'active')).toBe(true);
35+
expect(canTransitionSeedboxStatus('active', 'terminating')).toBe(true);
36+
expect(canTransitionSeedboxStatus('terminating', 'terminated')).toBe(true);
37+
});
38+
39+
it('rejects skipped or backwards lifecycle jumps', () => {
40+
expect(canTransitionSeedboxStatus('pending', 'active')).toBe(false);
41+
expect(canTransitionSeedboxStatus('provisioning', 'suspended')).toBe(false);
42+
expect(canTransitionSeedboxStatus('terminating', 'active')).toBe(false);
43+
});
44+
45+
it('keeps terminal states final', () => {
46+
expect(SEEDBOX_TERMINAL_STATUSES).toEqual(['terminated']);
47+
expect(isSeedboxTerminalStatus('terminated')).toBe(true);
48+
expect(getAllowedSeedboxStatusTargets('terminated')).toEqual([]);
49+
expect(canTransitionSeedboxStatus('terminated', 'active')).toBe(false);
50+
});
51+
52+
it('allows failed resources to retry provisioning or proceed to teardown', () => {
53+
expect(getAllowedSeedboxStatusTargets('failed')).toEqual(['provisioning', 'terminating']);
54+
expect(canTransitionSeedboxStatus('failed', 'provisioning')).toBe(true);
55+
expect(canTransitionSeedboxStatus('failed', 'terminating')).toBe(true);
56+
expect(canTransitionSeedboxStatus('failed', 'active')).toBe(false);
57+
});
58+
59+
it('throws a descriptive error for invalid transitions', () => {
60+
expect(() => requireSeedboxStatusTransition('pending', 'active')).toThrow(
61+
'Invalid seedbox lifecycle transition: pending -> active'
62+
);
63+
});
64+
65+
it('stamps activation and termination timestamps when transitioning resources', () => {
66+
const now = new Date('2026-06-16T12:00:00.000Z');
67+
const activated = transitionSeedboxResource(resource('provisioning'), 'active', now);
68+
const terminated = transitionSeedboxResource(resource('terminating'), 'terminated', now);
69+
70+
expect(activated.status).toBe('active');
71+
expect(activated.activatedAt).toEqual(now);
72+
expect(activated.terminatedAt).toBeNull();
73+
expect(terminated.status).toBe('terminated');
74+
expect(terminated.terminatedAt).toEqual(now);
75+
});
76+
});

src/lib/seedbox/lifecycle.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
export const SEEDBOX_RESOURCE_STATUSES = [
2+
// Keep in sync with the seedbox_resources.status enum in the seedbox reseller PRD.
3+
'pending',
4+
'provisioning',
5+
'active',
6+
'suspended',
7+
'terminating',
8+
'terminated',
9+
'failed',
10+
] as const;
11+
12+
export type SeedboxResourceStatus = (typeof SEEDBOX_RESOURCE_STATUSES)[number];
13+
export type SeedboxResourceKind = 'managed' | 'byos';
14+
export type SeedboxProvider = 'digitalocean' | 'reseller' | null;
15+
16+
export interface SeedboxResource {
17+
id: string;
18+
userId: string;
19+
kind: SeedboxResourceKind;
20+
provider: SeedboxProvider;
21+
providerRef: string | null;
22+
plan: string | null;
23+
status: SeedboxResourceStatus;
24+
host: string | null;
25+
createdAt: Date;
26+
activatedAt: Date | null;
27+
terminatedAt: Date | null;
28+
metadata: Record<string, unknown>;
29+
}
30+
31+
export const SEEDBOX_TERMINAL_STATUSES = ['terminated'] as const satisfies readonly SeedboxResourceStatus[];
32+
33+
const ALLOWED_TRANSITIONS = {
34+
pending: ['provisioning', 'failed', 'terminating'],
35+
provisioning: ['active', 'failed', 'terminating'],
36+
active: ['suspended', 'terminating', 'failed'],
37+
suspended: ['active', 'terminating', 'failed'],
38+
terminating: ['terminated', 'failed'],
39+
terminated: [],
40+
failed: ['provisioning', 'terminating'],
41+
} as const satisfies Record<SeedboxResourceStatus, readonly SeedboxResourceStatus[]>;
42+
43+
export function getAllowedSeedboxStatusTargets(
44+
status: SeedboxResourceStatus
45+
): SeedboxResourceStatus[] {
46+
return [...ALLOWED_TRANSITIONS[status]];
47+
}
48+
49+
export function isSeedboxTerminalStatus(status: SeedboxResourceStatus): boolean {
50+
return SEEDBOX_TERMINAL_STATUSES.includes(status as (typeof SEEDBOX_TERMINAL_STATUSES)[number]);
51+
}
52+
53+
export function canTransitionSeedboxStatus(
54+
from: SeedboxResourceStatus,
55+
to: SeedboxResourceStatus
56+
): boolean {
57+
const targets: readonly SeedboxResourceStatus[] = ALLOWED_TRANSITIONS[from];
58+
return targets.includes(to);
59+
}
60+
61+
export function requireSeedboxStatusTransition(
62+
from: SeedboxResourceStatus,
63+
to: SeedboxResourceStatus
64+
): void {
65+
if (!canTransitionSeedboxStatus(from, to)) {
66+
throw new Error(`Invalid seedbox lifecycle transition: ${from} -> ${to}`);
67+
}
68+
}
69+
70+
export function transitionSeedboxResource(
71+
resource: SeedboxResource,
72+
nextStatus: SeedboxResourceStatus,
73+
now = new Date()
74+
): SeedboxResource {
75+
requireSeedboxStatusTransition(resource.status, nextStatus);
76+
77+
return {
78+
...resource,
79+
status: nextStatus,
80+
activatedAt:
81+
nextStatus === 'active' && resource.activatedAt == null ? now : resource.activatedAt,
82+
terminatedAt: nextStatus === 'terminated' ? now : resource.terminatedAt,
83+
};
84+
}

0 commit comments

Comments
 (0)