@elaraai/e3-api-server 1.0.79 → 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.
- package/README.md +83 -19
- package/dist/src/beast2.js +1 -1
- package/dist/src/beast2.js.map +1 -1
- package/dist/src/cli.js +13 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +8 -1
- package/dist/src/errors.js.map +1 -1
- package/dist/src/handlers/dataflow.d.ts +58 -11
- package/dist/src/handlers/dataflow.d.ts.map +1 -1
- package/dist/src/handlers/dataflow.js +120 -48
- package/dist/src/handlers/dataflow.js.map +1 -1
- package/dist/src/handlers/dataflow.spec.d.ts +6 -0
- package/dist/src/handlers/dataflow.spec.d.ts.map +1 -0
- package/dist/src/handlers/dataflow.spec.js +106 -0
- package/dist/src/handlers/dataflow.spec.js.map +1 -0
- package/dist/src/handlers/datasets.d.ts +60 -34
- package/dist/src/handlers/datasets.d.ts.map +1 -1
- package/dist/src/handlers/datasets.js +452 -306
- package/dist/src/handlers/datasets.js.map +1 -1
- package/dist/src/handlers/datasets.spec.js +338 -119
- package/dist/src/handlers/datasets.spec.js.map +1 -1
- package/dist/src/handlers/functions.d.ts.map +1 -1
- package/dist/src/handlers/functions.js +10 -8
- package/dist/src/handlers/functions.js.map +1 -1
- package/dist/src/handlers/functions.spec.js +2 -2
- package/dist/src/handlers/functions.spec.js.map +1 -1
- package/dist/src/handlers/index.d.ts +2 -2
- package/dist/src/handlers/index.d.ts.map +1 -1
- package/dist/src/handlers/index.js +2 -2
- package/dist/src/handlers/index.js.map +1 -1
- package/dist/src/handlers/records.d.ts.map +1 -1
- package/dist/src/handlers/records.js +15 -12
- package/dist/src/handlers/records.js.map +1 -1
- package/dist/src/handlers/records.spec.js +9 -5
- package/dist/src/handlers/records.spec.js.map +1 -1
- package/dist/src/handlers/repository.d.ts +59 -10
- package/dist/src/handlers/repository.d.ts.map +1 -1
- package/dist/src/handlers/repository.js +147 -40
- package/dist/src/handlers/repository.js.map +1 -1
- package/dist/src/handlers/tasks.d.ts +2 -1
- package/dist/src/handlers/tasks.d.ts.map +1 -1
- package/dist/src/handlers/tasks.js +31 -8
- package/dist/src/handlers/tasks.js.map +1 -1
- package/dist/src/handlers/tasks.spec.d.ts +6 -0
- package/dist/src/handlers/tasks.spec.d.ts.map +1 -0
- package/dist/src/handlers/tasks.spec.js +64 -0
- package/dist/src/handlers/tasks.spec.js.map +1 -0
- package/dist/src/handlers/workspaces.d.ts +44 -6
- package/dist/src/handlers/workspaces.d.ts.map +1 -1
- package/dist/src/handlers/workspaces.js +74 -40
- package/dist/src/handlers/workspaces.js.map +1 -1
- package/dist/src/handlers/workspaces.spec.d.ts +6 -0
- package/dist/src/handlers/workspaces.spec.d.ts.map +1 -0
- package/dist/src/handlers/workspaces.spec.js +164 -0
- package/dist/src/handlers/workspaces.spec.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/local-dataflow.d.ts +14 -0
- package/dist/src/local-dataflow.d.ts.map +1 -0
- package/dist/src/local-dataflow.js +41 -0
- package/dist/src/local-dataflow.js.map +1 -0
- package/dist/src/middleware/repository.d.ts +39 -0
- package/dist/src/middleware/repository.d.ts.map +1 -0
- package/dist/src/middleware/repository.js +77 -0
- package/dist/src/middleware/repository.js.map +1 -0
- package/dist/src/routes/data.d.ts +2 -2
- package/dist/src/routes/data.d.ts.map +1 -1
- package/dist/src/routes/data.js +22 -45
- package/dist/src/routes/data.js.map +1 -1
- package/dist/src/routes/datasets.d.ts +0 -3
- package/dist/src/routes/datasets.d.ts.map +1 -1
- package/dist/src/routes/datasets.js +40 -8
- package/dist/src/routes/datasets.js.map +1 -1
- package/dist/src/routes/executions.d.ts +37 -2
- package/dist/src/routes/executions.d.ts.map +1 -1
- package/dist/src/routes/executions.js +21 -12
- package/dist/src/routes/executions.js.map +1 -1
- package/dist/src/routes/index.d.ts +2 -1
- package/dist/src/routes/index.d.ts.map +1 -1
- package/dist/src/routes/index.js +1 -0
- package/dist/src/routes/index.js.map +1 -1
- package/dist/src/routes/objects.d.ts +16 -2
- package/dist/src/routes/objects.d.ts.map +1 -1
- package/dist/src/routes/objects.js +34 -3
- package/dist/src/routes/objects.js.map +1 -1
- package/dist/src/routes/repositories.d.ts +26 -0
- package/dist/src/routes/repositories.d.ts.map +1 -0
- package/dist/src/routes/repositories.js +58 -0
- package/dist/src/routes/repositories.js.map +1 -0
- package/dist/src/routes/repositories.spec.d.ts +6 -0
- package/dist/src/routes/repositories.spec.d.ts.map +1 -0
- package/dist/src/routes/repositories.spec.js +77 -0
- package/dist/src/routes/repositories.spec.js.map +1 -0
- package/dist/src/routes/repository.d.ts +12 -2
- package/dist/src/routes/repository.d.ts.map +1 -1
- package/dist/src/routes/repository.js +24 -9
- package/dist/src/routes/repository.js.map +1 -1
- package/dist/src/routes/transfer.d.ts +11 -9
- package/dist/src/routes/transfer.d.ts.map +1 -1
- package/dist/src/routes/transfer.js +90 -115
- package/dist/src/routes/transfer.js.map +1 -1
- package/dist/src/routes/workspaces.d.ts +14 -1
- package/dist/src/routes/workspaces.d.ts.map +1 -1
- package/dist/src/routes/workspaces.js +57 -46
- package/dist/src/routes/workspaces.js.map +1 -1
- package/dist/src/server.d.ts +18 -6
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +55 -182
- package/dist/src/server.js.map +1 -1
- package/dist/src/server.spec.d.ts +6 -0
- package/dist/src/server.spec.d.ts.map +1 -0
- package/dist/src/server.spec.js +59 -0
- package/dist/src/server.spec.js.map +1 -0
- package/dist/src/types.d.ts +200 -16
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +5 -2
- package/dist/src/types.js.map +1 -1
- package/package.json +5 -5
- package/dist/src/async-operation-state.d.ts +0 -37
- package/dist/src/async-operation-state.d.ts.map +0 -1
- package/dist/src/async-operation-state.js +0 -118
- package/dist/src/async-operation-state.js.map +0 -1
- package/dist/src/orchestrator-manager.d.ts +0 -45
- package/dist/src/orchestrator-manager.d.ts.map +0 -1
- package/dist/src/orchestrator-manager.js +0 -150
- 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,
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
-
*
|
|
28
|
-
* that
|
|
29
|
-
*
|
|
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 >
|
|
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
|
-
|
|
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
|
|
108
|
-
const
|
|
109
|
-
let
|
|
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
|
|
113
|
-
while (
|
|
114
|
-
(
|
|
115
|
-
const oldest =
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
/**
|
|
121
|
-
*
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
265
|
+
// using the dataset's average element size, so pages stay bounded even for
|
|
192
266
|
// very wide rows.
|
|
193
|
-
const effectiveLimit = (
|
|
194
|
-
const avgBytes = totalElements > 0 ?
|
|
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 = (
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
from
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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(
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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(
|
|
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
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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;
|
|
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
|
-
:
|
|
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;
|
|
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(
|
|
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(
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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 (
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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-
|
|
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](
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|