From 73132d9dd3b29a3e6e66ec0b76f69dd7fd063b46 Mon Sep 17 00:00:00 2001 From: Nakul Tiruviluamala Date: Sun, 16 Aug 2026 16:19:08 -0600 Subject: [PATCH] Fix manual reordering: dedupe context cache paths and stabilize order comparator Manual reordering of space items breaks because the cached paths array that rank is derived from gets corrupted by two compounding bugs: 1. parseContextTableToCache appended paths twice: orderStringArrayByArray already returns every input path (unmatched ones included), and missingPaths were then appended again, so any child not matched in the db rows appeared twice in ContextState.paths. Raw row values were also compared without resolvePath, while mergeContextRows resolves them, so tables storing relative or stale paths never matched and every child was permanently duplicated. Now contextPaths are resolved with resolvePath and only paths present in contextPaths are ordered, with missing paths appended once. 2. orderStringArrayByArray used an inconsistent comparator: when both items were unranked it fell through to -1, reversing the unmatched run and producing different results on every sort. It now returns 0 for two unranked items, keeping their original relative order. Together these made rank (ranks.indexOf in getSpaceItems) live in a different index space than the actual db row array a drop inserts into (reorderRowsForPath), so drops landed in mirror-image positions and the order mutated again on the next reindex or on restart. Two smaller fixes on the same path: - mergeContextRows now dedupes rows by resolved path, so a table that already picked up duplicate rows heals on merge. - updateContextValue used `if (rank)`, silently ignoring rank 0, so a drop at the very top of a list was never persisted. Now `rank != null`. Fixes #443 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015NPvX1eLWMjqdFwEYbvzjM --- src/core/superstate/cacheParsers.ts | 8 +++++--- src/core/utils/contexts/context.ts | 2 +- src/core/utils/contexts/linkContextRow.ts | 6 +++++- src/shared/utils/array.ts | 9 ++++++--- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/core/superstate/cacheParsers.ts b/src/core/superstate/cacheParsers.ts index 089f40ce..ebd6637c 100644 --- a/src/core/superstate/cacheParsers.ts +++ b/src/core/superstate/cacheParsers.ts @@ -7,6 +7,7 @@ import { SpaceInfo } from "shared/types/spaceInfo"; import { orderStringArrayByArray, uniq } from "shared/utils/array"; import { builtinSpaces } from "core/types/space"; +import { resolvePath } from "core/superstate/utils/path"; import { linkContextRow, mergeContextRows, propertyDependencies, syncContextRow } from "core/utils/contexts/linkContextRow"; import { pathByJoins } from "core/utils/spaces/query"; import { ensureArray, initiateString, tagSpacePathFromTag } from "core/utils/strings"; @@ -47,10 +48,11 @@ export const parseContextTableToCache = (space: SpaceInfo, mdb: SpaceTables, pat cols = defaultContextFields.rows as SpaceProperty[]; } const schema = mdb[defaultContextSchemaID]?.schema ?? defaultContextDBSchema; - const contextPaths = mdb[defaultContextSchemaID]?.rows?.map(f => f[PathPropertyName]) ?? []; - + const contextPaths = mdb[defaultContextSchemaID]?.rows?.map(f => resolvePath(f[PathPropertyName], space.path, (p) => pathsIndex.get(p)?.type == 'space')) ?? []; + const missingPaths = paths.filter(f => !contextPaths.includes(f)); - const newPaths = [...orderStringArrayByArray(paths ?? [], contextPaths), ...missingPaths]; + const knownPaths = orderStringArrayByArray((paths ?? []).filter(f => contextPaths.includes(f)), contextPaths); + const newPaths = [...knownPaths, ...missingPaths]; const dependencies = propertyDependencies(cols); const spacePath = pathsIndex.get(space.path); let rows = mergeContextRows(paths, mdb[defaultContextSchemaID]?.rows ?? [], pathsIndex, spacesMap, spacePath) diff --git a/src/core/utils/contexts/context.ts b/src/core/utils/contexts/context.ts index 250bf262..02b544f8 100644 --- a/src/core/utils/contexts/context.ts +++ b/src/core/utils/contexts/context.ts @@ -241,7 +241,7 @@ export const updateContextValue = async ( { const updateFunction = _updateFunction ?? updateValue let newMDB = updateFunction(f, PathPropertyName, path, field, value); - if (rank) + if (rank != null) newMDB = reorderRowsForPath(newMDB, [path], rank); if (manager.superstate.settings.enhancedLogs) { // Update Context Value diff --git a/src/core/utils/contexts/linkContextRow.ts b/src/core/utils/contexts/linkContextRow.ts index 3f50e89b..44d7f74e 100644 --- a/src/core/utils/contexts/linkContextRow.ts +++ b/src/core/utils/contexts/linkContextRow.ts @@ -109,9 +109,13 @@ const resolvedPath = resolvePath(_row[PathPropertyName], path?.path, (spacePath) export const mergeContextRows = ( paths: string[], rows: DBRows, pathStates: Map, spaceMap: IndexMap, path: PathState) => { // Filter existing rows to only include valid paths, preserving database order (rank) + // Dedupe by resolved path so a corrupted table with duplicate rows heals on merge + const seenPaths = new Set(); const validRows = rows.filter(row => { const resolvedPath = resolvePath(row[PathPropertyName], path?.path, (spacePath) => pathStates.get(spacePath)?.type == 'space'); - return paths.includes(resolvedPath); + if (!paths.includes(resolvedPath) || seenPaths.has(resolvedPath)) return false; + seenPaths.add(resolvedPath); + return true; }); // Find paths that are in the paths array but not in any existing row diff --git a/src/shared/utils/array.ts b/src/shared/utils/array.ts index 1b34fb10..f128eea9 100644 --- a/src/shared/utils/array.ts +++ b/src/shared/utils/array.ts @@ -50,7 +50,10 @@ export const onlyUniquePropCaseInsensitive = return array.sort( function (a, b) { const A = order.indexOf(a), B = order.indexOf(b); - + if (A == -1 && B == -1) { + // neither is ranked: keep original relative order (stable sort) + return 0 + } if (A > B) { if (A != -1 && B == -1) { return -1 @@ -62,9 +65,9 @@ export const onlyUniquePropCaseInsensitive = } return -1; } - + }); - + };