@elaraai/e3-api-server 1.0.78 → 1.0.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +83 -19
  2. package/dist/src/beast2.js +1 -1
  3. package/dist/src/beast2.js.map +1 -1
  4. package/dist/src/cli.js +13 -0
  5. package/dist/src/cli.js.map +1 -1
  6. package/dist/src/errors.d.ts.map +1 -1
  7. package/dist/src/errors.js +8 -1
  8. package/dist/src/errors.js.map +1 -1
  9. package/dist/src/handlers/dataflow.d.ts +58 -11
  10. package/dist/src/handlers/dataflow.d.ts.map +1 -1
  11. package/dist/src/handlers/dataflow.js +120 -48
  12. package/dist/src/handlers/dataflow.js.map +1 -1
  13. package/dist/src/handlers/dataflow.spec.d.ts +6 -0
  14. package/dist/src/handlers/dataflow.spec.d.ts.map +1 -0
  15. package/dist/src/handlers/dataflow.spec.js +106 -0
  16. package/dist/src/handlers/dataflow.spec.js.map +1 -0
  17. package/dist/src/handlers/datasets.d.ts +60 -34
  18. package/dist/src/handlers/datasets.d.ts.map +1 -1
  19. package/dist/src/handlers/datasets.js +452 -306
  20. package/dist/src/handlers/datasets.js.map +1 -1
  21. package/dist/src/handlers/datasets.spec.js +338 -119
  22. package/dist/src/handlers/datasets.spec.js.map +1 -1
  23. package/dist/src/handlers/functions.d.ts.map +1 -1
  24. package/dist/src/handlers/functions.js +10 -8
  25. package/dist/src/handlers/functions.js.map +1 -1
  26. package/dist/src/handlers/functions.spec.js +2 -2
  27. package/dist/src/handlers/functions.spec.js.map +1 -1
  28. package/dist/src/handlers/index.d.ts +2 -2
  29. package/dist/src/handlers/index.d.ts.map +1 -1
  30. package/dist/src/handlers/index.js +2 -2
  31. package/dist/src/handlers/index.js.map +1 -1
  32. package/dist/src/handlers/records.d.ts.map +1 -1
  33. package/dist/src/handlers/records.js +15 -12
  34. package/dist/src/handlers/records.js.map +1 -1
  35. package/dist/src/handlers/records.spec.js +9 -5
  36. package/dist/src/handlers/records.spec.js.map +1 -1
  37. package/dist/src/handlers/repository.d.ts +59 -10
  38. package/dist/src/handlers/repository.d.ts.map +1 -1
  39. package/dist/src/handlers/repository.js +147 -40
  40. package/dist/src/handlers/repository.js.map +1 -1
  41. package/dist/src/handlers/tasks.d.ts +2 -1
  42. package/dist/src/handlers/tasks.d.ts.map +1 -1
  43. package/dist/src/handlers/tasks.js +31 -8
  44. package/dist/src/handlers/tasks.js.map +1 -1
  45. package/dist/src/handlers/tasks.spec.d.ts +6 -0
  46. package/dist/src/handlers/tasks.spec.d.ts.map +1 -0
  47. package/dist/src/handlers/tasks.spec.js +64 -0
  48. package/dist/src/handlers/tasks.spec.js.map +1 -0
  49. package/dist/src/handlers/workspaces.d.ts +44 -6
  50. package/dist/src/handlers/workspaces.d.ts.map +1 -1
  51. package/dist/src/handlers/workspaces.js +74 -40
  52. package/dist/src/handlers/workspaces.js.map +1 -1
  53. package/dist/src/handlers/workspaces.spec.d.ts +6 -0
  54. package/dist/src/handlers/workspaces.spec.d.ts.map +1 -0
  55. package/dist/src/handlers/workspaces.spec.js +164 -0
  56. package/dist/src/handlers/workspaces.spec.js.map +1 -0
  57. package/dist/src/index.d.ts +3 -0
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/dist/src/index.js +7 -0
  60. package/dist/src/index.js.map +1 -1
  61. package/dist/src/local-dataflow.d.ts +14 -0
  62. package/dist/src/local-dataflow.d.ts.map +1 -0
  63. package/dist/src/local-dataflow.js +41 -0
  64. package/dist/src/local-dataflow.js.map +1 -0
  65. package/dist/src/middleware/repository.d.ts +39 -0
  66. package/dist/src/middleware/repository.d.ts.map +1 -0
  67. package/dist/src/middleware/repository.js +77 -0
  68. package/dist/src/middleware/repository.js.map +1 -0
  69. package/dist/src/routes/data.d.ts +2 -2
  70. package/dist/src/routes/data.d.ts.map +1 -1
  71. package/dist/src/routes/data.js +22 -45
  72. package/dist/src/routes/data.js.map +1 -1
  73. package/dist/src/routes/datasets.d.ts +0 -3
  74. package/dist/src/routes/datasets.d.ts.map +1 -1
  75. package/dist/src/routes/datasets.js +40 -8
  76. package/dist/src/routes/datasets.js.map +1 -1
  77. package/dist/src/routes/executions.d.ts +37 -2
  78. package/dist/src/routes/executions.d.ts.map +1 -1
  79. package/dist/src/routes/executions.js +21 -12
  80. package/dist/src/routes/executions.js.map +1 -1
  81. package/dist/src/routes/index.d.ts +2 -1
  82. package/dist/src/routes/index.d.ts.map +1 -1
  83. package/dist/src/routes/index.js +1 -0
  84. package/dist/src/routes/index.js.map +1 -1
  85. package/dist/src/routes/objects.d.ts +16 -2
  86. package/dist/src/routes/objects.d.ts.map +1 -1
  87. package/dist/src/routes/objects.js +34 -3
  88. package/dist/src/routes/objects.js.map +1 -1
  89. package/dist/src/routes/repositories.d.ts +26 -0
  90. package/dist/src/routes/repositories.d.ts.map +1 -0
  91. package/dist/src/routes/repositories.js +58 -0
  92. package/dist/src/routes/repositories.js.map +1 -0
  93. package/dist/src/routes/repositories.spec.d.ts +6 -0
  94. package/dist/src/routes/repositories.spec.d.ts.map +1 -0
  95. package/dist/src/routes/repositories.spec.js +77 -0
  96. package/dist/src/routes/repositories.spec.js.map +1 -0
  97. package/dist/src/routes/repository.d.ts +12 -2
  98. package/dist/src/routes/repository.d.ts.map +1 -1
  99. package/dist/src/routes/repository.js +24 -9
  100. package/dist/src/routes/repository.js.map +1 -1
  101. package/dist/src/routes/transfer.d.ts +11 -9
  102. package/dist/src/routes/transfer.d.ts.map +1 -1
  103. package/dist/src/routes/transfer.js +90 -115
  104. package/dist/src/routes/transfer.js.map +1 -1
  105. package/dist/src/routes/workspaces.d.ts +14 -1
  106. package/dist/src/routes/workspaces.d.ts.map +1 -1
  107. package/dist/src/routes/workspaces.js +57 -46
  108. package/dist/src/routes/workspaces.js.map +1 -1
  109. package/dist/src/server.d.ts +18 -6
  110. package/dist/src/server.d.ts.map +1 -1
  111. package/dist/src/server.js +55 -182
  112. package/dist/src/server.js.map +1 -1
  113. package/dist/src/server.spec.d.ts +6 -0
  114. package/dist/src/server.spec.d.ts.map +1 -0
  115. package/dist/src/server.spec.js +59 -0
  116. package/dist/src/server.spec.js.map +1 -0
  117. package/dist/src/types.d.ts +200 -16
  118. package/dist/src/types.d.ts.map +1 -1
  119. package/dist/src/types.js +5 -2
  120. package/dist/src/types.js.map +1 -1
  121. package/package.json +5 -5
  122. package/dist/src/async-operation-state.d.ts +0 -37
  123. package/dist/src/async-operation-state.d.ts.map +0 -1
  124. package/dist/src/async-operation-state.js +0 -118
  125. package/dist/src/async-operation-state.js.map +0 -1
  126. package/dist/src/orchestrator-manager.d.ts +0 -45
  127. package/dist/src/orchestrator-manager.d.ts.map +0 -1
  128. package/dist/src/orchestrator-manager.js +0 -150
  129. package/dist/src/orchestrator-manager.js.map +0 -1
@@ -2,9 +2,9 @@
2
2
  * Copyright (c) 2025 Elara AI Pty Ltd
3
3
  * Licensed under BSL 1.1. See LICENSE for details.
4
4
  */
5
- import { NullType, ArrayType, StringType, compareFor, decodeBeast2, encodeBeast2For, openBeast2PagesFor, parseFor, readBeast2ExtentsRanged, carveBeast2Ranged, some, none, variant, toEastTypeValue, isVariant } from '@elaraai/east';
6
- import { workspaceListTree, workspaceGetDatasetHash, workspaceGetDatasetStatus, workspaceSetDataset, workspaceGetTree, } from '@elaraai/e3-core';
7
- import { BEAST2_CONTENT_TYPE } from '@elaraai/e3-core';
5
+ import { NullType, ArrayType, StringType, compareFor, encodeBeast2For, openBeast2PagesFor, parseFor, some, none, variant, toEastTypeValue, isVariant } from '@elaraai/east';
6
+ import { BEAST2_CONTENT_TYPE } from '@elaraai/e3-types';
7
+ import { workspaceListTree, workspaceGetDatasetHash, workspaceGetDatasetStatus, workspaceSetDatasetBytes, workspaceGetTree, openDatasetObject, recordIndexNames, resolveRecordIndex, DatasetSegments, } from '@elaraai/e3-core';
8
8
  import { sendSuccess, sendError } from '../beast2.js';
9
9
  import { errorToVariant, sendJsonError } from '../errors.js';
10
10
  import { DatasetStatusDetailType, ListEntryType } from '../types.js';
@@ -20,15 +20,23 @@ export async function listDatasets(storage, repoPath, workspace, treePath) {
20
20
  return sendError(ArrayType(StringType), errorToVariant(err));
21
21
  }
22
22
  }
23
- const SIZE_THRESHOLD = 1 * 1024 * 1024; // 1 MB
23
+ /** The size over which an object is answered with a URL to fetch it from
24
+ * rather than inline, when a transfer backend is configured. */
25
+ export const DOWNLOAD_REDIRECT_BYTES = 1 * 1024 * 1024;
24
26
  /**
25
27
  * Get dataset value as raw BEAST2 bytes.
26
28
  *
27
- * For objects > 1MB, returns a JSON response with a download URL
28
- * that the client can fetch directly. This avoids browser issues
29
- * with opaque redirect responses from `redirect: 'manual'`.
29
+ * A collection held as a segment manifest is streamed as its splice, or, for a
30
+ * caller that asks for its segments, answered with JSON `{ manifest }`, the
31
+ * manifest's hash — the primary's, for an indexed record — for a client that
32
+ * downloads the manifest and its segment objects itself and splices them. A
33
+ * host that cannot stream a response answers a large collection no other way.
34
+ * Any other object over {@link DOWNLOAD_REDIRECT_BYTES}, when a transfer
35
+ * backend is configured, is answered with a JSON response carrying a download
36
+ * URL the client fetches directly. This avoids browser issues with opaque
37
+ * redirect responses from `redirect: 'manual'`.
30
38
  */
