Skip to content

PersistedState, unable to have access to the non-reactive value #407

Description

@kraktus

Describe the feature in detail (code, mocks, or screenshots encouraged)

The issue is when you want have access to a non-proxy value of your persistedState (to serialise on your own, to clone, etc.).

It's impossible for the moment.

export const saveSession = () => {
    const ongoing = sessionState.current;
    // const ongoing2 = structuredClone(ongoing); <--- Fails!
    const endedSession: Session = {
            ...ongoing,
            questions: sessionState.current.questions.map((q) => q.qid),
            duration_s: sessionDuration.current || 0
        };
       // try to save to IDB, fail! because `sessionState.current` is a proxy
};

Solution, something like

	currentRaw(): T {
		this.#subscribe?.();

		let root: T | undefined;
		if (this.#connected) {
			// when we're connected to storage, we use storage as the source of truth
			const storageItem = this.#storage?.getItem(this.#key);
			root = storageItem ? this.#deserialize(storageItem) : this.#current;
		} else {
			// when we're not connected to storage, we use the current value in memory
			root = this.#current;
		}
		return root;
	}

Full repro, a bit lengthy due to no external lib for IDB.

Details
import { writable } from 'svelte/store';

import { PersistedState } from 'runed';


// TODO, switch to `idb` package?
export type IDbValue = Blob | string | ArrayBuffer | Uint8Array | string[] | number | object;

// bump version when touching this
const storeKeys = ['session'] as const;
type StoreKeys = (typeof storeKeys)[number];

export class Db {
    private inner: IDBDatabase;
    stores: Record<StoreKeys, Store>;

    constructor(inner: IDBDatabase, stores: Record<StoreKeys, Store>) {
        this.inner = inner;
        this.stores = stores;
    }

    static async open(): Promise<Db> {
        const openReq = indexedDB.open(prefix, 2);
        return new Promise<Db>((resolve) => {
            openReq.onupgradeneeded = () => {
                let db = openReq.result;
                // create all stores
                for (const storeKey of storeKeys) {
                    if (!db.objectStoreNames.contains(storeKey)) {
                        console.log(`Creating object store: ${storeKey}`);
                        db.createObjectStore(storeKey, { keyPath: 'id' });
                    }
                }
            };
            openReq.onerror = (event: any) => console.error(`DB open error: ${JSON.stringify(event)}`);
            openReq.onblocked = function () {
                // this event shouldn't trigger if we handle onversionchange correctly
                const blocked = 'Database is blocked, close all other tabs of this page';
                console.error(blocked);
                alert(blocked);
            };
            openReq.onsuccess = function () {
                let db = openReq.result;

                db.onversionchange = function () {
                    db.close();
                    const outdated = 'Database is outdated, please reload the page.';
                    console.error(outdated);
                    alert(outdated);
                };
                const stores = {
                    session: new Store(db, 'session')
                };
                resolve(new Db(db, stores));
            };
        });
    }
}

// IDB store, based on lila/ui/lib/src/objectStorage.ts, AGPL
export class Store {
    private db: IDBDatabase;
    private storeKey: StoreKeys;

    constructor(db: IDBDatabase, storeKey: StoreKeys) {
        this.db = db;
        this.storeKey = storeKey;
    }

    objectStore = (mode: IDBTransactionMode) => {
        return this.db.transaction(this.storeKey, mode).objectStore(this.storeKey);
    };

    // ArrayBuffer, Blob, File, and typed arrays like Uint8Array
    async put(key: IDbValue, value: IDbValue): Promise<void> {
        console.log(`putting in store ${this.storeKey}: key {${key}} value {${JSON.stringify(value)}}`);
        return promise(() => this.objectStore('readwrite').put({ id: key, data: value }));
    }

    async getMany<T extends IDbValue>(keys?: IDBKeyRange): Promise<(T | null)[]> {
        return promise(() => this.objectStore('readonly').getAll(keys));
    }

}

let theGlobalDb: Db;
let init: (db: Db) => void;

export const getDb = new Promise<Db>((resolve) => {
    init = (db: Db) => {
        resolve(db);
    };
});

export const initDb = async (): Promise<void> => {
    theGlobalDb = await Db.open();
    init(theGlobalDb);
};

interface SessionBase<T> {
    id: SessionId;
    name: string;
    kind: { is: 'exam'; year: number; initial_time: number } | { is: 'study' };
    created_at: Timestamp;
    questions: T[];
}

export type Session = SessionBase<Qid> & {
    duration_s: number;
};

export interface QuestionWip {
    qid: Qid;
    duration_s: number;
    selected_choice?: number;
    correct_choice?: number; // if correct is set, then the question is no longer editable
}

// duration_s is notset until the end of the session, so that we can display a timer during the session
// without having to (de)serialise the session object on every tick.
export type OngoingSession = SessionBase<QuestionWip>;

const sessionKey = 'ongoingSession' as LocalStorageKey;
const durationKey = 'sessionDuration' as LocalStorageKey;

export const sessionState = new PersistedState<OngoingSession | undefined>(sessionKey, undefined);
// if above is defined, assume below belongs to it
export const sessionDuration = new PersistedState<number>(durationKey, 0);
export const pastSessions = writable<Session[]>([], 
    (set) => {
    getDb.then((db: Db) => {
        db.stores.attempt.getMany().then((idbArray) => {
            // sort by most recent first
            const sessions = (idbArray as {id: SessionId, data: Session}[]).map((obj) => obj.data);
            (sessions).sort((a, b) => b.created_at - a.created_at);
            set(sessions);
        });
    });
});

// always keep the object in sync with IndexedDB
pastSessions.subscribe((value: Session[]) => {
    getDb.then((db: Db) => {
        for (const session of value) {
            db.stores.attempt.put(session.id, session);
        }
    });
});


export const saveSession = () => {
    const ongoing = sessionState.current;
    const ongoing2 = structuredClone(ongoing);
    const endedSession: Session = {
            ...ongoing,
            questions: sessionState.current.questions.map((q) => q.qid),
            duration_s: sessionDuration.current || 0
        };
        pastSessions.update((current) => {
            current.unshift(endedSession);
            return current;
        });
};

initDb();
saveSession()

What type of pull request would this be?

None

Provide relevant links or additional information.

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    triagePending triage by a maintainer to give it proper labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions