@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/README.md +1 -1
- package/dist/heavy-alloc-coord.d.ts +5 -0
- package/dist/heavy-alloc-coord.d.ts.map +1 -1
- package/dist/heavy-alloc-coord.js +12 -1
- package/dist/limits.d.ts +3 -8
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +9 -8
- package/dist/lost-call.d.ts +62 -0
- package/dist/lost-call.d.ts.map +1 -0
- package/dist/lost-call.js +67 -0
- package/dist/module-source.d.ts +31 -0
- package/dist/module-source.d.ts.map +1 -0
- package/dist/module-source.js +52 -0
- package/dist/oom-classify.d.ts +14 -0
- package/dist/oom-classify.d.ts.map +1 -1
- package/dist/oom-classify.js +30 -0
- package/dist/retry.d.ts +49 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +59 -0
- package/dist/utf8.d.ts +11 -0
- package/dist/utf8.d.ts.map +1 -0
- package/dist/utf8.js +35 -0
- package/dist/w7-frame.d.ts +278 -6
- package/dist/w7-frame.d.ts.map +1 -1
- package/dist/w7-frame.js +657 -134
- package/dist/wave-writer.d.ts +392 -0
- package/dist/wave-writer.d.ts.map +1 -0
- package/dist/wave-writer.js +921 -0
- package/package.json +1 -1
- package/src/heavy-alloc-coord.ts +15 -1
- package/src/limits.ts +10 -9
- package/src/lost-call.ts +80 -0
- package/src/module-source.ts +57 -0
- package/src/oom-classify.ts +32 -0
- package/src/retry.ts +74 -0
- package/src/utf8.ts +31 -0
- package/src/w7-frame.ts +780 -149
- package/src/wave-writer.ts +1108 -0
package/src/w7-frame.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* W7
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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 = '
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
373
|
+
parts.push(W7_MAGIC.slice());
|
|
374
|
+
bytes += W7_MAGIC.byteLength;
|
|
186
375
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
460
|
+
throw new Error(`w7-frame: unsupported protocol version ${version}; expected 4`);
|
|
236
461
|
}
|
|
237
|
-
throw new Error(`w7-frame: bad magic, expected NW7\\
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
|
312
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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)
|
|
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:
|
|
584
|
-
const files:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !==
|
|
615
|
-
return { id, 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
|
-
|
|
624
|
-
|
|
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
|
-
['
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
817
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1002
|
-
|
|
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 ${
|
|
1601
|
+
`w7-frame: stream ended ${filled} bytes into expected ${length}-byte ${label}`,
|
|
1006
1602
|
);
|
|
1007
1603
|
}
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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();
|