@detiq/crawler 1.1.28 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.28",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import test from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
  import { prisma, prismaAdmin } from '@detiq/database';
5
- import { claimNextQueuedJob, claimSpecificJob, requeueStaleRunningJobs } from './job-queue.js';
5
+ import { claimNextQueuedJob, claimSpecificJob, requeueStaleRunningJobs, classifyFailure, FailureType, recordJobFailure, resumeJob } from './job-queue.js';
6
6
 
7
7
  function id(prefix: string) {
8
8
  return `${prefix}_${crypto.randomUUID()}`;
@@ -13,7 +13,7 @@ async function makeFixtureProject() {
13
13
  const projectId = id('project');
14
14
  await prisma.$transaction(async (tx) => {
15
15
  await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, tenantId);
16
- await tx.tenant.create({ data: { id: tenantId, name: 'Job Queue Test Tenant', slug: tenantId } });
16
+ await tx.tenant.create({ data: { id: tenantId, name: 'Job Queue Test Tenant', slug: tenantId, isolation: 'SHARED', region: 'US' } });
17
17
  await tx.project.create({ data: { id: projectId, tenantId, name: 'Job Queue Test Project' } });
18
18
  });
19
19
  return { tenantId, projectId };
@@ -118,3 +118,70 @@ test('requeueStaleRunningJobs: a job with no heartbeat falls back to startedAt',
118
118
  const job = await prismaAdmin.platformJob.findUnique({ where: { id: jobId } });
119
119
  assert.equal(job!.status, 'QUEUED');
120
120
  });
121
+
122
+ test('classifyFailure: classifies errors accurately across categories', () => {
123
+ assert.equal(classifyFailure(new Error('ETIMEDOUT: network connection reset')), FailureType.TRANSIENT);
124
+ assert.equal(classifyFailure(new Error('Rate limit exceeded: 429 Too Many Requests')), FailureType.TRANSIENT);
125
+ assert.equal(classifyFailure(new Error('FATAL: master_enc_key environment variable missing')), FailureType.FATAL);
126
+ assert.equal(classifyFailure(new Error('PrismaClientInitializationError: database connection refused')), FailureType.INFRASTRUCTURE);
127
+ assert.equal(classifyFailure(new Error('Worker exited unexpectedly: sigkill out of memory')), FailureType.INFRASTRUCTURE);
128
+ assert.equal(classifyFailure(new Error('Validation failed: 400 bad request')), FailureType.LOGICAL);
129
+ });
130
+
131
+ test('recordJobFailure: handles transient error with RETRY_WAIT and exponential backoff', async () => {
132
+ const { tenantId, projectId } = await makeFixtureProject();
133
+ const jobId = await insertQueuedJob(tenantId, projectId);
134
+
135
+ await recordJobFailure(tenantId, jobId, new Error('network timeout 503'));
136
+
137
+ const job = await prismaAdmin.platformJob.findUnique({ where: { id: jobId } });
138
+ assert.equal(job!.status, 'RETRY_WAIT');
139
+ assert.equal(job!.failureType, 'TRANSIENT');
140
+ assert.equal(job!.retryCount, 1);
141
+ assert.equal(job!.dlq, false);
142
+ assert.ok(job!.nextRetryAt && job!.nextRetryAt > new Date());
143
+ });
144
+
145
+ test('recordJobFailure: immediately routes FATAL errors to DLQ with 0 retries', async () => {
146
+ const { tenantId, projectId } = await makeFixtureProject();
147
+ const jobId = await insertQueuedJob(tenantId, projectId);
148
+
149
+ await recordJobFailure(tenantId, jobId, new Error('invalid configuration: master_enc_key not set'));
150
+
151
+ const job = await prismaAdmin.platformJob.findUnique({ where: { id: jobId } });
152
+ assert.equal(job!.status, 'FAILED');
153
+ assert.equal(job!.failureType, 'FATAL');
154
+ assert.equal(job!.dlq, true);
155
+ assert.ok(job!.dlqAt);
156
+ assert.ok(job!.dlqReason?.includes('master_enc_key'));
157
+ });
158
+
159
+ test('resumeJob: restores visitedUrls and pendingQueue from PlatformJob.checkpoint column', async () => {
160
+ const { tenantId, projectId } = await makeFixtureProject();
161
+ const jobId = id('job');
162
+ await prismaAdmin.platformJob.create({
163
+ data: {
164
+ id: jobId,
165
+ tenantId,
166
+ projectId,
167
+ type: 'CRAWL',
168
+ status: 'FAILED',
169
+ config: { tenantId, projectId, appUrl: 'https://example.com' },
170
+ checkpoint: {
171
+ visitedUrls: ['https://example.com/page1', 'https://example.com/page2'],
172
+ pendingQueue: ['https://example.com/page3'],
173
+ },
174
+ checkpointAt: new Date(),
175
+ },
176
+ });
177
+
178
+ const resumedJobId = await resumeJob(tenantId, jobId);
179
+ assert.ok(resumedJobId);
180
+
181
+ const resumedJob = await prismaAdmin.platformJob.findUnique({ where: { id: resumedJobId } });
182
+ assert.ok(resumedJob);
183
+ const cfg = resumedJob!.config as any;
184
+ assert.deepEqual(cfg.startUrls, ['https://example.com/page3']);
185
+ assert.ok(cfg.skipUrls.includes('https://example.com/page1'));
186
+ assert.ok(cfg.skipUrls.includes('https://example.com/page2'));
187
+ });