@objy/server 0.0.0-stage → 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/API.md +1469 -0
- package/LICENSE +21 -0
- package/NOTICE.md +9 -0
- package/README.md +21 -2
- package/objydb.config.json +48 -0
- package/package.json +42 -4
- package/public/index.html +4134 -0
- package/public/objy-db-logo.png +0 -0
- package/src/aggregate.js +438 -0
- package/src/api.js +15 -0
- package/src/auth.js +472 -0
- package/src/clock.js +18 -0
- package/src/cluster-secret.js +25 -0
- package/src/cluster-status.js +562 -0
- package/src/compression-stats.js +32 -0
- package/src/config.js +360 -0
- package/src/crdt.js +1076 -0
- package/src/derived-state-scheduler.js +128 -0
- package/src/files.js +324 -0
- package/src/hnsw.js +269 -0
- package/src/http-cors.js +31 -0
- package/src/http-router.js +162 -0
- package/src/index.js +137 -0
- package/src/interfaces/auth.js +123 -0
- package/src/interfaces/cluster.js +296 -0
- package/src/interfaces/public-object.js +2452 -0
- package/src/interfaces/ui.js +38 -0
- package/src/lib/crypto.cjs +54 -0
- package/src/lib/jwt.cjs +231 -0
- package/src/local-wal.js +122 -0
- package/src/memory-guard.js +152 -0
- package/src/ndjson.js +117 -0
- package/src/node-runtime.js +29 -0
- package/src/object-hooks.js +282 -0
- package/src/object-id.js +7 -0
- package/src/object-storage.js +263 -0
- package/src/object-store.js +288 -0
- package/src/patch.js +87 -0
- package/src/permissions.js +178 -0
- package/src/persistence.js +1918 -0
- package/src/plugin-loader.js +21 -0
- package/src/query-planner.js +266 -0
- package/src/query.js +416 -0
- package/src/references.js +349 -0
- package/src/replication-scheduler.js +303 -0
- package/src/restore-status.js +77 -0
- package/src/runtime.js +47 -0
- package/src/server.js +242 -0
- package/src/shape.js +286 -0
- package/src/shard-map-store.js +85 -0
- package/src/shard-map.js +333 -0
- package/src/shard-router.js +737 -0
- package/src/store.js +3410 -0
- package/src/system-secrets.js +107 -0
- package/src/vector.js +83 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { text } from "../http-router.js";
|
|
5
|
+
|
|
6
|
+
const UI_HTML = readFileSync(
|
|
7
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "index.html"),
|
|
8
|
+
"utf8",
|
|
9
|
+
);
|
|
10
|
+
const API_DOCS = readFileSync(
|
|
11
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "..", "API.md"),
|
|
12
|
+
"utf8",
|
|
13
|
+
);
|
|
14
|
+
const LOGO_PNG = readFileSync(
|
|
15
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "objy-db-logo.png"),
|
|
16
|
+
);
|
|
17
|
+
export function createUiInterface() {
|
|
18
|
+
return {
|
|
19
|
+
name: "ui",
|
|
20
|
+
register(router) {
|
|
21
|
+
const send = (req, res) => text(res, 200, UI_HTML, "text/html; charset=utf-8");
|
|
22
|
+
const sendApiDocs = (req, res) => text(res, 200, API_DOCS, "text/markdown; charset=utf-8");
|
|
23
|
+
const sendPng = (body) => (req, res) => {
|
|
24
|
+
res.writeHead(200, {
|
|
25
|
+
"content-type": "image/png",
|
|
26
|
+
"content-length": body.byteLength,
|
|
27
|
+
"cache-control": "public, max-age=86400",
|
|
28
|
+
});
|
|
29
|
+
res.end(body);
|
|
30
|
+
};
|
|
31
|
+
router.get("/", send);
|
|
32
|
+
router.get("/ui", send);
|
|
33
|
+
router.get("/index.html", send);
|
|
34
|
+
router.get("/objy-db-logo.png", sendPng(LOGO_PNG));
|
|
35
|
+
router.get("/API.md", sendApiDocs);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createHmac,
|
|
3
|
+
pbkdf2Sync,
|
|
4
|
+
randomBytes,
|
|
5
|
+
randomUUID,
|
|
6
|
+
timingSafeEqual,
|
|
7
|
+
} = require('node:crypto')
|
|
8
|
+
|
|
9
|
+
const DEFAULT_ITERATIONS = 10000
|
|
10
|
+
const KEY_LENGTH = 32
|
|
11
|
+
|
|
12
|
+
function hashPassword(password, options = {}) {
|
|
13
|
+
if (typeof password !== 'string') throw new TypeError('Password must be a string')
|
|
14
|
+
|
|
15
|
+
const iterations = options.iterations || DEFAULT_ITERATIONS
|
|
16
|
+
const salt = options.salt || randomBytes(16).toString('hex')
|
|
17
|
+
const hash = derivePassword(password, salt, iterations).toString('hex')
|
|
18
|
+
return `pbkdf2$sha256$${iterations}$${salt}$${hash}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function verifyPassword(password, storedHash) {
|
|
22
|
+
if (typeof password !== 'string' || typeof storedHash !== 'string') return false
|
|
23
|
+
|
|
24
|
+
const parts = storedHash.split('$')
|
|
25
|
+
if (parts.length !== 5) return false
|
|
26
|
+
const [algorithm, digest, iterationsText, salt, expectedText] = parts
|
|
27
|
+
if (algorithm !== 'pbkdf2' || digest !== 'sha256') return false
|
|
28
|
+
if (!/^[0-9]+$/.test(iterationsText)) return false
|
|
29
|
+
if (!isEvenHex(salt) || !isEvenHex(expectedText)) return false
|
|
30
|
+
|
|
31
|
+
const iterations = Number(iterationsText)
|
|
32
|
+
if (!Number.isSafeInteger(iterations) || iterations < 1) return false
|
|
33
|
+
|
|
34
|
+
const expected = Buffer.from(expectedText, 'hex')
|
|
35
|
+
const actual = derivePassword(password, salt, iterations)
|
|
36
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hmacSha256(key, data) {
|
|
40
|
+
return createHmac('sha256', key).update(data).digest()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function derivePassword(password, salt, iterations) {
|
|
44
|
+
return pbkdf2Sync(password, Buffer.from(salt, 'hex'), iterations, KEY_LENGTH, 'sha256')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isEvenHex(value) {
|
|
48
|
+
return typeof value === 'string'
|
|
49
|
+
&& value.length > 0
|
|
50
|
+
&& value.length % 2 === 0
|
|
51
|
+
&& /^[a-f0-9]+$/i.test(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { hashPassword, hmacSha256, randomUUID, verifyPassword }
|
package/src/lib/jwt.cjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const { hmacSha256, randomUUID } = require('./crypto.cjs')
|
|
2
|
+
|
|
3
|
+
function signAccessToken(user, secret, { expiresInSeconds = 3600, sessionId = null, extraClaims = {} } = {}) {
|
|
4
|
+
const data = user && user._data ? user._data : user
|
|
5
|
+
if (!data || typeof data !== 'object') throw new TypeError('JWT user payload must be an object')
|
|
6
|
+
|
|
7
|
+
const now = Math.floor(Date.now() / 1000)
|
|
8
|
+
const id = sessionId || data.sessionId || randomUUID()
|
|
9
|
+
return sign({
|
|
10
|
+
typ: 'access',
|
|
11
|
+
sub: data._id,
|
|
12
|
+
userFamily: data.role,
|
|
13
|
+
username: data.username,
|
|
14
|
+
applications: data.applications || [],
|
|
15
|
+
authorisations: data.authorisations || {},
|
|
16
|
+
sessionId: id,
|
|
17
|
+
tokenVersion: data.tokenVersion || 0,
|
|
18
|
+
..._safeExtraClaims(extraClaims),
|
|
19
|
+
iat: now,
|
|
20
|
+
exp: now + expiresInSeconds
|
|
21
|
+
}, secret)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function signRefreshToken(user, secret, { expiresInSeconds = 1209600, sessionId = null, extraClaims = {} } = {}) {
|
|
25
|
+
const data = user && user._data ? user._data : user
|
|
26
|
+
if (!data || typeof data !== 'object') throw new TypeError('JWT user payload must be an object')
|
|
27
|
+
|
|
28
|
+
const now = Math.floor(Date.now() / 1000)
|
|
29
|
+
const id = sessionId || data.sessionId || randomUUID()
|
|
30
|
+
return sign({
|
|
31
|
+
typ: 'refresh',
|
|
32
|
+
sub: data._id,
|
|
33
|
+
userFamily: data.role,
|
|
34
|
+
username: data.username,
|
|
35
|
+
sessionId: id,
|
|
36
|
+
tokenVersion: data.tokenVersion || 0,
|
|
37
|
+
..._safeExtraClaims(extraClaims),
|
|
38
|
+
iat: now,
|
|
39
|
+
exp: now + expiresInSeconds
|
|
40
|
+
}, secret)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sign(payload, secret) {
|
|
44
|
+
_assertSecret(secret)
|
|
45
|
+
const header = { alg: 'HS256', typ: 'JWT' }
|
|
46
|
+
const unsigned = `${_encodeJson(header)}.${_encodeJson(payload)}`
|
|
47
|
+
return `${unsigned}.${_base64UrlEncodeBytes(hmacSha256(secret, unsigned))}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function verifyAccessToken(token, secret, options = {}) {
|
|
51
|
+
const payload = verify(token, secret, options)
|
|
52
|
+
if (payload.typ !== 'access') {
|
|
53
|
+
const err = new Error('JWT is not an access token')
|
|
54
|
+
err.code = 'E_INVALID_TOKEN'
|
|
55
|
+
throw err
|
|
56
|
+
}
|
|
57
|
+
return payload
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function verifyRefreshToken(token, secret, options = {}) {
|
|
61
|
+
const payload = verify(token, secret, options)
|
|
62
|
+
if (payload.typ !== 'refresh') {
|
|
63
|
+
const err = new Error('JWT is not a refresh token')
|
|
64
|
+
err.code = 'E_INVALID_TOKEN'
|
|
65
|
+
throw err
|
|
66
|
+
}
|
|
67
|
+
return payload
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function verify(token, secret, { clockToleranceSeconds = 0 } = {}) {
|
|
71
|
+
_assertSecret(secret)
|
|
72
|
+
if (typeof token !== 'string') {
|
|
73
|
+
const err = new Error('JWT must be a string')
|
|
74
|
+
err.code = 'E_INVALID_TOKEN'
|
|
75
|
+
throw err
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parts = token.split('.')
|
|
79
|
+
if (parts.length !== 3) {
|
|
80
|
+
const err = new Error('JWT must have three parts')
|
|
81
|
+
err.code = 'E_INVALID_TOKEN'
|
|
82
|
+
throw err
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const [headerPart, payloadPart, signaturePart] = parts
|
|
86
|
+
const header = _decodeJson(headerPart)
|
|
87
|
+
if (header.alg !== 'HS256' || header.typ !== 'JWT') {
|
|
88
|
+
const err = new Error('Unsupported JWT header')
|
|
89
|
+
err.code = 'E_INVALID_TOKEN'
|
|
90
|
+
throw err
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const unsigned = `${headerPart}.${payloadPart}`
|
|
94
|
+
const expected = _base64UrlEncodeBytes(hmacSha256(secret, unsigned))
|
|
95
|
+
if (!_constantTimeStringEqual(signaturePart, expected)) {
|
|
96
|
+
const err = new Error('Invalid JWT signature')
|
|
97
|
+
err.code = 'E_INVALID_TOKEN'
|
|
98
|
+
throw err
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const payload = _decodeJson(payloadPart)
|
|
102
|
+
const now = Math.floor(Date.now() / 1000)
|
|
103
|
+
if (typeof payload.exp !== 'number' || payload.exp + clockToleranceSeconds < now) {
|
|
104
|
+
const err = new Error('JWT expired')
|
|
105
|
+
err.code = 'E_TOKEN_EXPIRED'
|
|
106
|
+
throw err
|
|
107
|
+
}
|
|
108
|
+
if (payload.iat !== undefined && typeof payload.iat !== 'number') {
|
|
109
|
+
const err = new Error('Invalid JWT iat')
|
|
110
|
+
err.code = 'E_INVALID_TOKEN'
|
|
111
|
+
throw err
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return payload
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _assertSecret(secret) {
|
|
118
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
119
|
+
throw new TypeError('JWT secret must be a non-empty string')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _safeExtraClaims(extraClaims) {
|
|
124
|
+
if (!extraClaims || typeof extraClaims !== 'object' || Array.isArray(extraClaims)) return {}
|
|
125
|
+
const reserved = new Set([
|
|
126
|
+
'typ', 'sub', 'userFamily', 'username', 'applications', 'authorisations',
|
|
127
|
+
'sessionId', 'tokenVersion', 'iat', 'exp'
|
|
128
|
+
])
|
|
129
|
+
return Object.fromEntries(
|
|
130
|
+
Object.entries(extraClaims).filter(([key]) => !reserved.has(key))
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _encodeJson(value) {
|
|
135
|
+
return _base64UrlEncodeBytes(_utf8(JSON.stringify(value)))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _decodeJson(value) {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(_utf8Decode(_base64UrlDecodeBytes(value)))
|
|
141
|
+
} catch {
|
|
142
|
+
const err = new Error('Invalid JWT JSON')
|
|
143
|
+
err.code = 'E_INVALID_TOKEN'
|
|
144
|
+
throw err
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _base64UrlEncodeBytes(bytes) {
|
|
149
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
|
150
|
+
let out = ''
|
|
151
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
152
|
+
const a = bytes[i]
|
|
153
|
+
const b = bytes[i + 1] || 0
|
|
154
|
+
const c = bytes[i + 2] || 0
|
|
155
|
+
const triplet = (a << 16) | (b << 8) | c
|
|
156
|
+
out += alphabet[(triplet >>> 18) & 0x3f]
|
|
157
|
+
out += alphabet[(triplet >>> 12) & 0x3f]
|
|
158
|
+
if (i + 1 < bytes.length) out += alphabet[(triplet >>> 6) & 0x3f]
|
|
159
|
+
if (i + 2 < bytes.length) out += alphabet[triplet & 0x3f]
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _base64UrlDecodeBytes(value) {
|
|
165
|
+
if (typeof value !== 'string') throw new TypeError('base64url value must be a string')
|
|
166
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
|
167
|
+
const bytes = []
|
|
168
|
+
let buffer = 0
|
|
169
|
+
let bits = 0
|
|
170
|
+
|
|
171
|
+
for (const char of value) {
|
|
172
|
+
const idx = alphabet.indexOf(char)
|
|
173
|
+
if (idx < 0) throw new Error('Invalid base64url character')
|
|
174
|
+
buffer = (buffer << 6) | idx
|
|
175
|
+
bits += 6
|
|
176
|
+
if (bits >= 8) {
|
|
177
|
+
bits -= 8
|
|
178
|
+
bytes.push((buffer >>> bits) & 0xff)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return new Uint8Array(bytes)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _constantTimeStringEqual(a, b) {
|
|
186
|
+
let diff = a.length ^ b.length
|
|
187
|
+
const length = Math.max(a.length, b.length)
|
|
188
|
+
for (let i = 0; i < length; i++) {
|
|
189
|
+
diff |= (a.charCodeAt(i % a.length) || 0) ^ (b.charCodeAt(i % b.length) || 0)
|
|
190
|
+
}
|
|
191
|
+
return diff === 0
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _utf8(value) {
|
|
195
|
+
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value)
|
|
196
|
+
|
|
197
|
+
const out = []
|
|
198
|
+
for (let i = 0; i < value.length; i++) {
|
|
199
|
+
let code = value.charCodeAt(i)
|
|
200
|
+
if (code < 0x80) out.push(code)
|
|
201
|
+
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
|
|
202
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
203
|
+
const next = value.charCodeAt(++i)
|
|
204
|
+
code = 0x10000 + (((code & 0x3ff) << 10) | (next & 0x3ff))
|
|
205
|
+
out.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
|
|
206
|
+
} else {
|
|
207
|
+
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return new Uint8Array(out)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function _utf8Decode(bytes) {
|
|
214
|
+
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes)
|
|
215
|
+
|
|
216
|
+
let out = ''
|
|
217
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
218
|
+
const byte = bytes[i]
|
|
219
|
+
if (byte < 0x80) out += String.fromCharCode(byte)
|
|
220
|
+
else if (byte < 0xe0) out += String.fromCharCode(((byte & 0x1f) << 6) | (bytes[++i] & 0x3f))
|
|
221
|
+
else if (byte < 0xf0) out += String.fromCharCode(((byte & 0x0f) << 12) | ((bytes[++i] & 0x3f) << 6) | (bytes[++i] & 0x3f))
|
|
222
|
+
else {
|
|
223
|
+
const code = ((byte & 0x07) << 18) | ((bytes[++i] & 0x3f) << 12) | ((bytes[++i] & 0x3f) << 6) | (bytes[++i] & 0x3f)
|
|
224
|
+
const pair = code - 0x10000
|
|
225
|
+
out += String.fromCharCode(0xd800 + (pair >> 10), 0xdc00 + (pair & 0x3ff))
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { signAccessToken, signRefreshToken, sign, verifyAccessToken, verifyRefreshToken, verify }
|
package/src/local-wal.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { mkdir, open, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { DEFAULT_LOCAL_WAL_DIR } from "./config.js";
|
|
6
|
+
|
|
7
|
+
export class LocalWalStore {
|
|
8
|
+
constructor({ dir = DEFAULT_LOCAL_WAL_DIR, nodeId, fsync = true, flushMs = 0, maxRecordBytes = 4 * 1024 * 1024, maxOpsPerRecord = 1000 } = {}) {
|
|
9
|
+
if (!nodeId) throw new Error("local WAL node id is required");
|
|
10
|
+
this.dir = resolve(dir);
|
|
11
|
+
this.nodeId = String(nodeId);
|
|
12
|
+
this.fsync = fsync !== false;
|
|
13
|
+
this.flushMs = Math.max(0, Number(flushMs) || 0);
|
|
14
|
+
this.maxRecordBytes = Math.max(1024, Number(maxRecordBytes) || 4 * 1024 * 1024);
|
|
15
|
+
this.maxOpsPerRecord = Math.max(1, Number(maxOpsPerRecord) || 1000);
|
|
16
|
+
this.filePath = `${this.dir}/${safePathPart(this.nodeId)}/wal.jsonl`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async append(records = []) {
|
|
20
|
+
if (!records.length) return { records: 0, bytes: 0 };
|
|
21
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
22
|
+
const handle = await open(this.filePath, "a");
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
try {
|
|
25
|
+
for (const record of records) {
|
|
26
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
27
|
+
bytes += Buffer.byteLength(line);
|
|
28
|
+
await handle.write(line, undefined, "utf8");
|
|
29
|
+
}
|
|
30
|
+
if (this.fsync) await handle.sync();
|
|
31
|
+
} finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
return { records: records.length, bytes };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async readRecords() {
|
|
38
|
+
if (!await exists(this.filePath)) {
|
|
39
|
+
return { records: [], skippedPartialRecords: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const complete = await endsWithNewline(this.filePath);
|
|
43
|
+
const records = [];
|
|
44
|
+
let skippedPartialRecords = 0;
|
|
45
|
+
let previousLine = null;
|
|
46
|
+
let previousLineNo = 0;
|
|
47
|
+
let lineNo = 0;
|
|
48
|
+
const lines = createInterface({
|
|
49
|
+
input: createReadStream(this.filePath, { encoding: "utf8" }),
|
|
50
|
+
crlfDelay: Infinity,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
for await (const line of lines) {
|
|
54
|
+
if (previousLine !== null) pushRecord(records, parseLine(previousLine, previousLineNo, false));
|
|
55
|
+
previousLine = line;
|
|
56
|
+
previousLineNo = ++lineNo;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (previousLine !== null) {
|
|
60
|
+
try {
|
|
61
|
+
pushRecord(records, parseLine(previousLine, previousLineNo, !complete));
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (!complete) skippedPartialRecords++;
|
|
64
|
+
else throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { records, skippedPartialRecords };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async truncate() {
|
|
72
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
73
|
+
const handle = await open(this.filePath, "w");
|
|
74
|
+
try {
|
|
75
|
+
if (this.fsync) await handle.sync();
|
|
76
|
+
} finally {
|
|
77
|
+
await handle.close();
|
|
78
|
+
}
|
|
79
|
+
return { records: 0 };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseLine(line, lineNo, allowPartial) {
|
|
84
|
+
if (!line.trim()) return null;
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(line);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (allowPartial) throw err;
|
|
89
|
+
throw new Error(`failed to parse local WAL record at line ${lineNo}: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pushRecord(records, record) {
|
|
94
|
+
if (record) records.push(record);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function exists(path) {
|
|
98
|
+
try {
|
|
99
|
+
await stat(path);
|
|
100
|
+
return true;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err.code === "ENOENT") return false;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function endsWithNewline(path) {
|
|
108
|
+
const info = await stat(path);
|
|
109
|
+
if (info.size === 0) return true;
|
|
110
|
+
const handle = await open(path, "r");
|
|
111
|
+
try {
|
|
112
|
+
const buffer = Buffer.alloc(1);
|
|
113
|
+
await handle.read(buffer, 0, 1, info.size - 1);
|
|
114
|
+
return buffer[0] === 10;
|
|
115
|
+
} finally {
|
|
116
|
+
await handle.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safePathPart(value) {
|
|
121
|
+
return encodeURIComponent(String(value));
|
|
122
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { totalmem } from "node:os";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_REJECT_PERCENT = 85;
|
|
5
|
+
const DEFAULT_RESUME_PERCENT = 78;
|
|
6
|
+
const DEFAULT_CHECK_MS = 1000;
|
|
7
|
+
const RETRY_AFTER_SECONDS = 5;
|
|
8
|
+
|
|
9
|
+
export class MemoryGuard {
|
|
10
|
+
constructor(config = {}, deps = {}) {
|
|
11
|
+
this.enabled = config.enabled === true;
|
|
12
|
+
this.rejectWritesPercent = percent(
|
|
13
|
+
config.rejectWritesPercent ?? config.maxRssPercent,
|
|
14
|
+
DEFAULT_REJECT_PERCENT,
|
|
15
|
+
);
|
|
16
|
+
this.resumeWritesPercent = percent(config.resumeWritesPercent, Math.min(DEFAULT_RESUME_PERCENT, this.rejectWritesPercent));
|
|
17
|
+
if (this.resumeWritesPercent > this.rejectWritesPercent) {
|
|
18
|
+
throw new Error("memoryGuard.resumeWritesPercent must be <= memoryGuard.rejectWritesPercent");
|
|
19
|
+
}
|
|
20
|
+
this.checkMs = positiveInt(config.checkMs, DEFAULT_CHECK_MS);
|
|
21
|
+
this.getUsage = deps.getUsage || (() => process.memoryUsage());
|
|
22
|
+
this.now = deps.now || (() => Date.now());
|
|
23
|
+
const detected = deps.limitBytes
|
|
24
|
+
? { bytes: deps.limitBytes, source: "injected" }
|
|
25
|
+
: memoryLimit(config);
|
|
26
|
+
this.limitBytes = detected.bytes;
|
|
27
|
+
this.limitSource = detected.source;
|
|
28
|
+
this.acceptingWrites = true;
|
|
29
|
+
this.lastSample = null;
|
|
30
|
+
this.lastSampleAt = 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
status({ force = false } = {}) {
|
|
34
|
+
if (!this.enabled) {
|
|
35
|
+
const sample = this._sample(force);
|
|
36
|
+
return {
|
|
37
|
+
enabled: false,
|
|
38
|
+
acceptingWrites: true,
|
|
39
|
+
writeState: "accepting",
|
|
40
|
+
...sample,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sample = this._sample(force);
|
|
45
|
+
if (this.acceptingWrites && sample.rssPercent >= this.rejectWritesPercent) {
|
|
46
|
+
this.acceptingWrites = false;
|
|
47
|
+
} else if (!this.acceptingWrites && sample.rssPercent <= this.resumeWritesPercent) {
|
|
48
|
+
this.acceptingWrites = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
enabled: true,
|
|
53
|
+
acceptingWrites: this.acceptingWrites,
|
|
54
|
+
writeState: this.acceptingWrites ? "accepting" : "rejecting",
|
|
55
|
+
rejectWritesPercent: this.rejectWritesPercent,
|
|
56
|
+
resumeWritesPercent: this.resumeWritesPercent,
|
|
57
|
+
checkMs: this.checkMs,
|
|
58
|
+
...sample,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
writeRejection() {
|
|
63
|
+
const status = this.status({ force: true });
|
|
64
|
+
if (status.acceptingWrites) return null;
|
|
65
|
+
return {
|
|
66
|
+
status: 507,
|
|
67
|
+
headers: { "retry-after": String(RETRY_AFTER_SECONDS) },
|
|
68
|
+
body: {
|
|
69
|
+
error: "peer memory limit exceeded",
|
|
70
|
+
rssMb: status.rssMb,
|
|
71
|
+
limitMb: status.limitMb,
|
|
72
|
+
rssPercent: status.rssPercent,
|
|
73
|
+
writeState: status.writeState,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
assertCanWrite() {
|
|
79
|
+
const rejection = this.writeRejection();
|
|
80
|
+
if (!rejection) return true;
|
|
81
|
+
const err = new Error(rejection.body.error);
|
|
82
|
+
err.status = rejection.status;
|
|
83
|
+
err.body = rejection.body;
|
|
84
|
+
err.headers = rejection.headers;
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_sample(force = false) {
|
|
89
|
+
const at = this.now();
|
|
90
|
+
if (!force && this.lastSample && at - this.lastSampleAt < this.checkMs) return this.lastSample;
|
|
91
|
+
const usage = this.getUsage();
|
|
92
|
+
const rss = Number(usage.rss) || 0;
|
|
93
|
+
const limit = this.limitBytes || 1;
|
|
94
|
+
this.lastSample = {
|
|
95
|
+
rss,
|
|
96
|
+
rssMb: mib(rss),
|
|
97
|
+
limitBytes: limit,
|
|
98
|
+
limitMb: mib(limit),
|
|
99
|
+
limitSource: this.limitSource,
|
|
100
|
+
rssPercent: Math.round((rss / limit) * 10_000) / 100,
|
|
101
|
+
};
|
|
102
|
+
this.lastSampleAt = at;
|
|
103
|
+
return this.lastSample;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createMemoryGuard(config = {}, deps = {}) {
|
|
108
|
+
if (config instanceof MemoryGuard) return config;
|
|
109
|
+
return new MemoryGuard(config || {}, deps);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function memoryLimit(config = {}) {
|
|
113
|
+
if (Number.isFinite(config.limitBytes) && config.limitBytes > 0) {
|
|
114
|
+
return { bytes: Math.floor(config.limitBytes), source: "config" };
|
|
115
|
+
}
|
|
116
|
+
if (Number.isFinite(config.limitMb) && config.limitMb > 0) {
|
|
117
|
+
return { bytes: Math.floor(config.limitMb * 1024 * 1024), source: "config" };
|
|
118
|
+
}
|
|
119
|
+
return detectedMemoryLimit();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function detectedMemoryLimit() {
|
|
123
|
+
const host = totalmem();
|
|
124
|
+
const cgroup = cgroupMemoryLimit();
|
|
125
|
+
if (cgroup && cgroup < host) return { bytes: cgroup, source: "cgroup" };
|
|
126
|
+
return { bytes: host, source: "os" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cgroupMemoryLimit() {
|
|
130
|
+
for (const file of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) {
|
|
131
|
+
if (!existsSync(file)) continue;
|
|
132
|
+
const raw = readFileSync(file, "utf8").trim();
|
|
133
|
+
if (!raw || raw === "max") continue;
|
|
134
|
+
const value = Number(raw);
|
|
135
|
+
if (Number.isFinite(value) && value > 0 && value < Number.MAX_SAFE_INTEGER) return Math.floor(value);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function percent(value, fallback) {
|
|
141
|
+
const n = Number(value);
|
|
142
|
+
return Number.isFinite(n) && n >= 0 && n <= 100 ? n : fallback;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function positiveInt(value, fallback) {
|
|
146
|
+
const n = Number(value);
|
|
147
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mib(bytes) {
|
|
151
|
+
return Math.round((bytes / 1024 / 1024) * 10) / 10;
|
|
152
|
+
}
|