Skip to content

Commit 492a552

Browse files
Copilothotlong
andcommitted
feat: expand Export API with full CRUD endpoints, IExportService contract, and cursor-based pagination
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 4404ca5 commit 492a552

7 files changed

Lines changed: 739 additions & 5 deletions

File tree

ROADMAP.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,12 @@ Cursor/Pagination protocol for large-scale data import/export with template-base
196196
| Streaming/chunked export endpoint (CSV/JSON/Excel) || `api/export.zod.ts``CreateExportJobRequestSchema`, `ExportFormat` |
197197
| Import validation & deduplication || `api/export.zod.ts``ImportValidationConfigSchema`, `DeduplicationStrategy` |
198198
| Template-based field mapping for import/export || `api/export.zod.ts``ExportImportTemplateSchema`, `FieldMappingEntrySchema` |
199-
| Scheduled export jobs & status query || `api/export.zod.ts``ScheduledExportSchema` |
200-
| Export job progress & download URL || `api/export.zod.ts``ExportJobProgressSchema` |
199+
| Scheduled export jobs & status query || `api/export.zod.ts``ScheduledExportSchema`, `ScheduleExportRequestSchema` |
200+
| Export job progress & download URL || `api/export.zod.ts``ExportJobProgressSchema`, `GetExportJobDownloadResponseSchema` |
201+
| Export job listing & history || `api/export.zod.ts``ListExportJobsRequestSchema`, `ExportJobSummarySchema` |
202+
| Export API contracts (6 endpoints) || `api/export.zod.ts``ExportApiContracts` |
203+
| IExportService contract || `contracts/export-service.ts` |
204+
| Cursor-based pagination in data.find() || `api/protocol.zod.ts``FindDataResponseSchema` (`nextCursor` + `hasMore`) |
201205

202206
### 5. API Capability Declaration & Service Discovery
203207

packages/spec/src/api/export.test.ts

Lines changed: 223 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import {
1313
ExportImportTemplateSchema,
1414
ScheduledExportSchema,
1515
ExportApiContracts,
16+
GetExportJobDownloadRequestSchema,
17+
GetExportJobDownloadResponseSchema,
18+
ListExportJobsRequestSchema,
19+
ListExportJobsResponseSchema,
20+
ExportJobSummarySchema,
21+
ScheduleExportRequestSchema,
22+
ScheduleExportResponseSchema,
1623
} from './export.zod';
1724

1825
// ==========================================
@@ -474,22 +481,236 @@ describe('ScheduledExportSchema', () => {
474481
});
475482
});
476483

