📄 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