@nimbus-sh/platform 0.7.1 → 0.8.1

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/src/w7-frame.ts CHANGED
@@ -1,10 +1,21 @@
1
1
  /**
2
- * W7 v3 — incremental typed records for streamed bulk filesystem writes.
3
- * The format is internal: every producer and consumer deploys together.
2
+ * W7 v4 — incremental typed records for streamed filesystem writes, in
3
+ * program order: the records are the operations a writer made, in the order
4
+ * it made them, and a stream that stops commits a prefix of them (each group
5
+ * whole). A path may be named by several operations (a file written, renamed,
6
+ * written again). Every producer speaks v4; the format is internal, and every
7
+ * producer and consumer deploys together. v3 (no rename, truncate or setattr,
8
+ * one operation per path, deletes then directories then files) is still
9
+ * decoded for the one release that rolls v4 out: delete it with W7_MAGIC_V3.
10
+ * Fields added to v4 since (each deploys with both ends, so the magic stays):
11
+ * a create call's `umask`; the `open` and `close` calls (a write
12
+ * description's), an `open`'s `ino`, and a description's `description` on
13
+ * its write, append and ftruncate calls.
4
14
  */
5
15
 
6
16
  import { crc32 } from './crc32.js';
7
17
  import { CHUNK_SIZE } from './limits.js';
18
+ import { utf8Length } from './utf8.js';
8
19
 
9
20
  // The batch payload types live WITH the wire format that encodes them: every
10
21
  // producer and consumer of a W7 stream speaks exactly these records, and the
@@ -26,6 +37,12 @@ export interface BatchInodeEntry {
26
37
  uid?: number;
27
38
  gid?: number;
28
39
  chunkCount: number;
40
+ /**
41
+ * The inode number a delegation's holder gave a file or directory it made
42
+ * (v4): one of the numbers its grant reserved, so the name keeps the
43
+ * number the holder already showed. Absent: the session numbers it.
44
+ */
45
+ ino?: number;
29
46
  }
30
47
 
31
48
  /** Entry for bulk chunk creation via writeBatch(). */
@@ -35,25 +52,156 @@ export interface BatchChunkEntry {
35
52
  data: Uint8Array;
36
53
  }
37
54
 
55
+ /**
56
+ * A file whose bytes are read from `source` while the stream is drained, never
57
+ * held whole: its inode (in `inodes`) carries the size, and the source must
58
+ * yield exactly that many bytes, in pieces of any size.
59
+ */
60
+ export interface BatchStreamEntry {
61
+ path: string;
62
+ source: AsyncIterable<Uint8Array>;
63
+ }
64
+
38
65
  /** Payload for writeBatch() — all inodes + chunks written in ONE transactionSync(). */
39
66
  export interface BatchWritePayload {
40
67
  inodes: BatchInodeEntry[];
41
68
  chunks: BatchChunkEntry[];
42
69
  /** Paths to delete before writing (for clean reinstall). */
43
70
  deletePaths?: string[];
71
+ /** Files streamed from a source rather than given as chunks; encoder only. */
72
+ streams?: BatchStreamEntry[];
73
+ /**
74
+ * The operations themselves, in program order (encoder only): a writer
75
+ * with an order to keep (a delegation's holder) gives these, and nothing
76
+ * in `inodes`, `chunks`, `deletePaths` or `streams`. Without them, a
77
+ * payload is encoded as its deletes, then its directories, then its files.
78
+ */
79
+ ops?: W7Op[];
44
80
  }
45
81
 
46
- export const W7_MAGIC = new Uint8Array([0x4e, 0x57, 0x37, 0x03]);
82
+ /** One attribute change (setattr): the mode, the owner, or the times; what chmod, chown and utimes each make. */
83
+ export type W7Attrs =
84
+ | { mode: number }
85
+ | { uid: number; gid: number }
86
+ | { atime: number; mtime: number };
87
+
88
+ /**
89
+ * A process's filesystem call as a record (v4), applied by the session's own
90
+ * operation of that name with that operation's semantics and refusals: a
91
+ * writeFile follows a link at its name and refuses a directory (EISDIR), a
92
+ * new file is `mode` less the process's umask and an existing one keeps its
93
+ * mode, owner and inode, a mkdir refuses a name that exists (EEXIST), and
94
+ * nothing makes a missing parent. The program-order counterpart of the
95
+ * syscall, where `file` and `directory` are a checkout's upserts.
96
+ */
97
+ export type W7Call =
98
+ /**
99
+ * `ino`, on a call that makes a name (writeFile, appendFile, mkdir,
100
+ * symlink): the number a delegation's holder gave the name it made, from
101
+ * its grant's range, kept when the call makes it (W7 v4 `ino`).
102
+ */
103
+ /**
104
+ * `umask`, on a call that makes a name with a mode (writeFile, appendFile,
105
+ * mkdir): the process's umask when it made the call, applied in place of
106
+ * the session's record of it (which a later change may have moved).
107
+ */
108
+ | { call: 'writeFile'; path: string; mode: number; ino?: number; umask?: number; data: Uint8Array }
109
+ | { call: 'appendFile'; path: string; mode: number; ino?: number; umask?: number; data: Uint8Array }
110
+ /**
111
+ * A write through an open description (pwrite(2)) at `offset`: through
112
+ * the session's description `description` when given (opened by an `open`
113
+ * call: its access was decided then, and no later chmod, chown, rename or
114
+ * unlink changes it), else of the file whose inode is `ino` (wherever that
115
+ * file is named now, and nowhere once no name has it), else of the file at
116
+ * `path`. Past its end, the gap reads as zeros.
117
+ */
118
+ | { call: 'write'; path: string; ino?: number; description?: string; offset: number; data: Uint8Array }
119
+ /** A write through an O_APPEND description: at the file's end as it is when the write lands. */
120
+ | { call: 'append'; path: string; ino?: number; description?: string; data: Uint8Array }
121
+ /** ftruncate(2) through an open description: `description`'s file, else the file `ino` names, else the one at `path`. */
122
+ | { call: 'ftruncate'; path: string; ino?: number; description?: string; size: number }
123
+ /** `existing: 'ok'`: a directory already there answers success, as `mkdir -p` takes it (anything else there is still EEXIST). */
124
+ | { call: 'mkdir'; path: string; mode: number; ino?: number; umask?: number; existing?: 'ok' }
125
+ | { call: 'unlink'; path: string }
126
+ | { call: 'rmdir'; path: string }
127
+ | { call: 'symlink'; path: string; target: string; ino?: number }
128
+ /**
129
+ * rm(1) as fs.rm makes it: a file or link unlinked, a directory with all
130
+ * it holds when `recursive` (else EISDIR's refusal, as unlink's), and a
131
+ * name not there no refusal when `force`.
132
+ */
133
+ | { call: 'rm'; path: string; recursive?: true; force?: true }
134
+ /** chown(2) of the link itself (lchown): the name's own entry, never what it names. */
135
+ | { call: 'lchown'; path: string; uid: number; gid: number }
136
+ /** utimes of the link itself (lutimes). */
137
+ | { call: 'lutimes'; path: string; atime: number; mtime: number }
138
+ /**
139
+ * open(2) of a file to write it (and to read it when `read`), as the
140
+ * session's own open decides it: a name made (`create`, `mode` less
141
+ * `umask`) or refused (EEXIST when `exclusive`, ENOENT without `create`,
142
+ * EISDIR, EACCES for any access asked that the file's mode refuses),
143
+ * emptied when `truncate`, a link at the name followed unless `nofollow`
144
+ * (ELOOP). Its answer is the file's stat. With `description` (an id the
145
+ * process chose), the session keeps the open description under it, its
146
+ * access fixed now; the description's write, append and ftruncate calls
147
+ * name it, and its `close` ends it. `ino`: the number a name it makes is
148
+ * given (a delegation's, as a mkdir's).
149
+ */
150
+ | { call: 'open'; path: string; mode: number; ino?: number; umask?: number; read?: true; create?: true; truncate?: true; exclusive?: true; nofollow?: true; description?: string }
151
+ /** close(2) of the session's open description `description` (an `open` call's). */
152
+ | { call: 'close'; path: string; description: string };
153
+
154
+ /** A call whose bytes travel as a file's chunks. */
155
+ export type W7DataCall = Extract<W7Call, { data: Uint8Array }>['call'];
156
+ /** A call that is one metadata record. */
157
+ export type W7PathCall = Exclude<W7Call, { data: Uint8Array }>;
158
+
159
+ /** One operation of a program-order payload (BatchWritePayload.ops). */
160
+ export type W7Op =
161
+ | { type: 'delete'; path: string }
162
+ | { type: 'directory'; inode: BatchInodeEntry }
163
+ | { type: 'file'; inode: BatchInodeEntry; data: Uint8Array }
164
+ | { type: 'file'; inode: BatchInodeEntry; source: AsyncIterable<Uint8Array> }
165
+ | { type: 'rename'; from: string; to: string }
166
+ | { type: 'truncate'; path: string; size: number }
167
+ | { type: 'setattr'; path: string; attrs: W7Attrs }
168
+ | { type: 'call'; call: W7Call };
169
+
170
+ export const W7_MAGIC = new Uint8Array([0x4e, 0x57, 0x37, 0x04]);
171
+ /** v3's magic: decoded, never encoded, for the release that rolls v4 out. Delete with v3 decoding. */
172
+ const W7_MAGIC_V3 = new Uint8Array([0x4e, 0x57, 0x37, 0x03]);
47
173
 
48
174
  const ENCODER_QUEUE_HWM = 0;
175
+ const ENCODER_PULL_BYTES = 256 * 1024;
176
+ /** What one stream read asks for when a record's small fields are wanted. */
177
+ const READ_AHEAD_BYTES = 64 * 1024;
49
178
  const MAX_METADATA_BYTES = 64 * 1024;
50
179
  const MAX_PATH_BYTES = 64 * 1024;
51
180
  const MAX_BATCH_ID_BYTES = 128;
52
181
  const MAX_CONTENT_ID_BYTES = 256;
53
- export const W7_MAX_PATHS_PER_BATCH = 128;
54
- export const W7_MAX_OWNED_PATH_BYTES = 64 * 1024;
182
+ /**
183
+ * A batch's owned paths. Each stream costs the receiver a round trip and a
184
+ * publication's fixed work, so a batch is as wide as its byte budget allows;
185
+ * ownership is a set of names, small beside the bytes.
186
+ */
187
+ export const W7_MAX_PATHS_PER_BATCH = 1024;
188
+ export const W7_MAX_OWNED_PATH_BYTES = 256 * 1024;
55
189
  export const W7_MAX_RECORD_BYTES = 5 + 4 + MAX_CONTENT_ID_BYTES + 8 + CHUNK_SIZE;
56
190
 
191
+ /** The wire chunks a file or link of `size` bytes travels as: CHUNK_SIZE each, the last short, none when empty. */
192
+ export function w7ChunkCount(size: number): number {
193
+ return size === 0 ? 0 : Math.ceil(size / CHUNK_SIZE);
194
+ }
195
+
196
+ /** `data`, the content at `path`, as its wire chunks (w7ChunkCount): views of it, not copies. */
197
+ export function w7Chunks(path: string, data: Uint8Array): BatchChunkEntry[] {
198
+ const chunks: BatchChunkEntry[] = [];
199
+ for (let chunkId = 0, count = w7ChunkCount(data.byteLength); chunkId < count; chunkId++) {
200
+ chunks.push({ path, chunkId, data: data.subarray(chunkId * CHUNK_SIZE, (chunkId + 1) * CHUNK_SIZE) });
201
+ }
202
+ return chunks;
203
+ }
204
+
57
205
  const enum RecordTag {
58
206
  BatchBegin = 1,
59
207
  Delete = 2,
@@ -62,13 +210,21 @@ const enum RecordTag {
62
210
  FileChunk = 5,
63
211
  FileEnd = 6,
64
212
  BatchEnd = 7,
213
+ Rename = 8,
214
+ Truncate = 9,
215
+ SetAttr = 10,
216
+ /** A W7PathCall (v4). A data call is a FileBegin whose metadata names its call. */
217
+ Call = 11,
65
218
  }
66
219
 
67
- const MODE = 'path-atomic-committed-prefix' as const;
220
+ const MODE = 'program-order-committed-prefix' as const;
221
+ /** v3's batch mode. Delete with v3 decoding. */
222
+ const MODE_V3 = 'path-atomic-committed-prefix' as const;
223
+ type W7Mode = typeof MODE | typeof MODE_V3;
68
224
 
69
225
  interface BatchBeginMetadata {
70
226
  id: string;
71
- mode: typeof MODE;
227
+ mode: W7Mode;
72
228
  }
73
229
 
74
230
  interface DeleteMetadata {
@@ -80,6 +236,7 @@ interface InodeMetadata {
80
236
  atime?: number;
81
237
  mtime: number;
82
238
  mode: number;
239
+ ino?: number;
83
240
  }
84
241
 
85
242
  interface DirectoryMetadata extends InodeMetadata {
@@ -91,6 +248,13 @@ interface FileBeginMetadata extends InodeMetadata {
91
248
  contentId: string;
92
249
  size: number;
93
250
  chunkCount: number;
251
+ call?: W7DataCall;
252
+ /** A 'write' call's offset. */
253
+ offset?: number;
254
+ /** A writeFile's or appendFile's umask (W7Call umask). */
255
+ umask?: number;
256
+ /** A write's or append's open description (W7Call description). */
257
+ description?: string;
94
258
  }
95
259
 
96
260
  interface FileEndMetadata {
@@ -108,6 +272,8 @@ export interface W7BatchSummary {
108
272
  fileCount: number;
109
273
  chunkCount: number;
110
274
  byteCount: number;
275
+ /** Renames, truncates, attribute changes and path calls (v4; none in v3). */
276
+ opCount: number;
111
277
  check: number;
112
278
  }
113
279
 
@@ -122,7 +288,8 @@ export interface W7DecodeOptions {
122
288
  }
123
289
 
124
290
  type W7DirectoryInode = BatchInodeEntry & { kind: 'directory'; isDir: true };
125
- type W7ContentInode = BatchInodeEntry & { kind: 'file' | 'symlink'; isDir: false };
291
+ /** `call`: the file is a W7DataCall's bytes (its path, mode and size), not an upsert's inode. */
292
+ type W7ContentInode = BatchInodeEntry & { kind: 'file' | 'symlink'; isDir: false; call?: W7DataCall; offset?: number; umask?: number; description?: string };
126
293
 
127
294
  export type W7DecodedRecord =
128
295
  | { type: 'delete'; path: string }
@@ -148,18 +315,33 @@ export type W7DecodedRecord =
148
315
  chunkCount: number;
149
316
  check: number;
150
317
  }
318
+ | { type: 'rename'; from: string; to: string }
319
+ | { type: 'truncate'; path: string; size: number }
320
+ | { type: 'setattr'; path: string; attrs: W7Attrs }
321
+ | { type: 'call'; call: W7PathCall }
151
322
  | { type: 'batch-end'; summary: W7BatchSummary };
152
323
 
153
324
  export interface W7DecodedStream {
154
325
  readonly batchId: string;
155
- readonly mode: typeof MODE;
326
+ readonly mode: W7Mode;
156
327
  readonly records: AsyncIterable<W7DecodedRecord>;
157
328
  }
158
329
 
330
+ /** An operation as the encoder writes it, in the payload's program order. */
331
+ type EncoderOp =
332
+ | { kind: 'delete'; path: string }
333
+ | { kind: 'directory'; inode: W7DirectoryInode }
334
+ | { kind: 'file'; file: EncoderFile }
335
+ | { kind: 'rename'; from: string; to: string }
336
+ | { kind: 'truncate'; path: string; size: number }
337
+ | { kind: 'setattr'; path: string; attrs: W7Attrs }
338
+ | { kind: 'call'; call: W7PathCall };
339
+
159
340
  interface EncoderFile {
160
341
  inode: W7ContentInode;
161
342
  contentId: string;
162
343
  chunks: BatchChunkEntry[];
344
+ source: AsyncIterable<Uint8Array> | null;
163
345
  }
164
346
 
165
347
  interface EncoderState {
@@ -167,30 +349,47 @@ interface EncoderState {
167
349
  summary: Omit<W7BatchSummary, 'check'>;
168
350
  }
169
351
 
170
- /** Encode one bounded record per pull; no batch-sized metadata header exists. */
352
+ /**
353
+ * Encode the records a pull reaches into one enqueued chunk of about
354
+ * ENCODER_PULL_BYTES (a record never splits; a file's chunk is at most
355
+ * CHUNK_SIZE), so a wave crosses the RPC boundary in a few writes rather than
356
+ * one per record. The bytes are the same records either way; no batch-sized
357
+ * metadata header exists.
358
+ */
171
359
  export function encodeWriteBatchStream(payload: BatchWritePayload): ReadableStream<Uint8Array> {
172
360
  const batchId = crypto.randomUUID();
173
- const { deletes, directories, files } = preparePayload(payload, batchId);
174
- const iterator = encodeRecords(batchId, deletes, directories, files);
361
+ const iterator = encodeRecords(batchId, preparePayload(payload, batchId));
175
362
  let closed = false;
176
363
  let magicEmitted = false;
177
364
  const source: UnderlyingByteSource = {
178
365
  type: 'bytes',
179
- pull(controller) {
366
+ async pull(controller) {
180
367
  if (closed) return;
181
368
  try {
369
+ const parts: Uint8Array[] = [];
370
+ let bytes = 0;
182
371
  if (!magicEmitted) {
183
372
  magicEmitted = true;
184
- controller.enqueue(W7_MAGIC.slice());
185
- return;
373
+ parts.push(W7_MAGIC.slice());
374
+ bytes += W7_MAGIC.byteLength;
186
375
  }
187
- const next = iterator.next();
188
- if (next.done) {
189
- closed = true;
190
- controller.close();
191
- return;
376
+ while (bytes < ENCODER_PULL_BYTES) {
377
+ const next = await iterator.next();
378
+ if (next.done) {
379
+ closed = true;
380
+ break;
381
+ }
382
+ for (const part of next.value) {
383
+ parts.push(part);
384
+ bytes += part.byteLength;
385
+ }
192
386
  }
193
- for (const part of next.value) controller.enqueue(part);
387
+ // Every chunk enqueued is one the encoder built: a lone part is the
388
+ // magic or a metadata record, and a file chunk's record is always
389
+ // two parts, so its bytes are copied here. A caller's buffers are
390
+ // never transferred, and a payload may be encoded again.
391
+ if (bytes > 0) controller.enqueue(parts.length === 1 ? parts[0]! : concatBytes(...parts));
392
+ if (closed) controller.close();
194
393
  } catch (error) {
195
394
  closed = true;
196
395
  controller.error(error);
@@ -206,6 +405,31 @@ export function encodeWriteBatchStream(payload: BatchWritePayload): ReadableStre
206
405
  });
207
406
  }
208
407
 
408
+ /**
409
+ * The bytes encodeWriteBatchStream would stream for `payload`, as one buffer:
410
+ * for a payload held in memory (no streamed sources), the same records in
411
+ * the same order, copied once.
412
+ */
413
+ export async function encodeWriteBatch(payload: BatchWritePayload): Promise<Uint8Array> {
414
+ if ((payload.streams?.length ?? 0) > 0) throw new Error('w7-frame: a payload with streamed sources is never encoded whole');
415
+ const batchId = crypto.randomUUID();
416
+ const parts: Uint8Array[] = [W7_MAGIC];
417
+ let length = W7_MAGIC.byteLength;
418
+ for await (const record of encodeRecords(batchId, preparePayload(payload, batchId))) {
419
+ for (const part of record) {
420
+ parts.push(part);
421
+ length += part.byteLength;
422
+ }
423
+ }
424
+ const out = new Uint8Array(length);
425
+ let at = 0;
426
+ for (const part of parts) {
427
+ out.set(part, at);
428
+ at += part.byteLength;
429
+ }
430
+ return out;
431
+ }
432
+
209
433
  /**
210
434
  * Parse the v3 preamble eagerly, then expose validated operation records
211
435
  * incrementally. Chunk credit is acquired after its bounded header validates
@@ -226,15 +450,16 @@ export async function decodeWriteBatchStream(
226
450
  try {
227
451
  throwIfAborted(options.signal);
228
452
  const magic = await buffer.readExact(W7_MAGIC.length, 'magic');
229
- if (!bytesEqual(magic, W7_MAGIC)) {
453
+ const v3 = bytesEqual(magic, W7_MAGIC_V3);
454
+ if (!v3 && !bytesEqual(magic, W7_MAGIC)) {
230
455
  const version = magic.length === 4
231
456
  && magic[0] === 0x4e && magic[1] === 0x57 && magic[2] === 0x37
232
457
  ? magic[3]
233
458
  : null;
234
459
  if (version !== null) {
235
- throw new Error(`w7-frame: unsupported protocol version ${version}; expected 3`);
460
+ throw new Error(`w7-frame: unsupported protocol version ${version}; expected 4`);
236
461
  }
237
- throw new Error(`w7-frame: bad magic, expected NW7\\x03, got ${hex(magic)}`);
462
+ throw new Error(`w7-frame: bad magic, expected NW7\\x04, got ${hex(magic)}`);
238
463
  }
239
464
  const beginEnvelope = await readEnvelope(buffer, 'batch-begin');
240
465
  if (beginEnvelope.tag !== RecordTag.BatchBegin) {
@@ -246,13 +471,13 @@ export async function decodeWriteBatchStream(
246
471
  );
247
472
  }
248
473
  const beginPayload = await buffer.readExact(beginEnvelope.length, 'batch-begin payload');
249
- const begin = parseBatchBegin(beginPayload);
474
+ const begin = parseBatchBegin(beginPayload, v3 ? MODE_V3 : MODE);
250
475
  const initialCheck = updateRecordCheck(0, beginEnvelope.header, beginPayload);
251
476
  handedOff = true;
252
477
  return {
253
478
  batchId: begin.id,
254
479
  mode: begin.mode,
255
- records: decodeRecords(stream, reader, buffer, options, initialCheck),
480
+ records: decodeRecords(stream, reader, buffer, options, initialCheck, v3),
256
481
  };
257
482
  } catch (error) {
258
483
  if (!handedOff) await cancelReader(reader, error);
@@ -266,11 +491,15 @@ async function* decodeRecords(
266
491
  buffer: ExactByteReader,
267
492
  options: W7DecodeOptions,
268
493
  initialCheck: number,
494
+ /** A v3 stream: no renames, truncates or attribute changes, and each path once. */
495
+ v3: boolean,
269
496
  ): AsyncGenerator<W7DecodedRecord> {
270
- const ownedPaths = new PathOwnership();
497
+ const ownedPaths = new PathOwnership(!v3);
271
498
  const contentIds = new Set<string>();
272
499
  let active: {
273
500
  metadata: FileBeginMetadata;
501
+ /** The content id as the wire carries it: what each chunk must repeat. */
502
+ contentIdBytes: Uint8Array;
274
503
  inode: W7ContentInode;
275
504
  nextChunkId: number;
276
505
  receivedBytes: number;
@@ -285,6 +514,7 @@ async function* decodeRecords(
285
514
  fileCount: 0,
286
515
  chunkCount: 0,
287
516
  byteCount: 0,
517
+ opCount: 0,
288
518
  };
289
519
  let completed = false;
290
520
  let failure: unknown = new DOMException('W7 consumer cancelled', 'AbortError');
@@ -298,26 +528,28 @@ async function* decodeRecords(
298
528
 
299
529
  if (envelope.tag === RecordTag.FileChunk) {
300
530
  if (!active) throw new Error('w7-frame: file-chunk without active file');
301
- const prefixLength = Math.min(envelope.length, 4 + MAX_CONTENT_ID_BYTES + 8);
302
- const idLengthBytes = await buffer.readExact(4, 'file-chunk content-id length');
303
- const idLength = readU32LE(idLengthBytes, 0);
531
+ // The chunk must name its file's content id, so its prefix is that
532
+ // id's length plus the id-length, chunk-id and data-length fields:
533
+ // read whole, then checked field by field.
534
+ const expectedId = active.contentIdBytes;
535
+ const prefixLength = 4 + expectedId.byteLength + 8;
536
+ if (prefixLength > envelope.length) {
537
+ throw new Error('w7-frame: malformed file-chunk record length');
538
+ }
539
+ const headerPayload = await buffer.readExact(prefixLength, 'file-chunk header');
540
+ const idLength = readU32LE(headerPayload, 0);
304
541
  if (idLength === 0 || idLength > MAX_CONTENT_ID_BYTES) {
305
542
  throw new Error(`w7-frame: invalid file-chunk content-id length ${idLength}`);
306
543
  }
307
- const remainingPrefixLength = idLength + 8;
308
- if (4 + remainingPrefixLength > prefixLength || 4 + remainingPrefixLength > envelope.length) {
309
- throw new Error('w7-frame: malformed file-chunk record length');
544
+ if (idLength !== expectedId.byteLength || !sameBytes(headerPayload.subarray(4, 4 + idLength), expectedId)) {
545
+ throw new Error(`w7-frame: file-chunk content id does not own ${active.inode.path}`);
310
546
  }
311
- const rest = await buffer.readExact(remainingPrefixLength, 'file-chunk header');
312
- const contentId = decodeText(rest.subarray(0, idLength), 'file-chunk content id');
313
- const chunkId = readU32LE(rest, idLength);
314
- const dataLength = readU32LE(rest, idLength + 4);
547
+ const chunkId = readU32LE(headerPayload, 4 + idLength);
548
+ const dataLength = readU32LE(headerPayload, 8 + idLength);
315
549
  if (envelope.length !== 4 + idLength + 8 + dataLength) {
316
550
  throw new Error('w7-frame: file-chunk payload length mismatch');
317
551
  }
318
- if (contentId !== active.metadata.contentId) {
319
- throw new Error(`w7-frame: file-chunk content id ${contentId} does not own ${active.inode.path}`);
320
- }
552
+ const contentId = active.metadata.contentId;
321
553
  if (chunkId !== active.nextChunkId) {
322
554
  throw new Error(
323
555
  `w7-frame: ${active.inode.path}: expected chunk ${active.nextChunkId}, got ${chunkId}`,
@@ -335,7 +567,6 @@ async function* decodeRecords(
335
567
  `w7-frame: ${active.inode.path}: chunk ${chunkId} has ${dataLength} bytes; expected ${expectedBytes}`,
336
568
  );
337
569
  }
338
- const headerPayload = concatBytes(idLengthBytes, rest);
339
570
  let retention: W7ChunkRetention | null = null;
340
571
  try {
341
572
  retention = options.retainChunk
@@ -378,23 +609,23 @@ async function* decodeRecords(
378
609
  switch (envelope.tag) {
379
610
  case RecordTag.Delete: {
380
611
  const metadata = parseDelete(payload);
381
- claimPath(ownedPaths, metadata.path);
612
+ ownedPaths.claim(metadata.path);
382
613
  summary.pathCount++;
383
614
  summary.deleteCount++;
384
615
  yield { type: 'delete', path: metadata.path };
385
616
  break;
386
617
  }
387
618
  case RecordTag.Directory: {
388
- const metadata = parseDirectory(payload);
389
- claimPath(ownedPaths, metadata.path);
619
+ const metadata = parseDirectory(payload, v3);
620
+ ownedPaths.claim(metadata.path);
390
621
  summary.pathCount++;
391
622
  summary.directoryCount++;
392
623
  yield { type: 'directory', inode: directoryInode(metadata) };
393
624
  break;
394
625
  }
395
626
  case RecordTag.FileBegin: {
396
- const metadata = parseFileBegin(payload);
397
- claimPath(ownedPaths, metadata.path);
627
+ const metadata = parseFileBegin(payload, v3);
628
+ ownedPaths.claim(metadata.path);
398
629
  if (contentIds.has(metadata.contentId)) {
399
630
  throw new Error(`w7-frame: duplicate stream content id ${metadata.contentId}`);
400
631
  }
@@ -404,6 +635,7 @@ async function* decodeRecords(
404
635
  summary.fileCount++;
405
636
  active = {
406
637
  metadata,
638
+ contentIdBytes: TEXT_ENCODER.encode(metadata.contentId),
407
639
  inode,
408
640
  nextChunkId: 0,
409
641
  receivedBytes: 0,
@@ -441,9 +673,39 @@ async function* decodeRecords(
441
673
  yield record;
442
674
  break;
443
675
  }
676
+ case RecordTag.Call: {
677
+ if (v3) throw new Error(`w7-frame: unknown record tag ${envelope.tag}`);
678
+ summary.pathCount++;
679
+ summary.opCount++;
680
+ const value = parseObject(payload, 'call', ['call', 'path'], ['mode', 'target', 'ino', 'size', 'existing', 'recursive', 'force', 'uid', 'gid', 'atime', 'mtime', 'umask', 'read', 'create', 'truncate', 'exclusive', 'nofollow', 'description']);
681
+ yield { type: 'call', call: parsePathCall(value, (path, label) => ownedPaths.claim(canonicalPath(path, label))) };
682
+ break;
683
+ }
684
+ case RecordTag.Rename:
685
+ case RecordTag.Truncate:
686
+ case RecordTag.SetAttr: {
687
+ if (v3) throw new Error(`w7-frame: unknown record tag ${envelope.tag}`);
688
+ summary.pathCount++;
689
+ summary.opCount++;
690
+ if (envelope.tag === RecordTag.Rename) {
691
+ const value = parseObject(payload, 'rename', ['from', 'to']);
692
+ const from = ownedPaths.claim(canonicalPath(value.from, 'rename source'));
693
+ const to = ownedPaths.claim(canonicalPath(value.to, 'rename target'));
694
+ yield { type: 'rename', from, to };
695
+ } else if (envelope.tag === RecordTag.Truncate) {
696
+ const value = parseObject(payload, 'truncate', ['path', 'size']);
697
+ yield { type: 'truncate', path: ownedPaths.claim(canonicalPath(value.path, 'truncate path')), size: safeInteger(value.size, 'truncate size') };
698
+ } else {
699
+ const value = parseObject(payload, 'setattr', ['path'], ['mode', 'uid', 'gid', 'atime', 'mtime']);
700
+ const path = ownedPaths.claim(canonicalPath(value.path, 'setattr path'));
701
+ const { path: _path, ...attrs } = value;
702
+ yield { type: 'setattr', path, attrs: parseAttrs(attrs, 'setattr') };
703
+ }
704
+ break;
705
+ }
444
706
  case RecordTag.BatchEnd: {
445
707
  if (active) throw new Error(`w7-frame: batch-end while file ${active.inode.path} is active`);
446
- const actual = parseBatchEnd(payload);
708
+ const actual = parseBatchEnd(payload, v3);
447
709
  const expected = { ...summary, check: batchCheck };
448
710
  if (!sameSummary(actual, expected)) {
449
711
  throw new Error(
@@ -473,12 +735,7 @@ async function* decodeRecords(
473
735
  }
474
736
  }
475
737
 
476
- function* encodeRecords(
477
- batchId: string,
478
- deletes: string[],
479
- directories: W7DirectoryInode[],
480
- files: EncoderFile[],
481
- ): Generator<Uint8Array[]> {
738
+ async function* encodeRecords(batchId: string, ops: readonly EncoderOp[]): AsyncGenerator<Uint8Array[]> {
482
739
  const state: EncoderState = {
483
740
  batchCheck: 0,
484
741
  summary: {
@@ -489,55 +746,46 @@ function* encodeRecords(
489
746
  fileCount: 0,
490
747
  chunkCount: 0,
491
748
  byteCount: 0,
749
+ opCount: 0,
492
750
  },
493
751
  };
494
752
  yield encodeMetadataRecord(RecordTag.BatchBegin, { id: batchId, mode: MODE }, state);
495
- for (const path of deletes) {
496
- state.summary.pathCount++;
497
- state.summary.deleteCount++;
498
- yield encodeMetadataRecord(RecordTag.Delete, { path }, state);
499
- }
500
- for (const inode of directories) {
501
- state.summary.pathCount++;
502
- state.summary.directoryCount++;
503
- yield encodeMetadataRecord(RecordTag.Directory, {
504
- ...inodeMetadata(inode),
505
- kind: inode.kind,
506
- }, state);
507
- }
508
- for (const file of files) {
509
- state.summary.pathCount++;
510
- state.summary.fileCount++;
511
- yield encodeMetadataRecord(RecordTag.FileBegin, {
512
- ...inodeMetadata(file.inode),
513
- kind: file.inode.kind,
514
- contentId: file.contentId,
515
- size: file.inode.size,
516
- chunkCount: file.inode.chunkCount,
517
- }, state);
518
- let fileCheck = 0;
519
- for (const chunk of file.chunks) {
520
- const data = chunk.data;
521
- const contentBytes = new TextEncoder().encode(file.contentId);
522
- const prefix = new Uint8Array(4 + contentBytes.length + 8);
523
- writeU32LE(prefix, 0, contentBytes.length);
524
- prefix.set(contentBytes, 4);
525
- writeU32LE(prefix, 4 + contentBytes.length, chunk.chunkId);
526
- writeU32LE(prefix, 8 + contentBytes.length, data.byteLength);
527
- const header = recordHeader(RecordTag.FileChunk, prefix.byteLength + data.byteLength);
528
- state.batchCheck = updateRecordCheck(state.batchCheck, header, prefix, data);
529
- state.summary.recordCount++;
530
- state.summary.chunkCount++;
531
- state.summary.byteCount += data.byteLength;
532
- fileCheck = crc32(data, fileCheck);
533
- yield [concatBytes(header, prefix), data];
753
+ for (const op of ops) {
754
+ switch (op.kind) {
755
+ case 'delete':
756
+ state.summary.pathCount++;
757
+ state.summary.deleteCount++;
758
+ yield encodeMetadataRecord(RecordTag.Delete, { path: op.path }, state);
759
+ break;
760
+ case 'directory':
761
+ state.summary.pathCount++;
762
+ state.summary.directoryCount++;
763
+ yield encodeMetadataRecord(RecordTag.Directory, { ...inodeMetadata(op.inode), kind: op.inode.kind }, state);
764
+ break;
765
+ case 'file':
766
+ yield* encodeFile(op.file, state);
767
+ break;
768
+ case 'rename':
769
+ state.summary.pathCount++;
770
+ state.summary.opCount++;
771
+ yield encodeMetadataRecord(RecordTag.Rename, { from: op.from, to: op.to }, state);
772
+ break;
773
+ case 'truncate':
774
+ state.summary.pathCount++;
775
+ state.summary.opCount++;
776
+ yield encodeMetadataRecord(RecordTag.Truncate, { path: op.path, size: op.size }, state);
777
+ break;
778
+ case 'setattr':
779
+ state.summary.pathCount++;
780
+ state.summary.opCount++;
781
+ yield encodeMetadataRecord(RecordTag.SetAttr, { path: op.path, ...op.attrs }, state);
782
+ break;
783
+ case 'call':
784
+ state.summary.pathCount++;
785
+ state.summary.opCount++;
786
+ yield encodeMetadataRecord(RecordTag.Call, { ...op.call }, state);
787
+ break;
534
788
  }
535
- yield encodeMetadataRecord(RecordTag.FileEnd, {
536
- contentId: file.contentId,
537
- size: file.inode.size,
538
- chunkCount: file.inode.chunkCount,
539
- check: fileCheck,
540
- }, state);
541
789
  }
542
790
  const end: W7BatchSummary = {
543
791
  ...state.summary,
@@ -546,6 +794,87 @@ function* encodeRecords(
546
794
  yield encodeMetadataRecord(RecordTag.BatchEnd, end);
547
795
  }
548
796
 
797
+ /** One file's records: its begin, its chunks, its end. */
798
+ async function* encodeFile(file: EncoderFile, state: EncoderState): AsyncGenerator<Uint8Array[]> {
799
+ state.summary.pathCount++;
800
+ state.summary.fileCount++;
801
+ yield encodeMetadataRecord(RecordTag.FileBegin, {
802
+ ...inodeMetadata(file.inode),
803
+ kind: file.inode.kind,
804
+ contentId: file.contentId,
805
+ size: file.inode.size,
806
+ chunkCount: file.inode.chunkCount,
807
+ ...(file.inode.call === undefined ? {} : { call: file.inode.call }),
808
+ ...(file.inode.offset === undefined ? {} : { offset: file.inode.offset }),
809
+ ...(file.inode.umask === undefined ? {} : { umask: file.inode.umask }),
810
+ ...(file.inode.description === undefined ? {} : { description: file.inode.description }),
811
+ }, state);
812
+ let fileCheck = 0;
813
+ let chunkId = 0;
814
+ const pieces = file.source === null ? givenChunks(file.chunks) : fixedChunks(file.inode, file.source);
815
+ for await (const data of pieces) {
816
+ const contentBytes = new TextEncoder().encode(file.contentId);
817
+ const prefix = new Uint8Array(4 + contentBytes.length + 8);
818
+ writeU32LE(prefix, 0, contentBytes.length);
819
+ prefix.set(contentBytes, 4);
820
+ writeU32LE(prefix, 4 + contentBytes.length, chunkId++);
821
+ writeU32LE(prefix, 8 + contentBytes.length, data.byteLength);
822
+ const header = recordHeader(RecordTag.FileChunk, prefix.byteLength + data.byteLength);
823
+ state.batchCheck = updateRecordCheck(state.batchCheck, header, prefix, data);
824
+ state.summary.recordCount++;
825
+ state.summary.chunkCount++;
826
+ state.summary.byteCount += data.byteLength;
827
+ fileCheck = crc32(data, fileCheck);
828
+ yield [concatBytes(header, prefix), data];
829
+ }
830
+ yield encodeMetadataRecord(RecordTag.FileEnd, {
831
+ contentId: file.contentId,
832
+ size: file.inode.size,
833
+ chunkCount: file.inode.chunkCount,
834
+ check: fileCheck,
835
+ }, state);
836
+ }
837
+
838
+ /** Each chunk's bytes, read as the encoder reaches it (a producer's `data` may copy on access). */
839
+ function* givenChunks(chunks: BatchChunkEntry[]): Generator<Uint8Array> {
840
+ for (const chunk of chunks) yield chunk.data;
841
+ }
842
+
843
+ /**
844
+ * A streamed file's bytes as the wire's positional chunks: CHUNK_SIZE each but
845
+ * the last, every one over its own buffer (the stream transfers what it
846
+ * enqueues). The source must yield exactly the inode's size.
847
+ */
848
+ async function* fixedChunks(
849
+ inode: W7ContentInode,
850
+ source: AsyncIterable<Uint8Array>,
851
+ ): AsyncGenerator<Uint8Array> {
852
+ let pending = new Uint8Array(Math.min(CHUNK_SIZE, inode.size));
853
+ let filled = 0;
854
+ let total = 0;
855
+ for await (const part of source) {
856
+ if (!(part instanceof Uint8Array)) throw new Error(`w7-frame: ${inode.path}: streamed piece is not bytes`);
857
+ if (total + part.byteLength > inode.size) {
858
+ throw new Error(`w7-frame: ${inode.path}: streamed source exceeds its ${inode.size} bytes`);
859
+ }
860
+ total += part.byteLength;
861
+ for (let offset = 0; offset < part.byteLength;) {
862
+ const take = Math.min(pending.byteLength - filled, part.byteLength - offset);
863
+ pending.set(part.subarray(offset, offset + take), filled);
864
+ filled += take;
865
+ offset += take;
866
+ if (filled === pending.byteLength) {
867
+ yield pending;
868
+ pending = new Uint8Array(Math.min(CHUNK_SIZE, inode.size - total + (part.byteLength - offset)));
869
+ filled = 0;
870
+ }
871
+ }
872
+ }
873
+ if (total !== inode.size) {
874
+ throw new Error(`w7-frame: ${inode.path}: streamed source ended at ${total} of ${inode.size} bytes`);
875
+ }
876
+ }
877
+
549
878
  function encodeMetadataRecord(
550
879
  tag: RecordTag,
551
880
  value: object,
@@ -563,16 +892,25 @@ function encodeMetadataRecord(
563
892
  return [concatBytes(header, payload)];
564
893
  }
565
894
 
566
- function preparePayload(
567
- payload: BatchWritePayload,
568
- batchId: string,
569
- ): { deletes: string[]; directories: W7DirectoryInode[]; files: EncoderFile[] } {
895
+ /**
896
+ * The payload's operations in the order they are encoded: its `ops` as
897
+ * given, or its deletes, then its directories, then its files (one per path,
898
+ * since chunks are matched to their file by path).
899
+ */
900
+ function preparePayload(payload: BatchWritePayload, batchId: string): EncoderOp[] {
570
901
  if (!payload || !Array.isArray(payload.inodes) || !Array.isArray(payload.chunks)) {
571
902
  throw new Error('w7-frame: payload must contain inode and chunk arrays');
572
903
  }
573
- const ownedPaths = new PathOwnership();
904
+ if (payload.ops !== undefined) return prepareOps(payload, batchId);
905
+ const ownedPaths = new PathOwnership(false);
574
906
  const deletes = [...(payload.deletePaths ?? [])];
575
- for (const path of deletes) claimPath(ownedPaths, canonicalPath(path, 'delete path'));
907
+ for (const path of deletes) ownedPaths.claim(canonicalPath(path, 'delete path'));
908
+ const streamsByPath = new Map<string, AsyncIterable<Uint8Array>>();
909
+ for (const stream of payload.streams ?? []) {
910
+ const path = canonicalPath(stream.path, 'stream path');
911
+ if (streamsByPath.has(path)) throw new Error(`w7-frame: duplicate stream for ${path}`);
912
+ streamsByPath.set(path, stream.source);
913
+ }
576
914
  const chunksByPath = new Map<string, BatchChunkEntry[]>();
577
915
  for (const chunk of payload.chunks) {
578
916
  const path = canonicalPath(chunk.path, 'chunk path');
@@ -580,39 +918,111 @@ function preparePayload(
580
918
  if (list) list.push(chunk);
581
919
  else chunksByPath.set(path, [chunk]);
582
920
  }
583
- const directories: W7DirectoryInode[] = [];
584
- const files: EncoderFile[] = [];
921
+ const directories: EncoderOp[] = [];
922
+ const files: EncoderOp[] = [];
585
923
  let fileIndex = 0;
586
924
  for (const inode of payload.inodes) {
587
925
  const path = canonicalPath(inode.path, 'inode path');
588
926
  if (path !== inode.path) throw new Error(`w7-frame: noncanonical inode path ${inode.path}`);
589
- claimPath(ownedPaths, path);
927
+ ownedPaths.claim(path);
590
928
  const normalizedInode = normalizeInode(inode);
591
929
  const fileChunks = chunksByPath.get(path) ?? [];
930
+ const streamed = streamsByPath.get(path) ?? null;
592
931
  if (normalizedInode.kind === 'directory') {
593
- if (fileChunks.length > 0) throw new Error(`w7-frame: directory ${path} has chunks`);
594
- directories.push(normalizedInode);
932
+ if (fileChunks.length > 0 || streamed !== null) throw new Error(`w7-frame: directory ${path} has chunks`);
933
+ directories.push({ kind: 'directory', inode: normalizedInode });
595
934
  } else {
596
- validateChunks(normalizedInode, fileChunks);
935
+ if (streamed === null) validateChunks(normalizedInode, fileChunks);
936
+ else if (fileChunks.length > 0) throw new Error(`w7-frame: streamed file ${path} also has chunks`);
597
937
  files.push({
598
- inode: normalizedInode,
599
- contentId: `${batchId}:${fileIndex++}`,
600
- chunks: fileChunks,
938
+ kind: 'file',
939
+ file: { inode: normalizedInode, contentId: `${batchId}:${fileIndex++}`, chunks: fileChunks, source: streamed },
601
940
  });
602
941
  }
603
942
  chunksByPath.delete(path);
943
+ streamsByPath.delete(path);
604
944
  }
605
945
  if (chunksByPath.size > 0) {
606
946
  throw new Error(`w7-frame: chunk has no inode: ${chunksByPath.keys().next().value}`);
607
947
  }
608
- return { deletes, directories, files };
948
+ if (streamsByPath.size > 0) {
949
+ throw new Error(`w7-frame: stream has no inode: ${streamsByPath.keys().next().value}`);
950
+ }
951
+ return [...deletes.map((path): EncoderOp => ({ kind: 'delete', path })), ...directories, ...files];
952
+ }
953
+
954
+ /** A program-order payload's operations, each checked as the decoder will check it. */
955
+ function prepareOps(payload: BatchWritePayload, batchId: string): EncoderOp[] {
956
+ if (payload.inodes.length > 0 || payload.chunks.length > 0 || (payload.deletePaths?.length ?? 0) > 0 || (payload.streams?.length ?? 0) > 0) {
957
+ throw new Error('w7-frame: a payload of ops carries nothing else');
958
+ }
959
+ const ownedPaths = new PathOwnership(true);
960
+ let fileIndex = 0;
961
+ return payload.ops!.map((op): EncoderOp => {
962
+ switch (op.type) {
963
+ case 'delete':
964
+ return { kind: 'delete', path: ownedPaths.claim(canonicalPath(op.path, 'delete path')) };
965
+ case 'directory': {
966
+ const inode = normalizeInode({ ...op.inode, path: ownedPaths.claim(canonicalPath(op.inode.path, 'inode path')) });
967
+ if (inode.kind !== 'directory') throw new Error(`w7-frame: directory op ${inode.path} is a ${inode.kind}`);
968
+ return { kind: 'directory', inode };
969
+ }
970
+ case 'file': {
971
+ const inode = normalizeInode({ ...op.inode, path: ownedPaths.claim(canonicalPath(op.inode.path, 'inode path')) });
972
+ if (inode.kind === 'directory') throw new Error(`w7-frame: file op ${inode.path} is a directory`);
973
+ const contentId = `${batchId}:${fileIndex++}`;
974
+ if ('source' in op) return { kind: 'file', file: { inode, contentId, chunks: [], source: op.source } };
975
+ const chunks = w7Chunks(inode.path, op.data);
976
+ validateChunks(inode, chunks);
977
+ return { kind: 'file', file: { inode, contentId, chunks, source: null } };
978
+ }
979
+ case 'rename':
980
+ return {
981
+ kind: 'rename',
982
+ from: ownedPaths.claim(canonicalPath(op.from, 'rename source')),
983
+ to: ownedPaths.claim(canonicalPath(op.to, 'rename target')),
984
+ };
985
+ case 'truncate':
986
+ return { kind: 'truncate', path: ownedPaths.claim(canonicalPath(op.path, 'truncate path')), size: safeInteger(op.size, 'truncate size') };
987
+ case 'setattr':
988
+ return { kind: 'setattr', path: ownedPaths.claim(canonicalPath(op.path, 'setattr path')), attrs: parseAttrs(op.attrs as Record<string, unknown>, 'setattr') };
989
+ case 'call': {
990
+ const call = op.call;
991
+ if ('data' in call) {
992
+ const path = ownedPaths.claim(canonicalPath(call.path, `${call.call} path`));
993
+ // A call's file stamps no time of its own: the session's operation does.
994
+ const inode = normalizeInode({
995
+ path, parentPath: parentPath(path), kind: 'file', isDir: false,
996
+ size: call.data.byteLength, mtime: 0, mode: 'mode' in call ? u32(call.mode, `${call.call} mode`) : 0, chunkCount: w7ChunkCount(call.data.byteLength),
997
+ ...('ino' in call && call.ino !== undefined ? { ino: inodeNumber(call.ino, `${call.call} ino`) } : {}),
998
+ }) as W7ContentInode;
999
+ inode.call = call.call;
1000
+ if (call.call === 'write') inode.offset = safeInteger(call.offset, 'write offset');
1001
+ if ('umask' in call && call.umask !== undefined) inode.umask = umaskOf(call.umask, `${call.call} umask`);
1002
+ if ('description' in call && call.description !== undefined) inode.description = descriptionId(call.description, `${call.call} description`);
1003
+ const chunks = w7Chunks(path, call.data);
1004
+ return { kind: 'file', file: { inode, contentId: `${batchId}:${fileIndex++}`, chunks, source: null } };
1005
+ }
1006
+ return { kind: 'call', call: parsePathCall({ ...call } as Record<string, unknown>, (path, label) => ownedPaths.claim(canonicalPath(path, label))) };
1007
+ }
1008
+ }
1009
+ });
609
1010
  }
610
1011
 
611
- function parseBatchBegin(bytes: Uint8Array): BatchBeginMetadata {
1012
+ function parseBatchBegin(bytes: Uint8Array, mode: W7Mode): BatchBeginMetadata {
612
1013
  const value = parseObject(bytes, 'batch-begin', ['id', 'mode']);
613
1014
  const id = boundedString(value.id, 'batch id', MAX_BATCH_ID_BYTES);
614
- if (value.mode !== MODE) throw new Error(`w7-frame: unsupported batch mode ${String(value.mode)}`);
615
- return { id, mode: MODE };
1015
+ if (value.mode !== mode) throw new Error(`w7-frame: unsupported batch mode ${String(value.mode)}`);
1016
+ return { id, mode };
1017
+ }
1018
+
1019
+ /** One attribute change: exactly the mode, the owner (uid and gid), or the times (atime and mtime). */
1020
+ function parseAttrs(value: Record<string, unknown>, label: string): W7Attrs {
1021
+ const keys = Object.keys(value).sort().join(',');
1022
+ if (keys === 'mode') return { mode: u32(value.mode, `${label} mode`) };
1023
+ if (keys === 'gid,uid') return { uid: u32(value.uid, `${label} uid`), gid: u32(value.gid, `${label} gid`) };
1024
+ if (keys === 'atime,mtime') return { atime: safeInteger(value.atime, `${label} atime`), mtime: safeInteger(value.mtime, `${label} mtime`) };
1025
+ throw new Error(`w7-frame: ${label} changes the mode, the owner or the times, one of them: got ${keys || 'nothing'}`);
616
1026
  }
617
1027
 
618
1028
  function parseDelete(bytes: Uint8Array): DeleteMetadata {
@@ -620,20 +1030,25 @@ function parseDelete(bytes: Uint8Array): DeleteMetadata {
620
1030
  return { path: canonicalPath(value.path, 'delete path') };
621
1031
  }
622
1032
 
623
- function parseDirectory(bytes: Uint8Array): DirectoryMetadata {
624
- const value = parseObject(bytes, 'directory', ['path', 'kind', 'mtime', 'mode'], ['atime']);
1033
+ /** A v3 record names no inode number; a v4 one may. */
1034
+ function inodeOptional(v3: boolean): string[] {
1035
+ return v3 ? ['atime'] : ['atime', 'ino'];
1036
+ }
1037
+
1038
+ function parseDirectory(bytes: Uint8Array, v3: boolean): DirectoryMetadata {
1039
+ const value = parseObject(bytes, 'directory', ['path', 'kind', 'mtime', 'mode'], inodeOptional(v3));
625
1040
  if (value.kind !== 'directory') {
626
1041
  throw new Error(`w7-frame: unsupported directory kind ${String(value.kind)}`);
627
1042
  }
628
1043
  return { ...parseInodeMetadata(value, 'directory'), kind: 'directory' };
629
1044
  }
630
1045
 
631
- function parseFileBegin(bytes: Uint8Array): FileBeginMetadata {
1046
+ function parseFileBegin(bytes: Uint8Array, v3: boolean): FileBeginMetadata {
632
1047
  const value = parseObject(
633
1048
  bytes,
634
1049
  'file-begin',
635
1050
  ['path', 'kind', 'contentId', 'size', 'chunkCount', 'mtime', 'mode'],
636
- ['atime'],
1051
+ v3 ? inodeOptional(v3) : [...inodeOptional(v3), 'call', 'offset', 'umask', 'description'],
637
1052
  );
638
1053
  const base = parseInodeMetadata(value, 'file-begin');
639
1054
  if (value.kind !== 'file' && value.kind !== 'symlink') {
@@ -645,11 +1060,108 @@ function parseFileBegin(bytes: Uint8Array): FileBeginMetadata {
645
1060
  }
646
1061
  const size = safeInteger(value.size, 'file size');
647
1062
  const chunkCount = u32(value.chunkCount, 'file chunk count');
648
- const expected = size === 0 ? 0 : Math.ceil(size / CHUNK_SIZE);
1063
+ const expected = w7ChunkCount(size);
649
1064
  if (chunkCount !== expected) {
650
1065
  throw new Error(`w7-frame: ${base.path}: expected ${expected} chunks, got ${chunkCount}`);
651
1066
  }
652
- return { ...base, kind: value.kind, contentId, size, chunkCount };
1067
+ const call = value.call;
1068
+ if (call !== undefined && call !== 'writeFile' && call !== 'appendFile' && call !== 'write' && call !== 'append') {
1069
+ throw new Error(`w7-frame: ${base.path}: unknown data call ${String(call)}`);
1070
+ }
1071
+ if (call !== undefined && value.kind !== 'file') throw new Error(`w7-frame: ${base.path}: a ${String(call)} writes a file`);
1072
+ // An offset is a write's, and only a description's writes name an inode.
1073
+ if ((call === 'write') !== (value.offset !== undefined)) throw new Error(`w7-frame: ${base.path}: a write, and only a write, has an offset`);
1074
+ // A umask is a call's that makes a name with a mode; a description, a description's write's.
1075
+ if (value.umask !== undefined && call !== 'writeFile' && call !== 'appendFile') throw new Error(`w7-frame: ${base.path}: only a writeFile or appendFile has a umask`);
1076
+ if (value.description !== undefined && call !== 'write' && call !== 'append') throw new Error(`w7-frame: ${base.path}: only a write or append names a description`);
1077
+ return {
1078
+ ...base, kind: value.kind, contentId, size, chunkCount,
1079
+ ...(call === undefined ? {} : { call }),
1080
+ ...(value.offset === undefined ? {} : { offset: safeInteger(value.offset, 'write offset') }),
1081
+ ...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'file-begin umask') }),
1082
+ ...(value.description === undefined ? {} : { description: descriptionId(value.description, 'file-begin description') }),
1083
+ };
1084
+ }
1085
+
1086
+ /** A path call's fields, exactly: its paths made canonical (and claimed) by `path`. */
1087
+ function parsePathCall(value: Record<string, unknown>, path: (value: unknown, label: string) => string): W7PathCall {
1088
+ const keys = Object.keys(value).sort().join(',');
1089
+ switch (value.call) {
1090
+ case 'mkdir': {
1091
+ const optional = keys.replace(',existing', '').replace(',ino', '').replace(',umask', '');
1092
+ if (optional !== 'call,mode,path') break;
1093
+ if (value.existing !== undefined && value.existing !== 'ok') throw new Error(`w7-frame: mkdir existing is 'ok' or absent, not ${String(value.existing)}`);
1094
+ return {
1095
+ call: 'mkdir', path: path(value.path, 'mkdir path'), mode: u32(value.mode, 'mkdir mode'),
1096
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'mkdir ino') }),
1097
+ ...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'mkdir umask') }),
1098
+ ...(value.existing === undefined ? {} : { existing: 'ok' as const }),
1099
+ };
1100
+ }
1101
+ case 'unlink':
1102
+ case 'rmdir':
1103
+ if (keys !== 'call,path') break;
1104
+ return { call: value.call, path: path(value.path, `${value.call} path`) };
1105
+ case 'symlink':
1106
+ if (keys !== 'call,path,target' && keys !== 'call,ino,path,target') break;
1107
+ return {
1108
+ call: 'symlink', path: path(value.path, 'symlink path'), target: boundedString(value.target, 'symlink target', MAX_PATH_BYTES),
1109
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'symlink ino') }),
1110
+ };
1111
+ case 'ftruncate':
1112
+ if (keys.replace(',description', '').replace(',ino', '') !== 'call,path,size') break;
1113
+ return {
1114
+ call: 'ftruncate', path: path(value.path, 'ftruncate path'), size: safeInteger(value.size, 'ftruncate size'),
1115
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'ftruncate ino') }),
1116
+ ...(value.description === undefined ? {} : { description: descriptionId(value.description, 'ftruncate description') }),
1117
+ };
1118
+ case 'close':
1119
+ if (keys !== 'call,description,path') break;
1120
+ return { call: 'close', path: path(value.path, 'close path'), description: descriptionId(value.description, 'close description') };
1121
+ case 'rm': {
1122
+ if (keys.replace(',force', '').replace(',recursive', '') !== 'call,path') break;
1123
+ for (const flag of ['recursive', 'force'] as const) {
1124
+ if (value[flag] !== undefined && value[flag] !== true) throw new Error(`w7-frame: rm ${flag} is true or absent`);
1125
+ }
1126
+ return {
1127
+ call: 'rm', path: path(value.path, 'rm path'),
1128
+ ...(value.recursive === true ? { recursive: true as const } : {}),
1129
+ ...(value.force === true ? { force: true as const } : {}),
1130
+ };
1131
+ }
1132
+ case 'lchown':
1133
+ if (keys !== 'call,gid,path,uid') break;
1134
+ return { call: 'lchown', path: path(value.path, 'lchown path'), uid: u32(value.uid, 'lchown uid'), gid: u32(value.gid, 'lchown gid') };
1135
+ case 'open': {
1136
+ const flags = ['create', 'exclusive', 'nofollow', 'read', 'truncate'] as const;
1137
+ let required = keys;
1138
+ for (const flag of ['create', 'description', 'exclusive', 'ino', 'nofollow', 'read', 'truncate', 'umask']) required = required.replace(`,${flag}`, '');
1139
+ if (required !== 'call,mode,path') break;
1140
+ for (const flag of flags) {
1141
+ if (value[flag] !== undefined && value[flag] !== true) throw new Error(`w7-frame: open ${flag} is true or absent`);
1142
+ }
1143
+ return {
1144
+ call: 'open', path: path(value.path, 'open path'), mode: u32(value.mode, 'open mode'),
1145
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'open ino') }),
1146
+ ...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'open umask') }),
1147
+ ...(value.create === true ? { create: true as const } : {}),
1148
+ ...(value.truncate === true ? { truncate: true as const } : {}),
1149
+ ...(value.exclusive === true ? { exclusive: true as const } : {}),
1150
+ ...(value.nofollow === true ? { nofollow: true as const } : {}),
1151
+ ...(value.read === true ? { read: true as const } : {}),
1152
+ ...(value.description === undefined ? {} : { description: descriptionId(value.description, 'open description') }),
1153
+ };
1154
+ }
1155
+ case 'lutimes':
1156
+ if (keys !== 'atime,call,mtime,path') break;
1157
+ return {
1158
+ call: 'lutimes', path: path(value.path, 'lutimes path'),
1159
+ atime: safeInteger(value.atime, 'lutimes atime'), mtime: safeInteger(value.mtime, 'lutimes mtime'),
1160
+ };
1161
+ default:
1162
+ throw new Error(`w7-frame: unknown call ${String(value.call)}`);
1163
+ }
1164
+ throw new Error(`w7-frame: ${String(value.call)} takes other fields: got ${keys}`);
653
1165
  }
654
1166
 
655
1167
  function parseFileEnd(bytes: Uint8Array): FileEndMetadata {
@@ -662,10 +1174,10 @@ function parseFileEnd(bytes: Uint8Array): FileEndMetadata {
662
1174
  };
663
1175
  }
664
1176
 
665
- function parseBatchEnd(bytes: Uint8Array): W7BatchSummary {
1177
+ function parseBatchEnd(bytes: Uint8Array, v3: boolean): W7BatchSummary {
666
1178
  const keys = [
667
1179
  'recordCount', 'pathCount', 'deleteCount', 'directoryCount',
668
- 'fileCount', 'chunkCount', 'byteCount', 'check',
1180
+ 'fileCount', 'chunkCount', 'byteCount', 'check', ...(v3 ? [] : ['opCount']),
669
1181
  ];
670
1182
  const value = parseObject(bytes, 'batch-end', keys);
671
1183
  return {
@@ -676,6 +1188,7 @@ function parseBatchEnd(bytes: Uint8Array): W7BatchSummary {
676
1188
  fileCount: safeInteger(value.fileCount, 'batch file count'),
677
1189
  chunkCount: safeInteger(value.chunkCount, 'batch chunk count'),
678
1190
  byteCount: safeInteger(value.byteCount, 'batch byte count'),
1191
+ opCount: v3 ? 0 : safeInteger(value.opCount, 'batch op count'),
679
1192
  check: u32(value.check, 'batch check'),
680
1193
  };
681
1194
  }
@@ -686,9 +1199,31 @@ function parseInodeMetadata(value: Record<string, unknown>, label: string): Inod
686
1199
  ...(value.atime === undefined ? {} : { atime: safeInteger(value.atime, `${label} atime`) }),
687
1200
  mtime: safeInteger(value.mtime, `${label} mtime`),
688
1201
  mode: u32(value.mode, `${label} mode`),
1202
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, `${label} ino`) }),
689
1203
  };
690
1204
  }
691
1205
 
1206
+ /** An open description's id, as a process chose it: up to 64 characters of [A-Za-z0-9_-]. */
1207
+ function descriptionId(value: unknown, label: string): string {
1208
+ const id = boundedString(value, label, 64);
1209
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`w7-frame: ${label} is not a description id`);
1210
+ return id;
1211
+ }
1212
+
1213
+ /** A umask: permission bits, 0 to 0o777. */
1214
+ function umaskOf(value: unknown, label: string): number {
1215
+ const mask = u32(value, label);
1216
+ if (mask > 0o777) throw new Error(`w7-frame: ${label} must be at most 0o777`);
1217
+ return mask;
1218
+ }
1219
+
1220
+ /** An inode number: an integer from 2 (1 is the root's). */
1221
+ function inodeNumber(value: unknown, label: string): number {
1222
+ const ino = safeInteger(value, label);
1223
+ if (ino < 2) throw new Error(`w7-frame: ${label} must be at least 2`);
1224
+ return ino;
1225
+ }
1226
+
692
1227
  function parseObject(
693
1228
  bytes: Uint8Array,
694
1229
  label: string,
@@ -722,6 +1257,7 @@ function normalizeInode(inode: BatchInodeEntry): W7DirectoryInode | W7ContentIno
722
1257
  u32(inode.chunkCount, `${inode.path} chunk count`);
723
1258
  safeInteger(inode.mtime, `${inode.path} mtime`);
724
1259
  if (inode.atime !== undefined) safeInteger(inode.atime, `${inode.path} atime`);
1260
+ if (inode.ino !== undefined) inodeNumber(inode.ino, `${inode.path} ino`);
725
1261
  u32(inode.mode, `${inode.path} mode`);
726
1262
  const rawKind: unknown = inode.kind ?? (inode.isDir ? 'directory' : 'file');
727
1263
  if (rawKind !== 'file' && rawKind !== 'directory' && rawKind !== 'symlink') {
@@ -738,7 +1274,7 @@ function normalizeInode(inode: BatchInodeEntry): W7DirectoryInode | W7ContentIno
738
1274
  throw new Error(`w7-frame: directory ${inode.path} must have zero size and chunks`);
739
1275
  }
740
1276
  if (kind !== 'directory') {
741
- const expected = inode.size === 0 ? 0 : Math.ceil(inode.size / CHUNK_SIZE);
1277
+ const expected = w7ChunkCount(inode.size);
742
1278
  if (inode.chunkCount !== expected) {
743
1279
  throw new Error(`w7-frame: ${inode.path}: expected ${expected} chunks, got ${inode.chunkCount}`);
744
1280
  }
@@ -775,6 +1311,7 @@ function directoryInode(metadata: DirectoryMetadata): W7DirectoryInode {
775
1311
  mtime: metadata.mtime,
776
1312
  mode: metadata.mode,
777
1313
  chunkCount: 0,
1314
+ ...(metadata.ino === undefined ? {} : { ino: metadata.ino }),
778
1315
  };
779
1316
  }
780
1317
 
@@ -789,6 +1326,11 @@ function fileInode(metadata: FileBeginMetadata): W7ContentInode {
789
1326
  mtime: metadata.mtime,
790
1327
  mode: metadata.mode,
791
1328
  chunkCount: metadata.chunkCount,
1329
+ ...(metadata.ino === undefined ? {} : { ino: metadata.ino }),
1330
+ ...(metadata.call === undefined ? {} : { call: metadata.call }),
1331
+ ...(metadata.offset === undefined ? {} : { offset: metadata.offset }),
1332
+ ...(metadata.umask === undefined ? {} : { umask: metadata.umask }),
1333
+ ...(metadata.description === undefined ? {} : { description: metadata.description }),
792
1334
  };
793
1335
  }
794
1336
 
@@ -798,6 +1340,7 @@ function inodeMetadata(inode: BatchInodeEntry): InodeMetadata {
798
1340
  ...(inode.atime === undefined ? {} : { atime: inode.atime }),
799
1341
  mtime: inode.mtime,
800
1342
  mode: inode.mode,
1343
+ ...(inode.ino === undefined ? {} : { ino: inode.ino }),
801
1344
  };
802
1345
  }
803
1346
 
@@ -809,16 +1352,26 @@ function canonicalPath(value: unknown, label: string): string {
809
1352
  return path;
810
1353
  }
811
1354
 
1355
+ /**
1356
+ * The distinct paths a batch names, within W7's bounds. A program-order
1357
+ * batch (v4) may name a path in several operations; a v3 batch, and a payload
1358
+ * matched to its chunks by path, names each once.
1359
+ */
812
1360
  class PathOwnership {
813
1361
  private readonly paths = new Set<string>();
814
1362
  private pathBytes = 0;
815
1363
 
816
- claim(path: string): void {
817
- if (this.paths.has(path)) throw new Error(`w7-frame: duplicate path ownership: ${path}`);
1364
+ constructor(private readonly repeats: boolean) {}
1365
+
1366
+ claim(path: string): string {
1367
+ if (this.paths.has(path)) {
1368
+ if (this.repeats) return path;
1369
+ throw new Error(`w7-frame: duplicate path ownership: ${path}`);
1370
+ }
818
1371
  if (this.paths.size >= W7_MAX_PATHS_PER_BATCH) {
819
1372
  throw new Error(`w7-frame: batch exceeds ${W7_MAX_PATHS_PER_BATCH} owned paths`);
820
1373
  }
821
- const nextPathBytes = this.pathBytes + new TextEncoder().encode(path).byteLength;
1374
+ const nextPathBytes = this.pathBytes + utf8Length(path);
822
1375
  if (nextPathBytes > W7_MAX_OWNED_PATH_BYTES) {
823
1376
  throw new Error(
824
1377
  `w7-frame: owned path bytes exceed ${W7_MAX_OWNED_PATH_BYTES}`,
@@ -826,13 +1379,10 @@ class PathOwnership {
826
1379
  }
827
1380
  this.paths.add(path);
828
1381
  this.pathBytes = nextPathBytes;
1382
+ return path;
829
1383
  }
830
1384
  }
831
1385
 
832
- function claimPath(paths: PathOwnership, path: string): void {
833
- paths.claim(path);
834
- }
835
-
836
1386
  function normalizePath(path: string): string {
837
1387
  const out: string[] = [];
838
1388
  for (const segment of path.split('/')) {
@@ -854,8 +1404,7 @@ function boundedString(value: unknown, label: string, maxBytes: number): string
854
1404
  if (typeof value !== 'string' || value.length === 0) {
855
1405
  throw new Error(`w7-frame: ${label} must be a non-empty string`);
856
1406
  }
857
- const length = new TextEncoder().encode(value).byteLength;
858
- if (length > maxBytes) throw new Error(`w7-frame: ${label} exceeds ${maxBytes} bytes`);
1407
+ if (utf8Length(value) > maxBytes) throw new Error(`w7-frame: ${label} exceeds ${maxBytes} bytes`);
859
1408
  return value;
860
1409
  }
861
1410
 
@@ -880,6 +1429,7 @@ function sameSummary(left: W7BatchSummary, right: W7BatchSummary): boolean {
880
1429
  && left.fileCount === right.fileCount
881
1430
  && left.chunkCount === right.chunkCount
882
1431
  && left.byteCount === right.byteCount
1432
+ && left.opCount === right.opCount
883
1433
  && left.check === right.check;
884
1434
  }
885
1435
 
@@ -925,16 +1475,29 @@ function noopRetention(bytes: number): W7ChunkRetention {
925
1475
  return { bytes, release() {} };
926
1476
  }
927
1477
 
1478
+ // One decoder for every record: a non-streaming decode() starts from a
1479
+ // clean state each call, so sharing it changes nothing but the cost of
1480
+ // constructing one per record (three per small file).
1481
+ // Both options stated: workers-types declares TextDecoderConstructorOptions
1482
+ // with every property required, and ignoreBOM's default is false anyway.
1483
+ const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false });
1484
+
928
1485
  function decodeText(bytes: Uint8Array, label: string): string {
929
1486
  try {
930
- // Both options stated: workers-types declares TextDecoderConstructorOptions
931
- // with every property required, and ignoreBOM's default is false anyway.
932
- return new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes);
1487
+ return UTF8.decode(bytes);
933
1488
  } catch (error) {
934
1489
  throw new Error(`w7-frame: invalid UTF-8 in ${label}: ${errorMessage(error)}`);
935
1490
  }
936
1491
  }
937
1492
 
1493
+ const TEXT_ENCODER = new TextEncoder();
1494
+
1495
+ function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
1496
+ if (left.byteLength !== right.byteLength) return false;
1497
+ for (let index = 0; index < left.byteLength; index++) if (left[index] !== right[index]) return false;
1498
+ return true;
1499
+ }
1500
+
938
1501
  function concatBytes(...parts: Uint8Array[]): Uint8Array {
939
1502
  const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
940
1503
  let offset = 0;
@@ -977,8 +1540,17 @@ async function cancelReader(
977
1540
  try { reader.releaseLock(); } catch { /* already released */ }
978
1541
  }
979
1542
 
1543
+ /**
1544
+ * Exact reads over a byte stream, served from a block read ahead of them: a
1545
+ * stream read is an await through the stream machinery (and, across RPC, its
1546
+ * pump), so the small reads a record's header and metadata take come out of
1547
+ * one READ_AHEAD_BYTES block instead of one read each. A read larger than
1548
+ * the block (a chunk's data) fills its own buffer directly.
1549
+ */
980
1550
  class ExactByteReader {
981
1551
  private done = false;
1552
+ private block: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
1553
+ private offset = 0;
982
1554
 
983
1555
  constructor(private readonly reader: ReadableStreamBYOBReader, private readonly signal?: AbortSignal) {}
984
1556
 
@@ -995,30 +1567,89 @@ class ExactByteReader {
995
1567
  }
996
1568
  }
997
1569
 
1570
+ /** One stream read into `view`; zero bytes at the end of the stream. */
1571
+ private async fill(view: Uint8Array): Promise<Uint8Array> {
1572
+ const next = await this.read(
1573
+ () => this.reader.read(view),
1574
+ () => this.reader.cancel(this.signal?.reason),
1575
+ );
1576
+ if (next.done) {
1577
+ this.done = true;
1578
+ return new Uint8Array(0);
1579
+ }
1580
+ return next.value;
1581
+ }
1582
+
998
1583
  async readExact(length: number, label: string): Promise<Uint8Array> {
999
1584
  if (length === 0) return new Uint8Array(0);
1585
+ const buffered = this.block.byteLength - this.offset;
1586
+ if (buffered >= length) {
1587
+ const out = this.block.slice(this.offset, this.offset + length);
1588
+ this.offset += length;
1589
+ return out;
1590
+ }
1000
1591
  const output = new Uint8Array(length);
1001
- let offset = 0;
1002
- while (offset < length) {
1592
+ let filled = 0;
1593
+ if (buffered > 0) {
1594
+ output.set(this.block.subarray(this.offset), 0);
1595
+ filled = buffered;
1596
+ }
1597
+ this.offset = this.block.byteLength;
1598
+ while (filled < length) {
1003
1599
  if (this.done) {
1004
1600
  throw new Error(
1005
- `w7-frame: stream ended ${offset} bytes into expected ${length}-byte ${label}`,
1601
+ `w7-frame: stream ended ${filled} bytes into expected ${length}-byte ${label}`,
1006
1602
  );
1007
1603
  }
1008
- const next = await this.read(
1009
- () => this.reader.read(new Uint8Array(length - offset)),
1010
- () => this.reader.cancel(this.signal?.reason),
1011
- );
1012
- if (next.done) this.done = true;
1013
- else if (next.value.byteLength > 0) {
1014
- output.set(next.value, offset);
1015
- offset += next.value.byteLength;
1604
+ const remaining = length - filled;
1605
+ if (remaining >= READ_AHEAD_BYTES) {
1606
+ const got = await this.fill(output.subarray(filled));
1607
+ filled += got.byteLength;
1608
+ // The read took output's buffer and handed it back under a new view.
1609
+ if (got.byteLength > 0) return this.finish(got, length);
1610
+ continue;
1016
1611
  }
1612
+ // A BYOB read transfers the buffer it fills and returns it: the block's
1613
+ // storage is reused from read to read, never reallocated.
1614
+ const got = await this.fill(new Uint8Array(this.spare(), 0, READ_AHEAD_BYTES));
1615
+ const take = Math.min(remaining, got.byteLength);
1616
+ output.set(got.subarray(0, take), filled);
1617
+ filled += take;
1618
+ this.block = got;
1619
+ this.offset = take;
1017
1620
  }
1018
1621
  return output;
1019
1622
  }
1020
1623
 
1624
+ /** The read-ahead storage, taken back from the block once it is drained. */
1625
+ private spare(): ArrayBufferLike {
1626
+ const storage = this.block.buffer;
1627
+ return storage.byteLength >= READ_AHEAD_BYTES ? storage : new ArrayBuffer(READ_AHEAD_BYTES);
1628
+ }
1629
+
1630
+ /**
1631
+ * A large read lands in place: what one read returned is a view over the
1632
+ * output's own (transferred) buffer, so the remainder fills the same one.
1633
+ */
1634
+ private async finish(view: Uint8Array, length: number): Promise<Uint8Array> {
1635
+ let filled = view.byteOffset + view.byteLength;
1636
+ let buffer = view.buffer;
1637
+ while (filled < length) {
1638
+ if (this.done) {
1639
+ throw new Error(`w7-frame: stream ended ${filled} bytes into expected ${length}-byte file-chunk data`);
1640
+ }
1641
+ const got = await this.fill(new Uint8Array(buffer, filled, length - filled));
1642
+ buffer = got.buffer;
1643
+ filled += got.byteLength;
1644
+ }
1645
+ return new Uint8Array(buffer, 0, length);
1646
+ }
1647
+
1021
1648
  async ensureEof(stream: ReadableStream<Uint8Array>): Promise<void> {
1649
+ if (this.offset < this.block.byteLength) {
1650
+ await this.reader.cancel(new Error('w7-frame: trailing bytes after batch-end'));
1651
+ throw new Error('w7-frame: trailing bytes after batch-end');
1652
+ }
1022
1653
  if (this.done) return;
1023
1654
  this.reader.releaseLock();
1024
1655
  const reader = stream.getReader();