File size: 18,872 Bytes
7c1820c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 | // ---------------------------------------------------------------------------
// 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}`;
}
|