concatRange — join a stretch of values without shipping themRead the values of a key range in key order, join them, and write the result to one key — as one atomic operation, with the reading and the writing on the same side of the wire. The use it comes from is concrete: a ten-megabyte file stored as thousands of two-kilobyte rows, on a server with little memory, where reassembling in the application means moving all of it twice.
Like SAPE-32, this is an engine primitive exposed as a declared operation. Nobody writes an expression: the caller supplies a range and a destination, and the shape is fixed when the operation is declared. A rollup is the precedent — the engine computes, the driver declares.
The engine question is whether the cost can be bounded and whether it is safe for the server, and
this is where concatRange differs sharply from deleteRange.
Its output is unbounded until something bounds it. A range of N rows produces one
value of roughly the sum of their sizes, and the engine has to hold it to write it. There is no
maximum value size today — that is SAPE-31 — and a frame
length is a uint32. So on the exact machine this feature is for, a server with little
memory, an unbounded concatRange is the hazard rather than the fix.
It therefore needs two ceilings, both declared: a limit on how many rows it reads, and a maximum on the bytes it may produce. Refusing when either is exceeded is the honest answer; producing a truncated value would be the dishonest one. Without them the cost is not knowable before the call, and that is what would breach the promise — not the primitive existing.
hashRange should come firstA sibling primitive answers much of the same need and is strictly safer: read the same range in the same order and return a digest of it. Its output is thirty-two bytes whatever the input, so it needs no size ceiling and is not blocked by SAPE-31 at all.
It also answers a question asked separately — whether the store has SHA-256 — at the
layer where the answer can be yes. The driver still never lets anybody write sha256(x);
it lets them declare an operation that hashes a named range, exactly as it lets them declare a
rollup.
For an application that splits a file into rows and wants to know the whole thing arrived intact,
hashRange is the entire answer and moves nothing. This ticket recommends it as the
predecessor rather than claiming it as scope: it is a separate primitive and deserves its own ticket
if the owner agrees.
An operator command beside scan, taking the same bounds. For an operator the useful
form may be the one that prints the digest or the size rather than writing anything, since an
operator at a prompt is usually checking rather than assembling.
As an operation with parameters and no expression — but note the parameter count is larger than the proposal's three, because a document is not a value:
{ "action": "concatRange", "collection": "chunk",
"input": [ {"name":"from","type":"string","required":true},
{"name":"to","type":"string","required":true},
{"name":"target","type":"string","required":true} ],
"field": "b", // which field of each row is joined
"into": {"collection":"file","field":"bytes"},
"limit": 8192, // rows
"maxBytes": 16777216 }
Which field is read and which field is written are part of the declaration, not the call. That keeps the shape fixed and the caller supplying bounds rather than behaviour.
The separator is nothing, and that is a decision. Joining with anything else would be the first step towards a format string, which is an expression in disguise. If a caller needs delimiters they belong in the stored values.
A row whose field is missing, or is not the declared type, must refuse. The precedent is the rollup, which refuses a document it cannot add rather than skipping it, on the stated grounds that a total which silently skips is a total nobody can trust. A concatenation with a hole in it is worse, because nothing about the result says so.
These three primitives are the first things in this store that walk a range at run time. Everything
before them is bounded by construction: a declaration cannot express a loop, because a step pins
operation@version, version numbers only ever rise, and a pinned reference resolves only
to a version that already exists — so a cycle would need a version to exist before it was
declared. The reference graph is a directed acyclic graph because nothing can build anything else,
which is why ceiling() can compute a total at declare time and why nothing counts during
a run.
That property does not survive a thing that walks. So the limit on this operation is a ceiling the host counts against while walking, not a number the host takes on trust, and it is counted per run of the operation rather than per underlying call. At the ceiling the operation stops and refuses; it does not return a shortened answer, because a truncated result is indistinguishable from a complete one.
The reason to write this down rather than assume it is measured, in pipelines/tasks/0071,
measurement W7. An external operation declaring limit 50, correctly
sandboxed and correctly signed, served 50,000,000 rows in one call — one
million calls of a host function that served fifty each — because the host checked every
individual call and never the total. A signature proves whose binary it is. A sandbox proves it does
not escape. Neither proves what it costs. That gap is not open today because nothing
can loop; it opens the moment something can.
Two things are counted here, rows read and bytes produced, and either one reaching its ceiling stops the operation. This is the only one of the three where the output grows with the input, which is why it is also the only one that waits on a size ceiling existing at all.