From 88860e4f8e0c5d3bb370821088b2e873aee23bf2 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Wed, 12 Aug 2026 16:03:15 +0530 Subject: [PATCH 1/4] feat(dvm): add dvm job persistence migration and repository Signed-off-by: Priyanshubhartistm --- .changeset/dvm-job-persistence.md | 5 + .knip.json | 1 + .../20260812_150000_create_dvm_jobs_table.js | 23 +++ src/@types/dvm.ts | 37 +++++ src/@types/repositories.ts | 16 ++- src/repositories/dvm-job-repository.ts | 133 ++++++++++++++++++ 6 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 .changeset/dvm-job-persistence.md create mode 100644 migrations/20260812_150000_create_dvm_jobs_table.js create mode 100644 src/@types/dvm.ts create mode 100644 src/repositories/dvm-job-repository.ts diff --git a/.changeset/dvm-job-persistence.md b/.changeset/dvm-job-persistence.md new file mode 100644 index 00000000..c71f4f0a --- /dev/null +++ b/.changeset/dvm-job-persistence.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(dvm): add job persistence migration and repository for DVM job state diff --git a/.knip.json b/.knip.json index 7129b278..f47eb4b9 100644 --- a/.knip.json +++ b/.knip.json @@ -17,6 +17,7 @@ "ignore": [ ".nostr/**", "src/repositories/invite-code-repository.ts", + "src/repositories/dvm-job-repository.ts", "src/utils/relay-probe/**" ], "commitlint": false, diff --git a/migrations/20260812_150000_create_dvm_jobs_table.js b/migrations/20260812_150000_create_dvm_jobs_table.js new file mode 100644 index 00000000..6bbb1e56 --- /dev/null +++ b/migrations/20260812_150000_create_dvm_jobs_table.js @@ -0,0 +1,23 @@ +exports.up = function (knex) { + return knex.schema.createTable('dvm_jobs', (table) => { + table.binary('id').primary() + table.binary('requester_pubkey').notNullable() + table.integer('kind').unsigned().notNullable() + table.integer('worker_index').nullable() + table.enum('status', ['submitted', 'picked_up', 'completed', 'failed', 'timed_out']).notNullable().defaultTo('submitted') + table.binary('result_event_id').nullable() + table.text('error').nullable() + table.timestamp('picked_up_at', { useTz: true }).nullable() + table.timestamp('completed_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + + table.index(['requester_pubkey'], 'idx_dvm_jobs_requester_pubkey') + table.index(['status'], 'idx_dvm_jobs_status') + table.index(['kind'], 'idx_dvm_jobs_kind') + }) +} + +exports.down = function (knex) { + return knex.schema.dropTable('dvm_jobs') +} diff --git a/src/@types/dvm.ts b/src/@types/dvm.ts new file mode 100644 index 00000000..0f5c5940 --- /dev/null +++ b/src/@types/dvm.ts @@ -0,0 +1,37 @@ +import { Pubkey } from './base' + +export enum DvmJobStatus { + SUBMITTED = 'submitted', + PICKED_UP = 'picked_up', + COMPLETED = 'completed', + FAILED = 'failed', + TIMED_OUT = 'timed_out', +} + +export interface DvmJob { + id: string + requesterPubkey: Pubkey + kind: number + workerIndex: number | null + status: DvmJobStatus + resultEventId: string | null + error: string | null + pickedUpAt: Date | null + completedAt: Date | null + createdAt: Date + updatedAt: Date +} + +export interface DBDvmJob { + id: Buffer + requester_pubkey: Buffer + kind: number + worker_index: number | null + status: DvmJobStatus + result_event_id: Buffer | null + error: string | null + picked_up_at: Date | null + completed_at: Date | null + created_at: Date + updated_at: Date +} diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index df98e545..717ba034 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -1,12 +1,12 @@ import { PassThrough } from 'stream' - +import { EventKinds } from '../constants/base' import { DatabaseClient, EventId, Pubkey } from './base' +import { DvmJob } from './dvm' import { DBEvent, Event } from './event' -import { EventKinds } from '../constants/base' -import { EventKindsRange } from './settings' import { InviteCode } from './invite-code' import { Invoice } from './invoice' import { Nip05Verification } from './nip05' +import { EventKindsRange } from './settings' import { SubscriptionFilter } from './subscription' import { User } from './user' @@ -73,3 +73,13 @@ export interface IInviteCodeRepository { findActiveCodes(limit?: number): Promise deleteExpiredCodes(): Promise } + +export interface IDvmJobRepository { + create(id: string, requesterPubkey: Pubkey, kind: number): Promise + findById(id: string): Promise + assignWorker(id: string, workerIndex: number): Promise + updateStatus( + job: Pick & Partial>, + ): Promise + findPendingJobs(limit?: number): Promise +} diff --git a/src/repositories/dvm-job-repository.ts b/src/repositories/dvm-job-repository.ts new file mode 100644 index 00000000..32dbe629 --- /dev/null +++ b/src/repositories/dvm-job-repository.ts @@ -0,0 +1,133 @@ +import { DatabaseClient, Pubkey } from '../@types/base' +import { DBDvmJob, DvmJob, DvmJobStatus } from '../@types/dvm' +import { IDvmJobRepository } from '../@types/repositories' +import { createLogger } from '../factories/logger-factory' +import { fromBuffer, toBuffer } from '../utils/transform' + +const logger = createLogger('dvm-job-repository') + +function fromDBDvmJob(row: DBDvmJob): DvmJob { + return { + id: fromBuffer(row.id), + requesterPubkey: fromBuffer(row.requester_pubkey), + kind: row.kind, + workerIndex: row.worker_index, + status: row.status, + resultEventId: row.result_event_id ? fromBuffer(row.result_event_id) : null, + error: row.error, + pickedUpAt: row.picked_up_at, + completedAt: row.completed_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function affectedRows(result: unknown): number { + if (typeof result === 'number') { + return result + } + if (result && typeof (result as any).rowCount === 'number') { + return (result as any).rowCount + } + return 0 +} + +export class DvmJobRepository implements IDvmJobRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async create( + id: string, + requesterPubkey: Pubkey, + kind: number, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('create dvm job %s (kind %d) for %s', id, kind, requesterPubkey) + + const now = new Date() + const row: DBDvmJob = { + id: toBuffer(id), + requester_pubkey: toBuffer(requesterPubkey), + kind, + worker_index: null, + status: DvmJobStatus.SUBMITTED, + result_event_id: null, + error: null, + picked_up_at: null, + completed_at: null, + created_at: now, + updated_at: now, + } + + await client('dvm_jobs').insert(row) + + return fromDBDvmJob(row) + } + + public async findById(id: string, client: DatabaseClient = this.dbClient): Promise { + logger('find dvm job %s', id) + + const [row] = await client('dvm_jobs').where('id', toBuffer(id)).select() + + if (!row) { + return + } + + return fromDBDvmJob(row) + } + + // Atomic pickup: single conditional UPDATE ensures only one worker wins the job + public async assignWorker(id: string, workerIndex: number, client: DatabaseClient = this.dbClient): Promise { + logger('assign dvm job %s to worker %d', id, workerIndex) + + const now = new Date() + + const result = await client('dvm_jobs') + .where('id', toBuffer(id)) + .where('status', DvmJobStatus.SUBMITTED) + .update({ + worker_index: workerIndex, + status: DvmJobStatus.PICKED_UP, + picked_up_at: now, + updated_at: now, + }) + + return affectedRows(result) > 0 + } + + public async updateStatus( + job: Pick & Partial>, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('update dvm job status: %o', job) + + const now = new Date() + const isTerminal = + job.status === DvmJobStatus.COMPLETED || + job.status === DvmJobStatus.FAILED || + job.status === DvmJobStatus.TIMED_OUT + + const update: Partial = { + status: job.status, + updated_at: now, + ...(isTerminal ? { completed_at: now } : {}), + ...(job.resultEventId ? { result_event_id: toBuffer(job.resultEventId) } : {}), + ...(job.error ? { error: job.error } : {}), + } + + const [row] = await client('dvm_jobs').where('id', toBuffer(job.id)).update(update).returning(['*']) + + return row ? fromDBDvmJob(row) : undefined + } + + public async findPendingJobs(limit = 100, client: DatabaseClient = this.dbClient): Promise { + logger('find pending dvm jobs (limit %d)', limit) + + const rows = await client('dvm_jobs') + .whereIn('status', [DvmJobStatus.SUBMITTED, DvmJobStatus.PICKED_UP]) + .orderBy('created_at', 'asc') + .limit(limit) + .select() + + return rows.map(fromDBDvmJob) + } +} From 7de3528de682644421fa99f3371c1c93f65ae486 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Wed, 12 Aug 2026 16:04:11 +0530 Subject: [PATCH 2/4] test(dvm): add dvm-job-repository unit tests Signed-off-by: Priyanshubhartistm --- .../repositories/dvm-job-repository.spec.ts | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 test/unit/repositories/dvm-job-repository.spec.ts diff --git a/test/unit/repositories/dvm-job-repository.spec.ts b/test/unit/repositories/dvm-job-repository.spec.ts new file mode 100644 index 00000000..3bd1ef59 --- /dev/null +++ b/test/unit/repositories/dvm-job-repository.spec.ts @@ -0,0 +1,315 @@ +import * as chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import * as sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { DatabaseClient } from '../../../src/@types/base' +import { DvmJobStatus } from '../../../src/@types/dvm' +import { DvmJobRepository } from '../../../src/repositories/dvm-job-repository' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +describe('DvmJobRepository', () => { + let repository: DvmJobRepository + let sandbox: sinon.SinonSandbox + + const fixedDate = new Date('2026-08-12T00:00:00.000Z') + const jobId = 'a'.repeat(64) + const pubkeyHex = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + const resultEventId = 'b'.repeat(64) + + const dbDvmJobRow = { + id: Buffer.from(jobId, 'hex'), + requester_pubkey: Buffer.from(pubkeyHex, 'hex'), + kind: 5000, + worker_index: null as number | null, + status: DvmJobStatus.SUBMITTED, + result_event_id: null as Buffer | null, + error: null as string | null, + picked_up_at: null as Date | null, + completed_at: null as Date | null, + created_at: fixedDate, + updated_at: fixedDate, + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + sandbox.useFakeTimers(fixedDate.getTime()) + + repository = new DvmJobRepository({} as DatabaseClient) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('.create', () => { + it('inserts into the dvm_jobs table', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + await repository.create(jobId, pubkeyHex, 5000, client) + + expect(client).to.have.been.calledWith('dvm_jobs') + }) + + it('returns a DvmJob with submitted status and no worker assigned', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + const result = await repository.create(jobId, pubkeyHex, 5000, client) + + expect(result).to.deep.include({ + id: jobId, + requesterPubkey: pubkeyHex, + kind: 5000, + workerIndex: null, + status: DvmJobStatus.SUBMITTED, + resultEventId: null, + error: null, + }) + expect(result.createdAt).to.be.instanceOf(Date) + expect(result.updatedAt).to.be.instanceOf(Date) + }) + + it('stores id and requester pubkey as buffers', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + await repository.create(jobId, pubkeyHex, 5000, client) + + const insertedRow = insertStub.firstCall.args[0] + expect(insertedRow.id).to.deep.equal(Buffer.from(jobId, 'hex')) + expect(insertedRow.requester_pubkey).to.deep.equal(Buffer.from(pubkeyHex, 'hex')) + }) + }) + + describe('.findById', () => { + it('returns undefined when no job is found', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([]) }), + }) as unknown as DatabaseClient + + const result = await repository.findById(jobId, client) + + expect(result).to.be.undefined + }) + + it('returns a transformed DvmJob when found', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([dbDvmJobRow]) }), + }) as unknown as DatabaseClient + + const result = await repository.findById(jobId, client) + + expect(result).to.not.be.undefined + expect(result!.id).to.equal(jobId) + expect(result!.requesterPubkey).to.equal(pubkeyHex) + expect(result!.status).to.equal(DvmJobStatus.SUBMITTED) + }) + + it('queries the dvm_jobs table by id', async () => { + const whereStub = sandbox.stub().returns({ select: sandbox.stub().resolves([]) }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.findById(jobId, client) + + expect(client).to.have.been.calledWith('dvm_jobs') + const [field, value] = whereStub.firstCall.args + expect(field).to.equal('id') + expect(value).to.deep.equal(Buffer.from(jobId, 'hex')) + }) + }) + + describe('.assignWorker', () => { + it('returns true when assignment succeeds (rowCount > 0)', async () => { + const updateStub = sandbox.stub().resolves(1) + const whereStub2 = sandbox.stub().returns({ update: updateStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + const result = await repository.assignWorker(jobId, 0, client) + + expect(result).to.be.true + }) + + it('returns false when no submitted job matched (rowCount = 0)', async () => { + const updateStub = sandbox.stub().resolves(0) + const whereStub2 = sandbox.stub().returns({ update: updateStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + const result = await repository.assignWorker(jobId, 0, client) + + expect(result).to.be.false + }) + + it('returns true when pg returns { rowCount } object', async () => { + const updateStub = sandbox.stub().resolves({ rowCount: 1 }) + const whereStub2 = sandbox.stub().returns({ update: updateStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + const result = await repository.assignWorker(jobId, 0, client) + + expect(result).to.be.true + }) + + it('only matches jobs still in submitted status', async () => { + const updateStub = sandbox.stub().resolves(1) + const whereStub2 = sandbox.stub().returns({ update: updateStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + await repository.assignWorker(jobId, 2, client) + + expect(whereStub2).to.have.been.calledWith('status', DvmJobStatus.SUBMITTED) + }) + }) + + describe('.updateStatus', () => { + it('updates status and returns the transformed job', async () => { + const updatedRow = { ...dbDvmJobRow, status: DvmJobStatus.COMPLETED, completed_at: fixedDate } + const returningStub = sandbox.stub().resolves([updatedRow]) + const updateStub = sandbox.stub().returns({ returning: returningStub }) + const whereStub = sandbox.stub().returns({ update: updateStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + const result = await repository.updateStatus({ id: jobId, status: DvmJobStatus.COMPLETED }, client) + + expect(result).to.not.be.undefined + expect(result!.status).to.equal(DvmJobStatus.COMPLETED) + }) + + it('returns undefined when no matching job exists', async () => { + const returningStub = sandbox.stub().resolves([]) + const updateStub = sandbox.stub().returns({ returning: returningStub }) + const whereStub = sandbox.stub().returns({ update: updateStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + const result = await repository.updateStatus({ id: jobId, status: DvmJobStatus.FAILED }, client) + + expect(result).to.be.undefined + }) + + it('sets completed_at for terminal statuses', async () => { + const returningStub = sandbox.stub().resolves([dbDvmJobRow]) + const updateStub = sandbox.stub().returns({ returning: returningStub }) + const whereStub = sandbox.stub().returns({ update: updateStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.updateStatus({ id: jobId, status: DvmJobStatus.TIMED_OUT }, client) + + const update = updateStub.firstCall.args[0] + expect(update.completed_at).to.deep.equal(fixedDate) + }) + + it('does not set completed_at for the picked_up status', async () => { + const returningStub = sandbox.stub().resolves([dbDvmJobRow]) + const updateStub = sandbox.stub().returns({ returning: returningStub }) + const whereStub = sandbox.stub().returns({ update: updateStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.updateStatus({ id: jobId, status: DvmJobStatus.PICKED_UP }, client) + + const update = updateStub.firstCall.args[0] + expect(update.completed_at).to.be.undefined + }) + + it('encodes resultEventId as a buffer when provided', async () => { + const returningStub = sandbox.stub().resolves([dbDvmJobRow]) + const updateStub = sandbox.stub().returns({ returning: returningStub }) + const whereStub = sandbox.stub().returns({ update: updateStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.updateStatus({ id: jobId, status: DvmJobStatus.COMPLETED, resultEventId }, client) + + const update = updateStub.firstCall.args[0] + expect(update.result_event_id).to.deep.equal(Buffer.from(resultEventId, 'hex')) + }) + + it('sets error when provided', async () => { + const returningStub = sandbox.stub().resolves([dbDvmJobRow]) + const updateStub = sandbox.stub().returns({ returning: returningStub }) + const whereStub = sandbox.stub().returns({ update: updateStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.updateStatus({ id: jobId, status: DvmJobStatus.FAILED, error: 'worker crashed' }, client) + + const update = updateStub.firstCall.args[0] + expect(update.error).to.equal('worker crashed') + }) + }) + + describe('.findPendingJobs', () => { + it('returns an empty array when no pending jobs exist', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereInStub = sandbox.stub().returns({ orderBy: orderByStub }) + const client = sandbox.stub().returns({ whereIn: whereInStub }) as unknown as DatabaseClient + + const result = await repository.findPendingJobs(10, client) + + expect(result).to.be.an('array').that.is.empty + }) + + it('returns transformed DvmJob objects', async () => { + const selectStub = sandbox.stub().resolves([dbDvmJobRow]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereInStub = sandbox.stub().returns({ orderBy: orderByStub }) + const client = sandbox.stub().returns({ whereIn: whereInStub }) as unknown as DatabaseClient + + const result = await repository.findPendingJobs(10, client) + + expect(result).to.have.lengthOf(1) + expect(result[0].id).to.equal(jobId) + }) + + it('filters for submitted and picked_up statuses', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereInStub = sandbox.stub().returns({ orderBy: orderByStub }) + const client = sandbox.stub().returns({ whereIn: whereInStub }) as unknown as DatabaseClient + + await repository.findPendingJobs(10, client) + + expect(whereInStub).to.have.been.calledWith('status', [DvmJobStatus.SUBMITTED, DvmJobStatus.PICKED_UP]) + }) + + it('orders by created_at ascending', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereInStub = sandbox.stub().returns({ orderBy: orderByStub }) + const client = sandbox.stub().returns({ whereIn: whereInStub }) as unknown as DatabaseClient + + await repository.findPendingJobs(10, client) + + expect(orderByStub).to.have.been.calledWith('created_at', 'asc') + }) + + it('defaults limit to 100', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereInStub = sandbox.stub().returns({ orderBy: orderByStub }) + const client = sandbox.stub().returns({ whereIn: whereInStub }) as unknown as DatabaseClient + + await repository.findPendingJobs(undefined, client) + + expect(limitStub).to.have.been.calledWith(100) + }) + }) +}) From 0ab2a805b712f74a183f461ef7ab74a4e8799104 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Wed, 12 Aug 2026 20:04:01 +0530 Subject: [PATCH 3/4] feat(dvm): trap NIP-90 job request events and record them via job repository Signed-off-by: Priyanshubhartistm --- .changeset/dvm-job-ingestion.md | 5 +++ .knip.json | 1 - src/constants/base.ts | 3 ++ src/factories/event-strategy-factory.ts | 16 +++++-- src/factories/message-handler-factory.ts | 18 +++++++- src/factories/websocket-adapter-factory.ts | 17 +++++++- src/factories/worker-factory.ts | 10 ++++- .../dvm-job-request-event-strategy.ts | 42 +++++++++++++++++++ src/utils/event.ts | 30 ++++++++----- 9 files changed, 121 insertions(+), 21 deletions(-) create mode 100644 .changeset/dvm-job-ingestion.md create mode 100644 src/handlers/event-strategies/dvm-job-request-event-strategy.ts diff --git a/.changeset/dvm-job-ingestion.md b/.changeset/dvm-job-ingestion.md new file mode 100644 index 00000000..f0117531 --- /dev/null +++ b/.changeset/dvm-job-ingestion.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(dvm): trap NIP-90 job request events (kind 5000-5999) and record them via the job repository diff --git a/.knip.json b/.knip.json index f47eb4b9..7129b278 100644 --- a/.knip.json +++ b/.knip.json @@ -17,7 +17,6 @@ "ignore": [ ".nostr/**", "src/repositories/invite-code-repository.ts", - "src/repositories/dvm-job-repository.ts", "src/utils/relay-probe/**" ], "commitlint": false, diff --git a/src/constants/base.ts b/src/constants/base.ts index 200dd478..0715ecc0 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -41,6 +41,9 @@ export enum EventKinds { // Lightning zaps ZAP_REQUEST = 9734, ZAP_RECEIPT = 9735, + // NIP-90: Data Vending Machines — job request events + DVM_JOB_REQUEST_FIRST = 5000, + DVM_JOB_REQUEST_LAST = 5999, // Replaceable events REPLACEABLE_FIRST = 10000, // NIP-65: Relay List Metadata diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index a9843ea2..37f67e9c 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -1,7 +1,8 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters' -import { IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories' +import { IDvmJobRepository, IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories' import { isDeleteEvent, + isDvmJobRequestEvent, isEphemeralEvent, isGiftWrapEvent, isMarmotGroupEvent, @@ -14,6 +15,7 @@ import { isNip43JoinRequest, isNip43LeaveRequest } from '../utils/nip43' import { isRelayListEvent } from '../utils/nip65' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' +import { DvmJobRequestEventStrategy } from '../handlers/event-strategies/dvm-job-request-event-strategy' import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy' import { Event } from '../@types/event' import { Factory } from '../@types/base' @@ -33,6 +35,7 @@ export const eventStrategyFactory = eventRepository: IEventRepository, userRepository: IUserRepository, inviteCodeRepository: IInviteCodeRepository, + dvmJobRepository: IDvmJobRepository, cache: ICacheAdapter, settings: () => Settings, ): Factory>, [Event, IWebSocketAdapter]> => @@ -47,12 +50,17 @@ export const eventStrategyFactory = return new TimestampEventStrategy(adapter, eventRepository) } else if (isRelayListEvent(event) || isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) - // NIP-43: Join/Leave requests MUST be checked before the generic ephemeral - // handler, because kinds 28934/28936 fall in the ephemeral range (20000-29999). + // NIP-43: Join/Leave requests MUST be checked before the generic ephemeral + // handler, because kinds 28934/28936 fall in the ephemeral range (20000-29999). } else if (isNip43JoinRequest(event)) { return new JoinRequestEventStrategy(adapter, inviteCodeRepository, userRepository, cache, settings) } else if (isNip43LeaveRequest(event)) { return new LeaveRequestEventStrategy(adapter, userRepository, cache, settings) + // NIP-90: DVM job requests (kind 5000-5999) checked early, same reasoning + // as the NIP-43 checks above — kept explicit rather than relying on it + // falling through to DefaultEventStrategy. + } else if (isDvmJobRequestEvent(event)) { + return new DvmJobRequestEventStrategy(adapter, eventRepository, dvmJobRepository) } else if (isEphemeralEvent(event)) { return new EphemeralEventStrategy(adapter) } else if (isDeleteEvent(event)) { @@ -62,4 +70,4 @@ export const eventStrategyFactory = } return new DefaultEventStrategy(adapter, eventRepository) - } \ No newline at end of file + } diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index e7ae66de..6981a194 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -1,5 +1,11 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters' -import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' +import { + IDvmJobRepository, + IEventRepository, + IInviteCodeRepository, + INip05VerificationRepository, + IUserRepository, +} from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' import { createSettings } from './settings-factory' import { AuthMessageHandler } from '../handlers/auth-message-handler' @@ -26,13 +32,21 @@ export const messageHandlerFactory = userRepository: IUserRepository, nip05VerificationRepository: INip05VerificationRepository, inviteCodeRepository: IInviteCodeRepository, + dvmJobRepository: IDvmJobRepository, ) => ([message, adapter]: [IncomingMessage, IWebSocketAdapter]) => { switch (message[0]) { case MessageType.EVENT: { return new EventMessageHandler( adapter, - eventStrategyFactory(eventRepository, userRepository, inviteCodeRepository, getCache(), createSettings), + eventStrategyFactory( + eventRepository, + userRepository, + inviteCodeRepository, + dvmJobRepository, + getCache(), + createSettings, + ), eventRepository, userRepository, createSettings, diff --git a/src/factories/websocket-adapter-factory.ts b/src/factories/websocket-adapter-factory.ts index 433f1de5..67cb8764 100644 --- a/src/factories/websocket-adapter-factory.ts +++ b/src/factories/websocket-adapter-factory.ts @@ -1,7 +1,13 @@ import { IncomingMessage } from 'http' import { WebSocket } from 'ws' -import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' +import { + IDvmJobRepository, + IEventRepository, + IInviteCodeRepository, + INip05VerificationRepository, + IUserRepository, +} from '../@types/repositories' import { createSettings } from './settings-factory' import { IWebSocketServerAdapter } from '../@types/adapters' import { messageHandlerFactory } from './message-handler-factory' @@ -14,13 +20,20 @@ export const webSocketAdapterFactory = userRepository: IUserRepository, nip05VerificationRepository: INip05VerificationRepository, inviteCodeRepository: IInviteCodeRepository, + dvmJobRepository: IDvmJobRepository, ) => ([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) => new WebSocketAdapter( client, request, webSocketServerAdapter, - messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository), + messageHandlerFactory( + eventRepository, + userRepository, + nip05VerificationRepository, + inviteCodeRepository, + dvmJobRepository, + ), rateLimiterFactory, createSettings, ) diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 8b97d782..ce9f2f42 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -8,6 +8,7 @@ import { AppWorker } from '../app/worker' import { createLogger } from './logger-factory' import { createSettings } from '../factories/settings-factory' import { createWebApp } from './web-app-factory' +import { DvmJobRepository } from '../repositories/dvm-job-repository' import { EventRepository } from '../repositories/event-repository' import { InviteCodeRepository } from '../repositories/invite-code-repository' import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository' @@ -24,6 +25,7 @@ export const workerFactory = (): AppWorker => { const userRepository = new UserRepository(dbClient, eventRepository) const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) const inviteCodeRepository = new InviteCodeRepository(dbClient) + const dvmJobRepository = new DvmJobRepository(dbClient) const settings = createSettings() @@ -65,7 +67,13 @@ export const workerFactory = (): AppWorker => { const adapter = new WebSocketServerAdapter( server, webSocketServer, - webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository), + webSocketAdapterFactory( + eventRepository, + userRepository, + nip05VerificationRepository, + inviteCodeRepository, + dvmJobRepository, + ), createSettings, ) diff --git a/src/handlers/event-strategies/dvm-job-request-event-strategy.ts b/src/handlers/event-strategies/dvm-job-request-event-strategy.ts new file mode 100644 index 00000000..92b3ab7f --- /dev/null +++ b/src/handlers/event-strategies/dvm-job-request-event-strategy.ts @@ -0,0 +1,42 @@ +import { createEventCommandResult } from '../../telemetry/event-metrics' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { IDvmJobRepository, IEventRepository } from '../../@types/repositories' +import { IEventStrategy } from '../../@types/message-handlers' +import { IWebSocketAdapter } from '../../@types/adapters' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const logger = createLogger('dvm-job-request-event-strategy') + +export class DvmJobRequestEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly eventRepository: IEventRepository, + private readonly dvmJobRepository: IDvmJobRepository, + ) {} + + public async execute(event: Event): Promise { + logger('received dvm job request: %o', event) + + const count = await this.eventRepository.create(event) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createEventCommandResult(event.id, true, count ? '' : 'duplicate:'), + ) + + if (!count) { + return + } + + this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event) + + try { + await this.dvmJobRepository.create(event.id, event.pubkey, event.kind) + } catch (error) { + // Job-state recording is best-effort: the event itself is already + // stored and broadcast correctly, so a repository failure here must + // not surface as a rejection of a valid event. + logger.error('unable to record dvm job for event %s: %o', event.id, error) + } + } +} diff --git a/src/utils/event.ts b/src/utils/event.ts index 7de72d40..1b1c2296 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -40,18 +40,20 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => { const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix) - const isMatchingGenericTagCriterion = (key: string, criterion: string) => (tag: Tag): boolean => { - const [, tagName] = key - if (tag[0] !== tagName) { - return false - } + const isMatchingGenericTagCriterion = + (key: string, criterion: string) => + (tag: Tag): boolean => { + const [, tagName] = key + if (tag[0] !== tagName) { + return false + } - if (isGeohashPrefixCriterion(key, criterion)) { - return tag[1].startsWith(stripGeohashPrefixWildcard(criterion)) - } + if (isGeohashPrefixCriterion(key, criterion)) { + return tag[1].startsWith(stripGeohashPrefixWildcard(criterion)) + } - return tag[1] === criterion - } + return tag[1] === criterion + } // NIP-01: Basic protocol flow description @@ -96,7 +98,9 @@ export const isEventMatchingFilter = Object.entries(filter) .filter(([key, criteria]) => isGenericTagQuery(key) && Array.isArray(criteria)) .some(([key, criteria]) => { - return !event.tags.some((tag) => criteria.some((criterion) => isMatchingGenericTagCriterion(key, criterion)(tag))) + return !event.tags.some((tag) => + criteria.some((criterion) => isMatchingGenericTagCriterion(key, criterion)(tag)), + ) }) ) { return false @@ -205,6 +209,10 @@ export const isEphemeralEvent = (event: Event): boolean => { return event.kind >= EventKinds.EPHEMERAL_FIRST && event.kind <= EventKinds.EPHEMERAL_LAST } +export const isDvmJobRequestEvent = (event: Event): boolean => { + return event.kind >= EventKinds.DVM_JOB_REQUEST_FIRST && event.kind <= EventKinds.DVM_JOB_REQUEST_LAST +} + export const isParameterizedReplaceableEvent = (event: Event): boolean => { return ( event.kind >= EventKinds.PARAMETERIZED_REPLACEABLE_FIRST && event.kind <= EventKinds.PARAMETERIZED_REPLACEABLE_LAST From ad58095ea904643a4e428f0fd8bafda3e9c24b24 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Wed, 12 Aug 2026 20:04:57 +0530 Subject: [PATCH 4/4] test(dvm): add tests for dvm job request ingestion Signed-off-by: Priyanshubhartistm --- .../factories/event-strategy-factory.spec.ts | 31 +++- .../factories/message-handler-factory.spec.ts | 19 ++- .../websocket-adapter-factory.spec.ts | 17 ++- .../dvm-job-request-event-strategy.spec.ts | 132 ++++++++++++++++++ test/unit/utils/event.spec.ts | 19 +++ 5 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index 37dc159e..e24c0294 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -1,8 +1,14 @@ import { expect } from 'chai' -import { IEventRepository, IInviteCodeRepository, IUserRepository } from '../../../src/@types/repositories' +import { + IDvmJobRepository, + IEventRepository, + IInviteCodeRepository, + IUserRepository, +} from '../../../src/@types/repositories' import { DefaultEventStrategy } from '../../../src/handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../../../src/handlers/event-strategies/delete-event-strategy' +import { DvmJobRequestEventStrategy } from '../../../src/handlers/event-strategies/dvm-job-request-event-strategy' import { EphemeralEventStrategy } from '../../../src/handlers/event-strategies/ephemeral-event-strategy' import { Event } from '../../../src/@types/event' import { EventKinds } from '../../../src/constants/base' @@ -24,6 +30,7 @@ describe('eventStrategyFactory', () => { let eventRepository: IEventRepository let userRepository: IUserRepository let inviteCodeRepository: IInviteCodeRepository + let dvmJobRepository: IDvmJobRepository let cache: ICacheAdapter let settings: () => Settings let event: Event @@ -34,12 +41,20 @@ describe('eventStrategyFactory', () => { eventRepository = {} as any userRepository = {} as any inviteCodeRepository = {} as any + dvmJobRepository = {} as any cache = {} as any - settings = () => ({ info: { relay_url: 'wss://test.relay' } } as any) + settings = () => ({ info: { relay_url: 'wss://test.relay' } }) as any event = {} as any adapter = {} as any - factory = eventStrategyFactory(eventRepository, userRepository, inviteCodeRepository, cache, settings) + factory = eventStrategyFactory( + eventRepository, + userRepository, + inviteCodeRepository, + dvmJobRepository, + cache, + settings, + ) }) it('returns ReplaceableEvent given a set_metadata event', () => { @@ -136,4 +151,14 @@ describe('eventStrategyFactory', () => { event.kind = EventKinds.NIP43_LEAVE_REQUEST expect(factory([event, adapter])).to.be.an.instanceOf(LeaveRequestEventStrategy) }) + + it('returns DvmJobRequestEventStrategy given a DVM job request (kind 5000-5999)', () => { + event.kind = EventKinds.DVM_JOB_REQUEST_FIRST + expect(factory([event, adapter])).to.be.an.instanceOf(DvmJobRequestEventStrategy) + }) + + it('returns DvmJobRequestEventStrategy given the last DVM job request kind (5999)', () => { + event.kind = EventKinds.DVM_JOB_REQUEST_LAST + expect(factory([event, adapter])).to.be.an.instanceOf(DvmJobRequestEventStrategy) + }) }) diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 68844389..9ce8a284 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -1,6 +1,12 @@ import { expect } from 'chai' -import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' +import { + IDvmJobRepository, + IEventRepository, + IInviteCodeRepository, + INip05VerificationRepository, + IUserRepository, +} from '../../../src/@types/repositories' import { IncomingMessage, MessageType } from '../../../src/@types/messages' import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler' import { Event } from '../../../src/@types/event' @@ -19,6 +25,7 @@ describe('messageHandlerFactory', () => { let userRepository: IUserRepository let nip05VerificationRepository: INip05VerificationRepository let inviteCodeRepository: IInviteCodeRepository + let dvmJobRepository: IDvmJobRepository let message: IncomingMessage let adapter: IWebSocketAdapter let factory @@ -42,11 +49,18 @@ describe('messageHandlerFactory', () => { userRepository = {} as any nip05VerificationRepository = {} as any inviteCodeRepository = {} as any + dvmJobRepository = {} as any adapter = {} as any event = { tags: [], } as any - factory = messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository) + factory = messageHandlerFactory( + eventRepository, + userRepository, + nip05VerificationRepository, + inviteCodeRepository, + dvmJobRepository, + ) }) afterEach(() => { @@ -89,4 +103,3 @@ describe('messageHandlerFactory', () => { expect(() => factory([message, adapter])).to.throw(Error, 'Unknown message type: undefined') }) }) - diff --git a/test/unit/factories/websocket-adapter-factory.spec.ts b/test/unit/factories/websocket-adapter-factory.spec.ts index d1ec9405..48c34ba6 100644 --- a/test/unit/factories/websocket-adapter-factory.spec.ts +++ b/test/unit/factories/websocket-adapter-factory.spec.ts @@ -3,7 +3,13 @@ import { IncomingMessage } from 'http' import Sinon from 'sinon' import WebSocket from 'ws' -import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' +import { + IDvmJobRepository, + IEventRepository, + IInviteCodeRepository, + INip05VerificationRepository, + IUserRepository, +} from '../../../src/@types/repositories' import { IWebSocketServerAdapter } from '../../../src/@types/adapters' import { SettingsStatic } from '../../../src/utils/settings' import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter' @@ -33,6 +39,7 @@ describe('webSocketAdapterFactory', () => { const userRepository: IUserRepository = {} as any const nip05VerificationRepository: INip05VerificationRepository = {} as any const inviteCodeRepository: IInviteCodeRepository = {} as any + const dvmJobRepository: IDvmJobRepository = {} as any const client: WebSocket = { on: onStub, @@ -48,7 +55,13 @@ describe('webSocketAdapterFactory', () => { } as any const webSocketServerAdapter: IWebSocketServerAdapter = {} as any - const factory = webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository) + const factory = webSocketAdapterFactory( + eventRepository, + userRepository, + nip05VerificationRepository, + inviteCodeRepository, + dvmJobRepository, + ) expect(factory([client, request, webSocketServerAdapter])).to.be.an.instanceOf(WebSocketAdapter) }) }) diff --git a/test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts b/test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts new file mode 100644 index 00000000..cf2cad88 --- /dev/null +++ b/test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts @@ -0,0 +1,132 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' + +chai.use(chaiAsPromised) + +const { expect } = chai + +import { DvmJobRequestEventStrategy } from '../../../../src/handlers/event-strategies/dvm-job-request-event-strategy' +import { Event } from '../../../../src/@types/event' +import { IDvmJobRepository, IEventRepository } from '../../../../src/@types/repositories' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { MessageType } from '../../../../src/@types/messages' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +describe('DvmJobRequestEventStrategy', () => { + const event: Event = { + id: 'event-id', + pubkey: 'requester-pubkey', + kind: 5000, + } as any + + let webSocket: IWebSocketAdapter + let eventRepository: IEventRepository + let dvmJobRepository: IDvmJobRepository + + let webSocketEmitStub: Sinon.SinonStub + let eventRepositoryCreateStub: Sinon.SinonStub + let dvmJobRepositoryCreateStub: Sinon.SinonStub + + let strategy: IEventStrategy> + + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + webSocketEmitStub = sandbox.stub() + webSocket = { + emit: webSocketEmitStub, + } as any + + eventRepositoryCreateStub = sandbox.stub() + eventRepository = { + create: eventRepositoryCreateStub, + } as any + + dvmJobRepositoryCreateStub = sandbox.stub() + dvmJobRepository = { + create: dvmJobRepositoryCreateStub, + } as any + + strategy = new DvmJobRequestEventStrategy(webSocket, eventRepository, dvmJobRepository) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('execute', () => { + it('creates the event', async () => { + eventRepositoryCreateStub.resolves(1) + dvmJobRepositoryCreateStub.resolves({}) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + }) + + it('records a dvm job when the event is newly created', async () => { + eventRepositoryCreateStub.resolves(1) + dvmJobRepositoryCreateStub.resolves({}) + + await strategy.execute(event) + + expect(dvmJobRepositoryCreateStub).to.have.been.calledOnceWithExactly('event-id', 'requester-pubkey', 5000) + }) + + it('broadcasts the event when newly created', async () => { + eventRepositoryCreateStub.resolves(1) + dvmJobRepositoryCreateStub.resolves({}) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + 'event-id', + true, + '', + ]) + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Broadcast, event) + }) + + it('does not broadcast or record a job when the event is a duplicate', async () => { + eventRepositoryCreateStub.resolves(0) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + 'event-id', + true, + 'duplicate:', + ]) + expect(dvmJobRepositoryCreateStub).not.to.have.been.called + }) + + it('does not reject the event when job recording fails', async () => { + eventRepositoryCreateStub.resolves(1) + dvmJobRepositoryCreateStub.rejects(new Error('db unavailable')) + + await expect(strategy.execute(event)).to.eventually.be.fulfilled + + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + 'event-id', + true, + '', + ]) + }) + + it('rejects if unable to create the event', async () => { + const error = new Error('event creation failed') + eventRepositoryCreateStub.rejects(error) + + await expect(strategy.execute(event)).to.eventually.be.rejectedWith(error) + + expect(dvmJobRepositoryCreateStub).not.to.have.been.called + }) + }) +}) diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index 39d26c33..ad81b2b0 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -4,6 +4,7 @@ import { getEventExpiration, isDeleteEvent, isDirectMessageEvent, + isDvmJobRequestEvent, isEphemeralEvent, isEventIdValid, isEventMatchingFilter, @@ -415,6 +416,24 @@ describe('NIP-16', () => { expect(isEphemeralEvent({ kind: 30000 } as any)).to.be.false }) }) + + describe('isDvmJobRequestEvent', () => { + it('returns true for the first kind in the DVM job request range (5000)', () => { + expect(isDvmJobRequestEvent({ kind: 5000 } as any)).to.be.true + }) + + it('returns true for the last kind in the DVM job request range (5999)', () => { + expect(isDvmJobRequestEvent({ kind: 5999 } as any)).to.be.true + }) + + it('returns false for a kind below the DVM job request range', () => { + expect(isDvmJobRequestEvent({ kind: 4999 } as any)).to.be.false + }) + + it('returns false for a kind above the DVM job request range', () => { + expect(isDvmJobRequestEvent({ kind: 6000 } as any)).to.be.false + }) + }) }) describe('NIP-17', () => {