@getstrata/core 1.1.9 → 2.0.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 +19 -0
- package/dist/core/database/transaction.d.ts +2 -1
- package/dist/core/database/transactionControl.d.ts +5 -0
- package/dist/core/http/loginThrottleMiddleware.d.ts +3 -0
- package/dist/core/http/metricsMiddleware.d.ts +1 -2
- package/dist/core/http/requestMetaContext.d.ts +2 -0
- package/dist/core/http/throttleMiddleware.d.ts +11 -2
- package/dist/core/metrics/prometheus.d.ts +11 -1
- package/dist/core/queue/jobRunner.d.ts +3 -1
- package/dist/core/queue/redisQueue.d.ts +20 -3
- package/dist/core/tenant/databaseTenantContext.d.ts +5 -1
- package/dist/entries/audit/exportAuditLogs.js +522 -62
- package/dist/entries/auth/basicAuthGuard.js +245 -219
- package/dist/entries/auth/oauth/samlProvider.js +185 -161
- package/dist/entries/auth/oneTimeToken.js +130 -127
- package/dist/entries/auth/passwordLogin.js +185 -180
- package/dist/entries/auth/saml/samlServiceProvider.js +185 -161
- package/dist/entries/auth/scimAuthMiddleware.js +525 -11
- package/dist/entries/database/migrations.js +132 -129
- package/dist/entries/database/schema.js +58 -39
- package/dist/entries/http/metricsMiddleware.js +124 -27
- package/dist/entries/http/response.js +107 -0
- package/dist/entries/http/routeMiddleware.js +40 -0
- package/dist/entries/http/throttleMiddleware.js +124 -11
- package/dist/entries/jobs/exportAuditLogsJob.js +522 -62
- package/dist/entries/metrics/prometheus.js +71 -11
- package/dist/entries/queue/createAppQueue.js +215 -21
- package/dist/entries/queue/jobRunner.js +3 -2
- package/dist/entries/queue/publicQueue.js +215 -21
- package/dist/entries/queue/queueMetrics.js +215 -21
- package/dist/entries/queue/redisQueue.js +223 -22
- package/dist/entries/tenant/databaseTenantContext.js +477 -16
- package/dist/entries/tenant/tenantDatabaseScope.js +523 -9
- package/dist/framework/public-api.d.ts +2 -2
- package/dist/index.js +954 -457
- package/package.json +1 -1
|
@@ -149,7 +149,7 @@ var queueConfig = {
|
|
|
149
149
|
};
|
|
150
150
|
|
|
151
151
|
// ../../src/core/queue/jobRunner.ts
|
|
152
|
-
async function runQueueJob(envelope, failedJobs) {
|
|
152
|
+
async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
153
153
|
const job = jobRegistry.create(envelope.name);
|
|
154
154
|
if (!job) {
|
|
155
155
|
throw new Error(`Unknown job "${envelope.name}".`);
|
|
@@ -166,7 +166,7 @@ async function runQueueJob(envelope, failedJobs) {
|
|
|
166
166
|
await runQueueJob({
|
|
167
167
|
...envelope,
|
|
168
168
|
attempts: nextAttempt
|
|
169
|
-
}, failedJobs);
|
|
169
|
+
}, failedJobs, options);
|
|
170
170
|
return;
|
|
171
171
|
}
|
|
172
172
|
await failedJobs.recordFailure({
|
|
@@ -174,6 +174,7 @@ async function runQueueJob(envelope, failedJobs) {
|
|
|
174
174
|
payload: envelope.payload,
|
|
175
175
|
exception: error instanceof Error ? error.stack ?? error.message : String(error)
|
|
176
176
|
});
|
|
177
|
+
options.onFailureRecorded?.();
|
|
177
178
|
throw error;
|
|
178
179
|
}
|
|
179
180
|
}
|
|
@@ -192,6 +193,30 @@ var QUEUE_LIST_KEY = queueListKey();
|
|
|
192
193
|
var QUEUE_HIGH_KEY = queueHighKey();
|
|
193
194
|
var QUEUE_LOW_KEY = queueLowKey();
|
|
194
195
|
var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
|
|
196
|
+
var DEFAULT_VISIBILITY_MS = 60000;
|
|
197
|
+
function queueProcessingKey(queueKey) {
|
|
198
|
+
return `${queueKey}:processing`;
|
|
199
|
+
}
|
|
200
|
+
function queueProcessingLeaseKey(queueKey) {
|
|
201
|
+
return `${queueKey}:processing:leases`;
|
|
202
|
+
}
|
|
203
|
+
function readVisibilityMs() {
|
|
204
|
+
const parsed = Number(process.env.QUEUE_VISIBILITY_MS ?? String(DEFAULT_VISIBILITY_MS));
|
|
205
|
+
if (!Number.isSafeInteger(parsed) || parsed < 30) {
|
|
206
|
+
throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
|
|
207
|
+
}
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
function reservationExpired(lease, now, visibilityMs) {
|
|
211
|
+
if (lease === null || lease.length === 0) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
const reservedAt = Number(lease);
|
|
215
|
+
if (!Number.isFinite(reservedAt)) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
return now - reservedAt >= visibilityMs;
|
|
219
|
+
}
|
|
195
220
|
function queueKeyForPriority(priority = "default") {
|
|
196
221
|
switch (priority) {
|
|
197
222
|
case "high":
|
|
@@ -233,6 +258,129 @@ function parseQueueJobEnvelope(rawPayload) {
|
|
|
233
258
|
attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
|
|
234
259
|
};
|
|
235
260
|
}
|
|
261
|
+
var SCRIPT_HEADER = `
|
|
262
|
+
local function checkType(key, expected)
|
|
263
|
+
local actual = redis.call('TYPE', key).ok
|
|
264
|
+
if actual ~= 'none' and actual ~= expected then
|
|
265
|
+
return redis.error_reply('Unexpected queue key type: ' .. key)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
local function nowMs()
|
|
269
|
+
local time = redis.call('TIME')
|
|
270
|
+
return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
271
|
+
end
|
|
272
|
+
`;
|
|
273
|
+
var RESERVE_JOB = `${SCRIPT_HEADER}
|
|
274
|
+
local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
|
|
275
|
+
if err then return err end
|
|
276
|
+
local payload = redis.call('LINDEX', KEYS[1], -1)
|
|
277
|
+
if not payload then return nil end
|
|
278
|
+
local record = cjson.encode({strataReservation=1, id=ARGV[1], owner=ARGV[2], payload=payload})
|
|
279
|
+
local lease = cjson.encode({owner=ARGV[2], expiresAt=nowMs()+tonumber(ARGV[3])})
|
|
280
|
+
redis.call('LPUSH', KEYS[2], record)
|
|
281
|
+
redis.call('HSET', KEYS[3], ARGV[1], lease)
|
|
282
|
+
redis.call('RPOP', KEYS[1])
|
|
283
|
+
return record
|
|
284
|
+
`;
|
|
285
|
+
var RENEW_OR_ACK = `${SCRIPT_HEADER}
|
|
286
|
+
local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'hash') or checkType(KEYS[3], 'list')
|
|
287
|
+
if err then return err end
|
|
288
|
+
local raw = redis.call('HGET', KEYS[2], ARGV[1])
|
|
289
|
+
if not raw then return 0 end
|
|
290
|
+
local ok, lease = pcall(cjson.decode, raw)
|
|
291
|
+
local now = nowMs()
|
|
292
|
+
if not ok or type(lease) ~= 'table' or lease.owner ~= ARGV[2] or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now then
|
|
293
|
+
return 0
|
|
294
|
+
end
|
|
295
|
+
if ARGV[4] == 'renew' then
|
|
296
|
+
redis.call('HSET', KEYS[2], ARGV[1], cjson.encode({owner=ARGV[2], expiresAt=now+tonumber(ARGV[5])}))
|
|
297
|
+
return 1
|
|
298
|
+
end
|
|
299
|
+
if ARGV[4] == 'quarantine' then redis.call('LPUSH', KEYS[3], ARGV[6]) end
|
|
300
|
+
local removed = redis.call('LREM', KEYS[1], 1, ARGV[3])
|
|
301
|
+
redis.call('HDEL', KEYS[2], ARGV[1])
|
|
302
|
+
return removed
|
|
303
|
+
`;
|
|
304
|
+
var RECLAIM_JOBS = `${SCRIPT_HEADER}
|
|
305
|
+
local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
|
|
306
|
+
if err then return err end
|
|
307
|
+
local now = nowMs()
|
|
308
|
+
local reclaimed = 0
|
|
309
|
+
for _, record in ipairs(redis.call('LRANGE', KEYS[2], 0, -1)) do
|
|
310
|
+
local payload, field = record, record
|
|
311
|
+
local modern = false
|
|
312
|
+
local ok, reservation = pcall(cjson.decode, record)
|
|
313
|
+
if ok and type(reservation) == 'table' and reservation.strataReservation == 1 then
|
|
314
|
+
payload, field, modern = reservation.payload, reservation.id, true
|
|
315
|
+
end
|
|
316
|
+
local raw = redis.call('HGET', KEYS[3], field)
|
|
317
|
+
local expired = true
|
|
318
|
+
if raw then
|
|
319
|
+
if modern then
|
|
320
|
+
local leaseOk, lease = pcall(cjson.decode, raw)
|
|
321
|
+
expired = not leaseOk or type(lease) ~= 'table' or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now
|
|
322
|
+
else
|
|
323
|
+
local reservedAt = tonumber(raw)
|
|
324
|
+
expired = not reservedAt or now - reservedAt >= tonumber(ARGV[1])
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
if expired then
|
|
328
|
+
redis.call('LPUSH', KEYS[1], payload)
|
|
329
|
+
redis.call('LREM', KEYS[2], 1, record)
|
|
330
|
+
redis.call('HDEL', KEYS[3], field)
|
|
331
|
+
reclaimed = reclaimed + 1
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
return reclaimed
|
|
335
|
+
`;
|
|
336
|
+
function queueInvalidKey(queueKey) {
|
|
337
|
+
return `${queueKey}:invalid`;
|
|
338
|
+
}
|
|
339
|
+
async function reserveQueueJob(client, queueKey, owner) {
|
|
340
|
+
const raw = await client.send("EVAL", [
|
|
341
|
+
RESERVE_JOB,
|
|
342
|
+
"3",
|
|
343
|
+
queueKey,
|
|
344
|
+
queueProcessingKey(queueKey),
|
|
345
|
+
queueProcessingLeaseKey(queueKey),
|
|
346
|
+
crypto.randomUUID(),
|
|
347
|
+
owner,
|
|
348
|
+
String(readVisibilityMs())
|
|
349
|
+
]);
|
|
350
|
+
if (typeof raw !== "string")
|
|
351
|
+
return null;
|
|
352
|
+
const reservation = JSON.parse(raw);
|
|
353
|
+
return { ...reservation, record: raw };
|
|
354
|
+
}
|
|
355
|
+
async function updateQueueReservation(client, queueKey, reservation, action) {
|
|
356
|
+
return Number(await client.send("EVAL", [
|
|
357
|
+
RENEW_OR_ACK,
|
|
358
|
+
"3",
|
|
359
|
+
queueProcessingKey(queueKey),
|
|
360
|
+
queueProcessingLeaseKey(queueKey),
|
|
361
|
+
queueInvalidKey(queueKey),
|
|
362
|
+
reservation.id,
|
|
363
|
+
reservation.owner,
|
|
364
|
+
reservation.record,
|
|
365
|
+
action,
|
|
366
|
+
String(readVisibilityMs()),
|
|
367
|
+
reservation.payload
|
|
368
|
+
])) === 1;
|
|
369
|
+
}
|
|
370
|
+
async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
|
|
371
|
+
let reclaimed = 0;
|
|
372
|
+
for (const key of queueKeys) {
|
|
373
|
+
reclaimed += Number(await client.send("EVAL", [
|
|
374
|
+
RECLAIM_JOBS,
|
|
375
|
+
"3",
|
|
376
|
+
key,
|
|
377
|
+
queueProcessingKey(key),
|
|
378
|
+
queueProcessingLeaseKey(key),
|
|
379
|
+
String(readVisibilityMs())
|
|
380
|
+
]));
|
|
381
|
+
}
|
|
382
|
+
return reclaimed;
|
|
383
|
+
}
|
|
236
384
|
|
|
237
385
|
class RedisQueue {
|
|
238
386
|
client;
|
|
@@ -257,12 +405,15 @@ class RedisQueue {
|
|
|
257
405
|
class QueueWorker {
|
|
258
406
|
failedJobs;
|
|
259
407
|
timeoutSeconds;
|
|
408
|
+
queueKeys;
|
|
409
|
+
owner = crypto.randomUUID();
|
|
260
410
|
running = false;
|
|
261
411
|
stopping = false;
|
|
262
412
|
client;
|
|
263
|
-
constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
|
|
413
|
+
constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = QUEUE_KEYS) {
|
|
264
414
|
this.failedJobs = failedJobs;
|
|
265
415
|
this.timeoutSeconds = timeoutSeconds;
|
|
416
|
+
this.queueKeys = queueKeys;
|
|
266
417
|
this.client = new RedisClient(redisUrl);
|
|
267
418
|
}
|
|
268
419
|
requestStop() {
|
|
@@ -271,38 +422,81 @@ class QueueWorker {
|
|
|
271
422
|
isRunning() {
|
|
272
423
|
return this.running;
|
|
273
424
|
}
|
|
274
|
-
async
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
425
|
+
async reserve() {
|
|
426
|
+
const deadline = performance.now() + this.timeoutSeconds * 1000;
|
|
427
|
+
do {
|
|
428
|
+
for (const key of this.queueKeys) {
|
|
429
|
+
if (this.stopping)
|
|
430
|
+
return null;
|
|
431
|
+
const reservation = await reserveQueueJob(this.client, key, this.owner);
|
|
432
|
+
if (reservation)
|
|
433
|
+
return { queueKey: key, reservation };
|
|
280
434
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
435
|
+
if (this.stopping || performance.now() >= deadline)
|
|
436
|
+
return null;
|
|
437
|
+
await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
|
|
438
|
+
} while (!this.stopping);
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
async processNext() {
|
|
442
|
+
if (this.stopping)
|
|
286
443
|
return false;
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
444
|
+
await reclaimExpiredQueueReservations(this.client, this.queueKeys);
|
|
445
|
+
const reserved = await this.reserve();
|
|
446
|
+
if (!reserved)
|
|
447
|
+
return false;
|
|
448
|
+
const { queueKey, reservation } = reserved;
|
|
449
|
+
const envelope = parseQueueJobEnvelope(reservation.payload);
|
|
290
450
|
if (!envelope) {
|
|
451
|
+
await updateQueueReservation(this.client, queueKey, reservation, "quarantine");
|
|
291
452
|
return true;
|
|
292
453
|
}
|
|
454
|
+
let acknowledge = false;
|
|
455
|
+
let ownershipLost = false;
|
|
456
|
+
let renewal;
|
|
457
|
+
const heartbeat = setInterval(() => {
|
|
458
|
+
if (renewal || ownershipLost)
|
|
459
|
+
return;
|
|
460
|
+
renewal = (async () => {
|
|
461
|
+
try {
|
|
462
|
+
if (!await updateQueueReservation(this.client, queueKey, reservation, "renew")) {
|
|
463
|
+
ownershipLost = true;
|
|
464
|
+
}
|
|
465
|
+
} catch (error) {
|
|
466
|
+
ownershipLost = true;
|
|
467
|
+
console.error("[QueueWorker] Lease renewal failed:", error);
|
|
468
|
+
}
|
|
469
|
+
})().finally(() => {
|
|
470
|
+
renewal = undefined;
|
|
471
|
+
});
|
|
472
|
+
}, Math.max(1, Math.floor(readVisibilityMs() / 3)));
|
|
293
473
|
try {
|
|
294
|
-
await runQueueJob(envelope, this.failedJobs
|
|
474
|
+
await runQueueJob(envelope, this.failedJobs, {
|
|
475
|
+
onFailureRecorded: () => {
|
|
476
|
+
acknowledge = true;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
acknowledge = true;
|
|
295
480
|
} catch (error) {
|
|
296
481
|
console.error("[QueueWorker] Job failed:", error);
|
|
482
|
+
} finally {
|
|
483
|
+
clearInterval(heartbeat);
|
|
484
|
+
await renewal;
|
|
485
|
+
}
|
|
486
|
+
if (acknowledge && !ownershipLost) {
|
|
487
|
+
await updateQueueReservation(this.client, queueKey, reservation, "ack");
|
|
297
488
|
}
|
|
298
489
|
return true;
|
|
299
490
|
}
|
|
300
491
|
async run() {
|
|
301
492
|
this.running = true;
|
|
302
|
-
|
|
303
|
-
|
|
493
|
+
try {
|
|
494
|
+
while (!this.stopping) {
|
|
495
|
+
await this.processNext();
|
|
496
|
+
}
|
|
497
|
+
} finally {
|
|
498
|
+
this.running = false;
|
|
304
499
|
}
|
|
305
|
-
this.running = false;
|
|
306
500
|
}
|
|
307
501
|
close() {
|
|
308
502
|
this.client.close();
|
|
@@ -328,5 +522,12 @@ export {
|
|
|
328
522
|
RedisQueue,
|
|
329
523
|
countPendingQueueJobs,
|
|
330
524
|
parseQueueJobEnvelope,
|
|
331
|
-
|
|
525
|
+
queueInvalidKey,
|
|
526
|
+
queueKeyForPriority,
|
|
527
|
+
queueProcessingKey,
|
|
528
|
+
queueProcessingLeaseKey,
|
|
529
|
+
reclaimExpiredQueueReservations,
|
|
530
|
+
reservationExpired,
|
|
531
|
+
reserveQueueJob,
|
|
532
|
+
updateQueueReservation
|
|
332
533
|
};
|