making strides in the device and link domain setup

This commit is contained in:
user
2026-03-27 23:58:06 +02:00
parent 8c45efc92e
commit c7c303a934
17 changed files with 1202 additions and 198 deletions

View File

@@ -0,0 +1,67 @@
import { errAsync, ResultAsync } from "neverthrow";
import { db } from "@pkg/db";
import { type Err } from "@pkg/result";
import { FlowExecCtx } from "@core/flow.execution.context";
import { CreateDevice, Device, DeviceStatus, UpdateDevice } from "./data";
import { DeviceRepository } from "./repository";
import { deviceErrors } from "./errors";
export class DeviceController {
constructor(private repo: DeviceRepository) {}
list(fctx: FlowExecCtx): ResultAsync<Device[], Err> {
return this.repo.list(fctx);
}
getById(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
return this.repo.getById(fctx, id);
}
create(fctx: FlowExecCtx, data: CreateDevice): ResultAsync<Device, Err> {
return this.repo.create(fctx, data);
}
update(
fctx: FlowExecCtx,
id: number,
data: UpdateDevice,
): ResultAsync<Device, Err> {
return this.repo.update(fctx, id, data);
}
delete(fctx: FlowExecCtx, id: number): ResultAsync<boolean, Err> {
return this.repo.delete(fctx, id);
}
setStatus(
fctx: FlowExecCtx,
id: number,
status: DeviceStatus,
): ResultAsync<Device, Err> {
return this.repo.setStatus(fctx, id, status);
}
/**
* Mark a device as busy for an incoming session.
* Only succeeds if the device is currently online.
*/
allocate(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
return this.repo.getById(fctx, id).andThen((dev) => {
if (dev.status !== DeviceStatus.ONLINE) {
return errAsync(deviceErrors.deviceNotAvailable(fctx, id));
}
return this.repo.setStatus(fctx, id, DeviceStatus.BUSY);
});
}
/**
* Release a device back to online after a session ends.
*/
release(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
return this.repo.setStatus(fctx, id, DeviceStatus.ONLINE);
}
}
export function getDeviceController(): DeviceController {
return new DeviceController(new DeviceRepository(db));
}

View File

@@ -0,0 +1,48 @@
import * as v from "valibot";
export enum DeviceStatus {
ONLINE = "online",
OFFLINE = "offline",
BUSY = "busy",
ERROR = "error",
}
export const deviceStatusSchema = v.picklist(["online", "offline", "busy", "error"]);
export type DeviceStatusValue = v.InferOutput<typeof deviceStatusSchema>;
export const deviceSchema = v.object({
id: v.number(),
title: v.string(),
version: v.string(),
status: deviceStatusSchema,
isActive: v.boolean(),
containerId: v.nullable(v.string()),
host: v.string(),
wsPort: v.nullable(v.string()),
createdAt: v.date(),
updatedAt: v.date(),
});
export type Device = v.InferOutput<typeof deviceSchema>;
export const createDeviceSchema = v.object({
title: v.pipe(v.string(), v.minLength(1)),
version: v.pipe(v.string(), v.minLength(1)),
host: v.pipe(v.string(), v.minLength(1)),
containerId: v.optional(v.string()),
wsPort: v.optional(v.string()),
isActive: v.optional(v.boolean()),
});
export type CreateDevice = v.InferOutput<typeof createDeviceSchema>;
export const updateDeviceSchema = v.partial(
v.object({
title: v.string(),
version: v.string(),
host: v.string(),
containerId: v.nullable(v.string()),
wsPort: v.nullable(v.string()),
isActive: v.boolean(),
status: deviceStatusSchema,
}),
);
export type UpdateDevice = v.InferOutput<typeof updateDeviceSchema>;

View File

