@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.
Files changed (41) hide show
  1. package/dist/contracts/contract-resolvers.js +1 -0
  2. package/dist/contracts/index.d.ts +1 -0
  3. package/dist/contracts/index.js +1 -0
  4. package/dist/contracts/system/file-storage-contract.d.ts +170 -0
  5. package/dist/contracts/system/file-storage-contract.js +123 -0
  6. package/dist/contracts/system/files-contract.d.ts +5 -1
  7. package/dist/contracts/system/files-contract.js +50 -9
  8. package/dist/contracts/system/files-shared.d.ts +68 -3
  9. package/dist/contracts/system/files-shared.js +65 -5
  10. package/dist/data/derived-row-id.d.ts +26 -0
  11. package/dist/data/derived-row-id.js +47 -0
  12. package/dist/data/files/chunk-store.d.ts +119 -0
  13. package/dist/data/files/chunk-store.js +112 -0
  14. package/dist/data/files/file-read-stream.d.ts +32 -3
  15. package/dist/data/files/file-read-stream.js +44 -29
  16. package/dist/data/files/file-rows.d.ts +77 -0
  17. package/dist/data/files/file-rows.js +182 -0
  18. package/dist/data/files/file-write-stream.d.ts +27 -0
  19. package/dist/data/files/file-write-stream.js +67 -42
  20. package/dist/data/files/file.types.d.ts +59 -23
  21. package/dist/data/files/file.types.js +54 -51
  22. package/dist/data/files/files.d.ts +28 -24
  23. package/dist/data/files/files.js +71 -85
  24. package/dist/data/files/index.d.ts +2 -0
  25. package/dist/data/files/index.js +2 -0
  26. package/dist/data/groups.d.ts +13 -0
  27. package/dist/data/groups.js +6 -0
  28. package/dist/data/index.d.ts +1 -0
  29. package/dist/data/index.js +1 -0
  30. package/dist/data/orm/write-checks.d.ts +15 -0
  31. package/dist/data/orm/write-checks.js +21 -0
  32. package/dist/define/system-contracts.d.ts +9 -2
  33. package/dist/define/system-contracts.js +6 -2
  34. package/dist/device-pairing/types.d.ts +2 -2
  35. package/dist/invites/invite-messages.d.ts +38 -0
  36. package/dist/package-installer/package-installer.d.ts +3 -5
  37. package/dist/package-installer/package-installer.js +5 -21
  38. package/dist/package-installer/package-propagation.js +6 -17
  39. package/dist/system-ids.d.ts +2 -0
  40. package/dist/system-ids.js +3 -1
  41. 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
- // Hash and store the chunk
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
- const ops = await (0, file_types_1.getFileOps)();
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
- // Create the file record using the same logic as FileTable.saveFile
63
- const chunkHashesString = JSON.stringify(this.chunkHashes);
64
- const fileHash = (0, keys_1.hashBytes)(Buffer.from(chunkHashesString, "utf8"));
65
- let fileRecord;
66
- if (this.chunkHashes.length > file_types_1.CHUNK_INDEX_THRESHOLD) {
67
- // Large file: use recursive index file
68
- const indexFileId = await this.fileTable.createIndexFileRecursively(this.chunkHashes);
69
- fileRecord = {
70
- ...this.metadata,
71
- fileSize: this.bytesWritten,
72
- fileHash,
73
- indexFileId,
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 fileRecord;
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
- isIndexFile: z.ZodOptional<z.ZodBoolean>;
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
- export type IFileInput = Pick<IFile, "fileId" | "name" | "fileSize" | "mimeType" | "isIndexFile">;
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
- export declare const CHUNKS_DIR = "file_chunks";
36
- export declare let CHUNK_INDEX_THRESHOLD: number;
37
- export declare function setChunkIndexThreshold(threshold: number): void;
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
- * Compute the file-system hash for content without saving it.
40
- * Mirrors the chunking + hashing logic of FileWriteStream so the result
41
- * matches `IFile.fileHash` for the same bytes.
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.CHUNK_INDEX_THRESHOLD = exports.CHUNKS_DIR = exports.FILE_CHUNK_SIZE = exports.filesMetaData = exports.fileSchema = void 0;
4
- exports.setChunkIndexThreshold = setChunkIndexThreshold;
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
- .array(zod_1.z.string())
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 if this is an index file for another large file"),
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
- // File storage configuration
36
- exports.FILE_CHUNK_SIZE = 1024 * 1024; // 1MB chunks
37
- exports.CHUNKS_DIR = "file_chunks";
38
- exports.CHUNK_INDEX_THRESHOLD = 1000; // Use chunk index file for files with >1000 chunks (~1GB)
39
- // For testing - allow modifying the threshold
40
- function setChunkIndexThreshold(threshold) {
41
- exports.CHUNK_INDEX_THRESHOLD = threshold;
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
- * Compute the file-system hash for content without saving it.
45
- * Mirrors the chunking + hashing logic of FileWriteStream so the result
46
- * matches `IFile.fileHash` for the same bytes.
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
- while (offset < data.length) {
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
  }