telstore 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/state.js ADDED
@@ -0,0 +1,151 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ import { defaultConfigDir, writeJsonAtomic } from './config.js'
6
+
7
+ export function stateDir(configDir = defaultConfigDir()) {
8
+ return path.join(configDir, 'state')
9
+ }
10
+
11
+ export function stateKey(absPath, size, mtimeMs) {
12
+ return createHash('sha1').update(`${absPath}:${size}:${mtimeMs}`).digest('hex')
13
+ }
14
+
15
+ export function stateFile(key, configDir = defaultConfigDir()) {
16
+ return path.join(stateDir(configDir), `${key}.json`)
17
+ }
18
+
19
+ export async function loadState(key, configDir = defaultConfigDir()) {
20
+ try {
21
+ return JSON.parse(await fs.readFile(stateFile(key, configDir), 'utf8'))
22
+ } catch (err) {
23
+ if (err.code === 'ENOENT') return null
24
+ if (err instanceof SyntaxError) return null
25
+ throw err
26
+ }
27
+ }
28
+
29
+ export async function saveState(key, state, configDir = defaultConfigDir()) {
30
+ await writeJsonAtomic(stateFile(key, configDir), state)
31
+ }
32
+
33
+ export async function markChunkDone(key, state, i, entry, configDir = defaultConfigDir()) {
34
+ const updated = { ...state, done: { ...state.done, [String(i)]: entry } }
35
+ await saveState(key, updated, configDir)
36
+ return updated
37
+ }
38
+
39
+ export async function clearState(key, configDir = defaultConfigDir()) {
40
+ try {
41
+ await fs.unlink(stateFile(key, configDir))
42
+ } catch (err) {
43
+ if (err.code !== 'ENOENT') throw err
44
+ }
45
+ }
46
+
47
+ // A state file is only useful while its backup can still be resumed, and nothing ever
48
+ // removes one whose file was edited since: the key includes mtime, so that state can never
49
+ // match again. Left alone the directory only grows, and with it the report `status` prints.
50
+ export const MAX_STATES = 20
51
+
52
+ // The newest states are the ones worth keeping, and a state file is rewritten every time a
53
+ // chunk lands, so its mtime is when this backup last made progress. Returns the states that
54
+ // were dropped: the caller says their ids out loud, because after this the id is the only
55
+ // way left to find those chunks in the chat.
56
+ export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STATES) {
57
+ let names
58
+ try {
59
+ names = await fs.readdir(stateDir(configDir))
60
+ } catch (err) {
61
+ if (err.code === 'ENOENT') return []
62
+ throw err
63
+ }
64
+
65
+ const files = []
66
+
67
+ for (const name of names) {
68
+ if (!name.endsWith('.json')) continue
69
+
70
+ const file = path.join(stateDir(configDir), name)
71
+
72
+ try {
73
+ const stat = await fs.stat(file)
74
+ files.push({ key: name.slice(0, -'.json'.length), file, mtimeMs: stat.mtimeMs })
75
+ } catch (err) {
76
+ // Gone between readdir and stat: nothing left to prune.
77
+ if (err.code !== 'ENOENT') throw err
78
+ }
79
+ }
80
+
81
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs)
82
+
83
+ const dropped = []
84
+
85
+ for (const { key, file } of files.slice(keep)) {
86
+ // Read before unlink: a file that cannot be read back, or that carries no id, is
87
+ // still pruned — it just cannot be named, and a report naming nothing helps no one.
88
+ const state = await loadState(key, configDir)
89
+ await fs.unlink(file)
90
+ if (state?.id) dropped.push(state)
91
+ }
92
+
93
+ return dropped
94
+ }
95
+
96
+ // status needs every unfinished backup at once. A state file that cannot be read is skipped
97
+ // rather than fatal, for the same reason loadState returns null: one corrupt file must not
98
+ // hide the other backups still waiting to be finished.
99
+ export async function listStates(configDir = defaultConfigDir()) {
100
+ let names
101
+ try {
102
+ names = await fs.readdir(stateDir(configDir))
103
+ } catch (err) {
104
+ if (err.code === 'ENOENT') return []
105
+ throw err
106
+ }
107
+
108
+ const states = []
109
+
110
+ for (const name of names) {
111
+ if (!name.endsWith('.json')) continue
112
+
113
+ const state = await loadState(name.slice(0, -'.json'.length), configDir)
114
+ if (state) states.push(state)
115
+ }
116
+
117
+ return states
118
+ }
119
+
120
+ // delete needs the file a record came from, not just its contents — and the name of that
121
+ // file is a hash of the path, size and mtime *inside* the record, so recomputing it would
122
+ // be trusting an untrusted file to say where it lives. A hand-edited path yields a key that
123
+ // names no file at all, clearState ignores a file that is not there, and telark reports a
124
+ // record dropped that is still sitting on disk. Matching the id inside each file is the one
125
+ // way that cannot point at the wrong one.
126
+ //
127
+ // Every record claiming the id is returned rather than the first: two of them means telark
128
+ // cannot know which to drop, and that is the caller's decision to refuse, not ours to make
129
+ // by picking one.
130
+ export async function findStates(backupId, configDir = defaultConfigDir()) {
131
+ let names
132
+ try {
133
+ names = await fs.readdir(stateDir(configDir))
134
+ } catch (err) {
135
+ if (err.code === 'ENOENT') return []
136
+ throw err
137
+ }
138
+
139
+ const found = []
140
+
141
+ for (const name of names) {
142
+ if (!name.endsWith('.json')) continue
143
+
144
+ const key = name.slice(0, -'.json'.length)
145
+ const state = await loadState(key, configDir)
146
+
147
+ if (state?.id === backupId) found.push({ key, file: stateFile(key, configDir), state })
148
+ }
149
+
150
+ return found
151
+ }
@@ -0,0 +1,155 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { read as readCallback } from 'node:fs'
3
+ import { promisify } from 'node:util'
4
+
5
+ import { Api } from 'telegram'
6
+ import { readBigIntFromBuffer } from 'telegram/Helpers.js'
7
+
8
+ import { DEFAULT_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
9
+ import { withRetry } from './retry.js'
10
+ import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
11
+
12
+ const read = promisify(readCallback)
13
+
14
+ // Telegram splits its upload API by file size: only files above 10MB may use the
15
+ // "big" family. GramJS picks the same threshold (LARGE_FILE_THRESHOLD in
16
+ // node_modules/telegram/client/uploads.js), and telark follows it.
17
+ export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
18
+
19
+ async function readExactly(fd, length, position) {
20
+ // allocUnsafe skips zero-filling 512KB per part — about 0.4s of memset per 1800MB chunk,
21
+ // on the same thread that drives the in-flight requests. Safe only because the loop below
22
+ // either fills every byte or throws: no uninitialised byte can reach a request or the hash.
23
+ const buffer = Buffer.allocUnsafe(length)
24
+ let filled = 0
25
+
26
+ while (filled < length) {
27
+ const { bytesRead } = await read(fd, buffer, filled, length - filled, position + filled)
28
+ if (bytesRead === 0) {
29
+ throw new Error(`Short read: needed ${length} bytes at offset ${position} but the file ended.`)
30
+ }
31
+ filled += bytesRead
32
+ }
33
+
34
+ return buffer
35
+ }
36
+
37
+ export async function uploadRange(client, fd, options) {
38
+ const {
39
+ offset,
40
+ length,
41
+ fileName,
42
+ concurrency = DEFAULT_CONCURRENCY,
43
+ partSize = PART_SIZE,
44
+ onProgress,
45
+ retryOptions,
46
+ stallMs = DEFAULT_STALL_MS,
47
+ } = options
48
+
49
+ // A pool of fewer than one worker does no work. In the download path that means
50
+ // Promise.all resolves at once and a chunk nobody fetched is reported as complete; in the
51
+ // upload path the batch loop never advances and the process spins in microtasks, which not
52
+ // even a test timeout can interrupt. Neither is something a caller should be able to ask
53
+ // for by accident.
54
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
55
+ throw new Error(
56
+ `Worker count must be a whole number of at least one, got: ${JSON.stringify(concurrency)}.`,
57
+ )
58
+ }
59
+
60
+ const totalParts = Math.ceil(length / partSize)
61
+
62
+ if (totalParts > MAX_PARTS) {
63
+ throw new Error(
64
+ `This byte range needs ${totalParts} parts, but Telegram accepts at most 4000 parts per file.`,
65
+ )
66
+ }
67
+
68
+ const isLarge = length > LARGE_FILE_THRESHOLD
69
+ const fileId = readBigIntFromBuffer(randomBytes(8), true, true)
70
+ const hash = createHash('sha256')
71
+
72
+ function partRequest(part, bytes) {
73
+ if (isLarge) {
74
+ return new Api.upload.SaveBigFilePart({
75
+ fileId,
76
+ filePart: part,
77
+ fileTotalParts: totalParts,
78
+ bytes,
79
+ })
80
+ }
81
+
82
+ return new Api.upload.SaveFilePart({ fileId, filePart: part, bytes })
83
+ }
84
+
85
+ for (let start = 0; start < totalParts; start += concurrency) {
86
+ const end = Math.min(start + concurrency, totalParts)
87
+ const sending = []
88
+ let sendError = null
89
+ let sendFailed = false
90
+ let readError = null
91
+
92
+ // Read sequentially so the hash sees parts in order, but send in parallel.
93
+ try {
94
+ for (let part = start; part < end; part += 1) {
95
+ const partOffset = part * partSize
96
+ const partLength = Math.min(partSize, length - partOffset)
97
+ const bytes = await readExactly(fd, partLength, offset + partOffset)
98
+
99
+ hash.update(bytes)
100
+
101
+ // Attach the handler when the promise is created rather than waiting for the
102
+ // Promise.all at the end of the batch: if readExactly throws on a later part,
103
+ // an already-pushed promise with no handler becomes an unhandledRejection and
104
+ // hides the real error.
105
+ sending.push(
106
+ withRetry(
107
+ () =>
108
+ // Same exposure as the download path: a request left on a sender GramJS has
109
+ // stopped draining never settles, so without a deadline this await would hold
110
+ // the batch open forever and the upload would end without a word.
111
+ withStallTimeout(
112
+ client.invoke(partRequest(part, bytes)),
113
+ stallMs,
114
+ () =>
115
+ `Telegram stopped acknowledging part ${part + 1}/${totalParts} of ` +
116
+ `${fileName}: nothing back for ${Math.round(stallMs / 1000)}s.`,
117
+ ),
118
+ retryOptions,
119
+ ).then(
120
+ () => onProgress?.(partLength),
121
+ (err) => {
122
+ // Not `sendError ??= err`: if the rejection reason is falsy
123
+ // (undefined/null), that assignment still sets sendError to a falsy
124
+ // value and the `if (sendError)` below reads as "no error" — swallowing
125
+ // a failed send and turning it into a fake success.
126
+ if (!sendFailed) {
127
+ sendFailed = true
128
+ sendError = err
129
+ }
130
+ },
131
+ ),
132
+ )
133
+ }
134
+ } catch (err) {
135
+ readError = err
136
+ }
137
+
138
+ // Every promise in `sending` already absorbed its own error above, so they all
139
+ // resolve; this await only guarantees no request is still in flight when we throw.
140
+ await Promise.all(sending)
141
+
142
+ if (readError) throw readError
143
+ if (sendFailed) throw sendError
144
+ }
145
+
146
+ const inputFile = isLarge
147
+ ? new Api.InputFileBig({ id: fileId, parts: totalParts, name: fileName })
148
+ : new Api.InputFile({ id: fileId, parts: totalParts, name: fileName, md5Checksum: '' })
149
+
150
+ return {
151
+ inputFile,
152
+ sha256: hash.digest('hex'),
153
+ parts: totalParts,
154
+ }
155
+ }