@@ -0,0 +1,69 @@
import { FlowExecCtx } from "@/core/flow.execution.context";
import { ERROR_CODES, type Err } from "@pkg/result";
import { getError } from "@pkg/logger";
export const deviceErrors = {
dbError: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Database operation failed",
description: "Please try again later",
detail,
}),
deviceNotFound: (fctx: FlowExecCtx, id: number): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.NOT_FOUND,
message: "Device not found",
description: "The requested device does not exist",
detail: `No device found with ID: ${id}`,
}),
listFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to list devices",
description: "Try again later",
detail,
}),
createFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to create device",
description: "Try again later",
detail,
}),
updateFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update device",
description: "Try again later",
detail,
}),
deleteFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to delete device",
description: "Try again later",
detail,
}),
deviceNotAvailable: (fctx: FlowExecCtx, id: number): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.NOT_ALLOWED,
message: "Device is not available",
description: "The device is currently busy or offline",
detail: `Device ${id} cannot be allocated in its current state`,
actionable: true,
}),
};

View File

@@ -0,0 +1,137 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import { Database, asc, eq } from "@pkg/db";
import { device } from "@pkg/db/schema";
import { type Err } from "@pkg/result";
import { logger } from "@pkg/logger";
import { traceResultAsync } from "@core/observability";
import { CreateDevice, Device, DeviceStatus, UpdateDevice } from "./data";
import { deviceErrors } from "./errors";
export class DeviceRepository {
constructor(private db: Database) {}
list(fctx: FlowExecCtx): ResultAsync<Device[], Err> {
return traceResultAsync({
name: "device.list",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db.select().from(device).orderBy(asc(device.createdAt)),
(e) =>
deviceErrors.listFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map((rows) => rows as Device[]),
});
}
getById(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
return traceResultAsync({
name: "device.getById",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db.query.device.findFirst({ where: eq(device.id, id) }),
(e) =>
deviceErrors.dbError(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((row) => {
if (!row) return errAsync(deviceErrors.deviceNotFound(fctx, id));
return okAsync(row as Device);
}),
});
}
create(fctx: FlowExecCtx, data: CreateDevice): ResultAsync<Device, Err> {
logger.info("Creating device", { ...fctx, host: data.host });
return traceResultAsync({
name: "device.create",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db
.insert(device)
.values({
title: data.title,
version: data.version,
host: data.host,
containerId: data.containerId ?? null,
wsPort: data.wsPort ?? null,
status: DeviceStatus.OFFLINE,
isActive: data.isActive ?? false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
.execute(),
(e) =>
deviceErrors.createFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map((rows) => rows[0] as Device),
});
}
update(
fctx: FlowExecCtx,
id: number,
updates: UpdateDevice,
): ResultAsync<Device, Err> {
return traceResultAsync({
name: "device.update",
fctx,
fn: () =>
this.getById(fctx, id).andThen(() =>
ResultAsync.fromPromise(
this.db
.update(device)
.set({ ...updates, updatedAt: new Date() })
.where(eq(device.id, id))
.returning()
.execute(),
(e) =>
deviceErrors.updateFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((rows) => {
if (!rows[0])
return errAsync(deviceErrors.deviceNotFound(fctx, id));
return okAsync(rows[0] as Device);
}),
),
});
}
delete(fctx: FlowExecCtx, id: number): ResultAsync<boolean, Err> {
return traceResultAsync({
name: "device.delete",
fctx,
fn: () =>
this.getById(fctx, id).andThen(() =>
ResultAsync.fromPromise(
this.db.delete(device).where(eq(device.id, id)).execute(),
(e) =>
deviceErrors.deleteFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map(() => true),
),
});
}
setStatus(
fctx: FlowExecCtx,
id: number,
status: DeviceStatus,
): ResultAsync<Device, Err> {
return this.update(fctx, id, { status });
}
}

View File

@@ -0,0 +1,85 @@
import { errAsync, ResultAsync } from "neverthrow";
import { nanoid } from "nanoid";
import { db } from "@pkg/db";
import { type Err } from "@pkg/result";
import { FlowExecCtx } from "@core/flow.execution.context";
import { CreateLink, Link, LinkStatus, LinkWithDevice, UpdateLink } from "./data";
import { LinkRepository } from "./repository";
import { linkErrors } from "./errors";
export class LinkController {
constructor(private repo: LinkRepository) {}
list(fctx: FlowExecCtx): ResultAsync<Link[], Err> {
return this.repo.list(fctx);
}
getById(fctx: FlowExecCtx, id: number): ResultAsync<Link, Err> {
return this.repo.getById(fctx, id);
}
/**
* Fetch a link by its URL token, including the joined device.
* Used by apps/front to validate and resolve an incoming link.
*/
getByToken(fctx: FlowExecCtx, token: string): ResultAsync<LinkWithDevice, Err> {
return this.repo.getByToken(fctx, token);
}
/**
* Validate a token: must exist, be active, and not be expired.
* Returns the resolved link+device on success.
*/
validate(fctx: FlowExecCtx, token: string): ResultAsync<LinkWithDevice, Err> {
return this.repo.getByToken(fctx, token).andThen((l) => {
if (l.status !== LinkStatus.ACTIVE) {
return errAsync(linkErrors.linkNotActive(fctx, token));
}
if (l.expiresAt && l.expiresAt < new Date()) {
return errAsync(linkErrors.linkExpired(fctx, token));
}
return this.repo.touch(fctx, token).map(() => l);
});
}
/**
* Generate a new link. Token is auto-generated as a URL-safe nanoid.
*/
create(
fctx: FlowExecCtx,
data: Omit<CreateLink, "token">,
): ResultAsync<Link, Err> {
return this.repo.create(fctx, {
...data,
token: nanoid(12),
});
}
update(
fctx: FlowExecCtx,
id: number,
data: UpdateLink,
): ResultAsync<Link, Err> {
return this.repo.update(fctx, id, data);
}
assignDevice(
fctx: FlowExecCtx,
id: number,
deviceId: number | null,
): ResultAsync<Link, Err> {
return this.repo.update(fctx, id, { linkedDeviceId: deviceId });
}
revoke(fctx: FlowExecCtx, id: number): ResultAsync<Link, Err> {
return this.repo.update(fctx, id, { status: LinkStatus.REVOKED });
}
delete(fctx: FlowExecCtx, id: number): ResultAsync<boolean, Err> {
return this.repo.delete(fctx, id);
}
}
export function getLinkController(): LinkController {
return new LinkController(new LinkRepository(db));
}

View File

@@ -0,0 +1,47 @@
import * as v from "valibot";
import { deviceSchema } from "@domains/device/data";
export enum LinkStatus {
ACTIVE = "active",
INACTIVE = "inactive",
EXPIRED = "expired",
REVOKED = "revoked",
}
export const linkStatusSchema = v.picklist(["active", "inactive", "expired", "revoked"]);
export type LinkStatusValue = v.InferOutput<typeof linkStatusSchema>;
export const linkSchema = v.object({
id: v.number(),
token: v.string(),
status: linkStatusSchema,
linkedDeviceId: v.nullable(v.number()),
expiresAt: v.nullable(v.date()),
lastAccessedAt: v.nullable(v.date()),
createdAt: v.date(),
updatedAt: v.date(),
});
export type Link = v.InferOutput<typeof linkSchema>;
export const linkWithDeviceSchema = v.object({
...linkSchema.entries,
device: v.nullable(deviceSchema),
});
export type LinkWithDevice = v.InferOutput<typeof linkWithDeviceSchema>;
export const createLinkSchema = v.object({
token: v.pipe(v.string(), v.minLength(1)),
linkedDeviceId: v.optional(v.nullable(v.number())),
expiresAt: v.optional(v.nullable(v.date())),
});
export type CreateLink = v.InferOutput<typeof createLinkSchema>;
export const updateLinkSchema = v.partial(
v.object({
status: linkStatusSchema,
linkedDeviceId: v.nullable(v.number()),
expiresAt: v.nullable(v.date()),
lastAccessedAt: v.nullable(v.date()),
}),
);
export type UpdateLink = v.InferOutput<typeof updateLinkSchema>;

View File

@@ -0,0 +1,79 @@
import { FlowExecCtx } from "@/core/flow.execution.context";
import { ERROR_CODES, type Err } from "@pkg/result";
import { getError } from "@pkg/logger";
export const linkErrors = {
dbError: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Database operation failed",
description: "Please try again later",
detail,
}),
linkNotFound: (fctx: FlowExecCtx, identifier: string | number): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.NOT_FOUND,
message: "Link not found",
description: "The requested link does not exist",
detail: `No link found for: ${identifier}`,
}),
listFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to list links",
description: "Try again later",
detail,
}),
createFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to create link",
description: "Try again later",
detail,
}),
updateFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update link",
description: "Try again later",
detail,
}),
deleteFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to delete link",
description: "Try again later",
detail,
}),
linkNotActive: (fctx: FlowExecCtx, token: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.NOT_ALLOWED,
message: "Link is not active",
description: "This link has been revoked, expired, or deactivated",
detail: `Link with token ${token} is not in an active state`,
actionable: true,
}),
linkExpired: (fctx: FlowExecCtx, token: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.NOT_ALLOWED,
message: "Link has expired",
description: "This link is no longer valid",
detail: `Link with token ${token} has passed its expiry date`,
actionable: true,
}),
};

