| // --------------------------------------------------------------------------- | |
| // 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<string, number>; | |
| renamed?: Record<string, { at: number; name: string }>; | |
| deleted?: Record<string, number>; | |
| /** itemId -> {at, folderId} for a drag this browser just performed. */ | |
| moved?: Record<string, { at: number; folderId: string | null }>; | |
| /** | |
| * 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<string, number> | 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 = <T extends { at: number }>(rec: Record<string, T> | 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<T extends { id: string }>( | |
| 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<string>(); | |
| 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<string>(); | |
| 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<T> { | |
| 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<T>( | |
| items: T[], | |
| folders: GridFolder[], | |
| folderIdOf: (item: T) => string | null, | |
| isShared?: (item: T) => boolean | |
| ): FolderGroup<T>[] { | |
| const buckets = new Map<string, T[]>(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<T>[] = 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}`; | |
| } | |