@peers-app/peers-sdk 0.30.0 → 0.30.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contracts/contract-resolvers.js +1 -0
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/system/file-storage-contract.d.ts +170 -0
- package/dist/contracts/system/file-storage-contract.js +123 -0
- package/dist/contracts/system/files-contract.d.ts +5 -1
- package/dist/contracts/system/files-contract.js +50 -9
- package/dist/contracts/system/files-shared.d.ts +68 -3
- package/dist/contracts/system/files-shared.js +65 -5
- package/dist/data/derived-row-id.d.ts +26 -0
- package/dist/data/derived-row-id.js +47 -0
- package/dist/data/files/chunk-store.d.ts +119 -0
- package/dist/data/files/chunk-store.js +112 -0
- package/dist/data/files/file-read-stream.d.ts +32 -3
- package/dist/data/files/file-read-stream.js +44 -29
- package/dist/data/files/file-rows.d.ts +77 -0
- package/dist/data/files/file-rows.js +182 -0
- package/dist/data/files/file-write-stream.d.ts +27 -0
- package/dist/data/files/file-write-stream.js +67 -42
- package/dist/data/files/file.types.d.ts +59 -23
- package/dist/data/files/file.types.js +54 -51
- package/dist/data/files/files.d.ts +28 -24
- package/dist/data/files/files.js +71 -85
- package/dist/data/files/index.d.ts +2 -0
- package/dist/data/files/index.js +2 -0
- package/dist/data/groups.d.ts +13 -0
- package/dist/data/groups.js +6 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/orm/write-checks.d.ts +15 -0
- package/dist/data/orm/write-checks.js +21 -0
- package/dist/define/system-contracts.d.ts +9 -2
- package/dist/define/system-contracts.js +6 -2
- package/dist/device-pairing/types.d.ts +2 -2
- package/dist/invites/invite-messages.d.ts +38 -0
- package/dist/package-installer/package-installer.d.ts +3 -5
- package/dist/package-installer/package-installer.js +5 -21
- package/dist/package-installer/package-propagation.js +6 -17
- package/dist/system-ids.d.ts +2 -0
- package/dist/system-ids.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fileIdFor = fileIdFor;
|
|
4
|
+
exports.isLegacyFileId = isLegacyFileId;
|
|
5
|
+
exports.isLegacyBundleFile = isLegacyBundleFile;
|
|
6
|
+
exports.isRuntimeBundleFile = isRuntimeBundleFile;
|
|
7
|
+
exports.isKeptFile = isKeptFile;
|
|
8
|
+
exports.isCollectableFile = isCollectableFile;
|
|
9
|
+
exports.isSameFile = isSameFile;
|
|
10
|
+
exports.fileRowProblem = fileRowProblem;
|
|
11
|
+
exports.checkFileWrite = checkFileWrite;
|
|
12
|
+
/**
|
|
13
|
+
* The rules every `Files` row meets, on every device: ids derived from the content, rows that
|
|
14
|
+
* never change, and deletes only of runtime bundle rows. Each device checks an
|
|
15
|
+
* incoming row in full, so no member can put other content under a file's id.
|
|
16
|
+
*/
|
|
17
|
+
const derived_row_id_1 = require("../derived-row-id");
|
|
18
|
+
const write_checks_1 = require("../orm/write-checks");
|
|
19
|
+
const file_types_1 = require("./file.types");
|
|
20
|
+
/** A chunk hash: SHA-256 as unpadded base64url. */
|
|
21
|
+
const CHUNK_HASH = /^[A-Za-z0-9_-]{43}$/;
|
|
22
|
+
/**
|
|
23
|
+
* Names the package installer gives bundle files: `<packageId>-<kind>.bundle.js`, and the older
|
|
24
|
+
* `<name>.<kind>.<packageId>.js`.
|
|
25
|
+
*/
|
|
26
|
+
const BUNDLE_FILE_NAME = /^[0-9a-z]{25}-(package|routes|uis)\.bundle\.js$|^.+\.(package|routes|uis)\.[0-9a-z]{25}\.js$/;
|
|
27
|
+
/**
|
|
28
|
+
* The id of a file: derived from its hash, size, name, MIME type and `keep`, so saving the same
|
|
29
|
+
* content with the same description gives the same file on every device. Give the name and MIME
|
|
30
|
+
* type as storage keeps them (`FileWriteStream` normalizes them), or other devices, which
|
|
31
|
+
* rederive the id from the stored row, refuse it.
|
|
32
|
+
*
|
|
33
|
+
* @param file - The file's hash, size, name, MIME type and `keep`.
|
|
34
|
+
*/
|
|
35
|
+
function fileIdFor(file) {
|
|
36
|
+
return (0, derived_row_id_1.derivedRowId)("Files", [
|
|
37
|
+
file.fileHash,
|
|
38
|
+
file.fileSize,
|
|
39
|
+
file.name,
|
|
40
|
+
file.mimeType ?? "",
|
|
41
|
+
file.keep,
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether a row is a legacy file: one saved under a random id, before ids were derived from
|
|
46
|
+
* content. Legacy rows are admitted as they are, never change, and are always kept.
|
|
47
|
+
*
|
|
48
|
+
* @param fileId - The row's id.
|
|
49
|
+
*/
|
|
50
|
+
function isLegacyFileId(fileId) {
|
|
51
|
+
return !(0, derived_row_id_1.isDerivedRowId)(fileId);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether a legacy row is a package bundle the installer saved. The runtime owns these, so they
|
|
55
|
+
* are collected once nothing references them, and their rows may be deleted.
|
|
56
|
+
*
|
|
57
|
+
* @param file - The row's id and name.
|
|
58
|
+
*/
|
|
59
|
+
function isLegacyBundleFile(file) {
|
|
60
|
+
return isLegacyFileId(file.fileId) && BUNDLE_FILE_NAME.test(file.name);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether this is a runtime-owned package bundle: a legacy bundle, or a derived file with
|
|
64
|
+
* an installer bundle name, JavaScript MIME type and keep=false. Read keep through its
|
|
65
|
+
* identity when an older device relayed the row without that column.
|
|
66
|
+
*/
|
|
67
|
+
function isRuntimeBundleFile(file) {
|
|
68
|
+
return (isLegacyBundleFile(file) ||
|
|
69
|
+
(!isLegacyFileId(file.fileId) &&
|
|
70
|
+
BUNDLE_FILE_NAME.test(file.name) &&
|
|
71
|
+
file.mimeType === "application/javascript" &&
|
|
72
|
+
!isKeptFile(file)));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether a file is kept: never collected, even with nothing referencing it. A legacy row always
|
|
76
|
+
* is. A row relayed by a build that predates `keep` arrives without it; its id, derived from
|
|
77
|
+
* `keep`, then decides, and a row whose id says neither is kept.
|
|
78
|
+
*
|
|
79
|
+
* @param file - The row.
|
|
80
|
+
*/
|
|
81
|
+
function isKeptFile(file) {
|
|
82
|
+
if (isLegacyFileId(file.fileId))
|
|
83
|
+
return true;
|
|
84
|
+
if (typeof file.keep === "boolean")
|
|
85
|
+
return file.keep;
|
|
86
|
+
return fileIdFor({ ...file, keep: false }) !== file.fileId;
|
|
87
|
+
}
|
|
88
|
+
/** Whether an unreferenced file may expire, including the runtime's bundle exception. */
|
|
89
|
+
function isCollectableFile(file) {
|
|
90
|
+
return isRuntimeBundleFile(file) || !isKeptFile(file);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Whether two rows describe the same file, ignoring how an older build stored it (no `keep`, no
|
|
94
|
+
* MIME type).
|
|
95
|
+
*
|
|
96
|
+
* @param a - One row.
|
|
97
|
+
* @param b - The other.
|
|
98
|
+
*/
|
|
99
|
+
function isSameFile(a, b) {
|
|
100
|
+
return (a.fileId === b.fileId &&
|
|
101
|
+
a.name === b.name &&
|
|
102
|
+
a.fileSize === b.fileSize &&
|
|
103
|
+
a.fileHash === b.fileHash &&
|
|
104
|
+
(a.mimeType ?? "") === (b.mimeType ?? "") &&
|
|
105
|
+
isKeptFile(a) === isKeptFile(b) &&
|
|
106
|
+
JSON.stringify(a.chunkHashes ?? []) === JSON.stringify(b.chunkHashes ?? []));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Why `file` can't be stored as a new row under `recordId`, or undefined when it can. A legacy
|
|
110
|
+
* row needs only a size within the limit. Any other must have its derived id, a chunk list of
|
|
111
|
+
* well-formed hashes, one per started MiB, and the `fileHash` of that list.
|
|
112
|
+
*
|
|
113
|
+
* @param file - The candidate row.
|
|
114
|
+
* @param recordId - The record id it's written under.
|
|
115
|
+
*/
|
|
116
|
+
function fileRowProblem(file, recordId) {
|
|
117
|
+
if (file.fileId !== recordId)
|
|
118
|
+
return "the row's id isn't its record id";
|
|
119
|
+
if (!Number.isSafeInteger(file.fileSize) || file.fileSize < 0) {
|
|
120
|
+
return "its size isn't a whole number of bytes";
|
|
121
|
+
}
|
|
122
|
+
if (file.fileSize > file_types_1.FILE_MAX_SIZE) {
|
|
123
|
+
return `it's over the ${file_types_1.FILE_MAX_SIZE / (1024 * 1024)} MiB limit`;
|
|
124
|
+
}
|
|
125
|
+
if (isLegacyFileId(file.fileId))
|
|
126
|
+
return undefined;
|
|
127
|
+
if (typeof file.name !== "string")
|
|
128
|
+
return "its name isn't text";
|
|
129
|
+
// Storage may return an absent value as null.
|
|
130
|
+
if (file.mimeType != null && typeof file.mimeType !== "string") {
|
|
131
|
+
return "its MIME type isn't text";
|
|
132
|
+
}
|
|
133
|
+
if (file.keep != null && typeof file.keep !== "boolean")
|
|
134
|
+
return "keep isn't a boolean";
|
|
135
|
+
const chunkHashes = file.chunkHashes ?? [];
|
|
136
|
+
if (!Array.isArray(chunkHashes) ||
|
|
137
|
+
!chunkHashes.every((hash) => typeof hash === "string" && CHUNK_HASH.test(hash))) {
|
|
138
|
+
return "its chunk list isn't a list of chunk hashes";
|
|
139
|
+
}
|
|
140
|
+
if (chunkHashes.length !== Math.ceil(file.fileSize / file_types_1.FILE_CHUNK_SIZE)) {
|
|
141
|
+
return "its chunk count doesn't match its size";
|
|
142
|
+
}
|
|
143
|
+
if (file.fileHash !== (0, file_types_1.fileHashOf)(chunkHashes))
|
|
144
|
+
return "its hash doesn't match its chunk list";
|
|
145
|
+
// A build that predates `keep` relays the row without it.
|
|
146
|
+
const keeps = typeof file.keep === "boolean" ? [file.keep] : [false, true];
|
|
147
|
+
if (!keeps.some((keep) => fileIdFor({ ...file, keep }) === file.fileId)) {
|
|
148
|
+
return "its id isn't derived from its content";
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The `Files` write check: a new row must pass {@link fileRowProblem}; a stored row never
|
|
154
|
+
* changes (rewriting the same file is allowed); and only a stored runtime bundle row may be
|
|
155
|
+
* deleted. Unknown legacy ids remain allowed.
|
|
156
|
+
*
|
|
157
|
+
* @param existing - The row as stored, if any.
|
|
158
|
+
* @param candidate - The row to store, or undefined for a delete.
|
|
159
|
+
* @param recordId - The record written.
|
|
160
|
+
* @throws A {@link writeNotAuthorized} refusal.
|
|
161
|
+
*/
|
|
162
|
+
function checkFileWrite(existing, candidate, recordId) {
|
|
163
|
+
if (!candidate) {
|
|
164
|
+
if (existing ? isRuntimeBundleFile(existing) : isLegacyFileId(recordId))
|
|
165
|
+
return;
|
|
166
|
+
if (existing)
|
|
167
|
+
throw (0, write_checks_1.writeNotAuthorized)("Files", "a file can't be deleted");
|
|
168
|
+
// An unknown derived id carries no evidence that the row is a bundle. Refuse its delete
|
|
169
|
+
// so a later non-bundle row is still materialized from its authorized history. No later
|
|
170
|
+
// membership data changes that, so sync needn't keep the refusal for recovery.
|
|
171
|
+
throw (0, write_checks_1.writeRefusedForGood)("Files", "a file can't be deleted");
|
|
172
|
+
}
|
|
173
|
+
if (existing) {
|
|
174
|
+
if (!isSameFile(existing, candidate))
|
|
175
|
+
throw (0, write_checks_1.writeNotAuthorized)("Files", "a file can't change");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const problem = fileRowProblem(candidate, recordId);
|
|
179
|
+
if (problem)
|
|
180
|
+
throw (0, write_checks_1.writeNotAuthorized)("Files", `the file is refused: ${problem}`);
|
|
181
|
+
}
|
|
182
|
+
(0, write_checks_1.registerTableWriteCheck)("Files", (existing, candidate, { recordId }) => checkFileWrite(existing, candidate, recordId));
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type IFile, type IFileInput } from "./file.types";
|
|
2
2
|
import type { FilesTable } from "./files";
|
|
3
|
+
/**
|
|
4
|
+
* Writes one file in pieces: each full chunk is stored as it arrives, and `finalize` saves the
|
|
5
|
+
* row under the id derived from the content. Open one with {@link FilesTable.createWriteStream}.
|
|
6
|
+
*/
|
|
3
7
|
export declare class FileWriteStream {
|
|
4
8
|
private metadata;
|
|
5
9
|
private fileTable;
|
|
@@ -8,13 +12,36 @@ export declare class FileWriteStream {
|
|
|
8
12
|
private bytesWritten;
|
|
9
13
|
private finalized;
|
|
10
14
|
private aborted;
|
|
15
|
+
/**
|
|
16
|
+
* @param metadata - The file's name, MIME type and `keep`.
|
|
17
|
+
* @param fileTable - The Files table the row is saved to.
|
|
18
|
+
*/
|
|
11
19
|
constructor(metadata: IFileInput, fileTable: FilesTable);
|
|
20
|
+
/**
|
|
21
|
+
* Append bytes. Full chunks are stored as they complete.
|
|
22
|
+
*
|
|
23
|
+
* @param chunk - The next bytes, of any length.
|
|
24
|
+
* @throws When the file would exceed {@link FILE_MAX_SIZE}, or the stream has ended.
|
|
25
|
+
*/
|
|
12
26
|
write(chunk: Uint8Array): Promise<void>;
|
|
13
27
|
private processCompleteChunks;
|
|
28
|
+
private storeChunk;
|
|
29
|
+
/**
|
|
30
|
+
* Store the last chunk and save the file's row. Saving content that already has a row (the
|
|
31
|
+
* same bytes, name, MIME type and `keep`) returns the stored one; only a package bundle's row
|
|
32
|
+
* is written again, so a peer that deleted it gets it back.
|
|
33
|
+
*
|
|
34
|
+
* @returns The file's row.
|
|
35
|
+
*/
|
|
14
36
|
finalize(): Promise<IFile>;
|
|
37
|
+
/** Stop without saving a row. Stored chunks stay until collection frees them. */
|
|
15
38
|
abort(): Promise<void>;
|
|
39
|
+
/** Bytes written so far. */
|
|
16
40
|
getBytesWritten(): number;
|
|
41
|
+
/** Chunks stored so far. */
|
|
17
42
|
getChunkCount(): number;
|
|
43
|
+
/** Whether `finalize` has saved the row. */
|
|
18
44
|
isFinalized(): boolean;
|
|
45
|
+
/** Whether `abort` was called. */
|
|
19
46
|
isAborted(): boolean;
|
|
20
47
|
}
|
|
@@ -2,7 +2,26 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FileWriteStream = void 0;
|
|
4
4
|
const keys_1 = require("../../keys");
|
|
5
|
+
const chunk_store_1 = require("./chunk-store");
|
|
5
6
|
const file_types_1 = require("./file.types");
|
|
7
|
+
const file_rows_1 = require("./file-rows");
|
|
8
|
+
/**
|
|
9
|
+
* `text` as every host's storage keeps it. The id must be derived from the text as stored, or no
|
|
10
|
+
* other device could rederive it. A lone surrogate (possible in a Windows file name) becomes
|
|
11
|
+
* U+FFFD. The PWA's sql.js also cuts text at a NUL and drops a leading U+FEFF when reading, so
|
|
12
|
+
* neither is kept anywhere.
|
|
13
|
+
*/
|
|
14
|
+
function asStored(text) {
|
|
15
|
+
if (typeof text !== "string")
|
|
16
|
+
return text;
|
|
17
|
+
// ignoreBOM leaves U+FEFF for the replace, which drops every leading one (sql.js drops one).
|
|
18
|
+
const wellFormed = new TextDecoder("utf-8", { ignoreBOM: true }).decode(new TextEncoder().encode(text));
|
|
19
|
+
return wellFormed.split("\0", 1)[0].replace(/^\uFEFF+/, "");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Writes one file in pieces: each full chunk is stored as it arrives, and `finalize` saves the
|
|
23
|
+
* row under the id derived from the content. Open one with {@link FilesTable.createWriteStream}.
|
|
24
|
+
*/
|
|
6
25
|
class FileWriteStream {
|
|
7
26
|
metadata;
|
|
8
27
|
fileTable;
|
|
@@ -11,10 +30,20 @@ class FileWriteStream {
|
|
|
11
30
|
bytesWritten = 0;
|
|
12
31
|
finalized = false;
|
|
13
32
|
aborted = false;
|
|
33
|
+
/**
|
|
34
|
+
* @param metadata - The file's name, MIME type and `keep`.
|
|
35
|
+
* @param fileTable - The Files table the row is saved to.
|
|
36
|
+
*/
|
|
14
37
|
constructor(metadata, fileTable) {
|
|
15
38
|
this.metadata = metadata;
|
|
16
39
|
this.fileTable = fileTable;
|
|
17
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Append bytes. Full chunks are stored as they complete.
|
|
43
|
+
*
|
|
44
|
+
* @param chunk - The next bytes, of any length.
|
|
45
|
+
* @throws When the file would exceed {@link FILE_MAX_SIZE}, or the stream has ended.
|
|
46
|
+
*/
|
|
18
47
|
async write(chunk) {
|
|
19
48
|
if (this.finalized) {
|
|
20
49
|
throw new Error("Cannot write to finalized stream");
|
|
@@ -22,6 +51,9 @@ class FileWriteStream {
|
|
|
22
51
|
if (this.aborted) {
|
|
23
52
|
throw new Error("Cannot write to aborted stream");
|
|
24
53
|
}
|
|
54
|
+
if (this.bytesWritten + chunk.length > file_types_1.FILE_MAX_SIZE) {
|
|
55
|
+
throw new Error(`A file can be at most ${file_types_1.FILE_MAX_SIZE / (1024 * 1024)} MiB`);
|
|
56
|
+
}
|
|
25
57
|
// Add chunk to buffer
|
|
26
58
|
this.buffer = new Uint8Array(Buffer.concat([this.buffer, chunk]));
|
|
27
59
|
this.bytesWritten += chunk.length;
|
|
@@ -29,18 +61,26 @@ class FileWriteStream {
|
|
|
29
61
|
await this.processCompleteChunks();
|
|
30
62
|
}
|
|
31
63
|
async processCompleteChunks() {
|
|
32
|
-
const ops = await (0, file_types_1.getFileOps)();
|
|
33
64
|
while (this.buffer.length >= file_types_1.FILE_CHUNK_SIZE) {
|
|
34
|
-
// Extract one chunk
|
|
35
65
|
const chunkData = this.buffer.subarray(0, file_types_1.FILE_CHUNK_SIZE);
|
|
36
66
|
this.buffer = this.buffer.subarray(file_types_1.FILE_CHUNK_SIZE);
|
|
37
|
-
|
|
38
|
-
const chunkHash = (0, keys_1.hashBytes)(chunkData);
|
|
39
|
-
this.chunkHashes.push(chunkHash);
|
|
40
|
-
const chunkPath = `${file_types_1.CHUNKS_DIR}/${chunkHash}`;
|
|
41
|
-
await ops.writeFile(chunkPath, chunkData);
|
|
67
|
+
await this.storeChunk(chunkData);
|
|
42
68
|
}
|
|
43
69
|
}
|
|
70
|
+
async storeChunk(chunkData) {
|
|
71
|
+
const chunkHash = (0, keys_1.hashBytes)(chunkData);
|
|
72
|
+
this.chunkHashes.push(chunkHash);
|
|
73
|
+
// Stored even when present: a save restores bytes a device has freed, and refreshes the
|
|
74
|
+
// chunk's age so a sweep can't free it under this save.
|
|
75
|
+
await (await (0, chunk_store_1.getChunkStore)()).put(chunkHash, chunkData);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Store the last chunk and save the file's row. Saving content that already has a row (the
|
|
79
|
+
* same bytes, name, MIME type and `keep`) returns the stored one; only a package bundle's row
|
|
80
|
+
* is written again, so a peer that deleted it gets it back.
|
|
81
|
+
*
|
|
82
|
+
* @returns The file's row.
|
|
83
|
+
*/
|
|
44
84
|
async finalize() {
|
|
45
85
|
if (this.finalized) {
|
|
46
86
|
throw new Error("Stream already finalized");
|
|
@@ -48,45 +88,28 @@ class FileWriteStream {
|
|
|
48
88
|
if (this.aborted) {
|
|
49
89
|
throw new Error("Cannot finalize aborted stream");
|
|
50
90
|
}
|
|
51
|
-
// First, process any complete chunks in the buffer
|
|
52
91
|
await this.processCompleteChunks();
|
|
53
|
-
// Then process any remaining partial data as final chunk
|
|
54
92
|
if (this.buffer.length > 0) {
|
|
55
|
-
|
|
56
|
-
const finalChunkHash = (0, keys_1.hashBytes)(this.buffer);
|
|
57
|
-
this.chunkHashes.push(finalChunkHash);
|
|
58
|
-
const chunkPath = `${file_types_1.CHUNKS_DIR}/${finalChunkHash}`;
|
|
59
|
-
await ops.writeFile(chunkPath, this.buffer);
|
|
93
|
+
await this.storeChunk(this.buffer);
|
|
60
94
|
this.buffer = new Uint8Array(0);
|
|
61
95
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
// Small file: store chunk hashes directly
|
|
78
|
-
fileRecord = {
|
|
79
|
-
...this.metadata,
|
|
80
|
-
fileSize: this.bytesWritten,
|
|
81
|
-
fileHash,
|
|
82
|
-
chunkHashes: this.chunkHashes,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
// Save to database
|
|
86
|
-
await this.fileTable.saveFileRecord(fileRecord);
|
|
96
|
+
const description = {
|
|
97
|
+
name: asStored(this.metadata.name),
|
|
98
|
+
fileSize: this.bytesWritten,
|
|
99
|
+
fileHash: (0, file_types_1.fileHashOf)(this.chunkHashes),
|
|
100
|
+
mimeType: asStored(this.metadata.mimeType ?? ""),
|
|
101
|
+
keep: this.metadata.keep ?? true,
|
|
102
|
+
};
|
|
103
|
+
const fileRecord = {
|
|
104
|
+
fileId: (0, file_rows_1.fileIdFor)(description),
|
|
105
|
+
...description,
|
|
106
|
+
chunkHashes: this.chunkHashes,
|
|
107
|
+
};
|
|
108
|
+
const saved = await this.fileTable.saveFileRecord(fileRecord);
|
|
87
109
|
this.finalized = true;
|
|
88
|
-
return
|
|
110
|
+
return saved;
|
|
89
111
|
}
|
|
112
|
+
/** Stop without saving a row. Stored chunks stay until collection frees them. */
|
|
90
113
|
async abort() {
|
|
91
114
|
if (this.finalized) {
|
|
92
115
|
throw new Error("Cannot abort finalized stream");
|
|
@@ -94,18 +117,20 @@ class FileWriteStream {
|
|
|
94
117
|
this.aborted = true;
|
|
95
118
|
this.buffer = Buffer.alloc(0);
|
|
96
119
|
this.chunkHashes = [];
|
|
97
|
-
// Note: We don't delete chunks since they may be shared with other files
|
|
98
|
-
// Chunk cleanup should be handled by a separate garbage collection process
|
|
99
120
|
}
|
|
121
|
+
/** Bytes written so far. */
|
|
100
122
|
getBytesWritten() {
|
|
101
123
|
return this.bytesWritten;
|
|
102
124
|
}
|
|
125
|
+
/** Chunks stored so far. */
|
|
103
126
|
getChunkCount() {
|
|
104
127
|
return this.chunkHashes.length;
|
|
105
128
|
}
|
|
129
|
+
/** Whether `finalize` has saved the row. */
|
|
106
130
|
isFinalized() {
|
|
107
131
|
return this.finalized;
|
|
108
132
|
}
|
|
133
|
+
/** Whether `abort` was called. */
|
|
109
134
|
isAborted() {
|
|
110
135
|
return this.aborted;
|
|
111
136
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type ITableMetaData } from "../orm/types";
|
|
3
|
+
/**
|
|
4
|
+
* A `Files` row: one file's description and its chunk list. Rows saved since ids became derived
|
|
5
|
+
* from content have a `000r` id and never change; older rows ("legacy") have random ids.
|
|
6
|
+
*/
|
|
3
7
|
export declare const fileSchema: z.ZodObject<{
|
|
4
8
|
fileId: z.ZodEffects<z.ZodString, string, string>;
|
|
5
9
|
name: z.ZodString;
|
|
@@ -7,47 +11,79 @@ export declare const fileSchema: z.ZodObject<{
|
|
|
7
11
|
fileHash: z.ZodString;
|
|
8
12
|
mimeType: z.ZodOptional<z.ZodString>;
|
|
9
13
|
chunkHashes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
10
|
-
|
|
11
|
-
indexFileId: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
14
|
+
keep: z.ZodOptional<z.ZodBoolean>;
|
|
12
15
|
}, "strip", z.ZodTypeAny, {
|
|
13
16
|
name: string;
|
|
14
17
|
fileId: string;
|
|
15
18
|
fileSize: number;
|
|
16
19
|
fileHash: string;
|
|
20
|
+
keep?: boolean | undefined;
|
|
17
21
|
mimeType?: string | undefined;
|
|
18
22
|
chunkHashes?: string[] | undefined;
|
|
19
|
-
isIndexFile?: boolean | undefined;
|
|
20
|
-
indexFileId?: string | undefined;
|
|
21
23
|
}, {
|
|
22
24
|
name: string;
|
|
23
25
|
fileId: string;
|
|
24
26
|
fileSize: number;
|
|
25
27
|
fileHash: string;
|
|
28
|
+
keep?: boolean | undefined;
|
|
26
29
|
mimeType?: string | undefined;
|
|
27
30
|
chunkHashes?: string[] | undefined;
|
|
28
|
-
isIndexFile?: boolean | undefined;
|
|
29
|
-
indexFileId?: string | undefined;
|
|
30
31
|
}>;
|
|
32
|
+
/** A `Files` row. */
|
|
31
33
|
export type IFile = z.infer<typeof fileSchema>;
|
|
32
|
-
|
|
34
|
+
/**
|
|
35
|
+
* What a writer chooses about a new file. The id is derived from the content and these, so
|
|
36
|
+
* saving the same bytes with the same name, MIME type and `keep` returns the same file.
|
|
37
|
+
*/
|
|
38
|
+
export interface IFileInput {
|
|
39
|
+
/**
|
|
40
|
+
* Stored as every host can keep it: a lone surrogate becomes U+FFFD, the text ends at a NUL,
|
|
41
|
+
* and a leading U+FEFF is dropped. The saved row has the name as stored.
|
|
42
|
+
*/
|
|
43
|
+
name: string;
|
|
44
|
+
/** Stored like `name`; an omitted MIME type is stored empty. */
|
|
45
|
+
mimeType?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the file is never collected. Defaults to true, so a writer that doesn't declare its
|
|
48
|
+
* references keeps its files. System Files saves and the package installer pass false.
|
|
49
|
+
*/
|
|
50
|
+
keep?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** The `Files` system table: every context has one. */
|
|
33
53
|
export declare const filesMetaData: ITableMetaData;
|
|
54
|
+
/** Size of each chunk a file is split into; the last chunk may be shorter. */
|
|
34
55
|
export declare const FILE_CHUNK_SIZE: number;
|
|
35
|
-
|
|
36
|
-
export declare
|
|
37
|
-
|
|
56
|
+
/** Most chunks one file may have: a row's chunk list holds every chunk of any file. */
|
|
57
|
+
export declare const FILE_MAX_CHUNKS = 1024;
|
|
58
|
+
/** Largest file Peers stores (1 GiB). It can rise in a later release, never fall. */
|
|
59
|
+
export declare const FILE_MAX_SIZE: number;
|
|
60
|
+
/** Most chunks a read fetches from other devices at once, so a large file isn't one stream. */
|
|
61
|
+
export declare const FILE_FETCH_WINDOW = 16;
|
|
62
|
+
/** Default time an unreferenced file is retained, before the grace period (30 days). */
|
|
63
|
+
export declare const FILE_RETENTION_MS: number;
|
|
64
|
+
/** Extra time for a reference to reach devices holding the bytes (7 days). */
|
|
65
|
+
export declare const FILE_COLLECTION_GRACE_MS: number;
|
|
66
|
+
/** Minimum age of a stored chunk before collection (one day, longer than a write session). */
|
|
67
|
+
export declare const FILE_CHUNK_FLOOR_MS: number;
|
|
38
68
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
69
|
+
* Collection delays. `PEERS_FILE_COLLECTION_DELAY_MS` overrides all three for tests only;
|
|
70
|
+
* absent or invalid uses the constants. Explicit group retention is unaffected.
|
|
71
|
+
*/
|
|
72
|
+
export declare function fileCollectionDelays(): {
|
|
73
|
+
retentionMs: number;
|
|
74
|
+
graceMs: number;
|
|
75
|
+
chunkFloorMs: number;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* A file's `fileHash`: the SHA-256 of its chunk hashes as JSON, in order.
|
|
79
|
+
*
|
|
80
|
+
* @param chunkHashes - The file's chunk hashes.
|
|
81
|
+
*/
|
|
82
|
+
export declare function fileHashOf(chunkHashes: readonly string[]): string;
|
|
83
|
+
/**
|
|
84
|
+
* Compute the file-system hash for content without saving it. Mirrors the chunking of
|
|
85
|
+
* `FileWriteStream`, so the result matches `IFile.fileHash` for the same bytes.
|
|
86
|
+
*
|
|
87
|
+
* @param content - The bytes, or a string hashed as UTF-8.
|
|
42
88
|
*/
|
|
43
89
|
export declare function computeFileHash(content: string | Uint8Array): string;
|
|
44
|
-
export interface FileOps {
|
|
45
|
-
downloadFileChunk(chunkHash: string): Promise<Uint8Array | null>;
|
|
46
|
-
fileExists(path: string): Promise<boolean>;
|
|
47
|
-
readFile(path: string): Promise<Uint8Array>;
|
|
48
|
-
writeFile(path: string, data: Uint8Array): Promise<void>;
|
|
49
|
-
deletePath(path: string): Promise<void>;
|
|
50
|
-
}
|
|
51
|
-
export declare function setFileOps(ops: FileOps): void;
|
|
52
|
-
export declare function getFileOps(): Promise<FileOps>;
|
|
53
|
-
export declare function resetFileOps(): void;
|
|
@@ -1,80 +1,83 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.
|
|
3
|
+
exports.FILE_CHUNK_FLOOR_MS = exports.FILE_COLLECTION_GRACE_MS = exports.FILE_RETENTION_MS = exports.FILE_FETCH_WINDOW = exports.FILE_MAX_SIZE = exports.FILE_MAX_CHUNKS = exports.FILE_CHUNK_SIZE = exports.filesMetaData = exports.fileSchema = void 0;
|
|
4
|
+
exports.fileCollectionDelays = fileCollectionDelays;
|
|
5
|
+
exports.fileHashOf = fileHashOf;
|
|
5
6
|
exports.computeFileHash = computeFileHash;
|
|
6
|
-
exports.setFileOps = setFileOps;
|
|
7
|
-
exports.getFileOps = getFileOps;
|
|
8
|
-
exports.resetFileOps = resetFileOps;
|
|
9
7
|
const zod_1 = require("zod");
|
|
10
8
|
const keys_1 = require("../../keys");
|
|
11
9
|
const zod_types_1 = require("../../types/zod-types");
|
|
12
10
|
const types_1 = require("../orm/types");
|
|
11
|
+
/**
|
|
12
|
+
* A `Files` row: one file's description and its chunk list. Rows saved since ids became derived
|
|
13
|
+
* from content have a `000r` id and never change; older rows ("legacy") have random ids.
|
|
14
|
+
*/
|
|
13
15
|
exports.fileSchema = zod_1.z.object({
|
|
14
16
|
fileId: zod_types_1.zodPeerId,
|
|
15
17
|
name: zod_1.z.string().describe("The name of the file"),
|
|
16
18
|
fileSize: zod_1.z.number().describe("The size of the file in bytes"),
|
|
17
19
|
fileHash: zod_1.z.string().describe("Hash of the chunk hashes array for integrity verification"),
|
|
18
|
-
mimeType: zod_1.z.string().optional().describe("The MIME type of the file"),
|
|
19
|
-
chunkHashes: zod_1.z
|
|
20
|
-
|
|
21
|
-
.optional()
|
|
22
|
-
.describe("SHA-256 hashes of each chunk in order (for small files)"),
|
|
23
|
-
isIndexFile: zod_1.z
|
|
20
|
+
mimeType: zod_1.z.string().optional().describe("The MIME type of the file; empty when unknown"),
|
|
21
|
+
chunkHashes: zod_1.z.array(zod_1.z.string()).optional().describe("SHA-256 hashes of each chunk in order"),
|
|
22
|
+
keep: zod_1.z
|
|
24
23
|
.boolean()
|
|
25
24
|
.optional()
|
|
26
|
-
.describe("True
|
|
27
|
-
indexFileId: zod_types_1.zodPeerId.optional().describe("FileId of index file (for large files)"),
|
|
25
|
+
.describe("True for a file that is never collected, even with nothing referencing it"),
|
|
28
26
|
});
|
|
27
|
+
/** The `Files` system table: every context has one. */
|
|
29
28
|
exports.filesMetaData = {
|
|
30
29
|
name: "Files",
|
|
31
30
|
description: "Files stored in the chunked file system for peer sharing",
|
|
32
31
|
primaryKeyName: "fileId",
|
|
33
32
|
fields: (0, types_1.schemaToFields)(exports.fileSchema),
|
|
34
33
|
};
|
|
35
|
-
|
|
36
|
-
exports.FILE_CHUNK_SIZE = 1024 * 1024;
|
|
37
|
-
|
|
38
|
-
exports.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
/** Size of each chunk a file is split into; the last chunk may be shorter. */
|
|
35
|
+
exports.FILE_CHUNK_SIZE = 1024 * 1024;
|
|
36
|
+
/** Most chunks one file may have: a row's chunk list holds every chunk of any file. */
|
|
37
|
+
exports.FILE_MAX_CHUNKS = 1024;
|
|
38
|
+
/** Largest file Peers stores (1 GiB). It can rise in a later release, never fall. */
|
|
39
|
+
exports.FILE_MAX_SIZE = exports.FILE_MAX_CHUNKS * exports.FILE_CHUNK_SIZE;
|
|
40
|
+
/** Most chunks a read fetches from other devices at once, so a large file isn't one stream. */
|
|
41
|
+
exports.FILE_FETCH_WINDOW = 16;
|
|
42
|
+
/** Default time an unreferenced file is retained, before the grace period (30 days). */
|
|
43
|
+
exports.FILE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
44
|
+
/** Extra time for a reference to reach devices holding the bytes (7 days). */
|
|
45
|
+
exports.FILE_COLLECTION_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
46
|
+
/** Minimum age of a stored chunk before collection (one day, longer than a write session). */
|
|
47
|
+
exports.FILE_CHUNK_FLOOR_MS = 24 * 60 * 60 * 1000;
|
|
48
|
+
/**
|
|
49
|
+
* Collection delays. `PEERS_FILE_COLLECTION_DELAY_MS` overrides all three for tests only;
|
|
50
|
+
* absent or invalid uses the constants. Explicit group retention is unaffected.
|
|
51
|
+
*/
|
|
52
|
+
function fileCollectionDelays() {
|
|
53
|
+
const value = typeof process === "undefined" ? undefined : process.env.PEERS_FILE_COLLECTION_DELAY_MS;
|
|
54
|
+
const override = value === undefined || value.trim() === "" ? NaN : Number(value);
|
|
55
|
+
const delay = (fallback) => Number.isFinite(override) && override >= 0 ? override : fallback;
|
|
56
|
+
return {
|
|
57
|
+
retentionMs: delay(exports.FILE_RETENTION_MS),
|
|
58
|
+
graceMs: delay(exports.FILE_COLLECTION_GRACE_MS),
|
|
59
|
+
chunkFloorMs: delay(exports.FILE_CHUNK_FLOOR_MS),
|
|
60
|
+
};
|
|
42
61
|
}
|
|
43
62
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
63
|
+
* A file's `fileHash`: the SHA-256 of its chunk hashes as JSON, in order.
|
|
64
|
+
*
|
|
65
|
+
* @param chunkHashes - The file's chunk hashes.
|
|
66
|
+
*/
|
|
67
|
+
function fileHashOf(chunkHashes) {
|
|
68
|
+
return (0, keys_1.hashBytes)(new TextEncoder().encode(JSON.stringify(chunkHashes)));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Compute the file-system hash for content without saving it. Mirrors the chunking of
|
|
72
|
+
* `FileWriteStream`, so the result matches `IFile.fileHash` for the same bytes.
|
|
73
|
+
*
|
|
74
|
+
* @param content - The bytes, or a string hashed as UTF-8.
|
|
47
75
|
*/
|
|
48
76
|
function computeFileHash(content) {
|
|
49
77
|
const data = typeof content === "string" ? new Uint8Array(Buffer.from(content, "utf8")) : content;
|
|
50
78
|
const chunkHashes = [];
|
|
51
|
-
let offset = 0;
|
|
52
|
-
|
|
53
|
-
const end = Math.min(offset + exports.FILE_CHUNK_SIZE, data.length);
|
|
54
|
-
chunkHashes.push((0, keys_1.hashBytes)(data.subarray(offset, end)));
|
|
55
|
-
offset = end;
|
|
56
|
-
}
|
|
57
|
-
return (0, keys_1.hashBytes)(new Uint8Array(Buffer.from(JSON.stringify(chunkHashes), "utf8")));
|
|
58
|
-
}
|
|
59
|
-
let fileOps = null;
|
|
60
|
-
let fileOpsReady = null;
|
|
61
|
-
function setFileOps(ops) {
|
|
62
|
-
fileOps = ops;
|
|
63
|
-
if (fileOpsReady) {
|
|
64
|
-
fileOpsReady(ops);
|
|
65
|
-
fileOpsReady = null; // Reset the promise if it was waiting
|
|
79
|
+
for (let offset = 0; offset < data.length; offset += exports.FILE_CHUNK_SIZE) {
|
|
80
|
+
chunkHashes.push((0, keys_1.hashBytes)(data.subarray(offset, offset + exports.FILE_CHUNK_SIZE)));
|
|
66
81
|
}
|
|
67
|
-
|
|
68
|
-
function getFileOps() {
|
|
69
|
-
if (fileOps) {
|
|
70
|
-
return Promise.resolve(fileOps);
|
|
71
|
-
}
|
|
72
|
-
return new Promise((resolve) => {
|
|
73
|
-
fileOpsReady = resolve;
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
// For testing - reset the global state
|
|
77
|
-
function resetFileOps() {
|
|
78
|
-
fileOps = null;
|
|
79
|
-
fileOpsReady = null;
|
|
82
|
+
return fileHashOf(chunkHashes);
|
|
80
83
|
}
|