View File

@@ -0,0 +1,173 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import { Database, asc, eq } from "@pkg/db";
import { link } from "@pkg/db/schema";
import { type Err } from "@pkg/result";
import { logger } from "@pkg/logger";
import { traceResultAsync } from "@core/observability";
import { CreateLink, Link, LinkWithDevice, UpdateLink } from "./data";
import { linkErrors } from "./errors";
export class LinkRepository {
constructor(private db: Database) {}
list(fctx: FlowExecCtx): ResultAsync<Link[], Err> {
return traceResultAsync({
name: "link.list",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db.select().from(link).orderBy(asc(link.createdAt)),
(e) =>
linkErrors.listFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map((rows) => rows as Link[]),
});
}
getById(fctx: FlowExecCtx, id: number): ResultAsync<Link, Err> {
return traceResultAsync({
name: "link.getById",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db.query.link.findFirst({ where: eq(link.id, id) }),
(e) =>
linkErrors.dbError(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((row) => {
if (!row) return errAsync(linkErrors.linkNotFound(fctx, id));
return okAsync(row as Link);
}),
});
}
getByToken(fctx: FlowExecCtx, token: string): ResultAsync<LinkWithDevice, Err> {
return traceResultAsync({
name: "link.getByToken",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db.query.link.findFirst({
where: eq(link.token, token),
with: { device: true },
}),
(e) =>
linkErrors.dbError(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((row) => {
if (!row) return errAsync(linkErrors.linkNotFound(fctx, token));
return okAsync(row as LinkWithDevice);
}),
});
}
create(fctx: FlowExecCtx, data: CreateLink): ResultAsync<Link, Err> {
logger.info("Creating link", { ...fctx, token: data.token });
return traceResultAsync({
name: "link.create",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db
.insert(link)
.values({
token: data.token,
status: "active",
linkedDeviceId: data.linkedDeviceId ?? null,
expiresAt: data.expiresAt ?? null,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
.execute(),
(e) =>
linkErrors.createFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map((rows) => rows[0] as Link),
});
}
update(
fctx: FlowExecCtx,
id: number,
updates: UpdateLink,
): ResultAsync<Link, Err> {
return traceResultAsync({
name: "link.update",
fctx,
fn: () =>
this.getById(fctx, id).andThen(() =>
ResultAsync.fromPromise(
this.db
.update(link)
.set({ ...updates, updatedAt: new Date() })
.where(eq(link.id, id))
.returning()
.execute(),
(e) =>
linkErrors.updateFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((rows) => {
if (!rows[0])
return errAsync(linkErrors.linkNotFound(fctx, id));
return okAsync(rows[0] as Link);
}),
),
});
}
delete(fctx: FlowExecCtx, id: number): ResultAsync<boolean, Err> {
return traceResultAsync({
name: "link.delete",
fctx,
fn: () =>
this.getById(fctx, id).andThen(() =>
ResultAsync.fromPromise(
this.db.delete(link).where(eq(link.id, id)).execute(),
(e) =>
linkErrors.deleteFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map(() => true),
),
});
}
touch(fctx: FlowExecCtx, token: string): ResultAsync<Link, Err> {
return traceResultAsync({
name: "link.touch",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db
.update(link)
.set({ lastAccessedAt: new Date(), updatedAt: new Date() })
.where(eq(link.token, token))
.returning()
.execute(),
(e) =>
linkErrors.updateFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((rows) => {
if (!rows[0])
return errAsync(linkErrors.linkNotFound(fctx, token));
return okAsync(rows[0] as Link);
}),
});
}
}