@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
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, it } from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
|
-
import { ArrayType, DictType, IntegerType, SetType, StringType, StructType, decodeBeast2For, encodeBeast2For, encodeBeast2PagedFor, equalFor, none, toEastTypeValue, variant, } from '@elaraai/east';
|
|
8
|
-
import {
|
|
9
|
-
import { InMemoryStorage } from '@elaraai/e3-core/test';
|
|
10
|
-
import { PackageObjectType,
|
|
11
|
-
import {
|
|
7
|
+
import { ArrayType, DictType, IntegerType, NullType, RUN_MAX_BYTES, SetType, SortedMap, StringType, StructType, compareFor, decodeBeast2For, encodeBeast2For, encodeBeast2PagedFor, equalFor, none, some, toEastTypeValue, variant, } from '@elaraai/east';
|
|
8
|
+
import { computeHash, datasetWrite, InMemoryTransferBackend, writeRecordState } from '@elaraai/e3-core';
|
|
9
|
+
import { InMemoryStorage, encodeInSegmentsOf, storeSegmentsOf } from '@elaraai/e3-core/test';
|
|
10
|
+
import { BEAST2_CONTENT_TYPE, PackageObjectType, RecordIndexObjectType, WorkspaceRecordType, decodeCollectionManifest, indexCollectionType, indexWindowType, } from '@elaraai/e3-types';
|
|
11
|
+
import { ResponseType } from '../types.js';
|
|
12
|
+
import { findDatasetKey, getDataset, getDatasetPage, setDataset } from './datasets.js';
|
|
12
13
|
/**
|
|
13
14
|
* ~`byteLength` bytes of high-entropy ASCII, deterministic across runs.
|
|
14
15
|
*
|
|
@@ -139,6 +140,66 @@ describe('getDataset', () => {
|
|
|
139
140
|
assert.equal(response.headers.get('Content-Type'), BEAST2_CONTENT_TYPE);
|
|
140
141
|
assert.equal(response.headers.get('X-Content-SHA256'), hash);
|
|
141
142
|
});
|
|
143
|
+
it('streams a manifest-backed collection, reading each segment as the client takes it', async () => {
|
|
144
|
+
// A collection is many objects, so there is no one object to redirect a
|
|
145
|
+
// download to — and splicing it into a buffer first held the whole value
|
|
146
|
+
// (twice) in the server for every download.
|
|
147
|
+
const storage = new InMemoryStorage();
|
|
148
|
+
await storage.repos.create(REPO);
|
|
149
|
+
const type = DictType(StringType, IntegerType);
|
|
150
|
+
const value = new Map(Array.from({ length: 20_000 }, (_, i) => [`k${String(i).padStart(6, '0')}`, BigInt(i)]));
|
|
151
|
+
const hash = await datasetWrite(storage, REPO, value, type);
|
|
152
|
+
const segments = decodeCollectionManifest(await storage.objects.read(REPO, hash)).entries.map((entry) => entry.hash);
|
|
153
|
+
assert.ok(segments.length > 3, `the value spans segments, got ${segments.length}`);
|
|
154
|
+
await storage.datasets.write(REPO, WS, 'inputs/lookup', variant('value', { hash, versions: new Map() }));
|
|
155
|
+
const objects = storage.objects;
|
|
156
|
+
const read = objects.read.bind(objects);
|
|
157
|
+
let segmentReads = 0;
|
|
158
|
+
objects.read = (repo, object) => {
|
|
159
|
+
if (segments.includes(object))
|
|
160
|
+
segmentReads++;
|
|
161
|
+
return read(repo, object);
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
const response = await getDataset(storage, REPO, WS, [variant('field', 'inputs'), variant('field', 'lookup')]);
|
|
165
|
+
assert.equal(response.status, 200);
|
|
166
|
+
assert.equal(response.headers.get('Content-Type'), BEAST2_CONTENT_TYPE);
|
|
167
|
+
assert.equal(segmentReads, 0, 'nothing is read before the client takes the body');
|
|
168
|
+
const decoded = decodeBeast2For(type)(new Uint8Array(await response.arrayBuffer()));
|
|
169
|
+
assert.equal(decoded.size, 20_000);
|
|
170
|
+
assert.equal(decoded.get('k019999'), 19999n);
|
|
171
|
+
assert.equal(segmentReads, segments.length, 'each segment is read once, as its bytes are sent');
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
objects.read = read;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
it('answers a caller asking for segments with the manifest to download, and any other value as before', async () => {
|
|
178
|
+
const storage = new InMemoryStorage();
|
|
179
|
+
await storage.repos.create(REPO);
|
|
180
|
+
const type = DictType(StringType, IntegerType);
|
|
181
|
+
const hash = await datasetWrite(storage, REPO, new Map([['a', 1n], ['b', 2n]]), type);
|
|
182
|
+
await storage.datasets.write(REPO, WS, 'inputs/lookup', variant('value', { hash, versions: new Map() }));
|
|
183
|
+
const small = await storage.objects.write(REPO, encodeBeast2For(StringType)('small value'));
|
|
184
|
+
await storage.datasets.write(REPO, WS, 'inputs/small', variant('value', { hash: small, versions: new Map() }));
|
|
185
|
+
const response = await getDataset(storage, REPO, WS, [variant('field', 'inputs'), variant('field', 'lookup')], undefined, undefined, undefined, true);
|
|
186
|
+
assert.equal(response.status, 200);
|
|
187
|
+
assert.equal(response.headers.get('Content-Type'), 'application/json');
|
|
188
|
+
assert.equal(response.headers.get('X-Content-SHA256'), hash);
|
|
189
|
+
assert.deepEqual(await response.json(), { manifest: hash });
|
|
190
|
+
const inline = await getDataset(storage, REPO, WS, [variant('field', 'inputs'), variant('field', 'small')], undefined, undefined, undefined, true);
|
|
191
|
+
assert.equal(inline.headers.get('Content-Type'), BEAST2_CONTENT_TYPE);
|
|
192
|
+
assert.equal(computeHash(new Uint8Array(await inline.arrayBuffer())), small);
|
|
193
|
+
});
|
|
194
|
+
it('names an indexed record\'s primary as the manifest to download', async () => {
|
|
195
|
+
const storage = new InMemoryStorage();
|
|
196
|
+
const { rows } = await seedIndexedRecord(storage, 500);
|
|
197
|
+
const state = await storage.datasets.read(REPO, WS, 'records/plans');
|
|
198
|
+
assert.ok(state?.type === 'value');
|
|
199
|
+
const response = await getDataset(storage, REPO, WS, plansPath, undefined, undefined, undefined, true);
|
|
200
|
+
assert.equal(response.headers.get('X-Content-SHA256'), state.value.hash, 'the dataset is the record\'s state');
|
|
201
|
+
assert.deepEqual(await response.json(), { manifest: await datasetWrite(storage, REPO, rows, PlansType) });
|
|
202
|
+
});
|
|
142
203
|
it('returns 404 JSON error for null dataset', async () => {
|
|
143
204
|
const storage = new InMemoryStorage();
|
|
144
205
|
await storage.repos.create(REPO);
|
|
@@ -179,16 +240,18 @@ const rowsPath = [variant('field', 'inputs'), variant('field', 'rows')];
|
|
|
179
240
|
function makeRows(n) {
|
|
180
241
|
return Array.from({ length: n }, (_, i) => ({ id: BigInt(i), name: `row-${i % 97}` }));
|
|
181
242
|
}
|
|
182
|
-
/** Seeds a deployed workspace whose `.inputs.<name>` dataset holds `blob
|
|
183
|
-
*
|
|
184
|
-
|
|
243
|
+
/** Seeds a deployed workspace whose `.inputs.<name>` dataset holds `blob`'s
|
|
244
|
+
* segments, as they stand, under a manifest — or, `bare`, the blob itself, as
|
|
245
|
+
* an older e3 stored a collection. Reseeding the same storage replaces the
|
|
246
|
+
* workspace's package wholesale. */
|
|
247
|
+
async function seedDataset(storage, blob, name, type, options = {}) {
|
|
185
248
|
try {
|
|
186
249
|
await storage.repos.create(REPO);
|
|
187
250
|
}
|
|
188
251
|
catch {
|
|
189
252
|
// Already created by an earlier seed into this storage.
|
|
190
253
|
}
|
|
191
|
-
const hash = await storage.objects.write(REPO, blob);
|
|
254
|
+
const hash = options.bare === true ? await storage.objects.write(REPO, blob) : await storeSegmentsOf(storage, REPO, blob);
|
|
192
255
|
const structure = variant('struct', new Map([
|
|
193
256
|
['inputs', variant('struct', new Map([
|
|
194
257
|
[name, variant('value', { type: toEastTypeValue(type), writable: true })],
|
|
@@ -200,9 +263,9 @@ async function seedDataset(storage, blob, name, type) {
|
|
|
200
263
|
functions: new Map(),
|
|
201
264
|
records: new Map(), sources: new Map(),
|
|
202
265
|
}));
|
|
203
|
-
await storage.refs.workspaceWrite(REPO, WS, encodeBeast2For(
|
|
266
|
+
await storage.refs.workspaceWrite(REPO, WS, encodeBeast2For(WorkspaceRecordType)(some({
|
|
204
267
|
packageName: 'pages', packageVersion: '1.0.0', packageHash: pkgHash, deployedAt: new Date(0), currentRunId: none,
|
|
205
|
-
}));
|
|
268
|
+
})));
|
|
206
269
|
await storage.datasets.write(REPO, WS, `inputs/${name}`, variant('value', { hash, versions: new Map() }));
|
|
207
270
|
return hash;
|
|
208
271
|
}
|
|
@@ -210,83 +273,113 @@ async function seedDataset(storage, blob, name, type) {
|
|
|
210
273
|
async function seedRowsDataset(storage, blob) {
|
|
211
274
|
return seedDataset(storage, blob, 'rows', RowsType);
|
|
212
275
|
}
|
|
213
|
-
/** Counts whole-object
|
|
214
|
-
* workspace/package object reads are expected and not counted
|
|
215
|
-
function spyObjectReads(storage,
|
|
276
|
+
/** Counts whole-object reads of the watched objects — a dataset's segments, or
|
|
277
|
+
* a bare blob; workspace/package object reads are expected and not counted. */
|
|
278
|
+
function spyObjectReads(storage, watched) {
|
|
216
279
|
const objects = storage.objects;
|
|
280
|
+
const hashes = new Set(watched);
|
|
217
281
|
let whole = 0;
|
|
218
|
-
let ranged = 0;
|
|
219
|
-
let calls = 0;
|
|
220
282
|
const origRead = objects.read.bind(objects);
|
|
221
|
-
const origRange = objects.readRange.bind(objects);
|
|
222
283
|
objects.read = (repo, hash) => {
|
|
223
|
-
if (hash
|
|
284
|
+
if (hashes.has(hash))
|
|
224
285
|
whole++;
|
|
225
286
|
return origRead(repo, hash);
|
|
226
287
|
};
|
|
227
|
-
|
|
228
|
-
if (hash === watchedHash) {
|
|
229
|
-
ranged += length;
|
|
230
|
-
calls++;
|
|
231
|
-
}
|
|
232
|
-
return origRange(repo, hash, offset, length);
|
|
233
|
-
};
|
|
234
|
-
return { wholeReads: () => whole, rangedBytes: () => ranged, rangedCalls: () => calls };
|
|
288
|
+
return { wholeReads: () => whole };
|
|
235
289
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
290
|
+
/** The segment objects a stored collection's manifest names. */
|
|
291
|
+
async function segmentsOf(storage, hash) {
|
|
292
|
+
return decodeCollectionManifest(await storage.objects.read(REPO, hash)).entries.map((entry) => entry.hash);
|
|
293
|
+
}
|
|
294
|
+
describe('setDataset (an upload, read as it arrives)', () => {
|
|
295
|
+
/** The body in pieces, as a request's arrives. */
|
|
296
|
+
function* pieces(bytes) {
|
|
297
|
+
for (let at = 0; at < bytes.length; at += 4096)
|
|
298
|
+
yield bytes.subarray(at, at + 4096);
|
|
299
|
+
}
|
|
300
|
+
/** The dataset's current hash. */
|
|
301
|
+
async function rowsHash(storage) {
|
|
302
|
+
const ref = await storage.datasets.read(REPO, WS, 'inputs/rows');
|
|
303
|
+
return ref?.type === 'value' ? ref.value.hash : null;
|
|
304
|
+
}
|
|
305
|
+
it('stores a collection upload as the manifest the value path writes, whatever layout the client sent', async () => {
|
|
306
|
+
const storage = new InMemoryStorage();
|
|
307
|
+
await seedRowsDataset(storage, encodeBeast2PagedFor(RowsType)(makeRows(10)));
|
|
308
|
+
const rows = makeRows(5_000);
|
|
309
|
+
const expected = await datasetWrite(storage, REPO, rows, RowsType);
|
|
310
|
+
const layouts = [
|
|
311
|
+
['batched by the client', encodeInSegmentsOf(RowsType, 100)(rows)],
|
|
312
|
+
['encoded whole', encodeBeast2For(RowsType)(rows)],
|
|
313
|
+
];
|
|
314
|
+
for (const [layout, bytes] of layouts) {
|
|
315
|
+
const response = await setDataset(storage, REPO, WS, rowsPath, pieces(bytes));
|
|
316
|
+
const answer = decodeBeast2For(ResponseType(NullType))(new Uint8Array(await response.arrayBuffer()));
|
|
317
|
+
assert.equal(answer.type, 'success', `${layout}: ${JSON.stringify(answer.value)}`);
|
|
318
|
+
assert.equal(await rowsHash(storage), expected, layout);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
it('refuses an upload of another type, and one holding a segment larger than a collection is read in, storing nothing', async () => {
|
|
322
|
+
const storage = new InMemoryStorage();
|
|
323
|
+
const seeded = await seedRowsDataset(storage, encodeBeast2PagedFor(RowsType)(makeRows(10)));
|
|
324
|
+
const objects = await storage.objects.count(REPO);
|
|
325
|
+
const drifted = await setDataset(storage, REPO, WS, rowsPath, pieces(encodeBeast2PagedFor(ArrayType(StringType))(['a'])));
|
|
326
|
+
const refusal = decodeBeast2For(ResponseType(NullType))(new Uint8Array(await drifted.arrayBuffer()));
|
|
327
|
+
assert.equal(refusal.type === 'error' ? refusal.value.type : refusal.type, 'dataset_type_mismatch');
|
|
328
|
+
// A whole-value encode is one frame; this one declares more logical bytes
|
|
329
|
+
// than the limit, and none of them follow.
|
|
330
|
+
const head = encodeBeast2For(RowsType, { codec: 'none' })([]);
|
|
331
|
+
const frameHeader = new Uint8Array(21);
|
|
332
|
+
let at = 0;
|
|
333
|
+
for (const n of [0, RUN_MAX_BYTES + 1, RUN_MAX_BYTES + 1]) {
|
|
334
|
+
let v = n;
|
|
335
|
+
for (; v >= 0x80; v = Math.floor(v / 128))
|
|
336
|
+
frameHeader[at++] = (v & 0x7f) | 0x80;
|
|
337
|
+
frameHeader[at++] = v;
|
|
338
|
+
}
|
|
339
|
+
const oversized = await setDataset(storage, REPO, WS, rowsPath, [head.subarray(0, head.length - 5), frameHeader.subarray(0, at)]);
|
|
340
|
+
const cap = decodeBeast2For(ResponseType(NullType))(new Uint8Array(await oversized.arrayBuffer()));
|
|
341
|
+
assert.ok(cap.type === 'error' && cap.value.type === 'internal'
|
|
342
|
+
&& /more than the 67108864 a collection is read in at once/.test(cap.value.value.message), JSON.stringify(cap));
|
|
343
|
+
assert.equal(await rowsHash(storage), seeded, 'the dataset keeps its value');
|
|
344
|
+
assert.equal(await storage.objects.count(REPO), objects, 'nothing was stored');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
describe('getDatasetPage (segment reads)', () => {
|
|
348
|
+
it('serves element windows from the segments they touch, and no others', async () => {
|
|
349
|
+
const storage = new InMemoryStorage();
|
|
350
|
+
const rows = makeRows(8000);
|
|
351
|
+
const hash = await seedRowsDataset(storage, encodeInSegmentsOf(RowsType, 100)(rows));
|
|
352
|
+
const spy = spyObjectReads(storage, await segmentsOf(storage, hash));
|
|
255
353
|
const response = await getDatasetPage(storage, REPO, WS, rowsPath, { offset: 900, limit: 200 });
|
|
256
354
|
assert.equal(response.status, 200);
|
|
257
355
|
assert.equal(response.headers.get('X-Total-Elements'), '8000');
|
|
258
|
-
assert.equal(response.headers.get('X-Total-Bytes'), String(blob.byteLength));
|
|
259
356
|
assert.equal(response.headers.get('X-Page-Offset'), '900');
|
|
260
357
|
assert.equal(response.headers.get('X-Page-Count'), '200');
|
|
261
358
|
const page = decodeBeast2For(RowsType)(new Uint8Array(await response.arrayBuffer()));
|
|
262
359
|
assert.ok(equalFor(RowsType)(page, rows.slice(900, 1100)), 'window equals the expected slice');
|
|
263
|
-
assert.equal(spy.wholeReads(),
|
|
264
|
-
|
|
265
|
-
// A second window on the same hash reuses the cached extents: only the
|
|
266
|
-
// window's own frame bytes are read.
|
|
267
|
-
const before = spy.rangedBytes();
|
|
360
|
+
assert.equal(spy.wholeReads(), 2, 'rows 900..1099 are segments 9 and 10, and only they are read');
|
|
361
|
+
// A second window on the same hash reads only its own segment.
|
|
268
362
|
const deep = await getDatasetPage(storage, REPO, WS, rowsPath, { offset: 7500, limit: 100 });
|
|
269
363
|
assert.equal(deep.status, 200);
|
|
270
364
|
const deepPage = decodeBeast2For(RowsType)(new Uint8Array(await deep.arrayBuffer()));
|
|
271
365
|
assert.ok(equalFor(RowsType)(deepPage, rows.slice(7500, 7600)));
|
|
272
|
-
assert.
|
|
366
|
+
assert.equal(spy.wholeReads(), 3, 'the second window reads one segment');
|
|
273
367
|
});
|
|
274
|
-
it('
|
|
368
|
+
it('clamps a window that runs past the end to the rows there are', async () => {
|
|
275
369
|
const storage = new InMemoryStorage();
|
|
276
370
|
const rows = makeRows(2500);
|
|
277
|
-
const blob =
|
|
371
|
+
const blob = encodeInSegmentsOf(RowsType, 100)(rows);
|
|
278
372
|
await seedRowsDataset(storage, blob);
|
|
279
|
-
|
|
280
|
-
const response = await getDatasetPage(storage, REPO, WS, rowsPath, { offset: 2400, limit: 1000 }, { readMaxBytes: 1024 });
|
|
373
|
+
const response = await getDatasetPage(storage, REPO, WS, rowsPath, { offset: 2400, limit: 1000 });
|
|
281
374
|
assert.equal(response.status, 200);
|
|
282
|
-
assert.equal(response.headers.get('X-Page-Count'), '100', 'tail
|
|
375
|
+
assert.equal(response.headers.get('X-Page-Count'), '100', 'the tail clamps the window');
|
|
283
376
|
const page = decodeBeast2For(RowsType)(new Uint8Array(await response.arrayBuffer()));
|
|
284
377
|
assert.ok(equalFor(RowsType)(page, rows.slice(2400)));
|
|
285
378
|
});
|
|
286
379
|
it('serves segment windows and empty past-the-end windows', async () => {
|
|
287
380
|
const storage = new InMemoryStorage();
|
|
288
381
|
const rows = makeRows(2500);
|
|
289
|
-
const blob =
|
|
382
|
+
const blob = encodeInSegmentsOf(RowsType, 100)(rows);
|
|
290
383
|
await seedRowsDataset(storage, blob);
|
|
291
384
|
const seg = await getDatasetPage(storage, REPO, WS, rowsPath, { segment: 1 });
|
|
292
385
|
assert.equal(seg.status, 200);
|
|
@@ -300,52 +393,35 @@ describe('getDatasetPage (ranged reads)', () => {
|
|
|
300
393
|
assert.equal(past.headers.get('X-Page-Offset'), '5000');
|
|
301
394
|
assert.deepEqual(decodeBeast2For(RowsType)(new Uint8Array(await past.arrayBuffer())), []);
|
|
302
395
|
});
|
|
303
|
-
it('refuses
|
|
396
|
+
it('refuses a collection an older e3 stored as one blob, naming the fix, without reading it whole', async () => {
|
|
304
397
|
const storage = new InMemoryStorage();
|
|
305
|
-
// Whole-value v5 encode: no index — predates the stored-segmented contract.
|
|
306
398
|
const blob = encodeBeast2For(RowsType)(makeRows(50));
|
|
307
|
-
const hash = await
|
|
308
|
-
const spy = spyObjectReads(storage, hash);
|
|
399
|
+
const hash = await seedDataset(storage, blob, 'rows', RowsType, { bare: true });
|
|
400
|
+
const spy = spyObjectReads(storage, [hash]);
|
|
309
401
|
const response = await getDatasetPage(storage, REPO, WS, rowsPath, { offset: 0, limit: 10 });
|
|
310
402
|
assert.equal(response.status, 400);
|
|
311
403
|
const body = await response.json();
|
|
312
404
|
assert.equal(body.error.type, 'dataset_not_indexed');
|
|
313
|
-
assert.equal(
|
|
405
|
+
assert.equal(body.error.message, `the collection ${hash} is stored as one blob: ` +
|
|
406
|
+
'an older e3 wrote this repository — re-create it: deploy again and import its data again');
|
|
407
|
+
assert.equal(spy.wholeReads(), 0, 'the refusal comes from the head probe, not a whole read');
|
|
314
408
|
});
|
|
315
409
|
it('surfaces storage failures as errors, never as dataset_not_indexed', async () => {
|
|
316
410
|
const storage = new InMemoryStorage();
|
|
317
411
|
// Distinct row count → distinct content hash, so the module-level
|
|
318
|
-
//
|
|
412
|
+
// cache of opened datasets cannot mask the injected failure.
|
|
319
413
|
const rows = makeRows(600);
|
|
320
|
-
const blob =
|
|
414
|
+
const blob = encodeInSegmentsOf(RowsType, 100)(rows);
|
|
321
415
|
await seedRowsDataset(storage, blob);
|
|
322
|
-
// The
|
|
323
|
-
//
|
|
324
|
-
// what is really an I/O failure.
|
|
416
|
+
// The dataset is a manifest; the BACKEND fails at read time, and an I/O
|
|
417
|
+
// failure must not read as a refusal of the layout.
|
|
325
418
|
storage.objects.readRange = () => Promise.reject(new Error('injected storage failure'));
|
|
326
419
|
const response = await getDatasetPage(storage, REPO, WS, rowsPath, { offset: 0, limit: 10 });
|
|
327
420
|
assert.notEqual(response.status, 200);
|
|
328
421
|
const body = await response.json();
|
|
329
|
-
assert.notEqual(body.error.type, 'dataset_not_indexed', 'an I/O failure must not masquerade as a
|
|
422
|
+
assert.notEqual(body.error.type, 'dataset_not_indexed', 'an I/O failure must not masquerade as a refusal of the layout');
|
|
330
423
|
assert.match(body.error.message, /injected storage failure/);
|
|
331
424
|
});
|
|
332
|
-
it('falls back to whole reads — and keeps the cap — when the backend has no ranged reads', async () => {
|
|
333
|
-
const storage = new InMemoryStorage();
|
|
334
|
-
const rows = makeRows(500);
|
|
335
|
-
const blob = encodeBeast2PagedFor(RowsType, { batchSize: 100 })(rows);
|
|
336
|
-
await seedRowsDataset(storage, blob);
|
|
337
|
-
// Simulate a backend without ranged reads (e.g. a store that has not
|
|
338
|
-
// implemented the optional method yet).
|
|
339
|
-
storage.objects.readRange = undefined;
|
|
340
|
-
const capped = await getDatasetPage(storage, REPO, WS, rowsPath, { offset: 0, limit: 10 }, { readMaxBytes: 1024 });
|
|
341
|
-
assert.equal(capped.status, 400);
|
|
342
|
-
const body = await capped.json();
|
|
343
|
-
assert.equal(body.error.type, 'dataset_too_large');
|
|
344
|
-
const served = await getDatasetPage(storage, REPO, WS, rowsPath, { offset: 90, limit: 20 });
|
|
345
|
-
assert.equal(served.status, 200);
|
|
346
|
-
const page = decodeBeast2For(RowsType)(new Uint8Array(await served.arrayBuffer()));
|
|
347
|
-
assert.ok(equalFor(RowsType)(page, rows.slice(90, 110)), 'the fallback still pages correctly');
|
|
348
|
-
});
|
|
349
425
|
});
|
|
350
426
|
const LookupType = DictType(StringType, IntegerType);
|
|
351
427
|
const IntLookupType = DictType(IntegerType, StringType);
|
|
@@ -360,11 +436,11 @@ async function findJson(response) {
|
|
|
360
436
|
return await response.json();
|
|
361
437
|
}
|
|
362
438
|
describe('findDatasetKey', () => {
|
|
363
|
-
//
|
|
364
|
-
//
|
|
439
|
+
// Segments of 97 put boundaries at 97, 194, … — deliberately off the
|
|
440
|
+
// decimal key grid, so prefix ranges span segment boundaries.
|
|
365
441
|
it('locates exact keys by fence bisect, including segment-fence rows and misses', async () => {
|
|
366
442
|
const storage = new InMemoryStorage();
|
|
367
|
-
const blob =
|
|
443
|
+
const blob = encodeInSegmentsOf(LookupType, 97)(lookupOf(2500));
|
|
368
444
|
await seedDataset(storage, blob, 'lookup', LookupType);
|
|
369
445
|
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, lookupPath, { key: '"k0150"' })), { found: true, row: 150, count: 1 });
|
|
370
446
|
// A key that IS a segment fence — row 97 opens segment 1.
|
|
@@ -377,7 +453,7 @@ describe('findDatasetKey', () => {
|
|
|
377
453
|
});
|
|
378
454
|
it('prefix ranges are contiguous rows, spanning segment boundaries', async () => {
|
|
379
455
|
const storage = new InMemoryStorage();
|
|
380
|
-
const blob =
|
|
456
|
+
const blob = encodeInSegmentsOf(LookupType, 97)(lookupOf(2500));
|
|
381
457
|
await seedDataset(storage, blob, 'lookup', LookupType);
|
|
382
458
|
// k01__ covers rows 100..199 — across the boundary at row 194.
|
|
383
459
|
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, lookupPath, { prefix: 'k01' })), { found: true, row: 100, count: 100 });
|
|
@@ -387,25 +463,22 @@ describe('findDatasetKey', () => {
|
|
|
387
463
|
});
|
|
388
464
|
it('decodes at most the touched segments: one for exact, two for a spanning prefix', async () => {
|
|
389
465
|
const storage = new InMemoryStorage();
|
|
390
|
-
const blob =
|
|
466
|
+
const blob = encodeInSegmentsOf(LookupType, 97)(lookupOf(2500));
|
|
391
467
|
const hash = await seedDataset(storage, blob, 'lookup', LookupType);
|
|
392
|
-
// Warm the
|
|
393
|
-
// segment decode is
|
|
468
|
+
// Warm the cache of opened datasets, then count reads: the fences come
|
|
469
|
+
// with the manifest, and each segment decode is one read of its object.
|
|
394
470
|
await findDatasetKey(storage, REPO, WS, lookupPath, { key: '"k0150"' });
|
|
395
471
|
await findDatasetKey(storage, REPO, WS, lookupPath, { prefix: 'k01' });
|
|
396
|
-
const spy = spyObjectReads(storage, hash);
|
|
472
|
+
const spy = spyObjectReads(storage, await segmentsOf(storage, hash));
|
|
397
473
|
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, lookupPath, { key: '"k0150"' })), { found: true, row: 150, count: 1 });
|
|
398
|
-
assert.equal(spy.wholeReads(),
|
|
399
|
-
assert.equal(spy.rangedCalls(), 1, 'an exact find decodes exactly one segment');
|
|
400
|
-
const before = spy.rangedCalls();
|
|
474
|
+
assert.equal(spy.wholeReads(), 1, 'an exact find decodes exactly one segment');
|
|
401
475
|
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, lookupPath, { prefix: 'k01' })), { found: true, row: 100, count: 100 });
|
|
402
|
-
assert.equal(spy.
|
|
403
|
-
assert.equal(spy.wholeReads(), 0);
|
|
476
|
+
assert.equal(spy.wholeReads(), 3, 'a boundary-spanning prefix decodes exactly the two edge segments');
|
|
404
477
|
});
|
|
405
478
|
it('scalar keys parse as .east literals; bad literals are key_parse_error', async () => {
|
|
406
479
|
const storage = new InMemoryStorage();
|
|
407
480
|
const entries = new Map(Array.from({ length: 500 }, (_, i) => [BigInt(i), `v${i}`]));
|
|
408
|
-
const blob =
|
|
481
|
+
const blob = encodeInSegmentsOf(IntLookupType, 97)(entries);
|
|
409
482
|
await seedDataset(storage, blob, 'lookup', IntLookupType);
|
|
410
483
|
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, lookupPath, { key: '42' })), { found: true, row: 42, count: 1 });
|
|
411
484
|
const bad = await findDatasetKey(storage, REPO, WS, lookupPath, { key: '"abc"' });
|
|
@@ -421,14 +494,14 @@ describe('findDatasetKey', () => {
|
|
|
421
494
|
it('Set datasets search elements like dict keys', async () => {
|
|
422
495
|
const storage = new InMemoryStorage();
|
|
423
496
|
const tags = new Set(Array.from({ length: 300 }, (_, i) => `k${String(i).padStart(4, '0')}`));
|
|
424
|
-
const blob =
|
|
497
|
+
const blob = encodeInSegmentsOf(TagsType, 97)(tags);
|
|
425
498
|
await seedDataset(storage, blob, 'tags', TagsType);
|
|
426
499
|
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, tagsPath, { key: '"k0123"' })), { found: true, row: 123, count: 1 });
|
|
427
500
|
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, tagsPath, { prefix: 'k00' })), { found: true, row: 0, count: 100 });
|
|
428
501
|
});
|
|
429
502
|
it('refuses non-keyed datasets and malformed queries', async () => {
|
|
430
503
|
const storage = new InMemoryStorage();
|
|
431
|
-
const blob =
|
|
504
|
+
const blob = encodeInSegmentsOf(RowsType, 100)(makeRows(50));
|
|
432
505
|
await seedDataset(storage, blob, 'rows', RowsType);
|
|
433
506
|
const arr = await findDatasetKey(storage, REPO, WS, rowsPath, { key: '(id=1, name="x")' });
|
|
434
507
|
assert.equal(arr.status, 400);
|
|
@@ -454,24 +527,17 @@ describe('findDatasetKey', () => {
|
|
|
454
527
|
assert.equal(stale.status, 409);
|
|
455
528
|
assert.equal(stale.headers.get('X-Content-SHA256'), hash);
|
|
456
529
|
});
|
|
457
|
-
it('refuses
|
|
530
|
+
it('refuses a collection an older e3 stored as one blob, naming the fix, without reading it whole', async () => {
|
|
458
531
|
const storage = new InMemoryStorage();
|
|
459
|
-
// Whole-value v5 encode: no index — predates the stored-segmented contract.
|
|
460
532
|
const raw = encodeBeast2For(LookupType)(lookupOf(60));
|
|
461
|
-
const hash = await seedDataset(storage, raw, 'lookup', LookupType);
|
|
462
|
-
const spy = spyObjectReads(storage, hash);
|
|
533
|
+
const hash = await seedDataset(storage, raw, 'lookup', LookupType, { bare: true });
|
|
534
|
+
const spy = spyObjectReads(storage, [hash]);
|
|
463
535
|
const refused = await findDatasetKey(storage, REPO, WS, lookupPath, { key: '"k0001"' });
|
|
464
536
|
assert.equal(refused.status, 400);
|
|
465
|
-
|
|
466
|
-
assert.equal(
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
await seedDataset(fallback, blob, 'lookup', LookupType);
|
|
470
|
-
fallback.objects.readRange = undefined;
|
|
471
|
-
assert.deepEqual(await findJson(await findDatasetKey(fallback, REPO, WS, lookupPath, { key: '"k0123"' })), { found: true, row: 123, count: 1 });
|
|
472
|
-
const capped = await findDatasetKey(fallback, REPO, WS, lookupPath, { key: '"k0123"' }, { readMaxBytes: 64 });
|
|
473
|
-
assert.equal(capped.status, 400);
|
|
474
|
-
assert.equal((await capped.json()).error.type, 'dataset_too_large');
|
|
537
|
+
const body = await refused.json();
|
|
538
|
+
assert.equal(body.error.type, 'dataset_not_indexed');
|
|
539
|
+
assert.match(body.error.message, /is stored as one blob: an older e3 wrote this repository — re-create it/);
|
|
540
|
+
assert.equal(spy.wholeReads(), 0, 'the refusal comes from the head probe, not a whole read');
|
|
475
541
|
});
|
|
476
542
|
it('an empty collection reports no match at row 0', async () => {
|
|
477
543
|
const storage = new InMemoryStorage();
|
|
@@ -502,8 +568,8 @@ function machinesOf() {
|
|
|
502
568
|
describe('findDatasetKey — struct keys', () => {
|
|
503
569
|
it('leading fields and field prefixes address contiguous tuple ranges', async () => {
|
|
504
570
|
const storage = new InMemoryStorage();
|
|
505
|
-
//
|
|
506
|
-
const blob =
|
|
571
|
+
// Segments of 23 put boundaries all over the tuple ranges.
|
|
572
|
+
const blob = encodeInSegmentsOf(MachinesType, 23)(machinesOf());
|
|
507
573
|
await seedDataset(storage, blob, 'machines', MachinesType);
|
|
508
574
|
// A prefix alone types ahead on the FIRST field.
|
|
509
575
|
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, machinesPath, { prefix: 'p' })), { found: true, row: 100, count: 50 });
|
|
@@ -520,7 +586,7 @@ describe('findDatasetKey — struct keys', () => {
|
|
|
520
586
|
});
|
|
521
587
|
it('refuses malformed struct queries with typed errors', async () => {
|
|
522
588
|
const storage = new InMemoryStorage();
|
|
523
|
-
const blob =
|
|
589
|
+
const blob = encodeInSegmentsOf(MachinesType, 23)(machinesOf());
|
|
524
590
|
await seedDataset(storage, blob, 'machines', MachinesType);
|
|
525
591
|
const intPrefix = await findDatasetKey(storage, REPO, WS, machinesPath, { fields: ['"press"', '"L2"'], prefix: '7' });
|
|
526
592
|
assert.equal(intPrefix.status, 400);
|
|
@@ -543,4 +609,157 @@ describe('findDatasetKey — struct keys', () => {
|
|
|
543
609
|
assert.match((await fieldsOnScalar.json()).error.message, /Struct keys/);
|
|
544
610
|
});
|
|
545
611
|
});
|
|
612
|
+
const PlanRowType = StructType({ due: IntegerType, title: StringType });
|
|
613
|
+
const PlansType = DictType(StringType, PlanRowType);
|
|
614
|
+
/** The `by_status` index's key, which many rows share. */
|
|
615
|
+
const PlanStatusKeyType = StructType({ status: StringType, week: IntegerType });
|
|
616
|
+
const plansPath = [variant('field', 'records'), variant('field', 'plans')];
|
|
617
|
+
/**
|
|
618
|
+
* Seeds a deployed workspace holding an indexed record: `n` plans keyed
|
|
619
|
+
* `p-000000…`, and two indexes. `by_due`'s order scatters them — `due` is a
|
|
620
|
+
* permutation of the rows, so consecutive index entries land in unrelated
|
|
621
|
+
* primary segments. `by_status` keys each row by a status and a week its
|
|
622
|
+
* `due` picks, so a key is a run of rows: for `n` a multiple of 30, a third
|
|
623
|
+
* of them per status, `done` then `late` then `open`, and a tenth of a
|
|
624
|
+
* status's per week. An index object carries only what a read resolves (its
|
|
625
|
+
* key and projection types); no program runs here.
|
|
626
|
+
*/
|
|
627
|
+
async function seedIndexedRecord(storage, n) {
|
|
628
|
+
await storage.repos.create(REPO);
|
|
629
|
+
const rows = new SortedMap(Array.from({ length: n }, (_, i) => [`p-${String(i).padStart(6, '0')}`,
|
|
630
|
+
{ due: BigInt((i * 7919) % n), title: `Plan ${i}` }]), compareFor(StringType));
|
|
631
|
+
const primary = await datasetWrite(storage, REPO, rows, PlansType);
|
|
632
|
+
const EntryType = StructType({ ik: IntegerType, k: StringType });
|
|
633
|
+
const entries = new SortedMap([...rows].map(([k, row]) => [{ ik: row.due, k }, row.title]), compareFor(EntryType));
|
|
634
|
+
const index = await datasetWrite(storage, REPO, entries, indexCollectionType(StringType, IntegerType, StringType));
|
|
635
|
+
const declaration = await storage.objects.write(REPO, encodeBeast2For(RecordIndexObjectType)({
|
|
636
|
+
keyIr: '0'.repeat(64), multi: false, valueIr: some('0'.repeat(64)),
|
|
637
|
+
keyType: toEastTypeValue(IntegerType), valueType: toEastTypeValue(StringType),
|
|
638
|
+
buildIr: '0'.repeat(64), runner: variant('east_node', { platforms: [] }),
|
|
639
|
+
}));
|
|
640
|
+
const StatusEntryType = StructType({ ik: PlanStatusKeyType, k: StringType });
|
|
641
|
+
const statusEntries = new SortedMap([...rows].map(([k, row]) => [
|
|
642
|
+
{ ik: { status: ['done', 'late', 'open'][Number(row.due % 3n)], week: row.due % 10n }, k }, row.title,
|
|
643
|
+
]), compareFor(StatusEntryType));
|
|
644
|
+
const statusIndex = await datasetWrite(storage, REPO, statusEntries, indexCollectionType(StringType, PlanStatusKeyType, StringType));
|
|
645
|
+
const statusDeclaration = await storage.objects.write(REPO, encodeBeast2For(RecordIndexObjectType)({
|
|
646
|
+
keyIr: '0'.repeat(64), multi: false, valueIr: some('0'.repeat(64)),
|
|
647
|
+
keyType: toEastTypeValue(PlanStatusKeyType), valueType: toEastTypeValue(StringType),
|
|
648
|
+
buildIr: '0'.repeat(64), runner: variant('east_node', { platforms: [] }),
|
|
649
|
+
}));
|
|
650
|
+
const state = await writeRecordState(storage, REPO, {
|
|
651
|
+
primary, indexes: new Map([
|
|
652
|
+
['by_due', { manifest: index, index: declaration }],
|
|
653
|
+
['by_status', { manifest: statusIndex, index: statusDeclaration }],
|
|
654
|
+
]),
|
|
655
|
+
});
|
|
656
|
+
const structure = variant('struct', new Map([
|
|
657
|
+
['records', variant('struct', new Map([
|
|
658
|
+
['plans', variant('value', { type: toEastTypeValue(PlansType), writable: false })],
|
|
659
|
+
]))],
|
|
660
|
+
]));
|
|
661
|
+
const pkgHash = await storage.objects.write(REPO, encodeBeast2For(PackageObjectType)({
|
|
662
|
+
tasks: new Map(),
|
|
663
|
+
data: { structure, refs: new Map([['records/plans', variant('value', { hash: state, versions: new Map() })]]) },
|
|
664
|
+
functions: new Map(),
|
|
665
|
+
records: new Map(), sources: new Map(),
|
|
666
|
+
}));
|
|
667
|
+
await storage.refs.workspaceWrite(REPO, WS, encodeBeast2For(WorkspaceRecordType)(some({
|
|
668
|
+
packageName: 'plans', packageVersion: '1.0.0', packageHash: pkgHash, deployedAt: new Date(0), currentRunId: none,
|
|
669
|
+
})));
|
|
670
|
+
await storage.datasets.write(REPO, WS, 'records/plans', variant('value', { hash: state, versions: new Map() }));
|
|
671
|
+
const manifest = decodeCollectionManifest(await storage.objects.read(REPO, primary));
|
|
672
|
+
return { rows, primarySegments: manifest.entries.map((entry) => entry.hash), statusSegments: await segmentsOf(storage, statusIndex) };
|
|
673
|
+
}
|
|
674
|
+
describe('getDatasetPage (index reads)', () => {
|
|
675
|
+
it('a joined page reads each primary segment it needs once, and serves the window whole', async () => {
|
|
676
|
+
const storage = new InMemoryStorage();
|
|
677
|
+
const { rows, primarySegments } = await seedIndexedRecord(storage, 12_000);
|
|
678
|
+
assert.ok(primarySegments.length > 3, `the record spans segments, got ${primarySegments.length}`);
|
|
679
|
+
const reads = new Map();
|
|
680
|
+
const objects = storage.objects;
|
|
681
|
+
const read = objects.read.bind(objects);
|
|
682
|
+
objects.read = (repo, hash) => {
|
|
683
|
+
if (primarySegments.includes(hash))
|
|
684
|
+
reads.set(hash, (reads.get(hash) ?? 0) + 1);
|
|
685
|
+
return read(repo, hash);
|
|
686
|
+
};
|
|
687
|
+
const response = await getDatasetPage(storage, REPO, WS, plansPath, { offset: 100, limit: 200, index: 'by_due', join: true });
|
|
688
|
+
objects.read = read;
|
|
689
|
+
assert.equal(response.status, 200, await response.clone().text());
|
|
690
|
+
// The window is the index's rows 100..299, in INDEX order, each joined to
|
|
691
|
+
// its row — never shortened to bound the join: a client concatenates
|
|
692
|
+
// windows at their offsets, so a short one would silently drop rows.
|
|
693
|
+
const window = decodeBeast2For(indexWindowType(StringType, IntegerType, StringType, PlanRowType))(new Uint8Array(await response.arrayBuffer()));
|
|
694
|
+
const expected = [...rows].sort(([ka, a], [kb, b]) => compareFor(IntegerType)(a.due, b.due) || compareFor(StringType)(ka, kb)).slice(100, 300);
|
|
695
|
+
assert.equal(window.length, 200);
|
|
696
|
+
assert.equal(response.headers.get('X-Page-Count'), '200');
|
|
697
|
+
window.forEach((entry, i) => {
|
|
698
|
+
const [key, row] = expected[i];
|
|
699
|
+
assert.equal(entry.key, key);
|
|
700
|
+
assert.equal(entry.ik, row.due);
|
|
701
|
+
assert.equal(entry.value, row.title);
|
|
702
|
+
assert.ok(entry.row.type === 'some' && equalFor(PlanRowType)(entry.row.value, row), `row ${i} joined`);
|
|
703
|
+
});
|
|
704
|
+
// Scattered keys touch many segments; each is read once, however many of
|
|
705
|
+
// the window's rows it holds.
|
|
706
|
+
assert.ok(reads.size > 1, 'the window scatters across the record');
|
|
707
|
+
assert.deepEqual([...reads.values()].filter((count) => count !== 1), [], 'every touched segment is read exactly once');
|
|
708
|
+
});
|
|
709
|
+
it('an unjoined page never touches the primary', async () => {
|
|
710
|
+
const storage = new InMemoryStorage();
|
|
711
|
+
const { primarySegments } = await seedIndexedRecord(storage, 3000);
|
|
712
|
+
const objects = storage.objects;
|
|
713
|
+
const read = objects.read.bind(objects);
|
|
714
|
+
const touched = [];
|
|
715
|
+
objects.read = (repo, hash) => {
|
|
716
|
+
if (primarySegments.includes(hash))
|
|
717
|
+
touched.push(hash);
|
|
718
|
+
return read(repo, hash);
|
|
719
|
+
};
|
|
720
|
+
const response = await getDatasetPage(storage, REPO, WS, plansPath, { offset: 0, limit: 50, index: 'by_due' });
|
|
721
|
+
objects.read = read;
|
|
722
|
+
assert.equal(response.status, 200, await response.clone().text());
|
|
723
|
+
assert.deepEqual(touched, [], 'a covering read is the index alone');
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
describe('findDatasetKey (through an index)', () => {
|
|
727
|
+
it('takes the index key: an exact key is every entry under it, and a prefix or leading fields a run of them', async () => {
|
|
728
|
+
const storage = new InMemoryStorage();
|
|
729
|
+
const { statusSegments } = await seedIndexedRecord(storage, 12_000);
|
|
730
|
+
assert.ok(statusSegments.length > 3, `the index spans segments, got ${statusSegments.length}`);
|
|
731
|
+
const byStatus = async (query) => findJson(await findDatasetKey(storage, REPO, WS, plansPath, { ...query, index: 'by_status' }));
|
|
732
|
+
// `done` is rows 0..3999, `late` 4000..7999 and `open` 8000..11999, and
|
|
733
|
+
// a status's weeks are 400 rows apiece.
|
|
734
|
+
assert.deepEqual(await byStatus({ key: '(status="late", week=3)' }), { found: true, row: 5200, count: 400 });
|
|
735
|
+
assert.deepEqual(await byStatus({ key: '(status="late", week=12)' }), { found: false, row: 8000, count: 0 });
|
|
736
|
+
assert.deepEqual(await byStatus({ fields: ['"late"'] }), { found: true, row: 4000, count: 4000 });
|
|
737
|
+
assert.deepEqual(await byStatus({ fields: ['"late"', '3'] }), { found: true, row: 5200, count: 400 });
|
|
738
|
+
// A prefix types ahead on the index key's first field.
|
|
739
|
+
assert.deepEqual(await byStatus({ prefix: 'l' }), { found: true, row: 4000, count: 4000 });
|
|
740
|
+
// A range still bounds the collection's flattened key, `ik`'s fields first.
|
|
741
|
+
assert.deepEqual(await byStatus({ from: ['"late"', '3'], to: ['"late"', '5'] }), { found: true, row: 5200, count: 800 });
|
|
742
|
+
// `due` is a permutation, so each of its keys is one entry, at the row of its own value.
|
|
743
|
+
assert.deepEqual(await findJson(await findDatasetKey(storage, REPO, WS, plansPath, { key: '42', index: 'by_due' })), { found: true, row: 42, count: 1 });
|
|
744
|
+
});
|
|
745
|
+
it('refuses a form the index key cannot take, naming the index, and an index the record does not declare', async () => {
|
|
746
|
+
const storage = new InMemoryStorage();
|
|
747
|
+
await seedIndexedRecord(storage, 300);
|
|
748
|
+
const refusal = async (query) => {
|
|
749
|
+
const response = await findDatasetKey(storage, REPO, WS, plansPath, query);
|
|
750
|
+
return { status: response.status, ...await response.json() };
|
|
751
|
+
};
|
|
752
|
+
// A prefix continues the index key's own fields, never the collection's `ik`.
|
|
753
|
+
const intPrefix = await refusal({ fields: ['"late"'], prefix: '3', index: 'by_status' });
|
|
754
|
+
assert.equal(intPrefix.status, 400);
|
|
755
|
+
assert.match(intPrefix.error.message, /prefix continues key field 'week', which is Integer, not String/);
|
|
756
|
+
assert.match((await refusal({ prefix: '4', index: 'by_due' })).error.message, /prefix search needs String keys; the keys of index 'by_due' are Integer/);
|
|
757
|
+
// The collection's own key is not the index's.
|
|
758
|
+
assert.equal((await refusal({ key: '(ik=42, k="p-000042")', index: 'by_due' })).error.type, 'key_parse_error');
|
|
759
|
+
const missing = await refusal({ key: '42', index: 'nope' });
|
|
760
|
+
assert.equal(missing.status, 404);
|
|
761
|
+
assert.equal(missing.error.type, 'index_not_found');
|
|
762
|
+
assert.match(missing.error.message, /has no index 'nope' — it has by_due, by_status/);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
546
765
|
//# sourceMappingURL=datasets.spec.js.map
|