loopable / web /src /customer-grid /folders.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
18.9 kB
// ---------------------------------------------------------------------------
// 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}`;
}