diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 64a9cd2..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Publish - -on: - workflow_dispatch: - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Deno - uses: maximousblk/setup-deno@v2 - - - name: Setup Node.JS - uses: actions/setup-node@v4 - with: - node-version: 22 - - - run: deno install - - run: deno task lint - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Deno - uses: maximousblk/setup-deno@v2 - - - run: deno install - - run: deno task test - - publish: - runs-on: ubuntu-latest - needs: [lint, test] - - permissions: - contents: read - id-token: write - - steps: - - uses: actions/checkout@v4 - - - name: Publish package - run: npx jsr publish \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index fb101be..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Test - -on: - pull_request: - branches: - - main - push: - branches: - - main - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Deno - uses: maximousblk/setup-deno@v2 - - - name: Setup Node.JS - uses: actions/setup-node@v4 - with: - node-version: 20 - - - run: deno install - - run: deno task lint - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Deno - uses: maximousblk/setup-deno@v2 - - - run: deno install - - run: deno task test - - run: deno task test:publish \ No newline at end of file diff --git a/.gitignore b/.gitignore index b512c09..7625c09 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +.volumes node_modules \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b5afb12 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 120, + "singleQuote": false, + "overrides": [ + { + "files": "*.ts", + "options": { + "parser": "typescript" + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index b2bb4f6..52da05f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,32 @@ { "deno.enable": true, - "editor.formatOnSave": true, + "deno.lint": false, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, + "[markdown]": { + "editor.defaultFormatter": null, + "editor.wordWrap": "off" + }, + "eslint.options": { + "ignorePatterns": ["**/*.md"] + }, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true + }, + "vue.format.style.initialIndent": true, + "vue.format.script.initialIndent": true } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd147bd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM denoland/deno:2.3.1 +ENV TZ=UTC +ENV PORT=8370 +EXPOSE 8370 + +WORKDIR /app + +COPY api/ ./api/ +COPY relay/ ./relay/ +COPY .npmrc . +COPY deno-docker.json ./deno.json + +RUN chown -R deno:deno /app/ + +USER deno + +RUN deno install --allow-scripts + +CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index ed5c97f..0000000 --- a/LICENSE +++ /dev/null @@ -1,16 +0,0 @@ -MIT License - -Copyright 2025 Christoffer Rødvik. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -Commercial use is permitted, provided the Software is not sold, relicensed, or distributed as a stand-alone solution, whether in original or minimally modified form. -Use as part of a larger work, integrated product, or service is allowed. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 8803f5c..bd2198e 100644 --- a/README.md +++ b/README.md @@ -1,94 +1 @@ -
-
-
{
+ return this.$store.reduce({
+ name: "code",
+ stream,
+ reducer: this.#reducer,
+ });
+ }
+
+ get isClaimed(): boolean {
+ return this.claimedAt !== undefined;
+ }
+
+ // -------------------------------------------------------------------------
+ // Folder
+ // -------------------------------------------------------------------------
+
+ with(event: EventStoreFactory["$events"][number]["$record"]): void {
+ switch (event.type) {
+ case "code:created": {
+ this.id = event.stream;
+ this.value = event.data.value;
+ this.identity = event.data.identity;
+ this.createdAt = getDate(event.created);
+ break;
+ }
+ case "code:claimed": {
+ this.claimedAt = getDate(event.created);
+ break;
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Actions
+ // -------------------------------------------------------------------------
+
+ claim(): this {
+ return this.push({
+ type: "code:claimed",
+ stream: this.id,
+ });
+ }
+}
diff --git a/api/libraries/event-store/aggregates/mod.ts b/api/libraries/event-store/aggregates/mod.ts
new file mode 100644
index 0000000..fad22fc
--- /dev/null
+++ b/api/libraries/event-store/aggregates/mod.ts
@@ -0,0 +1,8 @@
+import { AggregateFactory } from "@valkyr/event-store";
+
+import { Account } from "./account.ts";
+import { Code } from "./code.ts";
+import { Organization } from "./organization.ts";
+import { Role } from "./role.ts";
+
+export const aggregates = new AggregateFactory([Account, Code, Organization, Role]);
diff --git a/api/libraries/event-store/aggregates/organization.ts b/api/libraries/event-store/aggregates/organization.ts
new file mode 100644
index 0000000..d561cff
--- /dev/null
+++ b/api/libraries/event-store/aggregates/organization.ts
@@ -0,0 +1,65 @@
+import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
+
+import { db } from "~libraries/read-store/mod.ts";
+
+import { Auditor } from "../events/auditor.ts";
+import { EventStoreFactory } from "../events/mod.ts";
+import { projector } from "../projector.ts";
+
+export class Organization extends AggregateRoot {
+ static override readonly name = "organization";
+
+ id!: string;
+
+ name!: string;
+
+ createdAt!: Date;
+ updatedAt!: Date;
+
+ // -------------------------------------------------------------------------
+ // Factories
+ // -------------------------------------------------------------------------
+
+ static #reducer = makeAggregateReducer(Organization);
+
+ static create(name: string, meta: Auditor): Organization {
+ return new Organization().push({
+ type: "organization:created",
+ data: { name },
+ meta,
+ });
+ }
+
+ static async getById(stream: string): Promise {
+ return this.$store.reduce({ name: "organization", stream, reducer: this.#reducer });
+ }
+
+ // -------------------------------------------------------------------------
+ // Reducer
+ // -------------------------------------------------------------------------
+
+ with(event: EventStoreFactory["$events"][number]["$record"]): void {
+ switch (event.type) {
+ case "organization:created": {
+ this.id = event.stream;
+ this.name = event.data.name;
+ this.createdAt = getDate(event.created);
+ break;
+ }
+ }
+ }
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Projectors
+ |--------------------------------------------------------------------------------
+ */
+
+projector.on("organization:created", async ({ stream: id, data: { name }, created }) => {
+ await db.collection("organizations").insertOne({
+ id,
+ name,
+ createdAt: getDate(created),
+ });
+});
diff --git a/api/libraries/event-store/aggregates/role.ts b/api/libraries/event-store/aggregates/role.ts
new file mode 100644
index 0000000..768c209
--- /dev/null
+++ b/api/libraries/event-store/aggregates/role.ts
@@ -0,0 +1,118 @@
+import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
+
+import { db } from "~libraries/read-store/database.ts";
+
+import type { Auditor } from "../events/auditor.ts";
+import { EventStoreFactory } from "../events/mod.ts";
+import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts";
+import { projector } from "../projector.ts";
+
+export class Role extends AggregateRoot {
+ static override readonly name = "role";
+
+ id!: string;
+
+ name!: string;
+ permissions: { [resource: string]: Set } = {};
+
+ createdAt!: Date;
+ updatedAt!: Date;
+
+ // -------------------------------------------------------------------------
+ // Factories
+ // -------------------------------------------------------------------------
+
+ static #reducer = makeAggregateReducer(Role);
+
+ static create(data: RoleCreatedData, meta: Auditor): Role {
+ return new Role().push({
+ type: "role:created",
+ data,
+ meta,
+ });
+ }
+
+ static async getById(stream: string): Promise {
+ return this.$store.reduce({ name: "role", stream, reducer: this.#reducer });
+ }
+
+ // -------------------------------------------------------------------------
+ // Reducer
+ // -------------------------------------------------------------------------
+
+ override with(event: EventStoreFactory["$events"][number]["$record"]): void {
+ switch (event.type) {
+ case "role:created": {
+ this.id = event.stream;
+ this.createdAt = getDate(event.created);
+ this.updatedAt = getDate(event.created);
+ break;
+ }
+ case "role:name-set": {
+ this.name = event.data;
+ this.updatedAt = getDate(event.created);
+ break;
+ }
+ case "role:permissions-set": {
+ for (const operation of event.data) {
+ if (operation.type === "grant") {
+ if (this.permissions[operation.resource] === undefined) {
+ this.permissions[operation.resource] = new Set();
+ }
+ this.permissions[operation.resource].add(operation.action);
+ }
+ if (operation.type === "deny") {
+ if (operation.action === undefined) {
+ delete this.permissions[operation.resource];
+ } else {
+ this.permissions[operation.resource]?.delete(operation.action);
+ }
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Actions
+ // -------------------------------------------------------------------------
+
+ setName(name: string, meta: Auditor): this {
+ return this.push({
+ type: "role:name-set",
+ stream: this.id,
+ data: name,
+ meta,
+ });
+ }
+
+ setPermissions(operations: RolePermissionOperation[], meta: Auditor): this {
+ return this.push({
+ type: "role:permissions-set",
+ stream: this.id,
+ data: operations,
+ meta,
+ });
+ }
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Projectors
+ |--------------------------------------------------------------------------------
+ */
+
+projector.on("role:created", async ({ stream, data: { name, permissions } }) => {
+ await db.collection("roles").insertOne({
+ id: stream,
+ name,
+ permissions: permissions.reduce(
+ (map, permission) => {
+ map[permission.resource] = permission.actions;
+ return map;
+ },
+ {} as Record,
+ ),
+ });
+});
diff --git a/api/libraries/event-store/event-store.ts b/api/libraries/event-store/event-store.ts
new file mode 100644
index 0000000..5c5a315
--- /dev/null
+++ b/api/libraries/event-store/event-store.ts
@@ -0,0 +1,25 @@
+import { EventStore } from "@valkyr/event-store";
+import { MongoAdapter } from "@valkyr/event-store/mongo";
+
+import { container } from "~libraries/database/container.ts";
+
+import { aggregates } from "./aggregates/mod.ts";
+import { events } from "./events/mod.ts";
+import { projector } from "./projector.ts";
+
+export const eventStore = new EventStore({
+ adapter: new MongoAdapter(() => container.get("client"), "balto:event-store"),
+ events,
+ aggregates,
+ snapshot: "auto",
+});
+
+eventStore.onEventsInserted(async (records, { batch }) => {
+ if (batch !== undefined) {
+ await projector.pushMany(batch, records);
+ } else {
+ for (const record of records) {
+ await projector.push(record, { hydrated: false, outdated: false });
+ }
+ }
+});
diff --git a/api/libraries/event-store/events/account.ts b/api/libraries/event-store/events/account.ts
new file mode 100644
index 0000000..ff00930
--- /dev/null
+++ b/api/libraries/event-store/events/account.ts
@@ -0,0 +1,29 @@
+import { event } from "@valkyr/event-store";
+import { email, name, phone } from "relay/schemas";
+import z from "zod";
+
+import { auditor } from "./auditor.ts";
+
+const created = z.discriminatedUnion([
+ z.object({
+ type: z.literal("admin"),
+ }),
+ z.object({
+ type: z.literal("consultant"),
+ }),
+ z.object({
+ type: z.literal("organization"),
+ organizationId: z.string(),
+ }),
+]);
+
+export default [
+ event.type("account:created").data(created).meta(auditor),
+ event.type("account:avatar:added").data(z.string()).meta(auditor),
+ event.type("account:name:added").data(name).meta(auditor),
+ event.type("account:email:added").data(email).meta(auditor),
+ event.type("account:phone:added").data(phone).meta(auditor),
+ event.type("account:role:added").data(z.string()).meta(auditor),
+];
+
+export type AccountCreatedData = z.infer;
diff --git a/api/libraries/event-store/events/auditor.ts b/api/libraries/event-store/events/auditor.ts
new file mode 100644
index 0000000..9819416
--- /dev/null
+++ b/api/libraries/event-store/events/auditor.ts
@@ -0,0 +1,7 @@
+import z from "zod";
+
+export const auditor = z.object({
+ accountId: z.string(),
+});
+
+export type Auditor = z.infer;
diff --git a/api/libraries/event-store/events/code.ts b/api/libraries/event-store/events/code.ts
new file mode 100644
index 0000000..cdca4d7
--- /dev/null
+++ b/api/libraries/event-store/events/code.ts
@@ -0,0 +1,30 @@
+import { event } from "@valkyr/event-store";
+import z from "zod";
+
+const identity = z.discriminatedUnion([
+ z.object({
+ type: z.literal("admin"),
+ accountId: z.string(),
+ }),
+ z.object({
+ type: z.literal("consultant"),
+ accountId: z.string(),
+ }),
+ z.object({
+ type: z.literal("organization"),
+ organizationId: z.string(),
+ accountId: z.string(),
+ }),
+]);
+
+export default [
+ event.type("code:created").data(
+ z.object({
+ value: z.string(),
+ identity,
+ }),
+ ),
+ event.type("code:claimed"),
+];
+
+export type CodeIdentity = z.infer;
diff --git a/api/libraries/event-store/events/mod.ts b/api/libraries/event-store/events/mod.ts
new file mode 100644
index 0000000..3c5f0dc
--- /dev/null
+++ b/api/libraries/event-store/events/mod.ts
@@ -0,0 +1,11 @@
+import { EventFactory } from "@valkyr/event-store";
+
+import account from "./account.ts";
+import code from "./code.ts";
+import organization from "./organization.ts";
+import role from "./role.ts";
+import strategy from "./strategy.ts";
+
+export const events = new EventFactory([...account, ...code, ...organization, ...role, ...strategy]);
+
+export type EventStoreFactory = typeof events;
diff --git a/api/libraries/event-store/events/organization.ts b/api/libraries/event-store/events/organization.ts
new file mode 100644
index 0000000..cea6003
--- /dev/null
+++ b/api/libraries/event-store/events/organization.ts
@@ -0,0 +1,11 @@
+import { event } from "@valkyr/event-store";
+import z from "zod";
+
+import { auditor } from "./auditor.ts";
+
+export default [
+ event
+ .type("organization:created")
+ .data(z.object({ name: z.string() }))
+ .meta(auditor),
+];
diff --git a/api/libraries/event-store/events/role.ts b/api/libraries/event-store/events/role.ts
new file mode 100644
index 0000000..6635512
--- /dev/null
+++ b/api/libraries/event-store/events/role.ts
@@ -0,0 +1,37 @@
+import { event } from "@valkyr/event-store";
+import z from "zod";
+
+import { auditor } from "./auditor.ts";
+
+const created = z.object({
+ name: z.string(),
+ permissions: z.array(
+ z.object({
+ resource: z.string(),
+ actions: z.array(z.string()),
+ }),
+ ),
+});
+
+const operation = z.discriminatedUnion([
+ z.object({
+ type: z.literal("grant"),
+ resource: z.string(),
+ action: z.string(),
+ }),
+ z.object({
+ type: z.literal("deny"),
+ resource: z.string(),
+ action: z.string().optional(),
+ }),
+]);
+
+export default [
+ event.type("role:created").data(created).meta(auditor),
+ event.type("role:name-set").data(z.string()).meta(auditor),
+ event.type("role:permissions-set").data(z.array(operation)).meta(auditor),
+];
+
+export type RoleCreatedData = z.infer;
+
+export type RolePermissionOperation = z.infer;
diff --git a/api/libraries/event-store/events/strategy.ts b/api/libraries/event-store/events/strategy.ts
new file mode 100644
index 0000000..b21d12e
--- /dev/null
+++ b/api/libraries/event-store/events/strategy.ts
@@ -0,0 +1,13 @@
+import { event } from "@valkyr/event-store";
+import z from "zod";
+
+import { auditor } from "./auditor.ts";
+
+export default [
+ event.type("strategy:email:added").data(z.string()).meta(auditor),
+ event.type("strategy:passkey:added").meta(auditor),
+ event
+ .type("strategy:password:added")
+ .data(z.object({ alias: z.string(), password: z.string() }))
+ .meta(auditor),
+];
diff --git a/api/libraries/event-store/mod.ts b/api/libraries/event-store/mod.ts
new file mode 100644
index 0000000..23cc215
--- /dev/null
+++ b/api/libraries/event-store/mod.ts
@@ -0,0 +1,2 @@
+export * from "./event-store.ts";
+export * from "./projector.ts";
diff --git a/api/libraries/event-store/projector.ts b/api/libraries/event-store/projector.ts
new file mode 100644
index 0000000..4a7e831
--- /dev/null
+++ b/api/libraries/event-store/projector.ts
@@ -0,0 +1,5 @@
+import { Projector } from "@valkyr/event-store";
+
+import { EventStoreFactory } from "./events/mod.ts";
+
+export const projector = new Projector();
diff --git a/api/libraries/logger/chalk.ts b/api/libraries/logger/chalk.ts
new file mode 100644
index 0000000..4a5d72d
--- /dev/null
+++ b/api/libraries/logger/chalk.ts
@@ -0,0 +1,48 @@
+import { HexValue } from "./color/hex.ts";
+import { type BGColor, type Color, hexToBgColor, hexToColor, type Modifier, styles } from "./color/styles.ts";
+
+export const chalk = {
+ color(hex: HexValue): (value: string) => string {
+ const color = hexToColor(hex);
+ return (value: string) => `${color}${value}${styles.modifier.reset}`;
+ },
+ bgColor(hex: HexValue): (value: string) => string {
+ const color = hexToBgColor(hex);
+ return (value: string) => `${color}${value}${styles.modifier.reset}`;
+ },
+} as Chalk;
+
+for (const key in styles.modifier) {
+ chalk[key as Modifier] = function (value: string) {
+ return toModifiedValue(key as Modifier, value);
+ };
+}
+
+for (const key in styles.color) {
+ chalk[key as Color] = function (value: string) {
+ return toColorValue(key as Color, value);
+ };
+}
+
+for (const key in styles.bgColor) {
+ chalk[key as BGColor] = function (value: string) {
+ return toBGColorValue(key as BGColor, value);
+ };
+}
+
+function toModifiedValue(key: Modifier, value: string): string {
+ return `${styles.modifier[key]}${value}${styles.modifier.reset}`;
+}
+
+function toColorValue(key: Color, value: string): string {
+ return `${styles.color[key]}${value}${styles.modifier.reset}`;
+}
+
+function toBGColorValue(key: BGColor, value: string): string {
+ return `${styles.bgColor[key]}${value}${styles.modifier.reset}`;
+}
+
+type Chalk = Record string> & {
+ color(hex: HexValue): (value: string) => string;
+ bgColor(hex: HexValue): (value: string) => string;
+};
diff --git a/api/libraries/logger/color/hex.ts b/api/libraries/logger/color/hex.ts
new file mode 100644
index 0000000..8ace2db
--- /dev/null
+++ b/api/libraries/logger/color/hex.ts
@@ -0,0 +1,28 @@
+import { rgbToAnsi256 } from "./rgb.ts";
+
+/**
+ * Convert provided hex value to closest 256-Color value.
+ *
+ * @param hex - Hex to convert.
+ */
+export function hexToAnsi256(hex: HexValue) {
+ const { r, g, b } = hexToRGB(hex);
+ return rgbToAnsi256(r, g, b);
+}
+
+/**
+ * Take a hex value and return its RGB values.
+ *
+ * @param hex - Hex to convert to RGB
+ * @returns
+ */
+export function hexToRGB(hex: HexValue): { r: number; g: number; b: number } {
+ return {
+ r: parseInt(hex.slice(1, 3), 16),
+ g: parseInt(hex.slice(3, 5), 16),
+ b: parseInt(hex.slice(5, 7), 16),
+ };
+}
+
+export type HexValue =
+ `#${string | number}${string | number}${string | number}${string | number}${string | number}${string | number}`;
diff --git a/api/libraries/logger/color/rgb.ts b/api/libraries/logger/color/rgb.ts
new file mode 100644
index 0000000..f28f096
--- /dev/null
+++ b/api/libraries/logger/color/rgb.ts
@@ -0,0 +1,24 @@
+/**
+ * Convert RGB to the nearest 256-color ANSI value
+ *
+ * @param r - Red value.
+ * @param g - Green value.
+ * @param b - Blue value.
+ */
+export function rgbToAnsi256(r: number, g: number, b: number): number {
+ if (r === g && g === b) {
+ if (r < 8) return 16;
+ if (r > 248) return 231;
+ return Math.round(((r - 8) / 247) * 24) + 232;
+ }
+
+ // Map RGB to 6×6×6 color cube (16–231)
+ const conv = (val: number) => Math.round(val / 51);
+ const ri = conv(r);
+ const gi = conv(g);
+ const bi = conv(b);
+
+ return 16 + 36 * ri + 6 * gi + bi;
+}
+
+export type RGB = { r: number; g: number; b: number };
diff --git a/api/libraries/logger/color/styles.ts b/api/libraries/logger/color/styles.ts
new file mode 100644
index 0000000..773f53a
--- /dev/null
+++ b/api/libraries/logger/color/styles.ts
@@ -0,0 +1,76 @@
+import { hexToAnsi256, HexValue } from "./hex.ts";
+import { toEscapeSequence } from "./utilities.ts";
+
+export const styles = {
+ modifier: {
+ reset: toEscapeSequence(0), // Reset to normal
+ bold: toEscapeSequence(1), // Bold text
+ dim: toEscapeSequence(2), // Dim text
+ italic: toEscapeSequence(3), // Italic text
+ underline: toEscapeSequence(4), // Underlined text
+ overline: toEscapeSequence(53), // Overline text
+ inverse: toEscapeSequence(7), // Inverse
+ hidden: toEscapeSequence(8), // Hidden text
+ strikethrough: toEscapeSequence(9), // Strikethrough
+ },
+
+ color: {
+ black: toEscapeSequence(30), // Black color
+ red: toEscapeSequence(31), // Red color
+ green: toEscapeSequence(32), // Green color
+ yellow: toEscapeSequence(33), // Yellow color
+ blue: toEscapeSequence(34), // Blue color
+ magenta: toEscapeSequence(35), // Magenta color
+ cyan: toEscapeSequence(36), // Cyan color
+ white: toEscapeSequence(37), // White color
+ orange: hexToColor("#FFA500"),
+
+ // Bright colors
+ blackBright: toEscapeSequence(90),
+ gray: toEscapeSequence(90), // Alias for blackBright
+ grey: toEscapeSequence(90), // Alias for blackBright
+ redBright: toEscapeSequence(91),
+ greenBright: toEscapeSequence(92),
+ yellowBright: toEscapeSequence(93),
+ blueBright: toEscapeSequence(94),
+ magentaBright: toEscapeSequence(95),
+ cyanBright: toEscapeSequence(96),
+ whiteBright: toEscapeSequence(97),
+ },
+
+ bgColor: {
+ bgBlack: toEscapeSequence(40),
+ bgRed: toEscapeSequence(41),
+ bgGreen: toEscapeSequence(42),
+ bgYellow: toEscapeSequence(43),
+ bgBlue: toEscapeSequence(44),
+ bgMagenta: toEscapeSequence(45),
+ bgCyan: toEscapeSequence(46),
+ bgWhite: toEscapeSequence(47),
+ bgOrange: hexToBgColor("#FFA500"),
+
+ // Bright background colors
+ bgBlackBright: toEscapeSequence(100),
+ bgGray: toEscapeSequence(100), // Alias for bgBlackBright
+ bgGrey: toEscapeSequence(100), // Alias for bgBlackBright
+ bgRedBright: toEscapeSequence(101),
+ bgGreenBright: toEscapeSequence(102),
+ bgYellowBright: toEscapeSequence(103),
+ bgBlueBright: toEscapeSequence(104),
+ bgMagentaBright: toEscapeSequence(105),
+ bgCyanBright: toEscapeSequence(106),
+ bgWhiteBright: toEscapeSequence(107),
+ },
+};
+
+export function hexToColor(hex: HexValue): string {
+ return toEscapeSequence(`38;5;${hexToAnsi256(hex)}`); // Foreground color
+}
+
+export function hexToBgColor(hex: HexValue): string {
+ return toEscapeSequence(`48;5;${hexToAnsi256(hex)}`); // Background color
+}
+
+export type Modifier = keyof typeof styles.modifier;
+export type Color = keyof typeof styles.color;
+export type BGColor = keyof typeof styles.bgColor;
diff --git a/api/libraries/logger/color/utilities.ts b/api/libraries/logger/color/utilities.ts
new file mode 100644
index 0000000..50a9fbd
--- /dev/null
+++ b/api/libraries/logger/color/utilities.ts
@@ -0,0 +1,3 @@
+export function toEscapeSequence(value: string | number): `\x1b[${string}m` {
+ return `\x1b[${value}m`;
+}
diff --git a/api/libraries/logger/config.ts b/api/libraries/logger/config.ts
new file mode 100644
index 0000000..72816f5
--- /dev/null
+++ b/api/libraries/logger/config.ts
@@ -0,0 +1,5 @@
+import { getArgsVariable } from "~libraries/config/mod.ts";
+
+export const config = {
+ level: getArgsVariable("LOG_LEVEL", "info"),
+};
diff --git a/api/libraries/logger/format/event-store.ts b/api/libraries/logger/format/event-store.ts
new file mode 100644
index 0000000..a78b827
--- /dev/null
+++ b/api/libraries/logger/format/event-store.ts
@@ -0,0 +1,19 @@
+import { EventValidationError } from "@valkyr/event-store";
+
+import type { Level } from "../level.ts";
+import { getTracedAt } from "../stack.ts";
+
+export function toEventStoreLog(arg: any, level: Level): any {
+ if (arg instanceof EventValidationError) {
+ const obj: any = {
+ origin: "EventStore",
+ message: arg.message,
+ at: getTracedAt(arg.stack, "/api/domains"),
+ data: arg.errors,
+ };
+ if (level === "debug") {
+ obj.stack = arg.stack;
+ }
+ return obj;
+ }
+}
diff --git a/api/libraries/logger/format/server.ts b/api/libraries/logger/format/server.ts
new file mode 100644
index 0000000..ba8e8ea
--- /dev/null
+++ b/api/libraries/logger/format/server.ts
@@ -0,0 +1,18 @@
+import { ServerError } from "@spec/relay";
+
+import type { Level } from "../level.ts";
+import { getTracedAt } from "../stack.ts";
+
+export function toServerLog(arg: any, level: Level): any {
+ if (arg instanceof ServerError) {
+ const obj: any = {
+ message: arg.message,
+ data: arg.data,
+ at: getTracedAt(arg.stack, "/api/domains"),
+ };
+ if (level === "debug") {
+ obj.stack = arg.stack;
+ }
+ return obj;
+ }
+}
diff --git a/api/libraries/logger/level.ts b/api/libraries/logger/level.ts
new file mode 100644
index 0000000..095af5b
--- /dev/null
+++ b/api/libraries/logger/level.ts
@@ -0,0 +1,8 @@
+export const logLevel = {
+ debug: 0,
+ info: 1,
+ warning: 2,
+ error: 3,
+};
+
+export type Level = "debug" | "error" | "warning" | "info";
diff --git a/api/libraries/logger/logger.ts b/api/libraries/logger/logger.ts
new file mode 100644
index 0000000..0a8f115
--- /dev/null
+++ b/api/libraries/logger/logger.ts
@@ -0,0 +1,95 @@
+import { chalk } from "./chalk.ts";
+import { type Level, logLevel } from "./level.ts";
+
+export class Logger {
+ #level: Level = "info";
+ #config: Config;
+
+ constructor(config: Config) {
+ this.#config = config;
+ }
+
+ get #prefix(): [string?] {
+ if (this.#config.prefix !== undefined) {
+ return [chalk.bold(chalk.green(this.#config.prefix))];
+ }
+ return [];
+ }
+
+ /**
+ * Set the highest logging level in the order of debug, info, warn, error.
+ *
+ * When value is 'info', info, warn and error will be logged and debug
+ * will be ignored.
+ *
+ * @param value Highest log level.
+ */
+ level(value: Level): this {
+ this.#level = value;
+ return this;
+ }
+
+ /**
+ * Returns a new logger instance with the given name as prefix.
+ *
+ * @param name - Prefix name.
+ */
+ prefix(name: string): Logger {
+ return new Logger({ prefix: name, loggers: this.#config.loggers }).level(this.#level);
+ }
+
+ /**
+ * Emit a debug message to terminal.
+ */
+ debug(...args: any[]) {
+ if (this.#isLevelEnabled(0)) {
+ console.log(new Date(), chalk.bold("Debug"), ...this.#prefix, ...args.map(this.#toFormattedArg));
+ }
+ }
+
+ /**
+ * Emit a info message to terminal.
+ */
+ info(...args: any[]) {
+ if (this.#isLevelEnabled(1)) {
+ console.log(new Date(), chalk.bold(chalk.blue("Info")), ...this.#prefix, ...args.map(this.#toFormattedArg));
+ }
+ }
+
+ /**
+ * Emit a warning message to terminal.
+ */
+ warn(...args: any[]) {
+ if (this.#isLevelEnabled(2)) {
+ console.log(new Date(), chalk.bold(chalk.orange("Warning")), ...this.#prefix, ...args.map(this.#toFormattedArg));
+ }
+ }
+
+ /**
+ * Emit a errpr message to terminal.
+ */
+ error(...args: any[]) {
+ if (this.#isLevelEnabled(3)) {
+ console.log(new Date(), chalk.bold(chalk.red("Error")), ...this.#prefix, ...args.map(this.#toFormattedArg));
+ }
+ }
+
+ #isLevelEnabled(level: 0 | 1 | 2 | 3): boolean {
+ return level >= logLevel[this.#level];
+ }
+
+ #toFormattedArg = (arg: any): string => {
+ for (const logger of this.#config.loggers) {
+ const res = logger(arg, this.#level);
+ if (res !== undefined) {
+ return res;
+ }
+ }
+ return arg;
+ };
+}
+
+type Config = {
+ prefix?: string;
+ loggers: ((arg: any, level: Level) => any)[];
+};
diff --git a/api/libraries/logger/mod.ts b/api/libraries/logger/mod.ts
new file mode 100644
index 0000000..9fd6623
--- /dev/null
+++ b/api/libraries/logger/mod.ts
@@ -0,0 +1,7 @@
+import { toEventStoreLog } from "./format/event-store.ts";
+import { toServerLog } from "./format/server.ts";
+import { Logger } from "./logger.ts";
+
+export const logger = new Logger({
+ loggers: [toServerLog, toEventStoreLog],
+});
diff --git a/api/libraries/logger/stack.ts b/api/libraries/logger/stack.ts
new file mode 100644
index 0000000..f6d4501
--- /dev/null
+++ b/api/libraries/logger/stack.ts
@@ -0,0 +1,20 @@
+/**
+ * Fetch the most closest relevant error from the local code base so it can
+ * be more easily traced to its source.
+ *
+ * @param stack - Error stack.
+ * @param search - Relevant stack line search value.
+ */
+export function getTracedAt(stack: string | undefined, search: string): string | undefined {
+ if (stack === undefined) {
+ return undefined;
+ }
+ const firstMatch = stack.split("\n").find((line) => line.includes(search));
+ if (firstMatch === undefined) {
+ return undefined;
+ }
+ return firstMatch
+ .replace(/^.*?(file:\/\/\/)/, "$1")
+ .replace(/\)$/, "")
+ .trim();
+}
diff --git a/api/libraries/read-store/.tasks/bootstrap.ts b/api/libraries/read-store/.tasks/bootstrap.ts
new file mode 100644
index 0000000..32ddac9
--- /dev/null
+++ b/api/libraries/read-store/.tasks/bootstrap.ts
@@ -0,0 +1,11 @@
+import { idIndex } from "~libraries/database/id.ts";
+import { register } from "~libraries/database/registrar.ts";
+
+import { db } from "../database.ts";
+
+await register(db.db, [
+ {
+ name: "accounts",
+ indexes: [idIndex],
+ },
+]);
diff --git a/api/libraries/read-store/account/methods.ts b/api/libraries/read-store/account/methods.ts
new file mode 100644
index 0000000..510393b
--- /dev/null
+++ b/api/libraries/read-store/account/methods.ts
@@ -0,0 +1,6 @@
+import { db, takeOne } from "../database.ts";
+import { type AccountSchema, fromAccountDriver } from "./schema.ts";
+
+export async function getAccountById(id: string): Promise {
+ return db.collection("accounts").find({ id }).toArray().then(fromAccountDriver).then(takeOne);
+}
diff --git a/api/libraries/read-store/account/schema.ts b/api/libraries/read-store/account/schema.ts
new file mode 100644
index 0000000..7e74c30
--- /dev/null
+++ b/api/libraries/read-store/account/schema.ts
@@ -0,0 +1,36 @@
+import { z } from "zod";
+
+const account = z.object({
+ id: z.uuid(),
+ name: z.object({
+ given: z.string(),
+ family: z.string(),
+ }),
+ email: z.email(),
+});
+
+/*
+ |--------------------------------------------------------------------------------
+ | Parsers
+ |--------------------------------------------------------------------------------
+ */
+
+const select = account;
+const insert = account;
+
+export function toAccountDriver(documents: unknown): AccountInsert {
+ return insert.parse(documents);
+}
+
+export function fromAccountDriver(documents: unknown[]): AccountSchema[] {
+ return documents.map((document) => select.parse(document));
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Types
+ |--------------------------------------------------------------------------------
+ */
+
+export type AccountSchema = z.infer;
+export type AccountInsert = z.infer;
diff --git a/api/libraries/read-store/database.ts b/api/libraries/read-store/database.ts
new file mode 100644
index 0000000..f2d0452
--- /dev/null
+++ b/api/libraries/read-store/database.ts
@@ -0,0 +1,12 @@
+import { config } from "~config";
+import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
+
+import { AccountInsert } from "./account/schema.ts";
+
+export const db = getDatabaseAccessor<{
+ accounts: AccountInsert;
+}>(`${config.name}:read-store`);
+
+export function takeOne(documents: TDocument[]): TDocument | undefined {
+ return documents[0];
+}
diff --git a/api/libraries/read-store/mod.ts b/api/libraries/read-store/mod.ts
new file mode 100644
index 0000000..739683a
--- /dev/null
+++ b/api/libraries/read-store/mod.ts
@@ -0,0 +1,3 @@
+export * from "./account/methods.ts";
+export * from "./account/schema.ts";
+export * from "./database.ts";
diff --git a/api/libraries/server/api.ts b/api/libraries/server/api.ts
new file mode 100644
index 0000000..0c36bfe
--- /dev/null
+++ b/api/libraries/server/api.ts
@@ -0,0 +1,415 @@
+import {
+ BadRequestError,
+ ForbiddenError,
+ InternalServerError,
+ NotFoundError,
+ NotImplementedError,
+ Route,
+ RouteMethod,
+ ServerError,
+ type ServerErrorResponse,
+ UnauthorizedError,
+ ZodValidationError,
+} from "@spec/relay";
+import { treeifyError } from "zod";
+
+import { logger } from "~libraries/logger/mod.ts";
+
+import { getRequestContext } from "./context.ts";
+import { req } from "./request.ts";
+
+const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
+
+export class Api {
+ readonly #index = new Map();
+
+ /**
+ * Route maps funneling registered routes to the specific methods supported by
+ * the relay instance.
+ */
+ readonly routes: Routes = {
+ POST: [],
+ GET: [],
+ PUT: [],
+ PATCH: [],
+ DELETE: [],
+ };
+
+ /**
+ * List of paths in the '${method} ${path}' format allowing us to quickly throw
+ * errors if a duplicate route path is being added.
+ */
+ readonly #paths = new Set();
+
+ /**
+ * Instantiate a new Api instance.
+ *
+ * @param routes - Initial list of routes to register with the api.
+ */
+ constructor(routes: Route[] = []) {
+ this.register(routes);
+ }
+
+ /**
+ * Register relays with the API instance allowing for decoupled registration
+ * of server side handling of relay contracts.
+ *
+ * @param routes - Relays to register with the instance.
+ */
+ register(routes: Route[]): this {
+ const methods: (keyof typeof this.routes)[] = [];
+ for (const route of routes) {
+ const path = `${route.method} ${route.path}`;
+ if (this.#paths.has(path)) {
+ throw new Error(`Router > Path ${path} already exists`);
+ }
+ this.#paths.add(path);
+ this.routes[route.method].push(route);
+ methods.push(route.method);
+ this.#index.set(`${route.method} ${route.path}`, route);
+ }
+ for (const method of methods) {
+ this.routes[method].sort(byStaticPriority);
+ }
+ return this;
+ }
+
+ /**
+ * Executes request and returns a `Response` instance.
+ *
+ * @param request - REST request to pass to a route handler.
+ */
+ async fetch(request: Request): Promise {
+ const url = new URL(request.url);
+
+ // ### Route
+ // Locate a route matching the incoming request method and path.
+
+ const resolved = this.#getResolvedRoute(request.method, url.pathname);
+ if (resolved === undefined) {
+ return toResponse(
+ new NotFoundError(`Invalid routing path provided for ${request.url}`, {
+ method: request.method,
+ url: request.url,
+ }),
+ request,
+ );
+ }
+
+ // ### Handle
+ // Execute request and return a response.
+
+ const response = await this.#getRouteResponse(resolved, request).catch((error) =>
+ this.#getErrorResponse(error, resolved.route, request),
+ );
+
+ return response;
+ }
+
+ /**
+ * Attempt to resolve a route based on the given method and pathname.
+ *
+ * @param method - HTTP method.
+ * @param url - HTTP request url.
+ */
+ #getResolvedRoute(method: string, url: string): ResolvedRoute | undefined {
+ assertMethod(method);
+ for (const route of this.routes[method]) {
+ if (route.match(url) === true) {
+ return { route, params: route.getParsedParams(url) };
+ }
+ }
+ }
+
+ /**
+ * Resolve the request on the given route and return a `Response` instance.
+ *
+ * @param resolved - Route and paramter details resolved for the request.
+ * @param request - Request instance to resolve.
+ */
+ async #getRouteResponse({ route, params }: ResolvedRoute, request: Request): Promise {
+ const url = new URL(request.url);
+
+ // ### Args
+ // Arguments is passed to every route handler and provides a suite of functionality
+ // and request data.
+
+ const args: any[] = [];
+
+ // ### Input
+ // Generate route input which contains a map fo params, query, and/or body. If
+ // none of these are present then the input is not added to the final argument
+ // context of the handler.
+
+ const input: {
+ params?: object;
+ query?: object;
+ body?: unknown;
+ } = {
+ params: undefined,
+ query: undefined,
+ body: undefined,
+ };
+
+ // ### Access
+ // Check the access requirements of the route and run any additional checks
+ // if nessesary before proceeding with further request handling.
+ // 1. All routes needs access assignment, else we consider it an internal error.
+ // 2. If access requires a session we throw Unauthorized if the request is not authenticated.
+ // 3. If access is an array of access resources, we check that each resources can be
+ // accessed by the request.
+
+ if (route.state.access === undefined) {
+ return toResponse(
+ new InternalServerError(`Route '${route.method} ${route.path}' is missing access assignment.`),
+ request,
+ );
+ }
+
+ if (route.state.access === "session" && req.isAuthenticated === false) {
+ return toResponse(new UnauthorizedError(), request);
+ }
+
+ if (Array.isArray(route.state.access)) {
+ for (const hasAccess of route.state.access) {
+ if (hasAccess() === false) {
+ return toResponse(new ForbiddenError(), request);
+ }
+ }
+ }
+
+ // ### Params
+ // If the route has params we want to coerce the values to the expected types.
+
+ if (route.state.params !== undefined) {
+ const result = await route.state.params.safeParseAsync(params);
+ if (result.success === false) {
+ return toResponse(new ZodValidationError("Invalid request params", treeifyError(result.error)), request);
+ }
+ input.params = result.data;
+ }
+
+ // ### Query
+ // If the route has a query schema we need to validate and parse the query.
+
+ if (route.state.query !== undefined) {
+ const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {});
+ if (result.success === false) {
+ return toResponse(new ZodValidationError("Invalid request query", treeifyError(result.error)), request);
+ }
+ input.query = result.data;
+ }
+
+ // ### Body
+ // If the route has a body schema we need to validate and parse the body.
+
+ if (route.state.body !== undefined) {
+ const body = await this.#getRequestBody(request);
+ const result = await route.state.body.safeParseAsync(body);
+ if (result.success === false) {
+ return toResponse(new ZodValidationError("Invalid request body", treeifyError(result.error)), request);
+ }
+ input.body = result.data;
+ }
+
+ if (input.params !== undefined || input.query !== undefined || input.body !== undefined) {
+ args.push(input);
+ }
+
+ // ### Context
+ // Request context pass to every route as the last argument.
+
+ args.push(getRequestContext(request));
+
+ // ### Handler
+ // Execute the route handler and apply the result.
+
+ if (route.state.handle === undefined) {
+ return toResponse(new NotImplementedError(`Path '${route.method} ${route.path}' is not implemented.`), request);
+ }
+
+ return toResponse(await route.state.handle(...args), request);
+ }
+
+ #getErrorResponse(error: unknown, route: Route, request: Request): Response {
+ if (route?.state.hooks?.onError !== undefined) {
+ return route.state.hooks.onError(error);
+ }
+ if (error instanceof ServerError) {
+ return toResponse(error, request);
+ }
+ logger.error(error);
+ if (error instanceof Error) {
+ return toResponse(new InternalServerError(error.message), request);
+ }
+ return toResponse(new InternalServerError(), request);
+ }
+
+ /**
+ * Resolves request body and returns it.
+ *
+ * @param request - Request to resolve body from.
+ * @param files - Files to populate if present.
+ */
+ async #getRequestBody(request: Request): Promise> {
+ let body: Record = {};
+
+ const type = request.headers.get("content-type");
+ if (!type || request.method === "GET") {
+ return body;
+ }
+
+ if (type.includes("json")) {
+ body = await request.json();
+ }
+
+ if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) {
+ try {
+ const formData = await request.formData();
+ for (const [name, value] of Array.from(formData.entries())) {
+ body[name] = value;
+ }
+ } catch (error) {
+ logger.error(error);
+ throw new BadRequestError(`Malformed FormData`, { error });
+ }
+ }
+
+ return body;
+ }
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Helpers
+ |--------------------------------------------------------------------------------
+ */
+
+/**
+ * Assert that the given method string is a valid routing method.
+ *
+ * @param candidate - Method candidate.
+ */
+function assertMethod(candidate: string): asserts candidate is RouteMethod {
+ if (!SUPPORTED_MEHODS.includes(candidate)) {
+ throw new Error(`Router > Unsupported method '${candidate}'`);
+ }
+}
+
+/**
+ * Sorting method for routes to ensure that static properties takes precedence
+ * for when a route is matched against incoming requests.
+ *
+ * @param a - Route A
+ * @param b - Route B
+ */
+function byStaticPriority(a: Route, b: Route) {
+ const aSegments = a.path.split("/");
+ const bSegments = b.path.split("/");
+
+ const maxLength = Math.max(aSegments.length, bSegments.length);
+
+ for (let i = 0; i < maxLength; i++) {
+ const aSegment = aSegments[i] || "";
+ const bSegment = bSegments[i] || "";
+
+ const isADynamic = aSegment.startsWith(":");
+ const isBDynamic = bSegment.startsWith(":");
+
+ if (isADynamic !== isBDynamic) {
+ return isADynamic ? 1 : -1;
+ }
+
+ if (isADynamic === false && aSegment !== bSegment) {
+ return aSegment.localeCompare(bSegment);
+ }
+ }
+
+ return a.path.localeCompare(b.path);
+}
+
+/**
+ * Resolve and return query object from the provided search parameters, or undefined
+ * if the search parameters does not have any entries.
+ *
+ * @param searchParams - Search params to create a query object from.
+ */
+function toQuery(searchParams: URLSearchParams): object | undefined {
+ if (searchParams.size === 0) {
+ return undefined;
+ }
+ const result: Record = {};
+ for (const [key, value] of searchParams.entries()) {
+ result[key] = value;
+ }
+ return result;
+}
+
+/**
+ * Takes a server side request result and returns a fetch Response.
+ *
+ * @param result - Result to send back as a Response.
+ * @param request - Request instance.
+ */
+export function toResponse(result: unknown, request: Request): Response {
+ const method = request.method;
+
+ if (result instanceof Response) {
+ if (method === "HEAD") {
+ return new Response(null, {
+ status: result.status,
+ statusText: result.statusText,
+ headers: new Headers(result.headers),
+ });
+ }
+ return result;
+ }
+
+ if (result instanceof ServerError) {
+ const body = JSON.stringify({
+ error: {
+ status: result.status,
+ message: result.message,
+ data: result.data,
+ },
+ } satisfies ServerErrorResponse);
+
+ return new Response(method === "HEAD" ? null : body, {
+ statusText: result.message || "Internal Server Error",
+ status: result.status || 500,
+ headers: {
+ "content-type": "application/json",
+ },
+ });
+ }
+
+ const body = JSON.stringify({
+ data: result ?? null,
+ });
+
+ return new Response(method === "HEAD" ? null : body, {
+ status: 200,
+ headers: {
+ "content-type": "application/json",
+ },
+ });
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Types
+ |--------------------------------------------------------------------------------
+ */
+
+type Routes = {
+ POST: Route[];
+ GET: Route[];
+ PUT: Route[];
+ PATCH: Route[];
+ DELETE: Route[];
+};
+
+type ResolvedRoute = {
+ route: Route;
+ params: any;
+};
diff --git a/api/libraries/server/context.ts b/api/libraries/server/context.ts
new file mode 100644
index 0000000..d77d389
--- /dev/null
+++ b/api/libraries/server/context.ts
@@ -0,0 +1,16 @@
+import { RouteContext } from "@spec/relay";
+
+export function getRequestContext(request: Request): RouteContext {
+ return {
+ request,
+ };
+}
+
+declare module "@spec/relay" {
+ interface RouteContext {
+ /**
+ * Current request instance being handled.
+ */
+ request: Request;
+ }
+}
diff --git a/api/libraries/server/mod.ts b/api/libraries/server/mod.ts
new file mode 100644
index 0000000..316ad24
--- /dev/null
+++ b/api/libraries/server/mod.ts
@@ -0,0 +1,5 @@
+export * from "./api.ts";
+export * from "./context.ts";
+export * from "./modules.ts";
+export * from "./request.ts";
+export * from "./storage.ts";
diff --git a/api/libraries/server/modules.ts b/api/libraries/server/modules.ts
new file mode 100644
index 0000000..bc3752e
--- /dev/null
+++ b/api/libraries/server/modules.ts
@@ -0,0 +1,40 @@
+import { Route } from "@spec/relay";
+
+/**
+ * Resolve and return all routes that has been created under any 'routes'
+ * folders that can be found under the given path.
+ *
+ * If the filter is empty, all paths are resolved, otherwise only paths
+ * declared in the array is resolved.
+ *
+ * @param path - Path to resolve routes from.
+ * @param filter - List of modules to include.
+ * @param routes - List of routes that has been resolved.
+ */
+export async function resolveRoutes(path: string, routes: Route[] = []): Promise {
+ for await (const entry of Deno.readDir(path)) {
+ if (entry.isDirectory === true) {
+ await loadRoutes(`${path}/${entry.name}/routes`, routes, [name]);
+ }
+ }
+ return routes;
+}
+
+async function loadRoutes(path: string, routes: Route[], modules: string[]): Promise {
+ for await (const entry of Deno.readDir(path)) {
+ if (entry.isDirectory === true) {
+ await loadRoutes(`${path}/${entry.name}`, routes, [...modules, entry.name]);
+ } else {
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith("i9n.ts")) {
+ continue;
+ }
+ const { default: route } = (await import(`${path}/${entry.name}`)) as { default: Route };
+ if (route instanceof Route === false) {
+ throw new Error(
+ `Router Violation: Could not load '${path}/${entry.name}' as it does not export a default Route instance.`,
+ );
+ }
+ routes.push(route);
+ }
+ }
+}
diff --git a/api/libraries/server/request.ts b/api/libraries/server/request.ts
new file mode 100644
index 0000000..c6d4678
--- /dev/null
+++ b/api/libraries/server/request.ts
@@ -0,0 +1,57 @@
+import { asyncLocalStorage } from "./storage.ts";
+
+export const req = {
+ get store() {
+ const store = asyncLocalStorage.getStore();
+ if (store === undefined) {
+ throw new Error("Request > AsyncLocalStorage not defined.");
+ }
+ return store;
+ },
+
+ get socket() {
+ return this.store.socket;
+ },
+
+ /**
+ * Get store that is potentially undefined.
+ * Typically used when utility functions might run in and out of request scope.
+ */
+ get unsafeStore() {
+ return asyncLocalStorage.getStore();
+ },
+
+ /**
+ * Check if the request is authenticated.
+ */
+ get isAuthenticated() {
+ return this.session !== undefined;
+ },
+
+ /**
+ * Get current session.
+ */
+ get session() {
+ return this.store.session;
+ },
+
+ /**
+ * Gets the meta information stored in the request.
+ */
+ get info() {
+ return this.store.info;
+ },
+
+ /**
+ * Sends a JSON-RPC 2.0 notification to the request if sent through a
+ * WebSocket connection.
+ *
+ * @param method - Method to send notification to.
+ * @param params - Params to pass to the method.
+ */
+ notify(method: string, params: any): void {
+ this.socket?.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
+ },
+} as const;
+
+export type ReqContext = typeof req;
diff --git a/api/libraries/server/storage.ts b/api/libraries/server/storage.ts
new file mode 100644
index 0000000..3cdf826
--- /dev/null
+++ b/api/libraries/server/storage.ts
@@ -0,0 +1,16 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+
+import type { Session } from "~libraries/auth/mod.ts";
+
+export const asyncLocalStorage = new AsyncLocalStorage<{
+ session?: Session;
+ info: {
+ method: string;
+ start: number;
+ end?: number;
+ };
+ socket?: WebSocket;
+ response: {
+ headers: Headers;
+ };
+}>();
diff --git a/api/libraries/socket/channels.ts b/api/libraries/socket/channels.ts
new file mode 100644
index 0000000..6653dd8
--- /dev/null
+++ b/api/libraries/socket/channels.ts
@@ -0,0 +1,81 @@
+import type { Params } from "@valkyr/json-rpc";
+
+import { Sockets } from "./sockets.ts";
+
+export class Channels {
+ readonly #channels = new Map();
+
+ /**
+ * Add a new channel.
+ *
+ * @param channel
+ */
+ add(channel: string): this {
+ this.#channels.set(channel, new Sockets());
+ return this;
+ }
+
+ /**
+ * Deletes a channel.
+ *
+ * @param channel
+ */
+ del(channel: string): this {
+ this.#channels.delete(channel);
+ return this;
+ }
+
+ /**
+ * Add socket to the given channel. If the channel does not exist it is
+ * automatically created.
+ *
+ * @param channel - Channel to add socket to.
+ * @param socket - Socket to add to the channel.
+ */
+ join(channel: string, socket: WebSocket): this {
+ const sockets = this.#channels.get(channel);
+ if (sockets === undefined) {
+ this.#channels.set(channel, new Sockets().add(socket));
+ } else {
+ sockets.add(socket);
+ }
+ return this;
+ }
+
+ /**
+ * Remove a socket from the given channel.
+ *
+ * @param channel - Channel to leave.
+ * @param socket - Socket to remove from the channel.
+ */
+ leave(channel: string, socket: WebSocket): this {
+ this.#channels.get(channel)?.del(socket);
+ return this;
+ }
+
+ /**
+ * Sends a JSON-RPC notification to all sockets in given channel.
+ *
+ * @param channel - Channel to emit method to.
+ * @param method - Method to send the notification to.
+ * @param params - Message data to send to the clients.
+ */
+ notify(channel: string, method: string, params: Params): this {
+ this.#channels.get(channel)?.notify(method, params);
+ return this;
+ }
+
+ /**
+ * Transmits data to all registered WebSocket connections in the given channel.
+ * Data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView.
+ *
+ * @param channel - Channel to emit message to.
+ * @param data - Data to send to each connected socket in the channel.
+ */
+ send(channel: string, data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
+ this.#channels.get(channel)?.send(data);
+ return this;
+ }
+}
+
+export const channels = new Channels();
diff --git a/api/libraries/socket/mod.ts b/api/libraries/socket/mod.ts
new file mode 100644
index 0000000..8496e56
--- /dev/null
+++ b/api/libraries/socket/mod.ts
@@ -0,0 +1 @@
+export { Sockets } from "./sockets.ts";
diff --git a/api/libraries/socket/sockets.ts b/api/libraries/socket/sockets.ts
new file mode 100644
index 0000000..fc981e9
--- /dev/null
+++ b/api/libraries/socket/sockets.ts
@@ -0,0 +1,49 @@
+import type { Params } from "@valkyr/json-rpc";
+
+export class Sockets {
+ readonly #sockets = new Set();
+
+ /**
+ * Add a socket to the pool.
+ *
+ * @param socket - WebSocket to add.
+ */
+ add(socket: WebSocket): this {
+ this.#sockets.add(socket);
+ return this;
+ }
+
+ /**
+ * Remove a socket from the pool.
+ *
+ * @param socket - WebSocket to remove.
+ */
+ del(socket: WebSocket): this {
+ this.#sockets.delete(socket);
+ return this;
+ }
+
+ /**
+ * Sends a JSON-RPC notification to all connected sockets.
+ *
+ * @param method - Method to send the notification to.
+ * @param params - Message data to send to the clients.
+ */
+ notify(method: string, params: Params): this {
+ this.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
+ return this;
+ }
+
+ /**
+ * Transmits data to all registered WebSocket connections. Data can be a string,
+ * a Blob, an ArrayBuffer, or an ArrayBufferView.
+ *
+ * @param data - Data to send to each connected socket.
+ */
+ send(data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
+ this.#sockets.forEach((socket) => socket.send(data));
+ return this;
+ }
+}
+
+export const sockets = new Sockets();
diff --git a/api/libraries/socket/upgrade.ts b/api/libraries/socket/upgrade.ts
new file mode 100644
index 0000000..3bc7962
--- /dev/null
+++ b/api/libraries/socket/upgrade.ts
@@ -0,0 +1,60 @@
+import { toJsonRpc } from "@valkyr/json-rpc";
+
+import { Session } from "~libraries/auth/mod.ts";
+import { logger } from "~libraries/logger/mod.ts";
+
+import { sockets } from "./sockets.ts";
+
+export function upgrade(request: Request, session?: Session) {
+ const { socket, response } = Deno.upgradeWebSocket(request);
+
+ socket.addEventListener("open", () => {
+ logger.prefix("Socket").info("socket connected", { session });
+ sockets.add(socket);
+ });
+
+ socket.addEventListener("close", () => {
+ logger.prefix("Socket").info("socket disconnected", { session });
+ sockets.del(socket);
+ });
+
+ socket.addEventListener("message", (event) => {
+ if (event.data === "ping") {
+ return;
+ }
+
+ const body = toJsonRpc(event.data);
+
+ logger.prefix("Socket").info(body);
+
+ asyncLocalStorage.run(
+ {
+ session,
+ info: {
+ method: body.method!,
+ start: Date.now(),
+ },
+ socket,
+ response: {
+ headers: new Headers(),
+ },
+ },
+ async () => {
+ api
+ .handleCommand(body)
+ .then((response) => {
+ if (response !== undefined) {
+ logger.info({ response });
+ socket.send(JSON.stringify(response));
+ }
+ })
+ .catch((error) => {
+ logger.info({ error });
+ socket.send(JSON.stringify(error));
+ });
+ },
+ );
+ });
+
+ return response;
+}
diff --git a/api/libraries/testing/config.ts b/api/libraries/testing/config.ts
new file mode 100644
index 0000000..ec1583b
--- /dev/null
+++ b/api/libraries/testing/config.ts
@@ -0,0 +1,4 @@
+export const config = {
+ mongodb: "mongo:8.0.3",
+ postgres: "postgres:17",
+};
diff --git a/api/libraries/testing/containers/api-container.ts b/api/libraries/testing/containers/api-container.ts
new file mode 100644
index 0000000..b82eff7
--- /dev/null
+++ b/api/libraries/testing/containers/api-container.ts
@@ -0,0 +1,154 @@
+import { getAvailablePort } from "@std/net";
+import cookie from "cookie";
+
+import { auth, Session } from "~libraries/auth/mod.ts";
+import { Code } from "~libraries/code/aggregates/code.ts";
+import { handler } from "~libraries/server/handler.ts";
+
+import { Api, QueryMethod } from "../.generated/api.ts";
+
+export class ApiTestContainer {
+ #server?: Deno.HttpServer;
+ #client?: Api;
+ #cookie?: string;
+ #session?: Session;
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Accessors
+ |--------------------------------------------------------------------------------
+ */
+
+ get accountId(): string | undefined {
+ if (this.#session?.valid === true) {
+ return this.#session.accountId;
+ }
+ }
+
+ get client() {
+ if (this.#client === undefined) {
+ throw new Error("ApiContainer > .start() has not been executed.");
+ }
+ return this.#client;
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Lifecycle
+ |--------------------------------------------------------------------------------
+ */
+
+ async start(): Promise {
+ const port = await getAvailablePort();
+ this.#server = await Deno.serve({ port, hostname: "127.0.0.1" }, handler);
+ this.#client = makeApiClient(port, {
+ onBeforeRequest: (headers: Headers) => {
+ if (this.#cookie !== undefined) {
+ headers.set("cookie", this.#cookie);
+ }
+ },
+ onAfterResponse: (response) => {
+ const cookie = response.headers.get("set-cookie");
+ if (cookie !== null) {
+ this.#cookie = cookie;
+ }
+ },
+ });
+ return this;
+ }
+
+ async stop() {
+ await this.#server?.shutdown();
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Utilities
+ |--------------------------------------------------------------------------------
+ */
+
+ async authorize(accountId: string): Promise {
+ const code = await Code.create({ identity: { type: "admin", accountId } }).save();
+ await this.client.auth.code(accountId, code.id, code.value, {});
+ this.#session = await this.getSession();
+ }
+
+ async getSession(): Promise {
+ const token = cookie.parse(this.#cookie ?? "").token;
+ if (token !== undefined) {
+ const session = await auth.resolve(token);
+ if (session.valid === true) {
+ return session;
+ }
+ }
+ }
+
+ unauthorize(): void {
+ this.#cookie = undefined;
+ }
+}
+
+function makeApiClient(
+ port: number,
+ {
+ onBeforeRequest,
+ onAfterResponse,
+ }: {
+ onBeforeRequest: (headers: Headers) => void;
+ onAfterResponse: (response: Response) => void;
+ },
+): Api {
+ return new Api({
+ async command(payload) {
+ const headers = new Headers();
+ onBeforeRequest(headers);
+ headers.set("content-type", "application/json");
+ const response = await fetch(`http://127.0.0.1:${port}/api/v1/command`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(payload),
+ });
+ const text = await response.text();
+ if (response.status >= 300) {
+ console.error(
+ `Command '${payload.method}' responded with error status '${response.status} ${response.statusText}'.`,
+ );
+ }
+ if (response.headers.get("content-type")?.includes("json") === true) {
+ return JSON.parse(text);
+ }
+ },
+ async query(method: QueryMethod, path: string, query: Record, body: any = {}) {
+ const headers = new Headers();
+ onBeforeRequest(headers);
+ if (method !== "GET") {
+ headers.set("content-type", "application/json");
+ }
+ const response = await fetch(`http://127.0.0.1:${port}${path}${getSearchQuery(query)}`, {
+ method,
+ headers,
+ body: method === "GET" ? undefined : JSON.stringify(body),
+ });
+ onAfterResponse(response);
+ const text = await response.text();
+ if (response.status >= 300) {
+ console.error(`Query '${path}' responded with error status '${response.status} ${response.statusText}'.`);
+ throw new Error(response.statusText);
+ }
+ if (response.headers.get("content-type")?.includes("json") === true) {
+ return JSON.parse(text);
+ }
+ },
+ });
+}
+
+function getSearchQuery(query: Record): string {
+ const search: string[] = [];
+ for (const key in query) {
+ search.push(`${key}=${query[key]}`);
+ }
+ if (search.length === 0) {
+ return "";
+ }
+ return `?${search.join("&")}`;
+}
diff --git a/api/libraries/testing/containers/database-container.ts b/api/libraries/testing/containers/database-container.ts
new file mode 100644
index 0000000..da20da5
--- /dev/null
+++ b/api/libraries/testing/containers/database-container.ts
@@ -0,0 +1,41 @@
+import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
+
+import { container } from "~database/container.ts";
+import { logger } from "~libraries/logger/mod.ts";
+import { bootstrap } from "~libraries/utilities/bootstrap.ts";
+import { API_DOMAINS_DIR, API_PACKAGES_DIR } from "~paths";
+
+export class DatabaseTestContainer {
+ constructor(readonly mongo: MongoTestContainer) {
+ container.set("client", mongo.client);
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Lifecycle
+ |--------------------------------------------------------------------------------
+ */
+
+ async start(): Promise {
+ logger.prefix("Database").info("DatabaseTestContainer Started");
+
+ await bootstrap(API_DOMAINS_DIR);
+ await bootstrap(API_PACKAGES_DIR);
+
+ return this;
+ }
+
+ async truncate() {
+ const promises: Promise[] = [];
+ for (const dbName of ["balto:auth", "balto:code", "balto:consultant", "balto:task"]) {
+ const db = this.mongo.client.db(dbName);
+ const collections = await db.listCollections().toArray();
+ promises.push(...collections.map(({ name }) => db.collection(name).deleteMany({})));
+ }
+ await Promise.all(promises);
+ }
+
+ async stop() {
+ logger.prefix("Database").info("DatabaseTestContainer stopped");
+ }
+}
diff --git a/api/libraries/testing/containers/test-container.ts b/api/libraries/testing/containers/test-container.ts
new file mode 100644
index 0000000..eb57dd1
--- /dev/null
+++ b/api/libraries/testing/containers/test-container.ts
@@ -0,0 +1,178 @@
+import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
+
+import { config } from "../config.ts";
+import { ApiTestContainer } from "./api-container.ts";
+import { DatabaseTestContainer } from "./database-container.ts";
+
+export class TestContainer {
+ readonly id = crypto.randomUUID();
+
+ // ### Enablers
+ // A map of services to enable when the TestContainer is started. These toggles
+ // must be toggled before the container is started.
+
+ #with: With = {
+ mongodb: false,
+ database: false,
+ api: false,
+ };
+
+ // ### Needs
+
+ #needs: Needs = {
+ mongodb: [],
+ database: ["mongodb"],
+ api: ["mongodb", "database"],
+ };
+
+ // ### Services
+ // Any services that has been enabled will be running under the following
+ // assignments. Make sure to .stop any running services to avoid shutdown
+ // leaks.
+
+ #mongodb?: MongoTestContainer;
+ #database?: DatabaseTestContainer;
+ #api?: ApiTestContainer;
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Accessors
+ |--------------------------------------------------------------------------------
+ */
+
+ get accountId() {
+ if (this.#api === undefined) {
+ throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
+ }
+ return this.#api.accountId;
+ }
+
+ get mongodb(): MongoTestContainer {
+ if (this.#mongodb === undefined) {
+ throw new Error("TestContainer > .withMongo() must be called before starting the TestContainer.");
+ }
+ return this.#mongodb;
+ }
+
+ get database(): DatabaseTestContainer {
+ if (this.#database === undefined) {
+ throw new Error("TestContainer > .withDatabase() must be called before starting the TestContainer.");
+ }
+ return this.#database;
+ }
+
+ get api() {
+ if (this.#api === undefined) {
+ throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
+ }
+ return this.#api.client;
+ }
+
+ get authorize() {
+ if (this.#api === undefined) {
+ throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
+ }
+ return this.#api.authorize.bind(this.#api);
+ }
+
+ get unauthorize() {
+ if (this.#api === undefined) {
+ throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
+ }
+ return this.#api.unauthorize.bind(this.#api);
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Builder
+ |--------------------------------------------------------------------------------
+ */
+
+ withMongo(): this {
+ this.#with.mongodb = true;
+ return this;
+ }
+
+ withDatabase(): this {
+ this.#with.database = true;
+ return this;
+ }
+
+ withApi(): this {
+ this.#with.api = true;
+ return this;
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Lifecycle
+ |--------------------------------------------------------------------------------
+ */
+
+ async start(): Promise {
+ const promises: Promise[] = [];
+ if (this.#isNeeded("mongodb") === true) {
+ promises.push(
+ (async () => {
+ this.#mongodb = await MongoTestContainer.start(config.mongodb);
+ if (this.#isNeeded("database") === true) {
+ this.#database = await new DatabaseTestContainer(this.mongodb).start();
+ }
+ })(),
+ );
+ }
+ if (this.#isNeeded("api") === true) {
+ promises.push(
+ (async () => {
+ this.#api = await new ApiTestContainer().start();
+ })(),
+ );
+ }
+ await Promise.all(promises);
+ return this;
+ }
+
+ async stop(): Promise {
+ await this.#api?.stop();
+ await this.#database?.stop();
+ await this.#mongodb?.stop();
+
+ this.#api = undefined;
+ this.#database = undefined;
+ this.#mongodb = undefined;
+
+ return this;
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Helpers
+ |--------------------------------------------------------------------------------
+ */
+
+ #isNeeded(target: keyof With): boolean {
+ if (this.#with[target] !== false) {
+ return true;
+ }
+ for (const key in this.#needs) {
+ if (this.#with[key as keyof With] !== false && this.#needs[key as keyof With].includes(target) === true) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Types
+ |--------------------------------------------------------------------------------
+ */
+
+type Needs = Record;
+
+type With = {
+ mongodb: boolean;
+ database: boolean;
+ api: boolean;
+};
diff --git a/api/libraries/testing/describe.ts b/api/libraries/testing/describe.ts
new file mode 100644
index 0000000..622ea84
--- /dev/null
+++ b/api/libraries/testing/describe.ts
@@ -0,0 +1,24 @@
+import * as assertSuite from "@std/assert";
+import * as bddSuite from "@std/testing/bdd";
+
+import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
+
+import { authorize } from "./utilities/account.ts";
+
+export function describe(name: string, runner: TestRunner): (container: TestContainer) => void {
+ return (container: TestContainer) =>
+ bddSuite.describe(name, () => runner(container, bddSuite, assertSuite, { authorize: authorize(container) }));
+}
+
+export type TestRunner = (
+ container: TestContainer,
+ bdd: {
+ [key in keyof typeof bddSuite]: (typeof bddSuite)[key];
+ },
+ assert: {
+ [key in keyof typeof assertSuite]: (typeof assertSuite)[key];
+ },
+ utils: {
+ authorize: ReturnType;
+ },
+) => void;
diff --git a/api/libraries/testing/utilities/account.ts b/api/libraries/testing/utilities/account.ts
new file mode 100644
index 0000000..057cbec
--- /dev/null
+++ b/api/libraries/testing/utilities/account.ts
@@ -0,0 +1,68 @@
+import type { EventData } from "@valkyr/event-store";
+
+import { AccountCreated, AccountEmailAdded } from "~libraries/auth/.generated/events.ts";
+import { Account } from "~libraries/auth/aggregates/account.ts";
+import { Role } from "~libraries/auth/aggregates/role.ts";
+import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
+
+type AuthorizationOptions = {
+ name?: { family?: string; given?: string };
+ email?: Partial>;
+};
+
+/**
+ * Return a function which provides the ability to create a new account which
+ * is authorized and ready to use for testing authorized requests.
+ *
+ * @param container - Container to authorize against.
+ */
+export function authorize(container: TestContainer): AuthorizeFn {
+ return async (data: EventData, { name = {}, email = {} }: AuthorizationOptions = {}) => {
+ const role = await makeRole(data.type).save();
+ const account = await Account.create(data, "test")
+ .addName(name?.family ?? "Doe", name?.given ?? "John", "test")
+ .addEmail({ value: "john.doe@fixture.none", type: "work", primary: true, verified: true, ...email }, "test")
+ .addRole(role.id, "test")
+ .save();
+ await container.authorize(account.id);
+ return account;
+ };
+}
+
+function makeRole(type: "admin" | "consultant" | "organization"): Role {
+ switch (type) {
+ case "admin": {
+ return Role.create(
+ {
+ name: "Admin",
+ permissions: [
+ { resource: "admin", actions: ["create", "update", "delete"] },
+ { resource: "consultant", actions: ["create", "update", "delete"] },
+ { resource: "organization", actions: ["create", "update", "delete"] },
+ ],
+ },
+ "test",
+ );
+ }
+ case "consultant": {
+ return Role.create(
+ {
+ name: "Consultant",
+ permissions: [{ resource: "consultant", actions: ["create", "update", "delete"] }],
+ },
+ "test",
+ );
+ }
+ case "organization": {
+ return Role.create(
+ {
+ name: "Organization",
+ permissions: [{ resource: "organization", actions: ["create", "update", "delete"] }],
+ },
+ "test",
+ );
+ }
+ }
+}
+
+type AuthorizeFn = (data: EventData, optional?: AuthorizationOptions) => Promise;
diff --git a/api/libraries/utilities/dedent.ts b/api/libraries/utilities/dedent.ts
new file mode 100644
index 0000000..e8da0a4
--- /dev/null
+++ b/api/libraries/utilities/dedent.ts
@@ -0,0 +1,62 @@
+/**
+ * Removes excess indentation caused by using multiline template strings.
+ *
+ * Ported from `dedent-js` solution.
+ *
+ * @see https://github.com/MartinKolarik/dedent-js
+ *
+ * @param templateStrings - Template strings to dedent.
+ *
+ * @example
+ * {
+ * nested: {
+ * examples: [
+ * dedent(`
+ * I am 8 spaces off from the beginning of this file.
+ * But I will be 2 spaces based on the trimmed distance
+ * of the first line.
+ * `),
+ * ]
+ * }
+ * }
+ */
+export function dedent(templateStrings: TemplateStringsArray | string, ...values: any[]) {
+ const matches = [];
+ const strings = typeof templateStrings === "string" ? [templateStrings] : templateStrings.slice();
+
+ // Remove trailing whitespace.
+
+ strings[strings.length - 1] = strings[strings.length - 1].replace(/\r?\n([\t ]*)$/, "");
+
+ // Find all line breaks to determine the highest common indentation level.
+
+ for (let i = 0; i < strings.length; i++) {
+ const match = strings[i].match(/\n[\t ]+/g);
+ if (match) {
+ matches.push(...match);
+ }
+ }
+
+ // Remove the common indentation from all strings.
+
+ if (matches.length) {
+ const size = Math.min(...matches.map((value) => value.length - 1));
+ const pattern = new RegExp(`\n[\t ]{${size}}`, "g");
+ for (let i = 0; i < strings.length; i++) {
+ strings[i] = strings[i].replace(pattern, "\n");
+ }
+ }
+
+ // Remove leading whitespace.
+
+ strings[0] = strings[0].replace(/^\r?\n/, "");
+
+ // Perform interpolation.
+
+ let string = strings[0];
+ for (let i = 0; i < values.length; i++) {
+ string += values[i] + strings[i + 1];
+ }
+
+ return string;
+}
diff --git a/api/libraries/utilities/generate.ts b/api/libraries/utilities/generate.ts
new file mode 100644
index 0000000..7920ef2
--- /dev/null
+++ b/api/libraries/utilities/generate.ts
@@ -0,0 +1,41 @@
+/**
+ * Traverse path and look for a `generate.ts` file in each folder found under
+ * the given path. If a `generate.ts` file is found it is imported so its content
+ * is executed.
+ *
+ * @param path - Path to resolve `generate.ts` files.
+ * @param filter - Which folders found under the given path to ignore.
+ */
+export async function generate(path: string, filter: string[] = []): Promise {
+ const generate: string[] = [];
+ for await (const entry of Deno.readDir(path)) {
+ if (entry.isDirectory === true) {
+ const moduleName = path.split("/").pop();
+ if (moduleName === undefined) {
+ continue;
+ }
+ if (filter.length > 0 && filter.includes(moduleName) === false) {
+ continue;
+ }
+ const filePath = `${path}/${entry.name}/.tasks/generate.ts`;
+ if (await hasFile(filePath)) {
+ generate.push(filePath);
+ }
+ }
+ }
+ for (const filePath of generate) {
+ await import(filePath);
+ }
+}
+
+async function hasFile(filePath: string) {
+ try {
+ await Deno.lstat(filePath);
+ } catch (err) {
+ if (!(err instanceof Deno.errors.NotFound)) {
+ throw err;
+ }
+ return false;
+ }
+ return true;
+}
diff --git a/api/modules/auth/routes/authenticate.ts b/api/modules/auth/routes/authenticate.ts
new file mode 100644
index 0000000..92ca136
--- /dev/null
+++ b/api/modules/auth/routes/authenticate.ts
@@ -0,0 +1,5 @@
+import { authenticate } from "@spec/modules/auth/routes/authenticate.ts";
+
+export default authenticate.access("public").handle(async ({ body }) => {
+ console.log({ body });
+});
diff --git a/api/package.json b/api/package.json
new file mode 100644
index 0000000..6adc53b
--- /dev/null
+++ b/api/package.json
@@ -0,0 +1,23 @@
+{
+ "private": true,
+ "scripts": {
+ "start": "deno --allow-all --watch-hmr=routes/ server.ts",
+ "migrate": "deno run --allow-all .tasks/migrate.ts"
+ },
+ "dependencies": {
+ "@felix/bcrypt": "npm:@jsr/felix__bcrypt@1",
+ "@spec/modules": "workspace:*",
+ "@spec/relay": "workspace:*",
+ "@spec/shared": "workspace:*",
+ "@std/cli": "npm:@jsr/std__cli@1",
+ "@std/dotenv": "npm:@jsr/std__dotenv@0.225",
+ "@std/fs": "npm:@jsr/std__fs@1",
+ "@std/path": "npm:@jsr/std__path@1",
+ "@valkyr/auth": "npm:@jsr/valkyr__auth@2",
+ "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.5",
+ "@valkyr/inverse": "npm:@jsr/valkyr__inverse@1",
+ "cookie": "1",
+ "mongodb": "6",
+ "zod": "4"
+ }
+}
\ No newline at end of file
diff --git a/api/server.ts b/api/server.ts
new file mode 100644
index 0000000..da850ab
--- /dev/null
+++ b/api/server.ts
@@ -0,0 +1,93 @@
+import { resolve } from "@std/path";
+import cookie from "cookie";
+
+import { auth, type Session } from "~libraries/auth/mod.ts";
+import { logger } from "~libraries/logger/mod.ts";
+import { asyncLocalStorage } from "~libraries/server/mod.ts";
+import { Api, resolveRoutes } from "~libraries/server/mod.ts";
+
+import { config } from "./config.ts";
+
+const MODULES_DIR = resolve(import.meta.dirname!, "modules");
+
+const log = logger.prefix("Server");
+
+/*
+ |--------------------------------------------------------------------------------
+ | Bootstrap
+ |--------------------------------------------------------------------------------
+ */
+
+await import("./tasks/bootstrap.ts");
+
+/*
+ |--------------------------------------------------------------------------------
+ | Service
+ |--------------------------------------------------------------------------------
+ */
+
+const api = new Api(await resolveRoutes(MODULES_DIR));
+
+/*
+ |--------------------------------------------------------------------------------
+ | Server
+ |--------------------------------------------------------------------------------
+ */
+
+Deno.serve(
+ {
+ port: config.port,
+ hostname: config.host,
+ onListen({ port, hostname }) {
+ logger.prefix("Server").info(`Listening at http://${hostname}:${port}`);
+ },
+ },
+ async (request) => {
+ const url = new URL(request.url);
+
+ // ### Session
+
+ let session: Session | undefined;
+
+ const token = cookie.parse(request.headers.get("cookie") ?? "").token;
+ if (token !== undefined) {
+ const resolved = await auth.resolve(token);
+ if (resolved.valid === false) {
+ return new Response(resolved.message, {
+ status: 401,
+ headers: {
+ "set-cookie": cookie.serialize("token", "", config.cookie(0)),
+ },
+ });
+ }
+ session = resolved;
+ }
+
+ // ### Headers
+ // Set the default headers.
+
+ const headers = new Headers();
+
+ // ### Handle
+
+ const ts = performance.now();
+
+ return asyncLocalStorage.run(
+ {
+ session,
+ info: {
+ method: request.url,
+ start: Date.now(),
+ },
+ response: {
+ headers,
+ },
+ },
+ async () => {
+ return api.fetch(request).finally(() => {
+ log.info(`${request.method} ${url.pathname} [${((performance.now() - ts) / 1000).toLocaleString()} seconds]`);
+ });
+ },
+ );
+ },
+);
diff --git a/api/tasks/bootstrap.ts b/api/tasks/bootstrap.ts
new file mode 100644
index 0000000..c1501c0
--- /dev/null
+++ b/api/tasks/bootstrap.ts
@@ -0,0 +1,68 @@
+import { resolve } from "node:path";
+
+import { logger } from "~libraries/logger/mod.ts";
+
+const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries");
+
+const log = logger.prefix("Bootstrap");
+
+/*
+ |--------------------------------------------------------------------------------
+ | Database
+ |--------------------------------------------------------------------------------
+ */
+
+await import("~libraries/database/tasks/bootstrap.ts");
+
+/*
+ |--------------------------------------------------------------------------------
+ | Packages
+ |--------------------------------------------------------------------------------
+ */
+
+await bootstrap(LIBRARIES_DIR);
+
+/*
+ |--------------------------------------------------------------------------------
+ | Helpers
+ |--------------------------------------------------------------------------------
+ */
+
+/**
+ * Traverse path and look for a `bootstrap.ts` file in each folder found under
+ * the given path. If a `boostrap.ts` file is found it is imported so its content
+ * is executed.
+ *
+ * @param path - Path to resolve `bootstrap.ts` files.
+ */
+export async function bootstrap(path: string): Promise {
+ const bootstrap: { name: string; path: string }[] = [];
+ for await (const entry of Deno.readDir(path)) {
+ if (entry.isDirectory === true) {
+ const moduleName = path.split("/").pop();
+ if (moduleName === undefined) {
+ continue;
+ }
+ const filePath = `${path}/${entry.name}/.tasks/bootstrap.ts`;
+ if (await hasFile(filePath)) {
+ bootstrap.push({ name: entry.name, path: filePath });
+ }
+ }
+ }
+ for (const entry of bootstrap) {
+ log.info(entry.name);
+ await import(entry.path);
+ }
+}
+
+async function hasFile(filePath: string) {
+ try {
+ await Deno.lstat(filePath);
+ } catch (err) {
+ if (!(err instanceof Deno.errors.NotFound)) {
+ throw err;
+ }
+ return false;
+ }
+ return true;
+}
diff --git a/api/tasks/migrate.ts b/api/tasks/migrate.ts
new file mode 100644
index 0000000..d2c6a37
--- /dev/null
+++ b/api/tasks/migrate.ts
@@ -0,0 +1,66 @@
+import { resolve } from "node:path";
+import process from "node:process";
+
+import { exists } from "@std/fs";
+
+import { config } from "~libraries/database/config.ts";
+import { getMongoClient } from "~libraries/database/connection.ts";
+import { container } from "~libraries/database/container.ts";
+import { logger } from "~libraries/logger/mod.ts";
+
+/*
+ |--------------------------------------------------------------------------------
+ | Dependencies
+ |--------------------------------------------------------------------------------
+ */
+
+const client = getMongoClient(config.mongo);
+
+container.set("client", client);
+
+/*
+|--------------------------------------------------------------------------------
+| Migrate
+|--------------------------------------------------------------------------------
+*/
+
+const db = client.db("api:migrations");
+const collection = db.collection("migrations");
+
+const { default: journal } = await import(resolve(import.meta.dirname!, "migrations", "meta", "_journal.json"), {
+ with: { type: "json" },
+});
+
+const migrations =
+ (await collection.findOne({ name: journal.name })) ?? ({ name: journal.name, entries: [] } as MigrationDocument);
+
+for (const entry of journal.entries) {
+ const migrationFileName = `${String(entry.idx).padStart(4, "0")}_${entry.name}.ts`;
+ if (migrations.entries.includes(migrationFileName)) {
+ continue;
+ }
+ const migrationPath = resolve(import.meta.dirname!, "migrations", migrationFileName);
+ if (await exists(migrationPath)) {
+ await import(migrationPath);
+ await collection.updateOne(
+ {
+ name: journal.name,
+ },
+ {
+ $set: { name: journal.name },
+ $push: { entries: migrationFileName }, // Assuming 'entries' is an array
+ },
+ {
+ upsert: true,
+ },
+ );
+ logger.info(`Migrated ${migrationPath}`);
+ }
+}
+
+type MigrationDocument = {
+ name: string;
+ entries: string[];
+};
+
+process.exit(0);
diff --git a/api/tasks/migrations/meta/_journal.json b/api/tasks/migrations/meta/_journal.json
new file mode 100644
index 0000000..e82d343
--- /dev/null
+++ b/api/tasks/migrations/meta/_journal.json
@@ -0,0 +1,4 @@
+{
+ "name": "api",
+ "entries": []
+}
\ No newline at end of file
diff --git a/apps/README.md b/apps/README.md
new file mode 100644
index 0000000..c7782ba
--- /dev/null
+++ b/apps/README.md
@@ -0,0 +1 @@
+# Apps
\ No newline at end of file
diff --git a/apps/react/.gitignore b/apps/react/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/apps/react/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/apps/react/.npmrc b/apps/react/.npmrc
new file mode 100644
index 0000000..41583e3
--- /dev/null
+++ b/apps/react/.npmrc
@@ -0,0 +1 @@
+@jsr:registry=https://npm.jsr.io
diff --git a/apps/react/README.md b/apps/react/README.md
new file mode 100644
index 0000000..7959ce4
--- /dev/null
+++ b/apps/react/README.md
@@ -0,0 +1,69 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+
+ // Remove tseslint.configs.recommended and replace with this
+ ...tseslint.configs.recommendedTypeChecked,
+ // Alternatively, use this for stricter rules
+ ...tseslint.configs.strictTypeChecked,
+ // Optionally, add this for stylistic rules
+ ...tseslint.configs.stylisticTypeChecked,
+
+ // Other configs...
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+ // Enable lint rules for React
+ reactX.configs['recommended-typescript'],
+ // Enable lint rules for React DOM
+ reactDom.configs.recommended,
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
diff --git a/apps/react/index.html b/apps/react/index.html
new file mode 100644
index 0000000..e4b78ea
--- /dev/null
+++ b/apps/react/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/apps/react/package.json b/apps/react/package.json
new file mode 100644
index 0000000..5fe6378
--- /dev/null
+++ b/apps/react/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "react",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "5",
+ "@tanstack/react-router": "1",
+ "@valkyr/db": "1",
+ "@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1",
+ "fast-equals": "5",
+ "react": "19",
+ "react-dom": "19"
+ },
+ "devDependencies": {
+ "@eslint/js": "9",
+ "@types/react": "19",
+ "@types/react-dom": "19",
+ "@vitejs/plugin-react": "4",
+ "eslint": "9",
+ "eslint-plugin-react-hooks": "5",
+ "eslint-plugin-react-refresh": "0.4",
+ "globals": "16",
+ "typescript": "5",
+ "typescript-eslint": "8",
+ "vite": "7"
+ }
+}
diff --git a/apps/react/public/vite.svg b/apps/react/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/apps/react/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/react/src/App.css b/apps/react/src/App.css
new file mode 100644
index 0000000..b9d355d
--- /dev/null
+++ b/apps/react/src/App.css
@@ -0,0 +1,42 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/apps/react/src/App.tsx b/apps/react/src/App.tsx
new file mode 100644
index 0000000..cfff5c9
--- /dev/null
+++ b/apps/react/src/App.tsx
@@ -0,0 +1,36 @@
+import "./App.css";
+
+import { useState } from "react";
+
+import viteLogo from "/vite.svg";
+
+import reactLogo from "./assets/react.svg";
+import { Session } from "./components/Session.tsx";
+
+function App() {
+ const [count, setCount] = useState(0);
+
+ return (
+ <>
+
+ Vite + React
+
+
+
+ Edit src/App.tsx and save to test HMR
+
+
+ Click on the Vite and React logos to learn more
+
+ >
+ );
+}
+
+export default App;
diff --git a/apps/react/src/adapters/http.ts b/apps/react/src/adapters/http.ts
new file mode 100644
index 0000000..1135e02
--- /dev/null
+++ b/apps/react/src/adapters/http.ts
@@ -0,0 +1,267 @@
+import {
+ assertServerErrorResponse,
+ type RelayAdapter,
+ type RelayInput,
+ type RelayResponse,
+ ServerError,
+ type ServerErrorResponse,
+ type ServerErrorType,
+} from "@spec/relay";
+
+export class HttpAdapter implements RelayAdapter {
+ /**
+ * Instantiate a new HttpAdapter instance.
+ *
+ * @param options - Adapter options.
+ */
+ constructor(readonly options: HttpAdapterOptions) {}
+
+ /**
+ * Override the initial url value set by instantiator.
+ */
+ set url(value: string) {
+ this.options.url = value;
+ }
+
+ /**
+ * Retrieve the URL value from options object.
+ */
+ get url() {
+ return this.options.url;
+ }
+
+ /**
+ * Return the full URL from given endpoint.
+ *
+ * @param endpoint - Endpoint to get url for.
+ */
+ getUrl(endpoint: string): string {
+ 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 {
+ 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
+ // 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 {
+ 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));
+ }
+ }
+ }
+ init.body = formData;
+ }
+
+ // ### Response
+
+ return this.request(`${endpoint}${query}`, init);
+ }
+
+ /**
+ * Send a fetch request using the given fetch options and returns
+ * a relay formatted response.
+ *
+ * @param endpoint - Which endpoint to submit request to.
+ * @param init - Request init details to submit with the request.
+ */
+ async request(endpoint: string, init?: RequestInit): Promise {
+ return this.#toResponse(await fetch(this.getUrl(endpoint), init));
+ }
+
+ /**
+ * Run before request operations.
+ *
+ * @param headers - Headers to pass to hooks.
+ */
+ async #beforeRequest(headers: Headers) {
+ if (this.options.hooks?.beforeRequest !== undefined) {
+ for (const hook of this.options.hooks.beforeRequest) {
+ await hook(headers);
+ }
+ }
+ }
+
+ /**
+ * Convert a fetch response to a compliant relay response.
+ *
+ * @param response - Fetch response to convert.
+ */
+ async #toResponse(response: Response): Promise {
+ const type = response.headers.get("content-type");
+
+ // ### Content Type
+ // Ensure that the server responds with a 'content-type' definition. We should
+ // always expect the server to respond with a type.
+
+ if (type === null) {
+ return {
+ result: "error",
+ headers: response.headers,
+ error: {
+ status: response.status,
+ message: "Missing 'content-type' in header returned from server.",
+ },
+ };
+ }
+
+ // ### Empty Response
+ // If the response comes back with empty response status 204 we simply return a
+ // empty success.
+
+ 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.
+
+ if (type.includes("json") === true) {
+ const parsed = await response.json();
+ 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.",
+ },
+ };
+ }
+
+ return {
+ result: "error",
+ headers: response.headers,
+ error: {
+ status: response.status,
+ message: "Unsupported 'content-type' in header returned from server.",
+ },
+ };
+ }
+
+ #toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] {
+ if (assertServerErrorResponse(candidate)) {
+ return ServerError.fromJSON({ type: "relay", ...candidate.error });
+ }
+ if (typeof candidate === "string") {
+ return {
+ status,
+ message: candidate,
+ };
+ }
+ return {
+ status,
+ message: "Unsupported 'error' returned from server.",
+ };
+ }
+}
+
+export type HttpAdapterOptions = {
+ url: string;
+ hooks?: {
+ beforeRequest?: ((headers: Headers) => Promise)[];
+ };
+};
diff --git a/apps/react/src/assets/react.svg b/apps/react/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/apps/react/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/react/src/components/Session.tsx b/apps/react/src/components/Session.tsx
new file mode 100644
index 0000000..efcf127
--- /dev/null
+++ b/apps/react/src/components/Session.tsx
@@ -0,0 +1,9 @@
+import { makeControllerView } from "../libraries/view.ts";
+import { SessionController } from "./session.controller.ts";
+
+export const Session = makeControllerView(SessionController, ({ state: { error } }) => {
+ if (error !== undefined) {
+ return "Failed to fetch session";
+ }
+ return Session OK!;
+});
diff --git a/apps/react/src/components/session.controller.ts b/apps/react/src/components/session.controller.ts
new file mode 100644
index 0000000..9c83254
--- /dev/null
+++ b/apps/react/src/components/session.controller.ts
@@ -0,0 +1,24 @@
+import { Controller } from "../libraries/controller.ts";
+import { api } from "../services/api.ts";
+
+export class SessionController extends Controller<{
+ error?: string;
+}> {
+ async onInit() {
+ await this.getSessionCookie();
+ }
+
+ async getSessionCookie() {
+ const response = await api.auth.authenticate({
+ body: {
+ type: "email",
+ payload: {
+ email: "john.doe@fixture.none",
+ },
+ },
+ });
+ if ("error" in response) {
+ this.setState("error", undefined);
+ }
+ }
+}
diff --git a/apps/react/src/index.css b/apps/react/src/index.css
new file mode 100644
index 0000000..08a3ac9
--- /dev/null
+++ b/apps/react/src/index.css
@@ -0,0 +1,68 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/apps/react/src/libraries/controller.ts b/apps/react/src/libraries/controller.ts
new file mode 100644
index 0000000..4ab1e7c
--- /dev/null
+++ b/apps/react/src/libraries/controller.ts
@@ -0,0 +1,372 @@
+import type { ChangeEvent, Collection, SubscribeToMany, SubscribeToSingle, SubscriptionOptions } from "@valkyr/db";
+import type { Subscription } from "@valkyr/event-emitter";
+
+import { Debounce } from "./debounce.ts";
+import { ControllerRefs } from "./refs.ts";
+import type { ControllerClass, Empty, ReactComponent, ReservedPropertyMembers, Unknown } from "./types.ts";
+
+export class Controller {
+ state: TState = {} as TState;
+ props: TProps = {} as TProps;
+
+ /**
+ * Stores a list of referenced elements identifies by a unique key.
+ */
+ readonly refs = new ControllerRefs();
+
+ /**
+ * Records of event emitter subscriptions. They are keyed to a subscription name
+ * for easier identification when unsubscribing.
+ */
+ readonly subscriptions = new Map();
+
+ /**
+ * Has the controller fully resolved the .onInit lifecycle method?
+ */
+ #resolved = false;
+
+ /**
+ * Internal debounce instance used to ensure that we aren't triggering state
+ * updates too frequently when updates are happening in quick succession.
+ */
+ #debounce = new Debounce();
+
+ /**
+ * Creates a new controller instance with given default state and pushState
+ * handler method.
+ *
+ * @param state - Default state to assign to controller.
+ * @param pushData - Push data handler method.
+ */
+ constructor(
+ readonly view: ReactComponent,
+ readonly setView: any,
+ ) {
+ this.query = this.query.bind(this);
+ this.subscribe = this.subscribe.bind(this);
+ this.setState = this.setState.bind(this);
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Factories
+ |--------------------------------------------------------------------------------
+ */
+
+ /**
+ * Creates a new controller instance using the given component and setView handler.
+ *
+ * @param component - Component to render.
+ * @param setView - Method to provide a resolved view component.
+ */
+ static make(
+ this: TController,
+ component: ReactComponent,
+ setView: any,
+ ): InstanceType {
+ return new this(component, setView);
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Bootstrap & Teardown
+ |--------------------------------------------------------------------------------
+ */
+
+ async $resolve(props: TProps): Promise {
+ this.props = props;
+ let state: Partial = {};
+ try {
+ if (this.#resolved === false) {
+ state = {
+ ...state,
+ ...((await this.onInit()) ?? {}),
+ };
+ }
+ state = {
+ ...state,
+ ...((await this.onResolve()) ?? {}),
+ };
+ } catch (err) {
+ console.error(err);
+ throw err;
+ }
+ this.#resolved = true;
+ this.setState(state);
+ }
+
+ async $destroy(): Promise {
+ for (const subscription of this.subscriptions.values()) {
+ subscription.unsubscribe();
+ }
+ await this.onDestroy();
+ this.refs.destroy();
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Lifecycle Methods
+ |--------------------------------------------------------------------------------
+ */
+
+ /**
+ * Method runs once per controller view lifecycle. This is where you should
+ * subscribe to and return initial controller state. A component is kept in
+ * loading state until the initial resolve is completed.
+ *
+ * Once the initial resolve is completed the controller will not run the onInit
+ * method again unless the controller is destroyed and re-created.
+ *
+ * @example
+ * ```ts
+ * async onInit() {
+ * return {
+ * foos: this.query(foos, {}, "foos")
+ * }
+ * }
+ * ```
+ */
+ async onInit(): Promise | void> {
+ return {};
+ }
+
+ /**
+ * Method runs every time the controller is resolved. This is where you should
+ * subscribe to and return state that is reflecting changes to the parent view
+ * properties.
+ *
+ * @example
+ * ```ts
+ * async onResolve() {
+ * return {
+ * foos: this.query(foos, { tenantId: this.props.tenantId }, "foos")
+ * }
+ * }
+ * ```
+ */
+ async onResolve(): Promise | void> {
+ return {};
+ }
+
+ /**
+ * Method runs when the controller parent view is destroyed.
+ */
+ async onDestroy(): Promise {}
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Query Methods
+ |--------------------------------------------------------------------------------
+ */
+
+ /**
+ * Executes a query on a given collection and returns the initial result. A
+ * subsequent internal subscription is also created, which automatically updates
+ * the controller state when changes are made to the data in which the query
+ * subscribes.
+ *
+ * @param collection - Collection to query against.
+ * @param query - Query to execute.
+ * @param stateKey - State key to assign the results to, or state handler method.
+ *
+ * @example
+ * ```ts
+ * async onInit() {
+ * return {
+ * foo: await this.query(db.collection("foos"), { limit: 1 }, "foo")
+ * }
+ * }
+ * ```
+ */
+ async query, TSchema = CollectionSchema, TStateKey = keyof TState>(
+ collection: TCollection,
+ query: QuerySingle,
+ next: TStateKey | ((document: TSchema | undefined) => Promise>),
+ ): Promise;
+
+ /**
+ * Executes a query on a given collection and returns the initial result. A
+ * subsequent internal subscription is also created, which automatically updates
+ * the controller state when changes are made to the data in which the query
+ * subscribes.
+ *
+ * @param collection - Collection to query against.
+ * @param query - Query to execute.
+ * @param next - State key to assign the results to, or state handler method.
+ *
+ * @example
+ * ```ts
+ * async onInit() {
+ * return {
+ * foos: await this.query(db.collection("foos"), {}, "foos")
+ * }
+ * }
+ * ```
+ */
+ async query, TSchema = CollectionSchema, TStateKey = keyof TState>(
+ collection: TCollection,
+ query: QueryMany,
+ next:
+ | TStateKey
+ | ((documents: TSchema[], changed: TSchema[], type: ChangeEvent["type"]) => Promise>),
+ ): Promise;
+
+ /**
+ * Executes a query on a given collection and returns the initial result. A
+ * subsequent internal subscription is also created, which automatically updates
+ * the controller state when changes are made to the data in which the query
+ * subscribes.
+ *
+ * @param collection - Collection to query against.
+ * @param query - Query to execute.
+ * @param stateKey - State key to assign the results to, or state handler method.
+ */
+ async query, TSchema = CollectionSchema, TStateKey = keyof TState>(
+ collection: TCollection,
+ query: Query,
+ next: TStateKey | ((...args: any[]) => Promise>),
+ ): Promise {
+ let resolved = false;
+ this.subscriptions.get(collection.name)?.unsubscribe();
+ return new Promise[] | CollectionSchema | undefined>((resolve) => {
+ const { where, ...options } = query;
+ this.subscriptions.set(
+ collection.name,
+ collection.subscribe(where, options, (...args: any[]) => {
+ if (this.#isStateKey(next)) {
+ if (resolved === true) {
+ this.setState(next, args[0]);
+ }
+ } else {
+ (next as any)(...args).then(this.setState);
+ }
+ setTimeout(() => {
+ resolve(args[0]);
+ resolved = true;
+ }, 0);
+ }),
+ );
+ });
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Event Methods
+ |--------------------------------------------------------------------------------
+ */
+
+ /**
+ * Consumes a subscription under a given event key that is unsubscribed
+ * automatically when the controller is unmounted.
+ *
+ * @param key - Unique identifier used to unsusbcribe duplicate subs.
+ * @param sub - Subscription to unsubscribe on controller unmount.
+ */
+ subscribe(key: string, sub: { unsubscribe: () => void }): void {
+ this.subscriptions.get(key)?.unsubscribe();
+ this.subscriptions.set(key, sub);
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | State Methods
+ |--------------------------------------------------------------------------------
+ */
+
+ /**
+ * Updates the state of the controller and triggers a state update via the push
+ * state handler. This method will debounce state updates to prevent excessive
+ * state updates.
+ *
+ * @param key - State key to assign data to.
+ * @param value - State value to assign.
+ */
+ setState(state: Partial): void;
+ setState(key: K): (state: TState[K]) => void;
+ setState(key: K, value: TState[K]): void;
+ setState(...args: [K | TState, TState[K]?]): void | ((state: TState[K]) => void) {
+ const [target, value] = args;
+
+ if (this.#isStateKey(target) && args.length === 1) {
+ return (value: TState[K]) => {
+ this.setState(target, value);
+ };
+ }
+
+ this.state = this.#isStateKey(target)
+ ? {
+ ...this.state,
+ [target]: value,
+ }
+ : {
+ ...this.state,
+ ...(target as Partial),
+ };
+
+ if (this.#resolved === true) {
+ this.#debounce.run(() => {
+ this.setView(
+ this.view({
+ props: this.props,
+ state: this.state,
+ actions: this.toActions(),
+ refs: this.refs,
+ }),
+ );
+ }, 0);
+ }
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Resolvers
+ |--------------------------------------------------------------------------------
+ */
+
+ /**
+ * Returns all the prototype methods defined on the controller as a list of
+ * actions bound to the controller instance to be used in the view.
+ *
+ * @returns List of actions.
+ */
+ toActions(): Omit {
+ const actions: any = {};
+ for (const name of Object.getOwnPropertyNames(this.constructor.prototype)) {
+ if (name !== "constructor" && name !== "resolve") {
+ const action = (this as any)[name];
+ if (typeof action === "function") {
+ actions[name] = action.bind(this);
+ }
+ }
+ }
+ return actions;
+ }
+
+ /*
+ |--------------------------------------------------------------------------------
+ | Utilities
+ |--------------------------------------------------------------------------------
+ */
+
+ #isStateKey(key: unknown): key is keyof TState {
+ return typeof key === "string";
+ }
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Types
+ |--------------------------------------------------------------------------------
+ */
+
+type Query = Where & SubscriptionOptions;
+
+type QuerySingle = Where & SubscribeToSingle;
+
+type QueryMany = Where & SubscribeToMany;
+
+type Where = {
+ where?: Record;
+};
+
+type CollectionSchema = TCollection extends Collection ? TSchema : never;
diff --git a/apps/react/src/libraries/debounce.ts b/apps/react/src/libraries/debounce.ts
new file mode 100644
index 0000000..0aa2893
--- /dev/null
+++ b/apps/react/src/libraries/debounce.ts
@@ -0,0 +1,14 @@
+export class Debounce {
+ #timeout?: number;
+
+ run(fn: (...args: any[]) => void, ms: number): void {
+ this.#clear();
+ this.#timeout = setTimeout(fn, ms);
+ }
+
+ #clear() {
+ if (this.#timeout !== undefined) {
+ clearTimeout(this.#timeout);
+ }
+ }
+}
diff --git a/apps/react/src/libraries/refs.ts b/apps/react/src/libraries/refs.ts
new file mode 100644
index 0000000..5b5b9d1
--- /dev/null
+++ b/apps/react/src/libraries/refs.ts
@@ -0,0 +1,50 @@
+const refs = new Map();
+
+export class ControllerRefs {
+ #refs = new Map();
+
+ #forwarded: string[] = [];
+
+ set(name: string) {
+ return (element: HTMLElement | null) => {
+ if (element !== null) {
+ refs.set(name, element);
+ }
+ };
+ }
+
+ forward(name: string) {
+ return (element: HTMLElement | null) => {
+ if (element !== null) {
+ refs.set(name, element);
+ this.#forwarded.push(name);
+ }
+ };
+ }
+
+ get(name: string) {
+ const element = this.#refs.get(name) ?? refs.get(name);
+ if (element === undefined) {
+ throw new Error(`Reference Exception: ${name} is not defined.`);
+ }
+ return element;
+ }
+
+ async on(name: string, count = 0): Promise {
+ if (count > 20) {
+ return undefined;
+ }
+ const element = this.#refs.get(name) ?? refs.get(name);
+ if (element === undefined) {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ return this.on(name, count + 1);
+ }
+ return element;
+ }
+
+ destroy() {
+ this.#forwarded.forEach((name) => {
+ refs.delete(name);
+ });
+ }
+}
diff --git a/apps/react/src/libraries/types.ts b/apps/react/src/libraries/types.ts
new file mode 100644
index 0000000..cea0c80
--- /dev/null
+++ b/apps/react/src/libraries/types.ts
@@ -0,0 +1,22 @@
+import React, { type FunctionComponent } from "react";
+
+import { ControllerRefs } from "./refs.ts";
+
+export type ReactComponent = FunctionComponent<{
+ props: TProps;
+ state: InstanceType["state"];
+ actions: Omit, ReservedPropertyMembers>;
+ refs: ControllerRefs;
+ component?: React.FC;
+}>;
+
+export type ControllerClass = {
+ new (state: any, pushState: any): any;
+ make(component: ReactComponent, pushState: any): any;
+};
+
+export type ReservedPropertyMembers = "state" | "pushState" | "init" | "destroy" | "setNext" | "setState" | "toActions";
+
+export type Unknown = Record;
+
+export type Empty = Record;
diff --git a/apps/react/src/libraries/view.ts b/apps/react/src/libraries/view.ts
new file mode 100644
index 0000000..3238c64
--- /dev/null
+++ b/apps/react/src/libraries/view.ts
@@ -0,0 +1,199 @@
+import { deepEqual } from "fast-equals";
+import React, { createElement, type FunctionComponent, memo, type PropsWithChildren, useEffect, useState } from "react";
+
+import type { ControllerClass, ReactComponent, Unknown } from "./types.ts";
+
+/*
+ |--------------------------------------------------------------------------------
+ | Options
+ |--------------------------------------------------------------------------------
+ */
+
+const options: Partial> = {
+ memoize: defaultMemoizeHandler,
+};
+
+/*
+ |--------------------------------------------------------------------------------
+ | Factory
+ |--------------------------------------------------------------------------------
+ */
+
+export function makeControllerView(
+ controller: TController,
+ component: ReactComponent,
+ options?: Partial>,
+): FunctionComponent {
+ const memoize = getMemoizeHandler(options?.memoize);
+ const render = {
+ loading: getLoadingComponent(options),
+ error: getErrorComponent(options),
+ };
+
+ const container: FunctionComponent> = (props: any) => {
+ const { error, view } = useView(controller, component, props);
+ if (view === undefined) {
+ return render.loading(props);
+ }
+ if (error !== undefined) {
+ return render.error({ ...props, error });
+ }
+ return view;
+ };
+
+ container.displayName = component.displayName = options?.name ?? `${controller.name}View`;
+
+ // ### Memoize
+ // By default run component through react memoization using stringify
+ // matching to determine changes to props.
+
+ if (memoize !== false) {
+ return memo(container, memoize);
+ }
+
+ return container;
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Hooks
+ |--------------------------------------------------------------------------------
+ */
+
+function useView(
+ instance: InstanceType | undefined,
+ component: ReactComponent,
+ props: any,
+) {
+ const [view, setView] = useState();
+
+ const error = useController(instance, component, props, setView);
+
+ return { error, view };
+}
+
+function useController(controller: ControllerClass, component: any, props: any, setView: any) {
+ const [instance, setInstance] = useState | undefined>(undefined);
+ const error = useProps(instance, props);
+
+ useEffect(() => {
+ const instance = controller.make(component, setView);
+ setInstance(instance);
+ return () => {
+ instance.$destroy();
+ };
+ }, []);
+
+ return error;
+}
+
+function useProps(controller: InstanceType | undefined, props: any) {
+ const [error, setError] = useState();
+
+ useEffect(() => {
+ if (controller === undefined) {
+ return;
+ }
+ let isMounted = true;
+ controller.$resolve(props).catch((error: Error) => {
+ if (isMounted === true) {
+ setError(error);
+ }
+ });
+ return () => {
+ isMounted = false;
+ };
+ }, [controller, props]);
+
+ return error;
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Components
+ |--------------------------------------------------------------------------------
+ */
+
+export function setLoadingComponent(component: React.FC) {
+ options.loading = component;
+}
+
+function getLoadingComponent({ loading }: Partial> = {}) {
+ const component = loading ?? options.loading;
+ if (component === undefined) {
+ return () => null;
+ }
+ return (props: TProps) => createElement(component, props);
+}
+
+export function setErrorComponent(component: React.FC) {
+ options.error = component;
+}
+
+function getErrorComponent({ error }: Partial> = {}) {
+ const component = error ?? options.loading;
+ if (component === undefined) {
+ return () => null;
+ }
+ return (props: TProps) => createElement(component, props);
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Memoize
+ |--------------------------------------------------------------------------------
+ */
+
+export function setMemoizeHandler(value: boolean | Memoize) {
+ if (typeof value === "function") {
+ options.memoize = value;
+ } else if (value === false) {
+ options.memoize = false;
+ } else {
+ options.memoize = defaultMemoizeHandler;
+ }
+}
+
+function getMemoizeHandler(memoize?: ViewOptions["memoize"]): false | Memoize | undefined {
+ if (typeof memoize === "function") {
+ return memoize;
+ }
+ if (memoize !== false) {
+ return options.memoize;
+ }
+ return false;
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Defaults
+ |--------------------------------------------------------------------------------
+ */
+
+function defaultMemoizeHandler(prev: any, next: any): boolean {
+ if (prev.children !== undefined && next.children !== undefined) {
+ if (prev.children.type.type.displayName !== next.children.type.type.displayName) {
+ return false;
+ }
+ }
+ return deepEqual(prev, next);
+}
+
+/*
+ |--------------------------------------------------------------------------------
+ | Types
+ |--------------------------------------------------------------------------------
+ */
+
+export type ViewOptions = {
+ name?: string;
+ loading: React.FC;
+ error: React.FC;
+ memoize: false | Memoize;
+};
+
+type Memoize = (prevProps: Readonly, nextProps: Readonly) => boolean;
+
+type Readonly = {
+ readonly [P in keyof T]: T[P];
+};
diff --git a/apps/react/src/main.tsx b/apps/react/src/main.tsx
new file mode 100644
index 0000000..33bb9dd
--- /dev/null
+++ b/apps/react/src/main.tsx
@@ -0,0 +1,26 @@
+import "./index.css";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createRouter, RouterProvider } from "@tanstack/react-router";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import { routeTree } from "./routes.tsx";
+
+const queryClient = new QueryClient();
+
+const router = createRouter({ routeTree });
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: typeof router;
+ }
+}
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/apps/react/src/routes.tsx b/apps/react/src/routes.tsx
new file mode 100644
index 0000000..a3356be
--- /dev/null
+++ b/apps/react/src/routes.tsx
@@ -0,0 +1,9 @@
+import { createRootRoute } from "@tanstack/react-router";
+
+import App from "./App.tsx";
+
+const rootRoute = createRootRoute({
+ component: App,
+});
+
+export const routeTree = rootRoute.addChildren([]);
diff --git a/apps/react/src/services/api.ts b/apps/react/src/services/api.ts
new file mode 100644
index 0000000..197b702
--- /dev/null
+++ b/apps/react/src/services/api.ts
@@ -0,0 +1,14 @@
+import { makeClient } from "@spec/relay";
+
+import { HttpAdapter } from "../adapters/http.ts";
+
+export const api = makeClient(
+ {
+ adapter: new HttpAdapter({
+ url: window.location.origin,
+ }),
+ },
+ {
+ auth: (await import("@spec/modules/auth/mod.ts")).routes,
+ },
+);
diff --git a/apps/react/src/vite-env.d.ts b/apps/react/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/react/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/react/tsconfig.app.json b/apps/react/tsconfig.app.json
new file mode 100644
index 0000000..126d126
--- /dev/null
+++ b/apps/react/tsconfig.app.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ // "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/apps/react/tsconfig.json b/apps/react/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/apps/react/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/apps/react/tsconfig.node.json b/apps/react/tsconfig.node.json
new file mode 100644
index 0000000..99f7cb0
--- /dev/null
+++ b/apps/react/tsconfig.node.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ // "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/react/vite.config.ts b/apps/react/vite.config.ts
new file mode 100644
index 0000000..6d54618
--- /dev/null
+++ b/apps/react/vite.config.ts
@@ -0,0 +1,13 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ "/api/v1": {
+ target: "http://localhost:8370",
+ },
+ },
+ },
+});
diff --git a/deno.json b/deno.json
index 7b006b4..d87e2a5 100644
--- a/deno.json
+++ b/deno.json
@@ -1,24 +1,41 @@
{
- "name": "@valkyr/relay",
- "version": "0.4.0",
- "exports": {
- ".": "./mod.ts",
- "./http": "./adapters/http.ts"
- },
- "publish": {
- "exclude": [
- ".github",
- ".vscode",
- ".gitignore",
- "tests"
- ]
- },
+ "unstable": ["fmt-component"],
+ "nodeModulesDir": "auto",
+ "workspace": [
+ "api",
+ "apps/react",
+ "spec/modules",
+ "spec/relay",
+ "spec/shared"
+ ],
"tasks": {
- "check": "deno check ./mod.ts",
- "lint": "npx eslint -c eslint.config.mjs .",
- "test": "deno test --allow-all",
- "test:publish": "deno publish --dry-run",
- "ncu": "npx ncu -u -p npm"
- },
- "nodeModulesDir": "auto"
+ "start:api": {
+ "command": "cd ./api && deno run start",
+ "description": "Start api server instance."
+ },
+ "start:react": {
+ "command": "cd ./apps/react && deno run dev",
+ "description": "Start react application instance."
+ },
+ "check": {
+ "command": "deno check ./mod.ts",
+ "description": "Runs a check on all the projects main entry files."
+ },
+ "lint": {
+ "command": "npx eslint -c eslint.config.mjs .",
+ "description": "Runs eslint across the entire project."
+ },
+ "fmt": {
+ "command": "npx prettier --write .",
+ "description": "Runs prettier formatting across the entire project."
+ },
+ "test": {
+ "command": "deno test --allow-all",
+ "description": "Runs all defined tests across the entire project."
+ },
+ "ncu": {
+ "command": "npx ncu -u -p npm",
+ "description": "Updates all the dependencies in package.json to their latest versions."
+ }
+ }
}
diff --git a/deno.lock b/deno.lock
index a54e3c5..5d8a21f 100644
--- a/deno.lock
+++ b/deno.lock
@@ -1,17 +1,320 @@
{
- "version": "4",
+ "version": "5",
"specifiers": {
- "npm:@jsr/std__assert@1.0.12": "1.0.12",
- "npm:@jsr/std__testing@1.0.11": "1.0.11",
- "npm:eslint-plugin-simple-import-sort@12.1.1": "12.1.1_eslint@9.24.0",
- "npm:eslint@9.24.0": "9.24.0",
- "npm:prettier@3.5.3": "3.5.3",
- "npm:typescript-eslint@8.30.1": "8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3",
- "npm:zod@next": "4.0.0-beta.20250417T043022"
+ "npm:@eslint/js@9": "9.33.0",
+ "npm:@jsr/felix__bcrypt@1": "1.0.5",
+ "npm:@jsr/std__assert@1": "1.0.13",
+ "npm:@jsr/std__cli@1": "1.0.21",
+ "npm:@jsr/std__dotenv@0.225": "0.225.5",
+ "npm:@jsr/std__fs@1": "1.0.19",
+ "npm:@jsr/std__path@1": "1.1.1",
+ "npm:@jsr/std__testing@1": "1.0.15",
+ "npm:@jsr/valkyr__auth@2": "2.0.2",
+ "npm:@jsr/valkyr__event-emitter@1": "1.0.1",
+ "npm:@jsr/valkyr__event-store@2.0.0-beta.5": "2.0.0-beta.5",
+ "npm:@jsr/valkyr__inverse@1": "1.0.1",
+ "npm:@tanstack/react-query@5": "5.84.2_react@19.1.1",
+ "npm:@tanstack/react-router@1": "1.131.5_react@19.1.1_react-dom@19.1.1__react@19.1.1",
+ "npm:@types/node@*": "22.15.15",
+ "npm:@types/react-dom@19": "19.1.7_@types+react@19.1.9",
+ "npm:@types/react@19": "19.1.9",
+ "npm:@valkyr/db@1": "1.0.1",
+ "npm:@vitejs/plugin-react@4": "4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0_@types+node@22.15.15",
+ "npm:cookie@1": "1.0.2",
+ "npm:eslint-plugin-react-hooks@5": "5.2.0_eslint@9.33.0",
+ "npm:eslint-plugin-react-refresh@0.4": "0.4.20_eslint@9.33.0",
+ "npm:eslint-plugin-simple-import-sort@12": "12.1.1_eslint@9.33.0",
+ "npm:eslint@9": "9.33.0",
+ "npm:fast-equals@5": "5.2.2",
+ "npm:globals@16": "16.3.0",
+ "npm:mongodb@6": "6.18.0",
+ "npm:path-to-regexp@8": "8.2.0",
+ "npm:prettier@3": "3.6.2",
+ "npm:react-dom@19": "19.1.1_react@19.1.1",
+ "npm:react@19": "19.1.1",
+ "npm:typescript-eslint@8": "8.39.0_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2",
+ "npm:typescript@5": "5.9.2",
+ "npm:vite@7": "7.1.1_picomatch@4.0.3_@types+node@22.15.15",
+ "npm:vite@7.1.1": "7.1.1_picomatch@4.0.3_@types+node@22.15.15",
+ "npm:zod@4": "4.0.17"
},
"npm": {
- "@eslint-community/eslint-utils@4.6.1_eslint@9.24.0": {
- "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==",
+ "@ampproject/remapping@2.3.0": {
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dependencies": [
+ "@jridgewell/gen-mapping",
+ "@jridgewell/trace-mapping"
+ ]
+ },
+ "@babel/code-frame@7.27.1": {
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dependencies": [
+ "@babel/helper-validator-identifier",
+ "js-tokens",
+ "picocolors"
+ ]
+ },
+ "@babel/compat-data@7.28.0": {
+ "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="
+ },
+ "@babel/core@7.28.0": {
+ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
+ "dependencies": [
+ "@ampproject/remapping",
+ "@babel/code-frame",
+ "@babel/generator",
+ "@babel/helper-compilation-targets",
+ "@babel/helper-module-transforms",
+ "@babel/helpers",
+ "@babel/parser",
+ "@babel/template",
+ "@babel/traverse",
+ "@babel/types",
+ "convert-source-map",
+ "debug",
+ "gensync",
+ "json5",
+ "semver@6.3.1"
+ ]
+ },
+ "@babel/generator@7.28.0": {
+ "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
+ "dependencies": [
+ "@babel/parser",
+ "@babel/types",
+ "@jridgewell/gen-mapping",
+ "@jridgewell/trace-mapping",
+ "jsesc"
+ ]
+ },
+ "@babel/helper-compilation-targets@7.27.2": {
+ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "dependencies": [
+ "@babel/compat-data",
+ "@babel/helper-validator-option",
+ "browserslist",
+ "lru-cache",
+ "semver@6.3.1"
+ ]
+ },
+ "@babel/helper-globals@7.28.0": {
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="
+ },
+ "@babel/helper-module-imports@7.27.1": {
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "dependencies": [
+ "@babel/traverse",
+ "@babel/types"
+ ]
+ },
+ "@babel/helper-module-transforms@7.27.3_@babel+core@7.28.0": {
+ "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
+ "dependencies": [
+ "@babel/core",
+ "@babel/helper-module-imports",
+ "@babel/helper-validator-identifier",
+ "@babel/traverse"
+ ]
+ },
+ "@babel/helper-plugin-utils@7.27.1": {
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="
+ },
+ "@babel/helper-string-parser@7.27.1": {
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="
+ },
+ "@babel/helper-validator-identifier@7.27.1": {
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="
+ },
+ "@babel/helper-validator-option@7.27.1": {
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="
+ },
+ "@babel/helpers@7.28.2": {
+ "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==",
+ "dependencies": [
+ "@babel/template",
+ "@babel/types"
+ ]
+ },
+ "@babel/parser@7.28.0": {
+ "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
+ "dependencies": [
+ "@babel/types"
+ ],
+ "bin": true
+ },
+ "@babel/plugin-transform-react-jsx-self@7.27.1_@babel+core@7.28.0": {
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dependencies": [
+ "@babel/core",
+ "@babel/helper-plugin-utils"
+ ]
+ },
+ "@babel/plugin-transform-react-jsx-source@7.27.1_@babel+core@7.28.0": {
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dependencies": [
+ "@babel/core",
+ "@babel/helper-plugin-utils"
+ ]
+ },
+ "@babel/template@7.27.2": {
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "dependencies": [
+ "@babel/code-frame",
+ "@babel/parser",
+ "@babel/types"
+ ]
+ },
+ "@babel/traverse@7.28.0": {
+ "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
+ "dependencies": [
+ "@babel/code-frame",
+ "@babel/generator",
+ "@babel/helper-globals",
+ "@babel/parser",
+ "@babel/template",
+ "@babel/types",
+ "debug"
+ ]
+ },
+ "@babel/types@7.28.2": {
+ "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
+ "dependencies": [
+ "@babel/helper-string-parser",
+ "@babel/helper-validator-identifier"
+ ]
+ },
+ "@esbuild/aix-ppc64@0.25.8": {
+ "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
+ "os": ["aix"],
+ "cpu": ["ppc64"]
+ },
+ "@esbuild/android-arm64@0.25.8": {
+ "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
+ "os": ["android"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/android-arm@0.25.8": {
+ "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
+ "os": ["android"],
+ "cpu": ["arm"]
+ },
+ "@esbuild/android-x64@0.25.8": {
+ "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
+ "os": ["android"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/darwin-arm64@0.25.8": {
+ "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/darwin-x64@0.25.8": {
+ "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/freebsd-arm64@0.25.8": {
+ "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
+ "os": ["freebsd"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/freebsd-x64@0.25.8": {
+ "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
+ "os": ["freebsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/linux-arm64@0.25.8": {
+ "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/linux-arm@0.25.8": {
+ "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@esbuild/linux-ia32@0.25.8": {
+ "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
+ "os": ["linux"],
+ "cpu": ["ia32"]
+ },
+ "@esbuild/linux-loong64@0.25.8": {
+ "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
+ "os": ["linux"],
+ "cpu": ["loong64"]
+ },
+ "@esbuild/linux-mips64el@0.25.8": {
+ "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
+ "os": ["linux"],
+ "cpu": ["mips64el"]
+ },
+ "@esbuild/linux-ppc64@0.25.8": {
+ "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
+ "os": ["linux"],
+ "cpu": ["ppc64"]
+ },
+ "@esbuild/linux-riscv64@0.25.8": {
+ "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@esbuild/linux-s390x@0.25.8": {
+ "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
+ "os": ["linux"],
+ "cpu": ["s390x"]
+ },
+ "@esbuild/linux-x64@0.25.8": {
+ "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/netbsd-arm64@0.25.8": {
+ "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
+ "os": ["netbsd"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/netbsd-x64@0.25.8": {
+ "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
+ "os": ["netbsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/openbsd-arm64@0.25.8": {
+ "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
+ "os": ["openbsd"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/openbsd-x64@0.25.8": {
+ "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
+ "os": ["openbsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/openharmony-arm64@0.25.8": {
+ "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
+ "os": ["openharmony"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/sunos-x64@0.25.8": {
+ "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
+ "os": ["sunos"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/win32-arm64@0.25.8": {
+ "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
+ "os": ["win32"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/win32-ia32@0.25.8": {
+ "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
+ "os": ["win32"],
+ "cpu": ["ia32"]
+ },
+ "@esbuild/win32-x64@0.25.8": {
+ "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
+ "@eslint-community/eslint-utils@4.7.0_eslint@9.33.0": {
+ "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"dependencies": [
"eslint",
"eslint-visitor-keys@3.4.3"
@@ -20,25 +323,19 @@
"@eslint-community/regexpp@4.12.1": {
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="
},
- "@eslint/config-array@0.20.0": {
- "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
+ "@eslint/config-array@0.21.0": {
+ "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dependencies": [
"@eslint/object-schema",
"debug",
"minimatch@3.1.2"
]
},
- "@eslint/config-helpers@0.2.1": {
- "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw=="
+ "@eslint/config-helpers@0.3.1": {
+ "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA=="
},
- "@eslint/core@0.12.0": {
- "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
- "dependencies": [
- "@types/json-schema"
- ]
- },
- "@eslint/core@0.13.0": {
- "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
+ "@eslint/core@0.15.2": {
+ "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dependencies": [
"@types/json-schema"
]
@@ -49,24 +346,24 @@
"ajv",
"debug",
"espree",
- "globals",
- "ignore",
+ "globals@14.0.0",
+ "ignore@5.3.2",
"import-fresh",
"js-yaml",
"minimatch@3.1.2",
"strip-json-comments"
]
},
- "@eslint/js@9.24.0": {
- "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA=="
+ "@eslint/js@9.33.0": {
+ "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A=="
},
"@eslint/object-schema@2.1.6": {
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="
},
- "@eslint/plugin-kit@0.2.8": {
- "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==",
+ "@eslint/plugin-kit@0.3.5": {
+ "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dependencies": [
- "@eslint/core@0.13.0",
+ "@eslint/core",
"levn"
]
},
@@ -86,35 +383,105 @@
"@humanwhocodes/retry@0.3.1": {
"integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="
},
- "@humanwhocodes/retry@0.4.2": {
- "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ=="
+ "@humanwhocodes/retry@0.4.3": {
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="
},
- "@jsr/std__assert@1.0.12": {
- "integrity": "sha512-9pmgjJhuljZCmLlbvsRV6aLT5+YCmhX/yIjaWYav7R7Vup2DOLAgpUOs4JkzRbwn7fdKYrwHT8+DjqPr7Ti8mg==",
+ "@jridgewell/gen-mapping@0.3.12": {
+ "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
+ "dependencies": [
+ "@jridgewell/sourcemap-codec",
+ "@jridgewell/trace-mapping"
+ ]
+ },
+ "@jridgewell/resolve-uri@3.1.2": {
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="
+ },
+ "@jridgewell/sourcemap-codec@1.5.4": {
+ "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="
+ },
+ "@jridgewell/trace-mapping@0.3.29": {
+ "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
+ "dependencies": [
+ "@jridgewell/resolve-uri",
+ "@jridgewell/sourcemap-codec"
+ ]
+ },
+ "@jsr/denosaurs__plug@1.1.0": {
+ "integrity": "sha512-GNRMr8XcYWbv8C1B5OjDa5u8q3p2lz7YVWQLhH5HAy0pkpb0+Y3npSxzjM49v5ajTFIzUCwIKv1gQukPm9q7qw==",
+ "dependencies": [
+ "@jsr/std__encoding",
+ "@jsr/std__fmt",
+ "@jsr/std__fs",
+ "@jsr/std__path"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/denosaurs__plug/1.1.0.tgz"
+ },
+ "@jsr/felix__bcrypt@1.0.5": {
+ "integrity": "sha512-XJAQ+NIs23r5YNUgFMtMbl6lhzn/Ms2x1fDO5qJdcVwHKcTFebc5ZH/EQlJss/YfVYdYC6Ng6QQQLzjhrLD/aw==",
+ "dependencies": [
+ "@jsr/denosaurs__plug"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/felix__bcrypt/1.0.5.tgz"
+ },
+ "@jsr/std__assert@1.0.13": {
+ "integrity": "sha512-rZ44REoi2/p+gqu8OfkcNeaTOSiG1kD6v8gyA0YjkXsOkDsiGw9g8h7JuGC/OD7GgOVgTEY+9Cih49Y18rkrCQ==",
"dependencies": [
"@jsr/std__internal"
- ]
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.13.tgz"
},
- "@jsr/std__async@1.0.12": {
- "integrity": "sha512-NUaSOcwMetVeVkIqet2Ammy2A5YxG8ViFxryBbTaC4h7l/cgAkU59U3zF58ek4Y8HZ0Nx5De7qBptPfp62kcgw=="
+ "@jsr/std__async@1.0.14": {
+ "integrity": "sha512-aIG8W3TOmW+lKdAJA5w56qASu9EiUmBXbhW6eAlSEUBid+KVESGqQygFFg+awt/c8K+qobVM6M/u3SbIy0NyUQ==",
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__async/1.0.14.tgz"
},
- "@jsr/std__data-structures@1.0.6": {
- "integrity": "sha512-Ejc8mHLuoYxXLu2zPquvqijdgQ19OV+1DdVDrLc/Cg+tiuGh4Dq2FSnLiPINh4lO1AJ3XcZcYPx38RxdsZcCOg=="
+ "@jsr/std__cli@1.0.21": {
+ "integrity": "sha512-sx/iCW12GUITEkiNmdj7LbM6q/oWq9JoHz24Q/VxPMlLSXKeS5y7teBEDbWSqxFGIevKfgYJYlsbcHWNumd7fw==",
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__cli/1.0.21.tgz"
},
- "@jsr/std__fs@1.0.16": {
- "integrity": "sha512-xnqp8XqEFN+ttkERg9GG+AxyipSd+rfCquLPviF5ZSwN6oCV1TM0ZNoKHXNk/EJAsz28YjF4sfgdJt8XwTV2UQ==",
+ "@jsr/std__data-structures@1.0.9": {
+ "integrity": "sha512-+mT4Nll6fx+CPNqrlC+huhIOYNSMS+KUdJ4B8NujiQrh/bq++ds5PXpEsfV5EPR+YuWcuDGG0P1DE+Rednd7Wg==",
"dependencies": [
+ "@jsr/std__assert"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__data-structures/1.0.9.tgz"
+ },
+ "@jsr/std__dotenv@0.225.5": {
+ "integrity": "sha512-qrBt3wfQgvXbjo+Up6lyzBGxk0IPhDqW9Jx7CJQUQpsxqhoqnBmD8gn0Mt8i+RHHI9uZFCO+FP122ClAC8yljg==",
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__dotenv/0.225.5.tgz"
+ },
+ "@jsr/std__encoding@1.0.10": {
+ "integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw==",
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz"
+ },
+ "@jsr/std__fmt@1.0.8": {
+ "integrity": "sha512-miZHzj9OgjuajrcMKzpqNVwFb9O71UHZzV/FHVq0E0Uwmv/1JqXgmXAoBNPrn+MP0fHT3mMgaZ6XvQO7dam67Q==",
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.8.tgz"
+ },
+ "@jsr/std__fs@1.0.19": {
+ "integrity": "sha512-TEjyE8g+46jPlu7dJHLrwc8NMGl8zfG+JjWxyNQyDbxP0RtqZ4JmYZfR9vy4RWYWJQbLpw6Kbt2n+K/2zAO/JA==",
+ "dependencies": [
+ "@jsr/std__internal",
"@jsr/std__path"
- ]
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.19.tgz"
},
- "@jsr/std__internal@1.0.6": {
- "integrity": "sha512-1NLtCx9XAL44nt56gzmRSCgXjIthHVzK62fTkJdq8/XsP7eN9a21AZDpc0EGJ/cgvmmOB52UGh46OuKrrY7eVg=="
+ "@jsr/std__internal@1.0.10": {
+ "integrity": "sha512-fmD6yKep/sMnB2yPQU/REZG7Z4N9SZwcUBNnceo4QkXk67l3JEfxHoROQ/YHeVSOmq6x55Ra6nuMjz2ib3nj3g==",
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.10.tgz"
},
- "@jsr/std__path@1.0.8": {
- "integrity": "sha512-eNBGlh/8ZVkMxtFH4bwIzlAeKoHYk5in4wrBZhi20zMdOiuX4QozP4+19mIXBT2lzHDjhuVLyECbhFeR304iDg=="
+ "@jsr/std__net@1.0.4": {
+ "integrity": "sha512-KJGU8ZpQ70sMW2Zk+wU3wFUkggS9lTLfRFBygnV9VaK8KI+1ggiqtB06rH4a14CNRGM9y46Mn/ZCbQUd4Q45Jg==",
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__net/1.0.4.tgz"
},
- "@jsr/std__testing@1.0.11": {
- "integrity": "sha512-pqQDYtIsaDf+x4NHQ+WiixRJ8DfhgFQRdlHWWssFAzIYwleR+VHLTNlgsgg+AH3mIIR+gTkBmKk21hTkM/WbMQ==",
+ "@jsr/std__path@1.1.1": {
+ "integrity": "sha512-+x5LgcNUSpMzOZIRmFSjqrMTCxHlgXjWzK8ZFr7lwgHfWZxoVXeis3MFQlkR5mN5uQ61Y1P30Li1PU0yx9uluA==",
+ "dependencies": [
+ "@jsr/std__internal"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.1.tgz"
+ },
+ "@jsr/std__testing@1.0.15": {
+ "integrity": "sha512-NgQuXxTEG4ecbh2fzYbkJWJoBgPXwbv6bdsrAYSOeLpX2d+TROEzpErbWQXHi/yxZy/FNn9IF548ZDAqMZxi/g==",
"dependencies": [
"@jsr/std__assert",
"@jsr/std__async",
@@ -122,6 +489,55 @@
"@jsr/std__fs",
"@jsr/std__internal",
"@jsr/std__path"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.15.tgz"
+ },
+ "@jsr/valkyr__auth@2.0.2": {
+ "integrity": "sha512-wxSWL0BUTXeVamCcpSYoMFceUMl/IKa/52aFtbtvMaprZiS6e4JHHU/tsFR72RjHn8RBGFLRnS/ttBIZlQM/Yg==",
+ "dependencies": [
+ "jose",
+ "zod@3.25.0-beta.20250519T094321"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__auth/2.0.2.tgz"
+ },
+ "@jsr/valkyr__event-emitter@1.0.1": {
+ "integrity": "sha512-mre5tWJddz8LylSQWuLOw3zgIxd2JmhGRV46jKXNPCGzY2NKJwGGT9H7SBw36RV4dW7jnnH2U1aCJkh8IS/pzA==",
+ "dependencies": [
+ "eventemitter3"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-emitter/1.0.1.tgz"
+ },
+ "@jsr/valkyr__event-store@2.0.0-beta.5": {
+ "integrity": "sha512-+xScdSFcIXbQUSofgQJJUdwJWssRzu42oHm8acsmbIStmYa0docCFTPtUQlUrRewND4lmFXvMlidsTb4tS7jww==",
+ "dependencies": [
+ "@jsr/valkyr__testcontainers",
+ "@valkyr/db",
+ "mongodb",
+ "nanoid@5.1.5",
+ "postgres",
+ "zod@4.0.17"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-store/2.0.0-beta.5.tgz"
+ },
+ "@jsr/valkyr__inverse@1.0.1": {
+ "integrity": "sha512-uZpzPct9FGobgl6H+iR3VJlzZbTFVmJSrB4z5In8zHgIJCkmgYj0diU3soU6MuiKR7SFBfD4PGSuUpTTJHNMlg==",
+ "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__inverse/1.0.1.tgz"
+ },
+ "@jsr/valkyr__testcontainers@2.0.2": {
+ "integrity": "sha512-YnmfraYFr3msoUGrIFeElm03nbQqXOaPu0QUT6JI3w6/mIYpVfzPxghkB7gn2RIc81QgrqjwKJE/AL3dltlR1w==",
+ "dependencies": [
+ "@jsr/std__async",
+ "@jsr/std__fs",
+ "@jsr/std__net",
+ "mongodb",
+ "postgres"
+ ],
+ "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__testcontainers/2.0.2.tgz"
+ },
+ "@mongodb-js/saslprep@1.3.0": {
+ "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==",
+ "dependencies": [
+ "sparse-bitfield"
]
},
"@nodelib/fs.scandir@2.1.5": {
@@ -141,14 +557,223 @@
"fastq"
]
},
- "@types/estree@1.0.7": {
- "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
+ "@rolldown/pluginutils@1.0.0-beta.27": {
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="
+ },
+ "@rollup/rollup-android-arm-eabi@4.46.2": {
+ "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
+ "os": ["android"],
+ "cpu": ["arm"]
+ },
+ "@rollup/rollup-android-arm64@4.46.2": {
+ "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==",
+ "os": ["android"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-darwin-arm64@4.46.2": {
+ "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==",
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-darwin-x64@4.46.2": {
+ "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==",
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-freebsd-arm64@4.46.2": {
+ "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==",
+ "os": ["freebsd"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-freebsd-x64@4.46.2": {
+ "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==",
+ "os": ["freebsd"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-linux-arm-gnueabihf@4.46.2": {
+ "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==",
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@rollup/rollup-linux-arm-musleabihf@4.46.2": {
+ "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==",
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@rollup/rollup-linux-arm64-gnu@4.46.2": {
+ "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-linux-arm64-musl@4.46.2": {
+ "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-linux-loongarch64-gnu@4.46.2": {
+ "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==",
+ "os": ["linux"],
+ "cpu": ["loong64"]
+ },
+ "@rollup/rollup-linux-ppc64-gnu@4.46.2": {
+ "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==",
+ "os": ["linux"],
+ "cpu": ["ppc64"]
+ },
+ "@rollup/rollup-linux-riscv64-gnu@4.46.2": {
+ "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==",
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@rollup/rollup-linux-riscv64-musl@4.46.2": {
+ "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==",
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@rollup/rollup-linux-s390x-gnu@4.46.2": {
+ "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==",
+ "os": ["linux"],
+ "cpu": ["s390x"]
+ },
+ "@rollup/rollup-linux-x64-gnu@4.46.2": {
+ "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-linux-x64-musl@4.46.2": {
+ "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-win32-arm64-msvc@4.46.2": {
+ "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==",
+ "os": ["win32"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-win32-ia32-msvc@4.46.2": {
+ "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==",
+ "os": ["win32"],
+ "cpu": ["ia32"]
+ },
+ "@rollup/rollup-win32-x64-msvc@4.46.2": {
+ "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
+ "@tanstack/history@1.131.2": {
+ "integrity": "sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw=="
+ },
+ "@tanstack/query-core@5.83.1": {
+ "integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q=="
+ },
+ "@tanstack/react-query@5.84.2_react@19.1.1": {
+ "integrity": "sha512-cZadySzROlD2+o8zIfbD978p0IphuQzRWiiH3I2ugnTmz4jbjc0+TdibpwqxlzynEen8OulgAg+rzdNF37s7XQ==",
+ "dependencies": [
+ "@tanstack/query-core",
+ "react"
+ ]
+ },
+ "@tanstack/react-router@1.131.5_react@19.1.1_react-dom@19.1.1__react@19.1.1": {
+ "integrity": "sha512-71suJGuCmrHN9PLLRUDB3CGnW5RNcEEfgfX616TOpKamHs977H8P4/75BgWPRWcLHCga/1kkA6c7bddCwZ35Fw==",
+ "dependencies": [
+ "@tanstack/history",
+ "@tanstack/react-store",
+ "@tanstack/router-core",
+ "isbot",
+ "react",
+ "react-dom",
+ "tiny-invariant",
+ "tiny-warning"
+ ]
+ },
+ "@tanstack/react-store@0.7.3_react@19.1.1_react-dom@19.1.1__react@19.1.1": {
+ "integrity": "sha512-3Dnqtbw9P2P0gw8uUM8WP2fFfg8XMDSZCTsywRPZe/XqqYW8PGkXKZTvP0AHkE4mpqP9Y43GpOg9vwO44azu6Q==",
+ "dependencies": [
+ "@tanstack/store",
+ "react",
+ "react-dom",
+ "use-sync-external-store"
+ ]
+ },
+ "@tanstack/router-core@1.131.5_seroval@1.3.2": {
+ "integrity": "sha512-XVfZdnKNQbWfkQ6G7I9ml2wHp98Wy7wgTboP5SfrJHfOE+kPeHeZRJqF/pp5oqLZ2feBJqsDDKNWo9323L7sWQ==",
+ "dependencies": [
+ "@tanstack/history",
+ "@tanstack/store",
+ "cookie-es",
+ "seroval",
+ "seroval-plugins",
+ "tiny-invariant",
+ "tiny-warning"
+ ]
+ },
+ "@tanstack/store@0.7.2": {
+ "integrity": "sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg=="
+ },
+ "@types/babel__core@7.20.5": {
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dependencies": [
+ "@babel/parser",
+ "@babel/types",
+ "@types/babel__generator",
+ "@types/babel__template",
+ "@types/babel__traverse"
+ ]
+ },
+ "@types/babel__generator@7.27.0": {
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dependencies": [
+ "@babel/types"
+ ]
+ },
+ "@types/babel__template@7.4.4": {
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dependencies": [
+ "@babel/parser",
+ "@babel/types"
+ ]
+ },
+ "@types/babel__traverse@7.28.0": {
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dependencies": [
+ "@babel/types"
+ ]
+ },
+ "@types/estree@1.0.8": {
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
},
"@types/json-schema@7.0.15": {
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
- "@typescript-eslint/eslint-plugin@8.30.1_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3_eslint@9.24.0_typescript@5.8.3": {
- "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==",
+ "@types/node@22.15.15": {
+ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
+ "dependencies": [
+ "undici-types"
+ ]
+ },
+ "@types/react-dom@19.1.7_@types+react@19.1.9": {
+ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
+ "dependencies": [
+ "@types/react"
+ ]
+ },
+ "@types/react@19.1.9": {
+ "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
+ "dependencies": [
+ "csstype"
+ ]
+ },
+ "@types/webidl-conversions@7.0.3": {
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
+ },
+ "@types/whatwg-url@11.0.5": {
+ "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
+ "dependencies": [
+ "@types/webidl-conversions"
+ ]
+ },
+ "@typescript-eslint/eslint-plugin@8.39.0_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2_eslint@9.33.0_typescript@5.9.2": {
+ "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==",
"dependencies": [
"@eslint-community/regexpp",
"@typescript-eslint/parser",
@@ -158,14 +783,14 @@
"@typescript-eslint/visitor-keys",
"eslint",
"graphemer",
- "ignore",
+ "ignore@7.0.5",
"natural-compare",
"ts-api-utils",
"typescript"
]
},
- "@typescript-eslint/parser@8.30.1_eslint@9.24.0_typescript@5.8.3": {
- "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
+ "@typescript-eslint/parser@8.39.0_eslint@9.33.0_typescript@5.9.2": {
+ "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==",
"dependencies": [
"@typescript-eslint/scope-manager",
"@typescript-eslint/types",
@@ -176,16 +801,32 @@
"typescript"
]
},
- "@typescript-eslint/scope-manager@8.30.1": {
- "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==",
+ "@typescript-eslint/project-service@8.39.0_typescript@5.9.2": {
+ "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==",
+ "dependencies": [
+ "@typescript-eslint/tsconfig-utils",
+ "@typescript-eslint/types",
+ "debug",
+ "typescript"
+ ]
+ },
+ "@typescript-eslint/scope-manager@8.39.0": {
+ "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/visitor-keys"
]
},
- "@typescript-eslint/type-utils@8.30.1_eslint@9.24.0_typescript@5.8.3": {
- "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==",
+ "@typescript-eslint/tsconfig-utils@8.39.0_typescript@5.9.2": {
+ "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==",
"dependencies": [
+ "typescript"
+ ]
+ },
+ "@typescript-eslint/type-utils@8.39.0_eslint@9.33.0_typescript@5.9.2": {
+ "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==",
+ "dependencies": [
+ "@typescript-eslint/types",
"@typescript-eslint/typescript-estree",
"@typescript-eslint/utils",
"debug",
@@ -194,25 +835,27 @@
"typescript"
]
},
- "@typescript-eslint/types@8.30.1": {
- "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw=="
+ "@typescript-eslint/types@8.39.0": {
+ "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="
},
- "@typescript-eslint/typescript-estree@8.30.1_typescript@5.8.3": {
- "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==",
+ "@typescript-eslint/typescript-estree@8.39.0_typescript@5.9.2": {
+ "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==",
"dependencies": [
+ "@typescript-eslint/project-service",
+ "@typescript-eslint/tsconfig-utils",
"@typescript-eslint/types",
"@typescript-eslint/visitor-keys",
"debug",
"fast-glob",
"is-glob",
"minimatch@9.0.5",
- "semver",
+ "semver@7.7.2",
"ts-api-utils",
"typescript"
]
},
- "@typescript-eslint/utils@8.30.1_eslint@9.24.0_typescript@5.8.3": {
- "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==",
+ "@typescript-eslint/utils@8.39.0_eslint@9.33.0_typescript@5.9.2": {
+ "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==",
"dependencies": [
"@eslint-community/eslint-utils",
"@typescript-eslint/scope-manager",
@@ -222,24 +865,58 @@
"typescript"
]
},
- "@typescript-eslint/visitor-keys@8.30.1": {
- "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==",
+ "@typescript-eslint/visitor-keys@8.39.0": {
+ "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==",
"dependencies": [
"@typescript-eslint/types",
- "eslint-visitor-keys@4.2.0"
+ "eslint-visitor-keys@4.2.1"
]
},
- "@zod/core@0.6.2": {
- "integrity": "sha512-KdH7bT0BRG1CvJ1LWH8oyNnkvLpjVZ5qVGpRu7Vq8WsFTKRDWfdr3rFfBYh8atZJSWDgD0ibhOyff1AyRvG1DA=="
+ "@valkyr/db@1.0.1": {
+ "integrity": "sha512-zOvf0jbTSOtjzAgWKeD6S3/QQdtodPy+LkxfnhoggOzYhthkmZ1A8SauucFgkvIrzEp8e3IfNBHy0qQUHJRTog==",
+ "dependencies": [
+ "dot-prop",
+ "fast-equals@5.0.1",
+ "idb",
+ "mingo",
+ "nanoid@5.0.2",
+ "rfdc",
+ "rxjs"
+ ]
},
- "acorn-jsx@5.3.2_acorn@8.14.1": {
+ "@vitejs/plugin-react@4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0": {
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dependencies": [
+ "@babel/core",
+ "@babel/plugin-transform-react-jsx-self",
+ "@babel/plugin-transform-react-jsx-source",
+ "@rolldown/pluginutils",
+ "@types/babel__core",
+ "react-refresh",
+ "vite@7.1.1_picomatch@4.0.3"
+ ]
+ },
+ "@vitejs/plugin-react@4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0_@types+node@22.15.15": {
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dependencies": [
+ "@babel/core",
+ "@babel/plugin-transform-react-jsx-self",
+ "@babel/plugin-transform-react-jsx-source",
+ "@rolldown/pluginutils",
+ "@types/babel__core",
+ "react-refresh",
+ "vite@7.1.1_picomatch@4.0.3_@types+node@22.15.15"
+ ]
+ },
+ "acorn-jsx@5.3.2_acorn@8.15.0": {
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dependencies": [
"acorn"
]
},
- "acorn@8.14.1": {
- "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="
+ "acorn@8.15.0": {
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "bin": true
},
"ajv@6.12.6": {
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
@@ -262,15 +939,15 @@
"balanced-match@1.0.2": {
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
- "brace-expansion@1.1.11": {
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "brace-expansion@1.1.12": {
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dependencies": [
"balanced-match",
"concat-map"
]
},
- "brace-expansion@2.0.1": {
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "brace-expansion@2.0.2": {
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": [
"balanced-match"
]
@@ -281,9 +958,25 @@
"fill-range"
]
},
+ "browserslist@4.25.2": {
+ "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
+ "dependencies": [
+ "caniuse-lite",
+ "electron-to-chromium",
+ "node-releases",
+ "update-browserslist-db"
+ ],
+ "bin": true
+ },
+ "bson@6.10.4": {
+ "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="
+ },
"callsites@3.1.0": {
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
},
+ "caniuse-lite@1.0.30001734": {
+ "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A=="
+ },
"chalk@4.1.2": {
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": [
@@ -303,6 +996,15 @@
"concat-map@0.0.1": {
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
+ "convert-source-map@2.0.0": {
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
+ },
+ "cookie-es@1.2.2": {
+ "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="
+ },
+ "cookie@1.0.2": {
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="
+ },
"cross-spawn@7.0.6": {
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dependencies": [
@@ -311,8 +1013,11 @@
"which"
]
},
- "debug@4.4.0": {
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "csstype@3.1.3": {
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ },
+ "debug@4.4.1": {
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dependencies": [
"ms"
]
@@ -320,17 +1025,74 @@
"deep-is@0.1.4": {
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
+ "dot-prop@8.0.2": {
+ "integrity": "sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ==",
+ "dependencies": [
+ "type-fest"
+ ]
+ },
+ "electron-to-chromium@1.5.199": {
+ "integrity": "sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ=="
+ },
+ "esbuild@0.25.8": {
+ "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
+ "optionalDependencies": [
+ "@esbuild/aix-ppc64",
+ "@esbuild/android-arm",
+ "@esbuild/android-arm64",
+ "@esbuild/android-x64",
+ "@esbuild/darwin-arm64",
+ "@esbuild/darwin-x64",
+ "@esbuild/freebsd-arm64",
+ "@esbuild/freebsd-x64",
+ "@esbuild/linux-arm",
+ "@esbuild/linux-arm64",
+ "@esbuild/linux-ia32",
+ "@esbuild/linux-loong64",
+ "@esbuild/linux-mips64el",
+ "@esbuild/linux-ppc64",
+ "@esbuild/linux-riscv64",
+ "@esbuild/linux-s390x",
+ "@esbuild/linux-x64",
+ "@esbuild/netbsd-arm64",
+ "@esbuild/netbsd-x64",
+ "@esbuild/openbsd-arm64",
+ "@esbuild/openbsd-x64",
+ "@esbuild/openharmony-arm64",
+ "@esbuild/sunos-x64",
+ "@esbuild/win32-arm64",
+ "@esbuild/win32-ia32",
+ "@esbuild/win32-x64"
+ ],
+ "scripts": true,
+ "bin": true
+ },
+ "escalade@3.2.0": {
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
+ },
"escape-string-regexp@4.0.0": {
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
},
- "eslint-plugin-simple-import-sort@12.1.1_eslint@9.24.0": {
+ "eslint-plugin-react-hooks@5.2.0_eslint@9.33.0": {
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dependencies": [
+ "eslint"
+ ]
+ },
+ "eslint-plugin-react-refresh@0.4.20_eslint@9.33.0": {
+ "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
+ "dependencies": [
+ "eslint"
+ ]
+ },
+ "eslint-plugin-simple-import-sort@12.1.1_eslint@9.33.0": {
"integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==",
"dependencies": [
"eslint"
]
},
- "eslint-scope@8.3.0": {
- "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
+ "eslint-scope@8.4.0": {
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dependencies": [
"esrecurse",
"estraverse"
@@ -339,23 +1101,23 @@
"eslint-visitor-keys@3.4.3": {
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="
},
- "eslint-visitor-keys@4.2.0": {
- "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="
+ "eslint-visitor-keys@4.2.1": {
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="
},
- "eslint@9.24.0": {
- "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
+ "eslint@9.33.0": {
+ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
"dependencies": [
"@eslint-community/eslint-utils",
"@eslint-community/regexpp",
"@eslint/config-array",
"@eslint/config-helpers",
- "@eslint/core@0.12.0",
+ "@eslint/core",
"@eslint/eslintrc",
"@eslint/js",
"@eslint/plugin-kit",
"@humanfs/node",
"@humanwhocodes/module-importer",
- "@humanwhocodes/retry@0.4.2",
+ "@humanwhocodes/retry@0.4.3",
"@types/estree",
"@types/json-schema",
"ajv",
@@ -364,7 +1126,7 @@
"debug",
"escape-string-regexp",
"eslint-scope",
- "eslint-visitor-keys@4.2.0",
+ "eslint-visitor-keys@4.2.1",
"espree",
"esquery",
"esutils",
@@ -372,7 +1134,7 @@
"file-entry-cache",
"find-up",
"glob-parent@6.0.2",
- "ignore",
+ "ignore@5.3.2",
"imurmurhash",
"is-glob",
"json-stable-stringify-without-jsonify",
@@ -380,14 +1142,15 @@
"minimatch@3.1.2",
"natural-compare",
"optionator"
- ]
+ ],
+ "bin": true
},
- "espree@10.3.0_acorn@8.14.1": {
- "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
+ "espree@10.4.0_acorn@8.15.0": {
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dependencies": [
"acorn",
"acorn-jsx",
- "eslint-visitor-keys@4.2.0"
+ "eslint-visitor-keys@4.2.1"
]
},
"esquery@1.6.0": {
@@ -408,9 +1171,18 @@
"esutils@2.0.3": {
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
+ "eventemitter3@5.0.1": {
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
+ },
"fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
+ "fast-equals@5.0.1": {
+ "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ=="
+ },
+ "fast-equals@5.2.2": {
+ "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="
+ },
"fast-glob@3.3.3": {
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dependencies": [
@@ -433,6 +1205,15 @@
"reusify"
]
},
+ "fdir@6.4.6_picomatch@4.0.3": {
+ "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+ "dependencies": [
+ "picomatch@4.0.3"
+ ],
+ "optionalPeers": [
+ "picomatch@4.0.3"
+ ]
+ },
"file-entry-cache@8.0.0": {
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dependencies": [
@@ -462,6 +1243,14 @@
"flatted@3.3.3": {
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="
},
+ "fsevents@2.3.3": {
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "os": ["darwin"],
+ "scripts": true
+ },
+ "gensync@1.0.0-beta.2": {
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="
+ },
"glob-parent@5.1.2": {
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": [
@@ -477,15 +1266,24 @@
"globals@14.0.0": {
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="
},
+ "globals@16.3.0": {
+ "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="
+ },
"graphemer@1.4.0": {
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
},
"has-flag@4.0.0": {
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
+ "idb@7.1.1": {
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="
+ },
"ignore@5.3.2": {
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
},
+ "ignore@7.0.5": {
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="
+ },
"import-fresh@3.3.1": {
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dependencies": [
@@ -508,14 +1306,28 @@
"is-number@7.0.0": {
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
+ "isbot@5.1.29": {
+ "integrity": "sha512-DelDWWoa3mBoyWTq3wjp+GIWx/yZdN7zLUE7NFhKjAiJ+uJVRkbLlwykdduCE4sPUUy8mlTYTmdhBUYu91F+sw=="
+ },
"isexe@2.0.0": {
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
+ "jose@6.0.10": {
+ "integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw=="
+ },
+ "js-tokens@4.0.0": {
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+ },
"js-yaml@4.1.0": {
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dependencies": [
"argparse"
- ]
+ ],
+ "bin": true
+ },
+ "jsesc@3.1.0": {
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "bin": true
},
"json-buffer@3.0.1": {
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
@@ -526,6 +1338,10 @@
"json-stable-stringify-without-jsonify@1.0.1": {
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
},
+ "json5@2.2.3": {
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "bin": true
+ },
"keyv@4.5.4": {
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dependencies": [
@@ -548,6 +1364,15 @@
"lodash.merge@4.6.2": {
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
+ "lru-cache@5.1.1": {
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dependencies": [
+ "yallist"
+ ]
+ },
+ "memory-pager@1.5.0": {
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
+ },
"merge2@1.4.1": {
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
},
@@ -555,27 +1380,60 @@
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": [
"braces",
- "picomatch"
+ "picomatch@2.3.1"
]
},
+ "mingo@6.4.6": {
+ "integrity": "sha512-SMp06Eo5iEthCPpKXgEZ6DTZKxknpTqj49YN6iHpapj9DKltBCv0RFu+0mBBjMU0SiHR9pYkurkk74+VFGTqxw=="
+ },
"minimatch@3.1.2": {
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": [
- "brace-expansion@1.1.11"
+ "brace-expansion@1.1.12"
]
},
"minimatch@9.0.5": {
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": [
- "brace-expansion@2.0.1"
+ "brace-expansion@2.0.2"
+ ]
+ },
+ "mongodb-connection-string-url@3.0.2": {
+ "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
+ "dependencies": [
+ "@types/whatwg-url",
+ "whatwg-url"
+ ]
+ },
+ "mongodb@6.18.0": {
+ "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==",
+ "dependencies": [
+ "@mongodb-js/saslprep",
+ "bson",
+ "mongodb-connection-string-url"
]
},
"ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
+ "nanoid@3.3.11": {
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "bin": true
+ },
+ "nanoid@5.0.2": {
+ "integrity": "sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg==",
+ "bin": true
+ },
+ "nanoid@5.1.5": {
+ "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
+ "bin": true
+ },
"natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
+ "node-releases@2.0.19": {
+ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="
+ },
"optionator@0.9.4": {
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dependencies": [
@@ -611,14 +1469,35 @@
"path-key@3.1.1": {
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
},
+ "path-to-regexp@8.2.0": {
+ "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="
+ },
+ "picocolors@1.1.1": {
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+ },
"picomatch@2.3.1": {
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
+ "picomatch@4.0.3": {
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
+ },
+ "postcss@8.5.6": {
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dependencies": [
+ "nanoid@3.3.11",
+ "picocolors",
+ "source-map-js"
+ ]
+ },
+ "postgres@3.4.7": {
+ "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="
+ },
"prelude-ls@1.2.1": {
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
},
- "prettier@3.5.3": {
- "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="
+ "prettier@3.6.2": {
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+ "bin": true
},
"punycode@2.3.1": {
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
@@ -626,20 +1505,89 @@
"queue-microtask@1.2.3": {
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
},
+ "react-dom@19.1.1_react@19.1.1": {
+ "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
+ "dependencies": [
+ "react",
+ "scheduler"
+ ]
+ },
+ "react-refresh@0.17.0": {
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="
+ },
+ "react@19.1.1": {
+ "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="
+ },
"resolve-from@4.0.0": {
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
},
"reusify@1.1.0": {
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
},
+ "rfdc@1.3.0": {
+ "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
+ },
+ "rollup@4.46.2": {
+ "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
+ "dependencies": [
+ "@types/estree"
+ ],
+ "optionalDependencies": [
+ "@rollup/rollup-android-arm-eabi",
+ "@rollup/rollup-android-arm64",
+ "@rollup/rollup-darwin-arm64",
+ "@rollup/rollup-darwin-x64",
+ "@rollup/rollup-freebsd-arm64",
+ "@rollup/rollup-freebsd-x64",
+ "@rollup/rollup-linux-arm-gnueabihf",
+ "@rollup/rollup-linux-arm-musleabihf",
+ "@rollup/rollup-linux-arm64-gnu",
+ "@rollup/rollup-linux-arm64-musl",
+ "@rollup/rollup-linux-loongarch64-gnu",
+ "@rollup/rollup-linux-ppc64-gnu",
+ "@rollup/rollup-linux-riscv64-gnu",
+ "@rollup/rollup-linux-riscv64-musl",
+ "@rollup/rollup-linux-s390x-gnu",
+ "@rollup/rollup-linux-x64-gnu",
+ "@rollup/rollup-linux-x64-musl",
+ "@rollup/rollup-win32-arm64-msvc",
+ "@rollup/rollup-win32-ia32-msvc",
+ "@rollup/rollup-win32-x64-msvc",
+ "fsevents"
+ ],
+ "bin": true
+ },
"run-parallel@1.2.0": {
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dependencies": [
"queue-microtask"
]
},
- "semver@7.7.1": {
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="
+ "rxjs@7.8.1": {
+ "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "scheduler@0.26.0": {
+ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="
+ },
+ "semver@6.3.1": {
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "bin": true
+ },
+ "semver@7.7.2": {
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "bin": true
+ },
+ "seroval-plugins@1.3.2_seroval@1.3.2": {
+ "integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==",
+ "dependencies": [
+ "seroval"
+ ]
+ },
+ "seroval@1.3.2": {
+ "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="
},
"shebang-command@2.0.0": {
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
@@ -650,6 +1598,15 @@
"shebang-regex@3.0.0": {
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
},
+ "source-map-js@1.2.1": {
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
+ },
+ "sparse-bitfield@3.0.3": {
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+ "dependencies": [
+ "memory-pager"
+ ]
+ },
"strip-json-comments@3.1.1": {
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
},
@@ -659,36 +1616,75 @@
"has-flag"
]
},
+ "tiny-invariant@1.3.3": {
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
+ },
+ "tiny-warning@1.0.3": {
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
+ },
+ "tinyglobby@0.2.14_picomatch@4.0.3": {
+ "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
+ "dependencies": [
+ "fdir",
+ "picomatch@4.0.3"
+ ]
+ },
"to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [
"is-number"
]
},
- "ts-api-utils@2.1.0_typescript@5.8.3": {
+ "tr46@5.1.1": {
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dependencies": [
+ "punycode"
+ ]
+ },
+ "ts-api-utils@2.1.0_typescript@5.9.2": {
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"dependencies": [
"typescript"
]
},
+ "tslib@2.8.1": {
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
"type-check@0.4.0": {
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dependencies": [
"prelude-ls"
]
},
- "typescript-eslint@8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3": {
- "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==",
+ "type-fest@3.13.1": {
+ "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="
+ },
+ "typescript-eslint@8.39.0_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2": {
+ "integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==",
"dependencies": [
"@typescript-eslint/eslint-plugin",
"@typescript-eslint/parser",
+ "@typescript-eslint/typescript-estree",
"@typescript-eslint/utils",
"eslint",
"typescript"
]
},
- "typescript@5.8.3": {
- "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="
+ "typescript@5.9.2": {
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+ "bin": true
+ },
+ "undici-types@6.21.0": {
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
+ },
+ "update-browserslist-db@1.1.3_browserslist@4.25.2": {
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "dependencies": [
+ "browserslist",
+ "escalade",
+ "picocolors"
+ ],
+ "bin": true
},
"uri-js@4.4.1": {
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
@@ -696,36 +1692,154 @@
"punycode"
]
},
+ "use-sync-external-store@1.5.0_react@19.1.1": {
+ "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
+ "dependencies": [
+ "react"
+ ]
+ },
+ "vite@7.1.1_picomatch@4.0.3": {
+ "integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==",
+ "dependencies": [
+ "esbuild",
+ "fdir",
+ "picomatch@4.0.3",
+ "postcss",
+ "rollup",
+ "tinyglobby"
+ ],
+ "optionalDependencies": [
+ "fsevents"
+ ],
+ "bin": true
+ },
+ "vite@7.1.1_picomatch@4.0.3_@types+node@22.15.15": {
+ "integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==",
+ "dependencies": [
+ "@types/node",
+ "esbuild",
+ "fdir",
+ "picomatch@4.0.3",
+ "postcss",
+ "rollup",
+ "tinyglobby"
+ ],
+ "optionalDependencies": [
+ "fsevents"
+ ],
+ "optionalPeers": [
+ "@types/node"
+ ],
+ "bin": true
+ },
+ "webidl-conversions@7.0.0": {
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
+ },
+ "whatwg-url@14.2.0": {
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dependencies": [
+ "tr46",
+ "webidl-conversions"
+ ]
+ },
"which@2.0.2": {
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dependencies": [
"isexe"
- ]
+ ],
+ "bin": true
},
"word-wrap@1.2.5": {
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="
},
+ "yallist@3.1.1": {
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
+ },
"yocto-queue@0.1.0": {
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
- "zod@4.0.0-beta.20250417T043022": {
- "integrity": "sha512-zjfYudLXPgHvRdCWzy/iJqhB6suE8tBqnGubbFHSkMvcknI4iexEP53QCO13FoC/EIALseuZReVykCY8yd/skA==",
- "dependencies": [
- "@zod/core"
- ]
+ "zod@3.25.0-beta.20250519T094321": {
+ "integrity": "sha512-FvDMTcBUhM/CZjeT0HJQ8M6KbSGRPHqEx2yLWx9kDU3ufoTiq7tQAI8UyBJ/82CBp1mv6tKVWp00ll6zV/WxmA=="
+ },
+ "zod@4.0.17": {
+ "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="
}
},
"workspace": {
"packageJson": {
"dependencies": [
- "npm:@jsr/std__assert@1.0.12",
- "npm:@jsr/std__testing@1.0.11",
- "npm:eslint-plugin-simple-import-sort@12.1.1",
- "npm:eslint@9.24.0",
- "npm:prettier@3.5.3",
- "npm:typescript-eslint@8.30.1",
- "npm:zod@next"
+ "npm:@jsr/std__assert@1",
+ "npm:@jsr/std__testing@1",
+ "npm:eslint-plugin-simple-import-sort@12",
+ "npm:eslint@9",
+ "npm:prettier@3",
+ "npm:typescript-eslint@8"
]
+ },
+ "members": {
+ "api": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@jsr/felix__bcrypt@1",
+ "npm:@jsr/std__cli@1",
+ "npm:@jsr/std__dotenv@0.225",
+ "npm:@jsr/std__fs@1",
+ "npm:@jsr/std__path@1",
+ "npm:@jsr/valkyr__auth@2",
+ "npm:@jsr/valkyr__event-store@2.0.0-beta.5",
+ "npm:@jsr/valkyr__inverse@1",
+ "npm:cookie@1",
+ "npm:mongodb@6",
+ "npm:zod@4"
+ ]
+ }
+ },
+ "apps/react": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@eslint/js@9",
+ "npm:@jsr/valkyr__event-emitter@1",
+ "npm:@tanstack/react-query@5",
+ "npm:@tanstack/react-router@1",
+ "npm:@types/react-dom@19",
+ "npm:@types/react@19",
+ "npm:@valkyr/db@1",
+ "npm:@vitejs/plugin-react@4",
+ "npm:eslint-plugin-react-hooks@5",
+ "npm:eslint-plugin-react-refresh@0.4",
+ "npm:eslint@9",
+ "npm:fast-equals@5",
+ "npm:globals@16",
+ "npm:react-dom@19",
+ "npm:react@19",
+ "npm:typescript-eslint@8",
+ "npm:typescript@5",
+ "npm:vite@7"
+ ]
+ }
+ },
+ "spec/modules": {
+ "packageJson": {
+ "dependencies": [
+ "npm:zod@4"
+ ]
+ }
+ },
+ "spec/relay": {
+ "packageJson": {
+ "dependencies": [
+ "npm:path-to-regexp@8",
+ "npm:zod@4"
+ ]
+ }
+ },
+ "spec/shared": {
+ "packageJson": {
+ "dependencies": [
+ "npm:zod@4"
+ ]
+ }
+ }
}
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..58c43f8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ mongo:
+ image: mongo:8
+ restart: unless-stopped
+ ports:
+ - "27017:27017"
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: root
+ MONGO_INITDB_ROOT_PASSWORD: password
+ volumes:
+ - ./.volumes/mongo/local:/data/db
+ networks:
+ - localdev
+
+networks:
+ localdev:
+ driver: bridge
diff --git a/eslint.config.mjs b/eslint.config.mjs
index ff5356c..503f24c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -15,16 +15,22 @@ export default [
{
files: ["**/*.ts"],
rules: {
- "@typescript-eslint/ban-ts-comment": ["error", {
- "ts-expect-error": "allow-with-description",
- minimumDescriptionLength: 10,
- }],
+ "@typescript-eslint/ban-ts-comment": [
+ "error",
+ {
+ "ts-expect-error": "allow-with-description",
+ minimumDescriptionLength: 10,
+ },
+ ],
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-explicit-any": "off",
- "@typescript-eslint/no-unused-vars": ["error", {
- argsIgnorePattern: "^_",
- varsIgnorePattern: "^_",
- }],
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ {
+ argsIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ },
+ ],
},
},
];
diff --git a/libraries/action.ts b/libraries/action.ts
deleted file mode 100644
index ef592cf..0000000
--- a/libraries/action.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import z, { ZodObject, ZodRawShape, ZodType } from "zod";
-
-import type { RelayError } from "./errors.ts";
-
-export class Action {
- constructor(readonly state: TActionState) {}
-
- /**
- * Input object required by the action to fulfill its function.
- *
- * @param input - Schema defining the input requirements of the action.
- */
- input(input: TInput): Action & { input: TInput }> {
- return new Action({ ...this.state, input });
- }
-
- /**
- * Output object defining the result shape of the action.
- *
- * @param output - Schema defining the result shape.
- */
- output(output: TOutput): Action & { output: ZodObject }> {
- return new Action({ ...this.state, output: z.object(output) as any });
- }
-
- /**
- * Add handler method to the action.
- *
- * @param handle - Handler method.
- */
- handle>(
- handle: THandleFn,
- ): Action & { handle: THandleFn }> {
- return new Action({ ...this.state, handle });
- }
-}
-
-/*
- |--------------------------------------------------------------------------------
- | Factory
- |--------------------------------------------------------------------------------
- */
-
-export const action: {
- make(name: string): Action;
-} = {
- make(name: string) {
- return new Action({ name });
- },
-};
-
-/*
- |--------------------------------------------------------------------------------
- | Types
- |--------------------------------------------------------------------------------
- */
-
-type ActionState = {
- name: string;
- input?: ZodType;
- output?: ZodObject;
- handle?: ActionHandlerFn;
-};
-
-export type ActionPrepareFn = (
- params: z.infer,
-) => TAction["state"]["input"] extends ZodType ? z.infer : void;
-
-type ActionHandlerFn = TInput extends ZodType
- ? (input: z.infer) => TOutput extends ZodObject ? Promise | RelayError> : Promise
- : () => TOutput extends ZodObject ? Promise | RelayError> : Promise;
diff --git a/libraries/adapter.ts b/libraries/adapter.ts
deleted file mode 100644
index 1c606a8..0000000
--- a/libraries/adapter.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { RelayError } from "./errors.ts";
-import type { RouteMethod } from "./route.ts";
-
-export type RelayAdapter = {
- readonly url: string;
- fetch(input: RelayRESTInput): Promise;
- send(input: RelayProcedureInput): Promise;
-};
-
-export type RelayRESTInput = {
- method: RouteMethod;
- url: string;
- query?: string;
- body?: string;
-};
-
-export type RelayProcedureInput = {
- method: string;
- params: any;
-};
-
-export type RelayProcedureResponse =
- | {
- relay: "1.0";
- result: unknown;
- id: string | number;
- }
- | {
- relay: "1.0";
- error: RelayError;
- id: string | number;
- };
diff --git a/libraries/api.ts b/libraries/api.ts
deleted file mode 100644
index c0975ac..0000000
--- a/libraries/api.ts
+++ /dev/null
@@ -1,434 +0,0 @@
-import z from "zod";
-
-import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts";
-import { Procedure } from "./procedure.ts";
-import { RelayRequest, request } from "./request.ts";
-import { Route, RouteMethod } from "./route.ts";
-
-const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
-
-export class Api {
- readonly #index = {
- rest: new Map(),
- rpc: new Map(),
- };
-
- /**
- * Route maps funneling registered routes to the specific methods supported by
- * the relay instance.
- */
- readonly routes: Routes = {
- POST: [],
- GET: [],
- PUT: [],
- PATCH: [],
- DELETE: [],
- };
-
- /**
- * List of paths in the '${method} ${path}' format allowing us to quickly throw
- * errors if a duplicate route path is being added.
- */
- readonly #paths = new Set();
-
- /**
- * Instantiate a new Server instance.
- *
- * @param routes - Routes to register with the instance.
- */
- constructor(relays: TRelays) {
- const methods: (keyof typeof this.routes)[] = [];
- for (const relay of relays) {
- if (relay instanceof Procedure === true) {
- this.#index.rpc.set(relay.method, relay);
- }
- if (relay instanceof Route === true) {
- this.#validateRoutePath(relay);
- this.routes[relay.method].push(relay);
- methods.push(relay.method);
- this.#index.rest.set(`${relay.method} ${relay.path}`, relay);
- }
- }
- for (const method of methods) {
- this.routes[method].sort(byStaticPriority);
- }
- }
-
- /**
- * Takes a request candidate and parses its json body.
- *
- * @param candidate - Request candidate to parse.
- */
- async parse(candidate: Request): Promise {
- return request.parseAsync(await candidate.json());
- }
-
- /**
- * Handle a incoming REST request.
- *
- * @param request - REST request to pass to a route handler.
- */
- async rest(request: Request): Promise {
- const url = new URL(request.url);
-
- const matched = this.#resolve(request.method, request.url);
- if (matched === undefined) {
- return toRestResponse(
- new NotFoundError(`Invalid routing path provided for ${request.url}`, {
- method: request.method,
- url: request.url,
- }),
- );
- }
-
- const { route, params } = matched;
-
- // ### Context
- // Context is passed to every route handler and provides a suite of functionality
- // and request data.
-
- const context: any[] = [];
-
- // ### Params
- // If the route has params we want to coerce the values to the expected types.
-
- if (route.state.params !== undefined) {
- const result = await route.state.params.safeParseAsync(params);
- if (result.success === false) {
- return toRestResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error)));
- }
- context.push(result.data);
- }
-
- // ### Query
- // If the route has a query schema we need to validate and parse the query.
-
- if (route.state.query !== undefined) {
- const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {});
- if (result.success === false) {
- return toRestResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error)));
- }
- context.push(result.data);
- }
-
- // ### Body
- // If the route has a body schema we need to validate and parse the body.
-
- if (route.state.body !== undefined) {
- let body: Record = {};
- if (request.headers.get("content-type")?.includes("json")) {
- body = await request.json();
- }
- const result = await route.state.body.safeParseAsync(body);
- if (result.success === false) {
- return toRestResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)));
- }
- context.push(result.data);
- }
-
- // ### Actions
- // Run through all assigned actions for the route.
-
- const data: Record = {};
-
- if (route.state.actions !== undefined) {
- for (const entry of route.state.actions) {
- let action = entry;
- let input: any;
-
- if (Array.isArray(entry)) {
- action = entry[0];
- input = entry[1](...context);
- }
-
- const result = (await action.state.input?.safeParseAsync(input)) ?? { success: true, data: {} };
- if (result.success === false) {
- return toRestResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)));
- }
-
- if (action.state.handle === undefined) {
- return toRestResponse(new InternalServerError(`Action '${action.state.name}' is missing handler.`));
- }
-
- const output = await action.state.handle(result.data);
- if (output instanceof RelayError) {
- return toRestResponse(output);
- }
-
- for (const key in output) {
- data[key] = output[key];
- }
- }
- context.push(data);
- }
-
- // ### Handler
- // Execute the route handler and apply the result.
-
- if (route.state.handle === undefined) {
- return toRestResponse(new InternalServerError(`Path '${route.method} ${route.path}' is missing request handler.`));
- }
- return toRestResponse(await route.state.handle(...context).catch((error) => error));
- }
-
- /**
- * Handle a incoming RPC request.
- *
- * @param method - Method name being executed.
- * @param params - Parameters provided with the method request.
- * @param id - Request id used for response identification.
- */
- async rpc({ method, params, id }: RelayRequest): Promise {
- const procedure = this.#index.rpc.get(method);
- if (procedure === undefined) {
- return toResponse(new NotFoundError(`Method '' does not exist`), id);
- }
-
- // ### Context
- // Context is passed to every route handler and provides a suite of functionality
- // and request data.
-
- const args: any[] = [];
-
- // ### Params
- // If the route has a body schema we need to validate and parse the body.
-
- if (procedure.state.params !== undefined) {
- if (params === undefined) {
- return toResponse(new BadRequestError("Procedure expected 'params' but got 'undefined'."), id);
- }
- const result = await procedure.state.params.safeParseAsync(params);
- if (result.success === false) {
- return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)), id);
- }
- args.push(result.data);
- }
-
- // ### Actions
- // Run through all assigned actions for the route.
-
- const data: Record = {};
-
- if (procedure.state.actions !== undefined) {
- for (const entry of procedure.state.actions) {
- let action = entry;
- let input: any;
-
- if (Array.isArray(entry)) {
- action = entry[0];
- input = entry[1](args[0]);
- }
-
- const result = (await action.state.input?.safeParseAsync(input)) ?? { success: true, data: {} };
- if (result.success === false) {
- return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)), id);
- }
-
- if (action.state.handle === undefined) {
- return toResponse(new InternalServerError(`Action '${action.state.name}' is missing handler.`), id);
- }
-
- const output = await action.state.handle(result.data);
- if (output instanceof RelayError) {
- return toResponse(output, id);
- }
-
- for (const key in output) {
- data[key] = output[key];
- }
- }
- args.push(data);
- }
-
- // ### Handler
- // Execute the route handler and apply the result.
-
- if (procedure.state.handle === undefined) {
- return toResponse(new InternalServerError(`Path '${procedure.method}' is missing request handler.`), id);
- }
- return toResponse(await procedure.state.handle(...args).catch((error) => error), id);
- }
-
- /**
- * Attempt to resolve a route based on the given method and pathname.
- *
- * @param method - HTTP method.
- * @param url - HTTP request url.
- */
- #resolve(method: string, url: string): ResolvedRoute | undefined {
- this.#assertMethod(method);
- for (const route of this.routes[method]) {
- if (route.match(url) === true) {
- return { route, params: route.getParsedParams(url) };
- }
- }
- }
-
- #validateRoutePath(route: Route): void {
- const path = `${route.method} ${route.path}`;
- if (this.#paths.has(path)) {
- throw new Error(`Router > Path ${path} already exists`);
- }
- this.#paths.add(path);
- }
-
- #assertMethod(method: string): asserts method is RouteMethod {
- if (!SUPPORTED_MEHODS.includes(method)) {
- throw new Error(`Router > Unsupported method '${method}'`);
- }
- }
-}
-
-/*
- |--------------------------------------------------------------------------------
- | Helpers
- |--------------------------------------------------------------------------------
- */
-
-/**
- * Sorting method for routes to ensure that static properties takes precedence
- * for when a route is matched against incoming requests.
- *
- * @param a - Route A
- * @param b - Route B
- */
-function byStaticPriority(a: Route, b: Route) {
- const aSegments = a.path.split("/");
- const bSegments = b.path.split("/");
-
- const maxLength = Math.max(aSegments.length, bSegments.length);
-
- for (let i = 0; i < maxLength; i++) {
- const aSegment = aSegments[i] || "";
- const bSegment = bSegments[i] || "";
-
- const isADynamic = aSegment.startsWith(":");
- const isBDynamic = bSegment.startsWith(":");
-
- if (isADynamic !== isBDynamic) {
- return isADynamic ? 1 : -1;
- }
-
- if (isADynamic === false && aSegment !== bSegment) {
- return aSegment.localeCompare(bSegment);
- }
- }
-
- return a.path.localeCompare(b.path);
-}
-
-/**
- * Resolve and return query object from the provided search parameters, or undefined
- * if the search parameters does not have any entries.
- *
- * @param searchParams - Search params to create a query object from.
- */
-function toQuery(searchParams: URLSearchParams): object | undefined {
- if (searchParams.size === 0) {
- return undefined;
- }
- const result: Record = {};
- for (const [key, value] of searchParams.entries()) {
- result[key] = value;
- }
- return result;
-}
-
-/**
- * Takes a server side request result and returns a fetch Response.
- *
- * @param result - Result to send back as a Response.
- */
-function toRestResponse(result: object | RelayError | Response | void): Response {
- if (result instanceof Response) {
- return result;
- }
- if (result instanceof RelayError) {
- return new Response(result.message, {
- status: result.status,
- });
- }
- if (result === undefined) {
- return new Response(null, { status: 204 });
- }
- return new Response(JSON.stringify(result), {
- status: 200,
- headers: {
- "content-type": "application/json",
- },
- });
-}
-
-/**
- * Takes a server side request result and returns a fetch Response.
- *
- * @param result - Result to send back as a Response.
- * @param id - Request id which can be used to identify the response.
- */
-export function toResponse(result: object | RelayError | Response | void, id: string | number): Response {
- if (result === undefined) {
- return new Response(
- JSON.stringify({
- relay: "1.0",
- result: null,
- id,
- }),
- {
- status: 200,
- headers: {
- "content-type": "application/json",
- },
- },
- );
- }
- if (result instanceof Response) {
- return result;
- }
- if (result instanceof RelayError) {
- return new Response(
- JSON.stringify({
- relay: "1.0",
- error: result,
- id,
- }),
- {
- status: result.status,
- headers: {
- "content-type": "application/json",
- },
- },
- );
- }
- return new Response(
- JSON.stringify({
- relay: "1.0",
- result,
- id,
- }),
- {
- status: 200,
- headers: {
- "content-type": "application/json",
- },
- },
- );
-}
-
-/*
- |--------------------------------------------------------------------------------
- | Types
- |--------------------------------------------------------------------------------
- */
-
-type Routes = {
- POST: Route[];
- GET: Route[];
- PUT: Route[];
- PATCH: Route[];
- DELETE: Route[];
-};
-
-type ResolvedRoute = {
- route: Route;
- params: any;
-};
diff --git a/libraries/client.ts b/libraries/client.ts
deleted file mode 100644
index 3700eb7..0000000
--- a/libraries/client.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import z, { ZodType } from "zod";
-
-import type { RelayAdapter, RelayRESTInput } from "./adapter.ts";
-import { Procedure } from "./procedure.ts";
-import type { Relays } from "./relay.ts";
-import { Route } from "./route.ts";
-
-/**
- * Make a new relay client instance.
- *
- * @param config - Client configuration.
- * @param procedures - Map of procedures to make available to the client.
- */
-export function makeRelayClient(config: RelayClientConfig, relays: TRelays): RelayClient {
- return mapRelays(relays, config.adapter);
-}
-
-/*
- |--------------------------------------------------------------------------------
- | Helpers
- |--------------------------------------------------------------------------------
- */
-
-function mapRelays(relays: TRelays, adapter: RelayAdapter): RelayClient {
- const client: any = {};
- for (const key in relays) {
- const relay = relays[key];
- if (relay instanceof Procedure) {
- client[key] = async (params: unknown) => {
- const response = await adapter.send({ method: relay.method, params });
- if ("error" in response) {
- throw new Error(response.error.message);
- }
- if ("result" in response && relay.state.result !== undefined) {
- return relay.state.result.parseAsync(response.result);
- }
- return response.result;
- };
- } else if (relay instanceof Route) {
- client[key] = async (...args: any[]) => {
- const input: RelayRESTInput = { method: relay.state.method, url: `${adapter.url}${relay.state.path}`, query: "" };
-
- let index = 0; // argument incrementor
-
- if (relay.state.params !== undefined) {
- const params = args[index++] as { [key: string]: string };
- for (const key in params) {
- input.url = input.url.replace(`:${key}`, params[key]);
- }
- }
-
- if (relay.state.query !== undefined) {
- const query = args[index++] as { [key: string]: string };
- const pieces: string[] = [];
- for (const key in query) {
- pieces.push(`${key}=${query[key]}`);
- }
- if (pieces.length > 0) {
- input.query = `?${pieces.join("&")}`;
- }
- }
-
- if (relay.state.body !== undefined) {
- input.body = JSON.stringify(args[index++]);
- }
-
- // ### Fetch
-
- const data = await adapter.fetch(input);
- if (relay.state.output !== undefined) {
- return relay.state.output.parse(data);
- }
- return data;
- };
- } else {
- client[key] = mapRelays(relay, adapter);
- }
- }
- return client;
-}
-
-/*
- |--------------------------------------------------------------------------------
- | Types
- |--------------------------------------------------------------------------------
- */
-
-export type RelayClient = {
- [TKey in keyof TRelays]: TRelays[TKey] extends Procedure
- ? TState["params"] extends ZodType
- ? (params: z.infer) => Promise : void>
- : () => Promise : void>
- : TRelays[TKey] extends Route
- ? (...args: TRelays[TKey]["args"]) => Promise>
- : TRelays[TKey] extends Relays
- ? RelayClient
- : never;
-};
-
-type RelayRouteResponse = TRoute["state"]["output"] extends ZodType ? z.infer : void;
-
-export type RelayClientConfig = {
- adapter: RelayAdapter;
-};
diff --git a/libraries/procedure.ts b/libraries/procedure.ts
deleted file mode 100644
index 268fd36..0000000
--- a/libraries/procedure.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-import z, { ZodObject, ZodType } from "zod";
-
-import { Action } from "./action.ts";
-import { RelayError } from "./errors.ts";
-
-export class Procedure {
- readonly type = "rpc" as const;
-
- declare readonly args: Args;
-
- constructor(readonly state: TState) {}
-
- get method(): TState["method"] {
- return this.state.method;
- }
-
- /**
- * Params allows for custom casting of URL parameters. If a parameter does not
- * have a corresponding zod schema the default param type is "string".
- *
- * @param params - URL params.
- *
- * @examples
- *
- * ```ts
- * relay
- * .method("user:create")
- * .params(
- * z.object({
- * bar: z.number()
- * })
- * )
- * .handle(async ({ bar }) => {
- * console.log(typeof bar); // => number
- * });
- * ```
- */
- params(params: TParams): Procedure & { params: TParams }> {
- return new Procedure({ ...this.state, params });
- }
-
- /**
- * List of route level middleware action to execute before running the
- * route handler.
- *
- * @param actions - Actions to execute on this route.
- *
- * @examples
- *
- * ```ts
- * const hasFooBar = action
- * .make("hasFooBar")
- * .output(z.object({ foobar: z.number() }))
- * .handle(async () => {
- * return {
- * foobar: 1,
- * };
- * });
- *
- * relay
- * .method("foo")
- * .actions([hasFooBar])
- * .handle(async ({ foobar }) => {
- * console.log(typeof foobar); // => number
- * });
- * ```
- */
- actions>(
- actions: (TAction | [TAction, TActionFn])[],
- ): Procedure & { actions: TAction[] }> {
- return new Procedure({ ...this.state, actions: actions as TAction[] });
- }
-
- /**
- * Shape of the response this route produces. This is used by the transform
- * tools to ensure the client receives parsed data.
- *
- * @param response - Response shape of the route.
- *
- * @examples
- *
- * ```ts
- * relay
- * .method("foo")
- * .result(
- * z.object({
- * bar: z.number()
- * })
- * )
- * .handle(async () => {
- * return { bar: 1 };
- * });
- * ```
- */
- result(result: TResult): Procedure & { result: TResult }> {
- return new Procedure({ ...this.state, result });
- }
-
- /**
- * Server handler callback method.
- *
- * @param handle - Handle function to trigger when the route is executed.
- */
- handle>(handle: THandleFn): Procedure & { handle: THandleFn }> {
- return new Procedure({ ...this.state, handle });
- }
-}
-
-/*
- |--------------------------------------------------------------------------------
- | Factories
- |--------------------------------------------------------------------------------
- */
-
-export const rpc: {
- method(method: TMethod): Procedure<{ type: "rpc"; method: TMethod }>;
-} = {
- method(method: TMethod): Procedure<{ type: "rpc"; method: TMethod }> {
- return new Procedure({ type: "rpc", method });
- },
-};
-
-/*
- |--------------------------------------------------------------------------------
- | Types
- |--------------------------------------------------------------------------------
- */
-
-type State = {
- method: string;
- params?: ZodType;
- actions?: Array;
- result?: ZodType;
- handle?: HandleFn;
-};
-
-type ActionFn = TState["params"] extends ZodType
- ? (params: z.infer) => TAction["state"]["input"] extends ZodType ? z.infer : void
- : () => TAction["state"]["input"] extends ZodType ? z.infer : void;
-
-type HandleFn = any[], TResponse = any> = (
- ...args: TArgs
-) => TResponse extends ZodType ? Promise | Response | RelayError> : Promise;
-
-type Args = [
- ...(TState["params"] extends ZodType ? [z.infer] : []),
- ...(TState["actions"] extends Array ? [UnionToIntersection>] : []),
-];
-
-type MergeAction> =
- TActions[number] extends Action ? (TState["output"] extends ZodObject ? z.infer : object) : object;
-
-type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
diff --git a/libraries/relay.ts b/libraries/relay.ts
deleted file mode 100644
index dcb5f24..0000000
--- a/libraries/relay.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { Api } from "./api.ts";
-import { makeRelayClient, RelayClient, RelayClientConfig } from "./client.ts";
-import { Procedure } from "./procedure.ts";
-import { Route, RouteMethod } from "./route.ts";
-
-export class Relay<
- TRelays extends Relays,
- TRPCIndex = RPCIndex,
- TPostIndex = RouteIndex<"POST", TRelays>,
- TGetIndex = RouteIndex<"GET", TRelays>,
- TPutIndex = RouteIndex<"PUT", TRelays>,
- TPatchIndex = RouteIndex<"PATCH", TRelays>,
- TDeleteIndex = RouteIndex<"DELETE", TRelays>,
-> {
- readonly #index = new Map();
-
- declare readonly $inferClient: RelayClient;
-
- /**
- * Instantiate a new Relay instance.
- *
- * @param procedures - Procedures to register with the instance.
- */
- constructor(readonly relays: TRelays) {
- indexRelays(relays, this.#index);
- }
-
- /**
- * Create a new relay api instance with the given relays.
- *
- * @param relays - List of relays to handle.
- */
- api(relays: TRelays): Api {
- return new Api(relays);
- }
-
- /**
- * Create a new relay client instance from the instance procedures.
- *
- * @param config - Client configuration.
- */
- client(config: RelayClientConfig): this["$inferClient"] {
- return makeRelayClient(config, this.relays) as any;
- }
-
- /**
- * Retrieve a registered procedure registered with the relay instance.
- *
- * @param method - Method name assigned to the procedure.
- */
- method(method: TMethod): TRPCIndex[TMethod] {
- return this.#index.get(method as string) as TRPCIndex[TMethod];
- }
-
- /**
- * Retrieve a registered 'POST' route registered with the relay instance.
- *
- * @param path - Route path to retrieve.
- */
- post(path: TPath): TPostIndex[TPath] {
- return this.#index.get(`POST ${path as string}`) as TPostIndex[TPath];
- }
-
- /**
- * Retrieve a registered 'GET' route registered with the relay instance.
- *
- * @param path - Route path to retrieve.
- */
- get(path: TPath): TGetIndex[TPath] {
- return this.#index.get(`GET ${path as string}`) as TGetIndex[TPath];
- }
-
- /**
- * Retrieve a registered 'PUT' route registered with the relay instance.
- *
- * @param path - Route path to retrieve.
- */
- put(path: TPath): TPutIndex[TPath] {
- return this.#index.get(`PUT ${path as string}`) as TPutIndex[TPath];
- }
-
- /**
- * Retrieve a registered 'PATCH' route registered with the relay instance.
- *
- * @param path - Route path to retrieve.
- */
- patch(path: TPath): TPatchIndex[TPath] {
- return this.#index.get(`PATCH ${path as string}`) as TPatchIndex[TPath];
- }
-
- /**
- * Retrieve a registered 'DELETE' route registered with the relay instance.
- *
- * @param path - Route path to retrieve.
- */
- delete(path: TPath): TDeleteIndex[TPath] {
- return this.#index.get(`DELETE ${path as string}`) as TDeleteIndex[TPath];
- }
-}
-
-/*
- |--------------------------------------------------------------------------------
- | Helpers
- |--------------------------------------------------------------------------------
- */
-
-function indexRelays(relays: Relays, index: Map) {
- for (const key in relays) {
- const relay = relays[key];
- if (relay instanceof Procedure) {
- const method = relay.method;
- if (index.has(method)) {
- throw new Error(`Relay > Procedure with method '${method}' already exists!`);
- }
- index.set(method, relay);
- } else if (relay instanceof Route) {
- const path = `${relay.method} ${relay.path}`;
- if (index.has(path)) {
- throw new Error(`Relay > Procedure with path 'path' already exists!`);
- }
- index.set(path, relay);
- } else {
- indexRelays(relay, index);
- }
- }
-}
-
-/*
- |--------------------------------------------------------------------------------
- | Types
- |--------------------------------------------------------------------------------
- */
-
-export type Relays = {
- [key: string]: Relays | Procedure | Route;
-};
-
-type RPCIndex = MergeUnion>;
-
-type RouteIndex = MergeUnion>;
-
-type FlattenRPCRelays = {
- [TKey in keyof TRelays]: TRelays[TKey] extends Procedure
- ? Record
- : TRelays[TKey] extends Relays
- ? FlattenRPCRelays
- : never;
-}[keyof TRelays];
-
-type FlattenRouteRelays = {
- [TKey in keyof TRelays]: TRelays[TKey] extends { state: { method: TMethod; path: infer TPath extends string } }
- ? Record
- : TRelays[TKey] extends Relays
- ? FlattenRouteRelays