From aac4c36307401f2ddca8f22eeb2440118a1494d8 Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 09 Jul 2026 18:10:00 +0000 Subject: [PATCH] web: add optimistic overlays and record mutation apis Signed-off-by: dawn --- web/src/lib/optimistic.svelte.ts | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/lib/api/graph.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/lib/api/index.ts | 2 ++ web/src/lib/api/profile.ts | 23 +++++++++++++++++++++++ web/src/lib/api/records.ts | 12 +++++++----- web/src/lib/api/search.ts | 1 - web/src/lib/api/uri.ts | 9 +++++++++ 7 file(s) changed, 269 insertion(s)(+), 6 deletion(s)(-) diff --git a/web/src/lib/optimistic.svelte.ts b/web/src/lib/optimistic.svelte.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/optimistic.svelte.ts @@ -0,0 +1,118 @@ +// optimistic overlays for eventually-consistent bobbin reads: mutations commit +// locally (the pds write is authoritative) and reconcile on natural reloads. +// `key` scopes an overlay to its subject so reused components drop it. +// TODO(bobbin): read-your-writes (serve at-or-after a commit rev) would let +// mutations invalidate loads immediately instead of waiting for navigation. + +interface OptimisticCountOptions { + key: () => string; + loaded: () => number | null | undefined; +} + +export interface OptimisticCount { + readonly value: number; + readonly failed: boolean; + adjust(delta: number): void; + fail(): void; + resetFailure(): void; +} + +export const createOptimisticCount = (options: OptimisticCountOptions): OptimisticCount => { + let failed = $state(false); + // a bump is a bound: fresher data may pass it, never regress across it. + let held = $state(null); + const currentKey = $derived(options.key()); + const loaded = $derived(Math.max(0, options.loaded() ?? 0)); + + const value = $derived.by(() => { + if (held === null || held.key !== currentKey) return loaded; + return held.up ? Math.max(loaded, held.value) : Math.min(loaded, held.value); + }); + + $effect(() => { + if (held === null) return; + const caughtUp = held.up ? loaded >= held.value : loaded <= held.value; + if (held.key !== currentKey || caughtUp) held = null; + }); + + return { + get value() { + return value; + }, + get failed() { + return failed; + }, + adjust(delta) { + failed = false; + held = { key: currentKey, value: Math.max(0, value + delta), up: delta > 0 }; + }, + fail() { + failed = true; + }, + resetFailure() { + failed = false; + } + }; +}; + +interface OptimisticRelationOptions { + key: () => string; + loadedRkey: () => string | null | undefined; +} + +export interface OptimisticRelation { + readonly rkey: string | null; + readonly known: boolean; + readonly active: boolean; + readonly failed: boolean; + created(rkey: string): void; + deleted(): void; + fail(): void; + resetFailure(): void; +} + +export const createOptimisticRelation = ( + options: OptimisticRelationOptions +): OptimisticRelation => { + let failed = $state(false); + let committed = $state(null); + const currentKey = $derived(options.key()); + const loaded = $derived(options.loadedRkey()); + const rkey = $derived(committed?.key === currentKey ? committed.rkey : (loaded ?? null)); + + $effect(() => { + if (committed === null) return; + if (committed.key !== currentKey || loaded === committed.rkey) { + committed = null; + failed = false; + } + }); + + const set = (next: string | null): void => { + failed = false; + committed = { key: currentKey, rkey: next }; + }; + + return { + get rkey() { + return rkey; + }, + get known() { + return loaded !== undefined || committed?.key === currentKey; + }, + get active() { + return rkey !== null; + }, + get failed() { + return failed; + }, + created: set, + deleted: () => set(null), + fail() { + failed = true; + }, + resetFailure() { + failed = false; + } + }; +}; diff --git a/web/src/lib/api/graph.ts b/web/src/lib/api/graph.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/graph.ts @@ -0,0 +1,110 @@ +import { ok } from '@atcute/client'; +import { mainSchema as createRecordSchema } from '@atcute/atproto/types/repo/createRecord'; +import { mainSchema as deleteRecordSchema } from '@atcute/atproto/types/repo/deleteRecord'; +import type { Did, Nsid, RecordKey } from '@atcute/lexicons/syntax'; +import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; +import { createClient } from '$lib/auth/agent'; +import type { BobbinContext } from './client'; +import { items } from './pagination'; +import { rkeyFromUri } from './uri'; +import type * as ShTangledGraphFollow from './lexicons/types/sh/tangled/graph/follow'; +import type * as ShTangledGraphVouch from './lexicons/types/sh/tangled/graph/vouch'; +import type * as ShTangledFeedStar from './lexicons/types/sh/tangled/feed/star'; + +export type FollowRecord = ShTangledGraphFollow.Main; +export type VouchRecord = ShTangledGraphVouch.Main; +export type StarRecord = ShTangledFeedStar.Main; + +const FOLLOW_COLLECTION = 'sh.tangled.graph.follow' as Nsid; +const STAR_COLLECTION = 'sh.tangled.feed.star' as Nsid; + +// TODO(bobbin): needs a relation point-lookup (e.g. graph.getFollow?actor=&subject=) +// or a `viewer` hydration param on lists; scanning listFollowsBy pages is O(follows). +export const findFollowRkey = async ( + ctx: BobbinContext, + viewerDid: string, + subject: string, + options: { maxPages?: number } = {} +): Promise => { + for await (const item of items( + ctx, + 'sh.tangled.graph.listFollowsBy', + { subject: viewerDid as Did }, + { maxPages: options.maxPages ?? 10 } + )) { + if ((item.value as FollowRecord).subject === subject) return rkeyFromUri(item.uri); + } + return null; +}; + +const createGenericRecord = async ( + agent: OAuthUserAgent, + collection: Nsid, + record: T +): Promise => { + const rpc = createClient(agent); + const result = await ok( + rpc.call(createRecordSchema, { + input: { repo: agent.sub, collection, record } + }) + ); + return rkeyFromUri(result.uri); +}; + +const deleteGenericRecord = async ( + agent: OAuthUserAgent, + collection: Nsid, + rkey: string +): Promise => { + const rpc = createClient(agent); + await ok( + rpc.call(deleteRecordSchema, { + input: { repo: agent.sub, collection, rkey: rkey as RecordKey } + }) + ); +}; + +export const createFollow = (agent: OAuthUserAgent, subject: string): Promise => + createGenericRecord(agent, FOLLOW_COLLECTION, { + $type: 'sh.tangled.graph.follow', + subject: subject as Did, + createdAt: new Date().toISOString() + }); + +export const deleteFollow = (agent: OAuthUserAgent, rkey: string): Promise => + deleteGenericRecord(agent, FOLLOW_COLLECTION, rkey); + +export const createStar = (agent: OAuthUserAgent, repoDid: string): Promise => + createGenericRecord(agent, STAR_COLLECTION, { + $type: 'sh.tangled.feed.star', + subject: { + $type: 'sh.tangled.feed.star#repo', + did: repoDid as Did + }, + createdAt: new Date().toISOString() + }); + +export const deleteStar = (agent: OAuthUserAgent, rkey: string): Promise => + deleteGenericRecord(agent, STAR_COLLECTION, rkey); + +// TODO(bobbin): same relation-lookup gap as findFollowRkey; walking every star of +// the viewer to build this map is O(stars) per page load. +export const listStarRkeys = async ( + ctx: BobbinContext, + viewerDid: string, + options: { maxPages?: number } = {} +): Promise> => { + const rkeys = new Map(); + for await (const item of items( + ctx, + 'sh.tangled.feed.listStarsBy', + { subject: viewerDid as Did }, + { maxPages: options.maxPages ?? 10 } + )) { + const value = item.value as StarRecord; + if (value.subject.$type === 'sh.tangled.feed.star#repo') { + rkeys.set(value.subject.did, rkeyFromUri(item.uri)); + } + } + return rkeys; +}; diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -7,3 +7,5 @@ export * from './coverage'; export * from './identity'; export * from './load'; +export * from './uri'; +export * from './graph'; diff --git a/web/src/lib/api/profile.ts b/web/src/lib/api/profile.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/profile.ts @@ -0,0 +1,23 @@ +import { ok } from '@atcute/client'; +import { mainSchema as putRecordSchema } from '@atcute/atproto/types/repo/putRecord'; +import type { Nsid, RecordKey } from '@atcute/lexicons/syntax'; +import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; +import { createClient } from '$lib/auth/agent'; +import type { ProfileRecord } from './records'; + +const PROFILE_COLLECTION = 'sh.tangled.actor.profile' as Nsid; + +// the profile record lives at the fixed rkey `self`; put upserts it. +export const putProfile = async (agent: OAuthUserAgent, record: ProfileRecord): Promise => { + const rpc = createClient(agent); + await ok( + rpc.call(putRecordSchema, { + input: { + repo: agent.sub, + collection: PROFILE_COLLECTION, + rkey: 'self' as RecordKey, + record + } + }) + ); +}; diff --git a/web/src/lib/api/records.ts b/web/src/lib/api/records.ts --- a/web/src/lib/api/records.ts +++ b/web/src/lib/api/records.ts @@ -5,14 +5,12 @@ import type * as ShTangledRepoIssue from './lexicons/types/sh/tangled/repo/issue'; import type * as ShTangledRepoPull from './lexicons/types/sh/tangled/repo/pull'; -/** getRecord-shaped view returned by the hydrated single-record `get*` endpoints. */ export interface RecordView { uri: string; cid?: string; value: V; } -/** Envelope returned by the bulk `get*s` endpoints (no cursor — inputs are explicit uris). */ export interface RecordList { items: RecordView[]; } @@ -22,7 +20,6 @@ export type IssueRecord = ShTangledRepoIssue.Main; export type PullRecord = ShTangledRepoPull.Main; -/** Max uris accepted per bulk request (mirrors bobbin's BULK_LIMIT). */ export const BULK_LIMIT = 50; export const getRepo = (ctx: BobbinContext, repo: string, init?: XrpcRequestInit) => @@ -31,8 +28,13 @@ export const getRepoByRepoDid = (ctx: BobbinContext, repoDid: string, init?: XrpcRequestInit) => jsonGet>(ctx, 'sh.tangled.repo.getRepoByRepoDid', { repoDid }, init); -export const getProfile = (ctx: BobbinContext, actor: string, init?: XrpcRequestInit) => - jsonGet>(ctx, 'sh.tangled.actor.getProfile', { actor }, init); +export const getProfile = (ctx: BobbinContext, did: string, init?: XrpcRequestInit) => + jsonGet>( + ctx, + 'sh.tangled.actor.getProfile', + { actor: `at://${did}/sh.tangled.actor.profile/self` }, + init + ); export const getIssue = (ctx: BobbinContext, issue: string, init?: XrpcRequestInit) => jsonGet>(ctx, 'sh.tangled.repo.getIssue', { issue }, init); diff --git a/web/src/lib/api/search.ts b/web/src/lib/api/search.ts --- a/web/src/lib/api/search.ts +++ b/web/src/lib/api/search.ts @@ -27,7 +27,6 @@ limit?: number; } -/** One page of full-text search hits. */ export const search = (ctx: BobbinContext, params: SearchParams, init?: XrpcRequestInit) => jsonGet(ctx, 'sh.tangled.search.query', { ...params }, init); diff --git a/web/src/lib/api/uri.ts b/web/src/lib/api/uri.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/uri.ts @@ -0,0 +1,9 @@ +// at-uri helpers: at://// + +export const didFromUri = (uri: string): string => { + const rest = uri.startsWith('at://') ? uri.slice(5) : uri; + const slash = rest.indexOf('/'); + return slash === -1 ? rest : rest.slice(0, slash); +}; + +export const rkeyFromUri = (uri: string): string => uri.slice(uri.lastIndexOf('/') + 1); -- tangled.sh