mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +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-theme',
|
||||
'@mana/shared-theme-ui',
|
||||
'@mana/shared-feedback-ui',
|
||||
'@mana/shared-feedback-service',
|
||||
'@mana/shared-feedback-types',
|
||||
'@mana/shared-auth',
|
||||
'@mana/shared-auth-ui',
|
||||
'@mana/shared-branding',
|
||||
'@mana/shared-subscription-ui',
|
||||
'@mana/shared-profile-ui',
|
||||
'@mana/shared-i18n',
|
||||
'@mana/shared-api-client',
|
||||
'@mana/shared-splitscreen',
|
||||
'@mana/shared-utils',
|
||||
'@mana/shared-tags',
|
||||
'@mana/shared-stores',
|
||||
'@mana/shared-help-types',
|
||||
'@mana/shared-help-content',
|
||||
'@mana/shared-help-ui',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue