Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/lib/seedbox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export {
SEEDBOX_RESOURCE_STATUSES,
SEEDBOX_TERMINAL_STATUSES,
canTransitionSeedboxStatus,
getAllowedSeedboxStatusTargets,
isSeedboxTerminalStatus,
requireSeedboxStatusTransition,
transitionSeedboxResource,
} from './lifecycle';

export type {
SeedboxProvider,
SeedboxResource,
SeedboxResourceKind,
SeedboxResourceStatus,
} from './lifecycle';
76 changes: 76 additions & 0 deletions src/lib/seedbox/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import {
SEEDBOX_TERMINAL_STATUSES,
canTransitionSeedboxStatus,
getAllowedSeedboxStatusTargets,
isSeedboxTerminalStatus,
requireSeedboxStatusTransition,
transitionSeedboxResource,
type SeedboxResource,
} from './lifecycle';

function resource(status: SeedboxResource['status']): SeedboxResource {
return {
id: 'resource-1',
userId: 'user-1',
kind: 'managed',
provider: 'digitalocean',
providerRef: 'droplet-1',
plan: 'starter',
status,
host: null,
createdAt: new Date('2026-06-15T00:00:00.000Z'),
activatedAt: null,
terminatedAt: null,
metadata: {},
};
}

describe('seedbox lifecycle', () => {
it('allows the managed seedbox happy path from order to teardown', () => {
expect(canTransitionSeedboxStatus('pending', 'provisioning')).toBe(true);
expect(canTransitionSeedboxStatus('provisioning', 'active')).toBe(true);
expect(canTransitionSeedboxStatus('active', 'suspended')).toBe(true);
expect(canTransitionSeedboxStatus('suspended', 'active')).toBe(true);
expect(canTransitionSeedboxStatus('active', 'terminating')).toBe(true);
expect(canTransitionSeedboxStatus('terminating', 'terminated')).toBe(true);
});

it('rejects skipped or backwards lifecycle jumps', () => {
expect(canTransitionSeedboxStatus('pending', 'active')).toBe(false);
expect(canTransitionSeedboxStatus('provisioning', 'suspended')).toBe(false);
expect(canTransitionSeedboxStatus('terminating', 'active')).toBe(false);
});

it('keeps terminal states final', () => {
expect(SEEDBOX_TERMINAL_STATUSES).toEqual(['terminated']);
expect(isSeedboxTerminalStatus('terminated')).toBe(true);
expect(getAllowedSeedboxStatusTargets('terminated')).toEqual([]);
expect(canTransitionSeedboxStatus('terminated', 'active')).toBe(false);
});

it('allows failed resources to retry provisioning or proceed to teardown', () => {
expect(getAllowedSeedboxStatusTargets('failed')).toEqual(['provisioning', 'terminating']);
expect(canTransitionSeedboxStatus('failed', 'provisioning')).toBe(true);
expect(canTransitionSeedboxStatus('failed', 'terminating')).toBe(true);
expect(canTransitionSeedboxStatus('failed', 'active')).toBe(false);
});

it('throws a descriptive error for invalid transitions', () => {
expect(() => requireSeedboxStatusTransition('pending', 'active')).toThrow(
'Invalid seedbox lifecycle transition: pending -> active'
);
});

it('stamps activation and termination timestamps when transitioning resources', () => {
const now = new Date('2026-06-16T12:00:00.000Z');
const activated = transitionSeedboxResource(resource('provisioning'), 'active', now);
const terminated = transitionSeedboxResource(resource('terminating'), 'terminated', now);

expect(activated.status).toBe('active');
expect(activated.activatedAt).toEqual(now);
expect(activated.terminatedAt).toBeNull();
expect(terminated.status).toBe('terminated');
expect(terminated.terminatedAt).toEqual(now);
});
});
84 changes: 84 additions & 0 deletions src/lib/seedbox/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
export const SEEDBOX_RESOURCE_STATUSES = [
// Keep in sync with the seedbox_resources.status enum in the seedbox reseller PRD.
'pending',
'provisioning',
'active',
'suspended',
'terminating',
'terminated',
'failed',
] as const;

export type SeedboxResourceStatus = (typeof SEEDBOX_RESOURCE_STATUSES)[number];
export type SeedboxResourceKind = 'managed' | 'byos';
export type SeedboxProvider = 'digitalocean' | 'reseller' | null;

export interface SeedboxResource {
id: string;
userId: string;
kind: SeedboxResourceKind;
provider: SeedboxProvider;
providerRef: string | null;
plan: string | null;
status: SeedboxResourceStatus;
host: string | null;
createdAt: Date;
activatedAt: Date | null;
terminatedAt: Date | null;
metadata: Record<string, unknown>;
}

export const SEEDBOX_TERMINAL_STATUSES = ['terminated'] as const satisfies readonly SeedboxResourceStatus[];

const ALLOWED_TRANSITIONS = {
pending: ['provisioning', 'failed', 'terminating'],
provisioning: ['active', 'failed', 'terminating'],
active: ['suspended', 'terminating', 'failed'],
suspended: ['active', 'terminating', 'failed'],
terminating: ['terminated', 'failed'],
terminated: [],
failed: ['provisioning', 'terminating'],
} as const satisfies Record<SeedboxResourceStatus, readonly SeedboxResourceStatus[]>;

export function getAllowedSeedboxStatusTargets(
status: SeedboxResourceStatus
): SeedboxResourceStatus[] {
return [...ALLOWED_TRANSITIONS[status]];
}

export function isSeedboxTerminalStatus(status: SeedboxResourceStatus): boolean {
return SEEDBOX_TERMINAL_STATUSES.includes(status as (typeof SEEDBOX_TERMINAL_STATUSES)[number]);
}

export function canTransitionSeedboxStatus(
from: SeedboxResourceStatus,
to: SeedboxResourceStatus
): boolean {
const targets: readonly SeedboxResourceStatus[] = ALLOWED_TRANSITIONS[from];
return targets.includes(to);
}

export function requireSeedboxStatusTransition(
from: SeedboxResourceStatus,
to: SeedboxResourceStatus
): void {
if (!canTransitionSeedboxStatus(from, to)) {
throw new Error(`Invalid seedbox lifecycle transition: ${from} -> ${to}`);
}
}

export function transitionSeedboxResource(
resource: SeedboxResource,
nextStatus: SeedboxResourceStatus,
now = new Date()
): SeedboxResource {
requireSeedboxStatusTransition(resource.status, nextStatus);

return {
...resource,
status: nextStatus,
activatedAt:
nextStatus === 'active' && resource.activatedAt == null ? now : resource.activatedAt,
terminatedAt: nextStatus === 'terminated' ? now : resource.terminatedAt,
};
}
Loading