feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
111
packages/api/src/utils/index.ts
Normal file
111
packages/api/src/utils/index.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import type { ClientRequestOptions } from "hono";
|
||||
import type { ClientResponse } from "hono/client";
|
||||
|
||||
type HandleReturn<
|
||||
T,
|
||||
E extends boolean,
|
||||
S extends z.ZodType | undefined = undefined,
|
||||
> = E extends true
|
||||
? S extends z.ZodType
|
||||
? z.infer<S>
|
||||
: T
|
||||
: S extends z.ZodType
|
||||
? z.infer<S> | null
|
||||
: T | null;
|
||||
|
||||
const apiErrorSchema = z.object({
|
||||
code: z.string().optional(),
|
||||
message: z.string(),
|
||||
timestamp: z.string(),
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
export const isAPIError = (e: unknown): e is z.infer<typeof apiErrorSchema> => {
|
||||
return apiErrorSchema.safeParse(e).success;
|
||||
};
|
||||
|
||||
interface HandleOptions<
|
||||
E extends boolean = true,
|
||||
S extends z.ZodType | undefined = undefined,
|
||||
> {
|
||||
throwOnError?: E;
|
||||
schema?: S;
|
||||
}
|
||||
|
||||
export const handle = <
|
||||
TResponse,
|
||||
TArgs,
|
||||
E extends boolean = true,
|
||||
S extends z.ZodType | undefined = undefined,
|
||||
>(
|
||||
fn: (
|
||||
args: TArgs,
|
||||
options?: ClientRequestOptions,
|
||||
) => Promise<ClientResponse<TResponse, number, "json">>,
|
||||
options: HandleOptions<E, S> = {},
|
||||
) => {
|
||||
const { throwOnError = true as E, schema } = options;
|
||||
|
||||
const handler = async (
|
||||
args?: TArgs,
|
||||
requestOptions?: ClientRequestOptions,
|
||||
): Promise<HandleReturn<TResponse, E, S>> => {
|
||||
const response = await fn(args as TArgs, requestOptions);
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
if (throwOnError) {
|
||||
throw new Error(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Something went wrong. Please try again later.",
|
||||
);
|
||||
}
|
||||
return null as HandleReturn<TResponse, E, S>;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (throwOnError) {
|
||||
throw new Error(
|
||||
isAPIError(data)
|
||||
? data.message
|
||||
: "Something went wrong. Please try again later.",
|
||||
);
|
||||
}
|
||||
return null as HandleReturn<TResponse, E, S>;
|
||||
}
|
||||
|
||||
if (schema) {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
if (throwOnError) {
|
||||
throw result.error;
|
||||
}
|
||||
return null as HandleReturn<TResponse, E, S>;
|
||||
}
|
||||
return result.data as HandleReturn<TResponse, E, S>;
|
||||
}
|
||||
|
||||
return data as HandleReturn<TResponse, E, S>;
|
||||
};
|
||||
|
||||
return Object.assign(handler, {
|
||||
__responseType: {} as HandleReturn<TResponse, E, S>,
|
||||
});
|
||||
};
|
||||
|
||||
export const withTimeout = <T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMillis: number,
|
||||
): Promise<T> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Request timed out!")), timeoutMillis),
|
||||
),
|
||||
]);
|
||||
};
|
||||
76
packages/api/src/utils/on-error.ts
Normal file
76
packages/api/src/utils/on-error.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { isKey } from "@turbostarter/i18n";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { captureException } from "@turbostarter/monitoring-web/server";
|
||||
import { HttpStatusCode } from "@turbostarter/shared/constants";
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
import { getStatusCode } from "@turbostarter/shared/utils";
|
||||
|
||||
import type { Context } from "hono";
|
||||
|
||||
const errorSchema = z.object({
|
||||
code: z.string().optional(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const isError = (e: unknown): e is z.infer<typeof errorSchema> => {
|
||||
return errorSchema.safeParse(e).success;
|
||||
};
|
||||
|
||||
export const onError = async (
|
||||
e: unknown,
|
||||
c?: Context<{
|
||||
Bindings: { NODE_ENV: string };
|
||||
Variables: { locale: string };
|
||||
}>,
|
||||
) => {
|
||||
const { t, i18n } = await getTranslation({
|
||||
locale: c?.var.locale,
|
||||
request: c?.req.raw,
|
||||
});
|
||||
|
||||
const status = getStatusCode(e);
|
||||
const details = {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const path = c?.req.raw.url ? new URL(c.req.raw.url).pathname : "/api";
|
||||
|
||||
if (status >= HttpStatusCode.INTERNAL_SERVER_ERROR) {
|
||||
captureException(e, { path, status, timestamp });
|
||||
}
|
||||
|
||||
if (isError(e)) {
|
||||
logger.error(e.code, e.message);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: e.code,
|
||||
message: e.message
|
||||
? e.message
|
||||
: e.code && isKey(e.code, i18n)
|
||||
? t(e.code)
|
||||
: ((e.message || e.code) ?? t("common:error.general")),
|
||||
status,
|
||||
timestamp,
|
||||
path,
|
||||
}),
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
logger.error(e);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: "common:error.general",
|
||||
message: t("common:error.general"),
|
||||
status,
|
||||
path,
|
||||
}),
|
||||
details,
|
||||
);
|
||||
};
|
||||
134
packages/api/src/utils/test/index.test.ts
Normal file
134
packages/api/src/utils/test/index.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { handle, isAPIError, withTimeout } from "../index";
|
||||
|
||||
describe("isAPIError", () => {
|
||||
it("should return true for valid API error object", () => {
|
||||
const error = {
|
||||
code: "ERROR_CODE",
|
||||
message: "Something went wrong",
|
||||
timestamp: new Date().toISOString(),
|
||||
path: "/api/test",
|
||||
};
|
||||
expect(isAPIError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}],
|
||||
[{ code: "ERROR_CODE" }],
|
||||
[{ message: "Something went wrong" }],
|
||||
[{ timestamp: new Date().toISOString() }],
|
||||
[{ path: "/api/test" }],
|
||||
])("should return false for invalid object", (input) => {
|
||||
expect(isAPIError(input)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([[null], [undefined]])("should return false for %s", (input) => {
|
||||
expect(isAPIError(input)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handle", () => {
|
||||
it("should return data on success", async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
const handler = handle(mockFn);
|
||||
const result = await handler({});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should throw error on failure if throwOnError is true (default)", async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: "Failed" }),
|
||||
});
|
||||
|
||||
const handler = handle(mockFn);
|
||||
await expect(handler({})).rejects.toThrow(Error);
|
||||
});
|
||||
|
||||
it("should return null on failure if throwOnError is false", async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: "Failed" }),
|
||||
});
|
||||
|
||||
const handler = handle(mockFn, { throwOnError: false });
|
||||
const result = await handler({});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw error if fetch throws and throwOnError is true", async () => {
|
||||
const mockFn = vi.fn().mockRejectedValue(new Error("Network Error"));
|
||||
const handler = handle(mockFn);
|
||||
await expect(handler({})).rejects.toThrow("Network Error");
|
||||
});
|
||||
|
||||
it("should allow error propagation if fetch throws and throwOnError is false", async () => {
|
||||
const mockFn = vi.fn().mockRejectedValue(new Error("Network Error"));
|
||||
const handler = handle(mockFn, { throwOnError: false });
|
||||
|
||||
// Expect it to throw because handle doesn't catch fn() errors
|
||||
await expect(handler({})).rejects.toThrow("Network Error");
|
||||
});
|
||||
|
||||
it("should validate data with schema if provided", async () => {
|
||||
const schema = z.object({ id: z.number() });
|
||||
const mockFn = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 123 }),
|
||||
});
|
||||
|
||||
const handler = handle(mockFn, { schema });
|
||||
const result = await handler({});
|
||||
|
||||
expect(result).toEqual({ id: 123 });
|
||||
});
|
||||
|
||||
it("should throw error if schema validation fails and throwOnError is true", async () => {
|
||||
const schema = z.object({ id: z.number() });
|
||||
const mockFn = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: "invalid" }),
|
||||
});
|
||||
|
||||
const handler = handle(mockFn, { schema });
|
||||
await expect(handler({})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should return null if schema validation fails and throwOnError is false", async () => {
|
||||
const schema = z.object({ id: z.number() });
|
||||
const mockFn = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: "invalid" }),
|
||||
});
|
||||
|
||||
const handler = handle(mockFn, { schema, throwOnError: false });
|
||||
const result = await handler({});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withTimeout", () => {
|
||||
it("should resolve if promise finishes before timeout", async () => {
|
||||
const promise = new Promise((resolve) =>
|
||||
setTimeout(() => resolve("done"), 10),
|
||||
);
|
||||
const result = await withTimeout(promise, 100);
|
||||
expect(result).toBe("done");
|
||||
});
|
||||
|
||||
it("should reject if timeout is reached", async () => {
|
||||
const promise = new Promise((resolve) =>
|
||||
setTimeout(() => resolve("done"), 100),
|
||||
);
|
||||
await expect(withTimeout(promise, 10)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
115
packages/api/src/utils/test/on-error.test.ts
Normal file
115
packages/api/src/utils/test/on-error.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { onError } from "../on-error";
|
||||
|
||||
import type { Context } from "hono";
|
||||
|
||||
vi.mock("@turbostarter/i18n/server", () => ({
|
||||
getTranslation: vi.fn().mockResolvedValue({
|
||||
t: (key: string) => key,
|
||||
i18n: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@turbostarter/i18n", () => ({
|
||||
isKey: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@turbostarter/shared/utils", () => ({
|
||||
getStatusCode: vi.fn().mockReturnValue(500),
|
||||
}));
|
||||
|
||||
vi.mock("@turbostarter/monitoring-web/server", () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
interface ErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
status: number;
|
||||
path: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
describe("onError", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {
|
||||
/* empty */
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return a formatted error response for valid error object", async () => {
|
||||
const error = {
|
||||
code: "ERROR_CODE",
|
||||
message: "Something went wrong",
|
||||
};
|
||||
|
||||
const context = {
|
||||
req: { raw: { url: "http://localhost/api/test" } },
|
||||
var: { locale: "en" },
|
||||
} as Context<{
|
||||
Bindings: { NODE_ENV: string };
|
||||
Variables: { locale: string };
|
||||
}>;
|
||||
|
||||
const response = await onError(error, context);
|
||||
|
||||
const data = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(data).toMatchObject({
|
||||
code: "ERROR_CODE",
|
||||
message: "Something went wrong",
|
||||
status: 500,
|
||||
path: "/api/test",
|
||||
});
|
||||
expect(data.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("should translate error code if message is missing", async () => {
|
||||
const error = {
|
||||
code: "auth.error.invalid_credentials",
|
||||
message: "",
|
||||
};
|
||||
|
||||
const context = {
|
||||
req: { raw: { url: "http://localhost/api/test" } },
|
||||
var: { locale: "en" },
|
||||
} as Context<{
|
||||
Bindings: { NODE_ENV: string };
|
||||
Variables: { locale: string };
|
||||
}>;
|
||||
|
||||
const response = await onError(error, context);
|
||||
|
||||
const data = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(data.message).toBe("auth.error.invalid_credentials");
|
||||
});
|
||||
|
||||
it("should fallback to general error if input is not a recognized error object", async () => {
|
||||
const error = "Just a string error";
|
||||
|
||||
const context = {
|
||||
req: { raw: { url: "http://localhost/api/test" } },
|
||||
var: { locale: "en" },
|
||||
} as Context<{
|
||||
Bindings: { NODE_ENV: string };
|
||||
Variables: { locale: string };
|
||||
}>;
|
||||
|
||||
const response = await onError(error, context);
|
||||
|
||||
const data = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(data).toMatchObject({
|
||||
code: "common:error.general",
|
||||
message: "common:error.general",
|
||||
status: 500,
|
||||
path: "/api/test",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user