mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
chore(packages): remove 4 dead zero-consumer packages
Pre-launch audit found 4 packages with zero workspace consumers
that were leftover from before the consolidation:
- @mana/cards-database (1475 LOC)
Pre-consolidation flashcard backend with its own Docker Compose
and Drizzle config. Replaced by the cards module in the unified
Mana app: apps/mana/apps/web/src/lib/modules/cards/. Now uses
Dexie + mana-sync against mana_platform.
- @mana/shared-api-client (1110 LOC)
Generic Go-style {data, error} REST client. Only reference left
was a string entry in shared-vite-config's noExternal list (not
a real import).
- @mana/shared-errors (1791 LOC)
NestJS-coupled exception filter package from before the Hono
migration. The Hono replacement (serviceErrorHandler in
@mana/shared-hono) ships in a separate commit. Result<T,E> +
ErrorCode enum bits had no consumers and weren't worth saving
standalone — if a need emerges they can grow organically.
- @mana/shared-splitscreen (694 LOC)
Side-by-side panel layout components. No code consumers; only
referenced from shared-vite-config noExternal and an old design
doc. The unified Mana app uses its own workbench scenes for
multi-pane layouts.
Verified zero code consumers via grep across .ts/.svelte/.json
before deletion. apps/api type-check stays at 0 errors after the
sweep, mana-auth tests still 19/19 passing.
Also clean packages/shared-vite-config/src/index.ts noExternal
list while we're here: drop the two deleted entries plus 8 ghost
packages (shared-feedback-ui/-service/-types, shared-help-ui/
-types/-content, shared-profile-ui, shared-subscription-ui) that
were referenced by name but never existed in packages/. List goes
from 22 → 12 entries.
Net: ~5070 LOC + workspace declarations removed.
Tracked as item #29 in docs/REFACTORING_AUDIT_2026_04.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e19a81c83c
commit
488489944d
64 changed files with 0 additions and 4702 deletions
|
|
@ -1,10 +0,0 @@
|
||||||
# Database connection URL
|
|
||||||
# Format: postgresql://user:password@host:port/database
|
|
||||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/cards
|
|
||||||
|
|
||||||
# Alternative name (used as fallback)
|
|
||||||
CARDS_DATABASE_URL=postgresql://postgres:password@localhost:5432/cards
|
|
||||||
|
|
||||||
# Supabase credentials (only needed for migration)
|
|
||||||
SUPABASE_URL=https://your-project.supabase.co
|
|
||||||
SUPABASE_SERVICE_KEY=your-service-role-key
|
|
||||||
23
packages/cards-database/.gitignore
vendored
23
packages/cards-database/.gitignore
vendored
|
|
@ -1,23 +0,0 @@
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Drizzle migrations (optional - can be tracked)
|
|
||||||
# drizzle/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: cards-postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: cards
|
|
||||||
POSTGRES_PASSWORD: cards_dev_password
|
|
||||||
POSTGRES_DB: cards
|
|
||||||
ports:
|
|
||||||
- "5433:5432"
|
|
||||||
volumes:
|
|
||||||
- cards_postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U cards -d cards"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
# Optional: pgAdmin for database management UI
|
|
||||||
pgadmin:
|
|
||||||
image: dpage/pgadmin4:latest
|
|
||||||
container_name: cards-pgadmin
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
PGADMIN_DEFAULT_EMAIL: admin@cards.local
|
|
||||||
PGADMIN_DEFAULT_PASSWORD: admin
|
|
||||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
|
||||||
ports:
|
|
||||||
- "5050:80"
|
|
||||||
volumes:
|
|
||||||
- cards_pgadmin_data:/var/lib/pgadmin
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cards_postgres_data:
|
|
||||||
cards_pgadmin_data:
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { defineConfig } from 'drizzle-kit';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
schema: './src/schema/*.ts',
|
|
||||||
out: './drizzle',
|
|
||||||
dialect: 'postgresql',
|
|
||||||
dbCredentials: {
|
|
||||||
url:
|
|
||||||
process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform',
|
|
||||||
},
|
|
||||||
schemaFilter: ['cards'],
|
|
||||||
verbose: true,
|
|
||||||
strict: true,
|
|
||||||
});
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@mana/cards-database",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"require": "./dist/index.js",
|
|
||||||
"default": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./schema": {
|
|
||||||
"types": "./dist/schema/index.d.ts",
|
|
||||||
"import": "./dist/schema/index.js",
|
|
||||||
"require": "./dist/schema/index.js",
|
|
||||||
"default": "./dist/schema/index.js"
|
|
||||||
},
|
|
||||||
"./client": {
|
|
||||||
"types": "./dist/client.d.ts",
|
|
||||||
"import": "./dist/client.js",
|
|
||||||
"require": "./dist/client.js",
|
|
||||||
"default": "./dist/client.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"prepare": "pnpm build",
|
|
||||||
"docker:up": "docker compose up -d",
|
|
||||||
"docker:down": "docker compose down",
|
|
||||||
"docker:logs": "docker compose logs -f postgres",
|
|
||||||
"db:generate": "dotenv -- drizzle-kit generate",
|
|
||||||
"db:migrate": "dotenv -- drizzle-kit migrate",
|
|
||||||
"db:push": "dotenv -- drizzle-kit push --force",
|
|
||||||
"db:studio": "dotenv -- drizzle-kit studio",
|
|
||||||
"db:seed": "dotenv -- tsx src/seed.ts",
|
|
||||||
"db:migrate-from-supabase": "dotenv -- tsx src/migrate-from-supabase.ts",
|
|
||||||
"db:reset": "docker compose down -v && docker compose up -d && sleep 3 && pnpm db:push",
|
|
||||||
"db:test": "dotenv -- tsx src/test-connection.ts",
|
|
||||||
"type-check": "tsc --noEmit",
|
|
||||||
"lint": "eslint ."
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"drizzle-orm": "^0.36.0",
|
|
||||||
"postgres": "^3.4.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@supabase/supabase-js": "^2.81.1",
|
|
||||||
"dotenv-cli": "^7.4.0",
|
|
||||||
"drizzle-kit": "^0.28.0",
|
|
||||||
"tsx": "^4.19.0",
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"@types/node": "^22.10.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
import * as schema from './schema/index.js';
|
|
||||||
|
|
||||||
// Singleton instance for the database client
|
|
||||||
let dbInstance: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
|
||||||
let pgClient: ReturnType<typeof postgres> | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the database URL from environment variables
|
|
||||||
*/
|
|
||||||
function getDatabaseUrl(): string {
|
|
||||||
const url = process.env.DATABASE_URL || process.env.CARDS_DATABASE_URL;
|
|
||||||
if (!url) {
|
|
||||||
throw new Error(
|
|
||||||
'Database URL not found. Set DATABASE_URL or CARDS_DATABASE_URL environment variable.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new database client
|
|
||||||
* Uses connection pooling with sensible defaults for serverless environments
|
|
||||||
*/
|
|
||||||
export function createClient(connectionString?: string) {
|
|
||||||
const url = connectionString || getDatabaseUrl();
|
|
||||||
|
|
||||||
const client = postgres(url, {
|
|
||||||
max: 10, // Maximum connections in the pool
|
|
||||||
idle_timeout: 20, // Close idle connections after 20 seconds
|
|
||||||
connect_timeout: 10, // Connection timeout in seconds
|
|
||||||
prepare: false, // Disable prepared statements for serverless
|
|
||||||
});
|
|
||||||
|
|
||||||
return drizzle(client, { schema });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the singleton database instance
|
|
||||||
* Creates a new instance if one doesn't exist
|
|
||||||
*/
|
|
||||||
export function getDb() {
|
|
||||||
if (!dbInstance) {
|
|
||||||
const url = getDatabaseUrl();
|
|
||||||
pgClient = postgres(url, {
|
|
||||||
max: 10,
|
|
||||||
idle_timeout: 20,
|
|
||||||
connect_timeout: 10,
|
|
||||||
prepare: false,
|
|
||||||
});
|
|
||||||
dbInstance = drizzle(pgClient, { schema });
|
|
||||||
}
|
|
||||||
return dbInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the database connection
|
|
||||||
* Should be called when shutting down the application
|
|
||||||
*/
|
|
||||||
export async function closeDb() {
|
|
||||||
if (pgClient) {
|
|
||||||
await pgClient.end();
|
|
||||||
pgClient = null;
|
|
||||||
dbInstance = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export the database type for typing purposes
|
|
||||||
export type Database = ReturnType<typeof createClient>;
|
|
||||||
|
|
||||||
// Re-export commonly used Drizzle utilities
|
|
||||||
export {
|
|
||||||
eq,
|
|
||||||
ne,
|
|
||||||
gt,
|
|
||||||
gte,
|
|
||||||
lt,
|
|
||||||
lte,
|
|
||||||
and,
|
|
||||||
or,
|
|
||||||
not,
|
|
||||||
inArray,
|
|
||||||
notInArray,
|
|
||||||
isNull,
|
|
||||||
isNotNull,
|
|
||||||
like,
|
|
||||||
ilike,
|
|
||||||
sql,
|
|
||||||
asc,
|
|
||||||
desc,
|
|
||||||
count,
|
|
||||||
sum,
|
|
||||||
avg,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
} from 'drizzle-orm';
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
// Main entry point for @mana/cards-database
|
|
||||||
|
|
||||||
// Export database client utilities
|
|
||||||
export { createClient, getDb, closeDb, type Database } from './client.js';
|
|
||||||
|
|
||||||
// Export Drizzle utilities
|
|
||||||
export {
|
|
||||||
eq,
|
|
||||||
ne,
|
|
||||||
gt,
|
|
||||||
gte,
|
|
||||||
lt,
|
|
||||||
lte,
|
|
||||||
and,
|
|
||||||
or,
|
|
||||||
not,
|
|
||||||
inArray,
|
|
||||||
notInArray,
|
|
||||||
isNull,
|
|
||||||
isNotNull,
|
|
||||||
like,
|
|
||||||
ilike,
|
|
||||||
sql,
|
|
||||||
asc,
|
|
||||||
desc,
|
|
||||||
count,
|
|
||||||
sum,
|
|
||||||
avg,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
} from './client.js';
|
|
||||||
|
|
||||||
// Export all schemas and types
|
|
||||||
export * from './schema/index.js';
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
/**
|
|
||||||
* Migration script to move data from Supabase to the new PostgreSQL database
|
|
||||||
*
|
|
||||||
* Prerequisites:
|
|
||||||
* 1. Set SUPABASE_URL and SUPABASE_SERVICE_KEY environment variables
|
|
||||||
* 2. Set DATABASE_URL for the new PostgreSQL database
|
|
||||||
* 3. Run migrations on the new database first: pnpm db:migrate
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* SUPABASE_URL=... SUPABASE_SERVICE_KEY=... DATABASE_URL=... tsx src/migrate-from-supabase.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
|
||||||
import { getDb, closeDb } from './client.js';
|
|
||||||
import {
|
|
||||||
decks,
|
|
||||||
cards,
|
|
||||||
studySessions,
|
|
||||||
cardProgress,
|
|
||||||
deckTemplates,
|
|
||||||
aiGenerations,
|
|
||||||
userStats,
|
|
||||||
} from './schema/index.js';
|
|
||||||
|
|
||||||
// Initialize Supabase client
|
|
||||||
const supabaseUrl = process.env.SUPABASE_URL;
|
|
||||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseServiceKey) {
|
|
||||||
console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_KEY environment variables');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createSupabaseClient(supabaseUrl, supabaseServiceKey);
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
interface MigrationStats {
|
|
||||||
table: string;
|
|
||||||
migrated: number;
|
|
||||||
errors: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats: MigrationStats[] = [];
|
|
||||||
|
|
||||||
async function migrateTable<T>(
|
|
||||||
tableName: string,
|
|
||||||
supabaseTableName: string,
|
|
||||||
drizzleTable: any,
|
|
||||||
transformer: (row: any) => T
|
|
||||||
) {
|
|
||||||
console.log(`\nMigrating ${tableName}...`);
|
|
||||||
let migrated = 0;
|
|
||||||
let errors = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch all data from Supabase
|
|
||||||
const { data, error } = await supabase.from(supabaseTableName).select('*');
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error(`Error fetching ${tableName}:`, error);
|
|
||||||
stats.push({ table: tableName, migrated: 0, errors: 1 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
console.log(`No data found in ${tableName}`);
|
|
||||||
stats.push({ table: tableName, migrated: 0, errors: 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Found ${data.length} rows in ${tableName}`);
|
|
||||||
|
|
||||||
// Process in batches of 100
|
|
||||||
const batchSize = 100;
|
|
||||||
for (let i = 0; i < data.length; i += batchSize) {
|
|
||||||
const batch = data.slice(i, i + batchSize);
|
|
||||||
const transformed = batch.map(transformer);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(drizzleTable).values(transformed).onConflictDoNothing();
|
|
||||||
migrated += batch.length;
|
|
||||||
process.stdout.write(`\r Migrated ${migrated}/${data.length} rows`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`\n Error inserting batch:`, err);
|
|
||||||
errors += batch.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n Completed: ${migrated} migrated, ${errors} errors`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error migrating ${tableName}:`, err);
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.push({ table: tableName, migrated, errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('=== Cards Data Migration ===');
|
|
||||||
console.log('From: Supabase');
|
|
||||||
console.log('To: PostgreSQL (Drizzle)');
|
|
||||||
console.log('==============================\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Migrate decks
|
|
||||||
await migrateTable('decks', 'decks', decks, (row) => ({
|
|
||||||
id: row.id,
|
|
||||||
userId: row.user_id,
|
|
||||||
title: row.title,
|
|
||||||
description: row.description,
|
|
||||||
coverImageUrl: row.cover_image_url,
|
|
||||||
isPublic: row.is_public ?? false,
|
|
||||||
isFeatured: row.is_featured ?? false,
|
|
||||||
featuredAt: row.featured_at ? new Date(row.featured_at) : null,
|
|
||||||
settings: row.settings ?? {},
|
|
||||||
tags: row.tags ?? [],
|
|
||||||
metadata: row.metadata ?? {},
|
|
||||||
createdAt: new Date(row.created_at),
|
|
||||||
updatedAt: new Date(row.updated_at),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 2. Migrate cards
|
|
||||||
await migrateTable('cards', 'cards', cards, (row) => ({
|
|
||||||
id: row.id,
|
|
||||||
deckId: row.deck_id,
|
|
||||||
position: row.position ?? 0,
|
|
||||||
title: row.title,
|
|
||||||
content: row.content,
|
|
||||||
cardType: row.card_type,
|
|
||||||
aiModel: row.ai_model,
|
|
||||||
aiPrompt: row.ai_prompt,
|
|
||||||
version: row.version ?? 1,
|
|
||||||
isFavorite: row.is_favorite ?? false,
|
|
||||||
createdAt: new Date(row.created_at),
|
|
||||||
updatedAt: new Date(row.updated_at),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. Migrate study sessions
|
|
||||||
await migrateTable('study_sessions', 'study_sessions', studySessions, (row) => ({
|
|
||||||
id: row.id,
|
|
||||||
deckId: row.deck_id,
|
|
||||||
userId: row.user_id,
|
|
||||||
mode: row.mode,
|
|
||||||
totalCards: row.total_cards ?? 0,
|
|
||||||
completedCards: row.completed_cards ?? 0,
|
|
||||||
correctCards: row.correct_cards ?? 0,
|
|
||||||
startedAt: new Date(row.started_at),
|
|
||||||
completedAt: row.completed_at ? new Date(row.completed_at) : null,
|
|
||||||
timeSpentSeconds: row.time_spent_seconds ?? 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 4. Migrate card progress
|
|
||||||
await migrateTable('card_progress', 'card_progress', cardProgress, (row) => ({
|
|
||||||
id: row.id,
|
|
||||||
userId: row.user_id,
|
|
||||||
cardId: row.card_id,
|
|
||||||
easeFactor: row.ease_factor?.toString() ?? '2.5',
|
|
||||||
interval: row.interval ?? 0,
|
|
||||||
repetitions: row.repetitions ?? 0,
|
|
||||||
lastReviewed: row.last_reviewed ? new Date(row.last_reviewed) : null,
|
|
||||||
nextReview: row.next_review ? new Date(row.next_review) : null,
|
|
||||||
status: row.status ?? 'new',
|
|
||||||
createdAt: new Date(row.created_at),
|
|
||||||
updatedAt: new Date(row.updated_at),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 5. Migrate deck templates
|
|
||||||
await migrateTable('deck_templates', 'deck_templates', deckTemplates, (row) => ({
|
|
||||||
id: row.id,
|
|
||||||
title: row.title,
|
|
||||||
description: row.description,
|
|
||||||
category: row.category,
|
|
||||||
templateData: row.template_data ?? { cards: [] },
|
|
||||||
isActive: row.is_active ?? true,
|
|
||||||
isPublic: row.is_public ?? true,
|
|
||||||
popularity: row.popularity ?? 0,
|
|
||||||
createdAt: new Date(row.created_at),
|
|
||||||
updatedAt: new Date(row.updated_at),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 6. Migrate AI generations
|
|
||||||
await migrateTable('ai_generations', 'ai_generations', aiGenerations, (row) => ({
|
|
||||||
id: row.id,
|
|
||||||
userId: row.user_id,
|
|
||||||
deckId: row.deck_id,
|
|
||||||
functionName: row.function_name,
|
|
||||||
prompt: row.prompt,
|
|
||||||
model: row.model,
|
|
||||||
status: row.status ?? 'pending',
|
|
||||||
metadata: row.metadata ?? {},
|
|
||||||
completedAt: row.completed_at ? new Date(row.completed_at) : null,
|
|
||||||
createdAt: new Date(row.created_at),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 7. Migrate user stats
|
|
||||||
await migrateTable('user_stats', 'user_stats', userStats, (row) => ({
|
|
||||||
userId: row.user_id,
|
|
||||||
totalWins: row.total_wins ?? 0,
|
|
||||||
totalSessions: row.total_sessions ?? 0,
|
|
||||||
totalCardsStudied: row.total_cards_studied ?? 0,
|
|
||||||
totalTimeSeconds: row.total_time_seconds ?? 0,
|
|
||||||
averageAccuracy: row.average_accuracy?.toString() ?? '0',
|
|
||||||
streakDays: row.streak_days ?? 0,
|
|
||||||
longestStreak: row.longest_streak ?? 0,
|
|
||||||
lastStudyDate: row.last_study_date,
|
|
||||||
createdAt: new Date(row.created_at),
|
|
||||||
updatedAt: new Date(row.updated_at),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Print summary
|
|
||||||
console.log('\n\n=== Migration Summary ===');
|
|
||||||
console.log('-------------------------');
|
|
||||||
let totalMigrated = 0;
|
|
||||||
let totalErrors = 0;
|
|
||||||
for (const stat of stats) {
|
|
||||||
console.log(`${stat.table}: ${stat.migrated} migrated, ${stat.errors} errors`);
|
|
||||||
totalMigrated += stat.migrated;
|
|
||||||
totalErrors += stat.errors;
|
|
||||||
}
|
|
||||||
console.log('-------------------------');
|
|
||||||
console.log(`Total: ${totalMigrated} rows migrated, ${totalErrors} errors`);
|
|
||||||
|
|
||||||
if (totalErrors === 0) {
|
|
||||||
console.log('\n✅ Migration completed successfully!');
|
|
||||||
} else {
|
|
||||||
console.log('\n⚠️ Migration completed with some errors. Please review.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await closeDb();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
|
||||||
import { createClient } from './client.js';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
async function runMigrations() {
|
|
||||||
console.log('Running migrations...');
|
|
||||||
|
|
||||||
const db = createClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await migrate(db, {
|
|
||||||
migrationsFolder: path.join(__dirname, '../drizzle'),
|
|
||||||
});
|
|
||||||
console.log('Migrations completed successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
runMigrations();
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { uuid, text, varchar, timestamp, jsonb, index, pgEnum } from 'drizzle-orm/pg-core';
|
|
||||||
import { relations } from 'drizzle-orm';
|
|
||||||
import { cardsSchema } from './schema.js';
|
|
||||||
import { decks } from './decks.js';
|
|
||||||
|
|
||||||
// AI generation status enum
|
|
||||||
export const aiGenerationStatusEnum = pgEnum('ai_generation_status', [
|
|
||||||
'pending',
|
|
||||||
'processing',
|
|
||||||
'completed',
|
|
||||||
'failed',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// AI generation metadata structure
|
|
||||||
export interface AIGenerationMetadata {
|
|
||||||
inputTokens?: number;
|
|
||||||
outputTokens?: number;
|
|
||||||
totalTokens?: number;
|
|
||||||
duration?: number;
|
|
||||||
error?: string;
|
|
||||||
cardCount?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const aiGenerations = cardsSchema.table(
|
|
||||||
'ai_generations',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
userId: text('user_id').notNull(),
|
|
||||||
deckId: uuid('deck_id').references(() => decks.id, { onDelete: 'set null' }),
|
|
||||||
functionName: varchar('function_name', { length: 100 }).notNull(),
|
|
||||||
prompt: text('prompt').notNull(),
|
|
||||||
model: varchar('model', { length: 100 }),
|
|
||||||
status: aiGenerationStatusEnum('status').default('pending').notNull(),
|
|
||||||
metadata: jsonb('metadata').default({}).$type<AIGenerationMetadata>(),
|
|
||||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index('idx_ai_generations_user_id').on(table.userId),
|
|
||||||
index('idx_ai_generations_deck_id').on(table.deckId),
|
|
||||||
index('idx_ai_generations_status').on(table.status),
|
|
||||||
index('idx_ai_generations_created_at').on(table.createdAt),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const aiGenerationsRelations = relations(aiGenerations, ({ one }) => ({
|
|
||||||
deck: one(decks, {
|
|
||||||
fields: [aiGenerations.deckId],
|
|
||||||
references: [decks.id],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export type AIGeneration = typeof aiGenerations.$inferSelect;
|
|
||||||
export type NewAIGeneration = typeof aiGenerations.$inferInsert;
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import {
|
|
||||||
uuid,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
pgEnum,
|
|
||||||
decimal,
|
|
||||||
unique,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
import { relations } from 'drizzle-orm';
|
|
||||||
import { cardsSchema } from './schema.js';
|
|
||||||
import { cards } from './cards.js';
|
|
||||||
|
|
||||||
// Progress status enum (SM-2 algorithm states)
|
|
||||||
export const progressStatusEnum = pgEnum('progress_status', [
|
|
||||||
'new',
|
|
||||||
'learning',
|
|
||||||
'review',
|
|
||||||
'relearning',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const cardProgress = cardsSchema.table(
|
|
||||||
'card_progress',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
userId: text('user_id').notNull(),
|
|
||||||
cardId: uuid('card_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => cards.id, { onDelete: 'cascade' }),
|
|
||||||
// SM-2 algorithm fields
|
|
||||||
easeFactor: decimal('ease_factor', { precision: 4, scale: 2 }).default('2.5').notNull(),
|
|
||||||
interval: integer('interval').default(0).notNull(), // Days until next review
|
|
||||||
repetitions: integer('repetitions').default(0).notNull(),
|
|
||||||
lastReviewed: timestamp('last_reviewed', { withTimezone: true }),
|
|
||||||
nextReview: timestamp('next_review', { withTimezone: true }),
|
|
||||||
status: progressStatusEnum('status').default('new').notNull(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index('idx_card_progress_user_id').on(table.userId),
|
|
||||||
index('idx_card_progress_card_id').on(table.cardId),
|
|
||||||
index('idx_card_progress_next_review').on(table.nextReview),
|
|
||||||
index('idx_card_progress_status').on(table.status),
|
|
||||||
unique('unique_user_card').on(table.userId, table.cardId),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const cardProgressRelations = relations(cardProgress, ({ one }) => ({
|
|
||||||
card: one(cards, {
|
|
||||||
fields: [cardProgress.cardId],
|
|
||||||
references: [cards.id],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export type CardProgress = typeof cardProgress.$inferSelect;
|
|
||||||
export type NewCardProgress = typeof cardProgress.$inferInsert;
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import {
|
|
||||||
uuid,
|
|
||||||
varchar,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
pgEnum,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
import { relations } from 'drizzle-orm';
|
|
||||||
import { cardsSchema } from './schema.js';
|
|
||||||
import { decks } from './decks.js';
|
|
||||||
import { cardProgress } from './cardProgress.js';
|
|
||||||
|
|
||||||
// Card type enum
|
|
||||||
export const cardTypeEnum = pgEnum('card_type', ['text', 'flashcard', 'quiz', 'mixed']);
|
|
||||||
|
|
||||||
// Card content types
|
|
||||||
export interface TextContent {
|
|
||||||
text: string;
|
|
||||||
formatting?: {
|
|
||||||
bold?: boolean;
|
|
||||||
italic?: boolean;
|
|
||||||
underline?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FlashcardContent {
|
|
||||||
front: string;
|
|
||||||
back: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuizContent {
|
|
||||||
question: string;
|
|
||||||
options: string[];
|
|
||||||
correctAnswer: number;
|
|
||||||
explanation?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MixedContent {
|
|
||||||
sections: Array<TextContent | FlashcardContent | QuizContent>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CardContent = TextContent | FlashcardContent | QuizContent | MixedContent;
|
|
||||||
|
|
||||||
export const cards = cardsSchema.table(
|
|
||||||
'cards',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
deckId: uuid('deck_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
|
||||||
position: integer('position').notNull().default(0),
|
|
||||||
title: varchar('title', { length: 255 }),
|
|
||||||
content: jsonb('content').notNull().$type<CardContent>(),
|
|
||||||
cardType: cardTypeEnum('card_type').notNull(),
|
|
||||||
aiModel: varchar('ai_model', { length: 100 }),
|
|
||||||
aiPrompt: text('ai_prompt'),
|
|
||||||
version: integer('version').default(1).notNull(),
|
|
||||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index('idx_cards_deck_id').on(table.deckId),
|
|
||||||
index('idx_cards_position').on(table.deckId, table.position),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const cardsRelations = relations(cards, ({ one, many }) => ({
|
|
||||||
deck: one(decks, {
|
|
||||||
fields: [cards.deckId],
|
|
||||||
references: [decks.id],
|
|
||||||
}),
|
|
||||||
progress: many(cardProgress),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export type Card = typeof cards.$inferSelect;
|
|
||||||
export type NewCard = typeof cards.$inferInsert;
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { uuid, text, date, integer, decimal, timestamp, index, unique } from 'drizzle-orm/pg-core';
|
|
||||||
import { cardsSchema } from './schema.js';
|
|
||||||
|
|
||||||
export const dailyProgress = cardsSchema.table(
|
|
||||||
'daily_progress',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
userId: text('user_id').notNull(),
|
|
||||||
date: date('date').notNull(),
|
|
||||||
cardsStudied: integer('cards_studied').default(0).notNull(),
|
|
||||||
timeSpentMinutes: integer('time_spent_minutes').default(0).notNull(),
|
|
||||||
accuracyPercentage: decimal('accuracy_percentage', { precision: 5, scale: 2 })
|
|
||||||
.default('0')
|
|
||||||
.notNull(),
|
|
||||||
decksStudied: text('decks_studied').array().default([]),
|
|
||||||
sessionsCompleted: integer('sessions_completed').default(0).notNull(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index('idx_daily_progress_user_id').on(table.userId),
|
|
||||||
index('idx_daily_progress_date').on(table.date),
|
|
||||||
unique('unique_user_date').on(table.userId, table.date),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type DailyProgress = typeof dailyProgress.$inferSelect;
|
|
||||||
export type NewDailyProgress = typeof dailyProgress.$inferInsert;
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import {
|
|
||||||
uuid,
|
|
||||||
varchar,
|
|
||||||
text,
|
|
||||||
boolean,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
import { cardsSchema } from './schema.js';
|
|
||||||
|
|
||||||
// Template data structure
|
|
||||||
export interface DeckTemplateData {
|
|
||||||
cards: Array<{
|
|
||||||
title?: string;
|
|
||||||
content: Record<string, unknown>;
|
|
||||||
cardType: 'text' | 'flashcard' | 'quiz' | 'mixed';
|
|
||||||
}>;
|
|
||||||
settings?: Record<string, unknown>;
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deckTemplates = cardsSchema.table(
|
|
||||||
'deck_templates',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
title: varchar('title', { length: 255 }).notNull(),
|
|
||||||
description: text('description'),
|
|
||||||
category: varchar('category', { length: 100 }),
|
|
||||||
templateData: jsonb('template_data').notNull().$type<DeckTemplateData>(),
|
|
||||||
isActive: boolean('is_active').default(true).notNull(),
|
|
||||||
isPublic: boolean('is_public').default(true).notNull(),
|
|
||||||
popularity: integer('popularity').default(0).notNull(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index('idx_deck_templates_category').on(table.category),
|
|
||||||
index('idx_deck_templates_is_active').on(table.isActive),
|
|
||||||
index('idx_deck_templates_popularity').on(table.popularity),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type DeckTemplate = typeof deckTemplates.$inferSelect;
|
|
||||||
export type NewDeckTemplate = typeof deckTemplates.$inferInsert;
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { uuid, text, varchar, boolean, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
|
|
||||||
import { relations } from 'drizzle-orm';
|
|
||||||
import { cardsSchema } from './schema.js';
|
|
||||||
import { cards } from './cards.js';
|
|
||||||
import { studySessions } from './studySessions.js';
|
|
||||||
import { aiGenerations } from './aiGenerations.js';
|
|
||||||
|
|
||||||
export const decks = cardsSchema.table(
|
|
||||||
'decks',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
userId: text('user_id').notNull(),
|
|
||||||
title: varchar('title', { length: 255 }).notNull(),
|
|
||||||
description: text('description'),
|
|
||||||
coverImageUrl: text('cover_image_url'),
|
|
||||||
isPublic: boolean('is_public').default(false).notNull(),
|
|
||||||
isFeatured: boolean('is_featured').default(false).notNull(),
|
|
||||||
featuredAt: timestamp('featured_at', { withTimezone: true }),
|
|
||||||
settings: jsonb('settings').default({}).$type<Record<string, unknown>>(),
|
|
||||||
tags: text('tags').array().default([]),
|
|
||||||
metadata: jsonb('metadata').default({}).$type<Record<string, unknown>>(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index('idx_decks_user_id').on(table.userId),
|
|
||||||
index('idx_decks_is_public').on(table.isPublic),
|
|
||||||
index('idx_decks_is_featured').on(table.isFeatured),
|
|
||||||
index('idx_decks_updated_at').on(table.updatedAt),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const decksRelations = relations(decks, ({ many }) => ({
|
|
||||||
cards: many(cards),
|
|
||||||
studySessions: many(studySessions),
|
|
||||||
aiGenerations: many(aiGenerations),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export type Deck = typeof decks.$inferSelect;
|
|
||||||
export type NewDeck = typeof decks.$inferInsert;
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
// Export schema definition
|
|
||||||
export * from './schema.js';
|
|
||||||
|
|
||||||
// Export all schemas
|
|
||||||
export * from './decks.js';
|
|
||||||
export * from './cards.js';
|
|
||||||
export * from './studySessions.js';
|
|
||||||
export * from './cardProgress.js';
|
|
||||||
export * from './deckTemplates.js';
|
|
||||||
export * from './aiGenerations.js';
|
|
||||||
export * from './userStats.js';
|
|
||||||
export * from './dailyProgress.js';
|
|
||||||
|
|
||||||
// Re-export relations for use with Drizzle query builder
|
|
||||||
export { decksRelations } from './decks.js';
|
|
||||||
export { cardsRelations } from './cards.js';
|
|
||||||
export { studySessionsRelations } from './studySessions.js';
|
|
||||||
export { cardProgressRelations } from './cardProgress.js';
|
|
||||||
export { aiGenerationsRelations } from './aiGenerations.js';
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { pgSchema } from 'drizzle-orm/pg-core';
|
|
||||||
|
|
||||||
export const cardsSchema = pgSchema('cards');
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { uuid, text, integer, timestamp, index, pgEnum } from 'drizzle-orm/pg-core';
|
|
||||||
import { relations } from 'drizzle-orm';
|
|
||||||
import { cardsSchema } from './schema.js';
|
|
||||||
import { decks } from './decks.js';
|
|
||||||
|
|
||||||
// Study mode enum
|
|
||||||
export const studyModeEnum = pgEnum('study_mode', ['all', 'new', 'review', 'favorites', 'random']);
|
|
||||||
|
|
||||||
export const studySessions = cardsSchema.table(
|
|
||||||
'study_sessions',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
deckId: uuid('deck_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
|
||||||
userId: text('user_id').notNull(),
|
|
||||||
mode: studyModeEnum('mode').notNull(),
|
|
||||||
totalCards: integer('total_cards').notNull().default(0),
|
|
||||||
completedCards: integer('completed_cards').notNull().default(0),
|
|
||||||
correctCards: integer('correct_cards').notNull().default(0),
|
|
||||||
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
||||||
timeSpentSeconds: integer('time_spent_seconds').default(0).notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index('idx_study_sessions_user_id').on(table.userId),
|
|
||||||
index('idx_study_sessions_deck_id').on(table.deckId),
|
|
||||||
index('idx_study_sessions_started_at').on(table.startedAt),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const studySessionsRelations = relations(studySessions, ({ one }) => ({
|
|
||||||
deck: one(decks, {
|
|
||||||
fields: [studySessions.deckId],
|
|
||||||
references: [decks.id],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export type StudySession = typeof studySessions.$inferSelect;
|
|
||||||
export type NewStudySession = typeof studySessions.$inferInsert;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { text, integer, decimal, date, timestamp, index } from 'drizzle-orm/pg-core';
|
|
||||||
import { cardsSchema } from './schema.js';
|
|
||||||
|
|
||||||
export const userStats = cardsSchema.table(
|
|
||||||
'user_stats',
|
|
||||||
{
|
|
||||||
userId: text('user_id').primaryKey(),
|
|
||||||
totalWins: integer('total_wins').default(0).notNull(),
|
|
||||||
totalSessions: integer('total_sessions').default(0).notNull(),
|
|
||||||
totalCardsStudied: integer('total_cards_studied').default(0).notNull(),
|
|
||||||
totalTimeSeconds: integer('total_time_seconds').default(0).notNull(),
|
|
||||||
averageAccuracy: decimal('average_accuracy', { precision: 5, scale: 2 }).default('0').notNull(),
|
|
||||||
streakDays: integer('streak_days').default(0).notNull(),
|
|
||||||
longestStreak: integer('longest_streak').default(0).notNull(),
|
|
||||||
lastStudyDate: date('last_study_date'),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index('idx_user_stats_total_wins').on(table.totalWins),
|
|
||||||
index('idx_user_stats_streak_days').on(table.streakDays),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type UserStats = typeof userStats.$inferSelect;
|
|
||||||
export type NewUserStats = typeof userStats.$inferInsert;
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { getDb, closeDb } from './client.js';
|
|
||||||
import { deckTemplates } from './schema/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed the database with initial data
|
|
||||||
*/
|
|
||||||
async function seed() {
|
|
||||||
console.log('Seeding database...');
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Seed deck templates
|
|
||||||
const templates = [
|
|
||||||
{
|
|
||||||
title: 'Language Basics',
|
|
||||||
description: 'Learn basic vocabulary and phrases for a new language',
|
|
||||||
category: 'languages',
|
|
||||||
templateData: {
|
|
||||||
cards: [
|
|
||||||
{
|
|
||||||
cardType: 'flashcard' as const,
|
|
||||||
content: { front: 'Hello', back: 'Hallo', hint: 'Greeting' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cardType: 'flashcard' as const,
|
|
||||||
content: { front: 'Goodbye', back: 'Auf Wiedersehen' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
settings: { language: 'de' },
|
|
||||||
tags: ['language', 'basics', 'german'],
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
isPublic: true,
|
|
||||||
popularity: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Math Fundamentals',
|
|
||||||
description: 'Essential math concepts and formulas',
|
|
||||||
category: 'education',
|
|
||||||
templateData: {
|
|
||||||
cards: [
|
|
||||||
{
|
|
||||||
cardType: 'quiz' as const,
|
|
||||||
content: {
|
|
||||||
question: 'What is 2 + 2?',
|
|
||||||
options: ['3', '4', '5', '6'],
|
|
||||||
correctAnswer: 1,
|
|
||||||
explanation: '2 + 2 equals 4',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
settings: { difficulty: 'beginner' },
|
|
||||||
tags: ['math', 'basics'],
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
isPublic: true,
|
|
||||||
popularity: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Programming Concepts',
|
|
||||||
description: 'Core programming concepts and terminology',
|
|
||||||
category: 'technology',
|
|
||||||
templateData: {
|
|
||||||
cards: [
|
|
||||||
{
|
|
||||||
cardType: 'flashcard' as const,
|
|
||||||
content: {
|
|
||||||
front: 'Variable',
|
|
||||||
back: 'A named storage location in memory that holds a value',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cardType: 'flashcard' as const,
|
|
||||||
content: {
|
|
||||||
front: 'Function',
|
|
||||||
back: 'A reusable block of code that performs a specific task',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
settings: {},
|
|
||||||
tags: ['programming', 'coding', 'basics'],
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
isPublic: true,
|
|
||||||
popularity: 90,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log('Inserting deck templates...');
|
|
||||||
await db.insert(deckTemplates).values(templates).onConflictDoNothing();
|
|
||||||
|
|
||||||
console.log('Seeding completed successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Seeding failed:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await closeDb();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seed().catch((error) => {
|
|
||||||
console.error('Seed script failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
/**
|
|
||||||
* Test database connection
|
|
||||||
* Usage: pnpm db:test
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getDb, closeDb, sql } from './client.js';
|
|
||||||
|
|
||||||
async function testConnection() {
|
|
||||||
console.log('Testing database connection...\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Test basic connection
|
|
||||||
const result = await db.execute(sql`SELECT NOW() as current_time, version() as pg_version`);
|
|
||||||
console.log('✅ Connection successful!');
|
|
||||||
console.log(` Time: ${result[0].current_time}`);
|
|
||||||
console.log(` PostgreSQL: ${result[0].pg_version}\n`);
|
|
||||||
|
|
||||||
// List tables
|
|
||||||
const tables = await db.execute(sql`
|
|
||||||
SELECT tablename
|
|
||||||
FROM pg_tables
|
|
||||||
WHERE schemaname = 'public'
|
|
||||||
ORDER BY tablename
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (tables.length > 0) {
|
|
||||||
console.log('📋 Tables in database:');
|
|
||||||
tables.forEach((t: any) => console.log(` - ${t.tablename}`));
|
|
||||||
} else {
|
|
||||||
console.log('📋 No tables found. Run "pnpm db:push" to create schema.');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ All tests passed!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Connection failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await closeDb();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testConnection();
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@mana/shared-api-client",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "./src/index.ts",
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"default": "./src/index.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"type-check": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mana/shared-utils": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
/**
|
|
||||||
* API Client Factory
|
|
||||||
* Creates a configured API client with consistent error handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ApiClient, ApiClientConfig, ApiResult, RequestOptions } from './types';
|
|
||||||
import {
|
|
||||||
buildQueryString,
|
|
||||||
createApiError,
|
|
||||||
getBaseUrl,
|
|
||||||
getErrorCodeFromStatus,
|
|
||||||
isRetryableError,
|
|
||||||
parseErrorResponse,
|
|
||||||
} from './utils';
|
|
||||||
import { sleep } from '@mana/shared-utils';
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT = 30000;
|
|
||||||
const DEFAULT_RETRIES = 0;
|
|
||||||
const DEFAULT_RETRY_DELAY = 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a configured API client instance
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* import { createApiClient } from '@mana/shared-api-client';
|
|
||||||
* import { authStore } from '$lib/stores/auth.svelte';
|
|
||||||
*
|
|
||||||
* export const api = createApiClient({
|
|
||||||
* baseUrl: 'http://localhost:3014',
|
|
||||||
* apiPrefix: '/api/v1',
|
|
||||||
* getAuthToken: () => authStore.getValidToken(),
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Usage
|
|
||||||
* const { data, error } = await api.get<User[]>('/users');
|
|
||||||
* if (error) {
|
|
||||||
* console.error('Failed:', error.message);
|
|
||||||
* return;
|
|
||||||
* }
|
|
||||||
* // data is typed as User[]
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createApiClient(config: ApiClientConfig): ApiClient {
|
|
||||||
const {
|
|
||||||
apiPrefix = '',
|
|
||||||
getAuthToken,
|
|
||||||
timeout = DEFAULT_TIMEOUT,
|
|
||||||
retries = DEFAULT_RETRIES,
|
|
||||||
retryDelay = DEFAULT_RETRY_DELAY,
|
|
||||||
onError,
|
|
||||||
debug = false,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal fetch with error handling, timeout, and retries
|
|
||||||
*/
|
|
||||||
async function fetchWithRetry<T>(
|
|
||||||
endpoint: string,
|
|
||||||
init: RequestInit,
|
|
||||||
options: RequestOptions = {},
|
|
||||||
attemptNum = 0
|
|
||||||
): Promise<ApiResult<T>> {
|
|
||||||
const baseUrl = config.useRuntimeUrl !== false ? getBaseUrl(config.baseUrl) : config.baseUrl;
|
|
||||||
const queryString = options.params ? buildQueryString(options.params) : '';
|
|
||||||
const url = baseUrl + apiPrefix + endpoint + queryString;
|
|
||||||
const requestTimeout = options.timeout ?? timeout;
|
|
||||||
const maxRetries = options.retries ?? retries;
|
|
||||||
|
|
||||||
// Create abort controller for timeout
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get auth token if not skipping
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
...((init.headers as Record<string, string>) || {}),
|
|
||||||
...(options.headers || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.skipAuth && getAuthToken) {
|
|
||||||
const token = await getAuthToken();
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = 'Bearer ' + token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
console.log('[API] ' + init.method + ' ' + url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
// Handle 204 No Content
|
|
||||||
if (response.status === 204) {
|
|
||||||
return { data: null as T, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle error responses
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = await parseErrorResponse(response);
|
|
||||||
const error = createApiError(
|
|
||||||
errorMessage,
|
|
||||||
getErrorCodeFromStatus(response.status),
|
|
||||||
response.status
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retry on server errors
|
|
||||||
if (isRetryableError(error) && attemptNum < maxRetries) {
|
|
||||||
if (debug) {
|
|
||||||
console.log('[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' for ' + url);
|
|
||||||
}
|
|
||||||
await sleep(retryDelay * (attemptNum + 1)); // Exponential backoff
|
|
||||||
return fetchWithRetry<T>(endpoint, init, options, attemptNum + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onError) {
|
|
||||||
onError(error, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: null, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON response
|
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
if (contentType?.includes('application/json')) {
|
|
||||||
const data = await response.json();
|
|
||||||
return { data, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle non-JSON responses (e.g., text, blob)
|
|
||||||
const text = await response.text();
|
|
||||||
return { data: text as T, error: null };
|
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
// Handle abort (timeout)
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
||||||
const error = createApiError('Request timed out after ' + requestTimeout + 'ms', 'TIMEOUT');
|
|
||||||
|
|
||||||
if (attemptNum < maxRetries) {
|
|
||||||
if (debug) {
|
|
||||||
console.log(
|
|
||||||
'[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' after timeout for ' + url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await sleep(retryDelay * (attemptNum + 1));
|
|
||||||
return fetchWithRetry<T>(endpoint, init, options, attemptNum + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onError) {
|
|
||||||
onError(error, endpoint);
|
|
||||||
}
|
|
||||||
return { data: null, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle network errors
|
|
||||||
const error = createApiError(
|
|
||||||
err instanceof Error ? err.message : 'Network error',
|
|
||||||
'NETWORK_ERROR'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (attemptNum < maxRetries) {
|
|
||||||
if (debug) {
|
|
||||||
console.log(
|
|
||||||
'[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' after network error for ' + url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await sleep(retryDelay * (attemptNum + 1));
|
|
||||||
return fetchWithRetry<T>(endpoint, init, options, attemptNum + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onError) {
|
|
||||||
onError(error, endpoint);
|
|
||||||
}
|
|
||||||
return { data: null, error };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepare request body and headers
|
|
||||||
*/
|
|
||||||
function prepareBody(body: unknown): { body?: string; contentType?: string } {
|
|
||||||
if (body === undefined || body === null) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body instanceof FormData) {
|
|
||||||
// Don't set Content-Type for FormData - browser handles it
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
async get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResult<T>> {
|
|
||||||
return fetchWithRetry<T>(
|
|
||||||
endpoint,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async post<T>(
|
|
||||||
endpoint: string,
|
|
||||||
body?: unknown,
|
|
||||||
options?: RequestOptions
|
|
||||||
): Promise<ApiResult<T>> {
|
|
||||||
const { body: jsonBody, contentType } = prepareBody(body);
|
|
||||||
return fetchWithRetry<T>(
|
|
||||||
endpoint,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(contentType ? { 'Content-Type': contentType } : {}),
|
|
||||||
},
|
|
||||||
body: jsonBody,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async put<T>(
|
|
||||||
endpoint: string,
|
|
||||||
body?: unknown,
|
|
||||||
options?: RequestOptions
|
|
||||||
): Promise<ApiResult<T>> {
|
|
||||||
const { body: jsonBody, contentType } = prepareBody(body);
|
|
||||||
return fetchWithRetry<T>(
|
|
||||||
endpoint,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(contentType ? { 'Content-Type': contentType } : {}),
|
|
||||||
},
|
|
||||||
body: jsonBody,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async patch<T>(
|
|
||||||
endpoint: string,
|
|
||||||
body?: unknown,
|
|
||||||
options?: RequestOptions
|
|
||||||
): Promise<ApiResult<T>> {
|
|
||||||
const { body: jsonBody, contentType } = prepareBody(body);
|
|
||||||
return fetchWithRetry<T>(
|
|
||||||
endpoint,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(contentType ? { 'Content-Type': contentType } : {}),
|
|
||||||
},
|
|
||||||
body: jsonBody,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResult<T>> {
|
|
||||||
return fetchWithRetry<T>(
|
|
||||||
endpoint,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async upload<T>(
|
|
||||||
endpoint: string,
|
|
||||||
formData: FormData,
|
|
||||||
options?: RequestOptions
|
|
||||||
): Promise<ApiResult<T>> {
|
|
||||||
return fetchWithRetry<T>(
|
|
||||||
endpoint,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
// Don't set Content-Type - browser handles multipart boundary
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
/**
|
|
||||||
* @mana/shared-api-client
|
|
||||||
*
|
|
||||||
* Unified API client for all Mana web applications.
|
|
||||||
* Provides consistent error handling, token management, and retry logic.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* import { createApiClient } from '@mana/shared-api-client';
|
|
||||||
* import { authStore } from '$lib/stores/auth.svelte';
|
|
||||||
*
|
|
||||||
* // Create client instance
|
|
||||||
* export const api = createApiClient({
|
|
||||||
* baseUrl: 'http://localhost:3014',
|
|
||||||
* apiPrefix: '/api/v1',
|
|
||||||
* getAuthToken: () => authStore.getValidToken(),
|
|
||||||
* timeout: 30000,
|
|
||||||
* retries: 2,
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Make requests
|
|
||||||
* const { data, error } = await api.get<User[]>('/users');
|
|
||||||
*
|
|
||||||
* if (error) {
|
|
||||||
* if (error.code === 'UNAUTHORIZED') {
|
|
||||||
* // Handle auth error
|
|
||||||
* }
|
|
||||||
* console.error('API Error:', error.message);
|
|
||||||
* return;
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // data is typed as User[]
|
|
||||||
* console.log('Users:', data);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Client factory
|
|
||||||
export { createApiClient } from './client';
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export type {
|
|
||||||
ApiClient,
|
|
||||||
ApiClientConfig,
|
|
||||||
ApiError,
|
|
||||||
ApiErrorCode,
|
|
||||||
ApiResult,
|
|
||||||
RequestOptions,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
// Utilities
|
|
||||||
export { buildQueryString, getBaseUrl } from './utils';
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
/**
|
|
||||||
* API Client Types
|
|
||||||
* Go-style Result pattern for consistent error handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result wrapper for API responses
|
|
||||||
* Provides explicit success/error handling without try/catch
|
|
||||||
*/
|
|
||||||
export interface ApiResult<T> {
|
|
||||||
data: T | null;
|
|
||||||
error: ApiError | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Structured API error with type information
|
|
||||||
*/
|
|
||||||
export interface ApiError {
|
|
||||||
message: string;
|
|
||||||
code: ApiErrorCode;
|
|
||||||
status?: number;
|
|
||||||
details?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error codes for different failure scenarios
|
|
||||||
*/
|
|
||||||
export type ApiErrorCode =
|
|
||||||
| 'NETWORK_ERROR'
|
|
||||||
| 'TIMEOUT'
|
|
||||||
| 'UNAUTHORIZED'
|
|
||||||
| 'FORBIDDEN'
|
|
||||||
| 'NOT_FOUND'
|
|
||||||
| 'VALIDATION_ERROR'
|
|
||||||
| 'SERVER_ERROR'
|
|
||||||
| 'UNKNOWN';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for creating an API client
|
|
||||||
*/
|
|
||||||
export interface ApiClientConfig {
|
|
||||||
/** Base URL for API requests (e.g., 'http://localhost:3014') */
|
|
||||||
baseUrl: string;
|
|
||||||
|
|
||||||
/** API prefix to prepend to all endpoints (e.g., '/api/v1') */
|
|
||||||
apiPrefix?: string;
|
|
||||||
|
|
||||||
/** Async function to get the current auth token (supports auto-refresh) */
|
|
||||||
getAuthToken?: () => Promise<string | null>;
|
|
||||||
|
|
||||||
/** Request timeout in milliseconds (default: 30000) */
|
|
||||||
timeout?: number;
|
|
||||||
|
|
||||||
/** Number of retry attempts for failed requests (default: 0) */
|
|
||||||
retries?: number;
|
|
||||||
|
|
||||||
/** Delay between retries in milliseconds (default: 1000) */
|
|
||||||
retryDelay?: number;
|
|
||||||
|
|
||||||
/** Custom error handler for logging/reporting */
|
|
||||||
onError?: (error: ApiError, endpoint: string) => void;
|
|
||||||
|
|
||||||
/** Enable debug logging (default: false) */
|
|
||||||
debug?: boolean;
|
|
||||||
|
|
||||||
/** Use window.__PUBLIC_BACKEND_URL__ runtime override (default: true).
|
|
||||||
* Set to false for cross-app clients that resolve their own base URL. */
|
|
||||||
useRuntimeUrl?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for individual requests
|
|
||||||
*/
|
|
||||||
export interface RequestOptions {
|
|
||||||
/** Custom headers to merge with defaults */
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
|
|
||||||
/** Override timeout for this request */
|
|
||||||
timeout?: number;
|
|
||||||
|
|
||||||
/** Skip authentication for this request */
|
|
||||||
skipAuth?: boolean;
|
|
||||||
|
|
||||||
/** Query parameters to append to URL */
|
|
||||||
params?: Record<string, string | number | boolean | undefined>;
|
|
||||||
|
|
||||||
/** Override retry count for this request */
|
|
||||||
retries?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API client interface with HTTP methods
|
|
||||||
*/
|
|
||||||
export interface ApiClient {
|
|
||||||
/** GET request */
|
|
||||||
get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResult<T>>;
|
|
||||||
|
|
||||||
/** POST request */
|
|
||||||
post<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResult<T>>;
|
|
||||||
|
|
||||||
/** PUT request */
|
|
||||||
put<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResult<T>>;
|
|
||||||
|
|
||||||
/** PATCH request */
|
|
||||||
patch<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResult<T>>;
|
|
||||||
|
|
||||||
/** DELETE request */
|
|
||||||
delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResult<T>>;
|
|
||||||
|
|
||||||
/** Upload file(s) with FormData */
|
|
||||||
upload<T>(endpoint: string, formData: FormData, options?: RequestOptions): Promise<ApiResult<T>>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
/**
|
|
||||||
* API Client Utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ApiError, ApiErrorCode } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a query string from parameters object
|
|
||||||
* Handles undefined values and proper encoding
|
|
||||||
*/
|
|
||||||
export function buildQueryString(
|
|
||||||
params: Record<string, string | number | boolean | undefined>
|
|
||||||
): string {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
|
||||||
searchParams.append(key, String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
|
||||||
return queryString ? `?${queryString}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine error code from HTTP status
|
|
||||||
*/
|
|
||||||
export function getErrorCodeFromStatus(status: number): ApiErrorCode {
|
|
||||||
if (status === 401) return 'UNAUTHORIZED';
|
|
||||||
if (status === 403) return 'FORBIDDEN';
|
|
||||||
if (status === 404) return 'NOT_FOUND';
|
|
||||||
if (status === 422 || status === 400) return 'VALIDATION_ERROR';
|
|
||||||
if (status >= 500) return 'SERVER_ERROR';
|
|
||||||
return 'UNKNOWN';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a standardized API error
|
|
||||||
*/
|
|
||||||
export function createApiError(
|
|
||||||
message: string,
|
|
||||||
code: ApiErrorCode,
|
|
||||||
status?: number,
|
|
||||||
details?: unknown
|
|
||||||
): ApiError {
|
|
||||||
return { message, code, status, details };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse error response body
|
|
||||||
*/
|
|
||||||
export async function parseErrorResponse(response: Response): Promise<string> {
|
|
||||||
try {
|
|
||||||
const data = await response.json();
|
|
||||||
return data.message || data.error || JSON.stringify(data);
|
|
||||||
} catch {
|
|
||||||
return response.statusText || 'Unknown error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is retryable (network issues, 5xx errors)
|
|
||||||
*/
|
|
||||||
export function isRetryableError(error: ApiError): boolean {
|
|
||||||
if (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (error.status && error.status >= 500) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get base URL with runtime injection support for Docker
|
|
||||||
* Checks window.__PUBLIC_BACKEND_URL__ first, then falls back to provided URL
|
|
||||||
*/
|
|
||||||
export function getBaseUrl(configuredUrl: string): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const runtimeUrl = (window as unknown as Record<string, unknown>).__PUBLIC_BACKEND_URL__;
|
|
||||||
if (typeof runtimeUrl === 'string' && runtimeUrl) {
|
|
||||||
return runtimeUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return configuredUrl;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@mana/shared-errors",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "Go-like error handling system for Mana backends",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"default": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./nestjs": {
|
|
||||||
"types": "./dist/nestjs/index.d.ts",
|
|
||||||
"default": "./dist/nestjs/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"typesVersions": {
|
|
||||||
"*": {
|
|
||||||
"nestjs": [
|
|
||||||
"./dist/nestjs/index.d.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc -p tsconfig.build.json",
|
|
||||||
"type-check": "tsc --noEmit",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"lint": "eslint ."
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@nestjs/common": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@nestjs/common": "^11.0.17",
|
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import {
|
|
||||||
type ErrorCode,
|
|
||||||
ERROR_CODE_TO_HTTP_STATUS,
|
|
||||||
ERROR_CODE_RETRYABLE,
|
|
||||||
} from '../types/error-codes';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional context that can be attached to errors.
|
|
||||||
*/
|
|
||||||
export interface ErrorContext {
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for creating an AppError.
|
|
||||||
*/
|
|
||||||
export interface AppErrorOptions {
|
|
||||||
code: ErrorCode;
|
|
||||||
message: string;
|
|
||||||
cause?: Error | AppError;
|
|
||||||
context?: ErrorContext;
|
|
||||||
httpStatus?: number;
|
|
||||||
retryable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base error class for all application errors.
|
|
||||||
*
|
|
||||||
* Follows Go-like error handling principles:
|
|
||||||
* - Errors are values, not exceptions
|
|
||||||
* - Support for error wrapping with context
|
|
||||||
* - Type-safe error checking
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Create a basic error
|
|
||||||
* const error = new AppError({
|
|
||||||
* code: ErrorCode.VALIDATION_FAILED,
|
|
||||||
* message: 'Invalid email format',
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Wrap an error with context (Go-like)
|
|
||||||
* const wrapped = error.wrap('validating user input');
|
|
||||||
* // Message becomes: "validating user input: Invalid email format"
|
|
||||||
*
|
|
||||||
* // Check error codes (like Go's errors.Is)
|
|
||||||
* if (error.hasCode(ErrorCode.VALIDATION_FAILED)) {
|
|
||||||
* // Handle validation error
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class AppError extends Error {
|
|
||||||
/** Standardized error code */
|
|
||||||
readonly code: ErrorCode;
|
|
||||||
|
|
||||||
/** HTTP status code for API responses */
|
|
||||||
readonly httpStatus: number;
|
|
||||||
|
|
||||||
/** Whether the operation can be retried */
|
|
||||||
readonly retryable: boolean;
|
|
||||||
|
|
||||||
/** Original error that caused this error (for wrapping) */
|
|
||||||
readonly cause?: Error | AppError;
|
|
||||||
|
|
||||||
/** Additional context information */
|
|
||||||
readonly context: ErrorContext;
|
|
||||||
|
|
||||||
/** Timestamp when error was created */
|
|
||||||
readonly timestamp: string;
|
|
||||||
|
|
||||||
constructor(options: AppErrorOptions) {
|
|
||||||
super(options.message);
|
|
||||||
this.name = 'AppError';
|
|
||||||
this.code = options.code;
|
|
||||||
this.cause = options.cause;
|
|
||||||
this.context = options.context ?? {};
|
|
||||||
this.timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
// Use provided values or defaults from mappings
|
|
||||||
this.httpStatus = options.httpStatus ?? ERROR_CODE_TO_HTTP_STATUS[options.code];
|
|
||||||
this.retryable = options.retryable ?? ERROR_CODE_RETRYABLE[options.code];
|
|
||||||
|
|
||||||
// Capture stack trace
|
|
||||||
Error.captureStackTrace(this, this.constructor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a wrapped error with additional context.
|
|
||||||
* Similar to Go's `fmt.Errorf("context: %w", err)`.
|
|
||||||
*
|
|
||||||
* @param contextMessage - Description of the operation that failed
|
|
||||||
* @param additionalContext - Extra context data to include
|
|
||||||
* @returns A new AppError with the original as its cause
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const wrapped = originalError.wrap('fetching user data');
|
|
||||||
* // Message: "fetching user data: original message"
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
wrap(contextMessage: string, additionalContext?: ErrorContext): AppError {
|
|
||||||
return new AppError({
|
|
||||||
code: this.code,
|
|
||||||
message: `${contextMessage}: ${this.message}`,
|
|
||||||
cause: this,
|
|
||||||
context: { ...this.context, ...additionalContext },
|
|
||||||
httpStatus: this.httpStatus,
|
|
||||||
retryable: this.retryable,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the root cause of the error chain.
|
|
||||||
* Traverses the cause chain to find the original error.
|
|
||||||
*/
|
|
||||||
rootCause(): Error {
|
|
||||||
let current: Error = this;
|
|
||||||
while (current instanceof AppError && current.cause) {
|
|
||||||
current = current.cause;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this error or any in the chain has the given code.
|
|
||||||
* Similar to Go's `errors.Is()`.
|
|
||||||
*
|
|
||||||
* @param code - The error code to check for
|
|
||||||
* @returns true if this error or any cause has the given code
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* if (error.hasCode(ErrorCode.INSUFFICIENT_CREDITS)) {
|
|
||||||
* // Show upgrade prompt
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
hasCode(code: ErrorCode): boolean {
|
|
||||||
let current: Error | undefined = this;
|
|
||||||
while (current) {
|
|
||||||
if (current instanceof AppError && current.code === code) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
current = current instanceof AppError ? current.cause : undefined;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert to JSON for API responses.
|
|
||||||
* Excludes stack traces and internal details.
|
|
||||||
*/
|
|
||||||
toJSON(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
code: this.code,
|
|
||||||
message: this.message,
|
|
||||||
httpStatus: this.httpStatus,
|
|
||||||
retryable: this.retryable,
|
|
||||||
timestamp: this.timestamp,
|
|
||||||
...(Object.keys(this.context).length > 0 && { details: this.context }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert to full JSON including stack and cause (for logging).
|
|
||||||
* Use this for server-side logging, not client responses.
|
|
||||||
*/
|
|
||||||
toFullJSON(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
...this.toJSON(),
|
|
||||||
stack: this.stack,
|
|
||||||
cause: this.cause instanceof AppError ? this.cause.toFullJSON() : this.cause?.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { AppError } from './app-error';
|
|
||||||
import type { ErrorContext } from './app-error';
|
|
||||||
|
|
||||||
type AuthErrorCode =
|
|
||||||
| ErrorCode.AUTHENTICATION_REQUIRED
|
|
||||||
| ErrorCode.INVALID_TOKEN
|
|
||||||
| ErrorCode.TOKEN_EXPIRED
|
|
||||||
| ErrorCode.PERMISSION_DENIED
|
|
||||||
| ErrorCode.RESOURCE_NOT_OWNED;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error for authentication and authorization failures.
|
|
||||||
* HTTP Status: 401 (auth) or 403 (authorization)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Authentication errors (401)
|
|
||||||
* return err(AuthError.unauthorized());
|
|
||||||
* return err(AuthError.invalidToken('Token has been revoked'));
|
|
||||||
* return err(AuthError.tokenExpired());
|
|
||||||
*
|
|
||||||
* // Authorization errors (403)
|
|
||||||
* return err(AuthError.forbidden('Admin access required'));
|
|
||||||
* return err(AuthError.notOwned('Story', storyId));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class AuthError extends AppError {
|
|
||||||
constructor(code: AuthErrorCode, message: string, context?: ErrorContext) {
|
|
||||||
super({ code, message, context });
|
|
||||||
this.name = 'AuthError';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error for missing authentication.
|
|
||||||
* HTTP 401 Unauthorized
|
|
||||||
*/
|
|
||||||
static unauthorized(message = 'Authentication required'): AuthError {
|
|
||||||
return new AuthError(ErrorCode.AUTHENTICATION_REQUIRED, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error for an invalid token.
|
|
||||||
* HTTP 401 Unauthorized
|
|
||||||
*/
|
|
||||||
static invalidToken(message = 'Invalid or malformed token'): AuthError {
|
|
||||||
return new AuthError(ErrorCode.INVALID_TOKEN, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error for an expired token.
|
|
||||||
* HTTP 401 Unauthorized
|
|
||||||
*/
|
|
||||||
static tokenExpired(message = 'Token has expired'): AuthError {
|
|
||||||
return new AuthError(ErrorCode.TOKEN_EXPIRED, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error for insufficient permissions.
|
|
||||||
* HTTP 403 Forbidden
|
|
||||||
*/
|
|
||||||
static forbidden(message = 'Permission denied'): AuthError {
|
|
||||||
return new AuthError(ErrorCode.PERMISSION_DENIED, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error when a user tries to access a resource they don't own.
|
|
||||||
* HTTP 403 Forbidden
|
|
||||||
*
|
|
||||||
* @param resourceType - Type of resource (e.g., 'Story', 'Character')
|
|
||||||
* @param resourceId - ID of the resource
|
|
||||||
*/
|
|
||||||
static notOwned(resourceType: string, resourceId: string): AuthError {
|
|
||||||
return new AuthError(ErrorCode.RESOURCE_NOT_OWNED, `${resourceType} does not belong to you`, {
|
|
||||||
resourceType,
|
|
||||||
resourceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { AppError } from './app-error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error for insufficient credits/mana.
|
|
||||||
* HTTP Status: 402 Payment Required
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* return err(new CreditError(100, 50, 'story_generation'));
|
|
||||||
* // Message: "Insufficient credits. Required: 100, Available: 50"
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class CreditError extends AppError {
|
|
||||||
/** Credits required for the operation */
|
|
||||||
readonly requiredCredits: number;
|
|
||||||
|
|
||||||
/** Credits currently available */
|
|
||||||
readonly availableCredits: number;
|
|
||||||
|
|
||||||
constructor(requiredCredits: number, availableCredits: number, operation?: string) {
|
|
||||||
super({
|
|
||||||
code: ErrorCode.INSUFFICIENT_CREDITS,
|
|
||||||
message: `Insufficient credits. Required: ${requiredCredits}, Available: ${availableCredits}`,
|
|
||||||
context: { requiredCredits, availableCredits, operation },
|
|
||||||
});
|
|
||||||
this.name = 'CreditError';
|
|
||||||
this.requiredCredits = requiredCredits;
|
|
||||||
this.availableCredits = availableCredits;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { AppError } from './app-error';
|
|
||||||
import type { ErrorContext } from './app-error';
|
|
||||||
|
|
||||||
type DatabaseErrorCode = ErrorCode.DATABASE_ERROR | ErrorCode.CONSTRAINT_VIOLATION;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error for database-level failures.
|
|
||||||
* HTTP Status: 500 (database), 409 (constraint violation)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Constraint violation (e.g., unique constraint)
|
|
||||||
* return err(DatabaseError.constraintViolation('email', 'Email already exists'));
|
|
||||||
*
|
|
||||||
* // Generic database error
|
|
||||||
* return err(DatabaseError.queryFailed('Failed to fetch user data', originalError));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class DatabaseError extends AppError {
|
|
||||||
constructor(code: DatabaseErrorCode, message: string, cause?: Error, context?: ErrorContext) {
|
|
||||||
super({ code, message, cause, context });
|
|
||||||
this.name = 'DatabaseError';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a constraint violation error (e.g., unique constraint).
|
|
||||||
*
|
|
||||||
* @param field - The field that violated the constraint
|
|
||||||
* @param message - Description of the violation
|
|
||||||
*/
|
|
||||||
static constraintViolation(field: string, message: string): DatabaseError {
|
|
||||||
return new DatabaseError(ErrorCode.CONSTRAINT_VIOLATION, message, undefined, { field });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a generic database query error.
|
|
||||||
*
|
|
||||||
* @param message - Description of what went wrong
|
|
||||||
* @param cause - Original error if available
|
|
||||||
*/
|
|
||||||
static queryFailed(message: string, cause?: Error): DatabaseError {
|
|
||||||
return new DatabaseError(ErrorCode.DATABASE_ERROR, message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export { AppError, type ErrorContext, type AppErrorOptions } from './app-error';
|
|
||||||
export { ValidationError } from './validation-error';
|
|
||||||
export { AuthError } from './auth-error';
|
|
||||||
export { NotFoundError } from './not-found-error';
|
|
||||||
export { CreditError } from './credit-error';
|
|
||||||
export { ServiceError } from './service-error';
|
|
||||||
export { RateLimitError } from './rate-limit-error';
|
|
||||||
export { NetworkError } from './network-error';
|
|
||||||
export { DatabaseError } from './database-error';
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { AppError } from './app-error';
|
|
||||||
import type { ErrorContext } from './app-error';
|
|
||||||
|
|
||||||
type NetworkErrorCode = ErrorCode.NETWORK_ERROR | ErrorCode.TIMEOUT | ErrorCode.CONNECTION_REFUSED;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error for network-level failures (timeouts, connection issues, etc.).
|
|
||||||
* HTTP Status: 502 (gateway), 503 (connection refused), 504 (timeout)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Timeout
|
|
||||||
* return err(NetworkError.timeout('Fetching user profile'));
|
|
||||||
*
|
|
||||||
* // Connection refused
|
|
||||||
* return err(NetworkError.connectionRefused('Database'));
|
|
||||||
*
|
|
||||||
* // Generic network error
|
|
||||||
* return err(new NetworkError(ErrorCode.NETWORK_ERROR, 'DNS resolution failed'));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class NetworkError extends AppError {
|
|
||||||
constructor(code: NetworkErrorCode, message: string, cause?: Error, context?: ErrorContext) {
|
|
||||||
super({ code, message, cause, context });
|
|
||||||
this.name = 'NetworkError';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a timeout error.
|
|
||||||
*
|
|
||||||
* @param operation - Description of the operation that timed out
|
|
||||||
*/
|
|
||||||
static timeout(operation: string): NetworkError {
|
|
||||||
return new NetworkError(ErrorCode.TIMEOUT, `Operation timed out: ${operation}`, undefined, {
|
|
||||||
operation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a connection refused error.
|
|
||||||
*
|
|
||||||
* @param service - Name of the service that refused connection
|
|
||||||
*/
|
|
||||||
static connectionRefused(service: string): NetworkError {
|
|
||||||
return new NetworkError(
|
|
||||||
ErrorCode.CONNECTION_REFUSED,
|
|
||||||
`Connection refused: ${service}`,
|
|
||||||
undefined,
|
|
||||||
{ service }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { AppError } from './app-error';
|
|
||||||
import type { ErrorContext } from './app-error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error for when a requested resource is not found.
|
|
||||||
* HTTP Status: 404 Not Found
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Generic resource not found
|
|
||||||
* return err(new NotFoundError('User', userId));
|
|
||||||
*
|
|
||||||
* // Using factory methods
|
|
||||||
* return err(NotFoundError.user(userId));
|
|
||||||
* return err(NotFoundError.resource('Story', storyId));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class NotFoundError extends AppError {
|
|
||||||
constructor(resourceType: string, identifier: string, context?: ErrorContext) {
|
|
||||||
super({
|
|
||||||
code: ErrorCode.RESOURCE_NOT_FOUND,
|
|
||||||
message: `${resourceType} not found: ${identifier}`,
|
|
||||||
context: { resourceType, identifier, ...context },
|
|
||||||
});
|
|
||||||
this.name = 'NotFoundError';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a not found error for a user.
|
|
||||||
*/
|
|
||||||
static user(userId: string): NotFoundError {
|
|
||||||
return new NotFoundError('User', userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a not found error for any resource type.
|
|
||||||
*/
|
|
||||||
static resource(resourceType: string, identifier: string): NotFoundError {
|
|
||||||
return new NotFoundError(resourceType, identifier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { AppError } from './app-error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error for rate limiting.
|
|
||||||
* HTTP Status: 429 Too Many Requests
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Basic rate limit error
|
|
||||||
* return err(new RateLimitError());
|
|
||||||
*
|
|
||||||
* // With retry-after information
|
|
||||||
* return err(new RateLimitError('Too many requests', 60));
|
|
||||||
* // Client should wait 60 seconds before retrying
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class RateLimitError extends AppError {
|
|
||||||
/** Seconds to wait before retrying (if known) */
|
|
||||||
readonly retryAfter?: number;
|
|
||||||
|
|
||||||
constructor(message = 'Rate limit exceeded', retryAfter?: number) {
|
|
||||||
super({
|
|
||||||
code: ErrorCode.RATE_LIMIT_EXCEEDED,
|
|
||||||
message,
|
|
||||||
context: retryAfter ? { retryAfter } : {},
|
|
||||||
});
|
|
||||||
this.name = 'RateLimitError';
|
|
||||||
this.retryAfter = retryAfter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { AppError } from './app-error';
|
|
||||||
import type { ErrorContext } from './app-error';
|
|
||||||
|
|
||||||
type ServiceErrorCode =
|
|
||||||
| ErrorCode.INTERNAL_ERROR
|
|
||||||
| ErrorCode.SERVICE_UNAVAILABLE
|
|
||||||
| ErrorCode.GENERATION_FAILED
|
|
||||||
| ErrorCode.EXTERNAL_SERVICE_ERROR;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error for service-level failures (internal errors, external API failures, etc.).
|
|
||||||
* HTTP Status: 500 (internal), 502 (external), 503 (unavailable)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // AI generation failed
|
|
||||||
* return err(ServiceError.generationFailed('OpenAI', 'Rate limit exceeded', originalError));
|
|
||||||
*
|
|
||||||
* // External service unavailable
|
|
||||||
* return err(ServiceError.unavailable('Payment Service'));
|
|
||||||
*
|
|
||||||
* // External API error
|
|
||||||
* return err(ServiceError.externalError('Stripe', 'Card declined'));
|
|
||||||
*
|
|
||||||
* // Internal error
|
|
||||||
* return err(ServiceError.internal('Failed to process request'));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class ServiceError extends AppError {
|
|
||||||
constructor(code: ServiceErrorCode, message: string, cause?: Error, context?: ErrorContext) {
|
|
||||||
super({ code, message, cause, context });
|
|
||||||
this.name = 'ServiceError';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error for AI/content generation failures.
|
|
||||||
*
|
|
||||||
* @param service - Name of the service (e.g., 'OpenAI', 'Azure OpenAI')
|
|
||||||
* @param reason - Why the generation failed
|
|
||||||
* @param cause - Original error if available
|
|
||||||
*/
|
|
||||||
static generationFailed(service: string, reason: string, cause?: Error): ServiceError {
|
|
||||||
return new ServiceError(
|
|
||||||
ErrorCode.GENERATION_FAILED,
|
|
||||||
`${service} generation failed: ${reason}`,
|
|
||||||
cause,
|
|
||||||
{ service }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error for a service that is temporarily unavailable.
|
|
||||||
*
|
|
||||||
* @param service - Name of the unavailable service
|
|
||||||
*/
|
|
||||||
static unavailable(service: string): ServiceError {
|
|
||||||
return new ServiceError(
|
|
||||||
ErrorCode.SERVICE_UNAVAILABLE,
|
|
||||||
`${service} is temporarily unavailable`,
|
|
||||||
undefined,
|
|
||||||
{ service }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error for external API failures.
|
|
||||||
*
|
|
||||||
* @param service - Name of the external service
|
|
||||||
* @param message - Error message or description
|
|
||||||
* @param cause - Original error if available
|
|
||||||
*/
|
|
||||||
static externalError(service: string, message: string, cause?: Error): ServiceError {
|
|
||||||
return new ServiceError(
|
|
||||||
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
|
||||||
`${service} error: ${message}`,
|
|
||||||
cause,
|
|
||||||
{ service }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an internal server error.
|
|
||||||
*
|
|
||||||
* @param message - Description of what went wrong
|
|
||||||
* @param cause - Original error if available
|
|
||||||
*/
|
|
||||||
static internal(message: string, cause?: Error): ServiceError {
|
|
||||||
return new ServiceError(ErrorCode.INTERNAL_ERROR, message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { AppError } from './app-error';
|
|
||||||
import type { ErrorContext } from './app-error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error for validation failures (invalid input, missing fields, etc.).
|
|
||||||
* HTTP Status: 400 Bad Request
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Using factory methods
|
|
||||||
* return err(ValidationError.invalidInput('email', 'must be a valid email address'));
|
|
||||||
* return err(ValidationError.missingField('password'));
|
|
||||||
*
|
|
||||||
* // Direct construction
|
|
||||||
* return err(new ValidationError('Age must be a positive number', { field: 'age' }));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class ValidationError extends AppError {
|
|
||||||
constructor(message: string, context?: ErrorContext) {
|
|
||||||
super({
|
|
||||||
code: ErrorCode.VALIDATION_FAILED,
|
|
||||||
message,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
this.name = 'ValidationError';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a validation error for an invalid field value.
|
|
||||||
*
|
|
||||||
* @param field - The field name that failed validation
|
|
||||||
* @param reason - Why the validation failed
|
|
||||||
*/
|
|
||||||
static invalidInput(field: string, reason: string): ValidationError {
|
|
||||||
return new ValidationError(`Invalid ${field}: ${reason}`, { field, reason });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a validation error for a missing required field.
|
|
||||||
*
|
|
||||||
* @param field - The field name that is missing
|
|
||||||
*/
|
|
||||||
static missingField(field: string): ValidationError {
|
|
||||||
return new ValidationError(`Missing required field: ${field}`, { field });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a validation error for an invalid format.
|
|
||||||
*
|
|
||||||
* @param field - The field name with invalid format
|
|
||||||
* @param expectedFormat - Description of the expected format
|
|
||||||
*/
|
|
||||||
static invalidFormat(field: string, expectedFormat: string): ValidationError {
|
|
||||||
return new ValidationError(`Invalid format for ${field}: expected ${expectedFormat}`, {
|
|
||||||
field,
|
|
||||||
expectedFormat,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from './type-guards';
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import { AppError } from '../errors/app-error';
|
|
||||||
import { ValidationError } from '../errors/validation-error';
|
|
||||||
import { AuthError } from '../errors/auth-error';
|
|
||||||
import { NotFoundError } from '../errors/not-found-error';
|
|
||||||
import { CreditError } from '../errors/credit-error';
|
|
||||||
import { ServiceError } from '../errors/service-error';
|
|
||||||
import { RateLimitError } from '../errors/rate-limit-error';
|
|
||||||
import { NetworkError } from '../errors/network-error';
|
|
||||||
import { DatabaseError } from '../errors/database-error';
|
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is an AppError.
|
|
||||||
* Similar to Go's `errors.As()`.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* if (isAppError(error)) {
|
|
||||||
* console.log(error.code); // TypeScript knows error is AppError
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function isAppError(error: unknown): error is AppError {
|
|
||||||
return error instanceof AppError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is a ValidationError.
|
|
||||||
*/
|
|
||||||
export function isValidationError(error: unknown): error is ValidationError {
|
|
||||||
return error instanceof ValidationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is an AuthError.
|
|
||||||
*/
|
|
||||||
export function isAuthError(error: unknown): error is AuthError {
|
|
||||||
return error instanceof AuthError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is a NotFoundError.
|
|
||||||
*/
|
|
||||||
export function isNotFoundError(error: unknown): error is NotFoundError {
|
|
||||||
return error instanceof NotFoundError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is a CreditError.
|
|
||||||
*/
|
|
||||||
export function isCreditError(error: unknown): error is CreditError {
|
|
||||||
return error instanceof CreditError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is a ServiceError.
|
|
||||||
*/
|
|
||||||
export function isServiceError(error: unknown): error is ServiceError {
|
|
||||||
return error instanceof ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is a RateLimitError.
|
|
||||||
*/
|
|
||||||
export function isRateLimitError(error: unknown): error is RateLimitError {
|
|
||||||
return error instanceof RateLimitError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is a NetworkError.
|
|
||||||
*/
|
|
||||||
export function isNetworkError(error: unknown): error is NetworkError {
|
|
||||||
return error instanceof NetworkError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is a DatabaseError.
|
|
||||||
*/
|
|
||||||
export function isDatabaseError(error: unknown): error is DatabaseError {
|
|
||||||
return error instanceof DatabaseError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error has a specific error code.
|
|
||||||
* Similar to Go's `errors.Is()`.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* if (hasErrorCode(error, ErrorCode.INSUFFICIENT_CREDITS)) {
|
|
||||||
* showUpgradePrompt();
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function hasErrorCode(error: unknown, code: ErrorCode): boolean {
|
|
||||||
if (!isAppError(error)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return error.hasCode(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the first error in the chain matching a predicate.
|
|
||||||
* Traverses the cause chain looking for a matching error.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const creditError = findError(error, isCreditError);
|
|
||||||
* if (creditError) {
|
|
||||||
* console.log('Required:', creditError.requiredCredits);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function findError<T extends AppError>(
|
|
||||||
error: unknown,
|
|
||||||
predicate: (e: AppError) => e is T
|
|
||||||
): T | undefined {
|
|
||||||
let current: unknown = error;
|
|
||||||
while (current) {
|
|
||||||
if (isAppError(current) && predicate(current)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
current = isAppError(current) ? current.cause : undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is retryable.
|
|
||||||
* Works with both AppError and standard Error.
|
|
||||||
*/
|
|
||||||
export function isRetryable(error: unknown): boolean {
|
|
||||||
if (isAppError(error)) {
|
|
||||||
return error.retryable;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the HTTP status code for an error.
|
|
||||||
* Returns 500 for non-AppError errors.
|
|
||||||
*/
|
|
||||||
export function getHttpStatus(error: unknown): number {
|
|
||||||
if (isAppError(error)) {
|
|
||||||
return error.httpStatus;
|
|
||||||
}
|
|
||||||
return 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the error code for an error.
|
|
||||||
* Returns UNKNOWN_ERROR for non-AppError errors.
|
|
||||||
*/
|
|
||||||
export function getErrorCode(error: unknown): ErrorCode {
|
|
||||||
if (isAppError(error)) {
|
|
||||||
return error.code;
|
|
||||||
}
|
|
||||||
return ErrorCode.UNKNOWN_ERROR;
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
/**
|
|
||||||
* @mana/shared-errors
|
|
||||||
*
|
|
||||||
* Go-like error handling system for NestJS backends.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Result<T, E> type for explicit error handling
|
|
||||||
* - Standardized error codes and HTTP status mappings
|
|
||||||
* - Error wrapping with context (like Go's fmt.Errorf)
|
|
||||||
* - Type guards for type-safe error checking (like Go's errors.Is/As)
|
|
||||||
* - NestJS exception filter for consistent API responses
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // In a service
|
|
||||||
* import {
|
|
||||||
* Result, ok, err, AsyncResult,
|
|
||||||
* ValidationError, NotFoundError, ServiceError
|
|
||||||
* } from '@mana/shared-errors';
|
|
||||||
*
|
|
||||||
* async function getUser(id: string): AsyncResult<User> {
|
|
||||||
* if (!isValidId(id)) {
|
|
||||||
* return err(ValidationError.invalidInput('id', 'must be a valid UUID'));
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* const user = await db.findUser(id);
|
|
||||||
* if (!user) {
|
|
||||||
* return err(new NotFoundError('User', id));
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* return ok(user);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // In a controller
|
|
||||||
* import { isOk } from '@mana/shared-errors';
|
|
||||||
*
|
|
||||||
* const result = await userService.getUser(id);
|
|
||||||
* if (!isOk(result)) {
|
|
||||||
* throw result.error; // Caught by AppExceptionFilter
|
|
||||||
* }
|
|
||||||
* return result.value;
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export { ErrorCode, ERROR_CODE_TO_HTTP_STATUS, ERROR_CODE_RETRYABLE } from './types/error-codes';
|
|
||||||
|
|
||||||
export {
|
|
||||||
type Result,
|
|
||||||
type AsyncResult,
|
|
||||||
ok,
|
|
||||||
err,
|
|
||||||
isOk,
|
|
||||||
isErr,
|
|
||||||
unwrap,
|
|
||||||
unwrapOr,
|
|
||||||
unwrapOrElse,
|
|
||||||
map,
|
|
||||||
mapErr,
|
|
||||||
andThen,
|
|
||||||
match,
|
|
||||||
tryCatch,
|
|
||||||
tryCatchAsync,
|
|
||||||
combine,
|
|
||||||
fromNullable,
|
|
||||||
toNullable,
|
|
||||||
} from './types/result';
|
|
||||||
|
|
||||||
// Errors
|
|
||||||
export { AppError, type ErrorContext, type AppErrorOptions } from './errors/app-error';
|
|
||||||
|
|
||||||
export { ValidationError } from './errors/validation-error';
|
|
||||||
export { AuthError } from './errors/auth-error';
|
|
||||||
export { NotFoundError } from './errors/not-found-error';
|
|
||||||
export { CreditError } from './errors/credit-error';
|
|
||||||
export { ServiceError } from './errors/service-error';
|
|
||||||
export { RateLimitError } from './errors/rate-limit-error';
|
|
||||||
export { NetworkError } from './errors/network-error';
|
|
||||||
export { DatabaseError } from './errors/database-error';
|
|
||||||
|
|
||||||
// Guards
|
|
||||||
export {
|
|
||||||
isAppError,
|
|
||||||
isValidationError,
|
|
||||||
isAuthError,
|
|
||||||
isNotFoundError,
|
|
||||||
isCreditError,
|
|
||||||
isServiceError,
|
|
||||||
isRateLimitError,
|
|
||||||
isNetworkError,
|
|
||||||
isDatabaseError,
|
|
||||||
hasErrorCode,
|
|
||||||
findError,
|
|
||||||
isRetryable,
|
|
||||||
getHttpStatus,
|
|
||||||
getErrorCode,
|
|
||||||
} from './guards/type-guards';
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
export { wrap, toAppError, cause, rootCause } from './utils/wrap';
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
import { Catch, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
|
||||||
import type { ExceptionFilter, ArgumentsHost } from '@nestjs/common';
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { type AppError } from '../errors/app-error';
|
|
||||||
import { isAppError, isCreditError, isRateLimitError } from '../guards/type-guards';
|
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard error response format returned by all backends.
|
|
||||||
*/
|
|
||||||
export interface ErrorResponseBody {
|
|
||||||
statusCode: number;
|
|
||||||
error: string;
|
|
||||||
message: string;
|
|
||||||
retryable: boolean;
|
|
||||||
timestamp: string;
|
|
||||||
path: string;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global exception filter that converts all errors to a consistent format.
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* - AppError and subclasses (from shared-errors)
|
|
||||||
* - NestJS HttpException
|
|
||||||
* - Standard JavaScript Error
|
|
||||||
* - Unknown errors
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // In main.ts
|
|
||||||
* import { AppExceptionFilter } from '@mana/shared-errors/nestjs';
|
|
||||||
*
|
|
||||||
* async function bootstrap() {
|
|
||||||
* const app = await NestFactory.create(AppModule);
|
|
||||||
* app.useGlobalFilters(new AppExceptionFilter());
|
|
||||||
* await app.listen(3000);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
@Catch()
|
|
||||||
export class AppExceptionFilter implements ExceptionFilter {
|
|
||||||
private readonly logger = new Logger(AppExceptionFilter.name);
|
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
|
||||||
const ctx = host.switchToHttp();
|
|
||||||
const response = ctx.getResponse<Response>();
|
|
||||||
const request = ctx.getRequest<Request>();
|
|
||||||
|
|
||||||
const errorResponse = this.buildErrorResponse(exception, request);
|
|
||||||
|
|
||||||
this.logError(exception, request, errorResponse);
|
|
||||||
|
|
||||||
response.status(errorResponse.statusCode).json(errorResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the error response body based on the exception type.
|
|
||||||
*/
|
|
||||||
private buildErrorResponse(exception: unknown, request: Request): ErrorResponseBody {
|
|
||||||
// Handle AppError and subclasses
|
|
||||||
if (isAppError(exception)) {
|
|
||||||
return this.buildAppErrorResponse(exception, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle NestJS HttpException
|
|
||||||
if (exception instanceof HttpException) {
|
|
||||||
return this.buildHttpExceptionResponse(exception, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle standard Error
|
|
||||||
if (exception instanceof Error) {
|
|
||||||
return this.buildStandardErrorResponse(exception, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle unknown errors
|
|
||||||
return this.buildUnknownErrorResponse(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build response for AppError and subclasses.
|
|
||||||
*/
|
|
||||||
private buildAppErrorResponse(exception: AppError, request: Request): ErrorResponseBody {
|
|
||||||
const baseResponse: ErrorResponseBody = {
|
|
||||||
statusCode: exception.httpStatus,
|
|
||||||
error: exception.code,
|
|
||||||
message: exception.message,
|
|
||||||
retryable: exception.retryable,
|
|
||||||
timestamp: exception.timestamp,
|
|
||||||
path: request.url,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add credit-specific fields for CreditError
|
|
||||||
if (isCreditError(exception)) {
|
|
||||||
baseResponse.details = {
|
|
||||||
requiredCredits: exception.requiredCredits,
|
|
||||||
availableCredits: exception.availableCredits,
|
|
||||||
...exception.context,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Add retry-after for RateLimitError
|
|
||||||
else if (isRateLimitError(exception) && exception.retryAfter) {
|
|
||||||
baseResponse.details = {
|
|
||||||
retryAfter: exception.retryAfter,
|
|
||||||
...exception.context,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Add other context if present
|
|
||||||
else if (Object.keys(exception.context).length > 0) {
|
|
||||||
baseResponse.details = exception.context;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build response for NestJS HttpException.
|
|
||||||
*/
|
|
||||||
private buildHttpExceptionResponse(
|
|
||||||
exception: HttpException,
|
|
||||||
request: Request
|
|
||||||
): ErrorResponseBody {
|
|
||||||
const status = exception.getStatus();
|
|
||||||
const exceptionResponse = exception.getResponse();
|
|
||||||
|
|
||||||
let message: string;
|
|
||||||
let details: Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
if (typeof exceptionResponse === 'object') {
|
|
||||||
const responseObj = exceptionResponse as Record<string, unknown>;
|
|
||||||
message =
|
|
||||||
typeof responseObj.message === 'string'
|
|
||||||
? responseObj.message
|
|
||||||
: Array.isArray(responseObj.message)
|
|
||||||
? (responseObj.message as string[]).join(', ')
|
|
||||||
: exception.message;
|
|
||||||
|
|
||||||
// Extract any additional details
|
|
||||||
const { message: _, error: __, statusCode: ___, ...rest } = responseObj;
|
|
||||||
if (Object.keys(rest).length > 0) {
|
|
||||||
details = rest;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message = String(exceptionResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
statusCode: status,
|
|
||||||
error: this.httpStatusToErrorCode(status),
|
|
||||||
message,
|
|
||||||
retryable: status >= 500,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
path: request.url,
|
|
||||||
...(details && { details }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build response for standard JavaScript Error.
|
|
||||||
*/
|
|
||||||
private buildStandardErrorResponse(exception: Error, request: Request): ErrorResponseBody {
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
return {
|
|
||||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
error: ErrorCode.INTERNAL_ERROR,
|
|
||||||
message: isProduction ? 'An unexpected error occurred' : exception.message,
|
|
||||||
retryable: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
path: request.url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build response for unknown error types.
|
|
||||||
*/
|
|
||||||
private buildUnknownErrorResponse(request: Request): ErrorResponseBody {
|
|
||||||
return {
|
|
||||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
error: ErrorCode.UNKNOWN_ERROR,
|
|
||||||
message: 'An unexpected error occurred',
|
|
||||||
retryable: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
path: request.url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map HTTP status code to ErrorCode.
|
|
||||||
*/
|
|
||||||
private httpStatusToErrorCode(status: number): string {
|
|
||||||
const statusToCode: Record<number, string> = {
|
|
||||||
400: ErrorCode.VALIDATION_FAILED,
|
|
||||||
401: ErrorCode.AUTHENTICATION_REQUIRED,
|
|
||||||
402: ErrorCode.PAYMENT_REQUIRED,
|
|
||||||
403: ErrorCode.PERMISSION_DENIED,
|
|
||||||
404: ErrorCode.RESOURCE_NOT_FOUND,
|
|
||||||
409: ErrorCode.CONFLICT,
|
|
||||||
429: ErrorCode.RATE_LIMIT_EXCEEDED,
|
|
||||||
500: ErrorCode.INTERNAL_ERROR,
|
|
||||||
502: ErrorCode.EXTERNAL_SERVICE_ERROR,
|
|
||||||
503: ErrorCode.SERVICE_UNAVAILABLE,
|
|
||||||
504: ErrorCode.TIMEOUT,
|
|
||||||
};
|
|
||||||
return statusToCode[status] || ErrorCode.UNKNOWN_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log the error with appropriate level based on status code.
|
|
||||||
*/
|
|
||||||
private logError(exception: unknown, request: Request, response: ErrorResponseBody): void {
|
|
||||||
const logData = {
|
|
||||||
method: request.method,
|
|
||||||
url: request.url,
|
|
||||||
statusCode: response.statusCode,
|
|
||||||
error: response.error,
|
|
||||||
message: response.message,
|
|
||||||
userId: (request as Request & { user?: { sub?: string } }).user?.sub,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log 5xx errors as errors, others as warnings
|
|
||||||
if (response.statusCode >= 500) {
|
|
||||||
this.logger.error(
|
|
||||||
`[${logData.method}] ${logData.url} - ${logData.statusCode}: ${logData.message}`,
|
|
||||||
isAppError(exception)
|
|
||||||
? JSON.stringify(exception.toFullJSON(), null, 2)
|
|
||||||
: exception instanceof Error
|
|
||||||
? exception.stack
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
|
||||||
`[${logData.method}] ${logData.url} - ${logData.statusCode}: ${logData.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { AppExceptionFilter, type ErrorResponseBody } from './app-exception.filter';
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
/**
|
|
||||||
* Standardized error codes across all backends.
|
|
||||||
* Follows pattern: CATEGORY_SPECIFIC_ERROR
|
|
||||||
*/
|
|
||||||
export enum ErrorCode {
|
|
||||||
// Validation Errors (400)
|
|
||||||
VALIDATION_FAILED = 'VALIDATION_FAILED',
|
|
||||||
INVALID_INPUT = 'INVALID_INPUT',
|
|
||||||
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
|
|
||||||
INVALID_FORMAT = 'INVALID_FORMAT',
|
|
||||||
|
|
||||||
// Authentication Errors (401)
|
|
||||||
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
|
|
||||||
INVALID_TOKEN = 'INVALID_TOKEN',
|
|
||||||
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
|
|
||||||
|
|
||||||
// Authorization Errors (403)
|
|
||||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
|
||||||
RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED',
|
|
||||||
|
|
||||||
// Not Found Errors (404)
|
|
||||||
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
|
|
||||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
|
||||||
|
|
||||||
// Payment/Credit Errors (402)
|
|
||||||
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
|
|
||||||
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
|
|
||||||
|
|
||||||
// Conflict Errors (409)
|
|
||||||
CONFLICT = 'CONFLICT',
|
|
||||||
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
|
|
||||||
|
|
||||||
// Rate Limiting (429)
|
|
||||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
|
||||||
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
|
|
||||||
|
|
||||||
// Service Errors (500)
|
|
||||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
|
||||||
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
|
||||||
GENERATION_FAILED = 'GENERATION_FAILED',
|
|
||||||
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
|
|
||||||
|
|
||||||
// Network Errors (502/503/504)
|
|
||||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
|
||||||
TIMEOUT = 'TIMEOUT',
|
|
||||||
CONNECTION_REFUSED = 'CONNECTION_REFUSED',
|
|
||||||
|
|
||||||
// Database Errors
|
|
||||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
|
||||||
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
|
|
||||||
|
|
||||||
// Unknown
|
|
||||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps error codes to default HTTP status codes.
|
|
||||||
*/
|
|
||||||
export const ERROR_CODE_TO_HTTP_STATUS: Record<ErrorCode, number> = {
|
|
||||||
// Validation (400)
|
|
||||||
[ErrorCode.VALIDATION_FAILED]: 400,
|
|
||||||
[ErrorCode.INVALID_INPUT]: 400,
|
|
||||||
[ErrorCode.MISSING_REQUIRED_FIELD]: 400,
|
|
||||||
[ErrorCode.INVALID_FORMAT]: 400,
|
|
||||||
|
|
||||||
// Authentication (401)
|
|
||||||
[ErrorCode.AUTHENTICATION_REQUIRED]: 401,
|
|
||||||
[ErrorCode.INVALID_TOKEN]: 401,
|
|
||||||
[ErrorCode.TOKEN_EXPIRED]: 401,
|
|
||||||
|
|
||||||
// Authorization (403)
|
|
||||||
[ErrorCode.PERMISSION_DENIED]: 403,
|
|
||||||
[ErrorCode.RESOURCE_NOT_OWNED]: 403,
|
|
||||||
|
|
||||||
// Not Found (404)
|
|
||||||
[ErrorCode.RESOURCE_NOT_FOUND]: 404,
|
|
||||||
[ErrorCode.USER_NOT_FOUND]: 404,
|
|
||||||
|
|
||||||
// Payment (402)
|
|
||||||
[ErrorCode.INSUFFICIENT_CREDITS]: 402,
|
|
||||||
[ErrorCode.PAYMENT_REQUIRED]: 402,
|
|
||||||
|
|
||||||
// Conflict (409)
|
|
||||||
[ErrorCode.CONFLICT]: 409,
|
|
||||||
[ErrorCode.DUPLICATE_ENTRY]: 409,
|
|
||||||
|
|
||||||
// Rate Limit (429)
|
|
||||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
|
|
||||||
[ErrorCode.TOO_MANY_REQUESTS]: 429,
|
|
||||||
|
|
||||||
// Service Errors (500)
|
|
||||||
[ErrorCode.INTERNAL_ERROR]: 500,
|
|
||||||
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
|
|
||||||
[ErrorCode.GENERATION_FAILED]: 500,
|
|
||||||
[ErrorCode.EXTERNAL_SERVICE_ERROR]: 502,
|
|
||||||
|
|
||||||
// Network Errors
|
|
||||||
[ErrorCode.NETWORK_ERROR]: 502,
|
|
||||||
[ErrorCode.TIMEOUT]: 504,
|
|
||||||
[ErrorCode.CONNECTION_REFUSED]: 503,
|
|
||||||
|
|
||||||
// Database Errors
|
|
||||||
[ErrorCode.DATABASE_ERROR]: 500,
|
|
||||||
[ErrorCode.CONSTRAINT_VIOLATION]: 409,
|
|
||||||
|
|
||||||
// Unknown
|
|
||||||
[ErrorCode.UNKNOWN_ERROR]: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps error codes to default retryable status.
|
|
||||||
*/
|
|
||||||
export const ERROR_CODE_RETRYABLE: Record<ErrorCode, boolean> = {
|
|
||||||
// Validation - not retryable (user needs to fix input)
|
|
||||||
[ErrorCode.VALIDATION_FAILED]: false,
|
|
||||||
[ErrorCode.INVALID_INPUT]: false,
|
|
||||||
[ErrorCode.MISSING_REQUIRED_FIELD]: false,
|
|
||||||
[ErrorCode.INVALID_FORMAT]: false,
|
|
||||||
|
|
||||||
// Authentication - not retryable (need new credentials)
|
|
||||||
[ErrorCode.AUTHENTICATION_REQUIRED]: false,
|
|
||||||
[ErrorCode.INVALID_TOKEN]: false,
|
|
||||||
[ErrorCode.TOKEN_EXPIRED]: false,
|
|
||||||
|
|
||||||
// Authorization - not retryable (permission issue)
|
|
||||||
[ErrorCode.PERMISSION_DENIED]: false,
|
|
||||||
[ErrorCode.RESOURCE_NOT_OWNED]: false,
|
|
||||||
|
|
||||||
// Not Found - not retryable (resource doesn't exist)
|
|
||||||
[ErrorCode.RESOURCE_NOT_FOUND]: false,
|
|
||||||
[ErrorCode.USER_NOT_FOUND]: false,
|
|
||||||
|
|
||||||
// Payment - not retryable (need more credits)
|
|
||||||
[ErrorCode.INSUFFICIENT_CREDITS]: false,
|
|
||||||
[ErrorCode.PAYMENT_REQUIRED]: false,
|
|
||||||
|
|
||||||
// Conflict - not retryable (data issue)
|
|
||||||
[ErrorCode.CONFLICT]: false,
|
|
||||||
[ErrorCode.DUPLICATE_ENTRY]: false,
|
|
||||||
|
|
||||||
// Rate Limit - retryable (after waiting)
|
|
||||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: true,
|
|
||||||
[ErrorCode.TOO_MANY_REQUESTS]: true,
|
|
||||||
|
|
||||||
// Service Errors - retryable (transient issues)
|
|
||||||
[ErrorCode.INTERNAL_ERROR]: true,
|
|
||||||
[ErrorCode.SERVICE_UNAVAILABLE]: true,
|
|
||||||
[ErrorCode.GENERATION_FAILED]: true,
|
|
||||||
[ErrorCode.EXTERNAL_SERVICE_ERROR]: true,
|
|
||||||
|
|
||||||
// Network Errors - retryable (transient issues)
|
|
||||||
[ErrorCode.NETWORK_ERROR]: true,
|
|
||||||
[ErrorCode.TIMEOUT]: true,
|
|
||||||
[ErrorCode.CONNECTION_REFUSED]: true,
|
|
||||||
|
|
||||||
// Database Errors - not retryable (except transient, but safer to say no)
|
|
||||||
[ErrorCode.DATABASE_ERROR]: false,
|
|
||||||
[ErrorCode.CONSTRAINT_VIOLATION]: false,
|
|
||||||
|
|
||||||
// Unknown - retryable (might be transient)
|
|
||||||
[ErrorCode.UNKNOWN_ERROR]: true,
|
|
||||||
};
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './error-codes';
|
|
||||||
export * from './result';
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
import { AppError } from '../errors/app-error';
|
|
||||||
import { ErrorCode } from './error-codes';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result type representing either success or failure.
|
|
||||||
* Inspired by Go's (value, error) return pattern and Rust's Result type.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // In a service
|
|
||||||
* async function getUser(id: string): AsyncResult<User> {
|
|
||||||
* const user = await db.findUser(id);
|
|
||||||
* if (!user) {
|
|
||||||
* return err(new NotFoundError('User', id));
|
|
||||||
* }
|
|
||||||
* return ok(user);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // In a controller (Go-like explicit unwrap)
|
|
||||||
* const result = await userService.getUser(id);
|
|
||||||
* if (!isOk(result)) {
|
|
||||||
* throw result.error;
|
|
||||||
* }
|
|
||||||
* return result.value;
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export type Result<T, E extends AppError = AppError> =
|
|
||||||
| { readonly ok: true; readonly value: T; readonly error?: never }
|
|
||||||
| { readonly ok: false; readonly error: E; readonly value?: never };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async version of Result - use this as return type for async functions.
|
|
||||||
*/
|
|
||||||
export type AsyncResult<T, E extends AppError = AppError> = Promise<Result<T, E>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a success Result.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* return ok({ name: 'John', email: 'john@example.com' });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function ok<T>(value: T): Result<T, never> {
|
|
||||||
return { ok: true, value };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a failure Result.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* return err(new ValidationError('Invalid email'));
|
|
||||||
* return err(NotFoundError.user(userId));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function err<E extends AppError>(error: E): Result<never, E> {
|
|
||||||
return { ok: false, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Result is success.
|
|
||||||
* Use this for type narrowing in conditionals.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const result = await service.getData();
|
|
||||||
* if (isOk(result)) {
|
|
||||||
* console.log(result.value); // TypeScript knows value exists
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function isOk<T, E extends AppError>(
|
|
||||||
result: Result<T, E>
|
|
||||||
): result is { ok: true; value: T } {
|
|
||||||
return result.ok === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Result is failure.
|
|
||||||
* Use this for type narrowing in conditionals.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const result = await service.getData();
|
|
||||||
* if (isErr(result)) {
|
|
||||||
* console.error(result.error.message); // TypeScript knows error exists
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function isErr<T, E extends AppError>(
|
|
||||||
result: Result<T, E>
|
|
||||||
): result is { ok: false; error: E } {
|
|
||||||
return result.ok === false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unwrap the value or throw if error.
|
|
||||||
* Use sparingly - prefer explicit error checking.
|
|
||||||
*
|
|
||||||
* @throws The error if Result is a failure
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Use when you want to propagate errors as exceptions
|
|
||||||
* const value = unwrap(result);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function unwrap<T, E extends AppError>(result: Result<T, E>): T {
|
|
||||||
if (isOk(result)) {
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unwrap the value or return a default value.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const users = unwrapOr(result, []); // Returns [] if error
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function unwrapOr<T, E extends AppError>(result: Result<T, E>, defaultValue: T): T {
|
|
||||||
return isOk(result) ? result.value : defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unwrap the value or compute a default from the error.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const value = unwrapOrElse(result, (error) => {
|
|
||||||
* console.error('Failed:', error.message);
|
|
||||||
* return fallbackValue;
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function unwrapOrElse<T, E extends AppError>(result: Result<T, E>, fn: (error: E) => T): T {
|
|
||||||
return isOk(result) ? result.value : fn(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map the success value to a new value.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const result = await getUser(id);
|
|
||||||
* const nameResult = map(result, user => user.name);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function map<T, U, E extends AppError>(
|
|
||||||
result: Result<T, E>,
|
|
||||||
fn: (value: T) => U
|
|
||||||
): Result<U, E> {
|
|
||||||
return isOk(result) ? ok(fn(result.value)) : result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map the error to a new error.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const result = mapErr(originalResult, error =>
|
|
||||||
* error.wrap('while processing user')
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function mapErr<T, E extends AppError, F extends AppError>(
|
|
||||||
result: Result<T, E>,
|
|
||||||
fn: (error: E) => F
|
|
||||||
): Result<T, F> {
|
|
||||||
return isErr(result) ? err(fn(result.error)) : result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chain Results (flatMap) - use when the mapping function returns a Result.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const result = andThen(getUserResult, user =>
|
|
||||||
* getPermissions(user.id)
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function andThen<T, U, E extends AppError>(
|
|
||||||
result: Result<T, E>,
|
|
||||||
fn: (value: T) => Result<U, E>
|
|
||||||
): Result<U, E> {
|
|
||||||
return isOk(result) ? fn(result.value) : result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pattern matching for Result - handle both success and failure cases.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const message = match(result, {
|
|
||||||
* ok: (user) => `Welcome, ${user.name}!`,
|
|
||||||
* err: (error) => `Error: ${error.message}`,
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function match<T, E extends AppError, U>(
|
|
||||||
result: Result<T, E>,
|
|
||||||
handlers: {
|
|
||||||
ok: (value: T) => U;
|
|
||||||
err: (error: E) => U;
|
|
||||||
}
|
|
||||||
): U {
|
|
||||||
return isOk(result) ? handlers.ok(result.value) : handlers.err(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to execute a synchronous function and wrap in Result.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const result = tryCatch(() => JSON.parse(jsonString));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function tryCatch<T>(fn: () => T): Result<T, AppError> {
|
|
||||||
try {
|
|
||||||
return ok(fn());
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
return err(error);
|
|
||||||
}
|
|
||||||
return err(
|
|
||||||
new AppError({
|
|
||||||
code: ErrorCode.UNKNOWN_ERROR,
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
cause: error instanceof Error ? error : undefined,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to execute an async function and wrap in Result.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const result = await tryCatchAsync(() => fetch(url).then(r => r.json()));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export async function tryCatchAsync<T>(fn: () => Promise<T>): AsyncResult<T, AppError> {
|
|
||||||
try {
|
|
||||||
return ok(await fn());
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
return err(error);
|
|
||||||
}
|
|
||||||
return err(
|
|
||||||
new AppError({
|
|
||||||
code: ErrorCode.UNKNOWN_ERROR,
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
cause: error instanceof Error ? error : undefined,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combine multiple Results - returns first error or array of all values.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const results = await Promise.all([
|
|
||||||
* getUser(id1),
|
|
||||||
* getUser(id2),
|
|
||||||
* getUser(id3),
|
|
||||||
* ]);
|
|
||||||
* const combined = combine(results);
|
|
||||||
* if (isOk(combined)) {
|
|
||||||
* const [user1, user2, user3] = combined.value;
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function combine<T, E extends AppError>(results: Result<T, E>[]): Result<T[], E> {
|
|
||||||
const values: T[] = [];
|
|
||||||
for (const result of results) {
|
|
||||||
if (isErr(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
values.push(result.value);
|
|
||||||
}
|
|
||||||
return ok(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a nullable value to a Result.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const result = fromNullable(
|
|
||||||
* maybeUser,
|
|
||||||
* () => new NotFoundError('User', id)
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function fromNullable<T, E extends AppError>(
|
|
||||||
value: T | null | undefined,
|
|
||||||
errorFn: () => E
|
|
||||||
): Result<T, E> {
|
|
||||||
return value != null ? ok(value) : err(errorFn());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a Result to a nullable value (loses error information).
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const user = toNullable(result); // User | null
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function toNullable<T, E extends AppError>(result: Result<T, E>): T | null {
|
|
||||||
return isOk(result) ? result.value : null;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from './wrap';
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
import { AppError } from '../errors/app-error';
|
|
||||||
import type { ErrorContext } from '../errors/app-error';
|
|
||||||
import { ErrorCode } from '../types/error-codes';
|
|
||||||
import { isAppError } from '../guards/type-guards';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap an error with context.
|
|
||||||
* Similar to Go's `fmt.Errorf("context: %w", err)`.
|
|
||||||
*
|
|
||||||
* @param error - The error to wrap (can be any type)
|
|
||||||
* @param context - Description of the operation that failed
|
|
||||||
* @param additionalContext - Extra context data to include
|
|
||||||
* @returns An AppError with the original as its cause
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* try {
|
|
||||||
* await fetchData();
|
|
||||||
* } catch (error) {
|
|
||||||
* return err(wrap(error, 'fetching user data'));
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function wrap(error: unknown, context: string, additionalContext?: ErrorContext): AppError {
|
|
||||||
if (isAppError(error)) {
|
|
||||||
return error.wrap(context, additionalContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return new AppError({
|
|
||||||
code: ErrorCode.UNKNOWN_ERROR,
|
|
||||||
message: `${context}: ${message}`,
|
|
||||||
cause: error instanceof Error ? error : undefined,
|
|
||||||
context: additionalContext,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert any error to AppError.
|
|
||||||
* If already an AppError, returns it unchanged.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* try {
|
|
||||||
* await riskyOperation();
|
|
||||||
* } catch (error) {
|
|
||||||
* return err(toAppError(error));
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function toAppError(error: unknown): AppError {
|
|
||||||
if (isAppError(error)) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return new AppError({
|
|
||||||
code: ErrorCode.UNKNOWN_ERROR,
|
|
||||||
message: error.message,
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AppError({
|
|
||||||
code: ErrorCode.UNKNOWN_ERROR,
|
|
||||||
message: String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cause of an error.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const originalError = cause(wrappedError);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function cause(error: AppError): Error | undefined {
|
|
||||||
return error.cause;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the root cause of an error chain.
|
|
||||||
* Traverses all causes to find the original error.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const original = rootCause(deeplyWrappedError);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function rootCause(error: AppError): Error {
|
|
||||||
return error.rootCause();
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2021",
|
|
||||||
"module": "commonjs",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@mana/shared-splitscreen",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"svelte": "./src/index.ts",
|
|
||||||
"main": "./src/index.ts",
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"svelte": "./src/index.ts",
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"default": "./src/index.ts"
|
|
||||||
},
|
|
||||||
"./store": {
|
|
||||||
"svelte": "./src/stores/split-panel.svelte.ts",
|
|
||||||
"default": "./src/stores/split-panel.svelte.ts"
|
|
||||||
},
|
|
||||||
"./types": {
|
|
||||||
"types": "./src/types.ts",
|
|
||||||
"default": "./src/types.ts"
|
|
||||||
},
|
|
||||||
"./utils": {
|
|
||||||
"default": "./src/utils/index.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint .",
|
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"svelte": "^5.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"svelte": "^5.0.0",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"typescript": "^5.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* AppPanel Component
|
|
||||||
* iFrame container for displaying an app in split-screen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { PanelConfig } from '../types.js';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
panel: PanelConfig;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { panel, class: className = '' }: Props = $props();
|
|
||||||
|
|
||||||
let isLoading = $state(true);
|
|
||||||
let hasError = $state(false);
|
|
||||||
|
|
||||||
function handleLoad() {
|
|
||||||
isLoading = false;
|
|
||||||
hasError = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleError() {
|
|
||||||
isLoading = false;
|
|
||||||
hasError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// iFrame sandbox permissions
|
|
||||||
const sandboxPermissions = [
|
|
||||||
'allow-same-origin',
|
|
||||||
'allow-scripts',
|
|
||||||
'allow-forms',
|
|
||||||
'allow-popups',
|
|
||||||
'allow-popups-to-escape-sandbox',
|
|
||||||
'allow-storage-access-by-user-activation',
|
|
||||||
].join(' ');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="app-panel {className}">
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="loading-state">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>Loading {panel.name || panel.appId}...</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasError}
|
|
||||||
<div class="error-state">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
||||||
</svg>
|
|
||||||
<span>Failed to load {panel.name || panel.appId}</span>
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
isLoading = true;
|
|
||||||
hasError = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<iframe
|
|
||||||
src={panel.url}
|
|
||||||
title={panel.name || panel.appId}
|
|
||||||
sandbox={sandboxPermissions}
|
|
||||||
class:hidden={hasError}
|
|
||||||
onload={handleLoad}
|
|
||||||
onerror={handleError}
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.app-panel {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-bg-secondary, #1a1a1a);
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
background: var(--color-bg-primary, #0a0a0a);
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-state,
|
|
||||||
.error-state {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--color-text-secondary, #888);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 3px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
|
||||||
border-top-color: var(--color-primary, #3b82f6);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-state {
|
|
||||||
color: var(--color-error, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-state button {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--color-primary, #3b82f6);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-state button:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* PanelControls Component
|
|
||||||
* Controls overlay for split panel (swap, close).
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
panelName: string;
|
|
||||||
onSwap: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { panelName, onSwap, onClose }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="panel-controls">
|
|
||||||
<span class="panel-label">{panelName}</span>
|
|
||||||
|
|
||||||
<div class="control-buttons">
|
|
||||||
<button class="control-btn" title="Swap panels" onclick={onSwap}>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M16 3l4 4-4 4" />
|
|
||||||
<path d="M20 7H4" />
|
|
||||||
<path d="M8 21l-4-4 4-4" />
|
|
||||||
<path d="M4 17h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="control-btn close" title="Close panel" onclick={onClose}>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.panel-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
z-index: 20;
|
|
||||||
/* Always visible - important for closing the panel */
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.close:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.3);
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* ResizeHandle Component
|
|
||||||
* Draggable divider for resizing split panels.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DIVIDER_CONSTRAINTS } from '../types.js';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
position: number;
|
|
||||||
onResize: (position: number) => void;
|
|
||||||
onReset: () => void;
|
|
||||||
onDragStateChange?: (isDragging: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { position, onResize, onReset, onDragStateChange }: Props = $props();
|
|
||||||
|
|
||||||
let isDragging = $state(false);
|
|
||||||
|
|
||||||
function setDragging(value: boolean) {
|
|
||||||
isDragging = value;
|
|
||||||
onDragStateChange?.(value);
|
|
||||||
}
|
|
||||||
let containerRef: HTMLElement | null = null;
|
|
||||||
|
|
||||||
function handleMouseDown(event: MouseEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setDragging(true);
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!containerRef) return;
|
|
||||||
|
|
||||||
const container = containerRef.closest('.split-pane-container');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const newPosition = ((e.clientX - rect.left) / rect.width) * 100;
|
|
||||||
|
|
||||||
const clamped = Math.max(
|
|
||||||
DIVIDER_CONSTRAINTS.MIN,
|
|
||||||
Math.min(DIVIDER_CONSTRAINTS.MAX, newPosition)
|
|
||||||
);
|
|
||||||
|
|
||||||
onResize(clamped);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setDragging(false);
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTouchStart(event: TouchEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setDragging(true);
|
|
||||||
|
|
||||||
const handleTouchMove = (e: TouchEvent) => {
|
|
||||||
if (!containerRef || !e.touches[0]) return;
|
|
||||||
|
|
||||||
const container = containerRef.closest('.split-pane-container');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const newPosition = ((e.touches[0].clientX - rect.left) / rect.width) * 100;
|
|
||||||
|
|
||||||
const clamped = Math.max(
|
|
||||||
DIVIDER_CONSTRAINTS.MIN,
|
|
||||||
Math.min(DIVIDER_CONSTRAINTS.MAX, newPosition)
|
|
||||||
);
|
|
||||||
|
|
||||||
onResize(clamped);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
|
||||||
setDragging(false);
|
|
||||||
document.removeEventListener('touchmove', handleTouchMove);
|
|
||||||
document.removeEventListener('touchend', handleTouchEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
||||||
document.addEventListener('touchend', handleTouchEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDoubleClick() {
|
|
||||||
onReset();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
const step = event.shiftKey ? 10 : 2;
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
event.preventDefault();
|
|
||||||
onResize(Math.max(DIVIDER_CONSTRAINTS.MIN, position - step));
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
event.preventDefault();
|
|
||||||
onResize(Math.min(DIVIDER_CONSTRAINTS.MAX, position + step));
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
event.preventDefault();
|
|
||||||
onResize(DIVIDER_CONSTRAINTS.DEFAULT);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={containerRef}
|
|
||||||
class="resize-handle"
|
|
||||||
class:dragging={isDragging}
|
|
||||||
role="separator"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-valuenow={position}
|
|
||||||
aria-valuemin={DIVIDER_CONSTRAINTS.MIN}
|
|
||||||
aria-valuemax={DIVIDER_CONSTRAINTS.MAX}
|
|
||||||
tabindex="0"
|
|
||||||
onmousedown={handleMouseDown}
|
|
||||||
ontouchstart={handleTouchStart}
|
|
||||||
ondblclick={handleDoubleClick}
|
|
||||||
onkeydown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<div class="handle-line"></div>
|
|
||||||
<div class="handle-grip">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.resize-handle {
|
|
||||||
position: relative;
|
|
||||||
width: 6px;
|
|
||||||
cursor: col-resize;
|
|
||||||
background: transparent;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle:hover,
|
|
||||||
.resize-handle.dragging {
|
|
||||||
background: var(--color-primary, #3b82f6);
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
var(--color-primary, #3b82f6) 20%,
|
|
||||||
var(--color-primary, #3b82f6) 80%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle:focus-visible {
|
|
||||||
background: var(--color-primary, #3b82f6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-line {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
width: 1px;
|
|
||||||
background: var(--color-border, rgba(255, 255, 255, 0.1));
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle:hover .handle-line,
|
|
||||||
.resize-handle.dragging .handle-line {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-grip {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle:hover .handle-grip,
|
|
||||||
.resize-handle.dragging .handle-grip {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-grip span {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* SplitPaneContainer Component
|
|
||||||
* Main container that handles split-screen layout.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import { getSplitPanelContext } from '../stores/split-panel.svelte.js';
|
|
||||||
import AppPanel from './AppPanel.svelte';
|
|
||||||
import PanelControls from './PanelControls.svelte';
|
|
||||||
import ResizeHandle from './ResizeHandle.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: Snippet;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children, class: className = '' }: Props = $props();
|
|
||||||
|
|
||||||
const splitPanel = getSplitPanelContext();
|
|
||||||
|
|
||||||
// Track if resize handle is being dragged - used to disable iframe pointer events
|
|
||||||
let isResizing = $state(false);
|
|
||||||
|
|
||||||
// Grid template based on divider position
|
|
||||||
let gridTemplate = $derived(
|
|
||||||
splitPanel.isActive && splitPanel.rightPanel ? `${splitPanel.dividerPosition}% 6px 1fr` : '1fr'
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleResize(position: number) {
|
|
||||||
splitPanel.setDividerPosition(position);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReset() {
|
|
||||||
splitPanel.resetDividerPosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragStateChange(isDragging: boolean) {
|
|
||||||
isResizing = isDragging;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="split-pane-container {className}"
|
|
||||||
class:split-active={splitPanel.isActive && splitPanel.rightPanel}
|
|
||||||
class:resizing={isResizing}
|
|
||||||
style:--grid-template={gridTemplate}
|
|
||||||
>
|
|
||||||
<!-- Overlay during resize to capture all mouse events -->
|
|
||||||
{#if isResizing}
|
|
||||||
<div class="resize-overlay"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="main-panel">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if splitPanel.isActive && splitPanel.rightPanel}
|
|
||||||
<ResizeHandle
|
|
||||||
position={splitPanel.dividerPosition}
|
|
||||||
onResize={handleResize}
|
|
||||||
onReset={handleReset}
|
|
||||||
onDragStateChange={handleDragStateChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="side-panel" class:resizing={isResizing}>
|
|
||||||
<AppPanel panel={splitPanel.rightPanel} />
|
|
||||||
<PanelControls
|
|
||||||
panelName={splitPanel.rightPanel.name || splitPanel.rightPanel.appId}
|
|
||||||
onSwap={() => splitPanel.swapPanels()}
|
|
||||||
onClose={() => splitPanel.closePanel()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.split-pane-container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--grid-template, 1fr);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: clip;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transparent overlay during resize - captures all mouse events */
|
|
||||||
.resize-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 100;
|
|
||||||
cursor: col-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-panel {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: clip;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-panel {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable iframe pointer events during resize to prevent event stealing */
|
|
||||||
.side-panel.resizing :global(iframe) {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure proper stacking */
|
|
||||||
.split-active .main-panel {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-active .side-panel {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide side panel on mobile via media query as fallback */
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.split-pane-container {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-panel,
|
|
||||||
.split-pane-container :global(.resize-handle) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
/**
|
|
||||||
* @mana/shared-splitscreen
|
|
||||||
*
|
|
||||||
* Split-screen panel system for Mana apps.
|
|
||||||
* Enables displaying two apps side-by-side using iFrames.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export type {
|
|
||||||
PanelConfig,
|
|
||||||
SplitScreenState,
|
|
||||||
AppDefinition,
|
|
||||||
PanelEvent,
|
|
||||||
StorageConfig,
|
|
||||||
UrlState,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
export { DIVIDER_CONSTRAINTS, MOBILE_BREAKPOINT } from './types.js';
|
|
||||||
|
|
||||||
// Store
|
|
||||||
export {
|
|
||||||
createSplitPanelStore,
|
|
||||||
setSplitPanelContext,
|
|
||||||
getSplitPanelContext,
|
|
||||||
hasSplitPanelContext,
|
|
||||||
DEFAULT_APPS,
|
|
||||||
type SplitPanelStore,
|
|
||||||
} from './stores/split-panel.svelte.js';
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
export {
|
|
||||||
parseUrlState,
|
|
||||||
updateUrlState,
|
|
||||||
clearUrlState,
|
|
||||||
getCurrentUrlState,
|
|
||||||
savePanelState,
|
|
||||||
loadPanelState,
|
|
||||||
clearPanelState,
|
|
||||||
createStorageConfig,
|
|
||||||
} from './utils/index.js';
|
|
||||||
|
|
||||||
// Components (will be added)
|
|
||||||
export { default as SplitPaneContainer } from './components/SplitPaneContainer.svelte';
|
|
||||||
export { default as AppPanel } from './components/AppPanel.svelte';
|
|
||||||
export { default as PanelControls } from './components/PanelControls.svelte';
|
|
||||||
export { default as ResizeHandle } from './components/ResizeHandle.svelte';
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
/**
|
|
||||||
* Split-Panel Store
|
|
||||||
* Svelte 5 runes-based state management for split-screen panels.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getContext, setContext } from 'svelte';
|
|
||||||
import type { PanelConfig, AppDefinition, StorageConfig } from '../types.js';
|
|
||||||
import { DIVIDER_CONSTRAINTS, MOBILE_BREAKPOINT } from '../types.js';
|
|
||||||
import { savePanelState, loadPanelState, createStorageConfig } from '../utils/local-storage.js';
|
|
||||||
import { updateUrlState, clearUrlState, getCurrentUrlState } from '../utils/url-state.js';
|
|
||||||
|
|
||||||
const SPLIT_PANEL_CONTEXT_KEY = Symbol('split-panel');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available apps that can be opened in split-screen.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_APPS: AppDefinition[] = [
|
|
||||||
{
|
|
||||||
id: 'calendar',
|
|
||||||
name: 'Calendar',
|
|
||||||
baseUrl: 'http://localhost:5179',
|
|
||||||
icon: 'calendar',
|
|
||||||
color: '#3b82f6',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'todo',
|
|
||||||
name: 'Todo',
|
|
||||||
baseUrl: 'http://localhost:5188',
|
|
||||||
icon: 'check-square',
|
|
||||||
color: '#10b981',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'contacts',
|
|
||||||
name: 'Contacts',
|
|
||||||
baseUrl: 'http://localhost:5184',
|
|
||||||
icon: 'users',
|
|
||||||
color: '#8b5cf6',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'clock',
|
|
||||||
name: 'Clock',
|
|
||||||
baseUrl: 'http://localhost:5187',
|
|
||||||
icon: 'clock',
|
|
||||||
color: '#f59e0b',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface SplitPanelStore {
|
|
||||||
// State
|
|
||||||
readonly isActive: boolean;
|
|
||||||
readonly rightPanel: PanelConfig | null;
|
|
||||||
readonly dividerPosition: number;
|
|
||||||
readonly isMobile: boolean;
|
|
||||||
|
|
||||||
// Available apps (excluding current)
|
|
||||||
readonly availableApps: AppDefinition[];
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
openPanel: (appId: string, path?: string) => void;
|
|
||||||
closePanel: () => void;
|
|
||||||
swapPanels: () => void;
|
|
||||||
setDividerPosition: (position: number) => void;
|
|
||||||
resetDividerPosition: () => void;
|
|
||||||
initialize: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a split-panel store for an app.
|
|
||||||
*/
|
|
||||||
export function createSplitPanelStore(
|
|
||||||
currentAppId: string,
|
|
||||||
apps: AppDefinition[] = DEFAULT_APPS
|
|
||||||
): SplitPanelStore {
|
|
||||||
// Reactive state using Svelte 5 runes
|
|
||||||
let isActive = $state(false);
|
|
||||||
let rightPanel = $state<PanelConfig | null>(null);
|
|
||||||
let dividerPosition = $state<number>(DIVIDER_CONSTRAINTS.DEFAULT);
|
|
||||||
let isMobile = $state(false);
|
|
||||||
|
|
||||||
// Storage config for persistence
|
|
||||||
const storageConfig: StorageConfig = createStorageConfig(currentAppId);
|
|
||||||
|
|
||||||
// Filter out current app from available apps
|
|
||||||
const availableApps = $derived(apps.filter((app) => app.id !== currentAppId));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open an app in the right panel.
|
|
||||||
*/
|
|
||||||
function openPanel(appId: string, path = '/'): void {
|
|
||||||
if (isMobile) return;
|
|
||||||
|
|
||||||
const app = apps.find((a) => a.id === appId);
|
|
||||||
if (!app || app.id === currentAppId) return;
|
|
||||||
|
|
||||||
const url = `${app.baseUrl}${path}`;
|
|
||||||
|
|
||||||
rightPanel = {
|
|
||||||
appId: app.id,
|
|
||||||
url,
|
|
||||||
name: app.name,
|
|
||||||
};
|
|
||||||
isActive = true;
|
|
||||||
|
|
||||||
// Persist to URL and localStorage
|
|
||||||
updateUrlState({ panel: appId, split: dividerPosition });
|
|
||||||
savePanelState(storageConfig, { rightPanel, dividerPosition, isActive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the split panel.
|
|
||||||
*/
|
|
||||||
function closePanel(): void {
|
|
||||||
rightPanel = null;
|
|
||||||
isActive = false;
|
|
||||||
|
|
||||||
// Clear persistence
|
|
||||||
clearUrlState();
|
|
||||||
savePanelState(storageConfig, { rightPanel: null, dividerPosition, isActive: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Swap left and right panels (navigate to the right panel app).
|
|
||||||
*/
|
|
||||||
function swapPanels(): void {
|
|
||||||
if (!rightPanel) return;
|
|
||||||
|
|
||||||
// Navigate to the other app
|
|
||||||
const targetUrl = rightPanel.url;
|
|
||||||
window.location.href = targetUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the divider position.
|
|
||||||
*/
|
|
||||||
function setDividerPosition(position: number): void {
|
|
||||||
const clamped = Math.max(DIVIDER_CONSTRAINTS.MIN, Math.min(DIVIDER_CONSTRAINTS.MAX, position));
|
|
||||||
dividerPosition = clamped;
|
|
||||||
|
|
||||||
// Persist
|
|
||||||
if (isActive) {
|
|
||||||
updateUrlState({ panel: rightPanel?.appId, split: clamped });
|
|
||||||
savePanelState(storageConfig, { rightPanel, dividerPosition: clamped, isActive });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset divider to default position.
|
|
||||||
*/
|
|
||||||
function resetDividerPosition(): void {
|
|
||||||
setDividerPosition(DIVIDER_CONSTRAINTS.DEFAULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize from URL and localStorage.
|
|
||||||
*/
|
|
||||||
function initialize(): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
// Check mobile
|
|
||||||
const checkMobile = () => {
|
|
||||||
isMobile = window.innerWidth < MOBILE_BREAKPOINT;
|
|
||||||
if (isMobile && isActive) {
|
|
||||||
closePanel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkMobile();
|
|
||||||
window.addEventListener('resize', checkMobile);
|
|
||||||
|
|
||||||
// Load from URL first, then localStorage
|
|
||||||
const urlState = getCurrentUrlState();
|
|
||||||
const storedState = loadPanelState(storageConfig);
|
|
||||||
|
|
||||||
const panelAppId = urlState.panel || storedState?.rightPanel?.appId;
|
|
||||||
const savedPosition = urlState.split || storedState?.dividerPosition;
|
|
||||||
|
|
||||||
if (panelAppId && !isMobile) {
|
|
||||||
const app = apps.find((a) => a.id === panelAppId);
|
|
||||||
if (app && app.id !== currentAppId) {
|
|
||||||
openPanel(panelAppId);
|
|
||||||
if (savedPosition) {
|
|
||||||
setDividerPosition(savedPosition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the store interface with getters for reactive access
|
|
||||||
return {
|
|
||||||
get isActive() {
|
|
||||||
return isActive;
|
|
||||||
},
|
|
||||||
get rightPanel() {
|
|
||||||
return rightPanel;
|
|
||||||
},
|
|
||||||
get dividerPosition() {
|
|
||||||
return dividerPosition;
|
|
||||||
},
|
|
||||||
get isMobile() {
|
|
||||||
return isMobile;
|
|
||||||
},
|
|
||||||
get availableApps() {
|
|
||||||
return availableApps;
|
|
||||||
},
|
|
||||||
openPanel,
|
|
||||||
closePanel,
|
|
||||||
swapPanels,
|
|
||||||
setDividerPosition,
|
|
||||||
resetDividerPosition,
|
|
||||||
initialize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the split-panel store in Svelte context.
|
|
||||||
* Call this in your layout component.
|
|
||||||
*/
|
|
||||||
export function setSplitPanelContext(
|
|
||||||
currentAppId: string,
|
|
||||||
apps: AppDefinition[] = DEFAULT_APPS
|
|
||||||
): SplitPanelStore {
|
|
||||||
const store = createSplitPanelStore(currentAppId, apps);
|
|
||||||
setContext(SPLIT_PANEL_CONTEXT_KEY, store);
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the split-panel store from Svelte context.
|
|
||||||
* Call this in child components.
|
|
||||||
*/
|
|
||||||
export function getSplitPanelContext(): SplitPanelStore {
|
|
||||||
const store = getContext<SplitPanelStore>(SPLIT_PANEL_CONTEXT_KEY);
|
|
||||||
if (!store) {
|
|
||||||
throw new Error(
|
|
||||||
'[SplitScreen] No split-panel context found. Did you call setSplitPanelContext in a parent component?'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if split-panel context exists.
|
|
||||||
*/
|
|
||||||
export function hasSplitPanelContext(): boolean {
|
|
||||||
try {
|
|
||||||
getContext(SPLIT_PANEL_CONTEXT_KEY);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
/**
|
|
||||||
* Split-Screen Types
|
|
||||||
* Type definitions for the split-screen panel system.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for a panel showing an app in an iFrame.
|
|
||||||
*/
|
|
||||||
export interface PanelConfig {
|
|
||||||
/** Unique identifier for the app (e.g., 'calendar', 'todo', 'contacts') */
|
|
||||||
appId: string;
|
|
||||||
/** Full URL to load in the iFrame */
|
|
||||||
url: string;
|
|
||||||
/** Display name for the app */
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* State of the split-screen system.
|
|
||||||
*/
|
|
||||||
export interface SplitScreenState {
|
|
||||||
/** Whether split-screen mode is active */
|
|
||||||
isActive: boolean;
|
|
||||||
/** Configuration for the right panel (null when not in split mode) */
|
|
||||||
rightPanel: PanelConfig | null;
|
|
||||||
/** Position of the divider as percentage (20-80) */
|
|
||||||
dividerPosition: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* App registration for the split-screen system.
|
|
||||||
* Used to define which apps can be opened in panels.
|
|
||||||
*/
|
|
||||||
export interface AppDefinition {
|
|
||||||
/** Unique app identifier */
|
|
||||||
id: string;
|
|
||||||
/** Display name */
|
|
||||||
name: string;
|
|
||||||
/** Base URL for the app */
|
|
||||||
baseUrl: string;
|
|
||||||
/** Icon name (Lucide icon) */
|
|
||||||
icon?: string;
|
|
||||||
/** App theme color */
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event payload for panel operations.
|
|
||||||
*/
|
|
||||||
export interface PanelEvent {
|
|
||||||
type: 'open' | 'close' | 'swap' | 'resize';
|
|
||||||
panel?: PanelConfig;
|
|
||||||
dividerPosition?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage key configuration.
|
|
||||||
*/
|
|
||||||
export interface StorageConfig {
|
|
||||||
/** Key prefix for localStorage */
|
|
||||||
prefix: string;
|
|
||||||
/** Current app ID for scoped storage */
|
|
||||||
currentAppId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL state parameters for split-screen.
|
|
||||||
*/
|
|
||||||
export interface UrlState {
|
|
||||||
/** App ID for the right panel */
|
|
||||||
panel?: string;
|
|
||||||
/** Divider position percentage */
|
|
||||||
split?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum and maximum constraints for divider position.
|
|
||||||
*/
|
|
||||||
export const DIVIDER_CONSTRAINTS = {
|
|
||||||
MIN: 20,
|
|
||||||
MAX: 80,
|
|
||||||
DEFAULT: 50,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Breakpoint for disabling split-screen on mobile.
|
|
||||||
*/
|
|
||||||
export const MOBILE_BREAKPOINT = 1024;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Split-Screen Utilities
|
|
||||||
* Re-export all utility functions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { parseUrlState, updateUrlState, clearUrlState, getCurrentUrlState } from './url-state.js';
|
|
||||||
|
|
||||||
export {
|
|
||||||
savePanelState,
|
|
||||||
loadPanelState,
|
|
||||||
clearPanelState,
|
|
||||||
createStorageConfig,
|
|
||||||
} from './local-storage.js';
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
/**
|
|
||||||
* LocalStorage Utilities
|
|
||||||
* Handle persistent storage for split-screen preferences.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SplitScreenState, StorageConfig } from '../types.js';
|
|
||||||
import { DIVIDER_CONSTRAINTS } from '../types.js';
|
|
||||||
|
|
||||||
const STORAGE_VERSION = 1;
|
|
||||||
|
|
||||||
interface StoredState {
|
|
||||||
version: number;
|
|
||||||
state: Partial<SplitScreenState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate storage key for an app.
|
|
||||||
*/
|
|
||||||
function getStorageKey(config: StorageConfig): string {
|
|
||||||
return `${config.prefix}-splitscreen-${config.currentAppId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save split-screen state to localStorage.
|
|
||||||
*/
|
|
||||||
export function savePanelState(config: StorageConfig, state: Partial<SplitScreenState>): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stored: StoredState = {
|
|
||||||
version: STORAGE_VERSION,
|
|
||||||
state: {
|
|
||||||
dividerPosition: state.dividerPosition,
|
|
||||||
rightPanel: state.rightPanel,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
localStorage.setItem(getStorageKey(config), JSON.stringify(stored));
|
|
||||||
} catch (_error) {
|
|
||||||
// localStorage not available or quota exceeded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load split-screen state from localStorage.
|
|
||||||
*/
|
|
||||||
export function loadPanelState(config: StorageConfig): Partial<SplitScreenState> | null {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getStorageKey(config));
|
|
||||||
if (!raw) return null;
|
|
||||||
|
|
||||||
const stored: StoredState = JSON.parse(raw);
|
|
||||||
|
|
||||||
// Version check for future migrations
|
|
||||||
if (stored.version !== STORAGE_VERSION) {
|
|
||||||
clearPanelState(config);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate divider position
|
|
||||||
if (stored.state.dividerPosition !== undefined) {
|
|
||||||
stored.state.dividerPosition = Math.max(
|
|
||||||
DIVIDER_CONSTRAINTS.MIN,
|
|
||||||
Math.min(DIVIDER_CONSTRAINTS.MAX, stored.state.dividerPosition)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return stored.state;
|
|
||||||
} catch (_error) {
|
|
||||||
// localStorage not available or corrupted data
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear split-screen state from localStorage.
|
|
||||||
*/
|
|
||||||
export function clearPanelState(config: StorageConfig): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(getStorageKey(config));
|
|
||||||
} catch (_error) {
|
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default storage config with mana prefix.
|
|
||||||
*/
|
|
||||||
export function createStorageConfig(currentAppId: string): StorageConfig {
|
|
||||||
return {
|
|
||||||
prefix: 'mana',
|
|
||||||
currentAppId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
/**
|
|
||||||
* URL State Utilities
|
|
||||||
* Handle URL-based state persistence for split-screen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { UrlState } from '../types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse split-screen state from URL search params.
|
|
||||||
* Reads `?panel=todo&split=60` format.
|
|
||||||
*/
|
|
||||||
export function parseUrlState(searchParams: URLSearchParams): UrlState {
|
|
||||||
const panel = searchParams.get('panel') || undefined;
|
|
||||||
const splitStr = searchParams.get('split');
|
|
||||||
const split = splitStr ? parseInt(splitStr, 10) : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
panel,
|
|
||||||
split: split && !isNaN(split) ? split : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update URL with split-screen state without page reload.
|
|
||||||
* Uses replaceState to avoid adding to browser history.
|
|
||||||
*/
|
|
||||||
export function updateUrlState(state: UrlState): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
|
|
||||||
if (state.panel) {
|
|
||||||
url.searchParams.set('panel', state.panel);
|
|
||||||
} else {
|
|
||||||
url.searchParams.delete('panel');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.split && state.split !== 50) {
|
|
||||||
url.searchParams.set('split', state.split.toString());
|
|
||||||
} else {
|
|
||||||
url.searchParams.delete('split');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.history.replaceState({}, '', url.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear split-screen state from URL.
|
|
||||||
*/
|
|
||||||
export function clearUrlState(): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete('panel');
|
|
||||||
url.searchParams.delete('split');
|
|
||||||
window.history.replaceState({}, '', url.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current URL state.
|
|
||||||
*/
|
|
||||||
export function getCurrentUrlState(): UrlState {
|
|
||||||
if (typeof window === 'undefined') return {};
|
|
||||||
return parseUrlState(new URLSearchParams(window.location.search));
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"types": ["svelte"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
|
|
@ -16,23 +16,13 @@ export const MANA_SHARED_PACKAGES = [
|
||||||
'@mana/shared-tailwind',
|
'@mana/shared-tailwind',
|
||||||
'@mana/shared-theme',
|
'@mana/shared-theme',
|
||||||
'@mana/shared-theme-ui',
|
'@mana/shared-theme-ui',
|
||||||
'@mana/shared-feedback-ui',
|
|
||||||
'@mana/shared-feedback-service',
|
|
||||||
'@mana/shared-feedback-types',
|
|
||||||
'@mana/shared-auth',
|
'@mana/shared-auth',
|
||||||
'@mana/shared-auth-ui',
|
'@mana/shared-auth-ui',
|
||||||
'@mana/shared-branding',
|
'@mana/shared-branding',
|
||||||
'@mana/shared-subscription-ui',
|
|
||||||
'@mana/shared-profile-ui',
|
|
||||||
'@mana/shared-i18n',
|
'@mana/shared-i18n',
|
||||||
'@mana/shared-api-client',
|
|
||||||
'@mana/shared-splitscreen',
|
|
||||||
'@mana/shared-utils',
|
'@mana/shared-utils',
|
||||||
'@mana/shared-tags',
|
'@mana/shared-tags',
|
||||||
'@mana/shared-stores',
|
'@mana/shared-stores',
|
||||||
'@mana/shared-help-types',
|
|
||||||
'@mana/shared-help-content',
|
|
||||||
'@mana/shared-help-ui',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue