📄 src/models/trash.ts
D-OPEN SOVEREIGN

Documentation & Insights

Global, boot-owned "trash" index: which record ids (any object type) are
currently `deleted` or flagged `!safe`, bucketed by the creatorUsername
whose copy they belong to. An item lands here by having its `deleted`/`safe`
property patched — the same EditPropertyNumber op every property edit
uses (orm.ts) — so this module never creates patches, it only mirrors them.

Population is two-layered:
 - scanTrash() — full pass over every currently-hydrated record (dump()),
   called once after each hydrateAll() (session.ts, boot + loadLanguage()).
 - live: registered as a ReactivitySystem. Orm.patch() calls
   dbReactivity.trigger(this) unconditionally, for EVERY mutation source —
   a local edit, restoreAccountSave()'s cross-device replay, and
   subscription-graph sync alike (they all funnel through record.patch()) —
   so one hook here covers all three, for any username, with no per-source
   wiring anywhere else.

A localStorage snapshot seeds the in-memory state synchronously at import
time so consumers (useTrash()) paint instantly instead of reading empty
until bootModelsV2() resolves; scanTrash() then reconciles it against the
live corpus once boot completes.
Merge ids across a set of usernames. Empty array (default) = every known username.
Usernames currently bucketed for this objectType (e.g. to populate a picker).
Re-derives one record's trash membership from its current deleted/safe
state. Mutates the bucket in place; returns the objectType if membership
actually changed (null if not), so callers can batch persist/notify rather
than firing them per record.
Live path — one record just mutated (any source): apply, then debounce persist+notify.
Full scan of every currently-hydrated record (dump()), across all object
types — no per-type registration needed since `deleted`/`safe` are
universal base properties (see dsafe.ts). Call after hydrateAll() (session.ts
does this for both the initial boot and each loadLanguage() call).

