Skip to content

Commit b884cce

Browse files
committed
feat: Migrate authentication from OIDC to Supabase and implement frontend SQLite database with synchronization capabilities.
1 parent ec7243f commit b884cce

24 files changed

Lines changed: 1183 additions & 1212 deletions

cmd/server/main.go

Lines changed: 68 additions & 305 deletions
Large diffs are not rendered by default.

frontend/lib/api.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import type {
2121
AllUserProgress,
2222
} from '@/types';
2323
import { appwriteService, isAppwriteEnabled, isAppwriteEnabledSync } from './appwrite';
24+
import { db } from './sqlite/db';
25+
import * as sqliteSchema from './sqlite/schema';
26+
import { checkHydration, addToOutbox } from './sqlite/manager';
27+
import { startOutboxSync } from './sqlite/sync_manager';
28+
import { eq } from 'drizzle-orm';
2429

2530
// Use relative URL when embedded, or explicit URL if provided
2631
const getAPIURL = () => {
@@ -64,9 +69,17 @@ class APIClient {
6469
this.updateHeaders();
6570
this.setupInterceptors();
6671
this.startTokenRefreshTimer();
72+
73+
// Initialize Local DB & Sync
74+
this.initLocalDB();
6775
}
6876
}
6977

78+
private async initLocalDB() {
79+
await checkHydration(this.client);
80+
startOutboxSync(this.client);
81+
}
82+
7083
private setupInterceptors() {
7184
// Request interceptor to check token expiry before each request
7285
this.client.interceptors.request.use(
@@ -300,13 +313,37 @@ class APIClient {
300313

301314
// Quests
302315
async getQuests(page = 1, limit = 20): Promise<PaginatedResponse<Quest>> {
316+
try {
317+
// Try local first
318+
const localQuests = await db.select().from(sqliteSchema.quests).offset((page - 1) * limit).limit(limit);
319+
if (localQuests.length > 0) {
320+
return {
321+
data: localQuests.map(q => q.data as Quest),
322+
pagination: {
323+
total: localQuests.length,
324+
page,
325+
limit
326+
}
327+
};
328+
}
329+
} catch (e) {
330+
console.warn('Local quest fetch failed, falling back to API', e);
331+
}
332+
303333
const response = await this.client.get<PaginatedResponse<Quest>>('/quests', {
304334
params: { page, limit },
305335
});
306336
return response.data;
307337
}
308338

309-
async getQuest(id: number): Promise<Quest> {
339+
async getQuest(id: number | string): Promise<Quest> {
340+
try {
341+
const local = await db.select().from(sqliteSchema.quests)
342+
.where(eq(typeof id === 'number' ? sqliteSchema.quests.id : sqliteSchema.quests.external_id, id as any))
343+
.limit(1);
344+
if (local.length > 0) return local[0].data as Quest;
345+
} catch (e) {}
346+
310347
const response = await this.client.get<Quest>(`/quests/${id}`);
311348
return response.data;
312349
}
@@ -792,6 +829,10 @@ class APIClient {
792829
}
793830

794831
async updateMyQuestProgress(questExternalId: string, completed: boolean): Promise<UserQuestProgress> {
832+
// 1. Update/Add to local outbox for offline sync
833+
await addToOutbox('quest_progress', questExternalId, 'upsert', { completed });
834+
835+
// 2. Optimistic Update (Backend call)
795836
const response = await this.client.put<UserQuestProgress>(`/progress/quests/${questExternalId}`, {
796837
completed,
797838
});

frontend/lib/sqlite/db.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { drizzle } from 'drizzle-orm/sqlite-proxy';
2+
import * as schema from './schema';
3+
4+
let worker: Worker | null = null;
5+
let idCounter = 0;
6+
const callbacks = new Map<number, (res: any) => void>();
7+
8+
if (typeof window !== 'undefined') {
9+
worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
10+
11+
worker.onmessage = (e) => {
12+
const { id, result, error } = e.data;
13+
const resolve = callbacks.get(id);
14+
if (resolve) {
15+
callbacks.delete(id);
16+
if (error) {
17+
console.error('SQL Error:', error);
18+
}
19+
resolve(result);
20+
}
21+
};
22+
}
23+
24+
async function callWorker(method: string, sql: string, params: any[]) {
25+
if (!worker) return [];
26+
const id = ++idCounter;
27+
return new Promise((resolve) => {
28+
callbacks.set(id, resolve);
29+
worker!.postMessage({ id, method, sql, params });
30+
});
31+
}
32+
33+
export const db = drizzle(
34+
async (sql, params, method) => {
35+
const res = await callWorker(method, sql, params);
36+
return { rows: res as any[] };
37+
},
38+
{ schema }
39+
);
40+
41+
// Helper to ensure tables exist
42+
export async function ensureTables() {
43+
// Simple table creation - usually handled by migrations, but for WASM
44+
// we can use Drizzle-generated SQL or just raw CREATE TABLEs for now.
45+
// In a production app, we'd use drizzle-kit generated migrations.
46+
47+
// For this MVP, we'll manually ensure tables or use schema-to-sql tools.
48+
// Actually, for a snapshot-based app, we can just DROP/CREATE on full reload.
49+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
declare module 'wa-sqlite/dist/wa-sqlite-async.mjs' {
2+
const factory: () => Promise<any>;
3+
export default factory;
4+
}
5+
6+
declare module 'wa-sqlite/src/examples/IDBBatchAtomicVFS.js' {
7+
export class IDBBatchAtomicVFS {
8+
constructor(name: string);
9+
}
10+
}

frontend/lib/sqlite/manager.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { db } from './db';
2+
import * as sqliteSchema from './schema';
3+
import { get, set } from 'idb-keyval';
4+
5+
// Check if we need to hydrate or if there's a new version
6+
export async function checkHydration(api: any) {
7+
const lastSync = await get('db_last_sync');
8+
const now = Date.now();
9+
10+
// Refetch if more than 24 hours old or never synced
11+
if (!lastSync || (now - (lastSync as number) > 86400000)) {
12+
console.log('Needs hydration - fetching snapshot...');
13+
await hydrate(api);
14+
}
15+
}
16+
17+
async function hydrate(api: any) {
18+
try {
19+
const response = await api.get('/sync/snapshot');
20+
const snapshot = response.data;
21+
22+
// We can clear tables before re-inserting for a clean slate
23+
// and to handle record removals from the backend.
24+
25+
// Quests
26+
if (snapshot.quests) {
27+
for (const quest of snapshot.quests) {
28+
await db.insert(sqliteSchema.quests).values({
29+
...quest,
30+
synced_at: new Date(quest.synced_at),
31+
}).onConflictDoUpdate({
32+
target: sqliteSchema.quests.external_id,
33+
set: { ...quest, synced_at: new Date(quest.synced_at) }
34+
});
35+
}
36+
}
37+
38+
// Items
39+
if (snapshot.items) {
40+
for (const item of snapshot.items) {
41+
await db.insert(sqliteSchema.items).values({
42+
...item,
43+
synced_at: new Date(item.synced_at),
44+
}).onConflictDoUpdate({
45+
target: sqliteSchema.items.external_id,
46+
set: { ...item, synced_at: new Date(item.synced_at) }
47+
});
48+
}
49+
}
50+
51+
// Hideout Modules
52+
if (snapshot.hideout_modules) {
53+
for (const module of snapshot.hideout_modules) {
54+
await db.insert(sqliteSchema.hideoutModules).values({
55+
...module,
56+
synced_at: new Date(module.synced_at),
57+
}).onConflictDoUpdate({
58+
target: sqliteSchema.hideoutModules.external_id,
59+
set: { ...module, synced_at: new Date(module.synced_at) }
60+
});
61+
}
62+
}
63+
64+
// Skill Nodes
65+
if (snapshot.skill_nodes) {
66+
for (const node of snapshot.skill_nodes) {
67+
await db.insert(sqliteSchema.skillNodes).values({
68+
...node,
69+
synced_at: new Date(node.synced_at),
70+
}).onConflictDoUpdate({
71+
target: sqliteSchema.skillNodes.external_id,
72+
set: { ...node, synced_at: new Date(node.synced_at) }
73+
});
74+
}
75+
}
76+
77+
// Bots
78+
if (snapshot.bots) {
79+
for (const bot of snapshot.bots) {
80+
await db.insert(sqliteSchema.bots).values({
81+
...bot,
82+
synced_at: new Date(bot.synced_at),
83+
}).onConflictDoUpdate({
84+
target: sqliteSchema.bots.external_id,
85+
set: { ...bot, synced_at: new Date(bot.synced_at) }
86+
});
87+
}
88+
}
89+
90+
// Maps
91+
if (snapshot.maps) {
92+
for (const map of snapshot.maps) {
93+
await db.insert(sqliteSchema.maps).values({
94+
...map,
95+
synced_at: new Date(map.synced_at),
96+
}).onConflictDoUpdate({
97+
target: sqliteSchema.maps.external_id,
98+
set: { ...map, synced_at: new Date(map.synced_at) }
99+
});
100+
}
101+
}
102+
103+
// Traders
104+
if (snapshot.traders) {
105+
for (const trader of snapshot.traders) {
106+
await db.insert(sqliteSchema.traders).values({
107+
...trader,
108+
synced_at: new Date(trader.synced_at),
109+
}).onConflictDoUpdate({
110+
target: sqliteSchema.traders.external_id,
111+
set: { ...trader, synced_at: new Date(trader.synced_at) }
112+
});
113+
}
114+
}
115+
116+
// Projects
117+
if (snapshot.projects) {
118+
for (const project of snapshot.projects) {
119+
await db.insert(sqliteSchema.projects).values({
120+
...project,
121+
synced_at: new Date(project.synced_at),
122+
}).onConflictDoUpdate({
123+
target: sqliteSchema.projects.external_id,
124+
set: { ...project, synced_at: new Date(project.synced_at) }
125+
});
126+
}
127+
}
128+
129+
await set('db_last_sync', Date.now());
130+
console.log('Hydration complete!');
131+
} catch (error) {
132+
console.error('Hydration failed:', error);
133+
}
134+
}
135+
136+
// Add progress to offline outbox
137+
export async function addToOutbox(type: any, targetId: string, action: any, payload: any) {
138+
await db.insert(sqliteSchema.outbox).values({
139+
type,
140+
target_id: targetId,
141+
action,
142+
payload,
143+
created_at: new Date(),
144+
});
145+
}

frontend/lib/sqlite/schema.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { sqliteTable, text, integer, blob } from 'drizzle-orm/sqlite-core';
2+
3+
// Static Collections (Hydrated from Snapshot)
4+
5+
export const quests = sqliteTable('quests', {
6+
id: integer('id').primaryKey({ autoIncrement: true }),
7+
external_id: text('external_id').notNull().unique(),
8+
name: text('name').notNull(),
9+
description: text('description'),
10+
trader: text('trader'),
11+
objectives: blob('objectives', { mode: 'json' }), // JSONB representation
12+
reward_item_ids: blob('reward_item_ids', { mode: 'json' }),
13+
xp: integer('xp'),
14+
data: blob('data', { mode: 'json' }), // Full raw JSON
15+
synced_at: integer('synced_at', { mode: 'timestamp' }).notNull(),
16+
});
17+
18+
export const items = sqliteTable('items', {
19+
id: integer('id').primaryKey({ autoIncrement: true }),
20+
external_id: text('external_id').notNull().unique(),
21+
name: text('name').notNull(),
22+
description: text('description'),
23+
type: text('type'),
24+
image_url: text('image_url'),
25+
image_filename: text('image_filename'),
26+
data: blob('data', { mode: 'json' }),
27+
synced_at: integer('synced_at', { mode: 'timestamp' }).notNull(),
28+
});
29+
30+
export const skillNodes = sqliteTable('skill_nodes', {
31+
id: integer('id').primaryKey({ autoIncrement: true }),
32+
external_id: text('external_id').notNull().unique(),
33+
name: text('name').notNull(),
34+
description: text('description'),
35+
impacted_skill: text('impacted_skill'),
36+
category: text('category'),
37+
max_points: integer('max_points'),
38+
icon_name: text('icon_name'),
39+
is_major: integer('is_major', { mode: 'boolean' }),
40+
data: blob('data', { mode: 'json' }),
41+
synced_at: integer('synced_at', { mode: 'timestamp' }).notNull(),
42+
});
43+
44+
export const hideoutModules = sqliteTable('hideout_modules', {
45+
id: integer('id').primaryKey({ autoIncrement: true }),
46+
external_id: text('external_id').notNull().unique(),
47+
name: text('name').notNull(),
48+
description: text('description'),
49+
max_level: integer('max_level'),
50+
levels: blob('levels', { mode: 'json' }),
51+
data: blob('data', { mode: 'json' }),
52+
synced_at: integer('synced_at', { mode: 'timestamp' }).notNull(),
53+
});
54+
55+
export const bots = sqliteTable('bots', {
56+
id: integer('id').primaryKey({ autoIncrement: true }),
57+
external_id: text('external_id').notNull().unique(),
58+
name: text('name').notNull(),
59+
description: text('description'),
60+
data: blob('data', { mode: 'json' }),
61+
synced_at: integer('synced_at', { mode: 'timestamp' }).notNull(),
62+
});
63+
64+
export const maps = sqliteTable('maps', {
65+
id: integer('id').primaryKey({ autoIncrement: true }),
66+
external_id: text('external_id').notNull().unique(),
67+
name: text('name').notNull(),
68+
description: text('description'),
69+
data: blob('data', { mode: 'json' }),
70+
synced_at: integer('synced_at', { mode: 'timestamp' }).notNull(),
71+
});
72+
73+
export const traders = sqliteTable('traders', {
74+
id: integer('id').primaryKey({ autoIncrement: true }),
75+
external_id: text('external_id').notNull().unique(),
76+
name: text('name').notNull(),
77+
description: text('description'),
78+
data: blob('data', { mode: 'json' }),
79+
synced_at: integer('synced_at', { mode: 'timestamp' }).notNull(),
80+
});
81+
82+
export const projects = sqliteTable('projects', {
83+
id: integer('id').primaryKey({ autoIncrement: true }),
84+
external_id: text('external_id').notNull().unique(),
85+
name: text('name').notNull(),
86+
description: text('description'),
87+
data: blob('data', { mode: 'json' }),
88+
synced_at: integer('synced_at', { mode: 'timestamp' }).notNull(),
89+
});
90+
91+
// Offline Outbox (For Progress Sync)
92+
93+
export const outbox = sqliteTable('outbox', {
94+
id: integer('id').primaryKey({ autoIncrement: true }),
95+
type: text('type', { enum: ['quest_progress', 'hideout_module_progress', 'skill_node_progress', 'blueprint_progress'] }).notNull(),
96+
target_id: text('target_id').notNull(), // External ID of the entity
97+
action: text('action', { enum: ['upsert', 'delete'] }).notNull(),
98+
payload: blob('payload', { mode: 'json' }).notNull(), // The actual progress data
99+
created_at: integer('created_at', { mode: 'timestamp' }).notNull(),
100+
retry_count: integer('retry_count').default(0),
101+
});

0 commit comments

Comments
 (0)