484+
// ==========================================
485+
// Get Export Job Download
486+
// ==========================================
487+
488+
describe('GetExportJobDownloadRequestSchema', () => {
489+
it('should accept a valid request', () => {
490+
const req = GetExportJobDownloadRequestSchema.parse({ jobId: 'job_001' });
491+
expect(req.jobId).toBe('job_001');
492+
});
493+
494+
it('should reject missing jobId', () => {
495+
expect(() => GetExportJobDownloadRequestSchema.parse({})).toThrow();
496+
});
497+
});
498+
499+
describe('GetExportJobDownloadResponseSchema', () => {
500+
it('should accept a valid response', () => {
501+
const resp = GetExportJobDownloadResponseSchema.parse({
502+
success: true,
503+
data: {
504+
jobId: 'job_001',
505+
downloadUrl: 'https://storage.example.com/exports/job_001.csv?token=abc',
506+
fileName: 'account_export_2026-02-01.csv',
507+
fileSize: 5242880,
508+
format: 'csv',
509+
expiresAt: '2026-02-02T10:00:00Z',
510+
checksum: 'sha256:abc123',
511+
},
512+
});
513+
expect(resp.data.downloadUrl).toContain('storage.example.com');
514+
expect(resp.data.fileName).toContain('.csv');
515+
expect(resp.data.checksum).toBe('sha256:abc123');
516+
});
517+
518+
it('should accept response without optional checksum', () => {
519+
const resp = GetExportJobDownloadResponseSchema.parse({
520+
success: true,
521+
data: {
522+
jobId: 'job_001',
523+
downloadUrl: 'https://storage.example.com/exports/job_001.json',
524+
fileName: 'export.json',
525+
fileSize: 1024,
526+
format: 'json',
527+
expiresAt: '2026-02-02T10:00:00Z',
528+
},
529+
});
530+
expect(resp.data.checksum).toBeUndefined();
531+
});
532+
});
533+
534+
// ==========================================
535+
// List Export Jobs
536+
// ==========================================
537+
538+
describe('ListExportJobsRequestSchema', () => {
539+
it('should apply defaults', () => {
540+
const req = ListExportJobsRequestSchema.parse({});
541+
expect(req.limit).toBe(20);
542+
expect(req.object).toBeUndefined();
543+
expect(req.status).toBeUndefined();
544+
expect(req.cursor).toBeUndefined();
545+
});
546+
547+
it('should accept a full request', () => {
548+
const req = ListExportJobsRequestSchema.parse({
549+
object: 'account',
550+
status: 'completed',
551+
limit: 50,
552+
cursor: 'cursor_abc',
553+
});
554+
expect(req.object).toBe('account');
555+
expect(req.status).toBe('completed');
556+
expect(req.limit).toBe(50);
557+
expect(req.cursor).toBe('cursor_abc');
558+
});
559+
560+
it('should reject limit exceeding max', () => {
561+
expect(() => ListExportJobsRequestSchema.parse({ limit: 200 })).toThrow();
562+
});
563+
});
564+
565+
describe('ExportJobSummarySchema', () => {
566+
it('should accept a valid summary', () => {
567+
const summary = ExportJobSummarySchema.parse({
568+
jobId: 'job_001',
569+
object: 'account',
570+
status: 'completed',
571+
format: 'csv',
572+
totalRecords: 5000,
573+
fileSize: 2048000,
574+
createdAt: '2026-02-01T10:00:00Z',
575+
completedAt: '2026-02-01T10:05:00Z',
576+
createdBy: 'user_admin',
577+
});
578+
expect(summary.jobId).toBe('job_001');
579+
expect(summary.totalRecords).toBe(5000);
580+
});
581+
});
582+
583+
describe('ListExportJobsResponseSchema', () => {
584+
it('should accept a response with jobs', () => {
585+
const resp = ListExportJobsResponseSchema.parse({
586+
success: true,
587+
data: {
588+
jobs: [
589+
{
590+
jobId: 'job_001',
591+
object: 'account',
592+
status: 'completed',
593+
format: 'csv',
594+
createdAt: '2026-02-01T10:00:00Z',
595+
},
596+
],
597+
nextCursor: 'cursor_next',
598+
hasMore: true,
599+
},
600+
});
601+
expect(resp.data.jobs).toHaveLength(1);
602+
expect(resp.data.nextCursor).toBe('cursor_next');
603+
expect(resp.data.hasMore).toBe(true);
604+
});
605+
606+
it('should accept an empty list', () => {
607+
const resp = ListExportJobsResponseSchema.parse({
608+
success: true,
609+
data: {
610+
jobs: [],
611+
hasMore: false,
612+
},
613+
});
614+
expect(resp.data.jobs).toHaveLength(0);
615+
expect(resp.data.hasMore).toBe(false);
616+
});
617+
});
618+
619+
// ==========================================
620+
// Schedule Export Request/Response
621+
// ==========================================
622+
623+
describe('ScheduleExportRequestSchema', () => {
624+
it('should accept a valid request', () => {
625+
const req = ScheduleExportRequestSchema.parse({
626+
name: 'weekly_account_export',
627+
label: 'Weekly Account Export',
628+
object: 'account',
629+
format: 'csv',
630+
fields: ['name', 'email'],
631+
schedule: {
632+
cronExpression: '0 6 * * MON',
633+
timezone: 'America/New_York',
634+
},
635+
delivery: {
636+
method: 'email',
637+
recipients: ['admin@example.com'],
638+
},
639+
});
640+
expect(req.name).toBe('weekly_account_export');
641+
expect(req.schedule.cronExpression).toBe('0 6 * * MON');
642+
expect(req.delivery.method).toBe('email');
643+
});
644+
645+
it('should apply defaults', () => {
646+
const req = ScheduleExportRequestSchema.parse({
647+
name: 'daily_export',
648+
object: 'order',
649+
schedule: { cronExpression: '0 0 * * *' },
650+
delivery: { method: 'storage', storagePath: '/exports/daily/' },
651+
});
652+
expect(req.format).toBe('csv');
653+
expect(req.schedule.timezone).toBe('UTC');
654+
});
655+
656+
it('should reject invalid name (not snake_case)', () => {
657+
expect(() => ScheduleExportRequestSchema.parse({
658+
name: 'WeeklyExport',
659+
object: 'account',
660+
schedule: { cronExpression: '0 6 * * MON' },
661+
delivery: { method: 'email' },
662+
})).toThrow();
663+
});
664+
});
665+
666+
describe('ScheduleExportResponseSchema', () => {
667+
it('should accept a valid response', () => {
668+
const resp = ScheduleExportResponseSchema.parse({
669+
success: true,
670+
data: {
671+
id: 'sched_001',
672+
name: 'weekly_account_export',
673+
enabled: true,
674+
nextRunAt: '2026-02-24T06:00:00Z',
675+
createdAt: '2026-02-21T09:00:00Z',
676+
},
677+
});
678+
expect(resp.data.id).toBe('sched_001');
679+
expect(resp.data.enabled).toBe(true);
680+
expect(resp.data.nextRunAt).toBeDefined();
681+
});
682+
});
683+
477684
// ==========================================
478685
// Export API Contracts
479686
// ==========================================
480687

481688
describe('ExportApiContracts', () => {
482-
it('should define 2 contracts', () => {
483-
expect(Object.keys(ExportApiContracts)).toHaveLength(2);
689+
it('should define 6 contracts', () => {
690+
expect(Object.keys(ExportApiContracts)).toHaveLength(6);
484691
});
485692

486693
it('should have correct HTTP methods', () => {
487694
expect(ExportApiContracts.createExportJob.method).toBe('POST');
488695
expect(ExportApiContracts.getExportJobProgress.method).toBe('GET');
696+
expect(ExportApiContracts.getExportJobDownload.method).toBe('GET');
697+
expect(ExportApiContracts.listExportJobs.method).toBe('GET');
698+
expect(ExportApiContracts.scheduleExport.method).toBe('POST');
699+
expect(ExportApiContracts.cancelExportJob.method).toBe('POST');
489700
});
490701

491702
it('should have valid paths', () => {
492703
expect(ExportApiContracts.createExportJob.path).toContain('/export');
493704
expect(ExportApiContracts.getExportJobProgress.path).toContain('/export');
705+
expect(ExportApiContracts.getExportJobDownload.path).toContain('/download');
706+
expect(ExportApiContracts.listExportJobs.path).toBe('/api/v1/data/export');
707+
expect(ExportApiContracts.scheduleExport.path).toContain('/schedules');
708+
expect(ExportApiContracts.cancelExportJob.path).toContain('/cancel');
709+
});
710+
711+
it('should have input/output schemas for all contracts', () => {
712+
Object.values(ExportApiContracts).forEach((contract) => {
713+
expect(contract.output).toBeDefined();
714+
});
494715
});
495716
});

0 commit comments

Comments
 (0)