@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.
Files changed (55) hide show
  1. package/API.md +1469 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +9 -0
  4. package/README.md +21 -2
  5. package/objydb.config.json +48 -0
  6. package/package.json +42 -4
  7. package/public/index.html +4134 -0
  8. package/public/objy-db-logo.png +0 -0
  9. package/src/aggregate.js +438 -0
  10. package/src/api.js +15 -0
  11. package/src/auth.js +472 -0
  12. package/src/clock.js +18 -0
  13. package/src/cluster-secret.js +25 -0
  14. package/src/cluster-status.js +562 -0
  15. package/src/compression-stats.js +32 -0
  16. package/src/config.js +360 -0
  17. package/src/crdt.js +1076 -0
  18. package/src/derived-state-scheduler.js +128 -0
  19. package/src/files.js +324 -0
  20. package/src/hnsw.js +269 -0
  21. package/src/http-cors.js +31 -0
  22. package/src/http-router.js +162 -0
  23. package/src/index.js +137 -0
  24. package/src/interfaces/auth.js +123 -0
  25. package/src/interfaces/cluster.js +296 -0
  26. package/src/interfaces/public-object.js +2452 -0
  27. package/src/interfaces/ui.js +38 -0
  28. package/src/lib/crypto.cjs +54 -0
  29. package/src/lib/jwt.cjs +231 -0
  30. package/src/local-wal.js +122 -0
  31. package/src/memory-guard.js +152 -0
  32. package/src/ndjson.js +117 -0
  33. package/src/node-runtime.js +29 -0
  34. package/src/object-hooks.js +282 -0
  35. package/src/object-id.js +7 -0
  36. package/src/object-storage.js +263 -0
  37. package/src/object-store.js +288 -0
  38. package/src/patch.js +87 -0
  39. package/src/permissions.js +178 -0
  40. package/src/persistence.js +1918 -0
  41. package/src/plugin-loader.js +21 -0
  42. package/src/query-planner.js +266 -0
  43. package/src/query.js +416 -0
  44. package/src/references.js +349 -0
  45. package/src/replication-scheduler.js +303 -0
  46. package/src/restore-status.js +77 -0
  47. package/src/runtime.js +47 -0
  48. package/src/server.js +242 -0
  49. package/src/shape.js +286 -0
  50. package/src/shard-map-store.js +85 -0
  51. package/src/shard-map.js +333 -0
  52. package/src/shard-router.js +737 -0
  53. package/src/store.js +3410 -0
  54. package/src/system-secrets.js +107 -0
  55. 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 }
@@ -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 }
@@ -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
+ }