diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
new file mode 100644
index 0000000..c4b7818
--- /dev/null
+++ b/apps/web/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/package.json b/apps/web/package.json
index d096f47..c7947be 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,7 +8,8 @@
"build": "next build",
"start": "next start --hostname 0.0.0.0",
"lint": "next lint",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
},
"dependencies": {
"@cmbot/db": "workspace:*",
@@ -43,7 +44,9 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+ "@vitest/ui": "^2.1.9",
"tailwindcss": "^4.0.0",
- "typescript": "^5.5.0"
+ "typescript": "^5.5.0",
+ "vitest": "^2.1.9"
}
}
diff --git a/apps/web/src/lib/cache.test.ts b/apps/web/src/lib/cache.test.ts
new file mode 100644
index 0000000..5deae36
--- /dev/null
+++ b/apps/web/src/lib/cache.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect, beforeEach, afterAll } from "vitest";
+import { cacheGet, cacheSet, cacheGetOrSet, cacheDelete } from "./cache";
+import { db, pool } from "./db";
+import { cacheEntries } from "@cmbot/db";
+import { sql } from "drizzle-orm";
+
+describe("cache helpers", () => {
+ beforeEach(async () => {
+ await db.delete(cacheEntries).where(sql`true`);
+ });
+
+ afterAll(async () => {
+ await pool.end();
+ });
+
+ it("set + get round-trip", async () => {
+ await cacheSet("k1", { hello: "world" }, 60);
+ const v = await cacheGet<{ hello: string }>("k1");
+ expect(v).toEqual({ hello: "world" });
+ });
+
+ it("getOrSet computes once, then returns cached", async () => {
+ let calls = 0;
+ const compute = async () => {
+ calls++;
+ return { stamp: 42 };
+ };
+ const a = await cacheGetOrSet("k2", 60, compute);
+ const b = await cacheGetOrSet("k2", 60, compute);
+ expect(a).toEqual({ stamp: 42 });
+ expect(b).toEqual({ stamp: 42 });
+ expect(calls).toBe(1);
+ });
+
+ it("expired entries are skipped on get", async () => {
+ await cacheSet("k3", { stale: true }, -1); // already expired
+ const v = await cacheGet("k3");
+ expect(v).toBeNull();
+ });
+
+ it("delete removes the entry", async () => {
+ await cacheSet("k4", { x: 1 }, 60);
+ await cacheDelete("k4");
+ expect(await cacheGet("k4")).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/cache.ts b/apps/web/src/lib/cache.ts
new file mode 100644
index 0000000..6cda42b
--- /dev/null
+++ b/apps/web/src/lib/cache.ts
@@ -0,0 +1,43 @@
+import "server-only";
+import { sql, eq } from "drizzle-orm";
+import { cacheEntries } from "@cmbot/db";
+import { db } from "./db";
+
+export async function cacheGet(key: string): Promise {
+ const row = await db.query.cacheEntries.findFirst({
+ where: (c, { eq, and, gt }) => and(eq(c.key, key), gt(c.expiresAt, new Date())),
+ });
+ return (row?.value ?? null) as T | null;
+}
+
+export async function cacheSet(key: string, value: unknown, ttlSeconds: number): Promise {
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
+ await db
+ .insert(cacheEntries)
+ .values({ key, value: value as never, expiresAt })
+ .onConflictDoUpdate({
+ target: cacheEntries.key,
+ set: { value: value as never, expiresAt },
+ });
+}
+
+export async function cacheDelete(key: string): Promise {
+ await db.delete(cacheEntries).where(eq(cacheEntries.key, key));
+}
+
+export async function cacheGetOrSet(
+ key: string,
+ ttlSeconds: number,
+ compute: () => Promise,
+): Promise {
+ const hit = await cacheGet(key);
+ if (hit !== null) return hit;
+ const fresh = await compute();
+ await cacheSet(key, fresh, ttlSeconds);
+ return fresh;
+}
+
+export async function cacheSweep(): Promise<{ removed: number }> {
+ const r = await db.execute(sql`DELETE FROM cache_entries WHERE expires_at < now() RETURNING key`);
+ return { removed: r.rowCount ?? 0 };
+}
diff --git a/apps/web/src/lib/rate-limit.test.ts b/apps/web/src/lib/rate-limit.test.ts
new file mode 100644
index 0000000..903ab57
--- /dev/null
+++ b/apps/web/src/lib/rate-limit.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect, beforeEach, afterAll } from "vitest";
+import { checkRateLimit } from "./rate-limit";
+import { db, pool } from "./db";
+import { rateLimitBuckets } from "@cmbot/db";
+import { sql } from "drizzle-orm";
+
+describe("checkRateLimit", () => {
+ beforeEach(async () => {
+ await db.delete(rateLimitBuckets).where(sql`true`);
+ });
+
+ afterAll(async () => {
+ await pool.end();
+ });
+
+ it("allows requests under the limit", async () => {
+ for (let i = 0; i < 5; i++) {
+ const r = await checkRateLimit("test:1", { max: 5, windowSec: 10 });
+ expect(r.limited).toBe(false);
+ }
+ });
+
+ it("blocks the (max+1)th request within the window", async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkRateLimit("test:2", { max: 3, windowSec: 10 });
+ }
+ const r = await checkRateLimit("test:2", { max: 3, windowSec: 10 });
+ expect(r.limited).toBe(true);
+ expect(r.count).toBeGreaterThan(3);
+ });
+
+ it("isolates buckets by key", async () => {
+ await checkRateLimit("a", { max: 1, windowSec: 10 });
+ await checkRateLimit("a", { max: 1, windowSec: 10 });
+ const aLimited = await checkRateLimit("a", { max: 1, windowSec: 10 });
+ const bFresh = await checkRateLimit("b", { max: 1, windowSec: 10 });
+ expect(aLimited.limited).toBe(true);
+ expect(bFresh.limited).toBe(false);
+ });
+});
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
new file mode 100644
index 0000000..29dfbf4
--- /dev/null
+++ b/apps/web/src/lib/rate-limit.ts
@@ -0,0 +1,43 @@
+import "server-only";
+import { sql } from "drizzle-orm";
+import { db } from "./db";
+
+export type RateLimitOptions = { max: number; windowSec: number };
+export type RateLimitResult = { limited: boolean; count: number };
+
+/**
+ * Sliding-window rate limit using a single atomic Postgres UPSERT.
+ * Returns { limited: count > max, count }. The UPSERT resets the window
+ * when the existing row is older than windowSec, otherwise increments
+ * the count in place.
+ */
+export async function checkRateLimit(
+ key: string,
+ opts: RateLimitOptions,
+): Promise {
+ const { windowSec } = opts;
+ const result = await db.execute(sql`
+ INSERT INTO rate_limit_buckets (key, window_start, count, expires_at)
+ VALUES (${key}, now(), 1, now() + (${windowSec} * interval '1 second'))
+ ON CONFLICT (key) DO UPDATE
+ SET count = CASE
+ WHEN rate_limit_buckets.window_start < now() - (${windowSec} * interval '1 second')
+ THEN 1
+ ELSE rate_limit_buckets.count + 1
+ END,
+ window_start = CASE
+ WHEN rate_limit_buckets.window_start < now() - (${windowSec} * interval '1 second')
+ THEN now()
+ ELSE rate_limit_buckets.window_start
+ END,
+ expires_at = now() + (${windowSec} * interval '1 second')
+ RETURNING count;
+ `);
+ const count = Number((result.rows[0] as { count: number }).count);
+ return { limited: count > opts.max, count };
+}
+
+export async function rateLimitSweep(): Promise<{ removed: number }> {
+ const r = await db.execute(sql`DELETE FROM rate_limit_buckets WHERE expires_at < now()`);
+ return { removed: r.rowCount ?? 0 };
+}
diff --git a/apps/web/src/lib/search.ts b/apps/web/src/lib/search.ts
new file mode 100644
index 0000000..187c1f7
--- /dev/null
+++ b/apps/web/src/lib/search.ts
@@ -0,0 +1,22 @@
+import "server-only";
+import { sql, type SQL } from "drizzle-orm";
+
+/**
+ * Build a Drizzle WHERE fragment that fuzzy-matches `column` against `query`
+ * using pg_trgm's similarity operator. Returns `true` (no filter) when query is
+ * empty so callers can compose unconditionally.
+ *
+ * Caller must ensure a `gin_trgm_ops` index exists on the column.
+ */
+export function trigramMatch(column: SQL, query: string | null | undefined): SQL {
+ const q = (query ?? "").trim();
+ if (!q) return sql`true`;
+ return sql`${column} % ${q}`;
+}
+
+/** Order-by fragment that ranks rows by similarity descending. */
+export function trigramRank(column: SQL, query: string | null | undefined): SQL {
+ const q = (query ?? "").trim();
+ if (!q) return sql`1`;
+ return sql`similarity(${column}, ${q}) DESC`;
+}
diff --git a/apps/web/src/test/server-only-stub.ts b/apps/web/src/test/server-only-stub.ts
new file mode 100644
index 0000000..67446b1
--- /dev/null
+++ b/apps/web/src/test/server-only-stub.ts
@@ -0,0 +1,2 @@
+// Empty stub — vitest replaces "server-only" imports with this so they don't crash.
+export {};
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
new file mode 100644
index 0000000..76c330f
--- /dev/null
+++ b/apps/web/vitest.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "vitest/config";
+import path from "node:path";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "src"),
+ // Stub out next's "server-only" import — vitest can't load it
+ "server-only": path.resolve(__dirname, "src/test/server-only-stub.ts"),
+ },
+ },
+});
diff --git a/packages/db/migrations/0002_left_jimmy_woo.sql b/packages/db/migrations/0002_left_jimmy_woo.sql
new file mode 100644
index 0000000..7248a8d
--- /dev/null
+++ b/packages/db/migrations/0002_left_jimmy_woo.sql
@@ -0,0 +1,40 @@
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+CREATE TABLE IF NOT EXISTS "cache_entries" (
+ "key" text PRIMARY KEY NOT NULL,
+ "value" jsonb NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "rate_limit_buckets" (
+ "key" text PRIMARY KEY NOT NULL,
+ "window_start" timestamp with time zone NOT NULL,
+ "count" integer NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL
+);
+
+-- Trigram fuzzy search indexes (pg_trgm)
+CREATE INDEX IF NOT EXISTS whatsapp_groups_name_trgm
+ ON whatsapp_groups USING gin (name gin_trgm_ops);
+CREATE INDEX IF NOT EXISTS reminders_name_trgm
+ ON reminders USING gin (name gin_trgm_ops);
+
+-- Common-filter B-tree indexes
+CREATE INDEX IF NOT EXISTS reminders_status_idx
+ ON reminders (status);
+CREATE INDEX IF NOT EXISTS reminders_account_scheduled_idx
+ ON reminders (account_id, scheduled_at DESC NULLS LAST);
+CREATE INDEX IF NOT EXISTS reminder_runs_reminder_fired_idx
+ ON reminder_runs (reminder_id, fired_at DESC);
+
+-- BRIN indexes for append-mostly time-series columns
+CREATE INDEX IF NOT EXISTS reminder_runs_fired_at_brin
+ ON reminder_runs USING brin (fired_at);
+CREATE INDEX IF NOT EXISTS audit_log_created_at_brin
+ ON audit_log USING brin (created_at);
+
+-- Expiry indexes for the new utility tables
+CREATE INDEX IF NOT EXISTS cache_entries_expires_idx
+ ON cache_entries (expires_at);
+CREATE INDEX IF NOT EXISTS rate_limit_buckets_expires_idx
+ ON rate_limit_buckets (expires_at);
diff --git a/packages/db/migrations/meta/0002_snapshot.json b/packages/db/migrations/meta/0002_snapshot.json
new file mode 100644
index 0000000..90cf3c5
--- /dev/null
+++ b/packages/db/migrations/meta/0002_snapshot.json
@@ -0,0 +1,1001 @@
+{
+ "id": "a0f39413-134d-477c-9fb5-f3155026ae4a",
+ "prevId": "dbce7f82-b3ae-4f9c-88b9-87d214c0e04e",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "operator_id": {
+ "name": "operator_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audit_log_operator_id_operators_id_fk": {
+ "name": "audit_log_operator_id_operators_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "operators",
+ "columnsFrom": [
+ "operator_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_sessions": {
+ "name": "auth_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "operator_id": {
+ "name": "operator_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_sessions_operator_id_operators_id_fk": {
+ "name": "auth_sessions_operator_id_operators_id_fk",
+ "tableFrom": "auth_sessions",
+ "tableTo": "operators",
+ "columnsFrom": [
+ "operator_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "auth_sessions_token_hash_unique": {
+ "name": "auth_sessions_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cache_entries": {
+ "name": "cache_entries",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_files": {
+ "name": "media_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "operator_id": {
+ "name": "operator_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filename_original": {
+ "name": "filename_original",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sha256": {
+ "name": "sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_path": {
+ "name": "storage_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_files_operator_id_operators_id_fk": {
+ "name": "media_files_operator_id_operators_id_fk",
+ "tableFrom": "media_files",
+ "tableTo": "operators",
+ "columnsFrom": [
+ "operator_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.operators": {
+ "name": "operators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "telegram_user_id": {
+ "name": "telegram_user_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'admin'"
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Asia/Kuala_Lumpur'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "operators_telegram_user_id_uq": {
+ "name": "operators_telegram_user_id_uq",
+ "columns": [
+ {
+ "expression": "telegram_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rate_limit_buckets": {
+ "name": "rate_limit_buckets",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "window_start": {
+ "name": "window_start",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reminder_messages": {
+ "name": "reminder_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "reminder_id": {
+ "name": "reminder_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text_content": {
+ "name": "text_content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reminder_messages_reminder_id_reminders_id_fk": {
+ "name": "reminder_messages_reminder_id_reminders_id_fk",
+ "tableFrom": "reminder_messages",
+ "tableTo": "reminders",
+ "columnsFrom": [
+ "reminder_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reminder_messages_media_id_media_files_id_fk": {
+ "name": "reminder_messages_media_id_media_files_id_fk",
+ "tableFrom": "reminder_messages",
+ "tableTo": "media_files",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reminder_run_targets": {
+ "name": "reminder_run_targets",
+ "schema": "",
+ "columns": {
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "wa_message_id": {
+ "name": "wa_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "latency_ms": {
+ "name": "latency_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reminder_run_targets_run_id_reminder_runs_id_fk": {
+ "name": "reminder_run_targets_run_id_reminder_runs_id_fk",
+ "tableFrom": "reminder_run_targets",
+ "tableTo": "reminder_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reminder_run_targets_group_id_whatsapp_groups_id_fk": {
+ "name": "reminder_run_targets_group_id_whatsapp_groups_id_fk",
+ "tableFrom": "reminder_run_targets",
+ "tableTo": "whatsapp_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "reminder_run_targets_run_id_group_id_pk": {
+ "name": "reminder_run_targets_run_id_group_id_pk",
+ "columns": [
+ "run_id",
+ "group_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reminder_runs": {
+ "name": "reminder_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "reminder_id": {
+ "name": "reminder_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fired_at": {
+ "name": "fired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error_summary": {
+ "name": "error_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reminder_runs_reminder_id_reminders_id_fk": {
+ "name": "reminder_runs_reminder_id_reminders_id_fk",
+ "tableFrom": "reminder_runs",
+ "tableTo": "reminders",
+ "columnsFrom": [
+ "reminder_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reminder_targets": {
+ "name": "reminder_targets",
+ "schema": "",
+ "columns": {
+ "reminder_id": {
+ "name": "reminder_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reminder_targets_reminder_id_reminders_id_fk": {
+ "name": "reminder_targets_reminder_id_reminders_id_fk",
+ "tableFrom": "reminder_targets",
+ "tableTo": "reminders",
+ "columnsFrom": [
+ "reminder_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reminder_targets_group_id_whatsapp_groups_id_fk": {
+ "name": "reminder_targets_group_id_whatsapp_groups_id_fk",
+ "tableFrom": "reminder_targets",
+ "tableTo": "whatsapp_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "reminder_targets_reminder_id_group_id_pk": {
+ "name": "reminder_targets_reminder_id_group_id_pk",
+ "columns": [
+ "reminder_id",
+ "group_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reminders": {
+ "name": "reminders",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schedule_kind": {
+ "name": "schedule_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rrule": {
+ "name": "rrule",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ends_at": {
+ "name": "ends_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_runs": {
+ "name": "max_runs",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reminders_account_id_whatsapp_accounts_id_fk": {
+ "name": "reminders_account_id_whatsapp_accounts_id_fk",
+ "tableFrom": "reminders",
+ "tableTo": "whatsapp_accounts",
+ "columnsFrom": [
+ "account_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "reminders_created_by_operators_id_fk": {
+ "name": "reminders_created_by_operators_id_fk",
+ "tableFrom": "reminders",
+ "tableTo": "operators",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.whatsapp_accounts": {
+ "name": "whatsapp_accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "operator_id": {
+ "name": "operator_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "phone_number": {
+ "name": "phone_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "last_connected_at": {
+ "name": "last_connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_qr_at": {
+ "name": "last_qr_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "whatsapp_accounts_operator_label_uq": {
+ "name": "whatsapp_accounts_operator_label_uq",
+ "columns": [
+ {
+ "expression": "operator_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "label",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "whatsapp_accounts_operator_id_operators_id_fk": {
+ "name": "whatsapp_accounts_operator_id_operators_id_fk",
+ "tableFrom": "whatsapp_accounts",
+ "tableTo": "operators",
+ "columnsFrom": [
+ "operator_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.whatsapp_groups": {
+ "name": "whatsapp_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "wa_group_jid": {
+ "name": "wa_group_jid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "participant_count": {
+ "name": "participant_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "whatsapp_groups_account_jid_uq": {
+ "name": "whatsapp_groups_account_jid_uq",
+ "columns": [
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "wa_group_jid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "whatsapp_groups_account_id_whatsapp_accounts_id_fk": {
+ "name": "whatsapp_groups_account_id_whatsapp_accounts_id_fk",
+ "tableFrom": "whatsapp_groups",
+ "tableTo": "whatsapp_accounts",
+ "columnsFrom": [
+ "account_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
index fe278d1..5965262 100644
--- a/packages/db/migrations/meta/_journal.json
+++ b/packages/db/migrations/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1778320434707,
"tag": "0001_smart_vertigo",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1778338808600,
+ "tag": "0002_left_jimmy_woo",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 6d15762..b283877 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -153,6 +153,19 @@ export const authSessions = pgTable("auth_sessions", {
userAgent: text("user_agent"),
});
+export const cacheEntries = pgTable("cache_entries", {
+ key: text("key").primaryKey(),
+ value: jsonb("value").notNull(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+});
+
+export const rateLimitBuckets = pgTable("rate_limit_buckets", {
+ key: text("key").primaryKey(),
+ windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
+ count: integer("count").notNull(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+});
+
export type Operator = typeof operators.$inferSelect;
export type NewOperator = typeof operators.$inferInsert;
export type WhatsappAccount = typeof whatsappAccounts.$inferSelect;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c0b4a2c..8ace663 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -71,7 +71,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^2.1.0
- version: 2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3))
+ version: 2.1.9(@types/node@22.19.18)(@vitest/ui@2.1.9)(lightningcss@1.32.0)(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3))
apps/web:
dependencies:
@@ -166,12 +166,18 @@ importers:
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14)
+ '@vitest/ui':
+ specifier: ^2.1.9
+ version: 2.1.9(vitest@2.1.9)
tailwindcss:
specifier: ^4.0.0
version: 4.3.0
typescript:
specifier: ^5.5.0
version: 5.9.3
+ vitest:
+ specifier: ^2.1.9
+ version: 2.1.9(@types/node@22.19.18)(@vitest/ui@2.1.9)(lightningcss@1.32.0)(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3))
packages/db:
dependencies:
@@ -218,7 +224,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^2.1.0
- version: 2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3))
+ version: 2.1.9(@types/node@22.19.18)(@vitest/ui@2.1.9)(lightningcss@1.32.0)(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3))
packages:
@@ -1292,6 +1298,9 @@ packages:
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
+ '@polka/url@1.0.0-next.29':
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -2353,6 +2362,11 @@ packages:
'@vitest/spy@2.1.9':
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
+ '@vitest/ui@2.1.9':
+ resolution: {integrity: sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==}
+ peerDependencies:
+ vitest: 2.1.9
+
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
@@ -2989,6 +3003,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
+ fflate@0.8.2:
+ resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
@@ -3009,6 +3026,9 @@ packages:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -3456,6 +3476,10 @@ packages:
mpg123-decoder@1.0.3:
resolution: {integrity: sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==}
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4029,6 +4053,10 @@ packages:
simple-yenc@1.0.4:
resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==}
+ sirv@3.0.2:
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
+ engines: {node: '>=18'}
+
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -4155,6 +4183,10 @@ packages:
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ engines: {node: '>=12.0.0'}
+
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -4186,6 +4218,10 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
+ totalist@3.0.1:
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+ engines: {node: '>=6'}
+
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
@@ -5252,6 +5288,8 @@ snapshots:
'@pinojs/redact@0.4.0': {}
+ '@polka/url@1.0.0-next.29': {}
+
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
@@ -6292,6 +6330,17 @@ snapshots:
dependencies:
tinyspy: 3.0.2
+ '@vitest/ui@2.1.9(vitest@2.1.9)':
+ dependencies:
+ '@vitest/utils': 2.1.9
+ fflate: 0.8.2
+ flatted: 3.4.2
+ pathe: 1.1.2
+ sirv: 3.0.2
+ tinyglobby: 0.2.16
+ tinyrainbow: 1.2.0
+ vitest: 2.1.9(@types/node@22.19.18)(@vitest/ui@2.1.9)(lightningcss@1.32.0)(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3))
+
'@vitest/utils@2.1.9':
dependencies:
'@vitest/pretty-format': 2.1.9
@@ -6931,6 +6980,8 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
+ fflate@0.8.2: {}
+
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
@@ -6964,6 +7015,8 @@ snapshots:
locate-path: 5.0.0
path-exists: 4.0.0
+ flatted@3.4.2: {}
+
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
@@ -7301,6 +7354,8 @@ snapshots:
'@wasm-audio-decoders/common': 9.0.7
optional: true
+ mrmime@2.0.1: {}
+
ms@2.1.3: {}
msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3):
@@ -8073,6 +8128,12 @@ snapshots:
simple-yenc@1.0.4:
optional: true
+ sirv@3.0.2:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
sisteransi@1.0.5: {}
sonic-boom@4.2.1:
@@ -8172,6 +8233,11 @@ snapshots:
tinyexec@0.3.2: {}
+ tinyglobby@0.2.16:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
tinypool@1.1.1: {}
tinyrainbow@1.2.0: {}
@@ -8196,6 +8262,8 @@ snapshots:
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
+ totalist@3.0.1: {}
+
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.30
@@ -8314,7 +8382,7 @@ snapshots:
fsevents: 2.3.3
lightningcss: 1.32.0
- vitest@2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3)):
+ vitest@2.1.9(@types/node@22.19.18)(@vitest/ui@2.1.9)(lightningcss@1.32.0)(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3)):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(msw@2.14.5(@types/node@22.19.18)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.18)(lightningcss@1.32.0))
@@ -8338,6 +8406,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.18
+ '@vitest/ui': 2.1.9(vitest@2.1.9)
transitivePeerDependencies:
- less
- lightningcss