@platformatic/sqlite-js-stream 0.0.0-stage → 0.1.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/CHANGELOG.md +40 -0
- package/LICENSE +202 -0
- package/NOTICE +22 -0
- package/README.md +482 -2
- package/dist/backends/file/index.d.ts +4 -0
- package/dist/backends/file/index.d.ts.map +1 -0
- package/dist/backends/file/index.js +3 -0
- package/dist/backends/file/leaser.d.ts +20 -0
- package/dist/backends/file/leaser.d.ts.map +1 -0
- package/dist/backends/file/leaser.js +123 -0
- package/dist/backends/file/replica-client.d.ts +38 -0
- package/dist/backends/file/replica-client.d.ts.map +1 -0
- package/dist/backends/file/replica-client.js +209 -0
- package/dist/db.d.ts +80 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +484 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +66 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/leaser.d.ts +55 -0
- package/dist/leaser.d.ts.map +1 -0
- package/dist/leaser.js +60 -0
- package/dist/ltx/byte-reader.d.ts +18 -0
- package/dist/ltx/byte-reader.d.ts.map +1 -0
- package/dist/ltx/byte-reader.js +38 -0
- package/dist/ltx/bytes.d.ts +20 -0
- package/dist/ltx/bytes.d.ts.map +1 -0
- package/dist/ltx/bytes.js +69 -0
- package/dist/ltx/checksum.d.ts +20 -0
- package/dist/ltx/checksum.d.ts.map +1 -0
- package/dist/ltx/checksum.js +42 -0
- package/dist/ltx/compaction-level.d.ts +58 -0
- package/dist/ltx/compaction-level.d.ts.map +1 -0
- package/dist/ltx/compaction-level.js +115 -0
- package/dist/ltx/compactor.d.ts +33 -0
- package/dist/ltx/compactor.d.ts.map +1 -0
- package/dist/ltx/compactor.js +137 -0
- package/dist/ltx/constants.d.ts +49 -0
- package/dist/ltx/constants.d.ts.map +1 -0
- package/dist/ltx/constants.js +58 -0
- package/dist/ltx/crc64.d.ts +29 -0
- package/dist/ltx/crc64.d.ts.map +1 -0
- package/dist/ltx/crc64.js +57 -0
- package/dist/ltx/decoder.d.ts +55 -0
- package/dist/ltx/decoder.d.ts.map +1 -0
- package/dist/ltx/decoder.js +236 -0
- package/dist/ltx/encoder.d.ts +49 -0
- package/dist/ltx/encoder.d.ts.map +1 -0
- package/dist/ltx/encoder.js +159 -0
- package/dist/ltx/file-info.d.ts +24 -0
- package/dist/ltx/file-info.d.ts.map +1 -0
- package/dist/ltx/file-info.js +74 -0
- package/dist/ltx/filename.d.ts +13 -0
- package/dist/ltx/filename.d.ts.map +1 -0
- package/dist/ltx/filename.js +23 -0
- package/dist/ltx/header.d.ts +17 -0
- package/dist/ltx/header.d.ts.map +1 -0
- package/dist/ltx/header.js +115 -0
- package/dist/ltx/index.d.ts +24 -0
- package/dist/ltx/index.d.ts.map +1 -0
- package/dist/ltx/index.js +25 -0
- package/dist/ltx/lz4.d.ts +41 -0
- package/dist/ltx/lz4.d.ts.map +1 -0
- package/dist/ltx/lz4.js +264 -0
- package/dist/ltx/page-index.d.ts +26 -0
- package/dist/ltx/page-index.d.ts.map +1 -0
- package/dist/ltx/page-index.js +118 -0
- package/dist/ltx/page.d.ts +27 -0
- package/dist/ltx/page.d.ts.map +1 -0
- package/dist/ltx/page.js +66 -0
- package/dist/ltx/sink.d.ts +17 -0
- package/dist/ltx/sink.d.ts.map +1 -0
- package/dist/ltx/sink.js +21 -0
- package/dist/ltx/stream.d.ts +64 -0
- package/dist/ltx/stream.d.ts.map +1 -0
- package/dist/ltx/stream.js +248 -0
- package/dist/ltx/streaming-compactor.d.ts +34 -0
- package/dist/ltx/streaming-compactor.d.ts.map +1 -0
- package/dist/ltx/streaming-compactor.js +140 -0
- package/dist/ltx/trailer.d.ts +12 -0
- package/dist/ltx/trailer.d.ts.map +1 -0
- package/dist/ltx/trailer.js +47 -0
- package/dist/ltx/types.d.ts +79 -0
- package/dist/ltx/types.d.ts.map +1 -0
- package/dist/ltx/types.js +70 -0
- package/dist/replica-client.d.ts +92 -0
- package/dist/replica-client.d.ts.map +1 -0
- package/dist/replica-client.js +122 -0
- package/dist/replica-url.d.ts +20 -0
- package/dist/replica-url.d.ts.map +1 -0
- package/dist/replica-url.js +49 -0
- package/dist/replica.d.ts +379 -0
- package/dist/replica.d.ts.map +1 -0
- package/dist/replica.js +1253 -0
- package/dist/store.d.ts +34 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +60 -0
- package/dist/util/fs.d.ts +28 -0
- package/dist/util/fs.d.ts.map +1 -0
- package/dist/util/fs.js +73 -0
- package/dist/util/logger.d.ts +10 -0
- package/dist/util/logger.d.ts.map +1 -0
- package/dist/util/logger.js +7 -0
- package/dist/util/stream.d.ts +11 -0
- package/dist/util/stream.d.ts.map +1 -0
- package/dist/util/stream.js +25 -0
- package/dist/wal/checksum.d.ts +17 -0
- package/dist/wal/checksum.d.ts.map +1 -0
- package/dist/wal/checksum.js +30 -0
- package/dist/wal/constants.d.ts +8 -0
- package/dist/wal/constants.d.ts.map +1 -0
- package/dist/wal/constants.js +7 -0
- package/dist/wal/index.d.ts +5 -0
- package/dist/wal/index.d.ts.map +1 -0
- package/dist/wal/index.js +4 -0
- package/dist/wal/reader.d.ts +61 -0
- package/dist/wal/reader.d.ts.map +1 -0
- package/dist/wal/reader.js +207 -0
- package/package.json +68 -3
- package/src/backends/file/index.ts +3 -0
- package/src/backends/file/leaser.ts +145 -0
- package/src/backends/file/replica-client.ts +246 -0
- package/src/db.ts +553 -0
- package/src/errors.ts +74 -0
- package/src/index.ts +26 -0
- package/src/leaser.ts +86 -0
- package/src/ltx/byte-reader.ts +46 -0
- package/src/ltx/bytes.ts +87 -0
- package/src/ltx/checksum.ts +46 -0
- package/src/ltx/compaction-level.ts +135 -0
- package/src/ltx/compactor.ts +167 -0
- package/src/ltx/constants.ts +71 -0
- package/src/ltx/crc64.ts +65 -0
- package/src/ltx/decoder.ts +282 -0
- package/src/ltx/encoder.ts +200 -0
- package/src/ltx/file-info.ts +81 -0
- package/src/ltx/filename.ts +27 -0
- package/src/ltx/header.ts +126 -0
- package/src/ltx/index.ts +27 -0
- package/src/ltx/lz4.ts +279 -0
- package/src/ltx/page-index.ts +140 -0
- package/src/ltx/page.ts +80 -0
- package/src/ltx/sink.ts +33 -0
- package/src/ltx/stream.ts +286 -0
- package/src/ltx/streaming-compactor.ts +169 -0
- package/src/ltx/trailer.ts +58 -0
- package/src/ltx/types.ts +152 -0
- package/src/lz4js.d.ts +27 -0
- package/src/replica-client.ts +222 -0
- package/src/replica-url.ts +72 -0
- package/src/replica.ts +1480 -0
- package/src/store.ts +71 -0
- package/src/util/fs.ts +82 -0
- package/src/util/logger.ts +15 -0
- package/src/util/stream.ts +28 -0
- package/src/wal/checksum.ts +39 -0
- package/src/wal/constants.ts +10 -0
- package/src/wal/index.ts +5 -0
- package/src/wal/reader.ts +264 -0
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/sqlite-js-stream",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Node.js port of Litestream: SQLite streaming replication & disaster recovery via LTX files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24"
|
|
8
|
+
},
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
|
+
"author": "Matteo Collina",
|
|
11
|
+
"homepage": "https://github.com/platformatic/sqlite-js-stream",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/platformatic/sqlite-js-stream.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/platformatic/sqlite-js-stream/issues"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"NOTICE",
|
|
35
|
+
"CHANGELOG.md"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"lz4js": "^0.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^24.0.0",
|
|
46
|
+
"eslint": "^9.39.5",
|
|
47
|
+
"neostandard": "^0.13.0",
|
|
48
|
+
"typescript": "^5.7.0"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"sqlite",
|
|
52
|
+
"litestream",
|
|
53
|
+
"replication",
|
|
54
|
+
"backup",
|
|
55
|
+
"ltx",
|
|
56
|
+
"disaster-recovery"
|
|
57
|
+
],
|
|
58
|
+
"scripts": {
|
|
59
|
+
"test": "node --test 'test/**/*.test.ts'",
|
|
60
|
+
"pretypecheck": "pnpm build",
|
|
61
|
+
"typecheck": "tsc -p . --noEmit",
|
|
62
|
+
"lint": "eslint .",
|
|
63
|
+
"lint:fix": "eslint . --fix",
|
|
64
|
+
"build": "tsc -p tsconfig.build.json && node scripts/rewrite-dts.mjs dist",
|
|
65
|
+
"golden:go": "cd tools/golden && go run .",
|
|
66
|
+
"pretest:all": "pnpm build",
|
|
67
|
+
"test:all": "pnpm test && pnpm -r test",
|
|
68
|
+
"typecheck:all": "pnpm typecheck",
|
|
69
|
+
"bench": "node bench/bench-sync-restore.ts"
|
|
70
|
+
}
|
|
6
71
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backend Leaser.
|
|
3
|
+
*
|
|
4
|
+
* Stores the lease as `<path>/lock.json` and uses an exclusive create + a
|
|
5
|
+
* content hash as the conditional-write token. The file backend is primarily
|
|
6
|
+
* for local/single-host use; for real multi-host safety use the S3 leaser
|
|
7
|
+
* (which uses S3 `If-None-Match`/`If-Match` conditional writes).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { link, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
12
|
+
import nodePath from 'node:path';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
DefaultLeasePath,
|
|
16
|
+
DefaultLeaseTTL,
|
|
17
|
+
ErrLeaseNotHeld,
|
|
18
|
+
Lease,
|
|
19
|
+
LeaseExistsError,
|
|
20
|
+
type Leaser,
|
|
21
|
+
} from '../../leaser.ts';
|
|
22
|
+
import { FileReplicaError } from '../../errors.ts';
|
|
23
|
+
|
|
24
|
+
// Disambiguates temp files written in the same millisecond by this process.
|
|
25
|
+
let tmpSeq = 0;
|
|
26
|
+
|
|
27
|
+
function sha256(data: Uint8Array): string {
|
|
28
|
+
return createHash('sha256').update(data).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function encodeLease(l: Lease): Uint8Array {
|
|
32
|
+
return new TextEncoder().encode(
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
generation: l.generation,
|
|
35
|
+
expires_at: new Date(l.expiresAt).toISOString(),
|
|
36
|
+
owner: l.owner,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decodeLease(data: Uint8Array, etag: string): Lease {
|
|
42
|
+
const parsed = JSON.parse(new TextDecoder().decode(data)) as {
|
|
43
|
+
generation?: number;
|
|
44
|
+
expires_at?: string;
|
|
45
|
+
owner?: string;
|
|
46
|
+
};
|
|
47
|
+
const expiresAt = Date.parse(parsed.expires_at ?? '');
|
|
48
|
+
if (Number.isNaN(expiresAt)) {
|
|
49
|
+
throw new FileReplicaError('invalid lease file: bad expires_at');
|
|
50
|
+
}
|
|
51
|
+
return new Lease(parsed.generation ?? 1, expiresAt, parsed.owner ?? '', etag);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads and decodes the lease file, returning null if it does not exist. */
|
|
55
|
+
async function readLease(lockPath: string): Promise<Lease | null> {
|
|
56
|
+
try {
|
|
57
|
+
const data = await readFile(lockPath);
|
|
58
|
+
return decodeLease(data, sha256(data));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** FileLeaser implements {@link Leaser} for a local directory. */
|
|
66
|
+
export class FileLeaser implements Leaser {
|
|
67
|
+
private lockPath: string;
|
|
68
|
+
private ttlMs: number;
|
|
69
|
+
|
|
70
|
+
constructor(path: string, lockName = DefaultLeasePath, ttlMs = DefaultLeaseTTL) {
|
|
71
|
+
this.lockPath = nodePath.join(path, lockName);
|
|
72
|
+
this.ttlMs = ttlMs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async acquireLease(): Promise<Lease> {
|
|
76
|
+
const existing = await readLease(this.lockPath);
|
|
77
|
+
if (existing !== null && !existing.isExpired()) {
|
|
78
|
+
throw new LeaseExistsError(existing.owner, existing.expiresAt);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let generation = 1;
|
|
82
|
+
if (existing !== null) generation = existing.generation + 1;
|
|
83
|
+
|
|
84
|
+
const lease = new Lease(generation, Date.now() + this.ttlMs, this.owner());
|
|
85
|
+
const bytes = encodeLease(lease);
|
|
86
|
+
|
|
87
|
+
if (existing === null) {
|
|
88
|
+
// Exclusive create so we do not clobber a concurrent acquirer. Write a
|
|
89
|
+
// temp file and hard-link it into place so the lock appears atomically
|
|
90
|
+
// with its full contents; a racing reader never sees an empty file.
|
|
91
|
+
const tmp = `${this.lockPath}.tmp-${process.pid}-${Date.now()}-${tmpSeq++}`;
|
|
92
|
+
await writeFile(tmp, bytes);
|
|
93
|
+
try {
|
|
94
|
+
await link(tmp, this.lockPath);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
97
|
+
if (code === 'EEXIST') {
|
|
98
|
+
const current = await readLease(this.lockPath);
|
|
99
|
+
if (current !== null && !current.isExpired()) {
|
|
100
|
+
throw new LeaseExistsError(current.owner, current.expiresAt);
|
|
101
|
+
}
|
|
102
|
+
// The other writer lost the race; retry the expired-overwrite path.
|
|
103
|
+
return this.acquireLease();
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
} finally {
|
|
107
|
+
await rm(tmp, { force: true });
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
// Existing lease expired: atomically replace it.
|
|
111
|
+
const tmp = `${this.lockPath}.tmp-${process.pid}-${Date.now()}-${tmpSeq++}`;
|
|
112
|
+
await writeFile(tmp, bytes);
|
|
113
|
+
await rename(tmp, this.lockPath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const written = await readLease(this.lockPath);
|
|
117
|
+
return written ?? lease;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async renewLease(lease: Lease): Promise<Lease> {
|
|
121
|
+
const current = await readLease(this.lockPath);
|
|
122
|
+
if (current === null) throw new ErrLeaseNotHeld();
|
|
123
|
+
if (current.etag !== lease.etag) throw new ErrLeaseNotHeld();
|
|
124
|
+
|
|
125
|
+
const renewed = new Lease(lease.generation, Date.now() + this.ttlMs, lease.owner);
|
|
126
|
+
const bytes = encodeLease(renewed);
|
|
127
|
+
const tmp = `${this.lockPath}.tmp-${process.pid}-${Date.now()}-${tmpSeq++}`;
|
|
128
|
+
await writeFile(tmp, bytes);
|
|
129
|
+
await rename(tmp, this.lockPath);
|
|
130
|
+
|
|
131
|
+
const written = await readLease(this.lockPath);
|
|
132
|
+
return written ?? renewed;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async releaseLease(lease: Lease): Promise<void> {
|
|
136
|
+
const current = await readLease(this.lockPath);
|
|
137
|
+
if (current === null) return; // already released
|
|
138
|
+
if (current.etag !== lease.etag) throw new ErrLeaseNotHeld();
|
|
139
|
+
await rm(this.lockPath, { force: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private owner(): string {
|
|
143
|
+
return `${process.pid}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File replica client — replicates LTX files to a local directory.
|
|
3
|
+
*
|
|
4
|
+
* Layout: `<root>/ltx/<level>/<minTXID>-<maxTXID>.ltx`
|
|
5
|
+
* Writes are atomic: data is streamed to a `.tmp` file, fsync'd, then renamed,
|
|
6
|
+
* the directory is fsync'd, and the file mtime is set from the LTX header
|
|
7
|
+
* timestamp to preserve original creation time.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createReadStream } from 'node:fs';
|
|
11
|
+
import { open, readdir, rename, rm, stat, utimes, type FileHandle } from 'node:fs/promises';
|
|
12
|
+
import nodePath from 'node:path';
|
|
13
|
+
import { Readable } from 'node:stream';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
ltxFilePath as sharedLTXFilePath,
|
|
17
|
+
ltxLevelDir as sharedLTXLevelDir,
|
|
18
|
+
LTXError,
|
|
19
|
+
parseFilename,
|
|
20
|
+
type OpenLTXFileOptions,
|
|
21
|
+
type ReplicaClient,
|
|
22
|
+
type WriteLTXFileOptions,
|
|
23
|
+
} from '../../replica-client.ts';
|
|
24
|
+
import type { FileInfo, Header } from '../../ltx/index.ts';
|
|
25
|
+
import { HeaderSize, unmarshalHeader } from '../../ltx/index.ts';
|
|
26
|
+
import { concatBytes } from '../../util/stream.ts';
|
|
27
|
+
import { fsyncDir, mkdirAll } from '../../util/fs.ts';
|
|
28
|
+
import type { Logger } from '../../util/logger.ts';
|
|
29
|
+
import { FileReplicaError, InvalidArgumentError } from '../../errors.ts';
|
|
30
|
+
import { noopLogger } from '../../util/logger.ts';
|
|
31
|
+
import { registerReplicaClientFactory } from '../../replica-url.ts';
|
|
32
|
+
|
|
33
|
+
export const ReplicaClientType = 'file';
|
|
34
|
+
|
|
35
|
+
/** FileReplicaClient writes LTX files to a local directory. */
|
|
36
|
+
export class FileReplicaClient implements ReplicaClient {
|
|
37
|
+
private pathVal: string;
|
|
38
|
+
private logger: Logger = noopLogger;
|
|
39
|
+
|
|
40
|
+
constructor(path: string) {
|
|
41
|
+
this.pathVal = path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type(): string {
|
|
45
|
+
return ReplicaClientType;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async init(): Promise<void> {
|
|
49
|
+
/* no initialization required */
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setLogger(logger: Logger): void {
|
|
53
|
+
this.logger = logger;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Returns the destination root path. */
|
|
57
|
+
path(): string {
|
|
58
|
+
return this.pathVal;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
ltxLevelDir(level: number): string {
|
|
62
|
+
return sharedLTXLevelDir(this.pathVal, level);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ltxFilePath(level: number, minTXID: bigint, maxTXID: bigint): string {
|
|
66
|
+
return sharedLTXFilePath(this.pathVal, level, minTXID, maxTXID);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async *ltxFiles(opts: { level: number; seek: bigint; useMetadata: boolean }): AsyncIterable<FileInfo> {
|
|
70
|
+
const dir = this.ltxLevelDir(opts.level);
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const infos: FileInfo[] = [];
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.isFile()) continue;
|
|
82
|
+
let minTXID: bigint;
|
|
83
|
+
let maxTXID: bigint;
|
|
84
|
+
try {
|
|
85
|
+
({ minTXID, maxTXID } = parseFilename(entry.name));
|
|
86
|
+
} catch {
|
|
87
|
+
continue; // skip non-conforming files
|
|
88
|
+
}
|
|
89
|
+
if (minTXID < opts.seek) continue;
|
|
90
|
+
|
|
91
|
+
const full = nodePath.join(dir, entry.name);
|
|
92
|
+
const st = await stat(full);
|
|
93
|
+
infos.push({
|
|
94
|
+
level: opts.level,
|
|
95
|
+
minTXID,
|
|
96
|
+
maxTXID,
|
|
97
|
+
size: st.size,
|
|
98
|
+
createdAt: st.mtimeMs,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
infos.sort((a, b) => {
|
|
103
|
+
if (a.minTXID !== b.minTXID) return a.minTXID < b.minTXID ? -1 : 1;
|
|
104
|
+
return a.maxTXID < b.maxTXID ? -1 : a.maxTXID > b.maxTXID ? 1 : 0;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (const info of infos) yield info;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async openLtxFile(opts: OpenLTXFileOptions): Promise<Readable> {
|
|
111
|
+
const p = this.ltxFilePath(opts.level, opts.minTXID, opts.maxTXID);
|
|
112
|
+
let st;
|
|
113
|
+
try {
|
|
114
|
+
st = await stat(p);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw new LTXError('open', p, opts.level, opts.minTXID, opts.maxTXID, err);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (opts.offset >= st.size) {
|
|
120
|
+
return Readable.from([]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const end =
|
|
124
|
+
opts.size > 0
|
|
125
|
+
? Math.min(opts.offset + opts.size - 1, st.size - 1)
|
|
126
|
+
: st.size - 1;
|
|
127
|
+
|
|
128
|
+
return createReadStream(p, { start: opts.offset, end });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async writeLtxFile(opts: WriteLTXFileOptions, body: AsyncIterable<Uint8Array | string>): Promise<FileInfo> {
|
|
132
|
+
const dir = this.ltxLevelDir(opts.level);
|
|
133
|
+
await mkdirAll(dir);
|
|
134
|
+
|
|
135
|
+
const filename = this.ltxFilePath(opts.level, opts.minTXID, opts.maxTXID);
|
|
136
|
+
const tmp = `${filename}.tmp`;
|
|
137
|
+
|
|
138
|
+
let fh: FileHandle | null = null;
|
|
139
|
+
try {
|
|
140
|
+
fh = await open(tmp, 'w');
|
|
141
|
+
} catch (err) {
|
|
142
|
+
throw new LTXError('create', tmp, opts.level, opts.minTXID, opts.maxTXID, err);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let headerBytes: Uint8Array | null = null;
|
|
146
|
+
let collected: Uint8Array[] = [];
|
|
147
|
+
let collectedLen = 0;
|
|
148
|
+
let size = 0;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
for await (const chunk of body) {
|
|
152
|
+
const bytes =
|
|
153
|
+
typeof chunk === 'string' ? new TextEncoder().encode(chunk) : Uint8Array.from(chunk);
|
|
154
|
+
if (headerBytes === null) {
|
|
155
|
+
collected.push(bytes);
|
|
156
|
+
collectedLen += bytes.length;
|
|
157
|
+
if (collectedLen >= HeaderSize) {
|
|
158
|
+
const concat = concatBytes(collected);
|
|
159
|
+
headerBytes = concat.subarray(0, HeaderSize);
|
|
160
|
+
collected = [];
|
|
161
|
+
collectedLen = 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await writeAll(fh, bytes);
|
|
165
|
+
size += bytes.length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (headerBytes === null) {
|
|
169
|
+
throw new FileReplicaError('write ltx file: input too short to contain a header');
|
|
170
|
+
}
|
|
171
|
+
const hdr: Header = unmarshalHeader(headerBytes);
|
|
172
|
+
const timestamp = Number(hdr.timestamp);
|
|
173
|
+
|
|
174
|
+
await fh.sync();
|
|
175
|
+
await fh.close();
|
|
176
|
+
fh = null;
|
|
177
|
+
|
|
178
|
+
await rename(tmp, filename);
|
|
179
|
+
await fsyncDir(dir);
|
|
180
|
+
await utimes(filename, new Date(timestamp), new Date(timestamp));
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
level: opts.level,
|
|
184
|
+
minTXID: opts.minTXID,
|
|
185
|
+
maxTXID: opts.maxTXID,
|
|
186
|
+
size,
|
|
187
|
+
createdAt: timestamp,
|
|
188
|
+
};
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (fh !== null) await fh.close().catch(() => {});
|
|
191
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async deleteLtxFiles(infos: FileInfo[]): Promise<void> {
|
|
197
|
+
for (const info of infos) {
|
|
198
|
+
const p = this.ltxFilePath(info.level, info.minTXID, info.maxTXID);
|
|
199
|
+
this.logger.debug('deleting ltx file', {
|
|
200
|
+
level: info.level,
|
|
201
|
+
minTXID: info.minTXID,
|
|
202
|
+
maxTXID: info.maxTXID,
|
|
203
|
+
path: p,
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
await rm(p);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async deleteAll(): Promise<void> {
|
|
214
|
+
try {
|
|
215
|
+
await rm(this.pathVal, { recursive: true, force: true });
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** NewReplicaClientFromURL creates a FileReplicaClient from a URL path. */
|
|
223
|
+
export function NewReplicaClientFromURL(
|
|
224
|
+
_scheme: string,
|
|
225
|
+
_host: string,
|
|
226
|
+
urlPath: string,
|
|
227
|
+
_query: URLSearchParams | null,
|
|
228
|
+
_userinfo: string | null,
|
|
229
|
+
): ReplicaClient {
|
|
230
|
+
if (!urlPath) {
|
|
231
|
+
throw new InvalidArgumentError('file replica path required');
|
|
232
|
+
}
|
|
233
|
+
return new FileReplicaClient(urlPath);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Mirrors Go's init(): register the file factory when this module loads.
|
|
237
|
+
registerReplicaClientFactory('file', NewReplicaClientFromURL);
|
|
238
|
+
|
|
239
|
+
/** Writes all of `bytes` to a FileHandle, handling partial writes. */
|
|
240
|
+
async function writeAll(handle: FileHandle, bytes: Uint8Array): Promise<void> {
|
|
241
|
+
let offset = 0;
|
|
242
|
+
while (offset < bytes.length) {
|
|
243
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset);
|
|
244
|
+
offset += bytesWritten;
|
|
245
|
+
}
|
|
246
|
+
}
|