@thi.ng/block-fs 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -1
- package/README.md +128 -2
- package/api.d.ts +17 -0
- package/bin/blockfs +20 -0
- package/cli.d.ts +23 -0
- package/cli.js +214 -0
- package/directory.js +15 -10
- package/entry.js +1 -1
- package/fs.d.ts +109 -4
- package/fs.js +189 -77
- package/package.json +13 -2
- package/storage/file.d.ts +6 -0
- package/storage/file.js +6 -9
- package/storage/memory.d.ts +11 -1
- package/storage/memory.js +15 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
-
- **Last updated**: 2025-04-
|
|
3
|
+
- **Last updated**: 2025-04-02T18:47:59Z
|
|
4
4
|
- **Generator**: [thi.ng/monopub](https://thi.ng/monopub)
|
|
5
5
|
|
|
6
6
|
All notable changes to this project will be documented in this file.
|
|
@@ -11,6 +11,42 @@ See [Conventional Commits](https://conventionalcommits.org/) for commit guidelin
|
|
|
11
11
|
**Note:** Unlisted _patch_ versions only involve non-code or otherwise excluded changes
|
|
12
12
|
and/or version bumps of transitive dependencies.
|
|
13
13
|
|
|
14
|
+
## [0.3.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/block-fs@0.3.0) (2025-04-02)
|
|
15
|
+
|
|
16
|
+
#### 🚀 Features
|
|
17
|
+
|
|
18
|
+
- add `BlockFS.readAsObjectURL()` ([551327c](https://github.com/thi-ng/umbrella/commit/551327c))
|
|
19
|
+
- add mem storage opts, add logging ([27016d6](https://github.com/thi-ng/umbrella/commit/27016d6))
|
|
20
|
+
- add `MemoryBlockStorage` support for pre-loaded buffers
|
|
21
|
+
- add logging
|
|
22
|
+
- add docs
|
|
23
|
+
- add CLI app wrapper ([68abe74](https://github.com/thi-ng/umbrella/commit/68abe74))
|
|
24
|
+
- add CLI app wrapper with these commands:
|
|
25
|
+
- `convert`: convert file tree into single BlockFS blob
|
|
26
|
+
- `list`: list file tree of a BlockFS blob
|
|
27
|
+
- update deps
|
|
28
|
+
- improve tree display (`list` cmd) ([a23866c](https://github.com/thi-ng/umbrella/commit/a23866c))
|
|
29
|
+
|
|
30
|
+
#### 🩹 Bug fixes
|
|
31
|
+
|
|
32
|
+
- fix parent dir linkage ([a121e76](https://github.com/thi-ng/umbrella/commit/a121e76))
|
|
33
|
+
|
|
34
|
+
#### ♻️ Refactoring
|
|
35
|
+
|
|
36
|
+
- update sentinel block ID ([51a1e44](https://github.com/thi-ng/umbrella/commit/51a1e44))
|
|
37
|
+
|
|
38
|
+
## [0.2.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/block-fs@0.2.0) (2025-04-02)
|
|
39
|
+
|
|
40
|
+
#### 🚀 Features
|
|
41
|
+
|
|
42
|
+
- add path separator option, various refactoring ([875465e](https://github.com/thi-ng/umbrella/commit/875465e))
|
|
43
|
+
- add `BlockFSOpts.separator`
|
|
44
|
+
- rename `.readFileRaw()` => `.readBlocks()`
|
|
45
|
+
- rename `.writeFileRaw()` => `.writeBlocks()`
|
|
46
|
+
- add additional internal safety checks
|
|
47
|
+
- internal refactoring (`this` destructuring)
|
|
48
|
+
- add docs
|
|
49
|
+
|
|
14
50
|
## [0.1.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/block-fs@0.1.0) (2025-04-01)
|
|
15
51
|
|
|
16
52
|
#### 🚀 Features
|
package/README.md
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
- [Root directory](#root-directory)
|
|
22
22
|
- [Directory entries](#directory-entries)
|
|
23
23
|
- [File blocks](#file-blocks)
|
|
24
|
+
- [Command line app](#command-line-app)
|
|
25
|
+
- [Convert file tree into single BlockFS blob](#convert-file-tree-into-single-blockfs-blob)
|
|
26
|
+
- [List file tree of a BlockFS blob](#list-file-tree-of-a-blockfs-blob)
|
|
24
27
|
- [Status](#status)
|
|
25
28
|
- [Installation](#installation)
|
|
26
29
|
- [Dependencies](#dependencies)
|
|
@@ -53,6 +56,7 @@ The package also provides an hierarchical filesystem layer with pluggable
|
|
|
53
56
|
storage providers and other customizable aspects. The default implementation
|
|
54
57
|
supports:
|
|
55
58
|
|
|
59
|
+
- 8 - 32bit block IDs
|
|
56
60
|
- arbitrarily nested directories
|
|
57
61
|
- filenames of max. 31 bytes (UTF-8) per directory level
|
|
58
62
|
- max. 32 owner IDs
|
|
@@ -73,6 +77,9 @@ The filesystem stores a [bitfield](https://thi.ng/bitfield) of block allocations
|
|
|
73
77
|
in the first N blocks. The number of blocks used depends on configured block
|
|
74
78
|
size and the max. number of blocks in the storage backend.
|
|
75
79
|
|
|
80
|
+
Blocks can be reserved for custom purposes by calling
|
|
81
|
+
[`.allocateBlocks()`](https://docs.thi.ng/umbrella/block-fs/classes/BlockFS.html#allocateblocks).
|
|
82
|
+
|
|
76
83
|
#### Root directory
|
|
77
84
|
|
|
78
85
|
The root directory starts in block N, directly after the block allocation table.
|
|
@@ -98,6 +105,121 @@ the max. number of blocks in the storage backend.
|
|
|
98
105
|
|
|
99
106
|
TODO diagram
|
|
100
107
|
|
|
108
|
+
### Command line app
|
|
109
|
+
|
|
110
|
+
The package includes a mult-command CLI app with the following operations:
|
|
111
|
+
|
|
112
|
+
#### Convert file tree into single BlockFS blob
|
|
113
|
+
|
|
114
|
+
The `convert` command is used to bundle an entire file tree from the host system
|
|
115
|
+
into a single binary blob based on `BlockFS` with configured block size. The
|
|
116
|
+
file tree MUST fit into the RAM available to `bun` (or `node`).
|
|
117
|
+
|
|
118
|
+
Once bundled, the binary blob can then be used together with
|
|
119
|
+
[`MemoryBlockStorage`](https://docs.thi.ng/umbrella/block-fs/classes/MemoryBlockStorage.html)
|
|
120
|
+
and [`BlockFS`](https://docs.thi.ng/umbrella/block-fs/classes/BlockFS.html) for
|
|
121
|
+
other purposes (e.g. distributed with your web app to provide a virtual
|
|
122
|
+
filesystem).
|
|
123
|
+
|
|
124
|
+
Example usage to bundle the source directory of this package:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx @thi.ng/block-fs convert -o dummy.dat packages/block-fs/src/
|
|
128
|
+
|
|
129
|
+
# [INFO] blockfs: number of files: 11
|
|
130
|
+
# [INFO] blockfs: number of directories: 2
|
|
131
|
+
# [INFO] blockfs: total file size: 40341
|
|
132
|
+
# [INFO] blockfs: number of blocks: 56
|
|
133
|
+
# [INFO] blockfs: writing file: dummy.dat
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
General usage:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
npx @thi.ng/block-fs convert --help
|
|
140
|
+
|
|
141
|
+
Usage: blockfs <cmd> [opts] input
|
|
142
|
+
|
|
143
|
+
Flags:
|
|
144
|
+
|
|
145
|
+
-v, --verbose Display extra process information
|
|
146
|
+
|
|
147
|
+
Main:
|
|
148
|
+
|
|
149
|
+
-bs BYTES, --block-size BYTES Block size (default: 1024)
|
|
150
|
+
-n INT, --num-blocks INT Number of blocks (multiple of 8)
|
|
151
|
+
-o STR, --out STR [required] Output file path
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### List file tree of a BlockFS blob
|
|
155
|
+
|
|
156
|
+
The `list` command is used to list the files & directories stored in a binary blob created via the `convert` command. Several output options (e.g. `tree`-like output) are supported.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npx @thi.ng/block-fs list dummy.dat
|
|
160
|
+
# /api.ts
|
|
161
|
+
# /cli.ts
|
|
162
|
+
# /directory.ts
|
|
163
|
+
# /entry.ts
|
|
164
|
+
# /fs.ts
|
|
165
|
+
# /index.ts
|
|
166
|
+
# /lock.ts
|
|
167
|
+
# /storage
|
|
168
|
+
# /storage/astorage.ts
|
|
169
|
+
# /storage/file.ts
|
|
170
|
+
# /storage/memory.ts
|
|
171
|
+
# /utils.ts
|
|
172
|
+
|
|
173
|
+
npx @thi.ng/block-fs list --tree dummy.dat
|
|
174
|
+
# ├── api.ts
|
|
175
|
+
# ├── cli.ts
|
|
176
|
+
# ├── directory.ts
|
|
177
|
+
# ├── entry.ts
|
|
178
|
+
# ├── fs.ts
|
|
179
|
+
# ├── index.ts
|
|
180
|
+
# ├── lock.ts
|
|
181
|
+
# ├── storage
|
|
182
|
+
# │ ├── astorage.ts
|
|
183
|
+
# │ ├── file.ts
|
|
184
|
+
# │ └── memory.ts
|
|
185
|
+
# └── utils.ts
|
|
186
|
+
|
|
187
|
+
# display file sizes & modification times
|
|
188
|
+
npx @thi.ng/block-fs list --tree --all dummy.dat
|
|
189
|
+
# ├── api.ts 2204 2025-04-02T10:22:55.573Z
|
|
190
|
+
# ├── cli.ts 6799 2025-04-02T18:07:58.895Z
|
|
191
|
+
# ├── directory.ts 3994 2025-04-02T13:47:00.108Z
|
|
192
|
+
# ├── entry.ts 4130 2025-04-02T10:22:55.574Z
|
|
193
|
+
# ├── fs.ts 16377 2025-04-02T13:46:36.608Z
|
|
194
|
+
# ├── index.ts 317 2025-04-01T21:38:08.232Z
|
|
195
|
+
# ├── lock.ts 1501 2025-04-01T21:38:08.232Z
|
|
196
|
+
# ├── storage 2025-04-02T18:33:47.389Z
|
|
197
|
+
# │ ├── astorage.ts 1205 2025-04-02T10:22:55.574Z
|
|
198
|
+
# │ ├── file.ts 1780 2025-04-02T14:25:12.461Z
|
|
199
|
+
# │ └── memory.ts 1802 2025-04-02T14:26:02.163Z
|
|
200
|
+
# └── utils.ts 418 2025-04-02T10:22:55.574Z
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
General usage:
|
|
204
|
+
|
|
205
|
+
```text
|
|
206
|
+
npx @thi.ng/block-fs list --help
|
|
207
|
+
|
|
208
|
+
Usage: blockfs <cmd> [opts] input
|
|
209
|
+
|
|
210
|
+
Flags:
|
|
211
|
+
|
|
212
|
+
-a, --all Display all attribs
|
|
213
|
+
-t, --tree List files as tree
|
|
214
|
+
-v, --verbose Display extra process information
|
|
215
|
+
-m, --with-mtime Display modified times
|
|
216
|
+
-s, --with-size Display file sizes
|
|
217
|
+
|
|
218
|
+
Main:
|
|
219
|
+
|
|
220
|
+
-bs BYTES, --block-size BYTES Block size (default: 1024)
|
|
221
|
+
```
|
|
222
|
+
|
|
101
223
|
## Status
|
|
102
224
|
|
|
103
225
|
**ALPHA** - bleeding edge / work-in-progress
|
|
@@ -130,11 +252,12 @@ For Node.js REPL:
|
|
|
130
252
|
const bf = await import("@thi.ng/block-fs");
|
|
131
253
|
```
|
|
132
254
|
|
|
133
|
-
Package sizes (brotli'd, pre-treeshake): ESM: 4.
|
|
255
|
+
Package sizes (brotli'd, pre-treeshake): ESM: 4.43 KB
|
|
134
256
|
|
|
135
257
|
## Dependencies
|
|
136
258
|
|
|
137
259
|
- [@thi.ng/api](https://github.com/thi-ng/umbrella/tree/develop/packages/api)
|
|
260
|
+
- [@thi.ng/args](https://github.com/thi-ng/umbrella/tree/develop/packages/args)
|
|
138
261
|
- [@thi.ng/binary](https://github.com/thi-ng/umbrella/tree/develop/packages/binary)
|
|
139
262
|
- [@thi.ng/bitfield](https://github.com/thi-ng/umbrella/tree/develop/packages/bitfield)
|
|
140
263
|
- [@thi.ng/checks](https://github.com/thi-ng/umbrella/tree/develop/packages/checks)
|
|
@@ -198,7 +321,10 @@ console.log(await fs.readFile("/deeply/nested/paths/are-ok"));
|
|
|
198
321
|
|
|
199
322
|
// iterate all files & directory entries in root dir
|
|
200
323
|
for await (let entry of fs.root.tree()) {
|
|
201
|
-
|
|
324
|
+
// entry.path is absolute path
|
|
325
|
+
// entry.size is always a bigint
|
|
326
|
+
// entry.ctime/mtime is UNIX epoch
|
|
327
|
+
console.log(entry.path, entry.size, new Date(entry.ctime));
|
|
202
328
|
}
|
|
203
329
|
|
|
204
330
|
// /hello 0n 2025-04-01T20:18:55.916Z
|
package/api.d.ts
CHANGED
|
@@ -18,18 +18,35 @@ export interface IBlockStorage {
|
|
|
18
18
|
}
|
|
19
19
|
export interface BlockStorageOpts {
|
|
20
20
|
logger?: ILogger;
|
|
21
|
+
/**
|
|
22
|
+
* Max. number of blocks. Will be rounded up to a multiple of 8.
|
|
23
|
+
*/
|
|
21
24
|
numBlocks: number;
|
|
25
|
+
/**
|
|
26
|
+
* Block size in bytes. Must be a power of 2. For usage with
|
|
27
|
+
* {@link BlockFS}, requires a minimum size of 128 bytes (though in practice
|
|
28
|
+
* should be larger to minimize overhead per block).
|
|
29
|
+
*/
|
|
22
30
|
blockSize: Pow2;
|
|
23
31
|
}
|
|
24
32
|
export interface EntrySpec {
|
|
33
|
+
/** Entry type (file or directory) */
|
|
25
34
|
type: EntryType;
|
|
35
|
+
/** TODO currently still unused */
|
|
26
36
|
locked?: boolean;
|
|
37
|
+
/** Owner ID */
|
|
27
38
|
owner?: number;
|
|
39
|
+
/** Entry name */
|
|
28
40
|
name: string;
|
|
41
|
+
/** Only used for files. File size as bigint */
|
|
29
42
|
size?: bigint;
|
|
43
|
+
/** Entry creation timestamp */
|
|
30
44
|
ctime?: number;
|
|
45
|
+
/** Entry modification timestamp */
|
|
31
46
|
mtime?: number;
|
|
47
|
+
/** Data start block ID */
|
|
32
48
|
start: number;
|
|
49
|
+
/** Data end block ID */
|
|
33
50
|
end?: number;
|
|
34
51
|
}
|
|
35
52
|
export declare enum EntryType {
|
package/bin/blockfs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# https://stackoverflow.com/a/246128/294515
|
|
4
|
+
SOURCE="${BASH_SOURCE[0]}"
|
|
5
|
+
while [ -L "$SOURCE" ]; do
|
|
6
|
+
DIR="$( cd -P "$( dirname "$SOURCE" )" >/dev/null 2>&1 && pwd )"
|
|
7
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
8
|
+
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
9
|
+
done
|
|
10
|
+
DIR="$( cd -P "$( dirname "$SOURCE" )" >/dev/null 2>&1 && pwd )"
|
|
11
|
+
DIR="$(dirname $DIR)"
|
|
12
|
+
|
|
13
|
+
# prefer using bun
|
|
14
|
+
if [ -x "$(command -v bun)" ]; then
|
|
15
|
+
CMD=bun
|
|
16
|
+
else
|
|
17
|
+
CMD=node
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
/usr/bin/env $CMD "$DIR/cli.js" "$DIR" "$@"
|
package/cli.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Command, type CommandCtx } from "@thi.ng/args";
|
|
2
|
+
interface CLIOpts {
|
|
3
|
+
verbose: boolean;
|
|
4
|
+
}
|
|
5
|
+
interface ConvertOpts extends CLIOpts {
|
|
6
|
+
numBlocks?: number;
|
|
7
|
+
blockSize: number;
|
|
8
|
+
include?: string[];
|
|
9
|
+
out: string;
|
|
10
|
+
}
|
|
11
|
+
interface ListOpts extends CLIOpts {
|
|
12
|
+
blockSize: number;
|
|
13
|
+
all: boolean;
|
|
14
|
+
tree: boolean;
|
|
15
|
+
withMtime: boolean;
|
|
16
|
+
withSize: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface AppCtx<T extends CLIOpts> extends CommandCtx<T, CLIOpts> {
|
|
19
|
+
}
|
|
20
|
+
export declare const CONVERT: Command<ConvertOpts, CLIOpts, AppCtx<ConvertOpts>>;
|
|
21
|
+
export declare const LIST: Command<ListOpts, CLIOpts, AppCtx<ListOpts>>;
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=cli.d.ts.map
|
package/cli.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cliApp,
|
|
3
|
+
flag,
|
|
4
|
+
int,
|
|
5
|
+
string,
|
|
6
|
+
strings
|
|
7
|
+
} from "@thi.ng/args";
|
|
8
|
+
import { align, isPow2 } from "@thi.ng/binary";
|
|
9
|
+
import { illegalArgs } from "@thi.ng/errors";
|
|
10
|
+
import { files, readBinary, readJSON, writeFile } from "@thi.ng/file-io";
|
|
11
|
+
import { LogLevel } from "@thi.ng/logger";
|
|
12
|
+
import { statSync } from "node:fs";
|
|
13
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
14
|
+
import { Entry } from "./entry.js";
|
|
15
|
+
import { BlockFS } from "./fs.js";
|
|
16
|
+
import { MemoryBlockStorage } from "./storage/memory.js";
|
|
17
|
+
const PKG = readJSON(join(process.argv[2], "package.json"));
|
|
18
|
+
const ARG_BLOCKSIZE = {
|
|
19
|
+
blockSize: int({
|
|
20
|
+
alias: "bs",
|
|
21
|
+
desc: "Block size",
|
|
22
|
+
hint: "BYTES",
|
|
23
|
+
default: 1024,
|
|
24
|
+
coerce: (x) => {
|
|
25
|
+
const size = +x;
|
|
26
|
+
if (!isPow2(size)) illegalArgs("block size must be a power of 2");
|
|
27
|
+
return size;
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
};
|
|
31
|
+
const collectFiles = (ctx) => {
|
|
32
|
+
const root = resolve(ctx.inputs[0]);
|
|
33
|
+
const filtered = [];
|
|
34
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
35
|
+
let total = 0;
|
|
36
|
+
for (let f of files(root)) {
|
|
37
|
+
const stats = statSync(f);
|
|
38
|
+
if (stats.isFile()) {
|
|
39
|
+
const dest = relative(root, f);
|
|
40
|
+
filtered.push({
|
|
41
|
+
src: f,
|
|
42
|
+
dest,
|
|
43
|
+
size: stats.size,
|
|
44
|
+
ctime: stats.ctimeMs,
|
|
45
|
+
mtime: stats.mtimeMs
|
|
46
|
+
});
|
|
47
|
+
dirs.add(dirname(dest));
|
|
48
|
+
total += stats.size;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { files: filtered, dirs: [...dirs], size: total };
|
|
52
|
+
};
|
|
53
|
+
const computeBlockCount = (collected, blockSize, numBlocks) => {
|
|
54
|
+
let blocks = collected.dirs.length;
|
|
55
|
+
const blockIDBytes = numBlocks ? align(Math.ceil(Math.log2(numBlocks)), 8) >> 3 : align(Math.ceil(Math.log2(collected.size / blockSize + blocks)), 8) >> 3;
|
|
56
|
+
const blockDataSizeBytes = align(Math.ceil(Math.log2(blockSize)), 8) >> 3;
|
|
57
|
+
const blockDataSize = blockSize - blockIDBytes - blockDataSizeBytes;
|
|
58
|
+
blocks += Math.ceil(
|
|
59
|
+
(collected.files.length + collected.dirs.length) * Entry.SIZE / blockDataSize
|
|
60
|
+
);
|
|
61
|
+
for (let f of collected.files) {
|
|
62
|
+
blocks += Math.ceil(f.size / blockDataSize);
|
|
63
|
+
}
|
|
64
|
+
const blockIDBytes2 = align(Math.ceil(Math.log2(blocks)), 8) >> 3;
|
|
65
|
+
return blockIDBytes2 > blockIDBytes ? computeBlockCount(collected, blockSize, blocks) : blocks;
|
|
66
|
+
};
|
|
67
|
+
const CONVERT = {
|
|
68
|
+
opts: {
|
|
69
|
+
...ARG_BLOCKSIZE,
|
|
70
|
+
numBlocks: int({
|
|
71
|
+
alias: "n",
|
|
72
|
+
desc: "Number of blocks (multiple of 8)",
|
|
73
|
+
optional: true
|
|
74
|
+
}),
|
|
75
|
+
out: string({
|
|
76
|
+
alias: "o",
|
|
77
|
+
desc: "Output file path",
|
|
78
|
+
optional: false
|
|
79
|
+
}),
|
|
80
|
+
include: strings({
|
|
81
|
+
alias: "i",
|
|
82
|
+
desc: "Only include file extensions",
|
|
83
|
+
hint: "EXT"
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
desc: "Convert file tree into single BlockFS blob",
|
|
87
|
+
fn: async (ctx) => {
|
|
88
|
+
const collected = collectFiles(ctx);
|
|
89
|
+
const numBlocks = align(
|
|
90
|
+
ctx.opts.numBlocks ?? computeBlockCount(collected, ctx.opts.blockSize),
|
|
91
|
+
8
|
|
92
|
+
);
|
|
93
|
+
ctx.logger.info("number of files:", collected.files.length);
|
|
94
|
+
ctx.logger.info("number of directories:", collected.dirs.length);
|
|
95
|
+
ctx.logger.info("total file size:", collected.size);
|
|
96
|
+
ctx.logger.info("number of blocks:", numBlocks);
|
|
97
|
+
const storage = new MemoryBlockStorage({
|
|
98
|
+
numBlocks,
|
|
99
|
+
blockSize: ctx.opts.blockSize,
|
|
100
|
+
logger: ctx.logger
|
|
101
|
+
});
|
|
102
|
+
const bfs = new BlockFS(storage, { logger: ctx.logger });
|
|
103
|
+
await bfs.init();
|
|
104
|
+
for (let f of collected.files) {
|
|
105
|
+
ctx.logger.debug("writing file:", f.dest);
|
|
106
|
+
await bfs.writeFile(f.dest, readBinary(f.src));
|
|
107
|
+
const entry = await bfs.entryForPath(f.dest);
|
|
108
|
+
entry.ctime = f.ctime;
|
|
109
|
+
entry.mtime = f.mtime;
|
|
110
|
+
await entry.save();
|
|
111
|
+
}
|
|
112
|
+
writeFile(ctx.opts.out, storage.buffer, void 0, ctx.logger);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const LIST = {
|
|
116
|
+
opts: {
|
|
117
|
+
...ARG_BLOCKSIZE,
|
|
118
|
+
all: flag({
|
|
119
|
+
alias: "a",
|
|
120
|
+
desc: "Display all attribs"
|
|
121
|
+
}),
|
|
122
|
+
tree: flag({
|
|
123
|
+
alias: "t",
|
|
124
|
+
desc: "List files as tree"
|
|
125
|
+
}),
|
|
126
|
+
withMtime: flag({
|
|
127
|
+
alias: "m",
|
|
128
|
+
desc: "Display modified times"
|
|
129
|
+
}),
|
|
130
|
+
withSize: flag({
|
|
131
|
+
alias: "s",
|
|
132
|
+
desc: "Display file sizes"
|
|
133
|
+
})
|
|
134
|
+
},
|
|
135
|
+
desc: "List file tree of a BlockFS blob",
|
|
136
|
+
fn: async (ctx) => {
|
|
137
|
+
if (ctx.opts.all) {
|
|
138
|
+
ctx.opts.withMtime = ctx.opts.withSize = true;
|
|
139
|
+
}
|
|
140
|
+
const buffer = readBinary(ctx.inputs[0]);
|
|
141
|
+
const storage = new MemoryBlockStorage({
|
|
142
|
+
numBlocks: buffer.length / ctx.opts.blockSize >>> 0,
|
|
143
|
+
blockSize: ctx.opts.blockSize,
|
|
144
|
+
logger: ctx.logger,
|
|
145
|
+
buffer
|
|
146
|
+
});
|
|
147
|
+
const bfs = new BlockFS(storage, { logger: ctx.logger });
|
|
148
|
+
await bfs.init();
|
|
149
|
+
const tree = [];
|
|
150
|
+
for await (let entry of bfs.root.tree()) {
|
|
151
|
+
const path = entry.path;
|
|
152
|
+
const depth = path.split("/").length - 2;
|
|
153
|
+
tree.push([path, depth, entry]);
|
|
154
|
+
}
|
|
155
|
+
tree.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
156
|
+
const rows = [];
|
|
157
|
+
const maxWidths = [0, 0, 0];
|
|
158
|
+
for (let i = 0, num = tree.length - 1; i <= num; i++) {
|
|
159
|
+
const f = tree[i];
|
|
160
|
+
const isLast = i === num || f[1] > tree[i + 1][1];
|
|
161
|
+
const row = ctx.opts.tree ? ["\u2502 ".repeat(f[1]) + (isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ") + f[2].name] : [f[0]];
|
|
162
|
+
if (ctx.opts.withSize) {
|
|
163
|
+
row.push(f[2].isDirectory() ? "" : String(Number(f[2].size)));
|
|
164
|
+
}
|
|
165
|
+
if (ctx.opts.withMtime) {
|
|
166
|
+
row.push(new Date(f[2].mtime).toISOString());
|
|
167
|
+
}
|
|
168
|
+
rows.push(row);
|
|
169
|
+
for (let i2 = 0; i2 < row.length; i2++) {
|
|
170
|
+
maxWidths[i2] = Math.max(maxWidths[i2], row[i2].length);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (let row of rows) {
|
|
174
|
+
console.log(row.map((x, i) => x.padEnd(maxWidths[i])).join(" "));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
cliApp({
|
|
179
|
+
opts: {
|
|
180
|
+
verbose: flag({
|
|
181
|
+
alias: "v",
|
|
182
|
+
desc: "Display extra process information"
|
|
183
|
+
})
|
|
184
|
+
},
|
|
185
|
+
commands: {
|
|
186
|
+
convert: CONVERT,
|
|
187
|
+
list: LIST
|
|
188
|
+
},
|
|
189
|
+
name: "blockfs",
|
|
190
|
+
ctx: async (ctx) => {
|
|
191
|
+
if (ctx.opts.verbose) ctx.logger.level = LogLevel.DEBUG;
|
|
192
|
+
return ctx;
|
|
193
|
+
},
|
|
194
|
+
start: 3,
|
|
195
|
+
usage: {
|
|
196
|
+
prefix: `
|
|
197
|
+
\u2588 \u2588 \u2588 \u2502
|
|
198
|
+
\u2588\u2588 \u2588 \u2502
|
|
199
|
+
\u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2502 ${PKG.name} ${PKG.version}
|
|
200
|
+
\u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2502 Block-based storage & file system layer
|
|
201
|
+
\u2588 \u2502
|
|
202
|
+
\u2588 \u2588 \u2502
|
|
203
|
+
|
|
204
|
+
Usage: blockfs <cmd> [opts] input [...]
|
|
205
|
+
blockfs <cmd> --help
|
|
206
|
+
`,
|
|
207
|
+
showGroupNames: true,
|
|
208
|
+
paramWidth: 32
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
export {
|
|
212
|
+
CONVERT,
|
|
213
|
+
LIST
|
|
214
|
+
};
|
package/directory.js
CHANGED
|
@@ -30,18 +30,22 @@ class Directory {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
async traverse() {
|
|
33
|
+
const {
|
|
34
|
+
fs,
|
|
35
|
+
fs: { sentinelID, storage }
|
|
36
|
+
} = this;
|
|
33
37
|
const blocks = [];
|
|
34
38
|
const entries = [];
|
|
35
39
|
let blockID = this.entry.start;
|
|
36
40
|
while (true) {
|
|
37
41
|
blocks.push(blockID);
|
|
38
|
-
const block = await
|
|
39
|
-
let { next, size } =
|
|
40
|
-
if (!next) next =
|
|
42
|
+
const block = await storage.loadBlock(blockID);
|
|
43
|
+
let { next, size } = fs.getBlockMeta(block);
|
|
44
|
+
if (!next) next = sentinelID;
|
|
41
45
|
for (let i = 0; i < size; i++) {
|
|
42
46
|
entries.push(this.defEntry(i, blockID, block));
|
|
43
47
|
}
|
|
44
|
-
if (next ===
|
|
48
|
+
if (next === sentinelID) break;
|
|
45
49
|
blockID = next;
|
|
46
50
|
}
|
|
47
51
|
return { blocks, entries };
|
|
@@ -52,16 +56,17 @@ class Directory {
|
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
async mkdir(name) {
|
|
59
|
+
const fs = this.fs;
|
|
55
60
|
const length = utf8Length(name);
|
|
56
|
-
if (!length || length >
|
|
61
|
+
if (!length || length > fs.opts.entry.maxLength)
|
|
57
62
|
illegalArgs(`invalid name: '${name}'`);
|
|
58
63
|
const traversed = await this.traverse();
|
|
59
64
|
this.ensureUniqueName(name, traversed.entries);
|
|
60
|
-
const block = (await
|
|
61
|
-
const data = await
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await
|
|
65
|
+
const block = (await fs.allocateBlocks(1))[0];
|
|
66
|
+
const data = await fs.storage.loadBlock(block);
|
|
67
|
+
fs.setBlockMeta(data, fs.sentinelID, 0);
|
|
68
|
+
fs.setBlockLink(data, this.entry.start, fs.blockDataOffset);
|
|
69
|
+
await fs.storage.saveBlock(block, data);
|
|
65
70
|
return this.addEntry(
|
|
66
71
|
{
|
|
67
72
|
name,
|
package/entry.js
CHANGED
package/fs.d.ts
CHANGED
|
@@ -3,6 +3,15 @@ import type { ILogger } from "@thi.ng/logger";
|
|
|
3
3
|
import { EntryType, type IBlockStorage, type IDirectory, type IEntry } from "./api.js";
|
|
4
4
|
import { Lock } from "./lock.js";
|
|
5
5
|
export interface BlockFSOpts {
|
|
6
|
+
/**
|
|
7
|
+
* Path separator.
|
|
8
|
+
*
|
|
9
|
+
* @defaultValue `/`
|
|
10
|
+
*/
|
|
11
|
+
separator: string;
|
|
12
|
+
/**
|
|
13
|
+
* Logger instance to use.
|
|
14
|
+
*/
|
|
6
15
|
logger: ILogger;
|
|
7
16
|
/**
|
|
8
17
|
* Customizable {@link IDirectory} factory function. By default creates
|
|
@@ -66,16 +75,112 @@ export declare class BlockFS {
|
|
|
66
75
|
root: IDirectory;
|
|
67
76
|
constructor(storage: IBlockStorage, opts?: Partial<BlockFSOpts>);
|
|
68
77
|
init(): Promise<this>;
|
|
78
|
+
/**
|
|
79
|
+
* Attempts to find the {@link IEntry} for given `path` and returns it if
|
|
80
|
+
* successful. Throws error if no such path exists.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* Also see {@link BlockFS.ensureEntryForPath}.
|
|
84
|
+
*
|
|
85
|
+
* @param path
|
|
86
|
+
*/
|
|
69
87
|
entryForPath(path: string): Promise<IEntry>;
|
|
88
|
+
/**
|
|
89
|
+
* Attempts to find or to create the {@link IEntry} for given `path` and
|
|
90
|
+
* entry type (file or directory) and then returns it if successful. Throws
|
|
91
|
+
* error if the path exists, but does not match the expected type or if the
|
|
92
|
+
* entry could not be created for other reasons.
|
|
93
|
+
*
|
|
94
|
+
* @remarks
|
|
95
|
+
* Also see {@link BlockFS.entryForPath}.
|
|
96
|
+
*
|
|
97
|
+
* @param path
|
|
98
|
+
* @param type
|
|
99
|
+
*/
|
|
70
100
|
ensureEntryForPath(path: string, type: EntryType): Promise<IEntry>;
|
|
101
|
+
/**
|
|
102
|
+
* Attempts to allocate a number of free blocks required for storing the
|
|
103
|
+
* given number of `bytes`. If successful, marks the blocks as used in the
|
|
104
|
+
* allocation table and then returns list of their IDs, otherwise throws an
|
|
105
|
+
* error if there're insufficient blocks available.
|
|
106
|
+
*
|
|
107
|
+
* @param bytes
|
|
108
|
+
*/
|
|
71
109
|
allocateBlocks(bytes: number): Promise<number[]>;
|
|
110
|
+
/**
|
|
111
|
+
* Marks the given block IDs as free/unused in the block allocation table
|
|
112
|
+
* and deletes/clears the blocks.
|
|
113
|
+
*
|
|
114
|
+
* @param ids
|
|
115
|
+
*/
|
|
72
116
|
freeBlocks(ids: number[]): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Same as POSIX `mkdirp`. Attempts to create given directory, including any
|
|
119
|
+
* missing intermediate ones defined by `path`.
|
|
120
|
+
*
|
|
121
|
+
* @param path
|
|
122
|
+
*/
|
|
73
123
|
mkdir(path: string): Promise<IEntry>;
|
|
74
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Async iterator. Reads block list for given `path` (file path or start
|
|
126
|
+
* block ID) and yields data contents (byte arrays) of each block.
|
|
127
|
+
*
|
|
128
|
+
* @remarks
|
|
129
|
+
* Also see other read methods:
|
|
130
|
+
*
|
|
131
|
+
* - {@link BlockFS.readFile}
|
|
132
|
+
* - {@link BlockFS.readText}
|
|
133
|
+
* - {@link BlockFS.readJSON}
|
|
134
|
+
*
|
|
135
|
+
* @param path
|
|
136
|
+
*/
|
|
137
|
+
readBlocks(path: string | number): AsyncGenerator<Uint8Array<ArrayBufferLike>, void, unknown>;
|
|
138
|
+
/**
|
|
139
|
+
* Fully reads given file into a single byte buffer and returns it.
|
|
140
|
+
*
|
|
141
|
+
* @param path
|
|
142
|
+
*/
|
|
75
143
|
readFile(path: string | number): Promise<Uint8Array<ArrayBuffer>>;
|
|
144
|
+
/**
|
|
145
|
+
* Fully reads given file into a single UTF-8 byte buffer, then decodes and
|
|
146
|
+
* returns it as string.
|
|
147
|
+
*
|
|
148
|
+
* @param path
|
|
149
|
+
*/
|
|
76
150
|
readText(path: string | number): Promise<string>;
|
|
77
|
-
|
|
78
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Fully reads given file into a single UTF-8 byte buffer, then decodes it
|
|
153
|
+
* as JSON and returns result.
|
|
154
|
+
*
|
|
155
|
+
* @param path
|
|
156
|
+
*/
|
|
157
|
+
readJSON<T>(path: string | number): Promise<T>;
|
|
158
|
+
/**
|
|
159
|
+
* Fully reads given file into a single byte buffer and returns it as blob
|
|
160
|
+
* object URL, optionally typed with given MIME type.
|
|
161
|
+
*
|
|
162
|
+
* @remarks
|
|
163
|
+
* Reference:
|
|
164
|
+
*
|
|
165
|
+
* - https://developer.mozilla.org/en-US/docs/Web/API/Blob#creating_a_url_representing_the_contents_of_a_typed_array
|
|
166
|
+
*
|
|
167
|
+
* @param path
|
|
168
|
+
* @param type
|
|
169
|
+
*/
|
|
170
|
+
readAsObjectURL(path: string | number, type?: string): Promise<string>;
|
|
171
|
+
/**
|
|
172
|
+
* Takes an array of block IDs (or `null`) and a `data` byte array. Writes
|
|
173
|
+
* chunks of data into given blocks and connecting each block as linked
|
|
174
|
+
* list. Returns object of start/end block IDs and data size.
|
|
175
|
+
*
|
|
176
|
+
* @remarks
|
|
177
|
+
* If `blocks` is null, blocks are automatically allocated via
|
|
178
|
+
* {@link BlockFS.allocateBlocks}.
|
|
179
|
+
*
|
|
180
|
+
* @param blocks
|
|
181
|
+
* @param data
|
|
182
|
+
*/
|
|
183
|
+
writeBlocks(blocks: number[] | null, data: Uint8Array): Promise<{
|
|
79
184
|
start: number;
|
|
80
185
|
end: number;
|
|
81
186
|
size: number;
|
|
@@ -85,7 +190,7 @@ export declare class BlockFS {
|
|
|
85
190
|
end: number;
|
|
86
191
|
size: number;
|
|
87
192
|
}>;
|
|
88
|
-
|
|
193
|
+
appendBlocks(blockID: number, data: Uint8Array): Promise<{
|
|
89
194
|
start: number;
|
|
90
195
|
end: number;
|
|
91
196
|
size: number;
|
package/fs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { defBitField } from "@thi.ng/bitfield/bitfield";
|
|
|
3
3
|
import { isString } from "@thi.ng/checks/is-string";
|
|
4
4
|
import { assert } from "@thi.ng/errors/assert";
|
|
5
5
|
import { illegalArgs } from "@thi.ng/errors/illegal-arguments";
|
|
6
|
+
import { illegalState } from "@thi.ng/errors/illegal-state";
|
|
6
7
|
import { NULL_LOGGER } from "@thi.ng/logger/null";
|
|
7
8
|
import {
|
|
8
9
|
EntryType
|
|
@@ -22,9 +23,10 @@ class BlockFS {
|
|
|
22
23
|
constructor(storage, opts) {
|
|
23
24
|
this.storage = storage;
|
|
24
25
|
this.opts = {
|
|
25
|
-
logger: NULL_LOGGER,
|
|
26
26
|
directory: (fs, entry) => new Directory(fs, entry),
|
|
27
27
|
entry: DEFAULT_ENTRY,
|
|
28
|
+
logger: NULL_LOGGER,
|
|
29
|
+
separator: "/",
|
|
28
30
|
...opts
|
|
29
31
|
};
|
|
30
32
|
assert(
|
|
@@ -39,7 +41,7 @@ class BlockFS {
|
|
|
39
41
|
this.rootDirBlockID = align(this.blockIndex.data.length, storage.blockSize) / storage.blockSize;
|
|
40
42
|
this.dataStartBlockID = this.rootDirBlockID + 1;
|
|
41
43
|
this.dirDataOffset = this.blockDataOffset + this.blockIDBytes;
|
|
42
|
-
this.sentinelID =
|
|
44
|
+
this.sentinelID = 2 ** (this.blockIDBytes * 8) - 1;
|
|
43
45
|
this.tmp = new Uint8Array(storage.blockSize);
|
|
44
46
|
}
|
|
45
47
|
blockIndex;
|
|
@@ -67,60 +69,84 @@ class BlockFS {
|
|
|
67
69
|
/** Root directory */
|
|
68
70
|
root;
|
|
69
71
|
async init() {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const { blockIndex, storage, opts, rootDirBlockID } = this;
|
|
73
|
+
const indexSize = blockIndex.data.length;
|
|
74
|
+
const blockSize = storage.blockSize;
|
|
75
|
+
for (let i = 0; i < rootDirBlockID; i++) {
|
|
76
|
+
const data = await storage.loadBlock(i);
|
|
74
77
|
const offset = i * blockSize;
|
|
75
78
|
if (offset + data.length > indexSize) {
|
|
76
|
-
|
|
79
|
+
blockIndex.data.set(
|
|
77
80
|
data.subarray(0, indexSize - offset),
|
|
78
81
|
offset
|
|
79
82
|
);
|
|
80
83
|
} else {
|
|
81
|
-
|
|
84
|
+
blockIndex.data.set(data, offset);
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
|
-
|
|
85
|
-
const rootEntry =
|
|
87
|
+
blockIndex.fill(1, 0, this.dataStartBlockID);
|
|
88
|
+
const rootEntry = opts.entry.factory(
|
|
86
89
|
this,
|
|
87
90
|
null,
|
|
88
|
-
|
|
89
|
-
new Uint8Array(
|
|
91
|
+
rootDirBlockID,
|
|
92
|
+
new Uint8Array(opts.entry.size),
|
|
90
93
|
0
|
|
91
94
|
);
|
|
92
95
|
rootEntry.set({
|
|
93
96
|
name: "",
|
|
94
97
|
type: EntryType.DIR,
|
|
95
|
-
start:
|
|
98
|
+
start: rootDirBlockID,
|
|
96
99
|
owner: 0
|
|
97
100
|
});
|
|
98
|
-
this.root =
|
|
101
|
+
this.root = opts.directory(this, rootEntry);
|
|
99
102
|
return this;
|
|
100
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Attempts to find the {@link IEntry} for given `path` and returns it if
|
|
106
|
+
* successful. Throws error if no such path exists.
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* Also see {@link BlockFS.ensureEntryForPath}.
|
|
110
|
+
*
|
|
111
|
+
* @param path
|
|
112
|
+
*/
|
|
101
113
|
async entryForPath(path) {
|
|
102
114
|
let dir = this.root;
|
|
103
|
-
|
|
115
|
+
const { directory, separator } = this.opts;
|
|
116
|
+
if (path[0] === separator) path = path.substring(1);
|
|
104
117
|
if (path === "") return dir.entry;
|
|
105
|
-
const $path = path.split(
|
|
106
|
-
for (let i = 0
|
|
118
|
+
const $path = path.split(separator);
|
|
119
|
+
for (let i = 0, len = $path.length - 1; i <= len; i++) {
|
|
107
120
|
let entry = await dir.findName($path[i]);
|
|
108
121
|
if (!entry) break;
|
|
109
|
-
if (i ===
|
|
122
|
+
if (i === len) return entry;
|
|
110
123
|
if (!entry.isDirectory()) illegalArgs(path);
|
|
111
|
-
dir =
|
|
124
|
+
dir = directory(this, entry);
|
|
112
125
|
}
|
|
113
126
|
illegalArgs(path);
|
|
114
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Attempts to find or to create the {@link IEntry} for given `path` and
|
|
130
|
+
* entry type (file or directory) and then returns it if successful. Throws
|
|
131
|
+
* error if the path exists, but does not match the expected type or if the
|
|
132
|
+
* entry could not be created for other reasons.
|
|
133
|
+
*
|
|
134
|
+
* @remarks
|
|
135
|
+
* Also see {@link BlockFS.entryForPath}.
|
|
136
|
+
*
|
|
137
|
+
* @param path
|
|
138
|
+
* @param type
|
|
139
|
+
*/
|
|
115
140
|
async ensureEntryForPath(path, type) {
|
|
116
141
|
let dir = this.root;
|
|
117
|
-
|
|
142
|
+
const { directory, separator } = this.opts;
|
|
143
|
+
if (path[0] === separator) path = path.substring(1);
|
|
118
144
|
if (path === "") return dir.entry;
|
|
119
|
-
const $path = path.split(
|
|
120
|
-
for (let i = 0
|
|
145
|
+
const $path = path.split(separator);
|
|
146
|
+
for (let i = 0, len = $path.length - 1; i <= len; i++) {
|
|
121
147
|
let entry = await dir.findName($path[i]);
|
|
122
148
|
if (!entry) {
|
|
123
|
-
if (i <
|
|
149
|
+
if (i < len) {
|
|
124
150
|
entry = await dir.mkdir($path[i]);
|
|
125
151
|
} else {
|
|
126
152
|
return await dir.addEntry({
|
|
@@ -130,7 +156,7 @@ class BlockFS {
|
|
|
130
156
|
});
|
|
131
157
|
}
|
|
132
158
|
}
|
|
133
|
-
if (i ===
|
|
159
|
+
if (i === len) {
|
|
134
160
|
if (entry.type !== type)
|
|
135
161
|
illegalArgs(
|
|
136
162
|
`path exists, but is not a ${EntryType[type]}: ${path}`
|
|
@@ -138,32 +164,53 @@ class BlockFS {
|
|
|
138
164
|
return entry;
|
|
139
165
|
}
|
|
140
166
|
if (!entry.isDirectory()) illegalArgs(path);
|
|
141
|
-
dir =
|
|
167
|
+
dir = directory(this, entry);
|
|
142
168
|
}
|
|
143
169
|
illegalArgs(path);
|
|
144
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Attempts to allocate a number of free blocks required for storing the
|
|
173
|
+
* given number of `bytes`. If successful, marks the blocks as used in the
|
|
174
|
+
* allocation table and then returns list of their IDs, otherwise throws an
|
|
175
|
+
* error if there're insufficient blocks available.
|
|
176
|
+
*
|
|
177
|
+
* @param bytes
|
|
178
|
+
*/
|
|
145
179
|
async allocateBlocks(bytes) {
|
|
146
|
-
const
|
|
180
|
+
const {
|
|
181
|
+
blockDataSize,
|
|
182
|
+
blockIndex,
|
|
183
|
+
dataStartBlockID,
|
|
184
|
+
lock,
|
|
185
|
+
sentinelID
|
|
186
|
+
} = this;
|
|
187
|
+
const lockID = await lock.acquire();
|
|
147
188
|
try {
|
|
148
189
|
const ids = [];
|
|
149
|
-
let last =
|
|
190
|
+
let last = dataStartBlockID;
|
|
150
191
|
while (bytes > 0) {
|
|
151
|
-
const next =
|
|
152
|
-
if (next < 0 || next >=
|
|
192
|
+
const next = blockIndex.firstZero(last);
|
|
193
|
+
if (next < 0 || next >= sentinelID) {
|
|
153
194
|
throw new Error(
|
|
154
195
|
`insufficient free blocks for storing ${bytes} bytes`
|
|
155
196
|
);
|
|
156
197
|
}
|
|
157
198
|
ids.push(next);
|
|
158
199
|
last = next + 1;
|
|
159
|
-
bytes -=
|
|
200
|
+
bytes -= blockDataSize;
|
|
160
201
|
}
|
|
161
202
|
await this.updateBlockIndex(ids, 1);
|
|
162
203
|
return ids;
|
|
163
204
|
} finally {
|
|
164
|
-
await
|
|
205
|
+
await lock.release(lockID);
|
|
165
206
|
}
|
|
166
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Marks the given block IDs as free/unused in the block allocation table
|
|
210
|
+
* and deletes/clears the blocks.
|
|
211
|
+
*
|
|
212
|
+
* @param ids
|
|
213
|
+
*/
|
|
167
214
|
async freeBlocks(ids) {
|
|
168
215
|
const lockID = await this.lock.acquire();
|
|
169
216
|
try {
|
|
@@ -173,54 +220,116 @@ class BlockFS {
|
|
|
173
220
|
await this.lock.release(lockID);
|
|
174
221
|
}
|
|
175
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Same as POSIX `mkdirp`. Attempts to create given directory, including any
|
|
225
|
+
* missing intermediate ones defined by `path`.
|
|
226
|
+
*
|
|
227
|
+
* @param path
|
|
228
|
+
*/
|
|
176
229
|
async mkdir(path) {
|
|
177
230
|
return this.ensureEntryForPath(path, EntryType.DIR);
|
|
178
231
|
}
|
|
179
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Async iterator. Reads block list for given `path` (file path or start
|
|
234
|
+
* block ID) and yields data contents (byte arrays) of each block.
|
|
235
|
+
*
|
|
236
|
+
* @remarks
|
|
237
|
+
* Also see other read methods:
|
|
238
|
+
*
|
|
239
|
+
* - {@link BlockFS.readFile}
|
|
240
|
+
* - {@link BlockFS.readText}
|
|
241
|
+
* - {@link BlockFS.readJSON}
|
|
242
|
+
*
|
|
243
|
+
* @param path
|
|
244
|
+
*/
|
|
245
|
+
async *readBlocks(path) {
|
|
180
246
|
let blockID = isString(path) ? (await this.entryForPath(path)).start : path;
|
|
247
|
+
const { blockDataOffset, blockIndex, sentinelID, storage } = this;
|
|
181
248
|
while (true) {
|
|
182
|
-
if (!
|
|
249
|
+
if (!blockIndex.at(blockID)) {
|
|
183
250
|
throw new Error(`invalid block ref: ${blockID}`);
|
|
184
251
|
}
|
|
185
|
-
const bytes = await
|
|
252
|
+
const bytes = await storage.loadBlock(blockID);
|
|
186
253
|
const { next, size } = this.getBlockMeta(bytes);
|
|
187
|
-
yield bytes.subarray(
|
|
188
|
-
|
|
189
|
-
this.blockDataOffset + size
|
|
190
|
-
);
|
|
191
|
-
if (next === this.sentinelID) return;
|
|
254
|
+
yield bytes.subarray(blockDataOffset, blockDataOffset + size);
|
|
255
|
+
if (next === sentinelID) return;
|
|
192
256
|
blockID = next;
|
|
193
257
|
}
|
|
194
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Fully reads given file into a single byte buffer and returns it.
|
|
261
|
+
*
|
|
262
|
+
* @param path
|
|
263
|
+
*/
|
|
195
264
|
async readFile(path) {
|
|
196
265
|
const buffer = [];
|
|
197
|
-
for await (let block of this.
|
|
266
|
+
for await (let block of this.readBlocks(path)) buffer.push(...block);
|
|
198
267
|
return new Uint8Array(buffer);
|
|
199
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Fully reads given file into a single UTF-8 byte buffer, then decodes and
|
|
271
|
+
* returns it as string.
|
|
272
|
+
*
|
|
273
|
+
* @param path
|
|
274
|
+
*/
|
|
200
275
|
async readText(path) {
|
|
201
276
|
return new TextDecoder().decode(await this.readFile(path));
|
|
202
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Fully reads given file into a single UTF-8 byte buffer, then decodes it
|
|
280
|
+
* as JSON and returns result.
|
|
281
|
+
*
|
|
282
|
+
* @param path
|
|
283
|
+
*/
|
|
203
284
|
async readJSON(path) {
|
|
204
285
|
return JSON.parse(await this.readText(path));
|
|
205
286
|
}
|
|
206
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Fully reads given file into a single byte buffer and returns it as blob
|
|
289
|
+
* object URL, optionally typed with given MIME type.
|
|
290
|
+
*
|
|
291
|
+
* @remarks
|
|
292
|
+
* Reference:
|
|
293
|
+
*
|
|
294
|
+
* - https://developer.mozilla.org/en-US/docs/Web/API/Blob#creating_a_url_representing_the_contents_of_a_typed_array
|
|
295
|
+
*
|
|
296
|
+
* @param path
|
|
297
|
+
* @param type
|
|
298
|
+
*/
|
|
299
|
+
async readAsObjectURL(path, type) {
|
|
300
|
+
return URL.createObjectURL(
|
|
301
|
+
new Blob([await this.readFile(path)], { type })
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Takes an array of block IDs (or `null`) and a `data` byte array. Writes
|
|
306
|
+
* chunks of data into given blocks and connecting each block as linked
|
|
307
|
+
* list. Returns object of start/end block IDs and data size.
|
|
308
|
+
*
|
|
309
|
+
* @remarks
|
|
310
|
+
* If `blocks` is null, blocks are automatically allocated via
|
|
311
|
+
* {@link BlockFS.allocateBlocks}.
|
|
312
|
+
*
|
|
313
|
+
* @param blocks
|
|
314
|
+
* @param data
|
|
315
|
+
*/
|
|
316
|
+
async writeBlocks(blocks, data) {
|
|
317
|
+
const { blockDataOffset, blockDataSize, sentinelID, storage } = this;
|
|
207
318
|
if (!blocks) blocks = await this.allocateBlocks(data.length);
|
|
208
319
|
let offset = 0;
|
|
209
320
|
for (let i = 0, numBlocks = blocks.length - 1; i <= numBlocks; i++) {
|
|
321
|
+
if (offset >= data.length)
|
|
322
|
+
illegalState(`too many blocks, EOF already reached`);
|
|
210
323
|
const id = blocks[i];
|
|
211
|
-
const block = await
|
|
212
|
-
i < numBlocks ? this.setBlockMeta(block, blocks[i + 1],
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const chunk = data.subarray(offset, offset + this.blockDataSize);
|
|
218
|
-
block.set(chunk, this.blockDataOffset);
|
|
219
|
-
if (chunk.length < this.blockDataSize) {
|
|
220
|
-
block.fill(0, this.blockDataOffset + chunk.length);
|
|
324
|
+
const block = await storage.loadBlock(id);
|
|
325
|
+
i < numBlocks ? this.setBlockMeta(block, blocks[i + 1], blockDataSize) : this.setBlockMeta(block, sentinelID, data.length - offset);
|
|
326
|
+
const chunk = data.subarray(offset, offset + blockDataSize);
|
|
327
|
+
block.set(chunk, blockDataOffset);
|
|
328
|
+
if (chunk.length < blockDataSize) {
|
|
329
|
+
block.fill(0, blockDataOffset + chunk.length);
|
|
221
330
|
}
|
|
222
|
-
await
|
|
223
|
-
offset +=
|
|
331
|
+
await storage.saveBlock(id, block);
|
|
332
|
+
offset += blockDataSize;
|
|
224
333
|
}
|
|
225
334
|
return {
|
|
226
335
|
start: blocks[0],
|
|
@@ -230,7 +339,7 @@ class BlockFS {
|
|
|
230
339
|
}
|
|
231
340
|
async writeFile(path, data) {
|
|
232
341
|
if (isString(data)) data = new TextEncoder().encode(data);
|
|
233
|
-
if (!path) return this.
|
|
342
|
+
if (!path) return this.writeBlocks(null, data);
|
|
234
343
|
const entry = await this.ensureEntryForPath(path, EntryType.FILE);
|
|
235
344
|
let blocks = await this.blockList(entry.start);
|
|
236
345
|
const overflow = data.length - blocks.length * this.blockDataSize;
|
|
@@ -241,37 +350,38 @@ class BlockFS {
|
|
|
241
350
|
await this.freeBlocks(blocks.slice(needed));
|
|
242
351
|
blocks = blocks.slice(0, needed);
|
|
243
352
|
}
|
|
244
|
-
blocks.sort();
|
|
353
|
+
blocks.sort((a, b) => a - b);
|
|
245
354
|
entry.start = blocks[0];
|
|
246
355
|
entry.end = blocks[blocks.length - 1];
|
|
247
356
|
entry.size = BigInt(data.length);
|
|
248
357
|
entry.mtime = Date.now();
|
|
249
358
|
await entry.save();
|
|
250
|
-
return this.
|
|
359
|
+
return this.writeBlocks(blocks, data);
|
|
251
360
|
}
|
|
252
|
-
async
|
|
253
|
-
if (blockID === this.sentinelID) return this.
|
|
361
|
+
async appendBlocks(blockID, data) {
|
|
362
|
+
if (blockID === this.sentinelID) return this.writeBlocks(null, data);
|
|
363
|
+
const { blockDataOffset, blockDataSize, storage } = this;
|
|
254
364
|
const blocks = await this.blockList(blockID);
|
|
255
365
|
const lastBlockID = blocks[blocks.length - 1];
|
|
256
|
-
const lastBlock = await
|
|
366
|
+
const lastBlock = await storage.loadBlock(lastBlockID);
|
|
257
367
|
const currLength = decodeBytes(
|
|
258
368
|
lastBlock,
|
|
259
369
|
this.blockIDBytes,
|
|
260
370
|
this.blockDataSizeBytes
|
|
261
371
|
);
|
|
262
|
-
const remaining =
|
|
263
|
-
lastBlock.fill(0, 0,
|
|
372
|
+
const remaining = blockDataSize - currLength;
|
|
373
|
+
lastBlock.fill(0, 0, blockDataOffset);
|
|
264
374
|
lastBlock.set(
|
|
265
375
|
data.subarray(0, remaining),
|
|
266
|
-
|
|
376
|
+
blockDataOffset + currLength
|
|
267
377
|
);
|
|
268
378
|
let newEndBlockID;
|
|
269
379
|
if (data.length > remaining) {
|
|
270
|
-
const { start, end } = await this.
|
|
380
|
+
const { start, end } = await this.writeBlocks(
|
|
271
381
|
null,
|
|
272
382
|
data.subarray(remaining)
|
|
273
383
|
);
|
|
274
|
-
this.setBlockMeta(lastBlock, start,
|
|
384
|
+
this.setBlockMeta(lastBlock, start, blockDataSize);
|
|
275
385
|
newEndBlockID = end;
|
|
276
386
|
} else {
|
|
277
387
|
this.setBlockMeta(
|
|
@@ -281,14 +391,14 @@ class BlockFS {
|
|
|
281
391
|
);
|
|
282
392
|
newEndBlockID = lastBlockID;
|
|
283
393
|
}
|
|
284
|
-
await
|
|
394
|
+
await storage.saveBlock(lastBlockID, lastBlock);
|
|
285
395
|
return { start: blockID, end: newEndBlockID };
|
|
286
396
|
}
|
|
287
397
|
async appendFile(path, data) {
|
|
288
398
|
if (isString(data)) data = new TextEncoder().encode(data);
|
|
289
|
-
if (!isString(path)) return this.
|
|
399
|
+
if (!isString(path)) return this.appendBlocks(path, data);
|
|
290
400
|
const entry = await this.ensureEntryForPath(path, EntryType.FILE);
|
|
291
|
-
const { start, end } = await this.
|
|
401
|
+
const { start, end } = await this.appendBlocks(entry.end, data);
|
|
292
402
|
if (entry.start === this.sentinelID) entry.start = start;
|
|
293
403
|
entry.end = end;
|
|
294
404
|
entry.size += BigInt(data.length);
|
|
@@ -308,33 +418,35 @@ class BlockFS {
|
|
|
308
418
|
}
|
|
309
419
|
}
|
|
310
420
|
async blockList(blockID) {
|
|
421
|
+
const { blockIDBytes, sentinelID, storage } = this;
|
|
311
422
|
const blocks = [];
|
|
312
|
-
if (blockID ===
|
|
423
|
+
if (blockID === sentinelID) return blocks;
|
|
313
424
|
while (true) {
|
|
314
425
|
blocks.push(blockID);
|
|
315
|
-
const block = await
|
|
316
|
-
const nextID = decodeBytes(block, 0,
|
|
317
|
-
if (nextID ===
|
|
426
|
+
const block = await storage.loadBlock(blockID);
|
|
427
|
+
const nextID = decodeBytes(block, 0, blockIDBytes);
|
|
428
|
+
if (nextID === sentinelID) break;
|
|
318
429
|
blockID = nextID;
|
|
319
430
|
}
|
|
320
431
|
return blocks;
|
|
321
432
|
}
|
|
322
433
|
async updateBlockIndex(ids, state) {
|
|
323
|
-
const
|
|
434
|
+
const { blockIndex, storage, tmp } = this;
|
|
435
|
+
const blockSize = storage.blockSize;
|
|
324
436
|
const updatedBlocks = /* @__PURE__ */ new Set();
|
|
325
437
|
for (let id of ids) {
|
|
326
|
-
|
|
438
|
+
blockIndex.setAt(id, state);
|
|
327
439
|
updatedBlocks.add((id >>> 3) / blockSize | 0);
|
|
328
440
|
}
|
|
329
441
|
for (let id of updatedBlocks) {
|
|
330
442
|
this.opts.logger.debug("update block index", id);
|
|
331
|
-
const chunk =
|
|
443
|
+
const chunk = blockIndex.data.subarray(
|
|
332
444
|
id * blockSize,
|
|
333
445
|
(id + 1) * blockSize
|
|
334
446
|
);
|
|
335
|
-
|
|
336
|
-
if (chunk.length < blockSize)
|
|
337
|
-
await
|
|
447
|
+
tmp.set(chunk);
|
|
448
|
+
if (chunk.length < blockSize) tmp.fill(0, chunk.length);
|
|
449
|
+
await storage.saveBlock(id, tmp);
|
|
338
450
|
}
|
|
339
451
|
}
|
|
340
452
|
/** @internal */
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thi.ng/block-fs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Customizable block-based storage, adapters & file system layer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./index.js",
|
|
7
7
|
"typings": "./index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"blockfs": "bin/blockfs"
|
|
10
|
+
},
|
|
8
11
|
"sideEffects": false,
|
|
9
12
|
"repository": {
|
|
10
13
|
"type": "git",
|
|
@@ -40,6 +43,7 @@
|
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
45
|
"@thi.ng/api": "^8.11.25",
|
|
46
|
+
"@thi.ng/args": "^2.3.66",
|
|
43
47
|
"@thi.ng/binary": "^3.4.48",
|
|
44
48
|
"@thi.ng/bitfield": "^2.4.0",
|
|
45
49
|
"@thi.ng/checks": "^3.7.5",
|
|
@@ -58,10 +62,13 @@
|
|
|
58
62
|
"keywords": [
|
|
59
63
|
"binary",
|
|
60
64
|
"block",
|
|
65
|
+
"cli",
|
|
66
|
+
"conversion",
|
|
61
67
|
"file",
|
|
62
68
|
"file-system",
|
|
63
69
|
"memory",
|
|
64
70
|
"memory-mapped",
|
|
71
|
+
"nodejs",
|
|
65
72
|
"path",
|
|
66
73
|
"storage",
|
|
67
74
|
"typedarray",
|
|
@@ -80,6 +87,7 @@
|
|
|
80
87
|
"files": [
|
|
81
88
|
"./*.js",
|
|
82
89
|
"./*.d.ts",
|
|
90
|
+
"bin",
|
|
83
91
|
"storage"
|
|
84
92
|
],
|
|
85
93
|
"exports": {
|
|
@@ -89,6 +97,9 @@
|
|
|
89
97
|
"./api": {
|
|
90
98
|
"default": "./api.js"
|
|
91
99
|
},
|
|
100
|
+
"./cli": {
|
|
101
|
+
"default": "./cli.js"
|
|
102
|
+
},
|
|
92
103
|
"./directory": {
|
|
93
104
|
"default": "./directory.js"
|
|
94
105
|
},
|
|
@@ -118,5 +129,5 @@
|
|
|
118
129
|
"status": "alpha",
|
|
119
130
|
"year": 2024
|
|
120
131
|
},
|
|
121
|
-
"gitHead": "
|
|
132
|
+
"gitHead": "549e798e6a82254ee68727f12229c5252892b8f1\n"
|
|
122
133
|
}
|
package/storage/file.d.ts
CHANGED
|
@@ -8,7 +8,13 @@ export declare class FileBlock implements IBlock {
|
|
|
8
8
|
save(data: Uint8Array): Promise<void>;
|
|
9
9
|
delete(): Promise<void>;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Configuration options for {@link FileBlockStorage}.
|
|
13
|
+
*/
|
|
11
14
|
export interface FileBlockStorageOpts extends BlockStorageOpts {
|
|
15
|
+
/**
|
|
16
|
+
* Path to host filesystem base directory used for storing blocks.
|
|
17
|
+
*/
|
|
12
18
|
baseDir: string;
|
|
13
19
|
}
|
|
14
20
|
export declare class FileBlockStorage extends ABlockStorage<FileBlock> {
|
package/storage/file.js
CHANGED
|
@@ -11,18 +11,15 @@ class FileBlock {
|
|
|
11
11
|
this.id = id;
|
|
12
12
|
}
|
|
13
13
|
async load() {
|
|
14
|
-
const
|
|
15
|
-
|
|
14
|
+
const { storage } = this;
|
|
15
|
+
const path = storage.getPath(this.id);
|
|
16
|
+
return existsSync(path) ? readBinary(path, storage.logger) : new Uint8Array(storage.blockSize);
|
|
16
17
|
}
|
|
17
18
|
async save(data) {
|
|
18
|
-
|
|
19
|
+
const { storage } = this;
|
|
20
|
+
if (data.length !== storage.blockSize)
|
|
19
21
|
illegalArgs(`wrong block size: ${data.length}`);
|
|
20
|
-
writeFile(
|
|
21
|
-
this.storage.getPath(this.id),
|
|
22
|
-
data,
|
|
23
|
-
null,
|
|
24
|
-
this.storage.logger
|
|
25
|
-
);
|
|
22
|
+
writeFile(storage.getPath(this.id), data, null, storage.logger);
|
|
26
23
|
}
|
|
27
24
|
async delete() {
|
|
28
25
|
deleteFile(this.storage.getPath(this.id), this.storage.logger);
|
package/storage/memory.d.ts
CHANGED
|
@@ -8,9 +8,19 @@ export declare class MemoryBlock implements IBlock {
|
|
|
8
8
|
save(data: Uint8Array): Promise<void>;
|
|
9
9
|
delete(): Promise<void>;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Configuration options for {@link MemoryBlockStorage}.
|
|
13
|
+
*/
|
|
14
|
+
export interface MemoryBlockStorageOpts extends BlockStorageOpts {
|
|
15
|
+
/**
|
|
16
|
+
* Optional, pre-defined/loaded byte buffer. Must have at least `numBlocks *
|
|
17
|
+
* blockSize` capacity.
|
|
18
|
+
*/
|
|
19
|
+
buffer?: Uint8Array;
|
|
20
|
+
}
|
|
11
21
|
export declare class MemoryBlockStorage extends ABlockStorage<MemoryBlock> {
|
|
12
22
|
buffer: Uint8Array;
|
|
13
|
-
constructor(opts:
|
|
23
|
+
constructor(opts: MemoryBlockStorageOpts);
|
|
14
24
|
hasBlock(id: number): Promise<boolean>;
|
|
15
25
|
ensureBlock(id: number): MemoryBlock;
|
|
16
26
|
}
|
package/storage/memory.js
CHANGED
|
@@ -6,16 +6,16 @@ class MemoryBlock {
|
|
|
6
6
|
this.id = id;
|
|
7
7
|
}
|
|
8
8
|
async load() {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
);
|
|
9
|
+
const { storage } = this;
|
|
10
|
+
storage.logger.debug("load block", this.id);
|
|
11
|
+
const size = storage.blockSize;
|
|
12
|
+
return storage.buffer.subarray(this.id * size, (this.id + 1) * size);
|
|
14
13
|
}
|
|
15
14
|
async save(data) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const { storage } = this;
|
|
16
|
+
if (data.length !== storage.blockSize) illegalArgs(`wrong block size`);
|
|
17
|
+
storage.logger.debug("save block", this.id);
|
|
18
|
+
storage.buffer.set(data, this.id * storage.blockSize);
|
|
19
19
|
}
|
|
20
20
|
async delete() {
|
|
21
21
|
const size = this.storage.blockSize;
|
|
@@ -26,7 +26,13 @@ class MemoryBlockStorage extends ABlockStorage {
|
|
|
26
26
|
buffer;
|
|
27
27
|
constructor(opts) {
|
|
28
28
|
super(opts);
|
|
29
|
-
|
|
29
|
+
const size = this.numBlocks * this.blockSize;
|
|
30
|
+
if (opts.buffer && opts.buffer.length < size) {
|
|
31
|
+
illegalArgs(
|
|
32
|
+
`given buffer is too small, expected at least ${size} bytes`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
this.buffer = opts.buffer ?? new Uint8Array(size);
|
|
30
36
|
}
|
|
31
37
|
async hasBlock(id) {
|
|
32
38
|
this.ensureValidID(id);
|