@nicnocquee/dataqueue 1.38.0 → 1.39.0-beta.20260322125514

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.
@@ -32,7 +32,8 @@ const SCORE_RANGE = '1000000000000000'; // 1e15
32
32
  * KEYS: [prefix]
33
33
  * ARGV: [jobType, payloadJson, maxAttempts, priority, runAtMs, timeoutMs,
34
34
  * forceKillOnTimeout, tagsJson, idempotencyKey, nowMs,
35
- * retryDelay, retryBackoff, retryDelayMax, deadLetterJobType, groupId, groupTier]
35
+ * retryDelay, retryBackoff, retryDelayMax, deadLetterJobType, groupId, groupTier,
36
+ * dependsOnJobIdsJson, dependsOnTagsJson]
36
37
  * Returns: job ID (number)
37
38
  */
38
39
  export const ADD_JOB_SCRIPT = `
@@ -53,6 +54,8 @@ local retryDelayMax = ARGV[13] -- "null" or seconds string
53
54
  local deadLetterJobType = ARGV[14] -- "null" or jobType string
54
55
  local groupId = ARGV[15] -- "null" or group ID
55
56
  local groupTier = ARGV[16] -- "null" or group tier
57
+ local dependsOnJobIdsJson = ARGV[17] -- "null" or JSON array of job ids
58
+ local dependsOnTagsJson = ARGV[18] -- "null" or JSON array of tags
56
59
 
57
60
  -- Idempotency check
58
61
  if idempotencyKey ~= "null" then
@@ -104,9 +107,18 @@ redis.call('HMSET', jobKey,
104
107
  'deadLetteredAt', 'null',
105
108
  'deadLetterJobId', 'null',
106
109
  'groupId', groupId,
107
- 'groupTier', groupTier
110
+ 'groupTier', groupTier,
111
+ 'dependsOnJobIds', dependsOnJobIdsJson,
112
+ 'dependsOnTags', dependsOnTagsJson
108
113
  )
109
114
 
115
+ if dependsOnJobIdsJson ~= "null" then
116
+ local depIds = cjson.decode(dependsOnJobIdsJson)
117
+ for _, parentId in ipairs(depIds) do
118
+ redis.call('SADD', prefix .. 'dep:' .. tostring(parentId), tostring(id))
119
+ end
120
+ end
121
+
110
122
  -- Status index
111
123
  redis.call('SADD', prefix .. 'status:pending', id)
112
124
 
@@ -155,6 +167,9 @@ return id
155
167
  * runAtMs, timeoutMs, forceKillOnTimeout, tags (JSON or "null"),
156
168
  * idempotencyKey, retryDelay, retryBackoff, retryDelayMax, deadLetterJobType
157
169
  * Returns: array of job IDs (one per input job, in order)
170
+ *
171
+ * Note: JSON null decodes to cjson.null, which is truthy in Lua; optional fields must
172
+ * be normalized with explicit nil/cjson.null checks before tostring/cjson.decode.
158
173
  */
159
174
  export const ADD_JOBS_SCRIPT = `
160
175
  local prefix = KEYS[1]
@@ -180,6 +195,8 @@ for i, job in ipairs(jobs) do
180
195
  local deadLetterJobType = tostring(job.deadLetterJobType)
181
196
  local groupId = tostring(job.groupId)
182
197
  local groupTier = tostring(job.groupTier)
198
+ local dependsOnJobIdsJson = (job.dependsOnJobIds ~= nil and job.dependsOnJobIds ~= cjson.null) and tostring(job.dependsOnJobIds) or "null"
199
+ local dependsOnTagsJson = (job.dependsOnTags ~= nil and job.dependsOnTags ~= cjson.null) and tostring(job.dependsOnTags) or "null"
183
200
 
184
201
  -- Idempotency check
185
202
  local skip = false
@@ -234,9 +251,18 @@ for i, job in ipairs(jobs) do
234
251
  'deadLetteredAt', 'null',