Batches persist()/notify() to once each (not once per record) — a bulk
boot-time pass over a large corpus must not fire a synchronous localStorage
write and a listener fan-out per changed record.
Test-only: clears in-memory + localStorage state and any pending debounce timer.
1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library — The World Library
4 *
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 *
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15/**
16 * Global, boot-owned "trash" index: which record ids (any object type) are
17 * currently `deleted` or flagged `!safe`, bucketed by the creatorUsername
18 * whose copy they belong to. An item lands here by having its `deleted`/`safe`
19 * property patched — the same EditPropertyNumber op every property edit
20 * uses (orm.ts) — so this module never creates patches, it only mirrors them.
21 *
22 * Population is two-layered:
23 *  - scanTrash() — full pass over every currently-hydrated record (dump()),
24 *    called once after each hydrateAll() (session.ts, boot + loadLanguage()).
25 *  - live: registered as a ReactivitySystem. Orm.patch() calls
26 *    dbReactivity.trigger(this) unconditionally, for EVERY mutation source —
27 *    a local edit, restoreAccountSave()'s cross-device replay, and
28 *    subscription-graph sync alike (they all funnel through record.patch()) —
29 *    so one hook here covers all three, for any username, with no per-source
30 *    wiring anywhere else.
31 *
32 * A localStorage snapshot seeds the in-memory state synchronously at import
33 * time so consumers (useTrash()) paint instantly instead of reading empty
34 * until bootModelsV2() resolves; scanTrash() then reconciles it against the
35 * live corpus once boot completes.
36 */
37import { dump } from "./db.ts";
38import { addReactivitySystem } from "./db.ts";
39
40export interface TrashStore {
41    /** Merge ids across a set of usernames. Empty array (default) = every known username. */
42    list(objectType: number, usernames?: string[]): number[];
43    count(objectType: number, usernames?: string[]): number;
44    /** Usernames currently bucketed for this objectType (e.g. to populate a picker). */
45    usernames(objectType: number): string[];
46    subscribe(objectType: number, listener: () => void): () => void;
47}
48
49type TrashState = Map<number, Map<string, Set<number>>>;
50
51const STORAGE_KEY = "the_library:trash:v1";
52
53function loadSeed(): TrashState {
54    const state: TrashState = new Map();
55    if (typeof localStorage === "undefined") return state;
56    try {
57        const raw = localStorage.getItem(STORAGE_KEY);
58        if (!raw) return state;
59        const parsed = JSON.parse(raw) as Record<string, Record<string, number[]>>;
60        for (const [typeStr, byUsername] of Object.entries(parsed)) {
61            const userMap = new Map<string, Set<number>>();
62            for (const [username, ids] of Object.entries(byUsername)) {
63                userMap.set(username, new Set(ids));
64            }
65            state.set(Number(typeStr), userMap);
66        }
67    } catch (e) {
68        console.warn("[trash] failed to load localStorage seed", e);
69    }
70    return state;
71}
72
73const _state: TrashState = loadSeed();
74const _listeners = new Map<number, Set<() => void>>();
75
76function persist(): void {
77    if (typeof localStorage === "undefined") return;
78    const serialized: Record<number, Record<string, number[]>> = {};
79    for (const [objectType, byUsername] of _state) {
80        const obj: Record<string, number[]> = {};
81        for (const [username, ids] of byUsername) {
82            if (ids.size > 0) obj[username] = Array.from(ids);
83        }
84        if (Object.keys(obj).length > 0) serialized[objectType] = obj;
85    }
86    try {
87        localStorage.setItem(STORAGE_KEY, JSON.stringify(serialized));
88    } catch (e) {
89        console.warn("[trash] failed to persist localStorage snapshot", e);
90    }
91}
92
93function notify(objectType: number): void {
94    _listeners.get(objectType)?.forEach(fn => {
95        try { fn(); } catch (e) { console.error("[trash] listener failed", e); }
96    });
97}
98
99function bucketFor(objectType: number, username: string): Set<number> {
100    let byUsername = _state.get(objectType);
101    if (!byUsername) { byUsername = new Map(); _state.set(objectType, byUsername); }
102    let ids = byUsername.get(username);
103    if (!ids) { ids = new Set(); byUsername.set(username, ids); }
104    return ids;
105}
106
107/**
108 * Re-derives one record's trash membership from its current deleted/safe
109 * state. Mutates the bucket in place; returns the objectType if membership
110 * actually changed (null if not), so callers can batch persist/notify rather
111 * than firing them per record.
112 */
113function applyMembership(record: any): number | null {
114    const objectType = record?.schema?.objectId;
115    const id = record?.id;
116    if (typeof objectType !== "number" || typeof id !== "number") return null;
117
118    const username = record.creatorUsername ?? "";
119    const ids = bucketFor(objectType, username);
120    const isTrashed = record.deleted === true || record.safe === false;
121    const has = ids.has(id);
122
123    if (isTrashed === has) return null;
124    if (isTrashed) ids.add(id); else ids.delete(id);
125    return objectType;
126}
127
128// Debounce window for the live path. A single interactive edit still feels
129// instant at 50ms; the reason this exists is applyRestoredPatches() — replayed
130// on every boot for the user's full local "mine" patch history — which loops
131// `await applyPatchWithContext(op, ctx)` per op. Each `await` yields a
132// microtask turn, so a microtask-based flush would fire *between* ops and
133// never actually batch; a short timer does, collapsing a whole replay burst
134// (or a rapid string of interactive edits) into one persist()+notify().
135const LIVE_FLUSH_DELAY_MS = 50;
136let _pendingLive: Set<number> | null = null;
137let _liveFlushTimer: ReturnType<typeof setTimeout> | null = null;
138
139function flushLive(): void {
140    _liveFlushTimer = null;
141    const types = _pendingLive;
142    _pendingLive = null;
143    if (!types || types.size === 0) return;
144    persist();
145    for (const objectType of types) notify(objectType);
146}
147
148/** Live path — one record just mutated (any source): apply, then debounce persist+notify. */
149function syncRecord(record: any): void {
150    const objectType = applyMembership(record);
151    if (objectType === null) return;
152    if (!_pendingLive) _pendingLive = new Set();
153    _pendingLive.add(objectType);
154    if (_liveFlushTimer === null) {
155        _liveFlushTimer = setTimeout(flushLive, LIVE_FLUSH_DELAY_MS);
156    }
157}
158
159/**
160 * Full scan of every currently-hydrated record (dump()), across all object
161 * types — no per-type registration needed since `deleted`/`safe` are
162 * universal base properties (see dsafe.ts). Call after hydrateAll() (session.ts
163 * does this for both the initial boot and each loadLanguage() call).
164 *
165 * Batches persist()/notify() to once each (not once per record) — a bulk
166 * boot-time pass over a large corpus must not fire a synchronous localStorage
167 * write and a listener fan-out per changed record.
168 */
169export function scanTrash(): void {
170    const changedTypes = new Set<number>();
171    for (const cache of dump().values()) {
172        for (const record of cache.values()) {
173            const objectType = applyMembership(record);
174            if (objectType !== null) changedTypes.add(objectType);
175        }
176    }
177    if (changedTypes.size === 0) return;
178    persist();
179    for (const objectType of changedTypes) notify(objectType);
180}
181
182addReactivitySystem({
183    track() { /* trash doesn't need fine-grained read tracking, only mutation notifications */ },
184    trigger: syncRecord,
185});
186
187/** Test-only: clears in-memory + localStorage state and any pending debounce timer. */
188export function _resetForTest(): void {
189    _state.clear();
190    _listeners.clear();
191    _pendingLive = null;
192    if (_liveFlushTimer !== null) {
193        clearTimeout(_liveFlushTimer);
194        _liveFlushTimer = null;
195    }
196    if (typeof localStorage !== "undefined") localStorage.removeItem(STORAGE_KEY);
197}
198
199export const trashStore: TrashStore = {
200    list(objectType, usernames = []) {
201        const byUsername = _state.get(objectType);
202        if (!byUsername) return [];
203        const merged = new Set<number>();
204        const keys = usernames.length > 0 ? usernames : byUsername.keys();
205        for (const username of keys) {
206            for (const id of byUsername.get(username) ?? []) merged.add(id);
207        }
208        return Array.from(merged);
209    },
210    count(objectType, usernames = []) {
211        return trashStore.list(objectType, usernames).length;
212    },
213    usernames(objectType) {
214        return Array.from(_state.get(objectType)?.keys() ?? []);
215    },
216    subscribe(objectType, listener) {
217        let set = _listeners.get(objectType);
218        if (!set) { set = new Set(); _listeners.set(objectType, set); }
219        set.add(listener);
220        return () => set!.delete(listener);
221    },
222};
223