podcast manager

doing some client organization

+383 -165
+16 -9
src/client/components/messenger.tsx
··· 1 1 import {useSignal} from '@preact/signals' 2 2 import {useCallback} from 'preact/hooks' 3 3 4 - import {useDatabase} from '#client/context-database.js' 5 - import {useSkypod} from '#client/context-skypod.js' 4 + import {useRealmIdentity} from '#client/realm/context-identity' 5 + import {useDatabase} from '#client/root/context-database' 6 + import {useSkypod} from '#client/skypod/context' 6 7 7 8 export const Messenger: preact.FunctionComponent = () => { 8 9 const {useDbSignal} = useDatabase() 10 + const {identity} = useRealmIdentity() 9 11 const store = useSkypod() 10 12 11 13 const feeds$ = useDbSignal((db) => db.feeds.toArray()) 12 14 13 15 const subscribeUrl$ = useSignal<string>('') 14 16 const subscribeAction = useCallback(() => { 15 - const action = store.action('feed:add', {url: subscribeUrl$.value, private: false}) 17 + const action = store.action('feed:add', { 18 + url: subscribeUrl$.value, 19 + lock: {by: identity.identid}, 20 + private: false, 21 + }) 16 22 17 23 store.dispatch(action).catch((ex: unknown) => { 18 24 console.log('error dispatching subscribe action', ex) 19 25 }) 20 - }, [store, subscribeUrl$]) 26 + }, [store, identity.identid, subscribeUrl$]) 21 27 22 28 return ( 23 29 <div className="messages-list"> 24 30 <ul> 25 - {feeds$.value?.map((feed) => ( 26 - <li key={feed.guid}> 27 - {feed.guid} - {feed.title ?? feed.url} 28 - </li> 29 - ))} 31 + {feeds$.value && 32 + feeds$.value.map((feed) => ( 33 + <li key={feed.url}> 34 + {feed.url} - {feed.title ?? 'unnamed'} - {feed.lastRefresh.status} - {feed.lock?.by} 35 + </li> 36 + ))} 30 37 <li> 31 38 <input 32 39 type="url"
+1 -1
src/client/components/peer-list.tsx
··· 1 1 import {useSignal, useSignalEffect} from '@preact/signals' 2 2 3 - import {useRealmConnection} from '#client/context-realm-connection.js' 3 + import {useRealmConnection} from '#client/realm/context-connection' 4 4 import {PeerState} from '#client/realm/types' 5 5 import {IdentID} from '#common/protocol' 6 6
+1 -1
src/client/context-database.tsx src/client/root/context-database.tsx
··· 3 3 import {createContext} from 'preact' 4 4 import {useContext, useMemo} from 'preact/hooks' 5 5 6 - import {Database} from '#client/database' 6 + import {Database} from '#client/root/service-database' 7 7 8 8 type DbQuerier<T> = (db: Database) => Promise<T> 9 9
+3 -3
src/client/context-realm-connection.tsx src/client/realm/context-connection.tsx
··· 2 2 import {createContext} from 'preact' 3 3 import {useCallback, useContext, useEffect} from 'preact/hooks' 4 4 5 - import {ConnectionOptions, RealmConnection} from '#client/realm/connection' 5 + import {useDatabase} from '#client/root/context-database' 6 6 import {normalizeProtocolError, ProtocolError} from '#common/errors.js' 7 7 8 - import {useDatabase} from './context-database' 9 - import {useRealmIdentity} from './context-realm-identity' 8 + import {useRealmIdentity} from './context-identity' 9 + import {ConnectionOptions, RealmConnection} from './service-connection' 10 10 11 11 export interface RealmConnectionContext { 12 12 realm: Signal<RealmConnection | undefined>
+2 -2
src/client/context-realm-identity.tsx src/client/realm/context-identity.tsx
··· 2 2 import {createContext} from 'preact' 3 3 import {useContext, useEffect} from 'preact/hooks' 4 4 5 - import {useDatabase} from '#client/context-database.js' 5 + import {useDatabase} from '#client/root/context-database' 6 6 import {generateSigningJwkPair} from '#common/crypto/jwks' 7 7 import {normalizeProtocolError, ProtocolError} from '#common/errors' 8 8 import {IdentBrand, IdentID} from '#common/protocol' 9 9 import {ClockState, ClockStorage, LogicalClock} from '#common/protocol/logical-clock' 10 10 11 - import {RealmIdentity} from './realm/identity' 11 + import {RealmIdentity} from './service-identity' 12 12 13 13 export interface RealmIdentityContext { 14 14 identity: RealmIdentity
+66 -10
src/client/context-skypod.tsx src/client/skypod/context.tsx
··· 1 1 import {useSignalEffect} from '@preact/signals' 2 2 import {createContext} from 'preact' 3 + import {useContext, useEffect, useRef} from 'preact/hooks' 4 + import {z} from 'zod/v4' 3 5 4 6 import {IdentID} from '#common/protocol' 5 7 import {jsonSchema} from '#common/protocol/schema' 6 8 import {Action, ActionMap, ActionOpts, actionSchema} from '#skypod/actions' 9 + import {feedSchema} from '#skypod/schema' 7 10 8 - import {useContext, useRef} from 'preact/hooks' 9 - import {useDatabase} from './context-database' 10 - import {useRealmConnection} from './context-realm-connection' 11 - import {useRealmIdentity} from './context-realm-identity' 11 + import {useRealmConnection} from '#client/realm/context-connection' 12 + import {useRealmIdentity} from '#client/realm/context-identity' 13 + import {useDatabase} from '#client/root/context-database' 14 + 15 + import FeedFetchWorker from './feed-fetch.worker?worker' 12 16 13 17 export type MiddlewareFn = ( 14 18 this: undefined, ··· 40 44 props, 41 45 ) => { 42 46 const {db} = useDatabase() 43 - const {clock} = useRealmIdentity() 47 + const {identity, clock} = useRealmIdentity() 44 48 const {realm} = useRealmConnection() 49 + 50 + const processor = useRef<Worker>(null) 45 51 46 52 // initial middleware 47 53 // TODO: import from elsewhere ··· 51 57 await db.feeds.add({ 52 58 url: action.dat.url, 53 59 tags: [], 54 - guid: crypto.randomUUID(), 55 - lock: null, 60 + lock: action.dat.lock || null, 56 61 private: action.dat.private, 57 62 lastRefresh: { 58 63 hp: 100, ··· 62 67 httpStatus: null, 63 68 }, 64 69 }) 70 + 71 + processor.current?.postMessage({msg: 'poll'}) 65 72 }, 66 73 67 74 'feed:remove': async (action) => { 68 75 await db.feeds.delete({url: action.dat.url}) 76 + }, 77 + 78 + 'feed:patch': async (action) => { 79 + await db.feeds.update(action.dat.url, action.dat.payload) 69 80 }, 70 81 }, 71 82 ]) ··· 153 164 const handler = (event: CustomEvent<{identid: IdentID; data: unknown}>) => { 154 165 const parsed = schema.safeParse(event.detail.data) 155 166 if (parsed.success) { 156 - console.log('forwarding event:', parsed) 157 - context.dispatch(parsed.data).catch((ex: unknown) => { 167 + console.log('handling forwarded event:', parsed) 168 + context.dispatch({...parsed.data, opt: {local: true}}).catch((ex: unknown) => { 158 169 console.error('couldnt dispatch realm action!', ex) 159 170 }) 160 171 } ··· 166 177 } 167 178 }) 168 179 169 - // just for setting up middlewares and such 180 + const patchSchema = z.union([ 181 + z.object({ 182 + msg: z.literal('patch'), 183 + key: z.string(), 184 + changes: feedSchema.partial(), 185 + }), 186 + z.object({ 187 + msg: z.literal('error'), 188 + key: z.string(), 189 + error: z.string(), 190 + }), 191 + ]) 192 + 193 + // start feed processor worker 194 + useEffect(() => { 195 + const worker = new FeedFetchWorker() 196 + 197 + worker.onmessage = async (event: MessageEvent) => { 198 + const parsed = patchSchema.safeParse(event.data) 199 + console.log('message from fetch worker', parsed) 200 + 201 + switch (parsed.data?.msg) { 202 + case 'patch': 203 + await context.dispatch( 204 + context.action('feed:patch', {url: parsed.data.key, payload: parsed.data.changes}), 205 + ) 206 + break 207 + 208 + case 'error': 209 + default: 210 + console.error('unknown message from worker', parsed) 211 + } 212 + } 213 + 214 + worker.onerror = (error) => { 215 + console.error('Feed processor worker error:', error) 216 + } 217 + 218 + worker.postMessage({msg: 'start', identid: identity.identid}) 219 + 220 + processor.current = worker 221 + return () => { 222 + worker.terminate() 223 + } 224 + }) 225 + 170 226 console.log('rendering the skypod context') 171 227 return <SkypodContext.Provider value={context}>{props.children}</SkypodContext.Provider> 172 228 }
-95
src/client/database.ts
··· 1 - import Dexie, {Collection, type Table} from 'dexie' 2 - 3 - import {JWK} from '#common/crypto/jwks' 4 - import {IdentID, RealmID} from '#common/protocol' 5 - import {LCTimestamp, LogicalClock} from '#common/protocol/logical-clock' 6 - import {Feed, FeedEntry} from '#skypod/schema' 7 - 8 - export interface LocalIdentity { 9 - identid: IdentID 10 - keypair: CryptoKeyPair 11 - } 12 - 13 - export interface Realm { 14 - realmid: RealmID 15 - touchedAt: number 16 - knownPeers: Record<IdentID, JWK> 17 - } 18 - 19 - type Lockable = {lock: LCTimestamp | null} 20 - 21 - export class Database extends Dexie { 22 - identity!: Table<LocalIdentity> 23 - realms!: Table<Realm> 24 - feeds!: Table<Feed> 25 - entries!: Table<FeedEntry> 26 - 27 - clock?: LogicalClock 28 - 29 - constructor() { 30 - super('skypod') 31 - 32 - this.version(1).stores({ 33 - identity: '&identid', 34 - realms: '&realmid, touchedAt', 35 - 36 - feeds: '&url, &guid, *tags, lastFetchedAt, lastEntryAt', 37 - feedentries: '&[entryguid+feedguid], feedguid, *tags, publishedAt, fetchedAt', 38 - }) 39 - } 40 - 41 - async withLock<T extends Lockable, R>( 42 - collection: Collection<T>, 43 - clock: LogicalClock, 44 - work: () => Promise<R>, 45 - _options?: Partial<{ 46 - heartbeat: number 47 - staleage: number 48 - }>, 49 - ): Promise<R> { 50 - // set defaults 51 - const options = { 52 - heartbeat: 2 * 1000, 53 - staleage: 60 * 1000, 54 - ..._options, 55 - } 56 - 57 - // Try to acquire lock 58 - let lock = clock.now() 59 - const updated = await collection 60 - .filter((obj) => !obj.lock || clock.elapsed(obj.lock) > options.staleage) 61 - .modify((obj) => { 62 - obj.lock = lock 63 - }) 64 - 65 - // couldn't get lock 66 - if (updated === 0) { 67 - throw new Error('Could not acquire lock - already locked by another process') 68 - } 69 - 70 - // heartbeat needs to only get these options 71 - let keepalive = setTimeout(heartbeat, options.heartbeat) 72 - function heartbeat() { 73 - const nextlock = clock.now() 74 - 75 - collection 76 - .filter((o) => o.lock == lock) 77 - .modify((o) => { 78 - o.lock = nextlock 79 - }) 80 - .then(() => { 81 - lock = nextlock 82 - keepalive = setTimeout(heartbeat, options.heartbeat) 83 - }) 84 - .catch(() => { 85 - clearTimeout(keepalive) 86 - }) 87 - } 88 - 89 - try { 90 - return await work() 91 - } finally { 92 - clearTimeout(keepalive) 93 - } 94 - } 95 - }
+9 -5
src/client/page-app.tsx
··· 1 + import {RealmConnectionManager} from '#client/realm/cmpnt-connection-manager' 2 + import { 3 + RealmConnectionFallbackProps, 4 + RealmConnectionProvider, 5 + } from '#client/realm/context-connection' 6 + import {RealmIdentityFallbackProps, RealmIdentityProvider} from '#client/realm/context-identity' 7 + import {DatabaseProvider} from '#client/root/context-database.js' 8 + import {SkypodProvider} from '#client/skypod/context' 9 + 1 10 import {Messenger} from './components/messenger' 2 11 import {PeerList} from './components/peer-list' 3 - import {DatabaseProvider} from './context-database' 4 - import {RealmConnectionFallbackProps, RealmConnectionProvider} from './context-realm-connection' 5 - import {RealmIdentityFallbackProps, RealmIdentityProvider} from './context-realm-identity' 6 - import {SkypodProvider} from './context-skypod' 7 - import {RealmConnectionManager} from './realm-connection-manager' 8 12 9 13 export const App: preact.FunctionComponent = () => { 10 14 const identityFallback = (p: RealmIdentityFallbackProps) =>
+2 -2
src/client/realm-connection-manager.tsx src/client/realm/cmpnt-connection-manager.tsx
··· 6 6 import {jwtSchema} from '#common/crypto/jwts' 7 7 import {RealmBrand} from '#common/protocol' 8 8 9 - import {useRealmConnection} from './context-realm-connection' 10 - import {useRealmIdentity} from './context-realm-identity' 9 + import {useRealmConnection} from './context-connection' 10 + import {useRealmIdentity} from './context-identity' 11 11 12 12 export const RealmConnectionManager: preact.FunctionComponent = () => { 13 13 const {identity} = useRealmIdentity()
+25 -2
src/client/realm/connection.ts src/client/realm/service-connection.ts
··· 10 10 import * as protocol from '#common/protocol' 11 11 import {IdentID, RealmID} from '#common/protocol' 12 12 import {streamSocketJson, takeSocketJson} from '#common/socket' 13 - import {RealmIdentity} from './identity' 14 - import {RealmPeer} from './peer' 13 + 14 + import {RealmIdentity} from './service-identity' 15 15 import {PeerState} from './types' 16 16 17 17 const realmRtcMessagesSchema = z.union([ ··· 403 403 } 404 404 } 405 405 } 406 + 407 + /** a single webrtc peer connection within a realm */ 408 + export class RealmPeer extends SimplePeer { 409 + identid: IdentID 410 + nonce: string 411 + initiator: boolean 412 + 413 + constructor(identid: IdentID, nonce: string, initiator: boolean) { 414 + super({ 415 + initiator, 416 + config: { 417 + iceServers: [ 418 + {urls: 'stun:stun.l.google.com:19302'}, 419 + {urls: 'stun:stun1.l.google.com:19302'}, 420 + ], 421 + }, 422 + }) 423 + 424 + this.identid = identid 425 + this.nonce = nonce 426 + this.initiator = initiator 427 + } 428 + }
+1 -3
src/client/realm/identity.ts src/client/realm/service-identity.ts
··· 1 1 import {JWK, jwkExport} from '#common/crypto/jwks.js' 2 2 import {IdentID} from '#common/protocol' 3 3 4 - export interface Signable { 5 - sign(key: CryptoKey): Promise<string> 6 - } 4 + import {Signable} from './types' 7 5 8 6 /** manages websocket and webrtc connections for a realm */ 9 7 export class RealmIdentity {
-26
src/client/realm/peer.ts
··· 1 - import SimplePeer from 'simple-peer' 2 - 3 - import {IdentID} from '#common/protocol.js' 4 - 5 - /** a single webrtc peer connection within a realm */ 6 - export class RealmPeer extends SimplePeer { 7 - identid: IdentID 8 - nonce: string 9 - initiator: boolean 10 - 11 - constructor(identid: IdentID, nonce: string, initiator: boolean) { 12 - super({ 13 - initiator, 14 - config: { 15 - iceServers: [ 16 - {urls: 'stun:stun.l.google.com:19302'}, 17 - {urls: 'stun:stun1.l.google.com:19302'}, 18 - ], 19 - }, 20 - }) 21 - 22 - this.identid = identid 23 - this.nonce = nonce 24 - this.initiator = initiator 25 - } 26 - }
+4
src/client/realm/types.ts
··· 13 13 connected: boolean 14 14 destroyed: boolean 15 15 } 16 + 17 + export interface Signable { 18 + sign(key: CryptoKey): Promise<string> 19 + }
src/client/reducer-feeds.ts

This is a binary file and will not be displayed.

+94
src/client/root/service-database.ts
··· 1 + import Dexie, {Collection, IndexableType, type Table} from 'dexie' 2 + 3 + import {JWK} from '#common/crypto/jwks' 4 + import {IdentID, RealmID} from '#common/protocol' 5 + import {LCTimestamp, LogicalClock} from '#common/protocol/logical-clock' 6 + import {Feed, FeedEntry, Lock} from '#skypod/schema' 7 + 8 + export interface LocalIdentity { 9 + identid: IdentID 10 + keypair: CryptoKeyPair 11 + } 12 + 13 + export interface Realm { 14 + realmid: RealmID 15 + touchedAt: number 16 + knownPeers: Record<IdentID, JWK> 17 + } 18 + 19 + type Lockable = {lock: Lock | null} 20 + 21 + export class Database extends Dexie { 22 + identity!: Table<LocalIdentity> 23 + realms!: Table<Realm> 24 + feeds!: Table<Feed> 25 + entries!: Table<FeedEntry> 26 + 27 + clock?: LogicalClock 28 + 29 + constructor() { 30 + super('skypod') 31 + 32 + this.version(1).stores({ 33 + identity: '&identid', 34 + realms: '&realmid, touchedAt', 35 + 36 + feeds: '&url, *tags, lock.at, lock.by, lastRefresh.status', 37 + feedentries: '&[entryguid+feedurl], feedurl, *tags, lock.at, lock.by, publishedAt, fetchedAt', 38 + }) 39 + } 40 + 41 + async withLock<T extends Lockable, C extends Collection<T>, R>( 42 + table: string, 43 + collection: C, 44 + clock: LogicalClock, 45 + owner: IdentID, 46 + work: (pkeys: IndexableType[], lock: LCTimestamp) => Promise<R>, 47 + _options?: Partial<{ 48 + heartbeat: number 49 + staleage: number 50 + }>, 51 + ): Promise<false | R> { 52 + // set defaults 53 + const options = { 54 + heartbeat: 2 * 1000, 55 + staleage: 60 * 1000, 56 + ..._options, 57 + } 58 + 59 + // Try to acquire lock 60 + const lock = clock.now() 61 + const updated = await collection 62 + .filter((obj) => { 63 + if (obj.lock == null) return true 64 + if (obj.lock.by !== owner) return false 65 + if (obj.lock.at == null) return true 66 + 67 + return clock.elapsed(obj.lock.at) > options.staleage 68 + }) 69 + .modify((obj) => { 70 + obj.lock = {by: owner, at: lock} 71 + }) 72 + 73 + // couldn't get lock 74 + if (updated === 0) { 75 + return false 76 + } 77 + 78 + const locked = await collection.db.table(table).where('lock.at').equals(lock).primaryKeys() 79 + const unlocks = locked.reduce<{key: IndexableType; changes: Lockable}[]>( 80 + (memo, key) => [...memo, {key, changes: {lock: null}}], 81 + [], 82 + ) 83 + 84 + try { 85 + return await work(locked, lock) 86 + } finally { 87 + try { 88 + await this.table(table).bulkUpdate(unlocks) 89 + } catch (ex) { 90 + console.log('problem unlocking rows', ex) 91 + } 92 + } 93 + } 94 + }
src/client/signals.ts src/client/util/signals.ts
+144
src/client/skypod/feed-fetch.worker.ts
··· 1 + import {IndexableType} from 'dexie' 2 + import {z} from 'zod/v4' 3 + 4 + import {Database} from '#client/root/service-database.js' 5 + import {normalizeProtocolError} from '#common/errors.js' 6 + import {IdentBrand, IdentID} from '#common/protocol' 7 + import {LCTimestamp, LogicalClock} from '#common/protocol/logical-clock' 8 + 9 + const msgStartSchema = z.object({ 10 + msg: z.literal('start'), 11 + identid: IdentBrand.schema, 12 + }) 13 + 14 + const msgPollSchema = z.object({ 15 + msg: z.literal('poll'), 16 + }) 17 + 18 + const msgStopSchema = z.object({ 19 + msg: z.literal('stop'), 20 + }) 21 + 22 + const msgSchema = z.discriminatedUnion('msg', [msgStartSchema, msgPollSchema, msgStopSchema]) 23 + 24 + class FeedFetch { 25 + #db: Database 26 + #owner: IdentID 27 + #clock: LogicalClock 28 + #timeout: ReturnType<typeof setTimeout> 29 + 30 + constructor(identid: IdentID) { 31 + this.#db = new Database() 32 + this.#clock = new LogicalClock(identid) 33 + this.#owner = identid 34 + 35 + this.#timeout = setTimeout(this.#poll, 10000) 36 + } 37 + 38 + stop() { 39 + clearTimeout(this.#timeout) 40 + } 41 + 42 + poll() { 43 + clearTimeout(this.#timeout) 44 + this.#poll() 45 + } 46 + 47 + #poll = () => { 48 + const pendingFeeds = this.#db.feeds.where('lastRefresh.status').equals('pending') 49 + this.#db 50 + .withLock('feeds', pendingFeeds, this.#clock, this.#owner, this.#pollLocked) 51 + .catch((ex: unknown) => { 52 + console.error('problem locking pending feeds', ex) 53 + }) 54 + .finally(() => { 55 + this.#timeout = setTimeout(this.#poll, 10000) 56 + }) 57 + } 58 + 59 + #pollLocked = async (urls: IndexableType[], lock: LCTimestamp) => { 60 + console.log('checking feeds...', urls, lock) 61 + 62 + try { 63 + const feeds = await this.#db.feeds.bulkGet(urls) 64 + for (const feed of feeds) { 65 + if (feed == null) { 66 + console.log('got a null feed?') 67 + continue 68 + } 69 + 70 + try { 71 + const parsed = await parseFeed(feed.url) 72 + postMessage({ 73 + msg: 'patch', 74 + key: feed.url, 75 + changes: { 76 + ...parsed, 77 + lastRefresh: { 78 + hp: 100, 79 + at: lock, 80 + status: 'complete', 81 + httpStatus: '200', 82 + httpEtag: '', 83 + }, 84 + }, 85 + }) 86 + } catch (exc: unknown) { 87 + const err = normalizeProtocolError(exc) 88 + console.error('problem fetching feed:', err) 89 + 90 + postMessage({ 91 + msg: 'patch', 92 + key: feed.url, 93 + changes: { 94 + lastRefresh: { 95 + hp: feed.lastRefresh.hp - 10, 96 + at: lock, 97 + status: 'error', 98 + httpStatus: err.status, 99 + httpEtag: err.message, 100 + }, 101 + }, 102 + }) 103 + } 104 + } 105 + } catch (ex: unknown) { 106 + console.error('problem fetching pending feeds:', ex) 107 + } 108 + } 109 + } 110 + 111 + let fetcher: FeedFetch 112 + 113 + onmessage = (event: MessageEvent) => { 114 + const parsed = msgSchema.safeParse(event.data) 115 + switch (parsed.data?.msg) { 116 + case 'start': 117 + fetcher = new FeedFetch(parsed.data.identid) 118 + break 119 + 120 + case 'poll': 121 + fetcher.poll() 122 + break 123 + 124 + case 'stop': 125 + fetcher.stop() 126 + break 127 + 128 + default: 129 + console.warn('unknown message, bailing', event.data, parsed.error) 130 + return 131 + } 132 + } 133 + 134 + // Mock feed parser - replace with actual implementation 135 + async function parseFeed(url: string) { 136 + // Simulate network delay 137 + await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 2000)) 138 + 139 + return { 140 + title: `Parsed Feed from ${url}`, 141 + description: 'Mock parsed feed data', 142 + lastBuildAt: Date.now(), 143 + } 144 + }
+3 -2
src/skypod/actions-feed.ts
··· 1 1 import {makeActionSchema} from '#common/protocol/schema' 2 2 import {z} from 'zod/v4' 3 3 4 - import {feedSchema} from './schema' 4 + import {feedSchema, lockSchema} from './schema' 5 5 6 6 export const addActionSchema = makeActionSchema( 7 7 'feed:add', 8 8 z.object({ 9 9 url: z.url(), 10 + lock: lockSchema.optional(), 10 11 private: z.boolean().optional().default(false), 11 12 }), 12 13 ) ··· 16 17 export const patchActionSchema = makeActionSchema( 17 18 'feed:patch', 18 19 z.object({ 19 - guid: z.guid(), 20 + url: z.url(), 20 21 payload: feedSchema.partial(), 21 22 }), 22 23 )
+11 -4
src/skypod/schema.ts
··· 1 + import {IdentBrand} from '#common/protocol/brands.js' 1 2 import {LogicalClock} from '#common/protocol/logical-clock.js' 2 3 import {z} from 'zod/v4' 3 4 5 + // lock schema for distributed work coordination 6 + export const lockSchema = z.object({ 7 + at: LogicalClock.schema.optional(), 8 + by: IdentBrand.schema, 9 + }) 10 + 4 11 // in-feed data 5 12 export const feedKeysSchema = z.object({ 6 13 url: z.url(), 7 - guid: z.guid(), 8 - lock: LogicalClock.schema.nullable(), 14 + lock: lockSchema.nullable(), 9 15 tags: z.array(z.string()), 10 16 private: z.boolean().default(false), 11 17 lastRefresh: z.object({ ··· 35 41 export const feedSchema = feedKeysSchema.extend(feedPayloadSchema.partial().shape) 36 42 37 43 export const feedEntrySchema = z.object({ 44 + feedurl: z.url(), 38 45 entryguid: z.guid(), 39 - feedguid: z.guid(), 40 46 tags: z.array(z.string()), 41 - lock: LogicalClock.schema.nullable(), 47 + lock: lockSchema.nullable(), 42 48 43 49 title: z.string().nullable(), 44 50 snippet: z.string().nullable(), ··· 74 80 .nullable(), 75 81 }) 76 82 83 + export type Lock = z.infer<typeof lockSchema> 77 84 export type Feed = z.infer<typeof feedSchema> 78 85 export type FeedEntry = z.infer<typeof feedEntrySchema>
+1
src/vite-env.d.ts
··· 1 + /// <reference types="vite/client" />