@juspay/neurolink 12.32.2 → 12.32.3
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 +2 -3
- package/dist/browser/neurolink.min.js +394 -394
- package/dist/proxy/accountCooldown.js +34 -19
- package/dist/proxy/accountQuota.js +64 -23
- package/package.json +1 -1
|
@@ -60,22 +60,27 @@ function sanitizePersistedCooldown(accountKey, entry) {
|
|
|
60
60
|
`${hours(ceiling)}h — the stored wait exceeded what "${entry.reason}" can mean`);
|
|
61
61
|
return { ...entry, coolingUntil: latest };
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the cooldown file as it is now. A missing file is empty; any other
|
|
65
|
+
* failure (unreadable, corrupt) returns `fallback`, so a write never discards
|
|
66
|
+
* entries just because one read failed.
|
|
67
|
+
*/
|
|
68
|
+
async function readCooldownsFromDisk(fallback) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
|
|
71
|
+
return Object.fromEntries(Object.entries(parsed)
|
|
72
|
+
.filter((entry) => isPersistedCooldown(entry[1]))
|
|
73
|
+
.map(([key, entry]) => [key, sanitizePersistedCooldown(key, entry)]));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return error.code === "ENOENT" ? {} : fallback;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
63
79
|
async function ensureAccountCooldownsLoaded() {
|
|
64
80
|
if (!cacheLoaded) {
|
|
65
81
|
if (!cacheLoadPromise) {
|
|
66
82
|
cacheLoadPromise = (async () => {
|
|
67
|
-
|
|
68
|
-
const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
|
|
69
|
-
memoryCache = Object.fromEntries(Object.entries(parsed)
|
|
70
|
-
.filter((entry) => isPersistedCooldown(entry[1]))
|
|
71
|
-
.map(([key, entry]) => [
|
|
72
|
-
key,
|
|
73
|
-
sanitizePersistedCooldown(key, entry),
|
|
74
|
-
]));
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
memoryCache = {};
|
|
78
|
-
}
|
|
83
|
+
memoryCache = await readCooldownsFromDisk({});
|
|
79
84
|
cacheLoaded = true;
|
|
80
85
|
})().finally(() => {
|
|
81
86
|
cacheLoadPromise = null;
|
|
@@ -91,14 +96,18 @@ export async function loadAccountCooldowns() {
|
|
|
91
96
|
export async function saveAccountCooldown(accountKey, coolingUntil, reason) {
|
|
92
97
|
await mutationMutex.runExclusive(async () => {
|
|
93
98
|
await ensureAccountCooldownsLoaded();
|
|
94
|
-
|
|
99
|
+
// Another worker (a draining one during a rolling restart) may have written
|
|
100
|
+
// the file since this one loaded it, so the change folds onto the file as
|
|
101
|
+
// it is now rather than onto this worker's copy.
|
|
102
|
+
const onDisk = await readCooldownsFromDisk(memoryCache);
|
|
103
|
+
memoryCache = onDisk;
|
|
104
|
+
const current = onDisk[accountKey];
|
|
95
105
|
if (current && current.coolingUntil > coolingUntil) {
|
|
96
106
|
return;
|
|
97
107
|
}
|
|
98
|
-
memoryCache
|
|
99
|
-
|
|
100
|
-
reason,
|
|
101
|
-
updatedAt: Date.now(),
|
|
108
|
+
memoryCache = {
|
|
109
|
+
...onDisk,
|
|
110
|
+
[accountKey]: { coolingUntil, reason, updatedAt: Date.now() },
|
|
102
111
|
};
|
|
103
112
|
await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
|
|
104
113
|
});
|
|
@@ -106,7 +115,11 @@ export async function saveAccountCooldown(accountKey, coolingUntil, reason) {
|
|
|
106
115
|
export async function clearAccountCooldown(accountKey, expectedCoolingUntil) {
|
|
107
116
|
await mutationMutex.runExclusive(async () => {
|
|
108
117
|
await ensureAccountCooldownsLoaded();
|
|
109
|
-
|
|
118
|
+
// Compare against the file, not this worker's copy: another worker may
|
|
119
|
+
// have extended this cooldown since, and that one must survive.
|
|
120
|
+
const onDisk = await readCooldownsFromDisk(memoryCache);
|
|
121
|
+
memoryCache = onDisk;
|
|
122
|
+
const current = onDisk[accountKey];
|
|
110
123
|
if (!current) {
|
|
111
124
|
return;
|
|
112
125
|
}
|
|
@@ -114,7 +127,9 @@ export async function clearAccountCooldown(accountKey, expectedCoolingUntil) {
|
|
|
114
127
|
current.coolingUntil !== expectedCoolingUntil) {
|
|
115
128
|
return;
|
|
116
129
|
}
|
|
117
|
-
|
|
130
|
+
const next = { ...onDisk };
|
|
131
|
+
delete next[accountKey];
|
|
132
|
+
memoryCache = next;
|
|
118
133
|
await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
|
|
119
134
|
});
|
|
120
135
|
}
|
|
@@ -308,8 +308,8 @@ const FLUSH_INTERVAL_MS = 5_000; // write to disk at most every 5 seconds
|
|
|
308
308
|
let memoryCache = {};
|
|
309
309
|
let cacheLoaded = false;
|
|
310
310
|
let cacheLoadPromise = null;
|
|
311
|
-
|
|
312
|
-
let
|
|
311
|
+
/** Accounts this worker changed since its last flush. */
|
|
312
|
+
let dirtyKeys = new Set();
|
|
313
313
|
let flushTimer = null;
|
|
314
314
|
const stateMutex = new AsyncMutex();
|
|
315
315
|
const flushMutex = new AsyncMutex();
|
|
@@ -332,38 +332,86 @@ export function initAccountQuota(quotaFilePath) {
|
|
|
332
332
|
memoryCache = {};
|
|
333
333
|
cacheLoaded = false;
|
|
334
334
|
cacheLoadPromise = null;
|
|
335
|
-
|
|
336
|
-
cacheVersion = 0;
|
|
335
|
+
dirtyKeys = new Set();
|
|
337
336
|
}
|
|
338
337
|
function getQuotaFilePath() {
|
|
339
338
|
return customQuotaFilePath ?? join(homedir(), ".neurolink", QUOTA_FILE);
|
|
340
339
|
}
|
|
341
|
-
/**
|
|
340
|
+
/**
|
|
341
|
+
* Read the quota file as it is now. A missing file is empty; any other failure
|
|
342
|
+
* (unreadable, corrupt) returns `fallback`, so a flush never discards entries
|
|
343
|
+
* just because one read failed.
|
|
344
|
+
*/
|
|
345
|
+
async function readQuotasFromDisk(filePath, fallback) {
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse(await fs.readFile(filePath, "utf-8"));
|
|
348
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
349
|
+
return fallback;
|
|
350
|
+
}
|
|
351
|
+
return parsed;
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
return error.code === "ENOENT" ? {} : fallback;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** Fold two snapshots of one account so the more recently updated one wins,
|
|
358
|
+
* whichever worker wrote it. */
|
|
359
|
+
function foldNewerQuota(onDisk, ours) {
|
|
360
|
+
if (!onDisk) {
|
|
361
|
+
return ours;
|
|
362
|
+
}
|
|
363
|
+
return onDisk.lastUpdated > ours.lastUpdated
|
|
364
|
+
? mergeQuotaSnapshot(ours, onDisk)
|
|
365
|
+
: mergeQuotaSnapshot(onDisk, ours);
|
|
366
|
+
}
|
|
367
|
+
/** Flush this worker's changed accounts to disk (async, non-blocking). */
|
|
342
368
|
async function flushToDisk() {
|
|
343
369
|
await flushMutex.runExclusive(async () => {
|
|
344
|
-
let
|
|
345
|
-
let
|
|
370
|
+
let changed;
|
|
371
|
+
let fallback = {};
|
|
346
372
|
let filePath = "";
|
|
347
373
|
await stateMutex.runExclusive(async () => {
|
|
348
|
-
if (
|
|
374
|
+
if (dirtyKeys.size === 0) {
|
|
349
375
|
return;
|
|
350
376
|
}
|
|
351
|
-
|
|
352
|
-
|
|
377
|
+
changed = Object.fromEntries([...dirtyKeys].map((key) => [key, { ...memoryCache[key] }]));
|
|
378
|
+
fallback = Object.fromEntries(Object.entries(memoryCache).map(([key, quota]) => [key, { ...quota }]));
|
|
379
|
+
dirtyKeys = new Set();
|
|
353
380
|
filePath = getQuotaFilePath();
|
|
354
381
|
});
|
|
355
|
-
|
|
382
|
+
const flushing = changed;
|
|
383
|
+
if (!flushing) {
|
|
356
384
|
return;
|
|
357
385
|
}
|
|
358
386
|
try {
|
|
359
|
-
|
|
387
|
+
// Another worker (a draining one during a rolling restart) may have
|
|
388
|
+
// written the file since this one loaded it: fold only this worker's
|
|
389
|
+
// changes onto the file as it is now, never rewrite it from this copy.
|
|
390
|
+
const onDisk = await readQuotasFromDisk(filePath, fallback);
|
|
391
|
+
const merged = { ...onDisk };
|
|
392
|
+
for (const [key, quota] of Object.entries(flushing)) {
|
|
393
|
+
merged[key] = foldNewerQuota(onDisk[key], quota);
|
|
394
|
+
}
|
|
395
|
+
await writeJsonSnapshotAtomically(filePath, merged, 0o600);
|
|
360
396
|
await stateMutex.runExclusive(async () => {
|
|
361
|
-
if (
|
|
362
|
-
|
|
397
|
+
if (getQuotaFilePath() !== filePath) {
|
|
398
|
+
return;
|
|
363
399
|
}
|
|
400
|
+
// Adopt what other workers wrote, keeping changes saved meanwhile.
|
|
401
|
+
memoryCache = {
|
|
402
|
+
...merged,
|
|
403
|
+
...Object.fromEntries([...dirtyKeys].map((key) => [key, memoryCache[key]])),
|
|
404
|
+
};
|
|
364
405
|
});
|
|
365
406
|
}
|
|
366
407
|
catch {
|
|
408
|
+
await stateMutex.runExclusive(async () => {
|
|
409
|
+
if (getQuotaFilePath() === filePath) {
|
|
410
|
+
for (const key of Object.keys(flushing)) {
|
|
411
|
+
dirtyKeys.add(key);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
});
|
|
367
415
|
// Non-fatal — quota is best-effort telemetry
|
|
368
416
|
}
|
|
369
417
|
});
|
|
@@ -394,13 +442,7 @@ async function ensureAccountQuotasLoaded() {
|
|
|
394
442
|
if (!cacheLoaded) {
|
|
395
443
|
if (!cacheLoadPromise) {
|
|
396
444
|
cacheLoadPromise = (async () => {
|
|
397
|
-
|
|
398
|
-
const raw = await fs.readFile(getQuotaFilePath(), "utf-8");
|
|
399
|
-
memoryCache = JSON.parse(raw);
|
|
400
|
-
}
|
|
401
|
-
catch {
|
|
402
|
-
memoryCache = {};
|
|
403
|
-
}
|
|
445
|
+
memoryCache = await readQuotasFromDisk(getQuotaFilePath(), {});
|
|
404
446
|
cacheLoaded = true;
|
|
405
447
|
})().finally(() => {
|
|
406
448
|
cacheLoadPromise = null;
|
|
@@ -436,8 +478,7 @@ export async function saveAccountQuota(accountKey, quota) {
|
|
|
436
478
|
// A header capture reports only the windows the served request touched, so
|
|
437
479
|
// it must fold onto the existing snapshot rather than replace it.
|
|
438
480
|
memoryCache[accountKey] = mergeQuotaSnapshot(memoryCache[accountKey], quota);
|
|
439
|
-
|
|
440
|
-
cacheVersion += 1;
|
|
481
|
+
dirtyKeys.add(accountKey);
|
|
441
482
|
});
|
|
442
483
|
scheduleFlush();
|
|
443
484
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.32.
|
|
3
|
+
"version": "12.32.3",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
|
|
6
6
|
"author": {
|