// --------------------------------------------------------------------------- // customer-grid / folders.ts // Wave-8 I11c (contract C4) — the folder MODEL for the Views and Cohorts rails, // pure and React-free so verify_folders.py can run it under node. // // One level, deliberately. Nesting brings cycle-checking, move-into-your-own- // descendant, and recursive delete semantics with it; the owner asked for // folders you can drag things into, and a flat model is the whole of that. // // THE PART THAT IS EASY TO GET WRONG — the echo. Folder operations ride the // same once-by-id event log as everything else, so between the emit and the // host's echo there is a window where the payload still describes the world as // it was BEFORE the click. Render that naively and a just-deleted folder // reappears for one round trip (the "delete blip"), a rename flickers back to // the old name, and a dragged view jumps home. So this module reconciles the // host's copy against this browser's own recent stamps, exactly as // optimism.ts::reconcileFields does for fields — same ECHO_RECENT_MS window, // same rule that a STALE stamp yields to the host (divergence is not an echo). // --------------------------------------------------------------------------- import { ECHO_RECENT_MS } from "./viewEcho"; import type { GridFolder } from "./types"; /** What this browser did recently, by folder id / item id. Values are epoch ms * from THIS machine's clock — both sides of every comparison are local, so * this is not the tenant-day rule (that one is about two ENGINES agreeing). */ export interface FolderStamps { created?: Record; renamed?: Record; deleted?: Record; /** itemId -> {at, folderId} for a drag this browser just performed. */ moved?: Record; /** * WAVE 20 item 19 (C-FOLDER-REORDER) — the FULL folder order this browser just set. * * ONE stamp, not one per folder, because a reorder is one decision about a list: the * order the user dropped into is the order they want, and reconstructing it from N * per-folder stamps would let two of them age out at different moments and leave a * sequence nobody ever chose. The host answers with `order` NUMBERS on each folder * (that is the durable form); this is what to render until it does. */ ordered?: { at: number; order: string[] }; /** * ⭐ WAVE 27 · OWNER ITEM 5 (contract C7) — the FULL VIEW order this browser just set. * * A separate stamp from `ordered` above, deliberately, even though both are "a list this * browser dragged into shape": they age independently and they are different decisions. One * stamp holding both would make reordering a folder revive a view order the user had already * let go of, and vice versa — the exact "a sequence nobody ever chose" failure `ordered`'s own * note refuses one level down. */ orderedViews?: { at: number; order: string[] }; } export const FOLDER_STAMP_MAX = 64; const isRecent = (t: number | undefined, now: number): boolean => typeof t === "number" && now - t <= ECHO_RECENT_MS; /** Drop stamps past the echo window so the map cannot grow without bound and a * long-lived tab cannot keep asserting an edit nobody remembers. */ export function pruneFolderStamps(stamps: FolderStamps | undefined, now: number): FolderStamps { const out: FolderStamps = {}; const keepNum = (rec: Record | undefined) => { if (!rec) return undefined; const kept = Object.entries(rec).filter(([, t]) => isRecent(t, now)); return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined; }; const keepObj = (rec: Record | undefined) => { if (!rec) return undefined; const kept = Object.entries(rec).filter(([, v]) => isRecent(v.at, now)); return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined; }; const created = keepNum(stamps?.created); const renamed = keepObj(stamps?.renamed); const deleted = keepNum(stamps?.deleted); const moved = keepObj(stamps?.moved); if (created) out.created = created; if (renamed) out.renamed = renamed; if (deleted) out.deleted = deleted; if (moved) out.moved = moved; // Item 19: a single stamp, so it is kept or dropped whole — pruning it by halves is // exactly the partial sequence the field's own note refuses. if (isRecent(stamps?.ordered?.at, now) && stamps?.ordered) out.ordered = stamps.ordered; // Item 5 (C7): the same rule for the VIEW order, kept or dropped whole for the same reason. if (isRecent(stamps?.orderedViews?.at, now) && stamps?.orderedViews) out.orderedViews = stamps.orderedViews; return out; } /** * ⭐ WAVE 27 · OWNER ITEM 5 (contract C7) — the rail's view order, as this browser last set it. * * ⛔ WHY IT IS NEEDED AT ALL: the server assembles view order (`aios_grid.py:1602-1647`) and the * echo is one round trip behind the drop. Without this the row springs back to its old place the * instant the workspace refreshes, which reads as "the drag did not work" — the NO-BLIP law's * subject, applied to a sequence instead of to a value. * * ⚠ PAST THE ECHO WINDOW THE SERVER WINS, unconditionally. That asymmetry is the whole design of * this module: inside the window a local drag is newer truth; outside it, a difference between * the copies is divergence between SESSIONS, and the durable store decides. * * ⛔ IDS THE STAMP DOES NOT NAME KEEP THEIR SERVER ORDER, appended after the named ones — the * same rule the host applies to a `folder_reorder` payload. A view created in another tab since * the drop is not evidence that the drop was wrong; dropping it would be this function deleting * a view from the rail to defend a sequence. */ export function applyViewOrder( views: T[], stamps: FolderStamps | undefined, now: number ): T[] { const stamp = stamps?.orderedViews; if (!stamp || !isRecent(stamp.at, now) || !Array.isArray(stamp.order)) return views; const byId = new Map(views.map((v) => [v.id, v])); const out: T[] = []; const placed = new Set(); for (const id of stamp.order) { const v = byId.get(id); if (!v || placed.has(id)) continue; placed.add(id); out.push(v); } for (const v of views) if (!placed.has(v.id)) out.push(v); return out; } /** * The host's folder list, corrected by what this browser just did. * * deleted recently -> DROP it, even though the echo still lists it * (the tombstone rule; without this a deleted folder * blinks back for one round trip) * renamed recently -> keep OUR name until the echo carries it * created recently -> keep OURS if the echo has not caught up yet * * Everything stale yields to the host: past the window, a difference between * the copies is divergence between sessions, and host state is the durable * truth. That asymmetry is the whole design. */ export function reconcileFolders( hostFolders: GridFolder[] | undefined, localFolders: GridFolder[] | undefined, stamps: FolderStamps | undefined, now: number ): GridFolder[] { const host = hostFolders ?? []; const out: GridFolder[] = []; const seen = new Set(); for (const f of host) { if (isRecent(stamps?.deleted?.[f.id], now)) continue; // tombstone seen.add(f.id); const rename = stamps?.renamed?.[f.id]; out.push(isRecent(rename?.at, now) && rename ? { ...f, name: rename.name } : f); } // A folder this browser created that the echo has not yet returned. Skipped // when it was also deleted since — creating and deleting inside one window // must net to nothing, not to a ghost. for (const f of localFolders ?? []) { if (seen.has(f.id)) continue; if (!isRecent(stamps?.created?.[f.id], now)) continue; if (isRecent(stamps?.deleted?.[f.id], now)) continue; out.push(f); } out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name)); // ── WAVE 20 item 19 (C-FOLDER-REORDER): this browser's drag, until the echo carries it. // // Applied AFTER the host sort and as a SEPARATE pass, both deliberately: // · the host's `order` numbers are the durable truth and stay the base sequence, so a // folder the stamp never names keeps exactly the place the server gave it; // · `Array.prototype.sort` is stable (ES2019), so every unnamed folder — one created in // another tab between the drag and the echo, say — holds its relative position at the // end instead of being flung to the front by a missing rank. // A stamped id that has since been DELETED needs no handling: the tombstone pass above // already dropped it, and `rank` is only ever consulted for folders that survived. const ordered = stamps?.ordered; if (isRecent(ordered?.at, now) && ordered) { const rank = new Map(ordered.order.map((id, i) => [id, i])); out.sort( (a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER) ); } return out; } /** * Where an item actually belongs right now: this browser's recent drag wins * over the host's echo, and a folder that no longer exists resolves to ROOT. * * The second half matters as much as the first. `folder_delete` moves contents * to root host-side, but the client sees the folder vanish one render before * the items' `folderId` is rewritten — and an item pointing at a folder nobody * renders would simply not appear in any group. Resolving a dangling ref to * root is what stops a folder delete from making views look deleted too. */ export function resolveFolderId( itemId: string, hostFolderId: string | null | undefined, folders: GridFolder[], stamps: FolderStamps | undefined, now: number ): string | null { const moved = stamps?.moved?.[itemId]; const id = isRecent(moved?.at, now) && moved ? moved.folderId : (hostFolderId ?? null); if (id == null) return null; // ⚠ W32-T27 — the RESERVED root placement is not a dangling reference. It names no folder by // design, so the `folders.some(...)` test below would null it and hand the item straight back // to the Shared bucket, re-creating item 20 one layer down from where it was fixed. if (id === ROOT_FOLDER_ID) return ROOT_FOLDER_ID; return folders.some((f) => f.id === id) ? id : null; } export interface FolderGroup { folder: GridFolder | null; // null = the root group items: T[]; } /** * WAVE 20 item 18 / WAVE 21 item 9 (ruling R12, contract C1) — the SYNTHETIC folder * that every view shared WITH you appears under. * * ⛔ IT IS NOT A STORED FOLDER, and nothing may ever write one with this id. It has no * record in `folders`, no `order`, no icon, and the rail refuses every action on it * (`ViewSidebar` suppresses the row menu for exactly this id): it cannot be renamed into * something else, duplicated into a second copy of other people's work, or deleted. It is a * READING of the view list — "these arrived by grant" — rendered as a group because that is * the only shape this rail has for "a set of views with something in common". * * Declared HERE rather than in the component (where it lived through wave 20) so the * synthesis below is pure, and `verify_folders.py` can run it under node like every other * rule in this file. A constant a gate cannot reach is a contract nobody checks. */ export const SHARED_FOLDER_ID = "__shared__"; export const SHARED_FOLDER_NAME = "Shared with me"; /** * ⭐⭐ WAVE 32 · T27 (owner item 20) — **"FILED AT ROOT", AS A VALUE.** * * Owner: *a shared View cannot be moved out of the Shared folder.* The cause is that **root was * represented by ABSENCE at every layer**, and absence cannot distinguish two different facts: * * · `folderId == null` because the receiver never filed this view — it should show under * "Shared with me", which is where a grant LANDS; * · `folderId == null` because the receiver deliberately dragged it OUT of that group. * * `groupByFolder` had to guess, and it guessed "shared" — so filing a shared view at root put it * straight back where it came from. **The root bucket was unreachable for a shared view by * construction**, which is exactly why only folder→folder moves ever appeared to work. * * ⛔ THE SENTINEL IS STORED, NOT DERIVED, AND THAT IS THE WHOLE FIX. Both other layers wrote the * same absence and must both learn this value: `core/grid_events.py`'s `item_move` branch * (`if target is None: cur.pop(item_id, None) # back to the root`) and * `aios_grid.clean_item_folders`, whose own docstring states the defect one level deeper — * *"nothing stores 'this item is in no folder'"*. A client-only fix is impossible; there is * nothing to read back. * * ⚠ It is a RESERVED id in the same namespace as real folder ids, so `resolveFolderId` must pass * it through rather than treating it as dangling, and `clean_item_folders` must admit it beside * `fid in fids`. It is deliberately NOT rendered as a group: {@link groupByFolder} maps it onto * the ordinary root bucket, so nothing in the rail ever shows the word. */ export const ROOT_FOLDER_ID = "__root__"; /** * Group items into folders + a root bucket, in folder order, root LAST. * * Root last because the rails are read top-down and folders are the structure * the user made; ungrouped items are the leftovers. Every item appears exactly * once — a grouping that can drop an item would make a view look deleted. * * ⭐ WAVE 21 item 9 (R12/C1) — `isShared` adds the synthetic "Shared with me" group, * AFTER root, and three things about it are deliberate: * * · **After root, not before it.** C1 says LAST in as many words. It reads correctly * too: the rail is "my folders, my loose views, and then other people's". * · **A shared view the receiver has FILED still goes to their folder.** The `__shared__` * group is where a grant LANDS, not a cage it stays in — the rail's own note calls * moving out "per-receiver placement" and that must keep working. So the synthetic * group collects only the shared views that resolved to ROOT. * · **An empty group does not render.** A folder head with nothing under it says "here is * something you cannot reach" — the same reason `foldNav` drops empty nav folders. * * Omitting `isShared` leaves the function byte-identical to the pre-wave-21 one, which is * what every existing caller (the cohort rail, the tests) still gets. */ export function groupByFolder( items: T[], folders: GridFolder[], folderIdOf: (item: T) => string | null, isShared?: (item: T) => boolean ): FolderGroup[] { const buckets = new Map(folders.map((f) => [f.id, []])); const root: T[] = []; const shared: T[] = []; for (const item of items) { const id = folderIdOf(item); const bucket = id == null ? undefined : buckets.get(id); if (bucket) bucket.push(item); // ⭐⭐ W32-T27 (owner item 20) — THE ROOT BUCKET IS REACHABLE FOR A SHARED VIEW NOW. // `ROOT_FOLDER_ID` is the receiver saying "I filed this at the top level"; absence still // means "this arrived by grant and I have not filed it". Before this line the two were one // value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on // the next render — the owner's item 20, in one branch. else if (id === ROOT_FOLDER_ID) root.push(item); else if (isShared?.(item)) shared.push(item); else root.push(item); } const out: FolderGroup[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] })); out.push({ folder: null, items: root }); if (shared.length) out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared }); return out; } /** * WAVE 20 item 19 (C-FOLDER-REORDER) — where a dragged folder lands: the full order with * `draggedId` moved to sit immediately BEFORE `beforeId`, or last when that is null (the * drop on the ungrouped section below every folder). * * Here rather than inside the rail because it is the only part of the drag a test can hold: * the drop handler is DOM, the emit is the caller's, and this is the arithmetic that decides * what the user sees. `null` means "emit nothing" — an unknown id, or a drop that changes * nothing. Returning the unchanged array instead would be worse than useless: the caller * cannot tell it apart from a real reorder, so every no-op drag would write the store, bump * every reader's payload, and reconcile to the identical list. * * ⚠ The dragged id is REMOVED BEFORE the target index is read. Taking the index first and * splicing after is the classic off-by-one here: dragging a folder DOWNWARD would land it one * place short of where it was dropped, and only in that direction — the shape of bug that * survives a demo and gets reported as "it sometimes doesn't move". */ export function reorderFolderIds( ids: string[], draggedId: string, beforeId: string | null ): string[] | null { if (!ids.includes(draggedId)) return null; // ⛔ DROPPED ON ITSELF. Without this the id is filtered out, `indexOf` cannot find its own // target, and the "not found" branch sends the folder to the END — so releasing a drag over // the folder you picked up would quietly move it to the bottom of the rail. Found by this // function's own gate the minute the arithmetic left the component; the drop handler's // indicator suppresses the same case visually, which is exactly why it would never have // been noticed there. if (beforeId === draggedId) return null; const rest = ids.filter((id) => id !== draggedId); const found = beforeId ? rest.indexOf(beforeId) : -1; const at = found < 0 ? rest.length : found; const next = [...rest.slice(0, at), draggedId, ...rest.slice(at)]; if (next.length === ids.length && next.every((id, i) => id === ids[i])) return null; return next; } /** A fresh folder id. Client-generated, like every other id in this component. */ export function newFolderId(): string { const rand = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().slice(0, 8) : Math.random().toString(36).slice(2, 10); return `fld_${rand}`; }