From 771054070370b26642fb859f47e2854f18d32c65 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 9 Jun 2026 13:01:31 -0500 Subject: [PATCH] refactor: types & type guard module for atmosphere * add CHANGELOG --- CHANGELOG.md | 38 ++++++ src/lib/atproto/identity.ts | 14 +- src/lib/atproto/repo.svelte.ts | 140 +++++++++++++++++--- src/lib/atproto/setup.svelte.ts | 3 +- src/lib/atproto/types.ts | 32 +++++ src/lib/components/CollectionBrowser.svelte | 3 +- src/lib/components/SetupDialog.svelte | 3 +- src/lib/db/repositories/records.ts | 21 +++ 8 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/lib/atproto/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..12b324a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project targets [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Target release: `v1.0.0`. + +### 2026-06-09 + +#### Added + +- PGlite-backed local cache/search foundation with WASM build support. + - Transactional schema migrations for accounts, cached records, collection + sync state, and record search. +- Ubuntu-purple boot splash that initializes PGlite, runs migrations, and offers + recovery actions. +- Cache-aware AT Protocol browsing: live record fetches populate PGlite and offline + failures can fall back to cached collections/records. +- Shared `errorMessage()` utility for consistent unknown-error messages. + +### 2026-06-08 + +#### Added + +- Ubuntu 8.10 / GNOME 2-inspired desktop shell, panel, wallpaper, windows, and Humanity + icon styling. +- First-run AT Protocol account setup flow with handle resolution and persisted account + identity. +- Nautilus-style collection browser backed by live AT Protocol repository collection and + record loading. +- gedit-style JSON record viewer opened from selected records. +- Window management basics including movable, resizable, minimizable, maximizable, and + closable windows. +- About Computer dialog, desktop icons, sticky note, and Tangled project links. diff --git a/src/lib/atproto/identity.ts b/src/lib/atproto/identity.ts index ca3f444..86b3e79 100644 --- a/src/lib/atproto/identity.ts +++ b/src/lib/atproto/identity.ts @@ -3,24 +3,14 @@ import type { ActorIdentifier } from '@atcute/lexicons/syntax'; import type { Handle } from '@atcute/lexicons/syntax'; import type {} from '@atcute/atproto'; import type {} from '@atcute/bluesky'; +import type { AccountIdentity, DidDocument } from './types'; -export type AccountIdentity = { - handle: string; - did: string; - pds: string | null; - displayName: string | null; - avatar: string | null; - description: string | null; -}; +export type { AccountIdentity } from './types'; export const defaultIdentity = { handle: 'desertthunder.dev', did: 'did:plc:xg2vq45muivyy3xwatcehspu' }; const publicApi = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) }); -type DidDocument = { - service?: Array<{ id?: string; type?: string; serviceEndpoint?: string | string[] | Record }>; -}; - export async function resolveAccount(handle: string): Promise { const normalizedHandle = handle.trim().replace(/^@/, '').toLowerCase(); diff --git a/src/lib/atproto/repo.svelte.ts b/src/lib/atproto/repo.svelte.ts index 39a948c..683593c 100644 --- a/src/lib/atproto/repo.svelte.ts +++ b/src/lib/atproto/repo.svelte.ts @@ -1,22 +1,25 @@ import { Client, ok, simpleFetchHandler } from '@atcute/client'; import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; import type {} from '@atcute/atproto'; +import { + cacheFetchedRecords, + getDatabase, + listCachedCollections, + listCachedRecords, + updateCollectionSyncState, + type CachedRecord, + type CachedRecordInput +} from '$lib/db'; import { errorMessage } from '$lib/utils/errors'; -import type { AccountIdentity } from './identity'; - -export type CollectionSummary = { name: string; icon: string; loadedCount: number | null }; - -export type RepoRecordSummary = { - uri: string; - cid: string; - title: string; - body: string; - author: string; - modified: string; - collection: string; - rkey: string; - json: string; -}; +import { + isRecordValue, + type AccountIdentity, + type CollectionSummary, + type RepoRecordSummary, + type UnknownRecord +} from './types'; + +export type { CollectionSummary, RepoRecordSummary } from './types'; class RepoBrowserState { collections = $state([]); @@ -57,7 +60,10 @@ class RepoBrowserState { await this.selectCollection(identity, this.selectedCollection); } } catch (unknownError) { - this.error = errorMessage(unknownError, 'Could not load repository collections.'); + const loadedFromCache = await this.loadCollectionsFromCache(identity); + this.error = loadedFromCache + ? `Showing cached collections. ${errorMessage(unknownError, 'Could not load repository collections.')}` + : errorMessage(unknownError, 'Could not load repository collections.'); } finally { this.isLoadingCollections = false; } @@ -85,14 +91,60 @@ class RepoBrowserState { this.collections = this.collections.map((collection) => collection.name === collectionName ? { ...collection, loadedCount: response.records.length } : collection ); + void cacheLiveRecords(identity, collectionName, response.records); } catch (unknownError) { - this.records = []; - this.error = errorMessage(unknownError, `Could not load records for ${collectionName}.`); + const loadedFromCache = await this.loadRecordsFromCache(identity, collectionName); + this.error = loadedFromCache + ? `Showing cached records. ${errorMessage(unknownError, `Could not load records for ${collectionName}.`)}` + : errorMessage(unknownError, `Could not load records for ${collectionName}.`); } finally { this.isLoadingRecords = false; } } + async loadCollectionsFromCache(identity: AccountIdentity) { + try { + const db = await getDatabase(); + const collections = await listCachedCollections(db, identity.did); + + if (collections.length === 0) return false; + + this.collections = collections.map((collection) => ({ + name: collection.name, + icon: iconForCollection(collection.name), + loadedCount: collection.loadedCount + })); + this.selectedCollection = preferredCollection(this.collections.map((collection) => collection.name)); + + if (this.selectedCollection) { + await this.loadRecordsFromCache(identity, this.selectedCollection); + } + + return true; + } catch (cacheError) { + console.warn('Could not read cached collections.', cacheError); + return false; + } + } + + async loadRecordsFromCache(identity: AccountIdentity, collectionName: string) { + try { + const db = await getDatabase(); + const records = await listCachedRecords(db, { repoDid: identity.did, collection: collectionName, limit: 25 }); + + if (records.length === 0) return false; + + this.records = records.map((record) => summarizeCachedRecord(record, identity.handle)); + this.collections = this.collections.map((collection) => + collection.name === collectionName ? { ...collection, loadedCount: records.length } : collection + ); + return true; + } catch (cacheError) { + console.warn('Could not read cached records.', cacheError); + return false; + } + } + reset() { this.collections = []; this.selectedCollection = null; @@ -123,8 +175,6 @@ export function iconForCollection(name: string) { return '/icons/humanity/mimes/text-x-generic.svg'; } -type UnknownRecord = { uri: string; cid: string; value: unknown }; - function summarizeRecord(record: UnknownRecord, handle: string): RepoRecordSummary { const value = isRecordValue(record.value) ? record.value : {}; const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName); @@ -146,8 +196,54 @@ function summarizeRecord(record: UnknownRecord, handle: string): RepoRecordSumma }; } -function isRecordValue(value: unknown): value is Record { - return typeof value === 'object' && value !== null; +function summarizeCachedRecord(record: CachedRecord, handle: string): RepoRecordSummary { + return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, handle); +} + +async function cacheLiveRecords(identity: AccountIdentity, collectionName: string, records: readonly UnknownRecord[]) { + try { + const db = await getDatabase(); + await cacheFetchedRecords( + db, + records.map((record) => toCachedRecordInput(identity, collectionName, record)) + ); + await updateCollectionSyncState(db, { + accountDid: identity.did, + repoDid: identity.did, + collection: collectionName, + lastSyncedAt: new Date().toISOString(), + lastError: null + }); + } catch (cacheError) { + console.warn('Could not write records to local cache.', cacheError); + } +} + +function toCachedRecordInput( + identity: AccountIdentity, + collectionName: string, + record: UnknownRecord +): CachedRecordInput { + const value = isRecordValue(record.value) ? record.value : {}; + const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName) ?? ''; + const type = stringifyField(value.$type) ?? collectionName; + const createdAt = stringifyField(value.createdAt); + const indexedAt = stringifyField(value.indexedAt); + const updatedAt = stringifyField(value.updatedAt); + + return { + accountDid: identity.did, + repoDid: identity.did, + collection: collectionName, + rkey: recordKeyFromUri(record.uri), + uri: record.uri, + cid: record.cid, + value: record.value, + indexedText: [type, text, record.uri].filter(Boolean).join('\n'), + createdAt, + indexedAt, + updatedAt + }; } function stringifyField(value: unknown) { diff --git a/src/lib/atproto/setup.svelte.ts b/src/lib/atproto/setup.svelte.ts index 50eb402..6748b9f 100644 --- a/src/lib/atproto/setup.svelte.ts +++ b/src/lib/atproto/setup.svelte.ts @@ -1,5 +1,6 @@ import { browser } from '$app/environment'; -import { defaultIdentity, type AccountIdentity } from './identity'; +import { defaultIdentity } from './identity'; +import type { AccountIdentity } from './types'; const storageKey = 'intrepid-ibex:account-identity'; diff --git a/src/lib/atproto/types.ts b/src/lib/atproto/types.ts new file mode 100644 index 0000000..71cdf88 --- /dev/null +++ b/src/lib/atproto/types.ts @@ -0,0 +1,32 @@ +export type AccountIdentity = { + handle: string; + did: string; + pds: string | null; + displayName: string | null; + avatar: string | null; + description: string | null; +}; + +export type DidDocument = { + service?: Array<{ id?: string; type?: string; serviceEndpoint?: string | string[] | Record }>; +}; + +export type CollectionSummary = { name: string; icon: string; loadedCount: number | null }; + +export type RepoRecordSummary = { + uri: string; + cid: string; + title: string; + body: string; + author: string; + modified: string; + collection: string; + rkey: string; + json: string; +}; + +export type UnknownRecord = { uri: string; cid: string; value: unknown }; + +export function isRecordValue(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/lib/components/CollectionBrowser.svelte b/src/lib/components/CollectionBrowser.svelte index ad0f8b3..10c13c3 100644 --- a/src/lib/components/CollectionBrowser.svelte +++ b/src/lib/components/CollectionBrowser.svelte @@ -1,7 +1,8 @@