@elaraai/e3-types 1.0.75 → 1.0.77

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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Copyright (c) 2025 Elara AI Pty Ltd
3
+ * Dual-licensed under AGPL-3.0 and commercial license. See LICENSE for details.
4
+ */
5
+
6
+ /**
7
+ * The ONE check that a value's wire type is the type its dataset declares.
8
+ *
9
+ * No door into a dataset used to compare the two. `workspaceSetDataset`
10
+ * validated the path and the `writable` flag and then encoded with whatever
11
+ * type the caller passed; `e3 dataset set` passed a `.beast2` argument's own
12
+ * header type; the API `PUT` forwarded the body's header type; the transfer
13
+ * commit checked only that a collection blob carried an index. So a delivery
14
+ * whose schema had moved was accepted at the boundary and failed later, inside
15
+ * the consuming task — a decode error on the runner, or `cannot open a blob of
16
+ * type ... as ...` out of `FileSystem.openBeast` — naming neither the dataset
17
+ * that was set nor the field that moved.
18
+ *
19
+ * This lives in `e3-types` for the reason {@link encodeDatasetBlob} does: the
20
+ * rule has to hold at every door, and the doors are spread across `e3` (the
21
+ * export's `file` source check), `e3-core` (the set and the adopt) and
22
+ * `e3-api-server` (the `PUT` and the transfer commit), which share no package
23
+ * above this one.
24
+ *
25
+ * **Equality, not assignability.** A runner decodes the object *by the declared
26
+ * type* and beast2 decoding is type-directed, so a blob that is merely a
27
+ * subtype still decodes wrong. `isTypeValueEqual` is the relation.
28
+ *
29
+ * @packageDocumentation
30
+ */
31
+
32
+ import {
33
+ diffTypeValues,
34
+ isTypeValueEqual,
35
+ isVariant,
36
+ printTypeValueSummary,
37
+ renderTypeDiff,
38
+ toEastTypeValue,
39
+ type EastType,
40
+ type EastTypeValue,
41
+ type TypeDiff,
42
+ } from '@elaraai/east';
43
+
44
+ /**
45
+ * A declared type and a wire type that disagree, localized to the first
46
+ * differing position.
47
+ */
48
+ export interface DatasetTypeMismatch {
49
+ /** The type the dataset declares. */
50
+ readonly declared: EastTypeValue;
51
+ /** The type the bytes carry. */
52
+ readonly given: EastTypeValue;
53
+ /** Every localized difference — see `diffTypeValues`. */
54
+ readonly diffs: readonly TypeDiff[];
55
+ /** The one-line message every door reports. */
56
+ readonly message: string;
57
+ }
58
+
59
+ /** Normalizes either type representation to the homoiconic value form. */
60
+ function asTypeValue(type: EastType | EastTypeValue): EastTypeValue {
61
+ return isVariant(type) ? (type as EastTypeValue) : toEastTypeValue(type as EastType);
62
+ }
63
+
64
+ /**
65
+ * Compares a dataset's declared type with the type some bytes carry.
66
+ *
67
+ * @param subject - what is being written, as it should read at the head of the
68
+ * message — e.g. `dataset '.inputs.table'` or `input 'table'`
69
+ * @param carrier - what carries the given type, e.g. `the value` or a file path
70
+ * @param declared - the type the dataset declares
71
+ * @param given - the type the bytes carry
72
+ * @returns `null` when the types are equal, else the localized mismatch
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const bad = checkDatasetType("dataset '.inputs.table'", 'the value', declared, header);
77
+ * if (bad) throw new DatasetTypeMismatchError(ws, '.inputs.table', bad);
78
+ * ```
79
+ */
80
+ export function checkDatasetType(
81
+ subject: string,
82
+ carrier: string,
83
+ declared: EastType | EastTypeValue,
84
+ given: EastType | EastTypeValue,
85
+ ): DatasetTypeMismatch | null {
86
+ const declaredValue = asTypeValue(declared);
87
+ const givenValue = asTypeValue(given);
88
+ if (isTypeValueEqual(declaredValue, givenValue)) return null;
89
+
90
+ // Equality failed, so a diff normally localizes it — except where the two are
91
+ // assignable but unequal (a subtype, or the same struct fields in another
92
+ // order), which the assignability-shaped diff prunes to nothing. Say so
93
+ // plainly rather than printing a headline with no location under it.
94
+ const diffs = diffTypeValues(givenValue, declaredValue);
95
+ const first = diffs[0];
96
+ const where = first
97
+ ? `first difference at ${renderTypeDiff([first])}`
98
+ : 'the two are assignable but not identical — a dataset is decoded by its declared type, ' +
99
+ `so the bytes must carry exactly it${structuralHint(declaredValue, givenValue)}`;
100
+
101
+ return {
102
+ declared: declaredValue,
103
+ given: givenValue,
104
+ diffs,
105
+ message:
106
+ `${subject} declares ${printTypeValueSummary(declaredValue, 2)} but ${carrier} carries ` +
107
+ `${printTypeValueSummary(givenValue, 2)} — ${where}`,
108
+ };
109
+ }
110
+
111
+ /** Names the commonest assignable-but-unequal case: the same struct fields in a
112
+ * different order (East struct field order is significant on the wire). */
113
+ function structuralHint(declared: EastTypeValue, given: EastTypeValue): string {
114
+ if (declared.type !== 'Struct' || given.type !== 'Struct') return '';
115
+ const declaredOrder = (declared.value as { name: string }[]).map((f) => f.name);
116
+ const givenOrder = (given.value as { name: string }[]).map((f) => f.name);
117
+ if (declaredOrder.length !== givenOrder.length) return '';
118
+ const key = (names: string[]) => names.join('\\');
119
+ if (key([...declaredOrder].sort()) !== key([...givenOrder].sort())) return '';
120
+ if (key(declaredOrder) === key(givenOrder)) return '';
121
+ return ` (same fields, different order: expected ${declaredOrder.join(', ')}; found ${givenOrder.join(', ')})`;
122
+ }
123
+
124
+ /**
125
+ * Renders a dataset's tree path as it appears in messages: `.inputs.table`.
126
+ *
127
+ * Exported so every door spells the subject identically.
128
+ *
129
+ * @param segments - the path's field names, root-first
130
+ * @returns the dotted address, or `(root)` for an empty path
131
+ */
132
+ export function datasetAddress(segments: readonly string[]): string {
133
+ return segments.length === 0 ? '(root)' : segments.map((s) => `.${s}`).join('');
134
+ }
package/src/index.ts CHANGED
@@ -126,6 +126,8 @@ export {
126
126
  type PackageData,
127
127
  PackageObjectType,
128
128
  type PackageObject,
129
+ DatasetSourceWireType,
130
+ type DatasetSourceWire,
129
131
  decodePackageObject,
130
132
  // Backwards compatibility
131
133
  PackageDatasetsType,
@@ -175,10 +177,15 @@ export {
175
177
 
176
178
  // Dataset transfer types
177
179
  export {
180
+ TRANSFER_PROTOCOL_VERSION,
181
+ transferPartCount,
182
+ transferPartRange,
178
183
  TransferUploadRequestType,
179
184
  type TransferUploadRequest,
180
185
  TransferUploadResponseType,
181
186
  type TransferUploadResponse,
187
+ TransferPartResponseType,
188
+ type TransferPartResponse,
182
189
  TransferDoneResponseType,
183
190
  type TransferDoneResponse,
184
191
  } from './transfer.js';
@@ -190,6 +197,14 @@ export {
190
197
  encodeDatasetBlob,
191
198
  } from './dataset-blob.js';
192
199
 
200
+ // The ONE declared-type-vs-wire-type check, shared by every door a value
201
+ // enters a dataset through (the export, the set, the adopt, the API)
202
+ export {
203
+ checkDatasetType,
204
+ datasetAddress,
205
+ type DatasetTypeMismatch,
206
+ } from './dataset-type.js';
207
+
193
208
  // Wire format constants
194
209
  export { BEAST2_CONTENT_TYPE } from './constants.js';
195
210
 
@@ -212,6 +227,7 @@ export {
212
227
  PermissionDeniedErrorType,
213
228
  InternalErrorType,
214
229
  RepositoryNotFoundErrorType,
230
+ DatasetTypeMismatchErrorType,
215
231
  ErrorType,
216
232
  ResponseType,
217
233
  // Repository
package/src/package.ts CHANGED
@@ -72,6 +72,27 @@ export type PackageDatasets = PackageData;
72
72
  * };
73
73
  * ```
74
74
  */
75
+ /**
76
+ * Where a dataset's initial value comes from, when it is not in the package.
77
+ *
78
+ * @remarks
79
+ * A package carries the DESCRIPTOR, never the bytes: `e3.input('table', T,
80
+ * variant('file', './TABLE.beast2'))` puts `{ file: { path } }` here and leaves
81
+ * the ref `unassigned`, and deploy resolves it — adopting the file into the
82
+ * object store by hash, so a new delivery under the same path is a new hash
83
+ * whose consumers re-run. An inline `variant('value', v)` needs no descriptor:
84
+ * its bytes are an object in the bundle like any other.
85
+ *
86
+ * A variant so that further source kinds can be appended later without a wire
87
+ * break; `file` is the only one today.
88
+ */
89
+ export const DatasetSourceWireType = VariantType({
90
+ /** A path on the machine that DEPLOYS the package, resolved at deploy. */
91
+ file: StructType({ path: StringType }),
92
+ });
93
+ export type DatasetSourceWireType = typeof DatasetSourceWireType;
94
+ export type DatasetSourceWire = ValueTypeOf<typeof DatasetSourceWireType>;
95
+
75
96
  export const PackageObjectType = StructType({
76
97
  /** Tasks defined in this package: name -> task object hash */
77
98
  tasks: DictType(StringType, StringType),
@@ -83,11 +104,26 @@ export const PackageObjectType = StructType({
83
104
  * BEAST2 encodes struct fields positionally in declaration order, so new
84
105
  * fields MUST be appended LAST — never inserted between existing fields. */
85
106
  records: DictType(StringType, StringType),
107
+ /** Unresolved dataset sources: refPath (e.g. "inputs/table") -> descriptor.
108
+ * Appended LAST, per the positional rule above. */
109
+ sources: DictType(StringType, DatasetSourceWireType),
86
110
  });
87
111
  export type PackageObjectType = typeof PackageObjectType;
88
112
 
89
113
  export type PackageObject = ValueTypeOf<typeof PackageObjectType>;
90
114
 
115
+ /**
116
+ * The pre-`sources` package object wire shape (tasks, data, functions,
117
+ * records), kept only so {@link decodePackageObject} can read packages exported
118
+ * before path-initialised inputs existed.
119
+ */
120
+ const RecordsEraPackageObjectType = StructType({
121
+ tasks: DictType(StringType, StringType),
122
+ data: PackageDataType,
123
+ functions: DictType(StringType, StringType),
124
+ records: DictType(StringType, StringType),
125
+ });
126
+
91
127
  /**
92
128
  * The pre-`records` package object wire shape (tasks, data, functions), kept
93
129
  * only so {@link decodePackageObject} can read packages exported before records
@@ -110,31 +146,37 @@ const LegacyPackageObjectType = StructType({
110
146
  });
111
147
 
112
148
  const decodeCurrent = decodeBeast2For(PackageObjectType);
149
+ const decodeRecordsEra = decodeBeast2For(RecordsEraPackageObjectType);
113
150
  const decodeFunctionsEra = decodeBeast2For(FunctionsEraPackageObjectType);
114
151
  const decodeLegacy = decodeBeast2For(LegacyPackageObjectType);
115
152
 
116
153
  /**
117
- * Decode a `PackageObject` from BEAST2 bytes, tolerating the two older wire
154
+ * Decode a `PackageObject` from BEAST2 bytes, tolerating the older wire
118
155
  * formats (dual-decode migration).
119
156
  *
120
157
  * Every package-read path — local AND cloud — must use this instead of
121
158
  * `decodeBeast2For(PackageObjectType)` directly, so packages exported before
122
- * the `records`/`functions` fields existed keep decoding. Older bytes decode
123
- * with the missing maps defaulted to empty.
159
+ * the `sources`/`records`/`functions` fields existed keep decoding. Older
160
+ * bytes decode with the missing maps defaulted to empty.
124
161
  */
125
162
  export function decodePackageObject(data: Uint8Array): PackageObject {
126
163
  try {
127
164
  return decodeCurrent(data);
128
165
  } catch (err) {
129
166
  try {
130
- const fnEra = decodeFunctionsEra(data);
131
- return { tasks: fnEra.tasks, data: fnEra.data, functions: fnEra.functions, records: new Map() };
167
+ const recEra = decodeRecordsEra(data);
168
+ return { ...recEra, sources: new Map() };
132
169
  } catch {
133
170
  try {
134
- const legacy = decodeLegacy(data);
135
- return { tasks: legacy.tasks, data: legacy.data, functions: new Map(), records: new Map() };
171
+ const fnEra = decodeFunctionsEra(data);
172
+ return { tasks: fnEra.tasks, data: fnEra.data, functions: fnEra.functions, records: new Map(), sources: new Map() };
136
173
  } catch {
137
- throw err; // no known shape — surface the current-format error
174
+ try {
175
+ const legacy = decodeLegacy(data);
176
+ return { tasks: legacy.tasks, data: legacy.data, functions: new Map(), records: new Map(), sources: new Map() };
177
+ } catch {
178
+ throw err; // no known shape — surface the current-format error
179
+ }
138
180
  }
139
181
  }
140
182
  }
package/src/task.ts CHANGED
@@ -167,18 +167,64 @@ export const PartitionTaskMetadataType = StructType({
167
167
  combine: OptionType(BlobType),
168
168
  /** Target carved-slice size in wire bytes — the only sizing knob. */
169
169
  targetPartitionBytes: IntegerType,
170
+ /**
171
+ * `encodeEastIR` bundle of the per-key merge `(Key, Value, Value) -> Value`
172
+ * for a Dict output; `none` otherwise.
173
+ *
174
+ * Its presence (or {@link mergeSets}) selects the SEGMENT-MERGE assembly:
175
+ * partials are walked by their segment fences in the orchestrator, disjoint
176
+ * segments are byte-copied exactly as a splice does, and only overlapping
177
+ * ones are decoded, merged and re-encoded. Appended LAST (BEAST2 encodes
178
+ * struct fields positionally) with a dual decoder — see
179
+ * {@link decodePartitionTaskMetadata}.
180
+ */
181
+ merge: OptionType(BlobType),
182
+ /** Whether a Set output assembles by segment merge (union). The Set twin of
183
+ * {@link merge}, which needs no function. Appended LAST. */
184
+ mergeSets: BooleanType,
170
185
  });
171
186
  export type PartitionTaskMetadataType = typeof PartitionTaskMetadataType;
172
187
 
173
188
  export type PartitionTaskMetadata = ValueTypeOf<typeof PartitionTaskMetadataType>;
174
189
 
190
+ /**
191
+ * The pre-`merge` partition metadata wire shape, kept only so
192
+ * {@link decodePartitionTaskMetadata} can read tasks exported before the
193
+ * segment-merge assembly existed.
194
+ */
195
+ const PreMergePartitionTaskMetadataType = StructType({
196
+ partitions: IntegerType,
197
+ by: OptionType(BlobType),
198
+ combine: OptionType(BlobType),
199
+ targetPartitionBytes: IntegerType,
200
+ });
201
+
175
202
  /** Encode a {@link PartitionTaskMetadataType} value for `TaskObject.metadata`. */
176
203
  export const encodePartitionTaskMetadata: (value: PartitionTaskMetadata) => Uint8Array =
177
204
  encodeBeast2For(PartitionTaskMetadataType);
178
205
 
179
- /** Decode a `TaskObject.metadata` blob of a {@link TASK_KIND_PARTITION} task. */
180
- export const decodePartitionTaskMetadata: (data: Uint8Array) => PartitionTaskMetadata =
181
- decodeBeast2For(PartitionTaskMetadataType);
206
+ const decodeCurrentPartitionMetadata = decodeBeast2For(PartitionTaskMetadataType);
207
+ const decodePreMergePartitionMetadata = decodeBeast2For(PreMergePartitionTaskMetadataType);
208
+
209
+ /**
210
+ * Decode a `TaskObject.metadata` blob of a {@link TASK_KIND_PARTITION} task,
211
+ * tolerating the pre-`merge` wire format (dual-decode migration).
212
+ *
213
+ * @param data - the metadata blob
214
+ * @returns the decoded metadata, with `merge`/`mergeSets` defaulted off for
215
+ * older bytes
216
+ */
217
+ export function decodePartitionTaskMetadata(data: Uint8Array): PartitionTaskMetadata {
218
+ try {
219
+ return decodeCurrentPartitionMetadata(data);
220
+ } catch (err) {
221
+ try {
222
+ return { ...decodePreMergePartitionMetadata(data), merge: none, mergeSets: false };
223
+ } catch {
224
+ throw err; // no known shape — surface the current-format error
225
+ }
226
+ }
227
+ }
182
228
 
183
229
  /**
184
230
  * Metadata of a {@link TASK_KIND_STREAM} task.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Copyright (c) 2025 Elara AI Pty Ltd
3
+ * Dual-licensed under AGPL-3.0 and commercial license. See LICENSE for details.
4
+ */
5
+
6
+ /**
7
+ * The dataset transfer protocol's two invariants that live in e3-types.
8
+ *
9
+ * Part arithmetic is shared by the client that cuts the bytes and the server
10
+ * that places them, so an off-by-one on either side would corrupt uploads
11
+ * silently until the commit's hash check. And protocol 2 only adds variant
12
+ * cases: a version-1 client decodes a version-1 answer from a new server, and a
13
+ * new client decodes an old server's answers, only while every added case sorts
14
+ * after the version-1 cases and so leaves their tags unchanged.
15
+ */
16
+
17
+ import { describe, it } from 'node:test';
18
+ import * as assert from 'node:assert';
19
+ import { NullType, StringType, StructType, VariantType, decodeBeast2For, encodeBeast2For, variant } from '@elaraai/east';
20
+ import {
21
+ TransferDoneResponseType,
22
+ TransferUploadResponseType,
23
+ transferPartCount,
24
+ transferPartRange,
25
+ } from './transfer.js';
26
+
27
+ describe('transferPartCount / transferPartRange', () => {
28
+ it('cuts an upload into full parts and a remainder', () => {
29
+ assert.strictEqual(transferPartCount(10, 4), 3);
30
+ assert.deepStrictEqual(transferPartRange(10, 4, 1), { start: 0, end: 4 });
31
+ assert.deepStrictEqual(transferPartRange(10, 4, 2), { start: 4, end: 8 });
32
+ assert.deepStrictEqual(transferPartRange(10, 4, 3), { start: 8, end: 10 });
33
+ });
34
+
35
+ it('gives an exact multiple no empty trailing part', () => {
36
+ assert.strictEqual(transferPartCount(8, 4), 2);
37
+ assert.deepStrictEqual(transferPartRange(8, 4, 2), { start: 4, end: 8 });
38
+ assert.strictEqual(transferPartRange(8, 4, 3), null);
39
+ });
40
+
41
+ it('sends an upload no larger than a part as one part', () => {
42
+ assert.strictEqual(transferPartCount(3, 4), 1);
43
+ assert.strictEqual(transferPartCount(4, 4), 1);
44
+ assert.deepStrictEqual(transferPartRange(3, 4, 1), { start: 0, end: 3 });
45
+ });
46
+
47
+ it('has no part 0, no fractional part and none past the end', () => {
48
+ assert.strictEqual(transferPartRange(10, 4, 0), null);
49
+ assert.strictEqual(transferPartRange(10, 4, 1.5), null);
50
+ assert.strictEqual(transferPartRange(10, 4, 4), null);
51
+ });
52
+
53
+ it('stays exact past 2^32 bytes, taking sizes as bigint or number', () => {
54
+ const size = 5n * 1024n ** 4n + 1n; // 5 TiB and a byte
55
+ const partBytes = 512n * 1024n ** 2n;
56
+ assert.strictEqual(transferPartCount(size, partBytes), 10241);
57
+ assert.deepStrictEqual(transferPartRange(Number(size), Number(partBytes), 10241), {
58
+ start: Number(size) - 1,
59
+ end: Number(size),
60
+ });
61
+ });
62
+ });
63
+
64
+ describe('transfer wire compatibility across protocol versions', () => {
65
+ // The version-1 shapes, as a client and server built before protocol 2 hold them.
66
+ const UploadResponseV1 = VariantType({
67
+ completed: NullType,
68
+ upload: StructType({ id: StringType, uploadUrl: StringType }),
69
+ });
70
+ const DoneResponseV1 = VariantType({
71
+ completed: NullType,
72
+ error: StructType({ message: StringType }),
73
+ });
74
+
75
+ it('keeps the init answers a version-1 client decodes', () => {
76
+ const upload = variant('upload', { id: 'abc', uploadUrl: 'http://h/api/uploads/abc' });
77
+ assert.deepStrictEqual(
78
+ decodeBeast2For(UploadResponseV1)(encodeBeast2For(TransferUploadResponseType)(upload)),
79
+ upload,
80
+ );
81
+ assert.deepStrictEqual(
82
+ decodeBeast2For(TransferUploadResponseType)(encodeBeast2For(UploadResponseV1)(upload)),
83
+ upload,
84
+ );
85
+ const completed = variant('completed', null);
86
+ assert.deepStrictEqual(
87
+ decodeBeast2For(UploadResponseV1)(encodeBeast2For(TransferUploadResponseType)(completed)),
88
+ completed,
89
+ );
90
+ });
91
+
92
+ it('keeps the commit answers a version-1 client decodes', () => {
93
+ for (const done of [variant('completed', null), variant('error', { message: 'hash mismatch' })] as const) {
94
+ assert.deepStrictEqual(decodeBeast2For(DoneResponseV1)(encodeBeast2For(TransferDoneResponseType)(done)), done);
95
+ assert.deepStrictEqual(decodeBeast2For(TransferDoneResponseType)(encodeBeast2For(DoneResponseV1)(done)), done);
96
+ }
97
+ });
98
+ });
package/src/transfer.ts CHANGED
@@ -8,9 +8,60 @@
8
8
  *
9
9
  * Used by both the API server and client for large dataset uploads
10
10
  * that exceed inline body size limits.
11
+ *
12
+ * Two protocol versions share these types. A client names the version it speaks
13
+ * with `?protocol=N` on the init and commit requests (a request without it is
14
+ * version 1), and a server answers only in the forms that version understands —
15
+ * every case version 2 adds sorts after the version-1 cases, so the tags a
16
+ * version-1 client decodes keep their numbers.
17
+ */
18
+
19
+ import { VariantType, StructType, StringType, IntegerType, NullType, DictType, type ValueTypeOf } from '@elaraai/east';
20
+
21
+ /**
22
+ * The dataset transfer protocol version this build speaks.
23
+ *
24
+ * @remarks
25
+ * Version 2 lets the server plan an upload as parts — each sent to its own URL
26
+ * with the request headers the server names, so an object store can bind a
27
+ * checksum to a single PUT or take a multipart upload larger than one PUT
28
+ * allows — and lets a commit answer `processing` while the server verifies the
29
+ * bytes, for the client to poll.
30
+ */
31
+ export const TRANSFER_PROTOCOL_VERSION = 2;
32
+
33
+ /**
34
+ * How many parts an upload of `size` bytes has when planned with `partBytes`.
35
+ *
36
+ * @param size - The upload's size in bytes
37
+ * @param partBytes - The size of every part but the last
38
+ * @returns The part count — at least 1
11
39
  */
40
+ export function transferPartCount(size: number | bigint, partBytes: number | bigint): number {
41
+ const total = BigInt(size);
42
+ const each = BigInt(partBytes);
43
+ return total <= each ? 1 : Number((total + each - 1n) / each);
44
+ }
12
45
 
13
- import { VariantType, StructType, StringType, IntegerType, NullType, type ValueTypeOf } from '@elaraai/east';
46
+ /**
47
+ * The byte range part `part` of an upload covers.
48
+ *
49
+ * @param size - The upload's size in bytes
50
+ * @param partBytes - The size of every part but the last
51
+ * @param part - The part's number, from 1
52
+ * @returns The half-open range `[start, end)`, or `null` when the upload has no
53
+ * such part
54
+ */
55
+ export function transferPartRange(
56
+ size: number | bigint,
57
+ partBytes: number | bigint,
58
+ part: number
59
+ ): { start: number; end: number } | null {
60
+ if (!Number.isInteger(part) || part < 1 || part > transferPartCount(size, partBytes)) return null;
61
+ const start = BigInt(part - 1) * BigInt(partBytes);
62
+ const end = start + BigInt(partBytes) < BigInt(size) ? start + BigInt(partBytes) : BigInt(size);
63
+ return { start: Number(start), end: Number(end) };
64
+ }
14
65
 
15
66
  /**
16
67
  * Transfer upload init request.
@@ -30,7 +81,15 @@ export type TransferUploadRequest = ValueTypeOf<typeof TransferUploadRequestType
30
81
  * Transfer upload init response.
31
82
  *
32
83
  * - `completed`: Object already exists (dedup), dataset ref updated
33
- * - `upload`: Staging slot created, client should upload then call done
84
+ * - `upload`: Staging slot created; the client PUTs every byte to `uploadUrl`,
85
+ * then commits
86
+ * - `upload_parts` (protocol 2): the client sends the bytes as parts of
87
+ * `partBytes` bytes each (the last part holds the remainder, and an upload no
88
+ * larger than `partBytes` is one part). Part `n` (1-based) is the byte range
89
+ * `[(n - 1) * partBytes, min(size, n * partBytes))`; its URL and required
90
+ * headers come from `GET …/upload/<id>/parts/<n>`. Parts may be sent in any
91
+ * order and concurrently, and re-sending a part replaces it. The client
92
+ * commits once every part is sent.
34
93
  */
35
94
  export const TransferUploadResponseType = VariantType({
36
95
  completed: NullType,
@@ -38,19 +97,41 @@ export const TransferUploadResponseType = VariantType({
38
97
  id: StringType,
39
98
  uploadUrl: StringType,
40
99
  }),
100
+ upload_parts: StructType({
101
+ id: StringType,
102
+ partBytes: IntegerType,
103
+ }),
41
104
  });
42
105
  export type TransferUploadResponse = ValueTypeOf<typeof TransferUploadResponseType>;
43
106
 
44
107
  /**
45
- * Transfer done response.
108
+ * Where and how to send one part of a protocol-2 upload.
109
+ *
110
+ * @property url - The URL the client PUTs the part's bytes to, without an
111
+ * `Authorization` header (it may be a presigned object-store URL)
112
+ * @property headers - Request headers the PUT must carry exactly as given — a
113
+ * presigned URL can sign them, e.g. the checksum an object store verifies the
114
+ * bytes against
115
+ */
116
+ export const TransferPartResponseType = StructType({
117
+ url: StringType,
118
+ headers: DictType(StringType, StringType),
119
+ });
120
+ export type TransferPartResponse = ValueTypeOf<typeof TransferPartResponseType>;
121
+
122
+ /**
123
+ * Transfer done response — the commit's answer, and the poll's.
46
124
  *
47
125
  * - `completed`: Hash verified, object stored, dataset ref updated
48
126
  * - `error`: Hash mismatch or other failure
127
+ * - `processing` (protocol 2): the server is still verifying the bytes; poll
128
+ * `GET …/upload/<id>` until it answers `completed` or `error`
49
129
  */
50
130
  export const TransferDoneResponseType = VariantType({
51
131
  completed: NullType,
52
132
  error: StructType({
53
133
  message: StringType,
54
134
  }),
135
+ processing: NullType,
55
136
  });
56
137
  export type TransferDoneResponse = ValueTypeOf<typeof TransferDoneResponseType>;