31
- export async function getDataset(storage, repoPath, workspace, treePath, repo, requestUrl, transferBackend) {
39
+ export async function getDataset(storage, repoPath, workspace, treePath, repo, requestUrl, transferBackend, segments = false) {
32
40
  try {
33
41
  if (treePath.length === 0) {
34
42
  return new Response(JSON.stringify({ error: { type: 'bad_request', message: 'Path required for get' } }), {
@@ -49,10 +57,49 @@ export async function getDataset(storage, repoPath, workspace, treePath, repo, r
49
57
  headers: { 'Content-Type': 'application/json' },
50
58
  });
51
59
  }
60
+ // A collection held as a segment manifest is many objects, so the
61
+ // transfer backend — which serves ONE object by hash — cannot hand the
62
+ // client a value. The splice is streamed instead, a segment read only
63
+ // when the client takes the bytes before it: a host that streams the
64
+ // response holds one segment at a time, and one that buffers it into a
65
+ // single payload holds the value, as far as that payload's limit allows.
66
+ // The download redirect stays for objects that are the value.
67
+ const collection = await openDatasetObject(storage, repoPath, hash);
68
+ if (collection.manifest !== null) {
69
+ if (segments) {
70
+ return new Response(JSON.stringify({ manifest: collection.hash }), {
71
+ status: 200,
72
+ headers: {
73
+ 'Content-Type': 'application/json',
74
+ 'X-Content-SHA256': hash,
75
+ },
76
+ });
77
+ }
78
+ const chunks = (await DatasetSegments.open(storage, repoPath, hash)).splice()[Symbol.asyncIterator]();
79
+ const body = new ReadableStream({
80
+ async pull(controller) {
81
+ const next = await chunks.next();
82
+ if (next.done)
83
+ controller.close();
84
+ else
85
+ controller.enqueue(next.value);
86
+ },
87
+ async cancel() {
88
+ await chunks.return?.();
89
+ },
90
+ }, { highWaterMark: 0 });
91
+ return new Response(body, {
92
+ status: 200,
93
+ headers: {
94
+ 'Content-Type': BEAST2_CONTENT_TYPE,
95
+ 'X-Content-SHA256': hash,
96
+ },
97
+ });
98
+ }
52
99
  // When serving via API with a transfer backend, check size to decide whether to redirect
53
100
  if (transferBackend && repo && requestUrl) {
54
101
  const { size } = await storage.objects.stat(repoPath, hash);
55
- if (size > SIZE_THRESHOLD) {
102
+ if (size > DOWNLOAD_REDIRECT_BYTES) {
56
103
  let downloadUrl = await transferBackend.datasetDownload.getDownloadUrl(repo, hash);
57
104
  // Resolve relative URL against the request origin
58
105
  if (downloadUrl.startsWith('/')) {
@@ -94,53 +141,61 @@ const PAGE_MAX_LIMIT = 10_000;
94
141
  * tighter response limits (e.g. Lambda proxy's 6 MB, base64-inflated) pass
95
142
  * a smaller budget through the route options. */
96
143
  export const PAGE_BYTE_BUDGET_DEFAULT = 4 * 1024 * 1024;
97
- /** Largest blob the page endpoint will buffer when the storage backend
98
- * cannot serve ranged reads (`objects.readRange` absent) and every page
99
- * request must read the blob whole. Backends with ranged reads never
100
- * buffer the blob, so no cap applies — per-window memory is O(window) at
101
- * any blob size. */
102
- export const PAGE_READ_MAX_BYTES_DEFAULT = 512 * 1024 * 1024;
103
- const EXTENTS_CACHE_MAX_ENTRIES = 64;
144
+ const SEGMENTS_CACHE_MAX_ENTRIES = 64;
104
145
  /** Cap on the cache's total retained bytes. The newest entry always stays
105
146
  * (serving the request needs it regardless), so one giant index can still
106
147
  * be held — but never alongside others. */
107
- const EXTENTS_CACHE_MAX_BYTES = 16 * 1024 * 1024;
108
- const extentsCache = new Map();
109
- let extentsCacheBytes = 0;
148
+ const SEGMENTS_CACHE_MAX_BYTES = 16 * 1024 * 1024;
149
+ const segmentsCache = new Map();
150
+ let segmentsCacheBytes = 0;
110
151
  /** Evicts oldest entries until the count and byte budgets hold, always
111
152
  * keeping at least the newest entry. */
112
- function evictExtents() {
113
- while (extentsCache.size > 1 &&
114
- (extentsCache.size > EXTENTS_CACHE_MAX_ENTRIES || extentsCacheBytes > EXTENTS_CACHE_MAX_BYTES)) {
115
- const oldest = extentsCache.keys().next().value;
116
- extentsCacheBytes -= extentsCache.get(oldest).bytes;
117
- extentsCache.delete(oldest);
153
+ function evictSegments() {
154
+ while (segmentsCache.size > 1 &&
155
+ (segmentsCache.size > SEGMENTS_CACHE_MAX_ENTRIES || segmentsCacheBytes > SEGMENTS_CACHE_MAX_BYTES)) {
156
+ const oldest = segmentsCache.keys().next().value;
157
+ segmentsCacheBytes -= segmentsCache.get(oldest).bytes;
158
+ segmentsCache.delete(oldest);
118
159
  }
119
160
  }
120
- /** Fetches (or reuses) a blob's ranged extents, LRU-cached per hash and
121
- * bounded by retained bytes. */
122
- async function cachedRangedExtents(hash, size, readRange) {
123
- const cached = extentsCache.get(hash);
124
- if (cached) {
125
- extentsCache.delete(hash);
126
- extentsCache.set(hash, cached); // refresh recency
127
- return cached;
161
+ /** Identifies a storage backend for the cache key — lazily, and without
162
+ * keeping it alive. */
163
+ const storageIds = new WeakMap();
164
+ let nextStorageId = 0;
165
+ /** Opens (or reuses) a stored collection, LRU-cached per backend, repository
166
+ * and content hash, and bounded by retained bytes.
167
+ *
168
+ * All three key parts are load-bearing. An opened dataset holds the backend
169
+ * it reads segment objects through, and a manifest names objects that exist
170
+ * in the repository it was read from and nowhere else — so two backends, or
171
+ * two repositories, that happen to hold the same bytes under the same name
172
+ * must not share an entry. */
173
+ async function cachedSegments(storage, repoPath, hash, size) {
174
+ let backendId = storageIds.get(storage);
175
+ if (backendId === undefined) {
176
+ backendId = nextStorageId++;
177
+ storageIds.set(storage, backendId);
128
178
  }
129
- const extents = await readBeast2ExtentsRanged({ size, read: readRange });
130
- const cumulative = new Array(extents.counts.length);
131
- let running = 0;
132
- for (let i = 0; i < extents.counts.length; i++) {
133
- running += extents.counts[i];
134
- cumulative[i] = running;
179
+ const key = `${backendId}\u0000${repoPath}\u0000${hash}`;
180
+ const cached = segmentsCache.get(key);
181
+ if (cached) {
182
+ segmentsCache.delete(key);
183
+ segmentsCache.set(key, cached); // refresh recency
184
+ return cached.segments;
135
185
  }
136
- // offsets + counts + cumulative at 8 bytes per element, plus the header.
137
- const bytes = extents.head.byteLength + 24 * extents.counts.length;
138
- const entry = { extents, cumulative, fences: new Map(), bytes };
139
- extentsCache.set(hash, entry);
140
- extentsCacheBytes += bytes;
141
- evictExtents();
142
- return entry;
186
+ const segments = await DatasetSegments.open(storage, repoPath, hash, size);
187
+ // counts + prefix sums at 8 bytes each, plus the manifest's stored fences.
188
+ let bytes = 16 * segments.segmentCount;
189
+ for (const entry of segments.manifest.entries)
190
+ bytes += entry.fence.byteLength + 80;
191
+ segmentsCache.set(key, { segments, bytes });
192
+ segmentsCacheBytes += bytes;
193
+ evictSegments();
194
+ return segments;
143
195
  }
196
+ /** The opener's refusal of a collection stored as one blob, which an older
197
+ * e3 wrote: answered as `dataset_not_indexed`, in the refusal's own words. */
198
+ const BLOB_REFUSAL = /^the collection [0-9a-f]{64} is stored as one blob:/;
144
199
  function pageError(type, message, status = 400, headers) {
145
200
  return new Response(JSON.stringify({ error: { type, message } }), {
146
201
  status,
@@ -149,7 +204,6 @@ function pageError(type, message, status = 400, headers) {
149
204
  }
150
205
  export async function getDatasetPage(storage, repoPath, workspace, treePath, window, limits) {
151
206
  const byteBudget = limits?.byteBudget ?? PAGE_BYTE_BUDGET_DEFAULT;
152
- const readMaxBytes = limits?.readMaxBytes ?? PAGE_READ_MAX_BYTES_DEFAULT;
153
207
  try {
154
208
  if (treePath.length === 0) {
155
209
  return pageError('bad_request', 'Path required for paged get');
@@ -174,6 +228,9 @@ export async function getDatasetPage(storage, repoPath, workspace, treePath, win
174
228
  if (kind !== 'Array' && kind !== 'Set' && kind !== 'Dict') {
175
229
  return pageError('dataset_not_pageable', `Paged reads address Array, Set or Dict datasets; this dataset holds ${kind}`);
176
230
  }
231
+ if (window.index !== undefined) {
232
+ return await indexPage(storage, repoPath, workspace, treePath, status.hash, window, byteBudget);
233
+ }
177
234
  const segmentMode = window.segment !== undefined;
178
235
  if (segmentMode && (window.offset !== undefined || window.limit !== undefined)) {
179
236
  return pageError('bad_request', 'Pass either segment or offset/limit, not both');
@@ -186,26 +243,43 @@ export async function getDatasetPage(storage, repoPath, workspace, treePath, win
186
243
  if (!segmentMode && (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(requestedLimit) || requestedLimit < 1)) {
187
244
  return pageError('bad_request', `offset must be a non-negative integer and limit a positive integer, got offset=${offset} limit=${requestedLimit}`);
188
245
  }
189
- const statSize = status.size ?? (await storage.objects.stat(repoPath, status.hash)).size;
246
+ const objectSize = status.size ?? (await storage.objects.stat(repoPath, status.hash)).size;
247
+ let segments;
248
+ try {
249
+ segments = await cachedSegments(storage, repoPath, status.hash, objectSize);
250
+ }
251
+ catch (err) {
252
+ // Only the opener's refusal of a blob-stored collection means "not
253
+ // indexed". Anything else (storage I/O, a missing or corrupt object) is
254
+ // a real failure and must surface as one.
255
+ const message = err instanceof Error ? err.message : String(err);
256
+ if (!BLOB_REFUSAL.test(message))
257
+ throw err;
258
+ return pageError('dataset_not_indexed', message);
259
+ }
260
+ const segmentCount = segments.segmentCount;
261
+ const totalElements = segments.elementCount;
262
+ const totalBytes = segments.bytes;
263
+ const cumulative = segments.cumulative;
190
264
  // The byte budget turns the requested element limit into an effective one
191
- // using the blob's average element size, so pages stay bounded even for
265
+ // using the dataset's average element size, so pages stay bounded even for
192
266
  // very wide rows.
193
- const effectiveLimit = (totalElements) => {
194
- const avgBytes = totalElements > 0 ? statSize / totalElements : 1;
267
+ const effectiveLimit = () => {
268
+ const avgBytes = totalElements > 0 ? totalBytes / totalElements : 1;
195
269
  const byBudget = Math.max(1, Math.floor(byteBudget / Math.max(1, avgBytes)));
196
270
  return Math.max(1, Math.min(requestedLimit, PAGE_MAX_LIMIT, byBudget));
197
271
  };
198
- const pageHeaders = (totalElements, segmentCount, pageOffset, pageCount) => ({
272
+ const pageHeaders = (pageOffset, pageCount) => ({
199
273
  'Content-Type': BEAST2_CONTENT_TYPE,
200
274
  // Hash-pinned windows are content-addressed: same URL ⇒ same bytes,
201
275
  // forever — cacheable at any layer with no invalidation. Unpinned
202
276
  // windows track the mutable current value and must not be cached.
203
277
  'Cache-Control': window.hash !== undefined ? 'public, max-age=31536000, immutable' : 'no-store',
204
278
  'X-Content-SHA256': status.hash,
205
- // The stored blob's full byte size — the page endpoint is then
279
+ // The value's full stored byte size — the page endpoint is then
206
280
  // self-describing (no separate status call needed for the header
207
281
  // line); Content-Length remains the page's own bytes.
208
- 'X-Total-Bytes': String(statSize),
282
+ 'X-Total-Bytes': String(totalBytes),
209
283
  'X-Total-Elements': String(totalElements),
210
284
  // Always exact: Array counts index stream order, and v5 Set/Dict
211
285
  // segments are disjoint ranges of the canonical value.
@@ -214,152 +288,54 @@ export async function getDatasetPage(storage, repoPath, workspace, treePath, win
214
288
  'X-Page-Offset': String(pageOffset),
215
289
  'X-Page-Count': String(pageCount),
216
290
  });
217
- // Ranged path: backends with `objects.readRange` never buffer the blob.
218
- // The tail (index) and head (header sections) are read once per content
219
- // hash and cached; each window then reads only the byte ranges of the
220
- // segments it touches — per-request memory is O(window) at any blob
221
- // size, so no cap applies (#513).
222
- const readRange = storage.objects.readRange?.bind(storage.objects);
223
- if (readRange) {
224
- let cached;
225
- try {
226
- cached = await cachedRangedExtents(status.hash, statSize, (offset, length) => readRange(repoPath, status.hash, offset, length));
227
- }
228
- catch (err) {
229
- // Only the reader's own blob-shape refusals mean "not indexed" — a
230
- // blob predating the stored-segmented contract (or injected raw).
231
- // Anything else (storage I/O, missing object) is a real failure and
232
- // must surface as one, not masquerade as a re-write suggestion.
233
- const message = err instanceof Error ? err.message : String(err);
234
- if (!/^(beast2 v5:|Data too short for Beast2|Invalid Beast2)/.test(message)) {
235
- throw err;
236
- }
237
- return pageError('dataset_not_indexed', 'Dataset blob carries no pageable segment index — re-write the dataset (re-run the producing task, or set the value again) to store it in the indexed form.');
238
- }
239
- const { extents, cumulative } = cached;
240
- if (!extents.selfContained) {
241
- return pageError('dataset_not_indexed', 'Dataset blob carries no pageable segment index — re-write the dataset (re-run the producing task, or set the value again) to store it in the indexed form.');
291
+ // The touched segment span [from, to) and the window placement.
292
+ let from;
293
+ let to;
294
+ let limit = 0;
295
+ if (segmentMode) {
296
+ const seg = window.segment;
297
+ if (seg >= segmentCount) {
298
+ return pageError('bad_request', `segment ${seg} out of range (${segmentCount} segments)`);
242
299
  }
243
- const segmentCount = extents.offsets.length;
244
- const totalElements = extents.elementCount;
245
- // The touched segment span [from, to) and the window placement.
246
- let from;
247
- let to;
248
- let limit = 0;
249
- if (segmentMode) {
250
- const seg = window.segment;
251
- if (seg >= segmentCount) {
252
- return pageError('bad_request', `segment ${seg} out of range (${segmentCount} segments)`);
253
- }
254
- from = seg;
255
- to = seg + 1;
300
+ from = seg;
301
+ to = seg + 1;
302
+ }
303
+ else {
304
+ limit = effectiveLimit();
305
+ if (offset >= totalElements) {
306
+ from = 0;
307
+ to = 0; // empty window past the end
256
308
  }
257
309
  else {
258
- limit = effectiveLimit(totalElements);
259
- if (offset >= totalElements) {
260
- from = 0;
261
- to = 0; // empty window past the end
262
- }
263
- else {
264
- from = 0;
265
- while (cumulative[from] <= offset)
266
- from++;
267
- const lastRow = Math.min(offset + limit, totalElements) - 1;
268
- to = from;
269
- while (cumulative[to] <= lastRow)
270
- to++;
310
+ from = 0;
311
+ while (cumulative[from] <= offset)
312
+ from++;
313
+ const lastRow = Math.min(offset + limit, totalElements) - 1;
314
+ to = from;
315
+ while (cumulative[to] <= lastRow)
271
316
  to++;
272
- }
273
- }
274
- const spanStart = to > from ? extents.offsets[from] : 0;
275
- const spanEnd = to > from
276
- ? (to < segmentCount ? extents.offsets[to] : extents.segmentsEnd)
277
- : 0;
278
- const frames = to > from
279
- ? await readRange(repoPath, status.hash, spanStart, spanEnd - spanStart)
280
- : new Uint8Array(0);
281
- const windowBlob = carveBeast2Ranged(extents, frames, from, to);
282
- const pages = openBeast2PagesFor(typeValue)(windowBlob);
283
- let windowValue;
284
- let pageOffset;
285
- let pageCount;
286
- if (segmentMode) {
287
- try {
288
- windowValue = pages.segment(0);
289
- }
290
- catch (err) {
291
- return pageError('dataset_not_segmented', err instanceof Error ? err.message : String(err));
292
- }
293
- pageCount = extents.counts[from];
294
- pageOffset = from === 0 ? 0 : cumulative[from - 1];
317
+ to++;
295
318
  }
296
- else {
297
- const base = from > 0 ? cumulative[from - 1] : 0;
298
- try {
299
- windowValue = pages.slice(to > from ? offset - base : 0, limit);
300
- }
301
- catch (err) {
302
- return pageError('dataset_not_canonical', err instanceof Error ? err.message : String(err));
303
- }
304
- pageCount = kind === 'Array'
305
- ? windowValue.length
306
- : windowValue.size;
307
- pageOffset = offset;
308
- }
309
- const body = encodeBeast2For(typeValue)(windowValue);
310
- return new Response(body, {
311
- status: 200,
312
- headers: {
313
- ...pageHeaders(totalElements, segmentCount, pageOffset, pageCount),
314
- 'Content-Length': String(body.byteLength),
315
- },
316
- });
317
- }
318
- // Fallback path (no ranged reads): the whole blob is buffered per
319
- // request, so the absolute cap protects the server process itself.
320
- if (statSize > readMaxBytes) {
321
- return pageError('dataset_too_large', `Dataset is ${Math.round(statSize / 1024 / 1024)} MB — beyond the ${Math.round(readMaxBytes / 1024 / 1024)} MB paging cap. Download it instead.`);
322
- }
323
- const data = await storage.objects.read(repoPath, status.hash);
324
- let pages = null;
325
- try {
326
- pages = openBeast2PagesFor(typeValue)(data);
327
- }
328
- catch {
329
- pages = null; // No index/footer — every existing whole-value blob.
330
319
  }
320
+ const windowBlob = await segments.span(from, to);
321
+ const pages = openBeast2PagesFor(typeValue)(windowBlob);
331
322
  let windowValue;
332
- let totalElements;
333
323
  let pageOffset;
334
324
  let pageCount;
335
325
  if (segmentMode) {
336
- if (!pages) {
337
- return pageError('dataset_not_segmented', 'Blob carries no segment index — address it with offset/limit instead');
338
- }
339
- const seg = window.segment;
340
- if (seg >= pages.segmentCount) {
341
- return pageError('bad_request', `segment ${seg} out of range (${pages.segmentCount} segments)`);
342
- }
343
326
  try {
344
- windowValue = pages.segment(seg);
327
+ windowValue = pages.segment(0);
345
328
  }
346
329
  catch (err) {
347
330
  return pageError('dataset_not_segmented', err instanceof Error ? err.message : String(err));
348
331
  }
349
- totalElements = pages.elementCount;
350
- pageCount = pages.counts[seg];
351
- pageOffset = 0;
352
- for (let i = 0; i < seg; i++)
353
- pageOffset += pages.counts[i];
354
- }
355
- else if (pages !== null && pages.selfContained) {
356
- // The indexed path, every collection kind: Array windows address
357
- // stream order, Set/Dict windows the canonical key order (fences are
358
- // verified on first access; a non-canonical blob is corrupt).
359
- totalElements = pages.elementCount;
360
- const limit = effectiveLimit(totalElements);
332
+ pageCount = segments.counts[from];
333
+ pageOffset = from === 0 ? 0 : cumulative[from - 1];
334
+ }
335
+ else {
336
+ const base = from > 0 ? cumulative[from - 1] : 0;
361
337
  try {
362
- windowValue = pages.slice(offset, limit);
338
+ windowValue = pages.slice(to > from ? offset - base : 0, limit);
363
339
  }
364
340
  catch (err) {
365
341
  return pageError('dataset_not_canonical', err instanceof Error ? err.message : String(err));
@@ -369,18 +345,11 @@ export async function getDatasetPage(storage, repoPath, workspace, treePath, win
369
345
  : windowValue.size;
370
346
  pageOffset = offset;
371
347
  }
372
- else {
373
- // Collection datasets are stored segmented + indexed by every writer
374
- // at every size; a blob without a pageable index predates that
375
- // contract (or was injected raw) and is refused rather than
376
- // whole-decoded — re-writing the dataset produces the indexed form.
377
- return pageError('dataset_not_indexed', 'Dataset blob carries no pageable segment index — re-write the dataset (re-run the producing task, or set the value again) to store it in the indexed form.');
378
- }
379
348
  const body = encodeBeast2For(typeValue)(windowValue);
380
349
  return new Response(body, {
381
350
  status: 200,
382
351
  headers: {
383
- ...pageHeaders(totalElements, pages ? pages.segmentCount : 0, pageOffset, pageCount),
352
+ ...pageHeaders(pageOffset, pageCount),
384
353
  'Content-Length': String(body.byteLength),
385
354
  },
386
355
  });
@@ -389,6 +358,154 @@ export async function getDatasetPage(storage, repoPath, workspace, treePath, win
389
358
  return sendJsonError(err);
390
359
  }
391
360
  }
361
+ /**
362
+ * One window of a record read through one of its secondary indexes.
363
+ *
364
+ * @remarks
365
+ * The window comes from the index's OWN segments — the same fence bisect and
366
+ * window slice a primary page uses, over the index manifest — so with a
367
+ * covering projection the window IS the answer and the primary is never
368
+ * touched. `join` fills the rows: the window's primary keys are bucketed by
369
+ * owning primary segment, each distinct segment read once, and the rows
370
+ * projected out, so a page whose entries share an index key usually costs a
371
+ * handful of reads rather than one per row.
372
+ */
373
+ async function indexPage(storage, repoPath, workspace, treePath, stateHash, window, byteBudget) {
374
+ const refPath = treePath.map((s) => s.value).join('/');
375
+ const resolved = await resolveRecordIndex(storage, repoPath, workspace, refPath, stateHash, window.index);
376
+ if (resolved === null) {
377
+ const declared = await recordIndexNames(storage, repoPath, stateHash);
378
+ return pageError('index_not_found', `Dataset '${refPath}' has no index '${window.index}'`
379
+ + (declared.length > 0 ? ` — it has ${declared.join(', ')}` : ' — it declares none'), 404);
380
+ }
381
+ const offset = window.offset ?? 0;
382
+ const requestedLimit = window.limit ?? PAGE_DEFAULT_LIMIT;
383
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(requestedLimit) || requestedLimit < 1) {
384
+ return pageError('bad_request', `offset must be a non-negative integer and limit a positive integer, got offset=${offset} limit=${requestedLimit}`);
385
+ }
386
+ const index = await cachedSegments(storage, repoPath, resolved.manifest, (await storage.objects.stat(repoPath, resolved.manifest)).size);
387
+ const totalElements = index.elementCount;
388
+ // A joined page carries whole rows, so its budget is the PRIMARY's average
389
+ // row size, not the index entry's — a covering page of the same limit is far
390
+ // smaller and is clamped on its own terms.
391
+ const primary = window.join === true
392
+ ? await cachedSegments(storage, repoPath, resolved.primary, (await storage.objects.stat(repoPath, resolved.primary)).size)
393
+ : null;
394
+ const perRow = (primary !== null && primary.elementCount > 0 ? primary.bytes / primary.elementCount : 0)
395
+ + (totalElements > 0 ? index.bytes / totalElements : 1);
396
+ const limit = Math.max(1, Math.min(requestedLimit, PAGE_MAX_LIMIT, Math.max(1, Math.floor(byteBudget / Math.max(1, perRow)))));
397
+ // The index window, as the index's own collection.
398
+ let from = 0;
399
+ let to = 0;
400
+ if (offset < totalElements) {
401
+ while (index.cumulative[from] <= offset)
402
+ from++;
403
+ const lastRow = Math.min(offset + limit, totalElements) - 1;
404
+ to = from;
405
+ while (index.cumulative[to] <= lastRow)
406
+ to++;
407
+ to++;
408
+ }
409
+ const base = from > 0 ? index.cumulative[from - 1] : 0;
410
+ const slice = openBeast2PagesFor(resolved.collectionType)(await index.span(from, to))
411
+ .slice(to > from ? offset - base : 0, limit);
412
+ // The rows, if asked for. An index's order is not the record's, so a page's
413
+ // primary keys scatter across the record: they are grouped by the segment
414
+ // that holds them, and each segment is decoded once and dropped before the
415
+ // next. A page holds one decoded segment at a time however widely its keys
416
+ // scatter, and reads at most one segment per row it returns.
417
+ const entries = [...slice];
418
+ const rows = entries.map(() => none);
419
+ if (primary !== null) {
420
+ const bySegment = new Map();
421
+ for (let i = 0; i < entries.length; i++) {
422
+ const segment = await primary.segmentFor(entries[i][0].k);
423
+ const held = bySegment.get(segment);
424
+ if (held === undefined)
425
+ bySegment.set(segment, [i]);
426
+ else
427
+ held.push(i);
428
+ }
429
+ const decodeSegment = openBeast2PagesFor(primary.typeValue);
430
+ for (const [segment, indices] of bySegment) {
431
+ const decoded = decodeSegment(await primary.segment(segment)).segment(0);
432
+ for (const i of indices) {
433
+ const row = decoded.get(entries[i][0].k);
434
+ if (row !== undefined)
435
+ rows[i] = some(row);
436
+ }
437
+ }
438
+ }
439
+ const windowValue = entries.map(([entry, value], i) => ({ ik: entry.ik, key: entry.k, value, row: rows[i] }));
440
+ const body = encodeBeast2For(resolved.windowType)(windowValue);
441
+ return new Response(body, {
442
+ status: 200,
443
+ headers: {
444
+ 'Content-Type': BEAST2_CONTENT_TYPE,
445
+ 'Cache-Control': window.hash !== undefined ? 'public, max-age=31536000, immutable' : 'no-store',
446
+ 'X-Content-SHA256': stateHash,
447
+ 'X-Total-Bytes': String(index.bytes),
448
+ 'X-Total-Elements': String(totalElements),
449
+ 'X-Total-Exactness': 'exact',
450
+ 'X-Segment-Count': String(index.segmentCount),
451
+ 'X-Page-Offset': String(offset),
452
+ 'X-Page-Count': String(windowValue.length),
453
+ // The window is the index's shape, not the dataset's: a client decodes
454
+ // it as `Array<{ik, key, value, row}>`, never as the record's type.
455
+ 'X-Window-Index': window.index,
456
+ ...(window.join === true && { 'X-Window-Joined': 'true' }),
457
+ 'Content-Length': String(body.byteLength),
458
+ },
459
+ });
460
+ }
461
+ /**
462
+ * A key type's leaves, in declaration order, recursing into nested structs.
463
+ *
464
+ * @remarks
465
+ * Struct keys compare field by field in declaration order, so this flattening
466
+ * IS the key's sort order — which is what makes a bound on a leading prefix of
467
+ * it one contiguous row range. A scalar key flattens to one leaf with an empty
468
+ * path.
469
+ */
470
+ function flattenKeyFields(keyType) {
471
+ if (keyType.type !== 'Struct')
472
+ return [{ path: [], type: keyType }];
473
+ const out = [];
474
+ for (const field of keyType.value) {
475
+ for (const leaf of flattenKeyFields(field.type)) {
476
+ out.push({ path: [field.name, ...leaf.path], type: leaf.type });
477
+ }
478
+ }
479
+ return out;
480
+ }
481
+ /** The value at a flattened field path. */
482
+ function fieldAt(key, path) {
483
+ let value = key;
484
+ for (const segment of path)
485
+ value = value[segment];
486
+ return value;
487
+ }
488
+ /**
489
+ * A monotone "is this key at or past the bound?" predicate over a leading
490
+ * prefix of the flattened key.
491
+ *
492
+ * @remarks
493
+ * Monotone over the canonical key order because it reads only the leading
494
+ * fields, which are exactly the ones the order sorts by first — so one fence
495
+ * bisect finds its boundary row, and a pair of them bound one contiguous
496
+ * range.
497
+ */
498
+ function boundPredicate(leaves, values) {
499
+ const comparators = leaves.slice(0, values.length).map((leaf) => compareFor(leaf.type));
500
+ return (key) => {
501
+ for (let i = 0; i < values.length; i++) {
502
+ const order = comparators[i](fieldAt(key, leaves[i].path), values[i]);
503
+ if (order !== 0)
504
+ return order > 0;
505
+ }
506
+ return true; // equal on the prefix: at the bound, so past it
507
+ };
508
+ }
392
509
  /**
393
510
  * Locate a key (or string-prefix range) in a Set/Dict dataset by global
394
511
  * element row, without decoding the collection.
@@ -396,18 +513,17 @@ export async function getDatasetPage(storage, repoPath, workspace, treePath, win
396
513
  * Rows address the canonical East key order — the same row space
397
514
  * {@link getDatasetPage} element windows serve — so the result plugs
398
515
  * straight into the paged preview's scroll position. The search
399
- * binary-searches the blob's segment fences (each segment's first key,
400
- * probed and cached per content hash) with the key type's East comparator,
401
- * then decodes at most the one owning segment (exact key) or the two edge
402
- * segments (prefix range).
516
+ * binary-searches the manifest's segment fences (each segment's first key)
517
+ * with the key type's East comparator, then decodes at most the one owning
518
+ * segment (exact key) or the two edge segments (a range — which is what an
519
+ * exact key is through an index: every entry under it).
403
520
  *
404
521
  * Responds with JSON `{ found, row, count }`: `row` is the match's global
405
522
  * element index (for a prefix, the range's first row; for a miss, the
406
523
  * key's insertion row), `count` the number of matched rows. Hash-pinned
407
524
  * queries are immutable-cacheable exactly like page windows.
408
525
  */
409
- export async function findDatasetKey(storage, repoPath, workspace, treePath, query, limits) {
410
- const readMaxBytes = limits?.readMaxBytes ?? PAGE_READ_MAX_BYTES_DEFAULT;
526
+ export async function findDatasetKey(storage, repoPath, workspace, treePath, query) {
411
527
  try {
412
528
  if (treePath.length === 0) {
413
529
  return pageError('bad_request', 'Path required for key search');
@@ -422,9 +538,24 @@ export async function findDatasetKey(storage, repoPath, workspace, treePath, que
422
538
  if (query.hash !== undefined && query.hash !== status.hash) {
423
539
  return pageError('dataset_hash_mismatch', `Dataset content is ${status.hash}, not ${query.hash} — refetch status and retry`, 409, { 'X-Content-SHA256': status.hash });
424
540
  }
425
- const typeValue = isVariant(status.datasetType)
541
+ // An index selector searches the index's own collection, whose rows the
542
+ // answer is in — the same row space an index page serves.
543
+ let typeValue = isVariant(status.datasetType)
426
544
  ? status.datasetType
427
545
  : toEastTypeValue(status.datasetType);
546
+ let searchHash = status.hash;
547
+ const indexed = query.index !== undefined;
548
+ if (query.index !== undefined) {
549
+ const refPath = treePath.map((seg) => seg.value).join('/');
550
+ const resolved = await resolveRecordIndex(storage, repoPath, workspace, refPath, status.hash, query.index);
551
+ if (resolved === null) {
552
+ const declared = await recordIndexNames(storage, repoPath, status.hash);
553
+ return pageError('index_not_found', `Dataset '${refPath}' has no index '${query.index}'`
554
+ + (declared.length > 0 ? ` — it has ${declared.join(', ')}` : ' — it declares none'), 404);
555
+ }
556
+ typeValue = resolved.collectionType;
557
+ searchHash = resolved.manifest;
558
+ }
428
559
  const kind = typeValue.type;
429
560
  if (kind !== 'Set' && kind !== 'Dict') {
430
561
  return pageError('dataset_not_searchable', `Key search addresses Set or Dict datasets; this dataset holds ${kind}`);
@@ -432,16 +563,56 @@ export async function findDatasetKey(storage, repoPath, workspace, treePath, que
432
563
  const keyTypeValue = kind === 'Dict'
433
564
  ? typeValue.value.key
434
565
  : typeValue.value;
566
+ // Through an index, the key, prefix and fields forms address the index key
567
+ // `ik` — the key a client holds, as the record's signature names it — not
568
+ // the collection's `{ik, k}`, which pairs it with each row's own key. The
569
+ // collection sorts by `ik` first, so a predicate over it is monotone over
570
+ // the collection's order. The range form bounds the collection's
571
+ // flattened key, which begins inside `ik`.
572
+ const formKeyType = indexed
573
+ ? keyTypeValue.value.find((f) => f.name === 'ik').type
574
+ : keyTypeValue;
575
+ const formKey = indexed ? (k) => k.ik : (k) => k;
576
+ const keysNoun = indexed ? `the keys of index '${query.index}'` : "this dataset's keys";
435
577
  const fields = query.fields !== undefined && query.fields.length > 0 ? query.fields : undefined;
436
- if ((query.key === undefined) === (query.prefix === undefined && fields === undefined)) {
437
- return pageError('bad_request', 'Pass a key literal, or a prefix and/or leading fields');
438
- }
439
- const structMeta = keyTypeValue.type === 'Struct'
440
- ? keyTypeValue.value
578
+ const ranged = (query.from !== undefined && query.from.length > 0) || (query.to !== undefined && query.to.length > 0);
579
+ const forms = [query.key !== undefined, query.prefix !== undefined || fields !== undefined, ranged];
580
+ if (forms.filter(Boolean).length !== 1) {
581
+ return pageError('bad_request', 'Pass a key literal, or a prefix and/or leading fields, or a from/to range — exactly one form');
582
+ }
583
+ // The range form's bounds are leading prefixes of the FLATTENED key, which
584
+ // is the order the collection is sorted in, so each bounds one contiguous
585
+ // row range.
586
+ const leaves = flattenKeyFields(keyTypeValue);
587
+ const parseBound = (label, literals) => {
588
+ if (literals === undefined || literals.length === 0)
589
+ return { values: [] };
590
+ if (literals.length > leaves.length) {
591
+ return pageError('bad_request', `${label} names ${literals.length} key fields, but the key flattens to ${leaves.length}: `
592
+ + leaves.map((leaf) => leaf.path.join('.') || '(the key)').join(', '));
593
+ }
594
+ const values = [];
595
+ for (let i = 0; i < literals.length; i++) {
596
+ const parsed = parseFor(leaves[i].type)(literals[i]);
597
+ if (!parsed.success) {
598
+ return pageError('key_parse_error', `${label} field '${leaves[i].path.join('.') || '(the key)'}': ${parsed.error}`);
599
+ }
600
+ values.push(parsed.value);
601
+ }
602
+ return { values };
603
+ };
604
+ const lowerBound = parseBound('from', query.from);
605
+ if (lowerBound instanceof Response)
606
+ return lowerBound;
607
+ const upperBound = parseBound('to', query.to);
608
+ if (upperBound instanceof Response)
609
+ return upperBound;
610
+ const structMeta = formKeyType.type === 'Struct'
611
+ ? formKeyType.value
441
612
  : null;
442
613
  if (fields !== undefined) {
443
614
  if (structMeta === null) {
444
- return pageError('bad_request', `Leading-field search addresses Struct keys; this dataset's keys are ${keyTypeValue.type}`);
615
+ return pageError('bad_request', `Leading-field search addresses Struct keys; ${keysNoun} are ${formKeyType.type}`);
445
616
  }
446
617
  if (fields.length > structMeta.length) {
447
618
  return pageError('bad_request', `Key has ${structMeta.length} fields, got ${fields.length}`);
@@ -461,106 +632,49 @@ export async function findDatasetKey(storage, repoPath, workspace, treePath, que
461
632
  const prefixIdx = fieldValues.length;
462
633
  const prefixFieldType = structMeta !== null
463
634
  ? (prefixIdx < structMeta.length ? structMeta[prefixIdx].type : undefined)
464
- : keyTypeValue;
635
+ : formKeyType;
465
636
  if (prefixFieldType === undefined) {
466
637
  return pageError('bad_request', `All ${structMeta.length} key fields are exact — nothing left for a prefix`);
467
638
  }
468
639
  if (prefixFieldType.type !== 'String') {
469
640
  return pageError('bad_request', structMeta !== null
470
641
  ? `prefix continues key field '${structMeta[prefixIdx].name}', which is ${prefixFieldType.type}, not String`
471
- : `prefix search needs String keys; this dataset's keys are ${keyTypeValue.type}`);
642
+ : `prefix search needs String keys; ${keysNoun} are ${formKeyType.type}`);
472
643
  }
473
644
  }
474
645
  let keyValue;
475
646
  if (query.key !== undefined) {
476
- const parsed = parseFor(keyTypeValue)(query.key);
647
+ const parsed = parseFor(formKeyType)(query.key);
477
648
  if (!parsed.success) {
478
649
  return pageError('key_parse_error', parsed.error);
479
650
  }
480
651
  keyValue = parsed.value;
481
652
  }
482
- const cmp = compareFor(keyTypeValue);
483
- const statSize = status.size ?? (await storage.objects.stat(repoPath, status.hash)).size;
484
- const notIndexed = () => pageError('dataset_not_indexed', 'Dataset blob carries no pageable segment index — re-write the dataset (re-run the producing task, or set the value again) to store it in the indexed form.');
485
- // Both data paths resolve to the same fence-search surface: the segment
486
- // geometry plus a fence probe and a one-segment key iterator.
487
- let segmentCount;
488
- let elementCount;
489
- let cumulative;
490
- let fenceAt;
491
- let keysAt;
492
- const readRange = storage.objects.readRange?.bind(storage.objects);
493
- if (readRange) {
494
- // Ranged path: extents (and previously probed fences) are cached per
495
- // content hash; each probe reads exactly one segment's frame bytes.
496
- let cached;
497
- try {
498
- cached = await cachedRangedExtents(status.hash, statSize, (offset, length) => readRange(repoPath, status.hash, offset, length));
499
- }
500
- catch (err) {
501
- const message = err instanceof Error ? err.message : String(err);
502
- if (!/^(beast2 v5:|Data too short for Beast2|Invalid Beast2)/.test(message)) {
503
- throw err;
504
- }
505
- return notIndexed();
506
- }
507
- const { extents } = cached;
508
- if (!extents.selfContained) {
509
- return notIndexed();
510
- }
511
- segmentCount = extents.offsets.length;
512
- elementCount = extents.elementCount;
513
- cumulative = cached.cumulative;
514
- const segmentBlob = async (i) => {
515
- const start = extents.offsets[i];
516
- const end = i + 1 < segmentCount ? extents.offsets[i + 1] : extents.segmentsEnd;
517
- const frames = await readRange(repoPath, status.hash, start, end - start);
518
- return carveBeast2Ranged(extents, frames, i, i + 1);
519
- };
520
- fenceAt = async (i) => {
521
- if (cached.fences.has(i))
522
- return cached.fences.get(i);
523
- const fence = openBeast2PagesFor(typeValue)(await segmentBlob(i)).fence(0);
524
- cached.fences.set(i, fence);
525
- return fence;
526
- };
527
- keysAt = async (i) => {
528
- const segment = openBeast2PagesFor(typeValue)(await segmentBlob(i)).segment(0);
529
- return kind === 'Set' ? segment : segment.keys();
530
- };
531
- }
532
- else {
533
- // Fallback path (no ranged reads): buffer the blob whole under the
534
- // same cap as paging; fences are then bounded in-memory probes.
535
- if (statSize > readMaxBytes) {
536
- return pageError('dataset_too_large', `Dataset is ${Math.round(statSize / 1024 / 1024)} MB — beyond the ${Math.round(readMaxBytes / 1024 / 1024)} MB paging cap. Download it instead.`);
537
- }
538
- const data = await storage.objects.read(repoPath, status.hash);
539
- let pages;
540
- try {
541
- pages = openBeast2PagesFor(typeValue)(data);
542
- }
543
- catch {
544
- return notIndexed();
545
- }
546
- if (!pages.selfContained) {
547
- return notIndexed();
548
- }
549
- segmentCount = pages.segmentCount;
550
- elementCount = pages.elementCount;
551
- const sums = new Array(pages.counts.length);
552
- let running = 0;
553
- for (let i = 0; i < pages.counts.length; i++) {
554
- running += pages.counts[i];
555
- sums[i] = running;
556
- }
557
- cumulative = sums;
558
- fenceAt = (i) => Promise.resolve(pages.fence(i));
559
- keysAt = (i) => {
560
- const segment = pages.segment(i);
561
- return Promise.resolve(kind === 'Set' ? segment : segment.keys());
562
- };
563
- }
653
+ const cmp = compareFor(formKeyType);
654
+ const objectSize = searchHash === status.hash
655
+ ? status.size ?? (await storage.objects.stat(repoPath, status.hash)).size
656
+ : (await storage.objects.stat(repoPath, searchHash)).size;
657
+ let segments;
658
+ try {
659
+ segments = await cachedSegments(storage, repoPath, searchHash, objectSize);
660
+ }
661
+ catch (err) {
662
+ const message = err instanceof Error ? err.message : String(err);
663
+ if (!BLOB_REFUSAL.test(message))
664
+ throw err;
665
+ return pageError('dataset_not_indexed', message);
666
+ }
667
+ // The fence-search surface: the segment geometry, a fence probe, and a
668
+ // one-segment key iterator. A manifest answers every fence from its own
669
+ // entries, so a bisect over one reads no frames at all.
670
+ const segmentCount = segments.segmentCount;
671
+ const elementCount = segments.elementCount;
672
+ const cumulative = segments.cumulative;
673
+ const fenceAt = (i) => segments.fence(i);
674
+ const keysAt = async (i) => {
675
+ const segment = openBeast2PagesFor(typeValue)(await segments.segment(i)).segment(0);
676
+ return kind === 'Set' ? segment : segment.keys();
677
+ };
564
678
  // First row whose key satisfies a monotone predicate (false… then
565
679
  // true… over the canonical key order), plus that row's key. Fences
566
680
  // bound the candidate segment, so at most ONE segment decodes: when the
@@ -597,11 +711,33 @@ export async function findDatasetKey(storage, repoPath, workspace, treePath, que
597
711
  let found;
598
712
  let row;
599
713
  let count;
600
- if (query.key !== undefined) {
601
- const at = await locate((k) => cmp(k, keyValue) >= 0);
602
- found = at.hasKey && cmp(at.key, keyValue) === 0;
603
- row = at.row;
604
- count = found ? 1 : 0;
714
+ if (ranged) {
715
+ // Two bisects, one contiguous range: `[from, to)` on the flattened
716
+ // prefix. An absent bound is the collection's own end.
717
+ const lower = query.from === undefined || query.from.length === 0
718
+ ? { row: 0 }
719
+ : await locate(boundPredicate(leaves, lowerBound.values));
720
+ const upper = query.to === undefined || query.to.length === 0
721
+ ? { row: elementCount }
722
+ : await locate(boundPredicate(leaves, upperBound.values));
723
+ row = lower.row;
724
+ count = Math.max(0, upper.row - lower.row);
725
+ found = count > 0;
726
+ }
727
+ else if (query.key !== undefined) {
728
+ const at = await locate((k) => cmp(formKey(k), keyValue) >= 0);
729
+ if (indexed) {
730
+ // An index holds an entry per row under a key, so a key is a run.
731
+ const past = await locate((k) => cmp(formKey(k), keyValue) > 0);
732
+ row = at.row;
733
+ count = past.row - at.row;
734
+ found = count > 0;
735
+ }
736
+ else {
737
+ found = at.hasKey && cmp(at.key, keyValue) === 0;
738
+ row = at.row;
739
+ count = found ? 1 : 0;
740
+ }
605
741
  }
606
742
  else {
607
743
  // Range query — a String prefix, or a struct key's exact leading
@@ -609,15 +745,16 @@ export async function findDatasetKey(storage, repoPath, workspace, treePath, que
609
745
  // depend only on the leading field tuple, and struct keys compare
610
746
  // field-by-field in declaration order, so they are monotone over the
611
747
  // canonical key order; prefix-extending strings form one contiguous
612
- // interval in East (code-unit) order.
748
+ // interval in East (code-point) order.
613
749
  let lowerPred;
614
750
  let upperPred;
615
751
  const prefix = query.prefix;
616
752
  if (structMeta !== null) {
617
753
  const fieldCmps = structMeta.map((f) => compareFor(f.type));
618
754
  const lead = (k) => {
755
+ const key = formKey(k);
619
756
  for (let j = 0; j < fieldValues.length; j++) {
620
- const c = fieldCmps[j](k[structMeta[j].name], fieldValues[j]);
757
+ const c = fieldCmps[j](key[structMeta[j].name], fieldValues[j]);
621
758
  if (c !== 0)
622
759
  return c;
623
760
  }
@@ -633,13 +770,13 @@ export async function findDatasetKey(storage, repoPath, workspace, treePath, que
633
770
  const prefixCmp = fieldCmps[prefixIdx];
634
771
  lowerPred = (k) => {
635
772
  const c = lead(k);
636
- return c !== 0 ? c > 0 : prefixCmp(k[prefixName], prefix) >= 0;
773
+ return c !== 0 ? c > 0 : prefixCmp(formKey(k)[prefixName], prefix) >= 0;
637
774
  };
638
775
  upperPred = (k) => {
639
776
  const c = lead(k);
640
777
  if (c !== 0)
641
778
  return c > 0;
642
- const field = k[prefixName];
779
+ const field = formKey(k)[prefixName];
643
780
  return prefixCmp(field, prefix) > 0 && !field.startsWith(prefix);
644
781
  };
645
782
  }
@@ -647,8 +784,8 @@ export async function findDatasetKey(storage, repoPath, workspace, treePath, que
647
784
  else {
648
785
  // Scalar String keys — validation guarantees the prefix is set here.
649
786
  const scalarPrefix = prefix;
650
- lowerPred = (k) => cmp(k, scalarPrefix) >= 0;
651
- upperPred = (k) => cmp(k, scalarPrefix) > 0 && !k.startsWith(scalarPrefix);
787
+ lowerPred = (k) => cmp(formKey(k), scalarPrefix) >= 0;
788
+ upperPred = (k) => cmp(formKey(k), scalarPrefix) > 0 && !formKey(k).startsWith(scalarPrefix);
652
789
  }
653
790
  const lower = await locate(lowerPred);
654
791
  const upper = await locate(upperPred);
@@ -672,16 +809,20 @@ export async function findDatasetKey(storage, repoPath, workspace, treePath, que
672
809
  }
673
810
  }
674
811
  /**
675
- * Set dataset value from raw BEAST2 bytes.
812
+ * Set a dataset value from raw BEAST2 bytes, read as they arrive.
813
+ *
814
+ * @remarks
815
+ * The body's type is read from its head and checked against the dataset's. A
816
+ * collection then goes into the store through its door a segment of the body
817
+ * at a time; any other value is collected and decoded whole, so the handler
818
+ * holds it.
676
819
  */
677
820
  export async function setDataset(storage, repoPath, workspace, treePath, body) {
678
821
  try {
679
822
  if (treePath.length === 0) {
680
823
  return sendError(NullType, errorToVariant(new Error('Path required for set')));
681
824
  }
682
- // Body is raw BEAST2 - decode to get type and value
683
- const { type, value } = decodeBeast2(body);
684
- await workspaceSetDataset(storage, repoPath, workspace, treePath, value, type);
825
+ await workspaceSetDatasetBytes(storage, repoPath, workspace, treePath, body);
685
826
  return sendSuccess(NullType, null);
686
827
  }
687
828
  catch (err) {
@@ -736,7 +877,12 @@ export async function getDatasetStatus(storage, repoPath, workspace, treePath) {
736
877
  type: typeValue,
737
878
  refType: result.refType,
738
879
  hash: result.hash ? some(result.hash) : none,
739
- size: result.size !== null ? some(BigInt(result.size)) : none,
880
+ // What the dataset costs in the store. For a collection held as a
881
+ // segment manifest that is the segments plus the manifest; the manifest
882
+ // object alone is a few dozen bytes per segment whatever the value
883
+ // weighs, and reporting that would say a 10 MiB dataset is 12 KiB.
884
+ size: result.storedBytes != null ? some(BigInt(result.storedBytes))
885
+ : result.size !== null ? some(BigInt(result.size)) : none,
740
886
  segments: result.segments != null ? some(BigInt(result.segments)) : none,
741
887
  rows: result.rows != null ? some(BigInt(result.rows)) : none,
742
888
  };