Skip to content

Introduce dense order keys for manual ordering - #12285

Draft
rymsha wants to merge 74 commits into
masterfrom
claude/order-key-integration
Draft

Introduce dense order keys for manual ordering#12285
rymsha wants to merge 74 commits into
masterfrom
claude/order-key-integration

Conversation

@rymsha

@rymsha rymsha commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

A new manual-ordering primitive wired through the node layer, export/import, layer synchronization, an anchored reorder API, and the JS library surface. Eleven commits, stacked on #12281 (base branch). Design discussed with @sry-enonic.

Why

_manualOrderValue resolves each value against the sibling set and splits fixed gaps:

  • assigning a value queries neighbors (refresh + search on the create path);
  • between() halves a 2^31 gap — 30 targeted inserts into one slot, then it silently mints an exact duplicate of the lower neighbor;
  • the allocator is deterministic, so two branches performing the same insertion mint the identical value by construction and collide when sibling sets merge (observed in production);
  • switching a parent to manual order rewrites every child to seed values.

The design in one paragraph

An order key is position '.' node-id, stored plain and sorted lexicographically: a base-36 fraction (lowercase, because the index sorts case-folded) followed by the id of the node holding it, so two live keys are unequal by construction whatever branch, layer or server minted them. A birth key encodes the inverted creation instant — creating a node and moving one to the top are the same zero-read operation. Between two distinct keys there is always a third, so nothing is ever renumbered. The write rule carries all the enforcement: the server mints every stored position itself — jittered, discriminated — whatever anchors it is handed. Reading is therefore open: the key is an opaque string on Node and Content, to compare and hand back as a reorder anchor, never to parse. (An earlier revision wrapped reads in HMAC tokens; that layer is gone — it protected nothing the write rule doesn't, and the raw order was observable through sorting anyway.)

What each commit adds

  1. Codec (core-internal): birth/between/before/after over dense keys; a directional run consumes space linearly (10 000 inserts, length unchanged — test); hammering one gap survives 1000+ levels then refuses cleanly at a length cap (test).
  2. Birth keys: every created node gets one; it rides the version blob, survives dump/load, is indexed as _orderKey. ChildOrder.orderKeyOrder() = _orderKey ASC, _ts DESC, _id ASC — keys decide; keyless nodes (pre-feature versions, delivered verbatim by pushes) tail in default order, which keeps a flip metadata-only and visually seamless on legacy folders; the id keeps the tail total so paginated listings never skip or duplicate. Verified against embedded ES (missing: _last).
  3. Deprecations: everything carrying or steering a manual order value; isManualOrder() stays as the legacy-parent detector, reordering stays.
  4. Lowercase alphabet: the round-trip test caught the index sorting strings by a case-folded copy — under a case-sensitive alphabet the index and codec disagreed on same-second keys. Base-36 makes folding the identity.
  5. Export/import transports order, not keys: manualChildOrder.txt lists key-ordered children in display order; import mints a fresh chain along it, keyless children included — an import drains the keyless tail for free.
  6. Layer sync carries placement: inherited content holds the key it holds in its source project — creation via import (the content it imports carries the key), updates via an internal setter gated on SORT inheritance, keyless sources dictate nothing.
  7. Open reads, anchored reorders: Content.getOrderKey() (in Content.equals, deliberately not in the update no-op gate — placement must never masquerade as a content change); ReorderChildNodeParams/ReorderChildContentParams gain afterOrderKey/beforeOrderKey; SortNodeCommand places a child after/before/between the anchor keys the caller read off the siblings, or first with no anchors — zero sibling reads, codec-validated anchors, misordered or garbage anchors refused. EditableNode.orderKey is the sanctioned exact-placement channel (node patch), codec-validated; the pinning test caught Node.equals ignoring the key, which made key-only patches silent no-ops.
  8. JS surface: contentLib.sort({key, childOrder, reorder})childOrder: 'manual' maps to orderKeyOrder(), any other string parses as an order expression, reorder entries carry afterOrderKey/beforeOrderKey anchors read off the sibling contents, and reorder without childOrder flips the parent to manual in the same call. Serialized contents expose the read-only _orderKey. lib-node exposes _orderKey on read; node create deliberately does not accept it — exact placement stays out of standard create means.
  9. Patch is the only write channel: node update never writes the key — NodeServiceImpl resets any order-key edit an update editor makes back to the stored value, so update edits content, never placement; only node patch carries the exact-placement capability. Layer sync's internal setter moves to nodeService.patch (the same command update delegates to — now also the honest name). lib-node mirrors the split: _orderKey is applied only on the patch path and typed only on patch/read shapes.
  10. lib-node sort parity: repo.sort gains the same surface as contentLib.sort'manual', anchored reorder entries (nodeId + afterOrderKey/beforeOrderKey), reorder-implies-manual — closing the gap where node-API scripts had no standard way to place a child between two siblings. The existing {key, childOrder} form works unchanged.
  11. Root node proof: an ES-backed test pins that the repository root key-orders like any parent — the flip to orderKeyOrder() is a metadata write on the root version, a node created under root lands first by its birth key, and anchored reorders place root children by sibling keys. The legacy resolver's root skip (resolvePotentialManualOrderValue) stays as-is on purpose: the key regime never reads it, and the legacy-root ordering fix was dropped from Speed up root access on the node read and create paths #12283, which is about root access performance.

Found by the test suite along the way

  • NodeId can be 256 chars — the discriminator cap was too small.
  • The index case-folds sort values — constrains any future sortable key-like field.
  • Node.equals/hashCode must include the key, or key-only patches no-op silently.

Verified (final tree)

itest-core · itest-core-content · core-api · core-repo · core-content · core-internal · core-export all green; lib-content + lib-node green incl. TS compile and eslint.

Not in this PR

The generic HMAC key service extraction (dropped with the token layer — the redirect checksums keep their key reading as before); migration of legacy manually ordered parents (root included — see commit 11); collapsing orderKeyOrder() to its first leg once keyless versions are formally impossible (the stored-expression-per-parent design makes that a one-line change whenever wanted).

🤖 Generated with Claude Code

https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B

claude added 30 commits August 18, 2026 16:55
NodeService.list answered with the whole listing at once, however large:
the scroll behind it accumulates every entry before anything is
returned, so listing a tree of millions of nodes holds millions of
entries in memory at both levels.

A caller may now set a batch size and repeat the call with the cursor
each batch answers with. The cursor names the position after the last
entry scanned, compared the way the index compares paths, so a
continuation is one sized query rather than a scroll, entries hidden by
permissions or depth still advance it, and ground already passed is
never revisited - deletions between batches included, which is what a
clean-up loop needs.

The cursor is taken from the scan rather than from the entries kept, so
a batch may be empty while the listing is not finished; the contract is
therefore to continue until a batch answers with no cursor, and the
javadoc says so.
CleanUpAuditLogCommand resolved every batch through the search index and
had to refresh it after each one, since the next search would otherwise
answer with the nodes just deleted. Enumerate the log in batches instead:
the cursor only moves forward over ground the deletions leave behind, so
one storage refresh up front is the only refresh the whole clean-up
needs, and the search index is not consulted at all.

A record is now aged by its node timestamp - the moment it was written -
where the search filtered on the time field of the record, which a
caller may set freely. The two coincide for every record the system
writes, and the timestamp is the only moment storage holds.
The javadoc promised a positive number while the builder accepts zero as
the default that turns batching off, so say so. Also take the last
scanned entry through Iterables.getLast instead of a reduction over the
whole batch.
The two build-time refusals and the defaults of ListNodesParams were
asserted nowhere.
Both walkers held the whole listing at once and used nothing of it but
the ids: an entry weighs a whole path, and in a deep tree every
descendant repeats its ancestry, so the paths dominated the memory of
walks that never read them. Consume the listing in batches and keep bare
ids - the set a dump needs anyway to dump each node once across
branches, and the list an export needs for the exact total its progress
listener is owed, which a count from the index could not give since it
ignores what the caller is permitted to read.

The batches replace a scroll, which pinned a point-in-time view of the
listing. A node moved across the cursor while a walk runs may now be
listed twice, which the ids absorb, or escape the listing - the trade
the batches make for never holding an entry per node.
A path names a position that a move takes away, so a path-ordered scan
let a node moved across the cursor be listed twice or escape the listing
altogether. An id is the one thing about a node that never changes:
scanned by id, a node moved within the listing keeps its place and is
observed exactly once, and the cursor is compared without normalization,
since ids are held by the index exactly as the node exposes them.

Order was the only thing path gave a batched scan, and none of the
batched walkers reads the listing in order - the vacuum filters by age,
the dump feeds a set, the export reads by id. An unbatched listing keeps
its path order, which the application and job listings show to people.

The batched permission itest gave the hidden node an id chosen by
chance, which chose its scan position too; it is now set explicitly.
The batching loops narrate themselves; what stays is what a reader
cannot recover from the code - why one refresh suffices, why the count
comes from the entries, why the cursor is the last scanned id, and what
bounds the batch size.
Both walkers enumerated full branch entries and read two fields of them:
the id names the node to rewrite or copy, the path names its parent in
the map that keeps the walk parent-first. The version id, the three blob
keys and the timestamp of every descendant were fetched, parsed and held
for nothing.

The branch query now takes the fields a caller wants of a hit, and the
command answers a walker with bare id-and-path pairs. A permission
requirement is refused on that projection, since deciding one costs
exactly the access control key it exists to not fetch - and neither
walker filters: move runs its walk with full rights because authority
over the root is authority over the subtree, and duplicate decides
readability on the nodes it loads anyway.
…listeners #12065

A single upfront total cannot say the amount of work is unknown, and obliges
the operation to resolve all of it before starting. resolved(int) carries the
total resolved so far - called any number of times, each call replacing the
previous value, -1 or silence meaning unknown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Every tree walker already holds its work list before touching anything, so
each now tells its listener how much was resolved - move and delete exactly,
duplicate as a running total that shrinks as the permission cascade skips
entries. Content commands forward the totals to their own listeners, which
recover the reach of the 7.16 setTotal in the running-total shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The command resolved the active versions of the whole subtree - one ACL read
and two storage lookups per node and branch - before reporting anything or
applying anything, and held every resolved version in memory at once. It now
walks the subtree in strides over the batched branch listing: each stride is
resolved, reported through the growing resolved total, and applied before the
next is fetched. First progress moves after one stride instead of after the
whole tree, and memory holds one stride instead of all of it.

Applying node-by-node instead of branch-by-branch preserves the version
origin: the caller-supplied branch order still decides, per node, which
branch stores the new version and which ones it is pushed to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Move and delete assert the exact total their walks report; duplicate asserts
the shrinking sequence around an unreadable child. The content-level tests
drive the listener delegates with plain listeners that override nothing but
the processed counts, pinning that the total callbacks are optional.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A per-stride total made progress a sawtooth: the bar neared its end and the
next stride grew the denominator by another stride. The batched listing
already knows how many raw entries the scan has in front of it, so each
stride now projects what remains at the weight per node of what it has
resolved, and the reported total settles near its final value at once,
corrected exactly by the last stride. The listener javadoc now says a
projected total is a legitimate resolved report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…2065

A search that requested fields still shipped every hit's whole source and
picked the fields out on this side of the wire. The requested names now ride
the request as a source filter, so a walker's listing ships ids and paths
instead of whole branch entries, and a path-only search stops shipping whole
documents. Nothing reads a hit's document id, so the branch index id staying
nodeId_branch is not disturbed - the node id remains a filtered source field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The stored source is one blob, so the filter saved neither the read nor the
one full parse - it only moved the parse to the other side of a fetch that is
in-process anyway, and the branch documents it was aimed at are small. The
real gain would be a query that asks for node ids alone and derives them from
the branch document id without touching the source - none exists today.

This reverts commit e67f1c6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The lean branch-query projection served nothing the walkers could not do
themselves: the query layer answers whole branch entries again, and move and
duplicate repack them into their own two-field pairs, letting the entries go
as soon as they are mapped - the same retained memory for a walk, none of the
query-layer machinery. Duplicate keeps deciding what is permitted exactly as
before: the caller-context reads omit unreadable nodes, and an omitted parent
takes its whole subtree out of the duplication.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Duplicate repacked entries into id-and-path pairs only to join them straight
back to the full nodes it reads for copying - and those nodes, like every
copy accumulating in the result, dominate the memory of the walk regardless.
The storage get answers in the requested order, so the path order of the
listing survives into the fetched nodes and the walk iterates them directly:
no repacking, no id map. Unreadable nodes are absent from the get's answer,
which shrinks the resolved total at once; a readable node below an unreadable
parent is still skipped one by one by the cascade, pinned in the test by a
readable grandchild under the hidden child.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
… in stride #12065

The bulk node get re-fetched from the index the very branch entries the
listing had just answered, read every subtree version upfront to decide read
access the access blobs - almost always cached - already carry, and held
every full node until the walk ended. The walk now settles the duplicable set
from the entries and access blobs alone, where a prohibited node takes its
whole subtree with it as the contiguous run of path-ordered entries right
behind it, reports the exact resolved total once, and completes each node
from its version blobs only when its turn comes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…12065

Every step of the walk re-fetched from the index the branch entry the
listing had just answered, and fetched the version metadata even when no
attributes resolver was there to read it. The walk now carries the entries
themselves - they hold the version keys - completes each node from its
version blobs when its turn comes, and touches the version metadata only for
a resolver that asks for the attributes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Resolving a stride ran a command per node that looked up the branch entry
and the version metadata per branch, one by one; denying ran a full node
read per node to see permissions the cached access blob already carries; and
applying read the full node by id again, re-fetching the branch entry a third
time. A stride now resolves with one bulk get per branch, denies from the
access blob - the walk only ever reaches nodes the caller may read, settled
upstream - completes each node from its version blobs when rewriting it, and
touches the version metadata only for a resolver that asks for attributes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A multi-get fans out to one get per document on its shard anyway, so bulking
the stride bought nothing and inherited a request-size ceiling. Better: the
stride's entries already answer the context branch - the listing returned
them - so a single-branch apply resolves without a single lookup, and only
the other branches cost one entry get per node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…children #12065

Every manually ordered parent cost the export a second listing of its
children - answered by scanning its whole subtree, since the branch index
narrows by prefix only - and a full re-read of children the walk reads
anyway. The walk now notes each node's name, order value and timestamp as it
passes, sheds what belongs to parents known to keep no manual order, and
writes every order file once the walk has read everyone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The enumerating listing could only answer the handful of providers by
scanning their whole subtree - every user and group in the system. The query
narrows on the parent, like every other principal lookup here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The sync flows split their walks by level only to bound memory, which a
single severely huge level defeats. The layers service now answers ids batch
by batch with the listing's own cursor, every walk stays bounded whatever the
shape of the tree, and the emptiness check over a subtree stops at the first
batch that fails it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
It reads as an optimization and is the opposite: the storage index holds no
parent field, so the direct children are found by walking the same subtree
and dropping the deeper levels afterward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Unreleased, and not supposed to be used: it read as the lighter choice while
costing the same subtree scan plus a filter, and its silent narrowing hid
whatever a subtree unexpectedly held. A listing now lists the subtree. The
one caller that genuinely wants levels - the layer sync descent - keeps its
direct children by filtering the batches where the need lives, and the flat
system listings lose nothing but the trap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…dren #12065

The descent used to refetch children level by level, refetching ground
the previous level had already enumerated. Now one grouped fetch per top
node takes the whole subtree and the recursion slices it: a parent is
still synced before its children, but no level costs another scan.

Sorted-event sync reads the reordered level through search - the same
index the sort itself relies on - instead of enumerating the subtree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The cleanup walked the target tree level by level, and every level was
bought by rescanning the whole subtree below it. The target subtree is
known upfront, so the walk now takes the same grouped fetch the sync
descent takes and slices it - the snapshot survives its own walk, since
a content that still has children is never deleted by it. That removes
the last user of the depth-filtered children listing, which goes away
with its filtering plumbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
claude added 8 commits August 21, 2026 04:58
The javadoc of list, enumerate and their types explained how the answer
is produced - storage rather than search index, scrolls, sized requests,
node-id ordering, what the index counts while cutting a batch. A caller
cannot rely on any of that, and it goes stale the moment the mechanism
changes.

What is left is what a caller can hold the API to: what the answer
holds, what it costs, when a node becomes visible, how far the
at-most-once guarantee reaches, what a bound and a cursor promise, which
exceptions come out. The numbers are named where they used to be
described - a batch is at most 10 000 rather than "as much as the index
will answer in one request", and the remaining count says what it counts
instead of calling itself exact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Implementation classes, tests and the benchmark carried running commentary
on what the code next to it was doing and why. It says nothing the code
does not, and it is one more thing to keep true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The remaining count came straight out of the index as a long, which is
more range than a node count can use and more than a script can hold:
JavaScript has no long, and every listener, batch size and count already
in the API is an int. It is now an int, saturated where the index's
number crosses into the API, and the two callers that had to clamp it
themselves add plainly instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Every other operation listener is a running total plus one domain-named
method carrying how many items are done: resolved(int) and nodesMoved,
nodesDeleted, nodesPushed, permissionsApplied. The audit clean-up had
resolved(int) plus start(int batchSize), a countless processed() called
once per record, and finished().

processed() is deprecated for recordsDeleted(int), which the default
forwards to once per record, so a listener implementing only the old name
hears exactly what it heard before. start(int) is deprecated for two
reasons: resolved already announces the work before the first delete, and
the batch size it publishes is the ceiling this API deliberately keeps
private. finished() is deprecated because the clean-up is over when it
returns, which the caller that started it already knows - the task
handler logs the two lines around its own call now, and its listener
reports progress on a report interval of its own rather than on whatever
batch size it was handed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The task handler reports progress once every thousand records, so a
clean-up that ended mid-interval left the task showing the count of the
last full thousand - a scheduled clean-up of nine hundred records showed
none of them at all. The handler now reports once more when cleanUp
returns, which is also where it logs that the clean-up is over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Deleting the listing's non-recursive mode quietly widened three system
listings from the children of a folder to its whole subtree.

VirtualAppService.list was wrong from that moment: a virtual application
keeps its resources in a subtree of its own node, so every folder and
every yaml file below an application came back as an application, each
mapped to whatever key its path happened to yield. The application repo
and the scheduled job listings hold flat folders today and so answered
the same as before, but they asked the wrong question and would break the
day anything gains a child.

All three now keep the entries whose parent is the folder they listed -
which is what the deleted mode did, since the branch index carries no
parent field and answered the mode by filtering the same scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The export enumerated in batches and then accumulated every entry into
one list before writing anything, so it held an entry per node in the
subtree - the whole point of enumerating in batches, given up one line
later.

It now writes each batch as it arrives: the first batch states the total,
the root goes out, and every batch is exported and forgotten. Only the
child order of manually ordered parents outlives a batch, which is what
the order files are written from at the end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A manually ordered parent has its children's order synced through a
search of the target level. Where the parent's order was already equal to
the source's, nothing was sorted first, so nothing had refreshed the
index either - and children written earlier in the same synchronization
were invisible to that search, leaving their order unsynced with no sign
of it.

The sync now refreshes before reading the level, so everything written up
to that point counts. Concurrent modifications remain out of reach, as
they are for every search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
@codacy-production

codacy-production Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codacy's Analysis Summary

3 new issues (≤ 0 issue)
0 new security issue
186 complexity
More details

AI Reviewer: run a review on demand. To trigger the first review automatically, go to your organization or repository integration settings. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes. Give us feedback

@rymsha rymsha changed the title Give every node an order key at creation Introduce dense order keys for manual ordering Aug 23, 2026
@rymsha
rymsha changed the base branch from claude/order-key-codec to master August 23, 2026 14:49
@JsonProperty("data") @JsonDeserialize(contentUsing = ImmutablePropertyArrayDeserializer.class) final List<ImmutableProperty> data,
@JsonProperty("childOrder") @JsonDeserialize(using = ChildOrderDeserializer.class) final ChildOrder childOrder,
@JsonProperty("manualOrderValue") final Long manualOrderValue,
@JsonProperty("orderKey") final String orderKey,
claude added 3 commits August 23, 2026 15:04
A manual order value today is a long resolved against the sibling set:
assigning one queries the neighbors, inserting between two of them
halves a fixed gap that runs out after thirty targeted inserts and then
silently duplicates the lower neighbor, and two branches performing the
same insertion mint the identical value by construction, colliding when
their sibling sets merge.

An order key is a base-62 fraction followed by the id of the node that
holds it. Between two distinct keys there is always a third, so nothing
is ever renumbered; the id suffix makes concurrently minted keys unequal
by construction, whatever branch, layer or server minted them; and a
birth key encodes the inverted creation instant, so creating a node and
moving one first are the same read-free operation, and the relative
operations need only the anchor keys the caller already holds. A run of
inserts in one direction consumes space linearly rather than by halving,
and a position never grows past a cap that turns hostile input into a
clean refusal instead of index growth.

Keys are stored plain and leave the server as tokens: a version
character, the key, and a truncated HMAC-SHA512 tag - the construction
the redirect checksums already use - under a subkey derived once from a
master key and bound to the scope the token was issued for. A client can
replay positions it was handed but cannot mint one, which keeps exact
placement out of reach of standard means, and verification tolerates
fumbles loudly: wrong scope, wrong version, truncation and tampering are
each rejected. A golden token pins the exact wire format against an
independently computed reference value.

Nothing is wired to storage yet: this is the primitive and its
properties, testable in isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
A node is born with an order key minted from its creation instant and
its own id, carried in the version data, indexed beside the manual
order value, and preserved through storage, dump and import. Nothing
reads it yet unless asked to: the new child order sorts by the key
ascending, which shows newest first - the order the default child order
already shows - with nodes that predate keys after every keyed node,
ordered among themselves by the default order, which is the order a key
would have frozen for them.

Switching a parent to the new order is a metadata write: the children
already hold the keys that make the order, so none of them is read or
rewritten, where the legacy manual order reads the sibling set and
versions every child to seed its values. The legacy order is untouched
and keeps working from its own field.

An imported node keeps the key of the dump through an internal channel
of the create command; the parameters of a create cannot carry a key,
so a key still cannot be chosen by standard means. The discriminator
cap of the codec rises to the longest node id, which a dump of a node
with a hundred-character name found to be longer than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
Everything that carries or steers a manual order value is deprecated in
favor of order keys: the value on node and content and their builders
and editable forms, the placement controls of node creation and import,
the index path, the child order built on the value, and the seeding of
a sort - which order keys make unnecessary, since the children already
carry the keys that make the order.

The detection predicate stays undeprecated: telling a legacy manually
ordered parent apart is how the remaining code paths and the eventual
migration find their work. Reordering likewise stays: naming a child to
move is not tied to how its position is stored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
@rymsha
rymsha force-pushed the claude/order-key-integration branch from 7fe1483 to af31009 Compare August 23, 2026 15:05
@rymsha
rymsha changed the base branch from master to claude/xp-issue-12065-alternative-pur3l4 August 23, 2026 15:05
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.87342% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.10%. Comparing base (b5d0402) to head (60d91b4).

Files with missing lines Patch % Lines
...nonic/xp/core/internal/orderkey/OrderKeyCodec.java 86.77% 8 Missing and 8 partials ⚠️
...a/com/enonic/xp/core/impl/export/NodeImporter.java 82.35% 4 Missing and 2 partials ⚠️
.../com/enonic/xp/repo/impl/node/SortNodeCommand.java 84.37% 3 Missing and 2 partials ⚠️
.../src/main/java/com/enonic/xp/index/ChildOrder.java 55.55% 2 Missing and 2 partials ⚠️
...i/src/main/java/com/enonic/xp/content/Content.java 71.42% 0 Missing and 2 partials ⚠️
.../com/enonic/xp/lib/content/SortContentHandler.java 94.11% 1 Missing and 1 partial ⚠️
.../main/java/com/enonic/xp/lib/node/SortHandler.java 92.59% 1 Missing and 1 partial ⚠️
.../xp/core/impl/content/UpdatedEventSyncCommand.java 85.71% 0 Missing and 1 partial ⚠️
...a/com/enonic/xp/core/impl/export/NodeExporter.java 92.85% 0 Missing and 1 partial ⚠️
...xp/lib/content/mapper/SortContentResultMapper.java 90.90% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                              Coverage Diff                               @@
##             claude/xp-issue-12065-alternative-pur3l4   #12285      +/-   ##
==============================================================================
+ Coverage                                       87.09%   87.10%   +0.01%     
- Complexity                                      20840    20941     +101     
==============================================================================
  Files                                            2610     2613       +3     
  Lines                                           69337    69709     +372     
  Branches                                         5749     5804      +55     
==============================================================================
+ Hits                                            60391    60723     +332     
- Misses                                           6273     6292      +19     
- Partials                                         2673     2694      +21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

claude added 7 commits August 23, 2026 16:23
The search index sorts strings by a case-folded copy of the value, so
under a case-sensitive alphabet the index and the codec disagreed on
the order of keys that differed only by letter case - a key minted
between two others could display outside them. Under the 36 lowercase
digits, folding is the identity and the two orders are one; node ids,
the discriminators, are lowercase by their own definition already.

The instant takes seven digits in the smaller radix and reaches the
year 4453; the jitter takes four and is wider than before. The longest
key stays well inside the length the index folds for sorting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
The export already treats a manually ordered parent the right way: it
writes the names of the children in display order and leaves the values
behind, and the import mints fresh ones. Parents ordered by keys now do
the same. The exporter lists their children in key order in the same
order file, and the importer, seeing a parent ordered by keys, mints a
fresh chain along that list: the first child starts it and every next
one is placed after the one before, the id of each node the
discriminator of its own key. A child that had no key at export time
gets one like the rest, in the position the order file gave it - an
import drains the keyless tail for free.

The keys of the source never travel: a key belongs to the node that
holds it where it lives, and two imports of one export into two places
must not mint the same keys.

The round-trip test caught the index case-folding mismatch fixed in the
previous commit: with case-sensitive keys, the display order and the
export order disagreed whenever same-second jitter differed by case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
An inherited content now holds the key it holds in the project it comes
from. Creation sync reads the raw key of the source node and hands it
through an internal import form to the node the import builds; update
sync copies a changed key when the target still inherits its sort, and
a keyless source - stored before order keys - dictates nothing; the
children resync after a sort change covers parents ordered by keys the
way it covers parents ordered by values. Raw keys move only inside the
synchronizer, which reads and writes them through the internal layers
service; the public content surface never carries one. Layers share
content ids, so a key stays true to its discriminator wherever it is
inherited.

Node patching is the sanctioned way to write an exact placement, and
now actually is one: the editable node exposes the key, and a changed
key is validated on its way in. The test that pinned this caught that
node equality ignored the key, so a patch changing nothing else was
dropped as a no-op by the unchanged-version shortcut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
Hiding the raw key behind a token protected nothing: the rule that no
caller chooses an exact placement lives entirely on the write side,
where the server mints every stored position itself - jittered, and
suffixed with the id of the node holding it - whatever anchors it was
handed. Reading, meanwhile, was already open one tier down and the
relative order of every key observable through sorting. So the key is
now readable where the content is: an opaque string on Content, to
compare and hand back, never to parse. The token layer is gone.

Reordering takes the anchors this opens up: a child is placed after one
sibling key, before another, between the two, or - with no anchors - at
the top, which a fresh birth key is. Nothing reads the sibling set;
garbage and misordered anchors are refused by the codec's own
validation.

Content carrying the key simplifies what the last commit built: the
synchronizer compares placements on the contents it already holds, and
a content import places at the key of the content given to it - the
import tier keeps exact placements, as it always has at the node level.
Writing stays closed: the content patch surface does not carry the key,
and the equality gate of content updates is deliberately blind to it,
so a placement change never masquerades as a content change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
lib-content gains contentLib.sort({key, childOrder, reorder}): 'manual'
maps to ChildOrder.orderKeyOrder(), any other string parses as an order
expression, and reorder entries carry afterOrderKey/beforeOrderKey
anchors read off the sibling contents. reorder without childOrder flips
the parent to manual ordering in the same call. Serialized contents now
expose the read-only _orderKey.

lib-node exposes _orderKey on read and accepts it through update/patch
editors - the sanctioned node-level escape hatch, validated server-side
by PatchNodeCommand. Create deliberately does not accept it: exact
placement stays out of standard create means.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
The update/patch tier split was blurred by update delegating to
PatchNodeCommand with the caller's editor verbatim: an update editor
could set EditableNode.orderKey and it stuck. Now convertUpdateParams
wraps the editor and resets the key to the source value, so update
edits content, never placement. Layer sync's internal setOrderKey moves
to nodeService.patch - same command underneath, now also the honest
name for what it does.

lib-node mirrors the split: the _orderKey property is applied only on
the patch path (UpdateNodeExecutor gains an orderKeyEditable switch),
and the TS types advertise _orderKey on patch and read shapes only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
repo.sort gains the surface contentLib.sort already has: childOrder
'manual' maps to ChildOrder.orderKeyOrder(), reorder entries carry
nodeId plus afterOrderKey/beforeOrderKey anchors read off the sibling
nodes, and reorder without childOrder flips the parent to manual in the
same call. The existing {key, childOrder} form works unchanged. This
closes the gap where node-API scripts had no standard way to place a
child between two siblings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
The repository root takes the same path as every other parent under
order keys: the flip to orderKeyOrder is a metadata write on the root
version, children created under root carry the birth key of their
creation instant and land first, and anchored reorders place them by
sibling keys. The legacy manualOrderValue resolver still skips root -
deliberately left as-is, since the key regime never reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANkY5TN9xn2cFKHk1sfX4B
Base automatically changed from claude/xp-issue-12065-alternative-pur3l4 to master August 24, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants