@peers-app/peers-sdk 0.30.0 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +76 -0
- package/dist/contracts/system/file-storage-contract.js +75 -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 +112 -0
- package/dist/data/files/chunk-store.js +107 -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 +68 -0
- package/dist/data/files/file-rows.js +158 -0
- package/dist/data/files/file-write-stream.d.ts +26 -0
- package/dist/data/files/file-write-stream.js +66 -42
- package/dist/data/files/file.types.d.ts +44 -23
- package/dist/data/files/file.types.js +33 -51
- package/dist/data/files/files.d.ts +27 -24
- package/dist/data/files/files.js +63 -85
- package/dist/data/files/index.d.ts +2 -0
- package/dist/data/files/index.js +2 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -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/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,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Where a device keeps file chunks, by hash. Each host supplies one store per account
|
|
4
|
+
* ({@link setChunkStore}): an encrypted SQLite database on Node hosts, IndexedDB in the browser.
|
|
5
|
+
* Chunks are deduplicated across the account's contexts: a chunk three groups list is stored once.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.MemoryChunkStore = exports.CHUNK_LIST_PAGE = void 0;
|
|
9
|
+
exports.setChunkStore = setChunkStore;
|
|
10
|
+
exports.getChunkStore = getChunkStore;
|
|
11
|
+
exports.setChunkDownloader = setChunkDownloader;
|
|
12
|
+
exports.downloadChunk = downloadChunk;
|
|
13
|
+
exports.resetChunkStore = resetChunkStore;
|
|
14
|
+
/** Most chunks one {@link ChunkStore.list} page returns by default. */
|
|
15
|
+
exports.CHUNK_LIST_PAGE = 1000;
|
|
16
|
+
/** A {@link ChunkStore} in memory, for tests and ephemeral hosts. */
|
|
17
|
+
class MemoryChunkStore {
|
|
18
|
+
now;
|
|
19
|
+
chunks = new Map();
|
|
20
|
+
/** @param now - The clock `storedAt` comes from. */
|
|
21
|
+
constructor(now = Date.now) {
|
|
22
|
+
this.now = now;
|
|
23
|
+
}
|
|
24
|
+
async has(hash) {
|
|
25
|
+
return this.chunks.has(hash);
|
|
26
|
+
}
|
|
27
|
+
async get(hash) {
|
|
28
|
+
return this.chunks.get(hash)?.bytes;
|
|
29
|
+
}
|
|
30
|
+
async put(hash, bytes) {
|
|
31
|
+
const stored = this.chunks.get(hash);
|
|
32
|
+
this.chunks.set(hash, { bytes: stored?.bytes ?? new Uint8Array(bytes), storedAt: this.now() });
|
|
33
|
+
}
|
|
34
|
+
async delete(hashes, storedBefore) {
|
|
35
|
+
let deleted = 0;
|
|
36
|
+
for (const hash of hashes) {
|
|
37
|
+
const stored = this.chunks.get(hash);
|
|
38
|
+
if (stored && stored.storedAt < storedBefore) {
|
|
39
|
+
this.chunks.delete(hash);
|
|
40
|
+
deleted++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return deleted;
|
|
44
|
+
}
|
|
45
|
+
async list(opts) {
|
|
46
|
+
const limit = opts?.limit ?? exports.CHUNK_LIST_PAGE;
|
|
47
|
+
return [...this.chunks.keys()]
|
|
48
|
+
.filter((hash) => opts?.after === undefined || hash > opts.after)
|
|
49
|
+
.sort()
|
|
50
|
+
.slice(0, limit)
|
|
51
|
+
.map((hash) => {
|
|
52
|
+
const { bytes, storedAt } = this.chunks.get(hash);
|
|
53
|
+
return { hash, size: bytes.length, storedAt };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async stats() {
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
for (const chunk of this.chunks.values())
|
|
59
|
+
bytes += chunk.bytes.length;
|
|
60
|
+
return { count: this.chunks.size, bytes };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.MemoryChunkStore = MemoryChunkStore;
|
|
64
|
+
let chunkStore = null;
|
|
65
|
+
let chunkStoreWaiters = [];
|
|
66
|
+
/**
|
|
67
|
+
* Set this process's chunk store. Reads and writes waiting for one continue.
|
|
68
|
+
*
|
|
69
|
+
* @param store - The account's chunk store.
|
|
70
|
+
*/
|
|
71
|
+
function setChunkStore(store) {
|
|
72
|
+
chunkStore = store;
|
|
73
|
+
const waiters = chunkStoreWaiters;
|
|
74
|
+
chunkStoreWaiters = [];
|
|
75
|
+
for (const resolve of waiters)
|
|
76
|
+
resolve(store);
|
|
77
|
+
}
|
|
78
|
+
/** This process's chunk store, once {@link setChunkStore} has set it. */
|
|
79
|
+
function getChunkStore() {
|
|
80
|
+
if (chunkStore)
|
|
81
|
+
return Promise.resolve(chunkStore);
|
|
82
|
+
return new Promise((resolve) => chunkStoreWaiters.push(resolve));
|
|
83
|
+
}
|
|
84
|
+
let chunkDownloader = null;
|
|
85
|
+
/**
|
|
86
|
+
* Set how chunks this device lacks are fetched. peers-device installs the connection manager's.
|
|
87
|
+
*
|
|
88
|
+
* @param downloader - The downloader, or null for none.
|
|
89
|
+
*/
|
|
90
|
+
function setChunkDownloader(downloader) {
|
|
91
|
+
chunkDownloader = downloader;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Fetch a chunk this device lacks from a connected device; the downloader stores it.
|
|
95
|
+
*
|
|
96
|
+
* @param hash - The chunk's hash.
|
|
97
|
+
* @returns The bytes, or null when no device has it or there's no downloader.
|
|
98
|
+
*/
|
|
99
|
+
async function downloadChunk(hash) {
|
|
100
|
+
return chunkDownloader ? chunkDownloader(hash) : null;
|
|
101
|
+
}
|
|
102
|
+
/** Forget the chunk store and downloader (for tests). */
|
|
103
|
+
function resetChunkStore() {
|
|
104
|
+
chunkStore = null;
|
|
105
|
+
chunkStoreWaiters = [];
|
|
106
|
+
chunkDownloader = null;
|
|
107
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { type IFile } from "./file.types";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Reads one file's bytes chunk by chunk, from this device's chunks or, for a chunk it lacks, from
|
|
4
|
+
* a connected device. Each chunk is checked against its hash unless verification is skipped.
|
|
5
|
+
* Open one with {@link FilesTable.openReadStream}.
|
|
6
|
+
*/
|
|
3
7
|
export declare class FileReadStream {
|
|
4
8
|
private fileRecord;
|
|
5
|
-
private fileTable;
|
|
6
9
|
private preloadChunksCount;
|
|
7
10
|
private skipVerification;
|
|
8
11
|
private chunkHashes;
|
|
@@ -12,17 +15,43 @@ export declare class FileReadStream {
|
|
|
12
15
|
private positionInChunk;
|
|
13
16
|
private totalPosition;
|
|
14
17
|
private eof;
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* @param fileRecord - The file's row.
|
|
20
|
+
* @param preloadChunksCount - Chunks to fetch ahead of the reader, counting the current one.
|
|
21
|
+
* @param skipVerification - Skip hash checks (only for trusted local data).
|
|
22
|
+
*/
|
|
23
|
+
constructor(fileRecord: IFile, preloadChunksCount?: number, skipVerification?: boolean);
|
|
16
24
|
private loadChunkHashes;
|
|
17
25
|
private loadChunkByIndex;
|
|
18
26
|
private preloadChunks;
|
|
19
27
|
private loadCurrentChunk;
|
|
28
|
+
/**
|
|
29
|
+
* Read the next bytes.
|
|
30
|
+
*
|
|
31
|
+
* @param size - Most bytes to read; one chunk's worth when omitted.
|
|
32
|
+
* @returns The bytes, or null at the end of the file.
|
|
33
|
+
*/
|
|
20
34
|
read(size?: number): Promise<Uint8Array | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Move to a byte position; the next read starts there.
|
|
37
|
+
*
|
|
38
|
+
* @param position - From 0 to the file's size.
|
|
39
|
+
*/
|
|
21
40
|
seek(position: number): Promise<void>;
|
|
41
|
+
/** A copy of the file's row. */
|
|
22
42
|
getMetadata(): IFile;
|
|
43
|
+
/** The byte position the next read starts at. */
|
|
23
44
|
getPosition(): number;
|
|
45
|
+
/** The whole file's size in bytes. */
|
|
24
46
|
getFileSize(): number;
|
|
47
|
+
/** Whether the stream is at the end of the file. */
|
|
25
48
|
isEOF(): boolean;
|
|
49
|
+
/** Bytes between the position and the end of the file. */
|
|
26
50
|
getBytesRemaining(): number;
|
|
51
|
+
/**
|
|
52
|
+
* Read the whole file from the start.
|
|
53
|
+
*
|
|
54
|
+
* @returns The bytes, or null when a chunk is missing everywhere or fails its hash check.
|
|
55
|
+
*/
|
|
27
56
|
readAll(): Promise<Uint8Array | null>;
|
|
28
57
|
}
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FileReadStream = 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
|
+
/**
|
|
8
|
+
* Reads one file's bytes chunk by chunk, from this device's chunks or, for a chunk it lacks, from
|
|
9
|
+
* a connected device. Each chunk is checked against its hash unless verification is skipped.
|
|
10
|
+
* Open one with {@link FilesTable.openReadStream}.
|
|
11
|
+
*/
|
|
6
12
|
class FileReadStream {
|
|
7
13
|
fileRecord;
|
|
8
|
-
fileTable;
|
|
9
14
|
preloadChunksCount;
|
|
10
15
|
skipVerification;
|
|
11
16
|
chunkHashes = null;
|
|
@@ -15,9 +20,13 @@ class FileReadStream {
|
|
|
15
20
|
positionInChunk = 0;
|
|
16
21
|
totalPosition = 0;
|
|
17
22
|
eof = false;
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* @param fileRecord - The file's row.
|
|
25
|
+
* @param preloadChunksCount - Chunks to fetch ahead of the reader, counting the current one.
|
|
26
|
+
* @param skipVerification - Skip hash checks (only for trusted local data).
|
|
27
|
+
*/
|
|
28
|
+
constructor(fileRecord, preloadChunksCount = 2, skipVerification = false) {
|
|
19
29
|
this.fileRecord = fileRecord;
|
|
20
|
-
this.fileTable = fileTable;
|
|
21
30
|
this.preloadChunksCount = preloadChunksCount;
|
|
22
31
|
this.skipVerification = skipVerification;
|
|
23
32
|
}
|
|
@@ -25,24 +34,19 @@ class FileReadStream {
|
|
|
25
34
|
if (this.chunkHashes !== null) {
|
|
26
35
|
return; // Already loaded
|
|
27
36
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
else if (this.fileRecord.indexFileId) {
|
|
33
|
-
// Large file: chunk hashes stored in separate index file (recursively)
|
|
34
|
-
this.chunkHashes = await this.fileTable.loadChunkHashesRecursively(this.fileRecord.indexFileId);
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
throw new Error(`File ${this.fileRecord.fileId} has neither chunkHashes nor indexFileId`);
|
|
37
|
+
const chunkHashes = this.fileRecord.chunkHashes ?? [];
|
|
38
|
+
if (chunkHashes.length === 0 && this.fileRecord.fileSize > 0) {
|
|
39
|
+
// Older builds kept a file over 1,000 chunks in index files, which are no longer read.
|
|
40
|
+
throw new Error(`File ${this.fileRecord.fileId} has no chunk list`);
|
|
38
41
|
}
|
|
39
42
|
if (!this.skipVerification) {
|
|
40
|
-
const computedFileHash = (0,
|
|
43
|
+
const computedFileHash = (0, file_types_1.fileHashOf)(chunkHashes);
|
|
41
44
|
if (computedFileHash !== this.fileRecord.fileHash) {
|
|
42
45
|
throw new Error(`File integrity check failed for ${this.fileRecord.fileId}: ` +
|
|
43
46
|
`chunk hashes produce ${computedFileHash}, expected ${this.fileRecord.fileHash}`);
|
|
44
47
|
}
|
|
45
48
|
}
|
|
49
|
+
this.chunkHashes = chunkHashes;
|
|
46
50
|
}
|
|
47
51
|
async loadChunkByIndex(chunkIndex) {
|
|
48
52
|
await this.loadChunkHashes();
|
|
@@ -51,19 +55,11 @@ class FileReadStream {
|
|
|
51
55
|
throw new Error(`Chunk index ${chunkIndex} out of bounds`);
|
|
52
56
|
}
|
|
53
57
|
const chunkHash = chunkHashes[chunkIndex];
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
chunk = await ops.readFile(chunkPath);
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
// Download from peer by hash
|
|
63
|
-
chunk = (await ops.downloadFileChunk(chunkHash));
|
|
64
|
-
if (!chunk) {
|
|
65
|
-
throw new Error(`Chunk ${chunkHash} not found in storage or peers`);
|
|
66
|
-
}
|
|
58
|
+
const store = await (0, chunk_store_1.getChunkStore)();
|
|
59
|
+
// Stored here, or downloaded from a connected device (which stores it).
|
|
60
|
+
const chunk = (await store.get(chunkHash)) ?? (await (0, chunk_store_1.downloadChunk)(chunkHash));
|
|
61
|
+
if (!chunk) {
|
|
62
|
+
throw new Error(`Chunk ${chunkHash} not found in storage or peers`);
|
|
67
63
|
}
|
|
68
64
|
// Verify chunk integrity (skip for trusted local reads)
|
|
69
65
|
if (!this.skipVerification) {
|
|
@@ -109,6 +105,12 @@ class FileReadStream {
|
|
|
109
105
|
this.currentChunkBuffer = await chunkPromise;
|
|
110
106
|
this.chunkPromises.delete(this.currentChunkIndex);
|
|
111
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Read the next bytes.
|
|
110
|
+
*
|
|
111
|
+
* @param size - Most bytes to read; one chunk's worth when omitted.
|
|
112
|
+
* @returns The bytes, or null at the end of the file.
|
|
113
|
+
*/
|
|
112
114
|
async read(size) {
|
|
113
115
|
if (this.eof) {
|
|
114
116
|
return null;
|
|
@@ -146,6 +148,11 @@ class FileReadStream {
|
|
|
146
148
|
await this.preloadChunks();
|
|
147
149
|
return chunks.length > 0 ? new Uint8Array(Buffer.concat(chunks)) : null;
|
|
148
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Move to a byte position; the next read starts there.
|
|
153
|
+
*
|
|
154
|
+
* @param position - From 0 to the file's size.
|
|
155
|
+
*/
|
|
149
156
|
async seek(position) {
|
|
150
157
|
if (position < 0) {
|
|
151
158
|
throw new Error("Seek position cannot be negative");
|
|
@@ -167,22 +174,31 @@ class FileReadStream {
|
|
|
167
174
|
this.chunkPromises.clear();
|
|
168
175
|
await this.preloadChunks();
|
|
169
176
|
}
|
|
177
|
+
/** A copy of the file's row. */
|
|
170
178
|
getMetadata() {
|
|
171
179
|
return { ...this.fileRecord };
|
|
172
180
|
}
|
|
181
|
+
/** The byte position the next read starts at. */
|
|
173
182
|
getPosition() {
|
|
174
183
|
return this.totalPosition;
|
|
175
184
|
}
|
|
185
|
+
/** The whole file's size in bytes. */
|
|
176
186
|
getFileSize() {
|
|
177
187
|
return this.fileRecord.fileSize;
|
|
178
188
|
}
|
|
189
|
+
/** Whether the stream is at the end of the file. */
|
|
179
190
|
isEOF() {
|
|
180
191
|
return this.eof;
|
|
181
192
|
}
|
|
193
|
+
/** Bytes between the position and the end of the file. */
|
|
182
194
|
getBytesRemaining() {
|
|
183
195
|
return this.fileRecord.fileSize - this.totalPosition;
|
|
184
196
|
}
|
|
185
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Read the whole file from the start.
|
|
199
|
+
*
|
|
200
|
+
* @returns The bytes, or null when a chunk is missing everywhere or fails its hash check.
|
|
201
|
+
*/
|
|
186
202
|
async readAll() {
|
|
187
203
|
await this.seek(0); // Start from beginning
|
|
188
204
|
const chunks = [];
|
|
@@ -199,7 +215,6 @@ class FileReadStream {
|
|
|
199
215
|
}
|
|
200
216
|
catch (error) {
|
|
201
217
|
// Only return null for chunk-specific errors (not found, download failed)
|
|
202
|
-
// Rethrow other errors like index file issues
|
|
203
218
|
if (error instanceof Error &&
|
|
204
219
|
error.message.includes("Chunk") &&
|
|
205
220
|
(error.message.includes("not found") || error.message.includes("integrity check failed"))) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type IFile } from "./file.types";
|
|
2
|
+
/** What a file's id is derived from. */
|
|
3
|
+
export interface IFileIdentity {
|
|
4
|
+
fileHash: string;
|
|
5
|
+
fileSize: number;
|
|
6
|
+
name: string;
|
|
7
|
+
/** Empty when unknown; omitted counts as empty. */
|
|
8
|
+
mimeType?: string;
|
|
9
|
+
keep: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The id of a file: derived from its hash, size, name, MIME type and `keep`, so saving the same
|
|
13
|
+
* content with the same description gives the same file on every device. Give the name and MIME
|
|
14
|
+
* type as storage keeps them (`FileWriteStream` normalizes them), or other devices, which
|
|
15
|
+
* rederive the id from the stored row, refuse it.
|
|
16
|
+
*
|
|
17
|
+
* @param file - The file's hash, size, name, MIME type and `keep`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fileIdFor(file: IFileIdentity): string;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a row is a legacy file: one saved under a random id, before ids were derived from
|
|
22
|
+
* content. Legacy rows are admitted as they are, never change, and are always kept.
|
|
23
|
+
*
|
|
24
|
+
* @param fileId - The row's id.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isLegacyFileId(fileId: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether a legacy row is a package bundle the installer saved. The runtime owns these, so they
|
|
29
|
+
* are collected once nothing references them, and their rows may be deleted.
|
|
30
|
+
*
|
|
31
|
+
* @param file - The row's id and name.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isLegacyBundleFile(file: Pick<IFile, "fileId" | "name">): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a file is kept: never collected, even with nothing referencing it. A legacy row always
|
|
36
|
+
* is. A row relayed by a build that predates `keep` arrives without it; its id, derived from
|
|
37
|
+
* `keep`, then decides, and a row whose id says neither is kept.
|
|
38
|
+
*
|
|
39
|
+
* @param file - The row.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isKeptFile(file: IFile): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether two rows describe the same file, ignoring how an older build stored it (no `keep`, no
|
|
44
|
+
* MIME type).
|
|
45
|
+
*
|
|
46
|
+
* @param a - One row.
|
|
47
|
+
* @param b - The other.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isSameFile(a: IFile, b: IFile): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Why `file` can't be stored as a new row under `recordId`, or undefined when it can. A legacy
|
|
52
|
+
* row needs only a size within the limit. Any other must have its derived id, a chunk list of
|
|
53
|
+
* well-formed hashes, one per started MiB, and the `fileHash` of that list.
|
|
54
|
+
*
|
|
55
|
+
* @param file - The candidate row.
|
|
56
|
+
* @param recordId - The record id it's written under.
|
|
57
|
+
*/
|
|
58
|
+
export declare function fileRowProblem(file: IFile, recordId: string): string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* The `Files` write check: a new row must pass {@link fileRowProblem}; a stored row never
|
|
61
|
+
* changes (rewriting the same file is allowed); and only a legacy bundle row may be deleted.
|
|
62
|
+
*
|
|
63
|
+
* @param existing - The row as stored, if any.
|
|
64
|
+
* @param candidate - The row to store, or undefined for a delete.
|
|
65
|
+
* @param recordId - The record written.
|
|
66
|
+
* @throws A {@link writeNotAuthorized} refusal.
|
|
67
|
+
*/
|
|
68
|
+
export declare function checkFileWrite(existing: IFile | undefined, candidate: IFile | undefined, recordId: string): void;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fileIdFor = fileIdFor;
|
|
4
|
+
exports.isLegacyFileId = isLegacyFileId;
|
|
5
|
+
exports.isLegacyBundleFile = isLegacyBundleFile;
|
|
6
|
+
exports.isKeptFile = isKeptFile;
|
|
7
|
+
exports.isSameFile = isSameFile;
|
|
8
|
+
exports.fileRowProblem = fileRowProblem;
|
|
9
|
+
exports.checkFileWrite = checkFileWrite;
|
|
10
|
+
/**
|
|
11
|
+
* The rules every `Files` row meets, on every device: ids derived from the content, rows that
|
|
12
|
+
* never change, and deletes only of runtime-owned legacy bundle rows. Each device checks an
|
|
13
|
+
* incoming row in full, so no member can put other content under a file's id.
|
|
14
|
+
*/
|
|
15
|
+
const derived_row_id_1 = require("../derived-row-id");
|
|
16
|
+
const write_checks_1 = require("../orm/write-checks");
|
|
17
|
+
const file_types_1 = require("./file.types");
|
|
18
|
+
/** A chunk hash: SHA-256 as unpadded base64url. */
|
|
19
|
+
const CHUNK_HASH = /^[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
/**
|
|
21
|
+
* Names the package installer gives bundle files: `<packageId>-<kind>.bundle.js`, and the older
|
|
22
|
+
* `<name>.<kind>.<packageId>.js`.
|
|
23
|
+
*/
|
|
24
|
+
const BUNDLE_FILE_NAME = /^[0-9a-z]{25}-(package|routes|uis)\.bundle\.js$|^.+\.(package|routes|uis)\.[0-9a-z]{25}\.js$/;
|
|
25
|
+
/**
|
|
26
|
+
* The id of a file: derived from its hash, size, name, MIME type and `keep`, so saving the same
|
|
27
|
+
* content with the same description gives the same file on every device. Give the name and MIME
|
|
28
|
+
* type as storage keeps them (`FileWriteStream` normalizes them), or other devices, which
|
|
29
|
+
* rederive the id from the stored row, refuse it.
|
|
30
|
+
*
|
|
31
|
+
* @param file - The file's hash, size, name, MIME type and `keep`.
|
|
32
|
+
*/
|
|
33
|
+
function fileIdFor(file) {
|
|
34
|
+
return (0, derived_row_id_1.derivedRowId)("Files", [
|
|
35
|
+
file.fileHash,
|
|
36
|
+
file.fileSize,
|
|
37
|
+
file.name,
|
|
38
|
+
file.mimeType ?? "",
|
|
39
|
+
file.keep,
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether a row is a legacy file: one saved under a random id, before ids were derived from
|
|
44
|
+
* content. Legacy rows are admitted as they are, never change, and are always kept.
|
|
45
|
+
*
|
|
46
|
+
* @param fileId - The row's id.
|
|
47
|
+
*/
|
|
48
|
+
function isLegacyFileId(fileId) {
|
|
49
|
+
return !(0, derived_row_id_1.isDerivedRowId)(fileId);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether a legacy row is a package bundle the installer saved. The runtime owns these, so they
|
|
53
|
+
* are collected once nothing references them, and their rows may be deleted.
|
|
54
|
+
*
|
|
55
|
+
* @param file - The row's id and name.
|
|
56
|
+
*/
|
|
57
|
+
function isLegacyBundleFile(file) {
|
|
58
|
+
return isLegacyFileId(file.fileId) && BUNDLE_FILE_NAME.test(file.name);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether a file is kept: never collected, even with nothing referencing it. A legacy row always
|
|
62
|
+
* is. A row relayed by a build that predates `keep` arrives without it; its id, derived from
|
|
63
|
+
* `keep`, then decides, and a row whose id says neither is kept.
|
|
64
|
+
*
|
|
65
|
+
* @param file - The row.
|
|
66
|
+
*/
|
|
67
|
+
function isKeptFile(file) {
|
|
68
|
+
if (isLegacyFileId(file.fileId))
|
|
69
|
+
return true;
|
|
70
|
+
if (typeof file.keep === "boolean")
|
|
71
|
+
return file.keep;
|
|
72
|
+
return fileIdFor({ ...file, keep: false }) !== file.fileId;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether two rows describe the same file, ignoring how an older build stored it (no `keep`, no
|
|
76
|
+
* MIME type).
|
|
77
|
+
*
|
|
78
|
+
* @param a - One row.
|
|
79
|
+
* @param b - The other.
|
|
80
|
+
*/
|
|
81
|
+
function isSameFile(a, b) {
|
|
82
|
+
return (a.fileId === b.fileId &&
|
|
83
|
+
a.name === b.name &&
|
|
84
|
+
a.fileSize === b.fileSize &&
|
|
85
|
+
a.fileHash === b.fileHash &&
|
|
86
|
+
(a.mimeType ?? "") === (b.mimeType ?? "") &&
|
|
87
|
+
isKeptFile(a) === isKeptFile(b) &&
|
|
88
|
+
JSON.stringify(a.chunkHashes ?? []) === JSON.stringify(b.chunkHashes ?? []));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Why `file` can't be stored as a new row under `recordId`, or undefined when it can. A legacy
|
|
92
|
+
* row needs only a size within the limit. Any other must have its derived id, a chunk list of
|
|
93
|
+
* well-formed hashes, one per started MiB, and the `fileHash` of that list.
|
|
94
|
+
*
|
|
95
|
+
* @param file - The candidate row.
|
|
96
|
+
* @param recordId - The record id it's written under.
|
|
97
|
+
*/
|
|
98
|
+
function fileRowProblem(file, recordId) {
|
|
99
|
+
if (file.fileId !== recordId)
|
|
100
|
+
return "the row's id isn't its record id";
|
|
101
|
+
if (!Number.isSafeInteger(file.fileSize) || file.fileSize < 0) {
|
|
102
|
+
return "its size isn't a whole number of bytes";
|
|
103
|
+
}
|
|
104
|
+
if (file.fileSize > file_types_1.FILE_MAX_SIZE) {
|
|
105
|
+
return `it's over the ${file_types_1.FILE_MAX_SIZE / (1024 * 1024)} MiB limit`;
|
|
106
|
+
}
|
|
107
|
+
if (isLegacyFileId(file.fileId))
|
|
108
|
+
return undefined;
|
|
109
|
+
if (typeof file.name !== "string")
|
|
110
|
+
return "its name isn't text";
|
|
111
|
+
// Storage may return an absent value as null.
|
|
112
|
+
if (file.mimeType != null && typeof file.mimeType !== "string") {
|
|
113
|
+
return "its MIME type isn't text";
|
|
114
|
+
}
|
|
115
|
+
if (file.keep != null && typeof file.keep !== "boolean")
|
|
116
|
+
return "keep isn't a boolean";
|
|
117
|
+
const chunkHashes = file.chunkHashes ?? [];
|
|
118
|
+
if (!Array.isArray(chunkHashes) ||
|
|
119
|
+
!chunkHashes.every((hash) => typeof hash === "string" && CHUNK_HASH.test(hash))) {
|
|
120
|
+
return "its chunk list isn't a list of chunk hashes";
|
|
121
|
+
}
|
|
122
|
+
if (chunkHashes.length !== Math.ceil(file.fileSize / file_types_1.FILE_CHUNK_SIZE)) {
|
|
123
|
+
return "its chunk count doesn't match its size";
|
|
124
|
+
}
|
|
125
|
+
if (file.fileHash !== (0, file_types_1.fileHashOf)(chunkHashes))
|
|
126
|
+
return "its hash doesn't match its chunk list";
|
|
127
|
+
// A build that predates `keep` relays the row without it.
|
|
128
|
+
const keeps = typeof file.keep === "boolean" ? [file.keep] : [false, true];
|
|
129
|
+
if (!keeps.some((keep) => fileIdFor({ ...file, keep }) === file.fileId)) {
|
|
130
|
+
return "its id isn't derived from its content";
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The `Files` write check: a new row must pass {@link fileRowProblem}; a stored row never
|
|
136
|
+
* changes (rewriting the same file is allowed); and only a legacy bundle row may be deleted.
|
|
137
|
+
*
|
|
138
|
+
* @param existing - The row as stored, if any.
|
|
139
|
+
* @param candidate - The row to store, or undefined for a delete.
|
|
140
|
+
* @param recordId - The record written.
|
|
141
|
+
* @throws A {@link writeNotAuthorized} refusal.
|
|
142
|
+
*/
|
|
143
|
+
function checkFileWrite(existing, candidate, recordId) {
|
|
144
|
+
if (!candidate) {
|
|
145
|
+
if (isLegacyFileId(recordId) && (!existing || isLegacyBundleFile(existing)))
|
|
146
|
+
return;
|
|
147
|
+
throw (0, write_checks_1.writeNotAuthorized)("Files", "a file can't be deleted");
|
|
148
|
+
}
|
|
149
|
+
if (existing) {
|
|
150
|
+
if (!isSameFile(existing, candidate))
|
|
151
|
+
throw (0, write_checks_1.writeNotAuthorized)("Files", "a file can't change");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const problem = fileRowProblem(candidate, recordId);
|
|
155
|
+
if (problem)
|
|
156
|
+
throw (0, write_checks_1.writeNotAuthorized)("Files", `the file is refused: ${problem}`);
|
|
157
|
+
}
|
|
158
|
+
(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,35 @@ 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`) writes no row and returns the stored one.
|
|
32
|
+
*
|
|
33
|
+
* @returns The file's row.
|
|
34
|
+
*/
|
|
14
35
|
finalize(): Promise<IFile>;
|
|
36
|
+
/** Stop without saving a row. Stored chunks stay until collection frees them. */
|
|
15
37
|
abort(): Promise<void>;
|
|
38
|
+
/** Bytes written so far. */
|
|
16
39
|
getBytesWritten(): number;
|
|
40
|
+
/** Chunks stored so far. */
|
|
17
41
|
getChunkCount(): number;
|
|
42
|
+
/** Whether `finalize` has saved the row. */
|
|
18
43
|
isFinalized(): boolean;
|
|
44
|
+
/** Whether `abort` was called. */
|
|
19
45
|
isAborted(): boolean;
|
|
20
46
|
}
|