mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 15:26:41 +02:00
feat(local-first): add local-first architecture with Dexie.js, Go sync server, and Todo pilot
Implement the foundational local-first data layer for ManaCore apps: - New @manacore/local-store package (Dexie.js IndexedDB, sync engine, Svelte 5 reactive queries) - New mana-sync Go service (sync protocol, WebSocket push, field-level LWW conflict resolution) - Todo app migrated as pilot: stores read/write IndexedDB, guest mode with onboarding seed data - PillNavigation: prominent login pill for unauthenticated users - SyncIndicator component showing local/syncing/offline status - GuestWelcomeModal on first visit for Todo app - Removed demo-mode auth_required checks from Todo components (all writes are now local) - CSP fix for local development (localhost:3001, localhost:3050) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ddff8485b
commit
2e4bb9bad7
41 changed files with 4388 additions and 340 deletions
153
packages/local-store/src/database.ts
Normal file
153
packages/local-store/src/database.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Local database management via Dexie.js.
|
||||
*
|
||||
* Each app gets its own IndexedDB database with:
|
||||
* - App-specific collections (tables)
|
||||
* - A shared _pendingChanges table for sync tracking
|
||||
* - A shared _syncMeta table for sync cursors
|
||||
*/
|
||||
|
||||
import Dexie from 'dexie';
|
||||
import type { BaseRecord, CollectionConfig, PendingChange, SyncMeta } from './types.js';
|
||||
|
||||
/**
|
||||
* Creates a Dexie database for an app with the given collections.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const db = createDatabase('todo', [
|
||||
* { name: 'tasks', indexes: ['projectId', 'dueDate', '[isCompleted+dueDate]'] },
|
||||
* { name: 'projects', indexes: ['order'] },
|
||||
* { name: 'labels', indexes: [] },
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
export function createDatabase(
|
||||
appId: string,
|
||||
collections: CollectionConfig<BaseRecord>[]
|
||||
): LocalDatabase {
|
||||
const db = new LocalDatabase(appId, collections);
|
||||
return db;
|
||||
}
|
||||
|
||||
export class LocalDatabase extends Dexie {
|
||||
/** Pending changes waiting to be synced to the server. */
|
||||
_pendingChanges!: Dexie.Table<PendingChange, number>;
|
||||
|
||||
/** Sync metadata per collection (last sync timestamp, etc.). */
|
||||
_syncMeta!: Dexie.Table<SyncMeta, string>;
|
||||
|
||||
private readonly _appId: string;
|
||||
private readonly _collections: CollectionConfig<BaseRecord>[];
|
||||
private _seeded = false;
|
||||
|
||||
constructor(appId: string, collections: CollectionConfig<BaseRecord>[]) {
|
||||
super(`manacore-${appId}`);
|
||||
this._appId = appId;
|
||||
this._collections = collections;
|
||||
|
||||
// Build Dexie schema from collection configs
|
||||
const schema: Record<string, string> = {
|
||||
// Internal tables
|
||||
_pendingChanges: '++id, collection, recordId, createdAt',
|
||||
_syncMeta: 'collection',
|
||||
};
|
||||
|
||||
for (const col of collections) {
|
||||
// Primary key is always 'id', plus any additional indexes
|
||||
const indexes = ['id', ...(col.indexes ?? [])];
|
||||
// Add updatedAt and deletedAt for sync queries
|
||||
indexes.push('updatedAt', 'deletedAt');
|
||||
schema[col.name] = indexes.join(', ');
|
||||
}
|
||||
|
||||
this.version(1).stores(schema);
|
||||
}
|
||||
|
||||
get appId(): string {
|
||||
return this._appId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load guest seed data into empty collections.
|
||||
* Only runs once per database lifetime.
|
||||
*/
|
||||
async seedGuestData(): Promise<void> {
|
||||
if (this._seeded) return;
|
||||
this._seeded = true;
|
||||
|
||||
for (const col of this._collections) {
|
||||
if (!col.guestSeed || col.guestSeed.length === 0) continue;
|
||||
|
||||
const table = this.table(col.name);
|
||||
const count = await table.count();
|
||||
|
||||
if (count === 0) {
|
||||
const now = new Date().toISOString();
|
||||
const records = col.guestSeed.map((record) => ({
|
||||
...record,
|
||||
createdAt: record.createdAt ?? now,
|
||||
updatedAt: record.updatedAt ?? now,
|
||||
deletedAt: null,
|
||||
}));
|
||||
await table.bulkPut(records);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sync cursor (last synced timestamp) for a collection.
|
||||
*/
|
||||
async getSyncCursor(collection: string): Promise<string> {
|
||||
const meta = await this._syncMeta.get(collection);
|
||||
// Default: epoch — pull everything on first sync
|
||||
return meta?.lastSyncedAt ?? '1970-01-01T00:00:00.000Z';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the sync cursor after a successful sync.
|
||||
*/
|
||||
async setSyncCursor(collection: string, syncedUntil: string): Promise<void> {
|
||||
const pendingCount = await this._pendingChanges.where('collection').equals(collection).count();
|
||||
|
||||
await this._syncMeta.put({
|
||||
collection,
|
||||
lastSyncedAt: syncedUntil,
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of pending (un-synced) changes across all collections.
|
||||
*/
|
||||
async getPendingCount(): Promise<number> {
|
||||
return this._pendingChanges.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending changes for a specific collection, ordered by creation time.
|
||||
*/
|
||||
async getPendingChanges(collection: string): Promise<PendingChange[]> {
|
||||
return this._pendingChanges.where('collection').equals(collection).sortBy('createdAt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending changes that have been successfully synced.
|
||||
*/
|
||||
async clearPendingChanges(ids: number[]): Promise<void> {
|
||||
await this._pendingChanges.bulkDelete(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe all data and re-seed. Used for recovery from corruption.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
for (const col of this._collections) {
|
||||
await this.table(col.name).clear();
|
||||
}
|
||||
await this._pendingChanges.clear();
|
||||
await this._syncMeta.clear();
|
||||
this._seeded = false;
|
||||
await this.seedGuestData();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue