@nimbus-sh/platform 0.7.0 → 0.8.0

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/dist/w7-frame.js CHANGED
@@ -1,18 +1,51 @@
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
  import { crc32 } from './crc32.js';
6
16
  import { CHUNK_SIZE } from './limits.js';
7
- export const W7_MAGIC = new Uint8Array([0x4e, 0x57, 0x37, 0x03]);
17
+ import { utf8Length } from './utf8.js';
18
+ export const W7_MAGIC = new Uint8Array([0x4e, 0x57, 0x37, 0x04]);
19
+ /** v3's magic: decoded, never encoded, for the release that rolls v4 out. Delete with v3 decoding. */
20
+ const W7_MAGIC_V3 = new Uint8Array([0x4e, 0x57, 0x37, 0x03]);
8
21
  const ENCODER_QUEUE_HWM = 0;
22
+ const ENCODER_PULL_BYTES = 256 * 1024;
23
+ /** What one stream read asks for when a record's small fields are wanted. */
24
+ const READ_AHEAD_BYTES = 64 * 1024;
9
25
  const MAX_METADATA_BYTES = 64 * 1024;
10
26
  const MAX_PATH_BYTES = 64 * 1024;
11
27
  const MAX_BATCH_ID_BYTES = 128;
12
28
  const MAX_CONTENT_ID_BYTES = 256;
13
- export const W7_MAX_PATHS_PER_BATCH = 128;
14
- export const W7_MAX_OWNED_PATH_BYTES = 64 * 1024;
29
+ /**
30
+ * A batch's owned paths. Each stream costs the receiver a round trip and a
31
+ * publication's fixed work, so a batch is as wide as its byte budget allows;
32
+ * ownership is a set of names, small beside the bytes.
33
+ */
34
+ export const W7_MAX_PATHS_PER_BATCH = 1024;
35
+ export const W7_MAX_OWNED_PATH_BYTES = 256 * 1024;
15
36
  export const W7_MAX_RECORD_BYTES = 5 + 4 + MAX_CONTENT_ID_BYTES + 8 + CHUNK_SIZE;
37
+ /** The wire chunks a file or link of `size` bytes travels as: CHUNK_SIZE each, the last short, none when empty. */
38
+ export function w7ChunkCount(size) {
39
+ return size === 0 ? 0 : Math.ceil(size / CHUNK_SIZE);
40
+ }
41
+ /** `data`, the content at `path`, as its wire chunks (w7ChunkCount): views of it, not copies. */
42
+ export function w7Chunks(path, data) {
43
+ const chunks = [];
44
+ for (let chunkId = 0, count = w7ChunkCount(data.byteLength); chunkId < count; chunkId++) {
45
+ chunks.push({ path, chunkId, data: data.subarray(chunkId * CHUNK_SIZE, (chunkId + 1) * CHUNK_SIZE) });
46
+ }
47
+ return chunks;
48
+ }
16
49
  var RecordTag;
17
50
  (function (RecordTag) {
18
51
  RecordTag[RecordTag["BatchBegin"] = 1] = "BatchBegin";
@@ -22,34 +55,59 @@ var RecordTag;
22
55
  RecordTag[RecordTag["FileChunk"] = 5] = "FileChunk";
23
56
  RecordTag[RecordTag["FileEnd"] = 6] = "FileEnd";
24
57
  RecordTag[RecordTag["BatchEnd"] = 7] = "BatchEnd";
58
+ RecordTag[RecordTag["Rename"] = 8] = "Rename";
59
+ RecordTag[RecordTag["Truncate"] = 9] = "Truncate";
60
+ RecordTag[RecordTag["SetAttr"] = 10] = "SetAttr";
61
+ /** A W7PathCall (v4). A data call is a FileBegin whose metadata names its call. */
62
+ RecordTag[RecordTag["Call"] = 11] = "Call";
25
63
  })(RecordTag || (RecordTag = {}));
26
- const MODE = 'path-atomic-committed-prefix';
27
- /** Encode one bounded record per pull; no batch-sized metadata header exists. */
64
+ const MODE = 'program-order-committed-prefix';
65
+ /** v3's batch mode. Delete with v3 decoding. */
66
+ const MODE_V3 = 'path-atomic-committed-prefix';
67
+ /**
68
+ * Encode the records a pull reaches into one enqueued chunk of about
69
+ * ENCODER_PULL_BYTES (a record never splits; a file's chunk is at most
70
+ * CHUNK_SIZE), so a wave crosses the RPC boundary in a few writes rather than
71
+ * one per record. The bytes are the same records either way; no batch-sized
72
+ * metadata header exists.
73
+ */
28
74
  export function encodeWriteBatchStream(payload) {
29
75
  const batchId = crypto.randomUUID();
30
- const { deletes, directories, files } = preparePayload(payload, batchId);
31
- const iterator = encodeRecords(batchId, deletes, directories, files);
76
+ const iterator = encodeRecords(batchId, preparePayload(payload, batchId));
32
77
  let closed = false;
33
78
  let magicEmitted = false;
34
79
  const source = {
35
80
  type: 'bytes',
36
- pull(controller) {
81
+ async pull(controller) {
37
82
  if (closed)
38
83
  return;
39
84
  try {
85
+ const parts = [];
86
+ let bytes = 0;
40
87
  if (!magicEmitted) {
41
88
  magicEmitted = true;
42
- controller.enqueue(W7_MAGIC.slice());
43
- return;
89
+ parts.push(W7_MAGIC.slice());
90
+ bytes += W7_MAGIC.byteLength;
44
91
  }
45
- const next = iterator.next();
46
- if (next.done) {
47
- closed = true;
48
- controller.close();
49
- return;
92
+ while (bytes < ENCODER_PULL_BYTES) {
93
+ const next = await iterator.next();
94
+ if (next.done) {
95
+ closed = true;
96
+ break;
97
+ }
98
+ for (const part of next.value) {
99
+ parts.push(part);
100
+ bytes += part.byteLength;
101
+ }
50
102
  }
51
- for (const part of next.value)
52
- controller.enqueue(part);
103
+ // Every chunk enqueued is one the encoder built: a lone part is the
104
+ // magic or a metadata record, and a file chunk's record is always
105
+ // two parts, so its bytes are copied here. A caller's buffers are
106
+ // never transferred, and a payload may be encoded again.
107
+ if (bytes > 0)
108
+ controller.enqueue(parts.length === 1 ? parts[0] : concatBytes(...parts));
109
+ if (closed)
110
+ controller.close();
53
111
  }
54
112
  catch (error) {
55
113
  closed = true;
@@ -65,6 +123,31 @@ export function encodeWriteBatchStream(payload) {
65
123
  highWaterMark: ENCODER_QUEUE_HWM,
66
124
  });
67
125
  }
126
+ /**
127
+ * The bytes encodeWriteBatchStream would stream for `payload`, as one buffer:
128
+ * for a payload held in memory (no streamed sources), the same records in
129
+ * the same order, copied once.
130
+ */
131
+ export async function encodeWriteBatch(payload) {
132
+ if ((payload.streams?.length ?? 0) > 0)
133
+ throw new Error('w7-frame: a payload with streamed sources is never encoded whole');
134
+ const batchId = crypto.randomUUID();
135
+ const parts = [W7_MAGIC];
136
+ let length = W7_MAGIC.byteLength;
137
+ for await (const record of encodeRecords(batchId, preparePayload(payload, batchId))) {
138
+ for (const part of record) {
139
+ parts.push(part);
140
+ length += part.byteLength;
141
+ }
142
+ }
143
+ const out = new Uint8Array(length);
144
+ let at = 0;
145
+ for (const part of parts) {
146
+ out.set(part, at);
147
+ at += part.byteLength;
148
+ }
149
+ return out;
150
+ }
68
151
  /**
69
152
  * Parse the v3 preamble eagerly, then expose validated operation records
70
153
  * incrementally. Chunk credit is acquired after its bounded header validates
@@ -83,15 +166,16 @@ export async function decodeWriteBatchStream(stream, options = {}) {
83
166
  try {
84
167
  throwIfAborted(options.signal);
85
168
  const magic = await buffer.readExact(W7_MAGIC.length, 'magic');
86
- if (!bytesEqual(magic, W7_MAGIC)) {
169
+ const v3 = bytesEqual(magic, W7_MAGIC_V3);
170
+ if (!v3 && !bytesEqual(magic, W7_MAGIC)) {
87
171
  const version = magic.length === 4
88
172
  && magic[0] === 0x4e && magic[1] === 0x57 && magic[2] === 0x37
89
173
  ? magic[3]
90
174
  : null;
91
175
  if (version !== null) {
92
- throw new Error(`w7-frame: unsupported protocol version ${version}; expected 3`);
176
+ throw new Error(`w7-frame: unsupported protocol version ${version}; expected 4`);
93
177
  }
94
- throw new Error(`w7-frame: bad magic, expected NW7\\x03, got ${hex(magic)}`);
178
+ throw new Error(`w7-frame: bad magic, expected NW7\\x04, got ${hex(magic)}`);
95
179
  }
96
180
  const beginEnvelope = await readEnvelope(buffer, 'batch-begin');
97
181
  if (beginEnvelope.tag !== RecordTag.BatchBegin) {
@@ -101,13 +185,13 @@ export async function decodeWriteBatchStream(stream, options = {}) {
101
185
  throw new Error(`w7-frame: batch-begin length ${beginEnvelope.length} exceeds ${MAX_METADATA_BYTES}`);
102
186
  }
103
187
  const beginPayload = await buffer.readExact(beginEnvelope.length, 'batch-begin payload');
104
- const begin = parseBatchBegin(beginPayload);
188
+ const begin = parseBatchBegin(beginPayload, v3 ? MODE_V3 : MODE);
105
189
  const initialCheck = updateRecordCheck(0, beginEnvelope.header, beginPayload);
106
190
  handedOff = true;
107
191
  return {
108
192
  batchId: begin.id,
109
193
  mode: begin.mode,
110
- records: decodeRecords(stream, reader, buffer, options, initialCheck),
194
+ records: decodeRecords(stream, reader, buffer, options, initialCheck, v3),
111
195
  };
112
196
  }
113
197
  catch (error) {
@@ -116,8 +200,10 @@ export async function decodeWriteBatchStream(stream, options = {}) {
116
200
  throw error;
117
201
  }
118
202
  }
119
- async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
120
- const ownedPaths = new PathOwnership();
203
+ async function* decodeRecords(stream, reader, buffer, options, initialCheck,
204
+ /** A v3 stream: no renames, truncates or attribute changes, and each path once. */
205
+ v3) {
206
+ const ownedPaths = new PathOwnership(!v3);
121
207
  const contentIds = new Set();
122
208
  let active = null;
123
209
  let batchCheck = initialCheck;
@@ -129,6 +215,7 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
129
215
  fileCount: 0,
130
216
  chunkCount: 0,
131
217
  byteCount: 0,
218
+ opCount: 0,
132
219
  };
133
220
  let completed = false;
134
221
  let failure = new DOMException('W7 consumer cancelled', 'AbortError');
@@ -142,26 +229,28 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
142
229
  if (envelope.tag === RecordTag.FileChunk) {
143
230
  if (!active)
144
231
  throw new Error('w7-frame: file-chunk without active file');
145
- const prefixLength = Math.min(envelope.length, 4 + MAX_CONTENT_ID_BYTES + 8);
146
- const idLengthBytes = await buffer.readExact(4, 'file-chunk content-id length');
147
- const idLength = readU32LE(idLengthBytes, 0);
232
+ // The chunk must name its file's content id, so its prefix is that
233
+ // id's length plus the id-length, chunk-id and data-length fields:
234
+ // read whole, then checked field by field.
235
+ const expectedId = active.contentIdBytes;
236
+ const prefixLength = 4 + expectedId.byteLength + 8;
237
+ if (prefixLength > envelope.length) {
238
+ throw new Error('w7-frame: malformed file-chunk record length');
239
+ }
240
+ const headerPayload = await buffer.readExact(prefixLength, 'file-chunk header');
241
+ const idLength = readU32LE(headerPayload, 0);
148
242
  if (idLength === 0 || idLength > MAX_CONTENT_ID_BYTES) {
149
243
  throw new Error(`w7-frame: invalid file-chunk content-id length ${idLength}`);
150
244
  }
151
- const remainingPrefixLength = idLength + 8;
152
- if (4 + remainingPrefixLength > prefixLength || 4 + remainingPrefixLength > envelope.length) {
153
- throw new Error('w7-frame: malformed file-chunk record length');
245
+ if (idLength !== expectedId.byteLength || !sameBytes(headerPayload.subarray(4, 4 + idLength), expectedId)) {
246
+ throw new Error(`w7-frame: file-chunk content id does not own ${active.inode.path}`);
154
247
  }
155
- const rest = await buffer.readExact(remainingPrefixLength, 'file-chunk header');
156
- const contentId = decodeText(rest.subarray(0, idLength), 'file-chunk content id');
157
- const chunkId = readU32LE(rest, idLength);
158
- const dataLength = readU32LE(rest, idLength + 4);
248
+ const chunkId = readU32LE(headerPayload, 4 + idLength);
249
+ const dataLength = readU32LE(headerPayload, 8 + idLength);
159
250
  if (envelope.length !== 4 + idLength + 8 + dataLength) {
160
251
  throw new Error('w7-frame: file-chunk payload length mismatch');
161
252
  }
162
- if (contentId !== active.metadata.contentId) {
163
- throw new Error(`w7-frame: file-chunk content id ${contentId} does not own ${active.inode.path}`);
164
- }
253
+ const contentId = active.metadata.contentId;
165
254
  if (chunkId !== active.nextChunkId) {
166
255
  throw new Error(`w7-frame: ${active.inode.path}: expected chunk ${active.nextChunkId}, got ${chunkId}`);
167
256
  }
@@ -172,7 +261,6 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
172
261
  if (dataLength !== expectedBytes || dataLength > CHUNK_SIZE) {
173
262
  throw new Error(`w7-frame: ${active.inode.path}: chunk ${chunkId} has ${dataLength} bytes; expected ${expectedBytes}`);
174
263
  }
175
- const headerPayload = concatBytes(idLengthBytes, rest);
176
264
  let retention = null;
177
265
  try {
178
266
  retention = options.retainChunk
@@ -214,23 +302,23 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
214
302
  switch (envelope.tag) {
215
303
  case RecordTag.Delete: {
216
304
  const metadata = parseDelete(payload);
217
- claimPath(ownedPaths, metadata.path);
305
+ ownedPaths.claim(metadata.path);
218
306
  summary.pathCount++;
219
307
  summary.deleteCount++;
220
308
  yield { type: 'delete', path: metadata.path };
221
309
  break;
222
310
  }
223
311
  case RecordTag.Directory: {
224
- const metadata = parseDirectory(payload);
225
- claimPath(ownedPaths, metadata.path);
312
+ const metadata = parseDirectory(payload, v3);
313
+ ownedPaths.claim(metadata.path);
226
314
  summary.pathCount++;
227
315
  summary.directoryCount++;
228
316
  yield { type: 'directory', inode: directoryInode(metadata) };
229
317
  break;
230
318
  }
231
319
  case RecordTag.FileBegin: {
232
- const metadata = parseFileBegin(payload);
233
- claimPath(ownedPaths, metadata.path);
320
+ const metadata = parseFileBegin(payload, v3);
321
+ ownedPaths.claim(metadata.path);
234
322
  if (contentIds.has(metadata.contentId)) {
235
323
  throw new Error(`w7-frame: duplicate stream content id ${metadata.contentId}`);
236
324
  }
@@ -240,6 +328,7 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
240
328
  summary.fileCount++;
241
329
  active = {
242
330
  metadata,
331
+ contentIdBytes: TEXT_ENCODER.encode(metadata.contentId),
243
332
  inode,
244
333
  nextChunkId: 0,
245
334
  receivedBytes: 0,
@@ -278,10 +367,44 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
278
367
  yield record;
279
368
  break;
280
369
  }
370
+ case RecordTag.Call: {
371
+ if (v3)
372
+ throw new Error(`w7-frame: unknown record tag ${envelope.tag}`);
373
+ summary.pathCount++;
374
+ summary.opCount++;
375
+ 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']);
376
+ yield { type: 'call', call: parsePathCall(value, (path, label) => ownedPaths.claim(canonicalPath(path, label))) };
377
+ break;
378
+ }
379
+ case RecordTag.Rename:
380
+ case RecordTag.Truncate:
381
+ case RecordTag.SetAttr: {
382
+ if (v3)
383
+ throw new Error(`w7-frame: unknown record tag ${envelope.tag}`);
384
+ summary.pathCount++;
385
+ summary.opCount++;
386
+ if (envelope.tag === RecordTag.Rename) {
387
+ const value = parseObject(payload, 'rename', ['from', 'to']);
388
+ const from = ownedPaths.claim(canonicalPath(value.from, 'rename source'));
389
+ const to = ownedPaths.claim(canonicalPath(value.to, 'rename target'));
390
+ yield { type: 'rename', from, to };
391
+ }
392
+ else if (envelope.tag === RecordTag.Truncate) {
393
+ const value = parseObject(payload, 'truncate', ['path', 'size']);
394
+ yield { type: 'truncate', path: ownedPaths.claim(canonicalPath(value.path, 'truncate path')), size: safeInteger(value.size, 'truncate size') };
395
+ }
396
+ else {
397
+ const value = parseObject(payload, 'setattr', ['path'], ['mode', 'uid', 'gid', 'atime', 'mtime']);
398
+ const path = ownedPaths.claim(canonicalPath(value.path, 'setattr path'));
399
+ const { path: _path, ...attrs } = value;
400
+ yield { type: 'setattr', path, attrs: parseAttrs(attrs, 'setattr') };
401
+ }
402
+ break;
403
+ }
281
404
  case RecordTag.BatchEnd: {
282
405
  if (active)
283
406
  throw new Error(`w7-frame: batch-end while file ${active.inode.path} is active`);
284
- const actual = parseBatchEnd(payload);
407
+ const actual = parseBatchEnd(payload, v3);
285
408
  const expected = { ...summary, check: batchCheck };
286
409
  if (!sameSummary(actual, expected)) {
287
410
  throw new Error(`w7-frame: batch-end summary mismatch; expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
@@ -314,7 +437,7 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
314
437
  }
315
438
  }
316
439
  }
317
- function* encodeRecords(batchId, deletes, directories, files) {
440
+ async function* encodeRecords(batchId, ops) {
318
441
  const state = {
319
442
  batchCheck: 0,
320
443
  summary: {
@@ -325,55 +448,46 @@ function* encodeRecords(batchId, deletes, directories, files) {
325
448
  fileCount: 0,
326
449
  chunkCount: 0,
327
450
  byteCount: 0,
451
+ opCount: 0,
328
452
  },
329
453
  };
330
454
  yield encodeMetadataRecord(RecordTag.BatchBegin, { id: batchId, mode: MODE }, state);
331
- for (const path of deletes) {
332
- state.summary.pathCount++;
333
- state.summary.deleteCount++;
334
- yield encodeMetadataRecord(RecordTag.Delete, { path }, state);
335
- }
336
- for (const inode of directories) {
337
- state.summary.pathCount++;
338
- state.summary.directoryCount++;
339
- yield encodeMetadataRecord(RecordTag.Directory, {
340
- ...inodeMetadata(inode),
341
- kind: inode.kind,
342
- }, state);
343
- }
344
- for (const file of files) {
345
- state.summary.pathCount++;
346
- state.summary.fileCount++;
347
- yield encodeMetadataRecord(RecordTag.FileBegin, {
348
- ...inodeMetadata(file.inode),
349
- kind: file.inode.kind,
350
- contentId: file.contentId,
351
- size: file.inode.size,
352
- chunkCount: file.inode.chunkCount,
353
- }, state);
354
- let fileCheck = 0;
355
- for (const chunk of file.chunks) {
356
- const data = chunk.data;
357
- const contentBytes = new TextEncoder().encode(file.contentId);
358
- const prefix = new Uint8Array(4 + contentBytes.length + 8);
359
- writeU32LE(prefix, 0, contentBytes.length);
360
- prefix.set(contentBytes, 4);
361
- writeU32LE(prefix, 4 + contentBytes.length, chunk.chunkId);
362
- writeU32LE(prefix, 8 + contentBytes.length, data.byteLength);
363
- const header = recordHeader(RecordTag.FileChunk, prefix.byteLength + data.byteLength);
364
- state.batchCheck = updateRecordCheck(state.batchCheck, header, prefix, data);
365
- state.summary.recordCount++;
366
- state.summary.chunkCount++;
367
- state.summary.byteCount += data.byteLength;
368
- fileCheck = crc32(data, fileCheck);
369
- yield [concatBytes(header, prefix), data];
370
- }
371
- yield encodeMetadataRecord(RecordTag.FileEnd, {
372
- contentId: file.contentId,
373
- size: file.inode.size,
374
- chunkCount: file.inode.chunkCount,
375
- check: fileCheck,
376
- }, state);
455
+ for (const op of ops) {
456
+ switch (op.kind) {
457
+ case 'delete':
458
+ state.summary.pathCount++;
459
+ state.summary.deleteCount++;
460
+ yield encodeMetadataRecord(RecordTag.Delete, { path: op.path }, state);
461
+ break;
462
+ case 'directory':
463
+ state.summary.pathCount++;
464
+ state.summary.directoryCount++;
465
+ yield encodeMetadataRecord(RecordTag.Directory, { ...inodeMetadata(op.inode), kind: op.inode.kind }, state);
466
+ break;
467
+ case 'file':
468
+ yield* encodeFile(op.file, state);
469
+ break;
470
+ case 'rename':
471
+ state.summary.pathCount++;
472
+ state.summary.opCount++;
473
+ yield encodeMetadataRecord(RecordTag.Rename, { from: op.from, to: op.to }, state);
474
+ break;
475
+ case 'truncate':
476
+ state.summary.pathCount++;
477
+ state.summary.opCount++;
478
+ yield encodeMetadataRecord(RecordTag.Truncate, { path: op.path, size: op.size }, state);
479
+ break;
480
+ case 'setattr':
481
+ state.summary.pathCount++;
482
+ state.summary.opCount++;
483
+ yield encodeMetadataRecord(RecordTag.SetAttr, { path: op.path, ...op.attrs }, state);
484
+ break;
485
+ case 'call':
486
+ state.summary.pathCount++;
487
+ state.summary.opCount++;
488
+ yield encodeMetadataRecord(RecordTag.Call, { ...op.call }, state);
489
+ break;
490
+ }
377
491
  }
378
492
  const end = {
379
493
  ...state.summary,
@@ -381,6 +495,83 @@ function* encodeRecords(batchId, deletes, directories, files) {
381
495
  };
382
496
  yield encodeMetadataRecord(RecordTag.BatchEnd, end);
383
497
  }
498
+ /** One file's records: its begin, its chunks, its end. */
499
+ async function* encodeFile(file, state) {
500
+ state.summary.pathCount++;
501
+ state.summary.fileCount++;
502
+ yield encodeMetadataRecord(RecordTag.FileBegin, {
503
+ ...inodeMetadata(file.inode),
504
+ kind: file.inode.kind,
505
+ contentId: file.contentId,
506
+ size: file.inode.size,
507
+ chunkCount: file.inode.chunkCount,
508
+ ...(file.inode.call === undefined ? {} : { call: file.inode.call }),
509
+ ...(file.inode.offset === undefined ? {} : { offset: file.inode.offset }),
510
+ ...(file.inode.umask === undefined ? {} : { umask: file.inode.umask }),
511
+ ...(file.inode.description === undefined ? {} : { description: file.inode.description }),
512
+ }, state);
513
+ let fileCheck = 0;
514
+ let chunkId = 0;
515
+ const pieces = file.source === null ? givenChunks(file.chunks) : fixedChunks(file.inode, file.source);
516
+ for await (const data of pieces) {
517
+ const contentBytes = new TextEncoder().encode(file.contentId);
518
+ const prefix = new Uint8Array(4 + contentBytes.length + 8);
519
+ writeU32LE(prefix, 0, contentBytes.length);
520
+ prefix.set(contentBytes, 4);
521
+ writeU32LE(prefix, 4 + contentBytes.length, chunkId++);
522
+ writeU32LE(prefix, 8 + contentBytes.length, data.byteLength);
523
+ const header = recordHeader(RecordTag.FileChunk, prefix.byteLength + data.byteLength);
524
+ state.batchCheck = updateRecordCheck(state.batchCheck, header, prefix, data);
525
+ state.summary.recordCount++;
526
+ state.summary.chunkCount++;
527
+ state.summary.byteCount += data.byteLength;
528
+ fileCheck = crc32(data, fileCheck);
529
+ yield [concatBytes(header, prefix), data];
530
+ }
531
+ yield encodeMetadataRecord(RecordTag.FileEnd, {
532
+ contentId: file.contentId,
533
+ size: file.inode.size,
534
+ chunkCount: file.inode.chunkCount,
535
+ check: fileCheck,
536
+ }, state);
537
+ }
538
+ /** Each chunk's bytes, read as the encoder reaches it (a producer's `data` may copy on access). */
539
+ function* givenChunks(chunks) {
540
+ for (const chunk of chunks)
541
+ yield chunk.data;
542
+ }
543
+ /**
544
+ * A streamed file's bytes as the wire's positional chunks: CHUNK_SIZE each but
545
+ * the last, every one over its own buffer (the stream transfers what it
546
+ * enqueues). The source must yield exactly the inode's size.
547
+ */
548
+ async function* fixedChunks(inode, source) {
549
+ let pending = new Uint8Array(Math.min(CHUNK_SIZE, inode.size));
550
+ let filled = 0;
551
+ let total = 0;
552
+ for await (const part of source) {
553
+ if (!(part instanceof Uint8Array))
554
+ throw new Error(`w7-frame: ${inode.path}: streamed piece is not bytes`);
555
+ if (total + part.byteLength > inode.size) {
556
+ throw new Error(`w7-frame: ${inode.path}: streamed source exceeds its ${inode.size} bytes`);
557
+ }
558
+ total += part.byteLength;
559
+ for (let offset = 0; offset < part.byteLength;) {
560
+ const take = Math.min(pending.byteLength - filled, part.byteLength - offset);
561
+ pending.set(part.subarray(offset, offset + take), filled);
562
+ filled += take;
563
+ offset += take;
564
+ if (filled === pending.byteLength) {
565
+ yield pending;
566
+ pending = new Uint8Array(Math.min(CHUNK_SIZE, inode.size - total + (part.byteLength - offset)));
567
+ filled = 0;
568
+ }
569
+ }
570
+ }
571
+ if (total !== inode.size) {
572
+ throw new Error(`w7-frame: ${inode.path}: streamed source ended at ${total} of ${inode.size} bytes`);
573
+ }
574
+ }
384
575
  function encodeMetadataRecord(tag, value, state) {
385
576
  const payload = new TextEncoder().encode(JSON.stringify(value));
386
577
  if (payload.byteLength > MAX_METADATA_BYTES) {
@@ -393,14 +584,28 @@ function encodeMetadataRecord(tag, value, state) {
393
584
  }
394
585
  return [concatBytes(header, payload)];
395
586
  }
587
+ /**
588
+ * The payload's operations in the order they are encoded: its `ops` as
589
+ * given, or its deletes, then its directories, then its files (one per path,
590
+ * since chunks are matched to their file by path).
591
+ */
396
592
  function preparePayload(payload, batchId) {
397
593
  if (!payload || !Array.isArray(payload.inodes) || !Array.isArray(payload.chunks)) {
398
594
  throw new Error('w7-frame: payload must contain inode and chunk arrays');
399
595
  }
400
- const ownedPaths = new PathOwnership();
596
+ if (payload.ops !== undefined)
597
+ return prepareOps(payload, batchId);
598
+ const ownedPaths = new PathOwnership(false);
401
599
  const deletes = [...(payload.deletePaths ?? [])];
402
600
  for (const path of deletes)
403
- claimPath(ownedPaths, canonicalPath(path, 'delete path'));
601
+ ownedPaths.claim(canonicalPath(path, 'delete path'));
602
+ const streamsByPath = new Map();
603
+ for (const stream of payload.streams ?? []) {
604
+ const path = canonicalPath(stream.path, 'stream path');
605
+ if (streamsByPath.has(path))
606
+ throw new Error(`w7-frame: duplicate stream for ${path}`);
607
+ streamsByPath.set(path, stream.source);
608
+ }
404
609
  const chunksByPath = new Map();
405
610
  for (const chunk of payload.chunks) {
406
611
  const path = canonicalPath(chunk.path, 'chunk path');
@@ -417,49 +622,134 @@ function preparePayload(payload, batchId) {
417
622
  const path = canonicalPath(inode.path, 'inode path');
418
623
  if (path !== inode.path)
419
624
  throw new Error(`w7-frame: noncanonical inode path ${inode.path}`);
420
- claimPath(ownedPaths, path);
625
+ ownedPaths.claim(path);
421
626
  const normalizedInode = normalizeInode(inode);
422
627
  const fileChunks = chunksByPath.get(path) ?? [];
628
+ const streamed = streamsByPath.get(path) ?? null;
423
629
  if (normalizedInode.kind === 'directory') {
424
- if (fileChunks.length > 0)
630
+ if (fileChunks.length > 0 || streamed !== null)
425
631
  throw new Error(`w7-frame: directory ${path} has chunks`);
426
- directories.push(normalizedInode);
632
+ directories.push({ kind: 'directory', inode: normalizedInode });
427
633
  }
428
634
  else {
429
- validateChunks(normalizedInode, fileChunks);
635
+ if (streamed === null)
636
+ validateChunks(normalizedInode, fileChunks);
637
+ else if (fileChunks.length > 0)
638
+ throw new Error(`w7-frame: streamed file ${path} also has chunks`);
430
639
  files.push({
431
- inode: normalizedInode,
432
- contentId: `${batchId}:${fileIndex++}`,
433
- chunks: fileChunks,
640
+ kind: 'file',
641
+ file: { inode: normalizedInode, contentId: `${batchId}:${fileIndex++}`, chunks: fileChunks, source: streamed },
434
642
  });
435
643
  }
436
644
  chunksByPath.delete(path);
645
+ streamsByPath.delete(path);
437
646
  }
438
647
  if (chunksByPath.size > 0) {
439
648
  throw new Error(`w7-frame: chunk has no inode: ${chunksByPath.keys().next().value}`);
440
649
  }
441
- return { deletes, directories, files };
650
+ if (streamsByPath.size > 0) {
651
+ throw new Error(`w7-frame: stream has no inode: ${streamsByPath.keys().next().value}`);
652
+ }
653
+ return [...deletes.map((path) => ({ kind: 'delete', path })), ...directories, ...files];
654
+ }
655
+ /** A program-order payload's operations, each checked as the decoder will check it. */
656
+ function prepareOps(payload, batchId) {
657
+ if (payload.inodes.length > 0 || payload.chunks.length > 0 || (payload.deletePaths?.length ?? 0) > 0 || (payload.streams?.length ?? 0) > 0) {
658
+ throw new Error('w7-frame: a payload of ops carries nothing else');
659
+ }
660
+ const ownedPaths = new PathOwnership(true);
661
+ let fileIndex = 0;
662
+ return payload.ops.map((op) => {
663
+ switch (op.type) {
664
+ case 'delete':
665
+ return { kind: 'delete', path: ownedPaths.claim(canonicalPath(op.path, 'delete path')) };
666
+ case 'directory': {
667
+ const inode = normalizeInode({ ...op.inode, path: ownedPaths.claim(canonicalPath(op.inode.path, 'inode path')) });
668
+ if (inode.kind !== 'directory')
669
+ throw new Error(`w7-frame: directory op ${inode.path} is a ${inode.kind}`);
670
+ return { kind: 'directory', inode };
671
+ }
672
+ case 'file': {
673
+ const inode = normalizeInode({ ...op.inode, path: ownedPaths.claim(canonicalPath(op.inode.path, 'inode path')) });
674
+ if (inode.kind === 'directory')
675
+ throw new Error(`w7-frame: file op ${inode.path} is a directory`);
676
+ const contentId = `${batchId}:${fileIndex++}`;
677
+ if ('source' in op)
678
+ return { kind: 'file', file: { inode, contentId, chunks: [], source: op.source } };
679
+ const chunks = w7Chunks(inode.path, op.data);
680
+ validateChunks(inode, chunks);
681
+ return { kind: 'file', file: { inode, contentId, chunks, source: null } };
682
+ }
683
+ case 'rename':
684
+ return {
685
+ kind: 'rename',
686
+ from: ownedPaths.claim(canonicalPath(op.from, 'rename source')),
687
+ to: ownedPaths.claim(canonicalPath(op.to, 'rename target')),
688
+ };
689
+ case 'truncate':
690
+ return { kind: 'truncate', path: ownedPaths.claim(canonicalPath(op.path, 'truncate path')), size: safeInteger(op.size, 'truncate size') };
691
+ case 'setattr':
692
+ return { kind: 'setattr', path: ownedPaths.claim(canonicalPath(op.path, 'setattr path')), attrs: parseAttrs(op.attrs, 'setattr') };
693
+ case 'call': {
694
+ const call = op.call;
695
+ if ('data' in call) {
696
+ const path = ownedPaths.claim(canonicalPath(call.path, `${call.call} path`));
697
+ // A call's file stamps no time of its own: the session's operation does.
698
+ const inode = normalizeInode({
699
+ path, parentPath: parentPath(path), kind: 'file', isDir: false,
700
+ size: call.data.byteLength, mtime: 0, mode: 'mode' in call ? u32(call.mode, `${call.call} mode`) : 0, chunkCount: w7ChunkCount(call.data.byteLength),
701
+ ...('ino' in call && call.ino !== undefined ? { ino: inodeNumber(call.ino, `${call.call} ino`) } : {}),
702
+ });
703
+ inode.call = call.call;
704
+ if (call.call === 'write')
705
+ inode.offset = safeInteger(call.offset, 'write offset');
706
+ if ('umask' in call && call.umask !== undefined)
707
+ inode.umask = umaskOf(call.umask, `${call.call} umask`);
708
+ if ('description' in call && call.description !== undefined)
709
+ inode.description = descriptionId(call.description, `${call.call} description`);
710
+ const chunks = w7Chunks(path, call.data);
711
+ return { kind: 'file', file: { inode, contentId: `${batchId}:${fileIndex++}`, chunks, source: null } };
712
+ }
713
+ return { kind: 'call', call: parsePathCall({ ...call }, (path, label) => ownedPaths.claim(canonicalPath(path, label))) };
714
+ }
715
+ }
716
+ });
442
717
  }
443
- function parseBatchBegin(bytes) {
718
+ function parseBatchBegin(bytes, mode) {
444
719
  const value = parseObject(bytes, 'batch-begin', ['id', 'mode']);
445
720
  const id = boundedString(value.id, 'batch id', MAX_BATCH_ID_BYTES);
446
- if (value.mode !== MODE)
721
+ if (value.mode !== mode)
447
722
  throw new Error(`w7-frame: unsupported batch mode ${String(value.mode)}`);
448
- return { id, mode: MODE };
723
+ return { id, mode };
724
+ }
725
+ /** One attribute change: exactly the mode, the owner (uid and gid), or the times (atime and mtime). */
726
+ function parseAttrs(value, label) {
727
+ const keys = Object.keys(value).sort().join(',');
728
+ if (keys === 'mode')
729
+ return { mode: u32(value.mode, `${label} mode`) };
730
+ if (keys === 'gid,uid')
731
+ return { uid: u32(value.uid, `${label} uid`), gid: u32(value.gid, `${label} gid`) };
732
+ if (keys === 'atime,mtime')
733
+ return { atime: safeInteger(value.atime, `${label} atime`), mtime: safeInteger(value.mtime, `${label} mtime`) };
734
+ throw new Error(`w7-frame: ${label} changes the mode, the owner or the times, one of them: got ${keys || 'nothing'}`);
449
735
  }
450
736
  function parseDelete(bytes) {
451
737
  const value = parseObject(bytes, 'delete', ['path']);
452
738
  return { path: canonicalPath(value.path, 'delete path') };
453
739
  }
454
- function parseDirectory(bytes) {
455
- const value = parseObject(bytes, 'directory', ['path', 'kind', 'mtime', 'mode'], ['atime']);
740
+ /** A v3 record names no inode number; a v4 one may. */
741
+ function inodeOptional(v3) {
742
+ return v3 ? ['atime'] : ['atime', 'ino'];
743
+ }
744
+ function parseDirectory(bytes, v3) {
745
+ const value = parseObject(bytes, 'directory', ['path', 'kind', 'mtime', 'mode'], inodeOptional(v3));
456
746
  if (value.kind !== 'directory') {
457
747
  throw new Error(`w7-frame: unsupported directory kind ${String(value.kind)}`);
458
748
  }
459
749
  return { ...parseInodeMetadata(value, 'directory'), kind: 'directory' };
460
750
  }
461
- function parseFileBegin(bytes) {
462
- const value = parseObject(bytes, 'file-begin', ['path', 'kind', 'contentId', 'size', 'chunkCount', 'mtime', 'mode'], ['atime']);
751
+ function parseFileBegin(bytes, v3) {
752
+ const value = parseObject(bytes, 'file-begin', ['path', 'kind', 'contentId', 'size', 'chunkCount', 'mtime', 'mode'], v3 ? inodeOptional(v3) : [...inodeOptional(v3), 'call', 'offset', 'umask', 'description']);
463
753
  const base = parseInodeMetadata(value, 'file-begin');
464
754
  if (value.kind !== 'file' && value.kind !== 'symlink') {
465
755
  throw new Error(`w7-frame: unsupported file-begin kind ${String(value.kind)}`);
@@ -470,11 +760,124 @@ function parseFileBegin(bytes) {
470
760
  }
471
761
  const size = safeInteger(value.size, 'file size');
472
762
  const chunkCount = u32(value.chunkCount, 'file chunk count');
473
- const expected = size === 0 ? 0 : Math.ceil(size / CHUNK_SIZE);
763
+ const expected = w7ChunkCount(size);
474
764
  if (chunkCount !== expected) {
475
765
  throw new Error(`w7-frame: ${base.path}: expected ${expected} chunks, got ${chunkCount}`);
476
766
  }
477
- return { ...base, kind: value.kind, contentId, size, chunkCount };
767
+ const call = value.call;
768
+ if (call !== undefined && call !== 'writeFile' && call !== 'appendFile' && call !== 'write' && call !== 'append') {
769
+ throw new Error(`w7-frame: ${base.path}: unknown data call ${String(call)}`);
770
+ }
771
+ if (call !== undefined && value.kind !== 'file')
772
+ throw new Error(`w7-frame: ${base.path}: a ${String(call)} writes a file`);
773
+ // An offset is a write's, and only a description's writes name an inode.
774
+ if ((call === 'write') !== (value.offset !== undefined))
775
+ throw new Error(`w7-frame: ${base.path}: a write, and only a write, has an offset`);
776
+ // A umask is a call's that makes a name with a mode; a description, a description's write's.
777
+ if (value.umask !== undefined && call !== 'writeFile' && call !== 'appendFile')
778
+ throw new Error(`w7-frame: ${base.path}: only a writeFile or appendFile has a umask`);
779
+ if (value.description !== undefined && call !== 'write' && call !== 'append')
780
+ throw new Error(`w7-frame: ${base.path}: only a write or append names a description`);
781
+ return {
782
+ ...base, kind: value.kind, contentId, size, chunkCount,
783
+ ...(call === undefined ? {} : { call }),
784
+ ...(value.offset === undefined ? {} : { offset: safeInteger(value.offset, 'write offset') }),
785
+ ...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'file-begin umask') }),
786
+ ...(value.description === undefined ? {} : { description: descriptionId(value.description, 'file-begin description') }),
787
+ };
788
+ }
789
+ /** A path call's fields, exactly: its paths made canonical (and claimed) by `path`. */
790
+ function parsePathCall(value, path) {
791
+ const keys = Object.keys(value).sort().join(',');
792
+ switch (value.call) {
793
+ case 'mkdir': {
794
+ const optional = keys.replace(',existing', '').replace(',ino', '').replace(',umask', '');
795
+ if (optional !== 'call,mode,path')
796
+ break;
797
+ if (value.existing !== undefined && value.existing !== 'ok')
798
+ throw new Error(`w7-frame: mkdir existing is 'ok' or absent, not ${String(value.existing)}`);
799
+ return {
800
+ call: 'mkdir', path: path(value.path, 'mkdir path'), mode: u32(value.mode, 'mkdir mode'),
801
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'mkdir ino') }),
802
+ ...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'mkdir umask') }),
803
+ ...(value.existing === undefined ? {} : { existing: 'ok' }),
804
+ };
805
+ }
806
+ case 'unlink':
807
+ case 'rmdir':
808
+ if (keys !== 'call,path')
809
+ break;
810
+ return { call: value.call, path: path(value.path, `${value.call} path`) };
811
+ case 'symlink':
812
+ if (keys !== 'call,path,target' && keys !== 'call,ino,path,target')
813
+ break;
814
+ return {
815
+ call: 'symlink', path: path(value.path, 'symlink path'), target: boundedString(value.target, 'symlink target', MAX_PATH_BYTES),
816
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'symlink ino') }),
817
+ };
818
+ case 'ftruncate':
819
+ if (keys.replace(',description', '').replace(',ino', '') !== 'call,path,size')
820
+ break;
821
+ return {
822
+ call: 'ftruncate', path: path(value.path, 'ftruncate path'), size: safeInteger(value.size, 'ftruncate size'),
823
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'ftruncate ino') }),
824
+ ...(value.description === undefined ? {} : { description: descriptionId(value.description, 'ftruncate description') }),
825
+ };
826
+ case 'close':
827
+ if (keys !== 'call,description,path')
828
+ break;
829
+ return { call: 'close', path: path(value.path, 'close path'), description: descriptionId(value.description, 'close description') };
830
+ case 'rm': {
831
+ if (keys.replace(',force', '').replace(',recursive', '') !== 'call,path')
832
+ break;
833
+ for (const flag of ['recursive', 'force']) {
834
+ if (value[flag] !== undefined && value[flag] !== true)
835
+ throw new Error(`w7-frame: rm ${flag} is true or absent`);
836
+ }
837
+ return {
838
+ call: 'rm', path: path(value.path, 'rm path'),
839
+ ...(value.recursive === true ? { recursive: true } : {}),
840
+ ...(value.force === true ? { force: true } : {}),
841
+ };
842
+ }
843
+ case 'lchown':
844
+ if (keys !== 'call,gid,path,uid')
845
+ break;
846
+ return { call: 'lchown', path: path(value.path, 'lchown path'), uid: u32(value.uid, 'lchown uid'), gid: u32(value.gid, 'lchown gid') };
847
+ case 'open': {
848
+ const flags = ['create', 'exclusive', 'nofollow', 'read', 'truncate'];
849
+ let required = keys;
850
+ for (const flag of ['create', 'description', 'exclusive', 'ino', 'nofollow', 'read', 'truncate', 'umask'])
851
+ required = required.replace(`,${flag}`, '');
852
+ if (required !== 'call,mode,path')
853
+ break;
854
+ for (const flag of flags) {
855
+ if (value[flag] !== undefined && value[flag] !== true)
856
+ throw new Error(`w7-frame: open ${flag} is true or absent`);
857
+ }
858
+ return {
859
+ call: 'open', path: path(value.path, 'open path'), mode: u32(value.mode, 'open mode'),
860
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'open ino') }),
861
+ ...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'open umask') }),
862
+ ...(value.create === true ? { create: true } : {}),
863
+ ...(value.truncate === true ? { truncate: true } : {}),
864
+ ...(value.exclusive === true ? { exclusive: true } : {}),
865
+ ...(value.nofollow === true ? { nofollow: true } : {}),
866
+ ...(value.read === true ? { read: true } : {}),
867
+ ...(value.description === undefined ? {} : { description: descriptionId(value.description, 'open description') }),
868
+ };
869
+ }
870
+ case 'lutimes':
871
+ if (keys !== 'atime,call,mtime,path')
872
+ break;
873
+ return {
874
+ call: 'lutimes', path: path(value.path, 'lutimes path'),
875
+ atime: safeInteger(value.atime, 'lutimes atime'), mtime: safeInteger(value.mtime, 'lutimes mtime'),
876
+ };
877
+ default:
878
+ throw new Error(`w7-frame: unknown call ${String(value.call)}`);
879
+ }
880
+ throw new Error(`w7-frame: ${String(value.call)} takes other fields: got ${keys}`);
478
881
  }
479
882
  function parseFileEnd(bytes) {
480
883
  const value = parseObject(bytes, 'file-end', ['contentId', 'size', 'chunkCount', 'check']);
@@ -485,10 +888,10 @@ function parseFileEnd(bytes) {
485
888
  check: u32(value.check, 'file-end check'),
486
889
  };
487
890
  }
488
- function parseBatchEnd(bytes) {
891
+ function parseBatchEnd(bytes, v3) {
489
892
  const keys = [
490
893
  'recordCount', 'pathCount', 'deleteCount', 'directoryCount',
491
- 'fileCount', 'chunkCount', 'byteCount', 'check',
894
+ 'fileCount', 'chunkCount', 'byteCount', 'check', ...(v3 ? [] : ['opCount']),
492
895
  ];
493
896
  const value = parseObject(bytes, 'batch-end', keys);
494
897
  return {
@@ -499,6 +902,7 @@ function parseBatchEnd(bytes) {
499
902
  fileCount: safeInteger(value.fileCount, 'batch file count'),
500
903
  chunkCount: safeInteger(value.chunkCount, 'batch chunk count'),
501
904
  byteCount: safeInteger(value.byteCount, 'batch byte count'),
905
+ opCount: v3 ? 0 : safeInteger(value.opCount, 'batch op count'),
502
906
  check: u32(value.check, 'batch check'),
503
907
  };
504
908
  }
@@ -508,8 +912,30 @@ function parseInodeMetadata(value, label) {
508
912
  ...(value.atime === undefined ? {} : { atime: safeInteger(value.atime, `${label} atime`) }),
509
913
  mtime: safeInteger(value.mtime, `${label} mtime`),
510
914
  mode: u32(value.mode, `${label} mode`),
915
+ ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, `${label} ino`) }),
511
916
  };
512
917
  }
918
+ /** An open description's id, as a process chose it: up to 64 characters of [A-Za-z0-9_-]. */
919
+ function descriptionId(value, label) {
920
+ const id = boundedString(value, label, 64);
921
+ if (!/^[A-Za-z0-9_-]+$/.test(id))
922
+ throw new Error(`w7-frame: ${label} is not a description id`);
923
+ return id;
924
+ }
925
+ /** A umask: permission bits, 0 to 0o777. */
926
+ function umaskOf(value, label) {
927
+ const mask = u32(value, label);
928
+ if (mask > 0o777)
929
+ throw new Error(`w7-frame: ${label} must be at most 0o777`);
930
+ return mask;
931
+ }
932
+ /** An inode number: an integer from 2 (1 is the root's). */
933
+ function inodeNumber(value, label) {
934
+ const ino = safeInteger(value, label);
935
+ if (ino < 2)
936
+ throw new Error(`w7-frame: ${label} must be at least 2`);
937
+ return ino;
938
+ }
513
939
  function parseObject(bytes, label, required, optional = []) {
514
940
  let value;
515
941
  try {
@@ -543,6 +969,8 @@ function normalizeInode(inode) {
543
969
  safeInteger(inode.mtime, `${inode.path} mtime`);
544
970
  if (inode.atime !== undefined)
545
971
  safeInteger(inode.atime, `${inode.path} atime`);
972
+ if (inode.ino !== undefined)
973
+ inodeNumber(inode.ino, `${inode.path} ino`);
546
974
  u32(inode.mode, `${inode.path} mode`);
547
975
  const rawKind = inode.kind ?? (inode.isDir ? 'directory' : 'file');
548
976
  if (rawKind !== 'file' && rawKind !== 'directory' && rawKind !== 'symlink') {
@@ -559,7 +987,7 @@ function normalizeInode(inode) {
559
987
  throw new Error(`w7-frame: directory ${inode.path} must have zero size and chunks`);
560
988
  }
561
989
  if (kind !== 'directory') {
562
- const expected = inode.size === 0 ? 0 : Math.ceil(inode.size / CHUNK_SIZE);
990
+ const expected = w7ChunkCount(inode.size);
563
991
  if (inode.chunkCount !== expected) {
564
992
  throw new Error(`w7-frame: ${inode.path}: expected ${expected} chunks, got ${inode.chunkCount}`);
565
993
  }
@@ -594,6 +1022,7 @@ function directoryInode(metadata) {
594
1022
  mtime: metadata.mtime,
595
1023
  mode: metadata.mode,
596
1024
  chunkCount: 0,
1025
+ ...(metadata.ino === undefined ? {} : { ino: metadata.ino }),
597
1026
  };
598
1027
  }
599
1028
  function fileInode(metadata) {
@@ -607,6 +1036,11 @@ function fileInode(metadata) {
607
1036
  mtime: metadata.mtime,
608
1037
  mode: metadata.mode,
609
1038
  chunkCount: metadata.chunkCount,
1039
+ ...(metadata.ino === undefined ? {} : { ino: metadata.ino }),
1040
+ ...(metadata.call === undefined ? {} : { call: metadata.call }),
1041
+ ...(metadata.offset === undefined ? {} : { offset: metadata.offset }),
1042
+ ...(metadata.umask === undefined ? {} : { umask: metadata.umask }),
1043
+ ...(metadata.description === undefined ? {} : { description: metadata.description }),
610
1044
  };
611
1045
  }
612
1046
  function inodeMetadata(inode) {
@@ -615,6 +1049,7 @@ function inodeMetadata(inode) {
615
1049
  ...(inode.atime === undefined ? {} : { atime: inode.atime }),
616
1050
  mtime: inode.mtime,
617
1051
  mode: inode.mode,
1052
+ ...(inode.ino === undefined ? {} : { ino: inode.ino }),
618
1053
  };
619
1054
  }
620
1055
  function canonicalPath(value, label) {
@@ -626,26 +1061,36 @@ function canonicalPath(value, label) {
626
1061
  throw new Error(`w7-frame: noncanonical ${label}: ${path}`);
627
1062
  return path;
628
1063
  }
1064
+ /**
1065
+ * The distinct paths a batch names, within W7's bounds. A program-order
1066
+ * batch (v4) may name a path in several operations; a v3 batch, and a payload
1067
+ * matched to its chunks by path, names each once.
1068
+ */
629
1069
  class PathOwnership {
1070
+ repeats;
630
1071
  paths = new Set();
631
1072
  pathBytes = 0;
1073
+ constructor(repeats) {
1074
+ this.repeats = repeats;
1075
+ }
632
1076
  claim(path) {
633
- if (this.paths.has(path))
1077
+ if (this.paths.has(path)) {
1078
+ if (this.repeats)
1079
+ return path;
634
1080
  throw new Error(`w7-frame: duplicate path ownership: ${path}`);
1081
+ }
635
1082
  if (this.paths.size >= W7_MAX_PATHS_PER_BATCH) {
636
1083
  throw new Error(`w7-frame: batch exceeds ${W7_MAX_PATHS_PER_BATCH} owned paths`);
637
1084
  }
638
- const nextPathBytes = this.pathBytes + new TextEncoder().encode(path).byteLength;
1085
+ const nextPathBytes = this.pathBytes + utf8Length(path);
639
1086
  if (nextPathBytes > W7_MAX_OWNED_PATH_BYTES) {
640
1087
  throw new Error(`w7-frame: owned path bytes exceed ${W7_MAX_OWNED_PATH_BYTES}`);
641
1088
  }
642
1089
  this.paths.add(path);
643
1090
  this.pathBytes = nextPathBytes;
1091
+ return path;
644
1092
  }
645
1093
  }
646
- function claimPath(paths, path) {
647
- paths.claim(path);
648
- }
649
1094
  function normalizePath(path) {
650
1095
  const out = [];
651
1096
  for (const segment of path.split('/')) {
@@ -667,8 +1112,7 @@ function boundedString(value, label, maxBytes) {
667
1112
  if (typeof value !== 'string' || value.length === 0) {
668
1113
  throw new Error(`w7-frame: ${label} must be a non-empty string`);
669
1114
  }
670
- const length = new TextEncoder().encode(value).byteLength;
671
- if (length > maxBytes)
1115
+ if (utf8Length(value) > maxBytes)
672
1116
  throw new Error(`w7-frame: ${label} exceeds ${maxBytes} bytes`);
673
1117
  return value;
674
1118
  }
@@ -692,6 +1136,7 @@ function sameSummary(left, right) {
692
1136
  && left.fileCount === right.fileCount
693
1137
  && left.chunkCount === right.chunkCount
694
1138
  && left.byteCount === right.byteCount
1139
+ && left.opCount === right.opCount
695
1140
  && left.check === right.check;
696
1141
  }
697
1142
  async function readEnvelope(buffer, label) {
@@ -725,16 +1170,29 @@ function updateRecordCheck(seed, ...parts) {
725
1170
  function noopRetention(bytes) {
726
1171
  return { bytes, release() { } };
727
1172
  }
1173
+ // One decoder for every record: a non-streaming decode() starts from a
1174
+ // clean state each call, so sharing it changes nothing but the cost of
1175
+ // constructing one per record (three per small file).
1176
+ // Both options stated: workers-types declares TextDecoderConstructorOptions
1177
+ // with every property required, and ignoreBOM's default is false anyway.
1178
+ const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false });
728
1179
  function decodeText(bytes, label) {
729
1180
  try {
730
- // Both options stated: workers-types declares TextDecoderConstructorOptions
731
- // with every property required, and ignoreBOM's default is false anyway.
732
- return new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes);
1181
+ return UTF8.decode(bytes);
733
1182
  }
734
1183
  catch (error) {
735
1184
  throw new Error(`w7-frame: invalid UTF-8 in ${label}: ${errorMessage(error)}`);
736
1185
  }
737
1186
  }
1187
+ const TEXT_ENCODER = new TextEncoder();
1188
+ function sameBytes(left, right) {
1189
+ if (left.byteLength !== right.byteLength)
1190
+ return false;
1191
+ for (let index = 0; index < left.byteLength; index++)
1192
+ if (left[index] !== right[index])
1193
+ return false;
1194
+ return true;
1195
+ }
738
1196
  function concatBytes(...parts) {
739
1197
  const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
740
1198
  let offset = 0;
@@ -771,10 +1229,19 @@ async function cancelReader(reader, reason) {
771
1229
  }
772
1230
  catch { /* already released */ }
773
1231
  }
1232
+ /**
1233
+ * Exact reads over a byte stream, served from a block read ahead of them: a
1234
+ * stream read is an await through the stream machinery (and, across RPC, its
1235
+ * pump), so the small reads a record's header and metadata take come out of
1236
+ * one READ_AHEAD_BYTES block instead of one read each. A read larger than
1237
+ * the block (a chunk's data) fills its own buffer directly.
1238
+ */
774
1239
  class ExactByteReader {
775
1240
  reader;
776
1241
  signal;
777
1242
  done = false;
1243
+ block = new Uint8Array(0);
1244
+ offset = 0;
778
1245
  constructor(reader, signal) {
779
1246
  this.reader = reader;
780
1247
  this.signal = signal;
@@ -792,26 +1259,82 @@ class ExactByteReader {
792
1259
  this.signal?.removeEventListener('abort', abort);
793
1260
  }
794
1261
  }
1262
+ /** One stream read into `view`; zero bytes at the end of the stream. */
1263
+ async fill(view) {
1264
+ const next = await this.read(() => this.reader.read(view), () => this.reader.cancel(this.signal?.reason));
1265
+ if (next.done) {
1266
+ this.done = true;
1267
+ return new Uint8Array(0);
1268
+ }
1269
+ return next.value;
1270
+ }
795
1271
  async readExact(length, label) {
796
1272
  if (length === 0)
797
1273
  return new Uint8Array(0);
1274
+ const buffered = this.block.byteLength - this.offset;
1275
+ if (buffered >= length) {
1276
+ const out = this.block.slice(this.offset, this.offset + length);
1277
+ this.offset += length;
1278
+ return out;
1279
+ }
798
1280
  const output = new Uint8Array(length);
799
- let offset = 0;
800
- while (offset < length) {
1281
+ let filled = 0;
1282
+ if (buffered > 0) {
1283
+ output.set(this.block.subarray(this.offset), 0);
1284
+ filled = buffered;
1285
+ }
1286
+ this.offset = this.block.byteLength;
1287
+ while (filled < length) {
801
1288
  if (this.done) {
802
- throw new Error(`w7-frame: stream ended ${offset} bytes into expected ${length}-byte ${label}`);
1289
+ throw new Error(`w7-frame: stream ended ${filled} bytes into expected ${length}-byte ${label}`);
803
1290
  }
804
- const next = await this.read(() => this.reader.read(new Uint8Array(length - offset)), () => this.reader.cancel(this.signal?.reason));
805
- if (next.done)
806
- this.done = true;
807
- else if (next.value.byteLength > 0) {
808
- output.set(next.value, offset);
809
- offset += next.value.byteLength;
1291
+ const remaining = length - filled;
1292
+ if (remaining >= READ_AHEAD_BYTES) {
1293
+ const got = await this.fill(output.subarray(filled));
1294
+ filled += got.byteLength;
1295
+ // The read took output's buffer and handed it back under a new view.
1296
+ if (got.byteLength > 0)
1297
+ return this.finish(got, length);
1298
+ continue;
810
1299
  }
1300
+ // A BYOB read transfers the buffer it fills and returns it: the block's
1301
+ // storage is reused from read to read, never reallocated.
1302
+ const got = await this.fill(new Uint8Array(this.spare(), 0, READ_AHEAD_BYTES));
1303
+ const take = Math.min(remaining, got.byteLength);
1304
+ output.set(got.subarray(0, take), filled);
1305
+ filled += take;
1306
+ this.block = got;
1307
+ this.offset = take;
811
1308
  }
812
1309
  return output;
813
1310
  }
1311
+ /** The read-ahead storage, taken back from the block once it is drained. */
1312
+ spare() {
1313
+ const storage = this.block.buffer;
1314
+ return storage.byteLength >= READ_AHEAD_BYTES ? storage : new ArrayBuffer(READ_AHEAD_BYTES);
1315
+ }
1316
+ /**
1317
+ * A large read lands in place: what one read returned is a view over the
1318
+ * output's own (transferred) buffer, so the remainder fills the same one.
1319
+ */
1320
+ async finish(view, length) {
1321
+ let filled = view.byteOffset + view.byteLength;
1322
+ let buffer = view.buffer;
1323
+ while (filled < length) {
1324
+ if (this.done) {
1325
+ throw new Error(`w7-frame: stream ended ${filled} bytes into expected ${length}-byte file-chunk data`);
1326
+ }
1327
+ const got = await this.fill(new Uint8Array(buffer, filled, length - filled));
1328
+ buffer = got.buffer;
1329
+ filled += got.byteLength;
1330
+ }
1331
+ return new Uint8Array(buffer, 0, length);
1332
+ }
814
1333
  async ensureEof(stream) {
1334
+ if (this.offset < this.block.byteLength) {
1335
+ await this.reader.cancel(new Error('w7-frame: trailing bytes after batch-end'));
1336
+ throw new Error('w7-frame: trailing bytes after batch-end');
1337
+ }
815
1338
  if (this.done)
816
1339
  return;
817
1340
  this.reader.releaseLock();