Template
1
0

feat: spec to platform

This commit is contained in:
2025-09-19 18:58:02 +02:00
parent a140780ec3
commit 2433f59d1a
51 changed files with 267 additions and 253 deletions

View File

@@ -1,4 +1,4 @@
import { RoleSchema } from "@spec/schemas/account/role.ts";
import { RoleSchema } from "@platform/spec/account/role.ts";
import { PrincipalProvider } from "@valkyr/auth";
import { db } from "~stores/read-store/database.ts";

View File

@@ -1,4 +1,4 @@
import { ServerError } from "@spec/relay";
import { ServerError } from "@platform/relay";
import type { Level } from "../level.ts";
import { getTracedAt } from "../stack.ts";

View File

@@ -10,7 +10,7 @@ import {
type ServerErrorResponse,
UnauthorizedError,
ZodValidationError,
} from "@spec/relay";
} from "@platform/relay";
import { treeifyError } from "zod";
import { logger } from "~libraries/logger/mod.ts";

View File

@@ -1,4 +1,4 @@
import { ServerContext } from "@spec/relay";
import { ServerContext } from "@platform/relay";
import type { Sockets } from "~libraries/socket/sockets.ts";
@@ -7,7 +7,7 @@ import { Session } from "../auth/auth.ts";
import { Principal } from "../auth/principal.ts";
import { req } from "./request.ts";
declare module "@spec/relay" {
declare module "@platform/relay" {
interface ServerContext {
/**
* Current request instance being handled.

View File

@@ -1,4 +1,4 @@
import { Route } from "@spec/relay";
import { Route } from "@platform/relay";
/**
* Resolve and return all routes that has been created under any 'routes'

View File

@@ -1,4 +1,4 @@
import { InternalServerError, UnauthorizedError } from "@spec/relay";
import { InternalServerError, UnauthorizedError } from "@platform/relay";
import { Session } from "../auth/auth.ts";
import { storage } from "./storage.ts";

View File

@@ -7,15 +7,15 @@
"dependencies": {
"@cerbos/http": "0.23.1",
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1.0.5",
"@spec/modules": "workspace:*",
"@spec/relay": "workspace:*",
"@spec/shared": "workspace:*",
"@platform/models": "workspace:*",
"@platform/relay": "workspace:*",
"@platform/spec": "workspace:*",
"@std/cli": "npm:@jsr/std__cli@1.0.22",
"@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
"@std/fs": "npm:@jsr/std__fs@1.0.19",
"@std/path": "npm:@jsr/std__path@1.1.2",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1.0.1",
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
"cookie": "1.0.2",

View File

@@ -1,4 +1,4 @@
import { procedure } from "@spec/relay/mod.ts";
import { procedure } from "@platform/relay";
import z from "zod";
const EventSchema = z.object({

View File

@@ -1,5 +1,5 @@
import { AccountEmailClaimedError } from "@spec/schemas/account/errors.ts";
import { create } from "@spec/schemas/account/routes.ts";
import { AccountEmailClaimedError } from "@platform/spec/account/errors.ts";
import { create } from "@platform/spec/account/routes.ts";
import { Account, isEmailClaimed } from "~stores/event-store/aggregates/account.ts";
import { eventStore } from "~stores/event-store/event-store.ts";

View File

@@ -1,6 +1,5 @@
import { ForbiddenError } from "@spec/relay/mod.ts";
import { NotFoundError } from "@spec/relay/mod.ts";
import { getById } from "@spec/schemas/account/routes.ts";
import { ForbiddenError, NotFoundError } from "@platform/relay";
import { getById } from "@platform/spec/account/routes.ts";
import { db } from "~stores/read-store/database.ts";

View File

@@ -1,4 +1,4 @@
import { code } from "@spec/schemas/auth/routes.ts";
import { code } from "@platform/spec/auth/routes.ts";
import cookie from "cookie";
import { auth, config } from "~libraries/auth/mod.ts";

View File

@@ -1,4 +1,4 @@
import { email } from "@spec/schemas/auth/routes.ts";
import { email } from "@platform/spec/auth/routes.ts";
import { logger } from "~libraries/logger/mod.ts";
import { Account, getAccountEmailRelation } from "~stores/event-store/aggregates/account.ts";

View File

@@ -1,5 +1,5 @@
import { BadRequestError } from "@spec/relay";
import { password as route } from "@spec/schemas/auth/routes.ts";
import { BadRequestError } from "@platform/relay";
import { password as route } from "@platform/spec/auth/routes.ts";
import cookie from "cookie";
import { config } from "~config";

View File

@@ -1,5 +1,5 @@
import { UnauthorizedError } from "@spec/relay/mod.ts";
import { session } from "@spec/schemas/auth/routes.ts";
import { UnauthorizedError } from "@platform/relay";
import { session } from "@platform/spec/auth/routes.ts";
import { getAccountById } from "~stores/read-store/methods.ts";

View File

@@ -1,17 +1,17 @@
import { toAccountDocument } from "@spec/schemas/account/account.ts";
import { Role } from "@spec/schemas/account/role.ts";
import { Strategy } from "@spec/schemas/account/strategies.ts";
import { Avatar } from "@spec/schemas/avatar.ts";
import { Contact } from "@spec/schemas/contact.ts";
import { Email } from "@spec/schemas/email.ts";
import { Name } from "@spec/schemas/name.ts";
import { toAccountDocument } from "@platform/models/account.ts";
import { Avatar } from "@platform/models/value-objects/avatar.ts";
import { Contact } from "@platform/models/value-objects/contact.ts";
import { Email } from "@platform/models/value-objects/email.ts";
import { Name } from "@platform/models/value-objects/name.ts";
import { Role } from "@platform/spec/account/role.ts";
import { Strategy } from "@platform/spec/account/strategies.ts";
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { db } from "~stores/read-store/database.ts";
import { eventStore } from "../event-store.ts";
import { Auditor, systemAuditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import { EventRecord, EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts";
export class Account extends AggregateRoot<EventStoreFactory> {
@@ -32,11 +32,12 @@ export class Account extends AggregateRoot<EventStoreFactory> {
// Reducer
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
with(event: EventRecord): void {
switch (event.type) {
case "account:created": {
this.id = event.stream;
this.createdAt = getDate(event.created);
break;
}
case "account:avatar:added": {
this.avatar = { url: event.data };

View File

@@ -1,7 +1,7 @@
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { CodeIdentity } from "../events/code.ts";
import { EventStoreFactory } from "../events/mod.ts";
import { EventRecord, EventStoreFactory } from "../events/mod.ts";
export class Code extends AggregateRoot<EventStoreFactory> {
static override readonly name = "code";
@@ -24,7 +24,7 @@ export class Code extends AggregateRoot<EventStoreFactory> {
// Folder
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
with(event: EventRecord): void {
switch (event.type) {
case "code:created": {
this.value = event.data.value;

View File

@@ -1,6 +1,6 @@
import { RoleSchema } from "@spec/schemas/account/role.ts";
import { EmailSchema } from "@spec/schemas/email.ts";
import { NameSchema } from "@spec/schemas/name.ts";
import { EmailSchema } from "@platform/models/value-objects/email.ts";
import { NameSchema } from "@platform/models/value-objects/name.ts";
import { RoleSchema } from "@platform/spec/account/role.ts";
import { event } from "@valkyr/event-store";
import z from "zod";

View File

@@ -1,4 +1,4 @@
import { EventFactory } from "@valkyr/event-store";
import { EventFactory, Prettify } from "@valkyr/event-store";
import account from "./account.ts";
import code from "./code.ts";
@@ -8,3 +8,5 @@ import strategy from "./strategy.ts";
export const events = new EventFactory([...account, ...code, ...organization, ...strategy]);
export type EventStoreFactory = typeof events;
export type EventRecord = Prettify<EventStoreFactory["$events"][number]["$record"]>;

View File

@@ -1,4 +1,4 @@
import type { AccountDocument } from "@spec/schemas/account/account.ts";
import type { AccountDocument } from "@platform/models/account.ts";
import { config } from "~config";
import { getDatabaseAccessor } from "~libraries/database/accessor.ts";

View File

@@ -1,5 +1,5 @@
import { type Account, fromAccountDocument } from "@spec/schemas/account/account.ts";
import { PasswordStrategy } from "@spec/schemas/auth/strategies.ts";
import { Account, fromAccountDocument } from "@platform/models/account.ts";
import { PasswordStrategy } from "@platform/spec/auth/strategies.ts";
import { db, takeOne } from "./database.ts";

View File

@@ -10,8 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"@spec/relay": "workspace:*",
"@spec/schemas": "workspace:*",
"@platform/relay": "workspace:*",
"@platform/spec": "workspace:*",
"@tanstack/react-query": "5.89.0",
"@tanstack/react-router": "1.131.47",
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0",

View File

@@ -6,8 +6,37 @@ import {
ServerError,
type ServerErrorResponse,
type ServerErrorType,
} from "@spec/relay";
} from "@platform/relay";
/**
* HttpAdapter provides a unified transport layer for Relay.
*
* It supports sending JSON objects, nested structures, arrays, and file uploads
* via FormData. The adapter automatically detects the payload type and formats
* the request accordingly. Responses are normalized into `RelayResponse`.
*
* @example
* ```ts
* const adapter = new HttpAdapter({ url: "https://api.example.com" });
*
* // Sending JSON data
* const jsonResponse = await adapter.send({
* method: "POST",
* endpoint: "/users",
* body: { name: "Alice", age: 30 },
* });
*
* // Sending files and nested objects
* const formResponse = await adapter.send({
* method: "POST",
* endpoint: "/upload",
* body: {
* user: { name: "Bob", avatar: fileInput.files[0] },
* documents: [fileInput.files[1], fileInput.files[2]],
* },
* });
* ```
*/
export class HttpAdapter implements RelayAdapter {
/**
* Instantiate a new HttpAdapter instance.
@@ -39,12 +68,7 @@ export class HttpAdapter implements RelayAdapter {
return `${this.url}${endpoint}`;
}
/**
* Send fetch request to the configured endpoint.
*
* @param input - Relay input parameters to use for the request.
*/
async json({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
async send({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
const init: RequestInit = { method, headers };
// ### Before Request
@@ -53,66 +77,18 @@ export class HttpAdapter implements RelayAdapter {
await this.#beforeRequest(headers);
// ### Content Type
// JSON requests are always of the type 'application/json' and this ensures that
// we override any custom pre-hook values for 'content-type' when executing the
// request via the 'json' method.
headers.set("content-type", "application/json");
// ### Body
if (body !== undefined) {
init.body = JSON.stringify(body);
}
// ### Response
return this.request(`${endpoint}${query}`, init);
}
async data({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
const init: RequestInit = { method, headers };
// ### Before Request
// If any before request hooks has been defined, we run them here passing in the
// request headers for further modification.
await this.#beforeRequest(headers);
// ### Content Type
// For multipart uploads we let the browser set the correct boundaries.
headers.delete("content-type");
// ### Body
const formData = new FormData();
if (body !== undefined) {
for (const key in body) {
const entity = body[key];
if (entity === undefined) {
continue;
}
if (Array.isArray(entity)) {
const isFileArray = entity.length > 0 && entity.every((candidate) => candidate instanceof File);
if (isFileArray) {
for (const file of entity) {
formData.append(key, file, file.name);
}
} else {
formData.append(key, JSON.stringify(entity));
}
} else {
if (entity instanceof File) {
formData.append(key, entity, entity.name);
} else {
formData.append(key, typeof entity === "string" ? entity : JSON.stringify(entity));
}
}
const type = this.#getRequestFormat(body);
if (type === "form-data") {
headers.delete("content-type");
init.body = this.#getFormData(body);
}
if (type === "json") {
headers.set("content-type", "application/json");
init.body = JSON.stringify(body);
}
init.body = formData;
}
// ### Response
@@ -144,6 +120,52 @@ export class HttpAdapter implements RelayAdapter {
}
}
/**
* Determine the parser method required for the request.
*
* @param body - Request body.
*/
#getRequestFormat(body: unknown): "form-data" | "json" {
if (containsFile(body) === true) {
return "form-data";
}
return "json";
}
/**
* Get FormData instance for the given body.
*
* @param body - Request body.
*/
#getFormData(data: Record<string, unknown>, formData = new FormData(), parentKey?: string): FormData {
for (const key in data) {
const value = data[key];
if (value === undefined || value === null) continue;
const formKey = parentKey ? `${parentKey}[${key}]` : key;
if (value instanceof File) {
formData.append(formKey, value, value.name);
} else if (Array.isArray(value)) {
value.forEach((item, index) => {
if (item instanceof File) {
formData.append(`${formKey}[${index}]`, item, item.name);
} else if (typeof item === "object") {
this.#getFormData(item as Record<string, unknown>, formData, `${formKey}[${index}]`);
} else {
formData.append(`${formKey}[${index}]`, String(item));
}
});
} else if (typeof value === "object") {
this.#getFormData(value as Record<string, unknown>, formData, formKey);
} else {
formData.append(formKey, String(value));
}
}
return formData;
}
/**
* Convert a fetch response to a compliant relay response.
*
@@ -159,7 +181,6 @@ export class HttpAdapter implements RelayAdapter {
if (type === null) {
return {
result: "error",
headers: response.headers,
error: {
status: response.status,
message: "Missing 'content-type' in header returned from server.",
@@ -174,34 +195,10 @@ export class HttpAdapter implements RelayAdapter {
if (response.status === 204) {
return {
result: "success",
headers: response.headers,
data: null,
};
}
// ### SCIM
// If the 'content-type' is of type 'scim' we need to convert the SCIM compliant
// response to a valid relay response.
if (type === "application/scim+json") {
const parsed = await response.json();
if (response.status >= 400) {
return {
result: "error",
headers: response.headers,
error: {
status: response.status,
message: parsed.detail,
},
};
}
return {
result: "success",
headers: response.headers,
data: parsed,
};
}
// ### JSON
// If the 'content-type' contains 'json' we treat it as a 'json' compliant response
// and attempt to resolve it as such.
@@ -211,20 +208,17 @@ export class HttpAdapter implements RelayAdapter {
if ("data" in parsed) {
return {
result: "success",
headers: response.headers,
data: parsed.data,
};
}
if ("error" in parsed) {
return {
result: "error",
headers: response.headers,
error: this.#toError(parsed),
};
}
return {
result: "error",
headers: response.headers,
error: {
status: response.status,
message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.",
@@ -234,7 +228,6 @@ export class HttpAdapter implements RelayAdapter {
return {
result: "error",
headers: response.headers,
error: {
status: response.status,
message: "Unsupported 'content-type' in header returned from server.",
@@ -259,6 +252,19 @@ export class HttpAdapter implements RelayAdapter {
}
}
function containsFile(value: unknown): boolean {
if (value instanceof File) {
return true;
}
if (Array.isArray(value)) {
return value.some(containsFile);
}
if (typeof value === "object" && value !== null) {
return Object.values(value).some(containsFile);
}
return false;
}
export type HttpAdapterOptions = {
url: string;
hooks?: {

View File

@@ -1,4 +1,4 @@
import { makeClient } from "@spec/relay";
import { makeClient } from "@platform/relay";
import { HttpAdapter } from "../adapters/http.ts";
@@ -9,7 +9,7 @@ export const api = makeClient(
}),
},
{
account: (await import("@spec/schemas/account/routes.ts")).routes,
auth: (await import("@spec/schemas/auth/routes.ts")).routes,
account: (await import("@platform/spec/account/routes.ts")).routes,
auth: (await import("@platform/spec/auth/routes.ts")).routes,
},
);

View File

@@ -4,12 +4,14 @@
"workspace": [
"api",
"apps/react",
"spec/relay",
"spec/schemas"
"platform/models",
"platform/relay",
"platform/spec"
],
"imports": {
"@spec/relay/": "./spec/relay/",
"@spec/schemas/": "./spec/schemas/"
"@platform/models/": "./platform/models/",
"@platform/relay": "./platform/relay/mod.ts",
"@platform/spec/": "./platform/spec/"
},
"tasks": {
"start:api": {
@@ -21,7 +23,7 @@
"description": "Start react application instance."
},
"check": {
"command": "deno check ./api/server.ts",
"command": "deno check ./api/server.ts ./platform",
"description": "Runs a check on all the projects main entry files."
},
"lint": {

109
deno.lock generated
View File

@@ -13,7 +13,7 @@
"npm:@jsr/valkyr__auth@2.1.4": "2.1.4",
"npm:@jsr/valkyr__db@2.0.0": "2.0.0",
"npm:@jsr/valkyr__event-emitter@1.0.1": "1.0.1",
"npm:@jsr/valkyr__event-store@2": "2.0.0",
"npm:@jsr/valkyr__event-store@2.0.1": "2.0.1",
"npm:@jsr/valkyr__inverse@1.0.1": "1.0.1",
"npm:@jsr/valkyr__json-rpc@1.1.0": "1.1.0",
"npm:@tailwindcss/vite@4.1.13": "4.1.13_vite@7.1.6__picomatch@4.0.3_@types+node@24.2.0",
@@ -538,8 +538,8 @@
],
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-emitter/1.0.1.tgz"
},
"@jsr/valkyr__event-store@2.0.0": {
"integrity": "sha512-izGy/QIGQXoTz0PP1UinSWcSPEEpNuePbmApBbvHq6MFp1p2X/k2eDPKlz2txwXcIn+QKjDhE5F59xPNEKnIng==",
"@jsr/valkyr__event-store@2.0.1": {
"integrity": "sha512-OvSPX0XH5+oS4zQh1O8J7JvsCoH5pBFNuJ1PdNA5B0OascrSWUqpxNEmytOtJhZuhfYzdvyOU1yNEvSI84D5wg==",
"dependencies": [
"@jsr/valkyr__db",
"@jsr/valkyr__testcontainers",
@@ -547,7 +547,7 @@
"postgres",
"zod"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-store/2.0.0.tgz"
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-store/2.0.1.tgz"
},
"@jsr/valkyr__inverse@1.0.1": {
"integrity": "sha512-uZpzPct9FGobgl6H+iR3VJlzZbTFVmJSrB4z5In8zHgIJCkmgYj0diU3soU6MuiKR7SFBfD4PGSuUpTTJHNMlg==",
@@ -597,108 +597,108 @@
"@rolldown/pluginutils@1.0.0-beta.27": {
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="
},
"@rollup/rollup-android-arm-eabi@4.50.2": {
"integrity": "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==",
"@rollup/rollup-android-arm-eabi@4.51.0": {
"integrity": "sha512-VyfldO8T/C5vAXBGIobrAnUE+VJNVLw5z9h4NgSDq/AJZWt/fXqdW+0PJbk+M74xz7yMDRiHtlsuDV7ew6K20w==",
"os": ["android"],
"cpu": ["arm"]
},
"@rollup/rollup-android-arm64@4.50.2": {
"integrity": "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==",
"@rollup/rollup-android-arm64@4.51.0": {
"integrity": "sha512-Z3ujzDZgsEVSokgIhmOAReh9SGT2qloJJX2Xo1Q3nPU1EhCXrV0PbpR3r7DWRgozqnjrPZQkLe5cgBPIYp70Vg==",
"os": ["android"],
"cpu": ["arm64"]
},
"@rollup/rollup-darwin-arm64@4.50.2": {
"integrity": "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==",
"@rollup/rollup-darwin-arm64@4.51.0": {
"integrity": "sha512-T3gskHgArUdR6TCN69li5VELVAZK+iQ4iwMoSMNYixoj+56EC9lTj35rcxhXzIJt40YfBkvDy3GS+t5zh7zM6g==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@rollup/rollup-darwin-x64@4.50.2": {
"integrity": "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==",
"@rollup/rollup-darwin-x64@4.51.0": {
"integrity": "sha512-Hh7n/fh0g5UjH6ATDF56Qdf5bzdLZKIbhp5KftjMYG546Ocjeyg15dxphCpH1FFY2PJ2G6MiOVL4jMq5VLTyrQ==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@rollup/rollup-freebsd-arm64@4.50.2": {
"integrity": "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==",
"@rollup/rollup-freebsd-arm64@4.51.0": {
"integrity": "sha512-0EddADb6FBvfqYoxwVom3hAbAvpSVUbZqmR1wmjk0MSZ06hn/UxxGHKRqEQDMkts7XiZjejVB+TLF28cDTU+gA==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
"@rollup/rollup-freebsd-x64@4.50.2": {
"integrity": "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==",
"@rollup/rollup-freebsd-x64@4.51.0": {
"integrity": "sha512-MpqaEDLo3JuVPF+wWV4mK7V8akL76WCz8ndfz1aVB7RhvXFO3k7yT7eu8OEuog4VTSyNu5ibvN9n6lgjq/qLEQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@rollup/rollup-linux-arm-gnueabihf@4.50.2": {
"integrity": "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==",
"@rollup/rollup-linux-arm-gnueabihf@4.51.0": {
"integrity": "sha512-WEWAGFNFFpvSWAIT3MYvxTkYHv/cJl9yWKpjhheg7ONfB0hetZt/uwBnM3GZqSHrk5bXCDYTFXg3jQyk/j7eXQ==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rollup/rollup-linux-arm-musleabihf@4.50.2": {
"integrity": "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==",
"@rollup/rollup-linux-arm-musleabihf@4.51.0": {
"integrity": "sha512-9bxtxj8QoAp++LOq5PGDGkEEOpCDk9rOEHUcXadnijedDH8IXrBt6PnBa4Y6NblvGWdoxvXZYghZLaliTCmAng==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rollup/rollup-linux-arm64-gnu@4.50.2": {
"integrity": "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==",
"@rollup/rollup-linux-arm64-gnu@4.51.0": {
"integrity": "sha512-DdqA+fARqIsfqDYkKo2nrWMp0kvu/wPJ2G8lZ4DjYhn+8QhrjVuzmsh7tTkhULwjvHTN59nWVzAixmOi6rqjNA==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rollup/rollup-linux-arm64-musl@4.50.2": {
"integrity": "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==",
"@rollup/rollup-linux-arm64-musl@4.51.0": {
"integrity": "sha512-2XVRNzcUJE1UJua8P4a1GXS5jafFWE+pQ6zhUbZzptOu/70p1F6+0FTi6aGPd6jNtnJqGMjtBCXancC2dhYlWw==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rollup/rollup-linux-loong64-gnu@4.50.2": {
"integrity": "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==",
"@rollup/rollup-linux-loong64-gnu@4.51.0": {
"integrity": "sha512-R8QhY0kLIPCAVXWi2yftDSpn7Jtejey/WhMoBESSfwGec5SKdFVupjxFlKoQ7clVRuaDpiQf7wNx3EBZf4Ey6g==",
"os": ["linux"],
"cpu": ["loong64"]
},
"@rollup/rollup-linux-ppc64-gnu@4.50.2": {
"integrity": "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==",
"@rollup/rollup-linux-ppc64-gnu@4.51.0": {
"integrity": "sha512-I498RPfxx9cMv1KTHQ9tg2Ku1utuQm+T5B+Xro+WNu3FzAFSKp4awKfgMoZwjoPgNbaFGINaOM25cQW6WuBhiQ==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@rollup/rollup-linux-riscv64-gnu@4.50.2": {
"integrity": "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==",
"@rollup/rollup-linux-riscv64-gnu@4.51.0": {
"integrity": "sha512-o8COudsb8lvtdm9ixg9aKjfX5aeoc2x9KGE7WjtrmQFquoCRZ9jtzGlonujE4WhvXFepTraWzT4RcwyDDeHXjA==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@rollup/rollup-linux-riscv64-musl@4.50.2": {
"integrity": "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==",
"@rollup/rollup-linux-riscv64-musl@4.51.0": {
"integrity": "sha512-0shJPgSXMdYzOQzpM5BJN2euXY1f8uV8mS6AnrbMcH2KrkNsbpMxWB1wp8UEdiJ1NtyBkCk3U/HfX5mEONBq6w==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@rollup/rollup-linux-s390x-gnu@4.50.2": {
"integrity": "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==",
"@rollup/rollup-linux-s390x-gnu@4.51.0": {
"integrity": "sha512-L7pV+ny7865jamSCQwyozBYjFRUKaTsPqDz7ClOtJCDu4paf2uAa0mrcHwSt4XxZP2ogFZS9uuitH3NXdeBEJA==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@rollup/rollup-linux-x64-gnu@4.50.2": {
"integrity": "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==",
"@rollup/rollup-linux-x64-gnu@4.51.0": {
"integrity": "sha512-4YHhP+Rv3T3+H3TPbUvWOw5tuSwhrVhkHHZhk4hC9VXeAOKR26/IsUAT4FsB4mT+kfIdxxb1BezQDEg/voPO8A==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rollup/rollup-linux-x64-musl@4.50.2": {
"integrity": "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==",
"@rollup/rollup-linux-x64-musl@4.51.0": {
"integrity": "sha512-P7U7U03+E5w7WgJtvSseNLOX1UhknVPmEaqgUENFWfNxNBa1OhExT6qYGmyF8gepcxWSaSfJsAV5UwhWrYefdQ==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rollup/rollup-openharmony-arm64@4.50.2": {
"integrity": "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==",
"@rollup/rollup-openharmony-arm64@4.51.0": {
"integrity": "sha512-FuD8g3u9W6RPwdO1R45hZFORwa1g9YXEMesAKP/sOi7mDqxjbni8S3zAXJiDcRfGfGBqpRYVuH54Gu3FTuSoEw==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@rollup/rollup-win32-arm64-msvc@4.50.2": {
"integrity": "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==",
"@rollup/rollup-win32-arm64-msvc@4.51.0": {
"integrity": "sha512-zST+FdMCX3QAYfmZX3dp/Fy8qLUetfE17QN5ZmmFGPrhl86qvRr+E9u2bk7fzkIXsfQR30Z7ZRS7WMryPPn4rQ==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@rollup/rollup-win32-ia32-msvc@4.50.2": {
"integrity": "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==",
"@rollup/rollup-win32-ia32-msvc@4.51.0": {
"integrity": "sha512-U+qhoCVAZmTHCmUKxdQxw1jwAFNFXmOpMME7Npt5GTb1W/7itfgAgNluVOvyeuSeqW+dEQLFuNZF3YZPO8XkMg==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@rollup/rollup-win32-x64-msvc@4.50.2": {
"integrity": "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==",
"@rollup/rollup-win32-x64-msvc@4.51.0": {
"integrity": "sha512-z6UpFzMhXSD8NNUfCi2HO+pbpSzSWIIPgb1TZsEZjmZYtk6RUIC63JYjlFBwbBZS3jt3f1q6IGfkj3g+GnBt2Q==",
"os": ["win32"],
"cpu": ["x64"]
},
@@ -1871,8 +1871,8 @@
"reusify@1.1.0": {
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
},
"rollup@4.50.2": {
"integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==",
"rollup@4.51.0": {
"integrity": "sha512-7cR0XWrdp/UAj2HMY/Y4QQEUjidn3l2AY1wSeZoFjMbD8aOMPoV9wgTFYbrJpPzzvejDEini1h3CiUP8wLzxQA==",
"dependencies": [
"@types/estree"
],
@@ -2194,7 +2194,7 @@
"npm:@jsr/std__fs@1.0.19",
"npm:@jsr/std__path@1.1.2",
"npm:@jsr/valkyr__auth@2.1.4",
"npm:@jsr/valkyr__event-store@2",
"npm:@jsr/valkyr__event-store@2.0.1",
"npm:@jsr/valkyr__inverse@1.0.1",
"npm:@jsr/valkyr__json-rpc@1.1.0",
"npm:cookie@1.0.2",
@@ -2231,7 +2231,14 @@
]
}
},
"spec/relay": {
"platform/models": {
"packageJson": {
"dependencies": [
"npm:zod@4"
]
}
},
"platform/relay": {
"packageJson": {
"dependencies": [
"npm:path-to-regexp@8",
@@ -2239,7 +2246,7 @@
]
}
},
"spec/schemas": {
"platform/spec": {
"packageJson": {
"dependencies": [
"npm:zod@4"

View File

@@ -0,0 +1,25 @@
import { RoleSchema } from "@platform/spec/account/role.ts";
import { StrategySchema } from "@platform/spec/account/strategies.ts";
import { z } from "zod";
import { makeModelParser } from "./helpers/parser.ts";
import { AvatarSchema } from "./value-objects/avatar.ts";
import { ContactSchema } from "./value-objects/contact.ts";
import { NameSchema } from "./value-objects/name.ts";
export const AccountSchema = z.object({
id: z.uuid(),
avatar: AvatarSchema.optional(),
name: NameSchema.optional(),
contact: ContactSchema.default({
emails: [],
}),
strategies: z.array(StrategySchema).default([]),
roles: z.array(RoleSchema).default([]),
});
export const toAccountDocument = makeModelParser(AccountSchema);
export const fromAccountDocument = makeModelParser(AccountSchema);
export type Account = z.infer<typeof AccountSchema>;
export type AccountDocument = z.infer<typeof AccountSchema>;

View File

@@ -1,15 +1,15 @@
import z, { ZodObject } from "zod";
export function makeSchemaParser<TSchema extends ZodObject>(schema: TSchema): SchemaParserFn<TSchema> {
export function makeModelParser<TSchema extends ZodObject>(schema: TSchema): ModelParserFn<TSchema> {
return ((value: unknown | unknown[]) => {
if (Array.isArray(value)) {
return value.map((value: unknown) => schema.parse(value));
}
return schema.parse(value);
}) as SchemaParserFn<TSchema>;
}) as ModelParserFn<TSchema>;
}
type SchemaParserFn<TSchema extends ZodObject> = {
type ModelParserFn<TSchema extends ZodObject> = {
(value: unknown): z.infer<TSchema>;
(value: unknown[]): z.infer<TSchema>[];
};

View File

@@ -1,10 +1,10 @@
{
"name": "@spec/schemas",
"name": "@platform/models",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@spec/relay": "workspace:*",
"@platform/spec": "workspace:*",
"zod": "4"
}
}

View File

@@ -49,18 +49,11 @@ export type RelayAdapter = {
getUrl(endpoint: string): string;
/**
* Send a 'application/json' request to the configured relay url.
* Send a request to the configured relay url.
*
* @param input - Request input parameters.
*/
json(input: RelayInput): Promise<RelayResponse>;
/**
* Send a form data request to the configured relay url.
*
* @param input - Request input parameters.
*/
data(input: RelayInput): Promise<RelayResponse>;
send(input: RelayInput): Promise<RelayResponse>;
/**
* Sends a fetch request using the given options and returns a

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import z, { ZodObject, ZodType } from "zod";
import type { ZodObject, ZodType } from "zod";
import type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts";
import { Route, type Routes } from "./route.ts";
@@ -110,10 +110,10 @@ function getRouteFn(route: Route, { adapter }: Config) {
// ### Fetch
const response = route.state.content === "json" ? await adapter.json(input) : await adapter.data(input);
const response = await adapter.send(input);
if ("data" in response && route.state.output !== undefined) {
response.data = route.state.output.parse(response.data);
if ("data" in response && route.state.response !== undefined) {
response.data = route.state.response.parse(response.data);
}
return response;
@@ -179,9 +179,7 @@ type RouteResponse<TRoute extends Route> = Promise<RelayResponse<RouteOutput<TRo
$response: TRoute["$response"];
};
type RouteOutput<TRoute extends Route> = TRoute["state"]["output"] extends ZodType
? z.infer<TRoute["state"]["output"]>
: null;
type RouteOutput<TRoute extends Route> = TRoute["state"]["response"] extends ZodType ? TRoute["$response"] : null;
type RouteErrors<TRoute extends Route> = InstanceType<TRoute["state"]["errors"][number]>;

View File

@@ -1,5 +1,5 @@
{
"name": "@spec/relay",
"name": "@platform/relay",
"version": "0.0.0",
"private": true,
"type": "module",

View File

@@ -1,4 +1,4 @@
import { ConflictError } from "@spec/relay";
import { ConflictError } from "@platform/relay";
export class AccountEmailClaimedError extends ConflictError {
constructor(email: string) {

View File

@@ -1,8 +1,8 @@
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@spec/relay";
import { AccountSchema } from "@platform/models/account.ts";
import { NameSchema } from "@platform/models/value-objects/name.ts";
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
import z from "zod";
import { NameSchema } from "../name.ts";
import { AccountSchema } from "./account.ts";
import { AccountEmailClaimedError } from "./errors.ts";
export const create = route

View File

@@ -1,4 +1,4 @@
import { BadRequestError } from "@spec/relay";
import { BadRequestError } from "@platform/relay";
export class AuthenticationStrategyPayloadError extends BadRequestError {
constructor() {

View File

@@ -1,8 +1,7 @@
import { route, UnauthorizedError } from "@spec/relay";
import { AccountSchema } from "@platform/models/account.ts";
import { route, UnauthorizedError } from "@platform/relay";
import z from "zod";
import { AccountSchema } from "../account/account.ts";
export * from "./errors.ts";
export * from "./strategies.ts";

View File

@@ -0,0 +1,11 @@
{
"name": "@platform/spec",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@platform/models": "workspace:*",
"@platform/relay": "workspace:*",
"zod": "4"
}
}

View File

@@ -1,3 +0,0 @@
# Spec
Todo ...

View File

@@ -1 +0,0 @@
# Modules

View File

@@ -1,25 +0,0 @@
import { z } from "zod";
import { AvatarSchema } from "../avatar.ts";
import { ContactSchema } from "../contact.ts";
import { makeSchemaParser } from "../database.ts";
import { NameSchema } from "../name.ts";
import { RoleSchema } from "./role.ts";
import { StrategySchema } from "./strategies.ts";
export const AccountSchema = z.object({
id: z.uuid(),
avatar: AvatarSchema.optional(),
name: NameSchema.optional(),
contact: ContactSchema.default({
emails: [],
}),
strategies: z.array(StrategySchema).default([]),
roles: z.array(RoleSchema).default([]),
});
export const toAccountDocument = makeSchemaParser(AccountSchema);
export const fromAccountDocument = makeSchemaParser(AccountSchema);
export type Account = z.infer<typeof AccountSchema>;
export type AccountDocument = z.infer<typeof AccountSchema>;