@nimbus-sh/platform 0.8.2 → 0.14.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.
Files changed (67) hide show
  1. package/dist/composition.d.ts +3 -2
  2. package/dist/composition.d.ts.map +1 -1
  3. package/dist/composition.js +34 -19
  4. package/dist/crc32.d.ts +5 -5
  5. package/dist/crc32.d.ts.map +1 -1
  6. package/dist/crc32.js +8 -12
  7. package/dist/diag-counters.d.ts +18 -10
  8. package/dist/diag-counters.d.ts.map +1 -1
  9. package/dist/diag-counters.js +7 -20
  10. package/dist/heap-estimate.d.ts +5 -20
  11. package/dist/heap-estimate.d.ts.map +1 -1
  12. package/dist/heap-estimate.js +6 -33
  13. package/dist/heavy-alloc-coord.d.ts +131 -11
  14. package/dist/heavy-alloc-coord.d.ts.map +1 -1
  15. package/dist/heavy-alloc-coord.js +241 -22
  16. package/dist/hex.d.ts +20 -0
  17. package/dist/hex.d.ts.map +1 -0
  18. package/dist/hex.js +29 -0
  19. package/dist/install-phase.d.ts +1 -15
  20. package/dist/install-phase.d.ts.map +1 -1
  21. package/dist/lexical-path.d.ts +11 -0
  22. package/dist/lexical-path.d.ts.map +1 -0
  23. package/dist/lexical-path.js +19 -0
  24. package/dist/limits.d.ts +4 -0
  25. package/dist/limits.d.ts.map +1 -1
  26. package/dist/limits.js +25 -0
  27. package/dist/module-source.d.ts.map +1 -1
  28. package/dist/module-source.js +2 -1
  29. package/dist/oom-classify.d.ts +4 -0
  30. package/dist/oom-classify.d.ts.map +1 -1
  31. package/dist/oom-classify.js +6 -0
  32. package/dist/realm-builtin.d.ts +2 -0
  33. package/dist/realm-builtin.d.ts.map +1 -0
  34. package/dist/realm-builtin.js +11 -0
  35. package/dist/utf8.d.ts +21 -1
  36. package/dist/utf8.d.ts.map +1 -1
  37. package/dist/utf8.js +73 -1
  38. package/dist/w7-frame.d.ts +92 -17
  39. package/dist/w7-frame.d.ts.map +1 -1
  40. package/dist/w7-frame.js +87 -35
  41. package/dist/wave-writer.d.ts +11 -6
  42. package/dist/wave-writer.d.ts.map +1 -1
  43. package/dist/wave-writer.js +28 -29
  44. package/dist/weighted-credit-pool.d.ts +43 -0
  45. package/dist/weighted-credit-pool.d.ts.map +1 -1
  46. package/dist/weighted-credit-pool.js +250 -46
  47. package/dist/worker-code.d.ts +48 -0
  48. package/dist/worker-code.d.ts.map +1 -0
  49. package/dist/worker-code.js +10 -0
  50. package/package.json +2 -2
  51. package/src/composition.ts +41 -21
  52. package/src/crc32.ts +9 -12
  53. package/src/diag-counters.ts +23 -22
  54. package/src/heap-estimate.ts +11 -40
  55. package/src/heavy-alloc-coord.ts +275 -20
  56. package/src/hex.ts +29 -0
  57. package/src/install-phase.ts +1 -15
  58. package/src/lexical-path.ts +17 -0
  59. package/src/limits.ts +29 -0
  60. package/src/module-source.ts +3 -1
  61. package/src/oom-classify.ts +8 -0
  62. package/src/realm-builtin.ts +22 -0
  63. package/src/utf8.ts +75 -1
  64. package/src/w7-frame.ts +152 -53
  65. package/src/wave-writer.ts +28 -27
  66. package/src/weighted-credit-pool.ts +256 -51
  67. package/src/worker-code.ts +38 -0
package/src/w7-frame.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import { crc32 } from './crc32.js';
17
17
  import { CHUNK_SIZE } from './limits.js';
18
18
  import { utf8Length } from './utf8.js';
19
+ import { rootClampedSegments } from './lexical-path.js';
19
20
 
20
21
  // The batch payload types live WITH the wire format that encodes them: every
21
22
  // producer and consumer of a W7 stream speaks exactly these records, and the
@@ -79,11 +80,26 @@ export interface BatchWritePayload {
79
80
  ops?: W7Op[];
80
81
  }
81
82
 
82
- /** One attribute change (setattr): the mode, the owner, or the times; what chmod, chown and utimes each make. */
83
+ /**
84
+ * One attribute change (setattr): the mode, the owner, or the times; what
85
+ * chmod, chown and utimes each make. An owner side that is null keeps what
86
+ * the file has when the change lands (chown's -1).
87
+ */
88
+ /**
89
+ * A time utimes and futimes set, as utimensat(2) takes it: ms since the
90
+ * epoch (explicit: only the owner may set it), 'now' (UTIME_NOW: the
91
+ * clock of the filesystem that commits the change, as it commits it) or
92
+ * 'omit' (UTIME_OMIT: left as it is then). 'now' and 'omit' need the owner or write
93
+ * permission. Carried as it is to the filesystem that commits the change,
94
+ * which alone resolves it (posix-access.ts, timeAt) and judges it
95
+ * (utimesRefusal, explicitTimes).
96
+ */
97
+ export type FsTime = number | 'now' | 'omit';
98
+
83
99
  export type W7Attrs =
84
100
  | { mode: number }
85
- | { uid: number; gid: number }
86
- | { atime: number; mtime: number };
101
+ | { uid: number | null; gid: number | null }
102
+ | { atime: FsTime; mtime: FsTime };
87
103
 
88
104
  /**
89
105
  * A process's filesystem call as a record (v4), applied by the session's own
@@ -94,6 +110,36 @@ export type W7Attrs =
94
110
  * nothing makes a missing parent. The program-order counterpart of the
95
111
  * syscall, where `file` and `directory` are a checkout's upserts.
96
112
  */
113
+ /** What a W7 open asks of its file, as open(2)'s O_ACCMODE: to read it, to write it, or both. */
114
+ export type W7OpenAccess = 'read' | 'write' | 'readwrite';
115
+ function isOpenAccess(value: unknown): value is W7OpenAccess {
116
+ return value === 'read' || value === 'write' || value === 'readwrite';
117
+ }
118
+
119
+ /** The rights an open's access grants its description: what a call through it may do. */
120
+ export function openAccessRights(access: W7OpenAccess): { read: boolean; write: boolean } {
121
+ return { read: access !== 'write', write: access !== 'read' };
122
+ }
123
+
124
+ /** The permission bits an open asks of the file it reaches (4 read, 2 write): its access's, and write under `truncate`, as open(2)'s. */
125
+ export function openAccessMask(call: { access: W7OpenAccess; truncate?: true }): number {
126
+ const rights = openAccessRights(call.access);
127
+ return (rights.read ? 4 : 0) | (rights.write || call.truncate === true ? 2 : 0);
128
+ }
129
+
130
+ /**
131
+ * What a process decided a call on from its own view of the name: the file
132
+ * it expected there (its number on the session's filesystem), none (null),
133
+ * or the file its open of description `made` reached, for a file that open
134
+ * made and the process has no number of yet (it keeps that description
135
+ * open until it has). Where the name leads elsewhere now (a stale view: a
136
+ * peer replaced the name, or a link there leads to another file; or that
137
+ * open was refused), the session refuses the call before any effect:
138
+ * ENOENT where it expected a file, EEXIST where it expected none, EXDEV
139
+ * where the name leads to a mount.
140
+ */
141
+ export type W7Expected = number | null | { readonly made: string };
142
+
97
143
  export type W7Call =
98
144
  /**
99
145
  * `ino`, on a call that makes a name (writeFile, appendFile, mkdir,
@@ -122,9 +168,17 @@ export type W7Call =
122
168
  | { call: 'ftruncate'; path: string; ino?: number; description?: string; size: number }
123
169
  /** `existing: 'ok'`: a directory already there answers success, as `mkdir -p` takes it (anything else there is still EEXIST). */
124
170
  | { call: 'mkdir'; path: string; mode: number; ino?: number; umask?: number; existing?: 'ok' }
125
- | { call: 'unlink'; path: string }
126
- | { call: 'rmdir'; path: string }
171
+ | { call: 'unlink'; path: string; expect?: W7Expected }
172
+ | { call: 'rmdir'; path: string; expect?: W7Expected }
127
173
  | { call: 'symlink'; path: string; target: string; ino?: number }
174
+ /**
175
+ * copyfile as the session copies a file: the file \`from\` names (a link
176
+ * followed) to \`path\` (a link there followed, a file there replaced),
177
+ * by reference within one filesystem and by its bytes across, no byte of
178
+ * it in the wave. The copy's mode is a copy's there: a new file the
179
+ * source's less the umask, a file there its own.
180
+ */
181
+ | { call: 'copyFile'; path: string; from: string }
128
182
  /**
129
183
  * rm(1) as fs.rm makes it: a file or link unlinked, a directory with all
130
184
  * it holds when `recursive` (else EISDIR's refusal, as unlink's), and a
@@ -132,22 +186,27 @@ export type W7Call =
132
186
  */
133
187
  | { call: 'rm'; path: string; recursive?: true; force?: true }
134
188
  /** 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 }
189
+ | { call: 'lchown'; path: string; uid: number | null; gid: number | null }
136
190
  /** utimes of the link itself (lutimes). */
137
191
  | { call: 'lutimes'; path: string; atime: number; mtime: number }
138
192
  /**
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),
193
+ * open(2) of a file with the access `access` asks, as O_ACCMODE does
194
+ * (W7OpenAccess), as the session's own open decides it: a name made
195
+ * (`create`, `mode` less `umask`) or refused (EEXIST when `exclusive`,
196
+ * ENOENT without `create`, EISDIR, EACCES for any access asked that the
197
+ * file's mode refuses, write too under `truncate`: openAccessMask),
143
198
  * emptied when `truncate`, a link at the name followed unless `nofollow`
144
199
  * (ELOOP). Its answer is the file's stat. With `description` (an id the
145
200
  * 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).
201
+ * rights the access's (openAccessRights); the description's write, append
202
+ * and ftruncate calls name it, and its `close` ends it. `ino`: the number
203
+ * a name it makes is given (a delegation's, as a mkdir's). `fchmod`: the
204
+ * file it opened then given exactly this mode (fchmod(2), as libuv's
205
+ * copyfile narrows its destination before a byte reaches it), part of the
206
+ * open: refused (EPERM: another's file), the open is refused and keeps no
207
+ * description, so a write through it is EBADF.
149
208
  */
150
- | { call: 'open'; path: string; mode: number; ino?: number; umask?: number; read?: true; create?: true; truncate?: true; exclusive?: true; nofollow?: true; description?: string }
209
+ | { call: 'open'; path: string; mode: number; access: W7OpenAccess; ino?: number; umask?: number; create?: true; truncate?: true; exclusive?: true; nofollow?: true; description?: string; fchmod?: number; expect?: W7Expected }
151
210
  /** close(2) of the session's open description `description` (an `open` call's). */
152
211
  | { call: 'close'; path: string; description: string };
153
212
 
@@ -162,9 +221,11 @@ export type W7Op =
162
221
  | { type: 'directory'; inode: BatchInodeEntry }
163
222
  | { type: 'file'; inode: BatchInodeEntry; data: Uint8Array }
164
223
  | { type: 'file'; inode: BatchInodeEntry; source: AsyncIterable<Uint8Array> }
165
- | { type: 'rename'; from: string; to: string }
224
+ /** `expect`, `expectTo`: the files the process expected at `from` and at `to` (W7Expected). */
225
+ | { type: 'rename'; from: string; to: string; expect?: W7Expected; expectTo?: W7Expected }
166
226
  | { type: 'truncate'; path: string; size: number }
167
- | { type: 'setattr'; path: string; attrs: W7Attrs }
227
+ /** `description`: through the session's open description of that id (fchmod, fchown, futimens): its file, whatever names it now. */
228
+ | { type: 'setattr'; path: string; attrs: W7Attrs; description?: string; expect?: W7Expected }
168
229
  | { type: 'call'; call: W7Call };
169
230
 
170
231
  export const W7_MAGIC = new Uint8Array([0x4e, 0x57, 0x37, 0x04]);
@@ -315,9 +376,9 @@ export type W7DecodedRecord =
315
376
  chunkCount: number;
316
377
  check: number;
317
378
  }
318
- | { type: 'rename'; from: string; to: string }
379
+ | { type: 'rename'; from: string; to: string; expect?: W7Expected; expectTo?: W7Expected }
319
380
  | { type: 'truncate'; path: string; size: number }
320
- | { type: 'setattr'; path: string; attrs: W7Attrs }
381
+ | { type: 'setattr'; path: string; attrs: W7Attrs; description?: string; expect?: W7Expected }
321
382
  | { type: 'call'; call: W7PathCall }
322
383
  | { type: 'batch-end'; summary: W7BatchSummary };
323
384
 
@@ -332,9 +393,9 @@ type EncoderOp =
332
393
  | { kind: 'delete'; path: string }
333
394
  | { kind: 'directory'; inode: W7DirectoryInode }
334
395
  | { kind: 'file'; file: EncoderFile }
335
- | { kind: 'rename'; from: string; to: string }
396
+ | { kind: 'rename'; from: string; to: string; expect?: W7Expected; expectTo?: W7Expected }
336
397
  | { kind: 'truncate'; path: string; size: number }
337
- | { kind: 'setattr'; path: string; attrs: W7Attrs }
398
+ | { kind: 'setattr'; path: string; attrs: W7Attrs; description?: string; expect?: W7Expected }
338
399
  | { kind: 'call'; call: W7PathCall };
339
400
 
340
401
  interface EncoderFile {
@@ -677,7 +738,7 @@ async function* decodeRecords(
677
738
  if (v3) throw new Error(`w7-frame: unknown record tag ${envelope.tag}`);
678
739
  summary.pathCount++;
679
740
  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']);
741
+ const value = parseObject(payload, 'call', ['call', 'path'], ['mode', 'target', 'from', 'ino', 'size', 'existing', 'recursive', 'force', 'uid', 'gid', 'atime', 'mtime', 'umask', 'access', 'create', 'truncate', 'exclusive', 'nofollow', 'description', 'fchmod', 'expect']);
681
742
  yield { type: 'call', call: parsePathCall(value, (path, label) => ownedPaths.claim(canonicalPath(path, label))) };
682
743
  break;
683
744
  }
@@ -688,18 +749,21 @@ async function* decodeRecords(
688
749
  summary.pathCount++;
689
750
  summary.opCount++;
690
751
  if (envelope.tag === RecordTag.Rename) {
691
- const value = parseObject(payload, 'rename', ['from', 'to']);
752
+ const value = parseObject(payload, 'rename', ['from', 'to'], ['expect', 'expectTo']);
692
753
  const from = ownedPaths.claim(canonicalPath(value.from, 'rename source'));
693
754
  const to = ownedPaths.claim(canonicalPath(value.to, 'rename target'));
694
- yield { type: 'rename', from, to };
755
+ yield { type: 'rename', from, to, ...expectations(value, 'rename') };
695
756
  } else if (envelope.tag === RecordTag.Truncate) {
696
757
  const value = parseObject(payload, 'truncate', ['path', 'size']);
697
758
  yield { type: 'truncate', path: ownedPaths.claim(canonicalPath(value.path, 'truncate path')), size: safeInteger(value.size, 'truncate size') };
698
759
  } else {
699
- const value = parseObject(payload, 'setattr', ['path'], ['mode', 'uid', 'gid', 'atime', 'mtime']);
760
+ const value = parseObject(payload, 'setattr', ['path'], ['mode', 'uid', 'gid', 'atime', 'mtime', 'description', 'expect']);
700
761
  const path = ownedPaths.claim(canonicalPath(value.path, 'setattr path'));
701
- const { path: _path, ...attrs } = value;
702
- yield { type: 'setattr', path, attrs: parseAttrs(attrs, 'setattr') };
762
+ const { path: _path, description, expect: _expect, ...attrs } = value;
763
+ yield {
764
+ type: 'setattr', path, attrs: parseAttrs(attrs, 'setattr'),
765
+ ...(description === undefined ? {} : { description: descriptionId(description, 'setattr description') }), ...expectations(value, 'setattr'),
766
+ };
703
767
  }
704
768
  break;
705
769
  }
@@ -768,7 +832,7 @@ async function* encodeRecords(batchId: string, ops: readonly EncoderOp[]): Async
768
832
  case 'rename':
769
833
  state.summary.pathCount++;
770
834
  state.summary.opCount++;
771
- yield encodeMetadataRecord(RecordTag.Rename, { from: op.from, to: op.to }, state);
835
+ yield encodeMetadataRecord(RecordTag.Rename, { from: op.from, to: op.to, ...expectations(op, 'rename') }, state);
772
836
  break;
773
837
  case 'truncate':
774
838
  state.summary.pathCount++;
@@ -778,7 +842,7 @@ async function* encodeRecords(batchId: string, ops: readonly EncoderOp[]): Async
778
842
  case 'setattr':
779
843
  state.summary.pathCount++;
780
844
  state.summary.opCount++;
781
- yield encodeMetadataRecord(RecordTag.SetAttr, { path: op.path, ...op.attrs }, state);
845
+ yield encodeMetadataRecord(RecordTag.SetAttr, { path: op.path, ...op.attrs, ...(op.description === undefined ? {} : { description: op.description }), ...expectations(op, 'setattr') }, state);
782
846
  break;
783
847
  case 'call':
784
848
  state.summary.pathCount++;
@@ -812,8 +876,8 @@ async function* encodeFile(file: EncoderFile, state: EncoderState): AsyncGenerat
812
876
  let fileCheck = 0;
813
877
  let chunkId = 0;
814
878
  const pieces = file.source === null ? givenChunks(file.chunks) : fixedChunks(file.inode, file.source);
879
+ const contentBytes = TEXT_ENCODER.encode(file.contentId);
815
880
  for await (const data of pieces) {
816
- const contentBytes = new TextEncoder().encode(file.contentId);
817
881
  const prefix = new Uint8Array(4 + contentBytes.length + 8);
818
882
  writeU32LE(prefix, 0, contentBytes.length);
819
883
  prefix.set(contentBytes, 4);
@@ -880,16 +944,20 @@ function encodeMetadataRecord(
880
944
  value: object,
881
945
  state?: EncoderState,
882
946
  ): Uint8Array[] {
883
- const payload = new TextEncoder().encode(JSON.stringify(value));
947
+ const payload = TEXT_ENCODER.encode(JSON.stringify(value));
884
948
  if (payload.byteLength > MAX_METADATA_BYTES) {
885
949
  throw new Error(`w7-frame: metadata record exceeds ${MAX_METADATA_BYTES} bytes`);
886
950
  }
887
- const header = recordHeader(tag, payload.byteLength);
951
+ // The header and the payload in one buffer, checked as one (crc32 composes).
952
+ const record = new Uint8Array(5 + payload.byteLength);
953
+ record[0] = tag;
954
+ writeU32LE(record, 1, payload.byteLength);
955
+ record.set(payload, 5);
888
956
  if (state && tag !== RecordTag.BatchEnd) {
889
- state.batchCheck = updateRecordCheck(state.batchCheck, header, payload);
957
+ state.batchCheck = updateRecordCheck(state.batchCheck, record);
890
958
  state.summary.recordCount++;
891
959
  }
892
- return [concatBytes(header, payload)];
960
+ return [record];
893
961
  }
894
962
 
895
963
  /**
@@ -981,11 +1049,16 @@ function prepareOps(payload: BatchWritePayload, batchId: string): EncoderOp[] {
981
1049
  kind: 'rename',
982
1050
  from: ownedPaths.claim(canonicalPath(op.from, 'rename source')),
983
1051
  to: ownedPaths.claim(canonicalPath(op.to, 'rename target')),
1052
+ ...expectations(op, 'rename'),
984
1053
  };
985
1054
  case 'truncate':
986
1055
  return { kind: 'truncate', path: ownedPaths.claim(canonicalPath(op.path, 'truncate path')), size: safeInteger(op.size, 'truncate size') };
987
1056
  case 'setattr':
988
- return { kind: 'setattr', path: ownedPaths.claim(canonicalPath(op.path, 'setattr path')), attrs: parseAttrs(op.attrs as Record<string, unknown>, 'setattr') };
1057
+ return {
1058
+ kind: 'setattr', path: ownedPaths.claim(canonicalPath(op.path, 'setattr path')), attrs: parseAttrs(op.attrs as Record<string, unknown>, 'setattr'),
1059
+ ...(op.description === undefined ? {} : { description: descriptionId(op.description, 'setattr description') }),
1060
+ ...expectations(op, 'setattr'),
1061
+ };
989
1062
  case 'call': {
990
1063
  const call = op.call;
991
1064
  if ('data' in call) {
@@ -1020,11 +1093,18 @@ function parseBatchBegin(bytes: Uint8Array, mode: W7Mode): BatchBeginMetadata {
1020
1093
  function parseAttrs(value: Record<string, unknown>, label: string): W7Attrs {
1021
1094
  const keys = Object.keys(value).sort().join(',');
1022
1095
  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`) };
1096
+ if (keys === 'gid,uid') return { uid: ownerId(value.uid, `${label} uid`), gid: ownerId(value.gid, `${label} gid`) };
1097
+ if (keys === 'atime,mtime') return { atime: parseFsTime(value.atime, `w7-frame: ${label} atime`), mtime: parseFsTime(value.mtime, `w7-frame: ${label} mtime`) };
1025
1098
  throw new Error(`w7-frame: ${label} changes the mode, the owner or the times, one of them: got ${keys || 'nothing'}`);
1026
1099
  }
1027
1100
 
1101
+ /** `value` as an FsTime (a finite number of milliseconds, 'now' or 'omit'); else an error naming `label`. */
1102
+ export function parseFsTime(value: unknown, label: string): FsTime {
1103
+ if (value === 'now' || value === 'omit') return value;
1104
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
1105
+ throw new Error(`${label} must be a time: milliseconds, 'now' or 'omit'`);
1106
+ }
1107
+
1028
1108
  function parseDelete(bytes: Uint8Array): DeleteMetadata {
1029
1109
  const value = parseObject(bytes, 'delete', ['path']);
1030
1110
  return { path: canonicalPath(value.path, 'delete path') };
@@ -1100,14 +1180,17 @@ function parsePathCall(value: Record<string, unknown>, path: (value: unknown, la
1100
1180
  }
1101
1181
  case 'unlink':
1102
1182
  case 'rmdir':
1103
- if (keys !== 'call,path') break;
1104
- return { call: value.call, path: path(value.path, `${value.call} path`) };
1183
+ if (keys !== 'call,path' && keys !== 'call,expect,path') break;
1184
+ return { call: value.call, path: path(value.path, `${value.call} path`), ...expectations(value, value.call) };
1105
1185
  case 'symlink':
1106
1186
  if (keys !== 'call,path,target' && keys !== 'call,ino,path,target') break;
1107
1187
  return {
1108
1188
  call: 'symlink', path: path(value.path, 'symlink path'), target: boundedString(value.target, 'symlink target', MAX_PATH_BYTES),
1109
1189
  ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'symlink ino') }),
1110
1190
  };
1191
+ case 'copyFile':
1192
+ if (keys !== 'call,from,path') break;
1193
+ return { call: 'copyFile', path: path(value.path, 'copyFile path'), from: path(value.from, 'copyFile from') };
1111
1194
  case 'ftruncate':
1112
1195
  if (keys.replace(',description', '').replace(',ino', '') !== 'call,path,size') break;
1113
1196
  return {
@@ -1131,25 +1214,28 @@ function parsePathCall(value: Record<string, unknown>, path: (value: unknown, la
1131
1214
  }
1132
1215
  case 'lchown':
1133
1216
  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') };
1217
+ return { call: 'lchown', path: path(value.path, 'lchown path'), uid: ownerId(value.uid, 'lchown uid'), gid: ownerId(value.gid, 'lchown gid') };
1135
1218
  case 'open': {
1136
- const flags = ['create', 'exclusive', 'nofollow', 'read', 'truncate'] as const;
1219
+ const flags = ['create', 'exclusive', 'nofollow', 'truncate'] as const;
1137
1220
  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;
1221
+ for (const flag of ['create', 'description', 'exclusive', 'expect', 'fchmod', 'ino', 'nofollow', 'truncate', 'umask']) required = required.replace(`,${flag}`, '');
1222
+ if (required !== 'access,call,mode,path') break;
1140
1223
  for (const flag of flags) {
1141
1224
  if (value[flag] !== undefined && value[flag] !== true) throw new Error(`w7-frame: open ${flag} is true or absent`);
1142
1225
  }
1226
+ const access = value.access;
1227
+ if (!isOpenAccess(access)) throw new Error(`w7-frame: open access is 'read', 'write' or 'readwrite': got ${String(access)}`);
1143
1228
  return {
1144
- call: 'open', path: path(value.path, 'open path'), mode: u32(value.mode, 'open mode'),
1229
+ call: 'open', path: path(value.path, 'open path'), mode: u32(value.mode, 'open mode'), access,
1145
1230
  ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'open ino') }),
1146
1231
  ...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'open umask') }),
1147
1232
  ...(value.create === true ? { create: true as const } : {}),
1148
1233
  ...(value.truncate === true ? { truncate: true as const } : {}),
1149
1234
  ...(value.exclusive === true ? { exclusive: true as const } : {}),
1150
1235
  ...(value.nofollow === true ? { nofollow: true as const } : {}),
1151
- ...(value.read === true ? { read: true as const } : {}),
1152
1236
  ...(value.description === undefined ? {} : { description: descriptionId(value.description, 'open description') }),
1237
+ ...(value.fchmod === undefined ? {} : { fchmod: u32(value.fchmod, 'open fchmod') }),
1238
+ ...expectations(value, 'open'),
1153
1239
  };
1154
1240
  }
1155
1241
  case 'lutimes':
@@ -1224,6 +1310,22 @@ function inodeNumber(value: unknown, label: string): number {
1224
1310
  return ino;
1225
1311
  }
1226
1312
 
1313
+ /** A record's expectations (W7Expected), checked: `expect`, and a rename's `expectTo`. */
1314
+ function expectations(value: { expect?: unknown; expectTo?: unknown }, label: string): { expect?: W7Expected; expectTo?: W7Expected } {
1315
+ const expected = (field: unknown, name: string): W7Expected => {
1316
+ if (field === null) return null;
1317
+ if (isObject(field) && !Array.isArray(field)) {
1318
+ if (Object.keys(field).join(',') !== 'made') throw new Error(`w7-frame: ${label} ${name} is a number, null or { made }`);
1319
+ return { made: descriptionId(field.made, `${label} ${name} made`) };
1320
+ }
1321
+ return inodeNumber(field, `${label} ${name}`);
1322
+ };
1323
+ return {
1324
+ ...(value.expect === undefined ? {} : { expect: expected(value.expect, 'expect') }),
1325
+ ...(value.expectTo === undefined ? {} : { expectTo: expected(value.expectTo, 'expectTo') }),
1326
+ };
1327
+ }
1328
+
1227
1329
  function parseObject(
1228
1330
  bytes: Uint8Array,
1229
1331
  label: string,
@@ -1384,15 +1486,7 @@ class PathOwnership {
1384
1486
  }
1385
1487
 
1386
1488
  function normalizePath(path: string): string {
1387
- const out: string[] = [];
1388
- for (const segment of path.split('/')) {
1389
- if (segment === '..') {
1390
- if (out.length > 0) out.pop();
1391
- } else if (segment !== '' && segment !== '.') {
1392
- out.push(segment);
1393
- }
1394
- }
1395
- return out.join('/');
1489
+ return rootClampedSegments(path).join('/');
1396
1490
  }
1397
1491
 
1398
1492
  function parentPath(path: string): string {
@@ -1415,6 +1509,11 @@ function safeInteger(value: unknown, label: string): number {
1415
1509
  return value as number;
1416
1510
  }
1417
1511
 
1512
+ /** A chown side: a uid or gid, or null, which keeps the file's. */
1513
+ function ownerId(value: unknown, label: string): number | null {
1514
+ return value === null ? null : u32(value, label);
1515
+ }
1516
+
1418
1517
  function u32(value: unknown, label: string): number {
1419
1518
  const integer = safeInteger(value, label);
1420
1519
  if (integer > 0xffff_ffff) throw new Error(`w7-frame: ${label} exceeds uint32`);
@@ -164,7 +164,7 @@ export interface WaveWriterOptions<Meta = undefined> {
164
164
  root: string | null;
165
165
  /** An existing worktree: only directories strictly below it are published. */
166
166
  worktreeRoot?: string | null;
167
- /** Record paths are relative to it (joined with '/'); omitted, they are VFS paths. */
167
+ /** Record paths are relative to it (joined with '/'; '' is the base itself); omitted, they are VFS paths. */
168
168
  base?: string;
169
169
  /** Wall-clock time (ms) after which no new wave starts. */
170
170
  deadline?: number | null;
@@ -300,7 +300,6 @@ export class WaveWriter<Meta = undefined> {
300
300
  private ownedPathBytes = 0;
301
301
  private pin: Pin | null = null;
302
302
  private inFlight: Promise<void> | null = null;
303
- private cutQueue: Promise<unknown> = Promise.resolve();
304
303
  private sequence = 0;
305
304
  private failure: WaveFailure | null = null;
306
305
  /** Owners (records' `meta`) whose records a failed wave carried. */
@@ -532,7 +531,8 @@ export class WaveWriter<Meta = undefined> {
532
531
  // ── Internals ────────────────────────────────────────────────────────
533
532
 
534
533
  private key(path: string): string {
535
- return this.options.base ? this.options.base + '/' + path : path;
534
+ if (!this.options.base) return path;
535
+ return path ? this.options.base + '/' + path : this.options.base;
536
536
  }
537
537
 
538
538
  private tally(): Tally {
@@ -551,13 +551,24 @@ export class WaveWriter<Meta = undefined> {
551
551
  if (admit) this.owned.add(path);
552
552
  }
553
553
 
554
- /** collectDirectoryPaths' chain from `path` upward, to the first directory already owned. */
555
- private walkChain(path: string, admit: boolean, tally: Tally): void {
554
+ /**
555
+ * The directories a wave publishes for something at or below `dir`:
556
+ * `dir` and each one above it, deepest first, up to the writer's root
557
+ * (included) and inside its worktree root.
558
+ */
559
+ private *chain(dir: string): Generator<string, void, undefined> {
556
560
  const { root, worktreeRoot = null } = this.options;
557
- let current = path;
558
- while (current) {
559
- if (root && current !== root && !current.startsWith(root + '/')) break;
560
- if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/')) break;
561
+ for (let current = dir; current; current = parentOf(current)) {
562
+ if (root && current !== root && !current.startsWith(root + '/')) return;
563
+ if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/')) return;
564
+ yield current;
565
+ if (current === root) return;
566
+ }
567
+ }
568
+
569
+ /** The chain from `path` upward, to the first directory already owned. */
570
+ private walkChain(path: string, admit: boolean, tally: Tally): void {
571
+ for (const current of this.chain(path)) {
561
572
  this.counters.ownershipVisits++;
562
573
  if (this.ownedDirectories.has(current)) break;
563
574
  if (admit) this.ownedDirectories.add(current);
@@ -566,8 +577,6 @@ export class WaveWriter<Meta = undefined> {
566
577
  tally.pathBytes += utf8Length(current);
567
578
  if (admit) this.owned.add(current);
568
579
  }
569
- if (current === root) break;
570
- current = parentOf(current);
571
580
  }
572
581
  }
573
582
 
@@ -636,16 +645,14 @@ export class WaveWriter<Meta = undefined> {
636
645
  }
637
646
 
638
647
  /**
639
- * Send the buffered wave once the one in flight has published. Cuts are
640
- * serialised, so at most one wave is in flight and one buffers.
648
+ * Send the buffered wave once the one in flight has published. Only a
649
+ * mutation cuts, and mutations run one at a time, so at most one wave is
650
+ * in flight and one buffers.
641
651
  */
642
- private cut(): Promise<void> {
643
- const run = this.cutQueue.then(() => this.cutNow());
644
- this.cutQueue = run.catch(() => {});
645
- return run;
646
- }
647
-
648
- private async cutNow(): Promise<void> {
652
+ private async cut(): Promise<void> {
653
+ // A later turn than the mutation that asks: a pin set or cleared by a
654
+ // microtask queued before the cut is the wave's.
655
+ await Promise.resolve();
649
656
  if (this.inFlight) {
650
657
  const waitStarted = Date.now();
651
658
  await this.inFlight.catch(() => {});
@@ -848,16 +855,10 @@ export class WaveWriter<Meta = undefined> {
848
855
  /** The directories the buffered records publish, shallowest first. */
849
856
  private publishedDirectories(): string[] {
850
857
  const dirs = new Set<string>();
851
- const { root, worktreeRoot = null } = this.options;
852
858
  const collect = (path: string): void => {
853
- let current = path;
854
- while (current) {
855
- if (root && current !== root && !current.startsWith(root + '/')) break;
856
- if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/')) break;
859
+ for (const current of this.chain(path)) {
857
860
  if (dirs.has(current)) break;
858
861
  dirs.add(current);
859
- if (current === root) break;
860
- current = parentOf(current);
861
862
  }
862
863
  };
863
864
  for (const path of this.records.keys()) collect(parentOf(path));