235
252
  'deadLetterJobId', 'null',
236
253
  'groupId', groupId,
237
- 'groupTier', groupTier
254
+ 'groupTier', groupTier,
255
+ 'dependsOnJobIds', dependsOnJobIdsJson,
256
+ 'dependsOnTags', dependsOnTagsJson
238
257
  )
239
258
 
259
+ if dependsOnJobIdsJson ~= "null" then
260
+ local depIds = cjson.decode(dependsOnJobIdsJson)
261
+ for _, parentId in ipairs(depIds) do
262
+ redis.call('SADD', prefix .. 'dep:' .. tostring(parentId), tostring(id))
263
+ end
264
+ end
265
+
240
266
  -- Status index
241
267
  redis.call('SADD', prefix .. 'status:pending', id)
242
268
 
@@ -409,6 +435,48 @@ for i = 1, #candidates, 2 do
409
435
  end
410
436
  end
411
437
 
438
+ if canClaim then
439
+ local depIdsJson = redis.call('HGET', jk, 'dependsOnJobIds')
440
+ local depTagsJson = redis.call('HGET', jk, 'dependsOnTags')
441
+ local depsOk = true
442
+ if depIdsJson and depIdsJson ~= 'null' then
443
+ local dids = cjson.decode(depIdsJson)
444
+ for _, pid in ipairs(dids) do
445
+ local pst = redis.call('HGET', prefix .. 'job:' .. pid, 'status')
446
+ if pst ~= 'completed' then depsOk = false break end
447
+ end
448
+ end
449
+ if depsOk and depTagsJson and depTagsJson ~= 'null' then
450
+ local req = cjson.decode(depTagsJson)
451
+ if #req > 0 then
452
+ for _, stname in ipairs({'pending','processing','waiting'}) do
453
+ local members = redis.call('SMEMBERS', prefix .. 'status:' .. stname)
454
+ for _, oid in ipairs(members) do
455
+ if oid ~= jobId then
456
+ local otags = redis.call('HGET', prefix .. 'job:' .. oid, 'tags')
457
+ if otags and otags ~= 'null' then
458
+ local oarr = cjson.decode(otags)
459
+ local tagset = {}
460
+ for _, t in ipairs(oarr) do tagset[t] = true end
461
+ local all = true
462
+ for _, rt in ipairs(req) do
463
+ if not tagset[rt] then all = false break end
464
+ end
465
+ if all then depsOk = false break end
466
+ end
467
+ end
468
+ end
469
+ if not depsOk then break end
470
+ end
471
+ end
472
+ end
473
+ if not depsOk then
474
+ table.insert(putBack, score)
475
+ table.insert(putBack, jobId)
476
+ canClaim = false
477
+ end
478
+ end
479
+
412
480
  if canClaim then
413
481
  -- Claim this job
414
482
  local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
@@ -495,6 +563,14 @@ if groupId and groupId ~= 'null' then
495
563
  end
496
564
  end
497
565
 
566
+ local depIdsJson = redis.call('HGET', jk, 'dependsOnJobIds')
567
+ if depIdsJson and depIdsJson ~= 'null' then
568
+ local dids = cjson.decode(depIdsJson)
569
+ for _, pid in ipairs(dids) do
570
+ redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
571
+ end
572
+ end
573
+
498
574
  return 1
499
575
  `;
500
576
 
@@ -650,7 +726,11 @@ if nextAttemptAt == 'null' and deadLetterJobType and deadLetterJobType ~= 'null'
650
726
  'retryDelayMax', 'null',
651
727
  'deadLetterJobType', 'null',
652
728
  'deadLetteredAt', 'null',
653
- 'deadLetterJobId', 'null'
729
+ 'deadLetterJobId', 'null',
730
+ 'dependsOnJobIds', 'null',
731
+ 'dependsOnTags', 'null',
732
+ 'groupId', 'null',
733
+ 'groupTier', 'null'
654
734
  )
655
735
 
656
736
  redis.call('SADD', prefix .. 'status:pending', deadLetterJobId)
@@ -665,6 +745,14 @@ if nextAttemptAt == 'null' and deadLetterJobType and deadLetterJobType ~= 'null'
665
745
  )
666
746
  end
667
747
 
748
+ local depIdsJsonFail = redis.call('HGET', jk, 'dependsOnJobIds')
749
+ if depIdsJsonFail and depIdsJsonFail ~= 'null' then
750
+ local dids = cjson.decode(depIdsJsonFail)
751
+ for _, pid in ipairs(dids) do
752
+ redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
753
+ end
754
+ end
755
+
668
756
  return deadLetterJobId
669
757
  `;
670
758
 
@@ -743,6 +831,14 @@ redis.call('ZREM', prefix .. 'queue', jobId)
743
831
  redis.call('ZREM', prefix .. 'delayed', jobId)
744
832
  redis.call('ZREM', prefix .. 'waiting', jobId)
745
833
 
834
+ local depIdsJsonCan = redis.call('HGET', jk, 'dependsOnJobIds')
835
+ if depIdsJsonCan and depIdsJsonCan ~= 'null' then
836
+ local dids = cjson.decode(depIdsJsonCan)
837
+ for _, pid in ipairs(dids) do
838
+ redis.call('SREM', prefix .. 'dep:' .. tostring(pid), jobId)
839
+ end
840
+ end
841
+
746
842
  return 1
747
843
  `;
748
844
 
@@ -23,6 +23,11 @@ import {
23
23
  CronScheduleInput,
24
24
  } from '../backend.js';
25
25
  import { log } from '../log-context.js';
26
+ import {
27
+ normalizeDependsOn,
28
+ resolveDependsOnJobIdsForBatch,
29
+ tagsAreSuperset,
30
+ } from '../job-dependencies.js';
26
31
 
27
32
  const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
28
33
 
@@ -181,9 +186,31 @@ function deserializeJob<PayloadMap, T extends JobType<PayloadMap>>(
181
186
  groupId: nullish(h.groupId) as string | null | undefined,
182
187
  groupTier: nullish(h.groupTier) as string | null | undefined,
183
188
  output: parseJsonField(h.output),
189
+ dependsOnJobIds: parseOptionalIntArray(h.dependsOnJobIds),
190
+ dependsOnTags: parseOptionalStringArray(h.dependsOnTags),
184
191
  };
185
192
  }
186
193
 
194
+ function parseOptionalIntArray(raw: string | undefined): number[] | null {
195
+ if (!raw || raw === 'null') return null;
196
+ try {
197
+ const arr = JSON.parse(raw) as number[];
198
+ return Array.isArray(arr) && arr.length > 0 ? arr : null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ function parseOptionalStringArray(raw: string | undefined): string[] | null {
205
+ if (!raw || raw === 'null') return null;
206
+ try {
207
+ const arr = JSON.parse(raw) as string[];
208
+ return Array.isArray(arr) && arr.length > 0 ? arr : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
187
214
  /** Parse a JSON field from a Redis hash, returning null for missing/null values. */
188
215
  function parseJsonField(raw: string | undefined): unknown {
189
216
  if (!raw || raw === 'null') return null;
@@ -271,6 +298,76 @@ export class RedisBackend implements QueueBackend {
271
298
  return Date.now();
272
299
  }
273
300
 
301
+ /**
302
+ * Cancel pending/waiting jobs that depend on seed jobs (job id or tag), transitively.
303
+ *
304
+ * @param initialSeeds - Job ids that failed or were cancelled.
305
+ * @param rootJobId - Root id for event metadata.
306
+ */
307
+ private async propagateDependencyCancellationsRedis(
308
+ initialSeeds: number[],
309
+ rootJobId: number,
310
+ ): Promise<void> {
311
+ const cancelled = new Set<number>();
312
+ let frontier = [...new Set(initialSeeds.filter((id) => id > 0))];
313
+
314
+ while (frontier.length > 0) {
315
+ const pendingRaw = await this.client.sunion(
316
+ `${this.prefix}status:pending`,
317
+ `${this.prefix}status:waiting`,
318
+ );
319
+ const toCancel: number[] = [];
320
+
321
+ for (const pidStr of pendingRaw) {
322
+ const pid = Number(pidStr);
323
+ if (cancelled.has(pid)) continue;
324
+ const job = await this.getJob<any, any>(pid);
325
+ if (!job || (job.status !== 'pending' && job.status !== 'waiting')) {
326
+ continue;
327
+ }
328
+
329
+ for (const seedId of frontier) {
330
+ if (pid === seedId) continue;
331
+ const seedJob = await this.getJob<any, any>(seedId);
332
+ if (!seedJob) continue;
333
+
334
+ const byJobId = job.dependsOnJobIds?.includes(seedId) ?? false;
335
+ const byTag =
336
+ job.dependsOnTags &&
337
+ job.dependsOnTags.length > 0 &&
338
+ tagsAreSuperset(seedJob.tags, job.dependsOnTags);
339
+
340
+ if (byJobId || byTag) {
341
+ toCancel.push(pid);
342
+ break;
343
+ }
344
+ }
345
+ }
346
+
347
+ if (toCancel.length === 0) break;
348
+
349
+ const now = this.nowMs();
350
+ for (const jid of toCancel) {
351
+ const ok = await this.client.eval(
352
+ CANCEL_JOB_SCRIPT,
353
+ 1,
354
+ this.prefix,
355
+ jid,
356
+ now,
357
+ );
358
+ if (Number(ok) === 1) {
359
+ cancelled.add(jid);
360
+ await this.recordJobEvent(jid, JobEventType.Cancelled, {
361
+ rootJobId,
362
+ dependencyCascade: true,
363
+ });
364
+ }
365
+ }
366
+
367
+ frontier = toCancel;
368
+ }
369
+ }
370
+
274
371
  // ── Events ──────────────────────────────────────────────────────────
275
372
 
276
373
  async recordJobEvent(
@@ -327,6 +424,7 @@ export class RedisBackend implements QueueBackend {
327
424
  retryDelayMax = undefined,
328
425
  deadLetterJobType = undefined,
329
426
  group = undefined,
427
+ dependsOn,
330
428
  }: JobOptions<PayloadMap, T>,
331
429
  options?: AddJobOptions,
332
430
  ): Promise<number> {
@@ -336,6 +434,20 @@ export class RedisBackend implements QueueBackend {
336
434
  'Transactional job creation is only available with PostgreSQL.',
337
435
  );
338
436
  }
437
+ const { jobIds: depJobIdsRaw, tags: depTags } =
438
+ normalizeDependsOn(dependsOn);
439
+ if (depJobIdsRaw?.some((id) => id < 0)) {
440
+ throw new Error(
441
+ 'dependsOn.jobIds: batch-relative (negative) ids are only supported in addJobs()',
442
+ );
443
+ }
444
+ const dependsOnJobIdsJson =
445
+ depJobIdsRaw && depJobIdsRaw.length > 0
446
+ ? JSON.stringify(depJobIdsRaw)
447
+ : 'null';
448
+ const dependsOnTagsJson =
449
+ depTags && depTags.length > 0 ? JSON.stringify(depTags) : 'null';
450
+
339
451
  const now = this.nowMs();
340
452
  const runAtMs = runAt ? runAt.getTime() : 0;
341
453
 
@@ -359,6 +471,8 @@ export class RedisBackend implements QueueBackend {
359
471
  deadLetterJobType ?? 'null',
360
472
  group?.id ?? 'null',
361
473
  group?.tier ?? 'null',
474
+ dependsOnJobIdsJson,
475
+ dependsOnTagsJson,
362
476
  )) as number;
363
477
 
364
478
  const jobId = Number(result);
@@ -370,6 +484,10 @@ export class RedisBackend implements QueueBackend {
370
484
  payload,
371
485
  tags,
372
486
  idempotencyKey,
487
+ dependsOn:
488
+ dependsOnJobIdsJson !== 'null' || dependsOnTagsJson !== 'null'
489
+ ? dependsOn
490
+ : undefined,
373
491
  });
374
492
  return jobId;
375
493
  }
@@ -391,29 +509,67 @@ export class RedisBackend implements QueueBackend {
391
509
  );
392
510
  }
393
511
 
512
+ const needsSequential = jobs.some((j) => {
513
+ const n = normalizeDependsOn(j.dependsOn);
514
+ return Boolean(n.jobIds?.length || n.tags?.length);
515
+ });
516
+
517
+ if (needsSequential) {
518
+ const ids: number[] = [];
519
+ for (let i = 0; i < jobs.length; i++) {
520
+ let job = jobs[i]!;
521
+ const nd = normalizeDependsOn(job.dependsOn);
522
+ if (nd.jobIds?.some((id) => id < 0)) {
523
+ const resolvedJobIds = resolveDependsOnJobIdsForBatch(
524
+ nd.jobIds!,
525
+ ids,
526
+ );
527
+ job = {
528
+ ...job,
529
+ dependsOn: {
530
+ jobIds: resolvedJobIds,
531
+ tags: job.dependsOn?.tags,
532
+ },
533
+ };
534
+ }
535
+ ids.push(await this.addJob(job));
536
+ }
537
+ log(
538
+ `Batch-inserted ${jobs.length} jobs (sequential), IDs: [${ids.join(', ')}]`,
539
+ );
540
+ return ids;
541
+ }
542
+
394
543
  const now = this.nowMs();
395
544
 
396
- const jobsPayload = jobs.map((job) => ({
397
- jobType: job.jobType,
398
- payload: JSON.stringify(job.payload),
399
- maxAttempts: job.maxAttempts ?? 3,
400
- priority: job.priority ?? 0,
401
- runAtMs: job.runAt ? job.runAt.getTime() : 0,
402
- timeoutMs:
403
- job.timeoutMs !== undefined ? job.timeoutMs.toString() : 'null',
404
- forceKillOnTimeout: job.forceKillOnTimeout ? 'true' : 'false',
405
- tags: job.tags ? JSON.stringify(job.tags) : 'null',
406
- idempotencyKey: job.idempotencyKey ?? 'null',
407
- retryDelay:
408
- job.retryDelay !== undefined ? job.retryDelay.toString() : 'null',
409
- retryBackoff:
410
- job.retryBackoff !== undefined ? job.retryBackoff.toString() : 'null',
411
- retryDelayMax:
412
- job.retryDelayMax !== undefined ? job.retryDelayMax.toString() : 'null',
413
- deadLetterJobType: job.deadLetterJobType ?? 'null',
414
- groupId: job.group?.id ?? 'null',
415
- groupTier: job.group?.tier ?? 'null',
416
- }));
545
+ const jobsPayload = jobs.map((job) => {
546
+ const nd = normalizeDependsOn(job.dependsOn);
547
+ return {
548
+ jobType: job.jobType,
549
+ payload: JSON.stringify(job.payload),
550
+ maxAttempts: job.maxAttempts ?? 3,
551
+ priority: job.priority ?? 0,
552
+ runAtMs: job.runAt ? job.runAt.getTime() : 0,
553
+ timeoutMs:
554
+ job.timeoutMs !== undefined ? job.timeoutMs.toString() : 'null',
555
+ forceKillOnTimeout: job.forceKillOnTimeout ? 'true' : 'false',
556
+ tags: job.tags ? JSON.stringify(job.tags) : 'null',
557
+ idempotencyKey: job.idempotencyKey ?? 'null',
558
+ retryDelay:
559
+ job.retryDelay !== undefined ? job.retryDelay.toString() : 'null',
560
+ retryBackoff:
561
+ job.retryBackoff !== undefined ? job.retryBackoff.toString() : 'null',
562
+ retryDelayMax:
563
+ job.retryDelayMax !== undefined
564
+ ? job.retryDelayMax.toString()
565
+ : 'null',
566
+ deadLetterJobType: job.deadLetterJobType ?? 'null',
567
+ groupId: job.group?.id ?? 'null',
568
+ groupTier: job.group?.tier ?? 'null',
569
+ dependsOnJobIds: nd.jobIds?.length ? JSON.stringify(nd.jobIds) : null,
570
+ dependsOnTags: nd.tags?.length ? JSON.stringify(nd.tags) : null,
571
+ };
572
+ });
417
573
 
418
574
  const result = (await this.client.eval(
419
575
  ADD_JOBS_SCRIPT,
@@ -665,6 +821,7 @@ export class RedisBackend implements QueueBackend {
665
821
  failureReason,
666
822
  deadLetterJobId,
667
823
  });
824
+ await this.propagateDependencyCancellationsRedis([jobId], jobId);
668
825
  if (deadLetterJobId) {
669
826
  const sourceJob = await this.client.hget(
670
827
  `${this.prefix}job:${jobId}`,
@@ -743,9 +900,22 @@ export class RedisBackend implements QueueBackend {
743
900
 
744
901
  async cancelJob(jobId: number): Promise<void> {
745
902
  const now = this.nowMs();
746
- await this.client.eval(CANCEL_JOB_SCRIPT, 1, this.prefix, jobId, now);
747
- await this.recordJobEvent(jobId, JobEventType.Cancelled);
748
- log(`Cancelled job ${jobId}`);
903
+ const ok = await this.client.eval(
904
+ CANCEL_JOB_SCRIPT,
905
+ 1,
906
+ this.prefix,
907
+ jobId,
908
+ now,
909
+ );
910
+ if (Number(ok) === 1) {
911
+ await this.recordJobEvent(jobId, JobEventType.Cancelled);
912
+ await this.propagateDependencyCancellationsRedis([jobId], jobId);
913
+ log(`Cancelled job ${jobId}`);
914
+ } else {
915
+ log(
916
+ `Job ${jobId} could not be cancelled (not in pending/waiting state or does not exist)`,
917
+ );
918
+ }
749
919
  }
750
920
 
751
921
  async cancelAllUpcomingJobs(filters?: JobFilters): Promise<number> {
package/src/index.ts CHANGED
@@ -442,6 +442,14 @@ const withLogContext =
442
442
  };
443
443
 
444
444
  export * from './types.js';
445
+ export {
446
+ batchDepRef,
447
+ normalizeDependsOn,
448
+ resolveDependsOnJobIdsForBatch,
449
+ tagsAreSuperset,
450
+ validatePrerequisiteJobIdsExist,
451
+ assertNoDependencyCycle,
452
+ } from './job-dependencies.js';
445
453
  export { QueueBackend, CronScheduleInput } from './backend.js';
446
454
  export { PostgresBackend } from './backends/postgres.js';
447
455
  export {
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ assertNoDependencyCycle,
4
+ batchDepRef,
5
+ normalizeDependsOn,
6
+ resolveDependsOnJobIdsForBatch,
7
+ tagsAreSuperset,
8
+ validatePrerequisiteJobIdsExist,
9
+ } from './job-dependencies.js';
10
+ import type { DatabaseClient } from './types.js';
11
+
12
+ describe('batchDepRef', () => {
13
+ it('returns negative index encoding', () => {
14
+ expect(batchDepRef(0)).toBe(-1);
15
+ expect(batchDepRef(2)).toBe(-3);
16
+ });
17
+
18
+ it('throws on invalid index', () => {
19
+ expect(() => batchDepRef(-1)).toThrow();
20
+ expect(() => batchDepRef(1.5)).toThrow();
21
+ });
22
+ });
23
+
24
+ describe('normalizeDependsOn', () => {
25
+ it('returns undefined for empty input', () => {
26
+ expect(normalizeDependsOn(undefined)).toEqual({
27
+ jobIds: undefined,
28
+ tags: undefined,
29
+ });
30
+ });
31
+
32
+ it('deduplicates and drops empty', () => {
33
+ expect(
34
+ normalizeDependsOn({ jobIds: [1, 1, 2], tags: ['a', 'a', 'b'] }),
35
+ ).toEqual({
36
+ jobIds: [1, 2],
37
+ tags: ['a', 'b'],
38
+ });
39
+ });
40
+ });
41
+
42
+ describe('resolveDependsOnJobIdsForBatch', () => {
43
+ it('resolves negative placeholders', () => {
44
+ expect(resolveDependsOnJobIdsForBatch([-1, -2], [10, 20])).toEqual([
45
+ 10, 20,
46
+ ]);
47
+ });
48
+
49
+ it('passes through positive ids', () => {
50
+ expect(resolveDependsOnJobIdsForBatch([5, -1], [99])).toEqual([5, 99]);
51
+ });
52
+
53
+ it('throws when index out of range', () => {
54
+ expect(() => resolveDependsOnJobIdsForBatch([-3], [1, 2])).toThrow();
55
+ });
56
+ });
57
+
58
+ describe('tagsAreSuperset', () => {
59
+ it('returns false for empty required', () => {
60
+ expect(tagsAreSuperset(['a'], [])).toBe(false);
61
+ });
62
+
63
+ it('checks inclusion', () => {
64
+ expect(tagsAreSuperset(['a', 'b'], ['a'])).toBe(true);
65
+ expect(tagsAreSuperset(['a'], ['a', 'b'])).toBe(false);
66
+ expect(tagsAreSuperset(null, ['a'])).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe('validatePrerequisiteJobIdsExist', () => {
71
+ it('no-ops for empty', async () => {
72
+ const client: DatabaseClient = {
73
+ query: vi.fn(),
74
+ };
75
+ await validatePrerequisiteJobIdsExist(client, []);
76
+ expect(client.query).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('throws when count mismatches', async () => {
80
+ const client: DatabaseClient = {
81
+ query: vi.fn().mockResolvedValue({ rows: [{ c: 1 }], rowCount: 1 }),
82
+ };
83
+ await expect(
84
+ validatePrerequisiteJobIdsExist(client, [1, 2]),
85
+ ).rejects.toThrow(/do not exist/);
86
+ });
87
+
88
+ it('resolves when all exist', async () => {
89
+ const client: DatabaseClient = {
90
+ query: vi.fn().mockResolvedValue({ rows: [{ c: 2 }], rowCount: 1 }),
91
+ };
92
+ await validatePrerequisiteJobIdsExist(client, [1, 2]);
93
+ expect(client.query).toHaveBeenCalledTimes(1);
94
+ });
95
+ });
96
+
97
+ describe('assertNoDependencyCycle', () => {
98
+ it('throws on self-dependency', async () => {
99
+ const client: DatabaseClient = { query: vi.fn() };
100
+ await expect(assertNoDependencyCycle(client, 1, [1])).rejects.toThrow(
101
+ /cannot depend on itself/,
102
+ );
103
+ });
104
+
105
+ it('throws when cycle detected', async () => {
106
+ const client: DatabaseClient = {
107
+ query: vi
108
+ .fn()
109
+ .mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }),
110
+ };
111
+ await expect(assertNoDependencyCycle(client, 5, [1, 2])).rejects.toThrow(
112
+ /cycle/,
113
+ );
114
+ });
115
+
116
+ it('no-ops when no deps', async () => {
117
+ const client: DatabaseClient = { query: vi.fn() };
118
+ await assertNoDependencyCycle(client, 1, []);
119
+ expect(client.query).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('no-ops when query returns no cycle', async () => {
123
+ const client: DatabaseClient = {
124
+ query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
125
+ };
126
+ await assertNoDependencyCycle(client, 5, [1]);
127
+ expect(client.query).toHaveBeenCalledTimes(1);
128
+ });
129
+ });