Skip to content

Import and export reference plugin

adonia-import-export provides streaming CSV/XLSX codecs, scope-bound import orchestration, durable import history, reusable export presets, private Drive/S3 artifacts, and owned background transfers. Its installable manifest contributes guarded panel routes, worker action names, migrations, and a doctor check; the host still supplies authenticated controller and worker bindings because authorization, resource queries, and write policy are application-owned.

Install

pnpm add adonia-import-export @adonisjs/lucid

Large background transfers also require an AdoniaQueueContract. Install the supported BullMQ adapter when chosen, and install Drive for S3/GCS/private object storage:

pnpm add @adonia/queue-bullmq bullmq @adonisjs/drive

Declare an allowlist

Every imported or exported attribute must be in static importExportColumns. The server plugin validates this declaration at resource boot.

import { BaseResource } from '@adonia/core'
import { defineTransferColumns } from 'adonia-import-export'
import Post from '#models/post'

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  static importExportColumns = defineTransferColumns([
    {
      key: 'title',
      label: 'Title',
      required: true,
      aliases: ['Post title'],
    },
    {
      key: 'readingMinutes',
      label: 'Reading minutes',
      parse: (cell: string) => Number(cell),
    },
  ])

  override schema() {
    return []
  }
}

Mapping suggestions are case-insensitive hints only. The user must confirm a ColumnMapping[]; resolveColumnMapping rejects duplicate headings, targets outside the allowlist, non-importable columns, and missing required mappings. ImportService persists the complete server-owned panel/actor/tenant/parent identity on every source, rechecks exact equality during confirmation, validates bounded rows, and never sends unallowlisted cells to a writer.

Attach the installable server manifest. It validates resource allowlists and registers the package routes, workers, migrations, and doctor check:

import { Panel } from '@adonia/core'
import { importExportPlugin } from 'adonia-import-export'

export const admin = Panel.make('admin').plugins([importExportPlugin()])

Install the mapping field in the UI registry:

import { importExportUiPlugin } from 'adonia-import-export/client'
import { adonia } from '@adonia/ui'

export default adonia({
  registry(registry) {
    registry.use(importExportUiPlugin)
  },
})

ImportWorkflow is a controlled upload → mapping → confirmation component. Its upload and confirm callbacks must call authenticated host routes under the panel middleware stack.

Import service

Construct ImportService with an ImportSourceStore, a server-derived scope, a resource-to-column resolver, and writeRows. FileImportSourceStore is the private shared-volume implementation. For horizontally scaled deployments, combine DriveSourceStore with LucidSourceRecordStore; bytes live on the configured private Drive disk (including S3), while scope and expiry metadata live in the database.

confirm({ dryRun: true }) validates every row, resolves upsert matches, returns row errors plus wouldCreate/wouldUpdate, invokes no writer, and retains the uploaded source so the same mapping can be committed afterward. Upsert mode requires mapped matchOn key(s), matchRows, and—outside preview—writeUpserts; conflictPolicy: 'skip' | 'update' controls matched rows. When artifacts is configured, a non-preview import with rejected rows returns a signed failure CSV containing the original row, source row number, worksheet identity when applicable, and validation errors.

Imports default to at most 100,000 data rows, 50 columns, and one worksheet. maxRows, maxColumns, maxWorksheets, upload byte, cell, row, error, and batch options may tighten those limits. The host writers still own transactions, model hooks, tenant stamping, and authorization.

Resource action factories

Actions remain explicit resource declarations; installing the manifest does not silently add actions to resources. createImportAction confirms an opaque source prepared by an authenticated upload route, and createExportAction queues only rows already authorized by Adonia. Both factories reject malformed form state before calling their configured services.

The factories have deliberately different jobs:

  • createImportAction({ service, resource?, slug?, label?, icon?, contexts? }) creates a synchronous page action. It accepts an opaque sourceId plus the confirmed mapping and calls the scope-bound ImportService.confirm.
  • createExportAction({ service, columns, identity, resource?, statusUrl?, slug?, label?, icon?, contexts? }) creates row and bulk actions. columns is the server-owned export allowlist, identity derives panel/actor/tenant/lens/parent from the authorized action context, and statusUrl may point at the owned progress page.
  • importExportPlugin() returns the installable manifest. Host container bindings implement the contributed HTTP and worker boundaries; routes never bypass the panel middleware or host authorization.
import { BaseResource, type AnyAction } from '@adonia/core'
import {
  createExportAction,
  createImportAction,
  defineTransferColumns,
  type ExportActionOptions,
  type ImportService,
  type QueuedExportService,
} from 'adonia-import-export'

type Post = { id: number; title: string }
declare const imports: ImportService<Post>
declare const exports: QueuedExportService
declare const exportIdentity: ExportActionOptions<Post>['identity']

class PostResource extends BaseResource {
  static override slug = 'posts'
  static importExportColumns = defineTransferColumns<Post>([
    { key: 'id', label: 'ID', importable: false },
    { key: 'title', label: 'Title', required: true },
  ])

  override schema() {
    return []
  }

  override actions(): AnyAction[] {
    return [
      createImportAction({ service: imports, resource: 'posts' }),
      createExportAction({
        service: exports,
        resource: 'posts',
        columns: PostResource.importExportColumns,
        identity: exportIdentity,
      }),
    ]
  }
}

The import action modal accepts the sourceId and confirmed ColumnMapping[] returned by the host upload/preparation flow. The export modal accepts format, safe filename, and an explicit subset of the server-owned export allowlist. Supply statusUrl when the host has an authenticated progress page and wants the action result to open it.

Manifest routes and host bindings

The manifest contributes panel-scoped named routes. Each handler resolves the host’s adonia.importExportController binding; it does not invent authentication or database policy. The worker actions resolve adonia.importExportWorker. Missing bindings fail the plugin doctor check rather than degrading to unscoped access.

Contributed route Required host behavior
POST transfers/:resource/import/upload Authorize import, derive panel/actor/tenant/parent, construct the scope-bound service, stream the upload, and return ImportPreparation.
POST transfers/:resource/import/confirm Re-authorize and call ImportService.confirm; the service independently checks the persisted source scope. Set dryRun for preview.
GET transfers/:resource/imports Return QueuedImportService.history for the exact current scope.
POST transfers/:resource/imports/:id/retry Upload the corrected failure CSV, then call retryFromFailures with the new opaque source.
GET/DELETE transfers/:resource/exports/:id Return owned status or request cooperative cancellation. Translate a missing/mismatched job to 404.
GET .../download / POST .../regenerate Require a finished owned job; redirect to its signed URL or issue a new short-lived URL from the persisted artifact key.
GET/POST/DELETE transfers/:resource/export-presets List, save, or delete named column/filter sets inside the exact owner scope.
POST transfers/:resource/export-presets/:id/run Call enqueuePreset; validate and reapply the persisted opaque filter state in the scoped worker row loader.

ImportExportControllerContract<DownloadResponse> and the endpoint DTOs type these host adapters without coupling the package’s storage/services to Adonis HTTP response classes.

Background imports and exports

QueuedExportService persists and dispatches exports. savePreset stores an owned descriptor-column/filter selection, and enqueuePreset replays both into a new job; filter data remains opaque and must be validated by the application row loader. QueuedExportWorker replays the complete identity, resolves descriptor-owned columns, enforces the configured row/column caps, streams CSV or XLSX, optionally gzip-compresses the artifact, and persists the stable object key separately from its expiring signed URL. Configure the service with the same artifact store to use regenerateDownload.

QueuedImportService persists a scope-bound confirmation and dispatches IMPORT_ACTION. QueuedImportWorker rejects payload/history identity drift before resolving the host’s scope-bound ImportService, cooperatively checks cancellation, records progress, and stores the result/failure artifact. history is owner-scoped; retryFromFailures creates a linked history entry using a newly uploaded corrected failure CSV.

Use FileImportJobRepository for a shared private volume or LucidImportJobRepository for multi-host history and atomic claims. FileExportArtifactStore signs local opaque URLs; DriveArtifactStore delegates private streaming writes and signed downloads to @adonisjs/drive, so S3/GCS drivers work without buffering whole artifacts. FileExportPresetRepository and LucidExportPresetRepository persist named column/filter sets.

import type {
  ActionBatchStore,
  ActionWorkerRuntime,
  AdoniaQueueContract,
} from '@adonia/core'
import {
  IMPORT_ACTION,
  IMPORT_EXPORT_ACTION,
  QueuedExportService,
  QueuedExportWorker,
  QueuedImportService,
  QueuedImportWorker,
  type ExportArtifactStore,
  type ExportJobRepository,
  type ImportJobRepository,
  type ImportService,
} from 'adonia-import-export'

declare const queue: AdoniaQueueContract
declare const exportJobs: ExportJobRepository
declare const importJobs: ImportJobRepository
declare const artifacts: ExportArtifactStore
declare const batches: ActionBatchStore
declare const importsForJob: () => ImportService<{ title: string }>

export const exports = new QueuedExportService({
  queue,
  jobs: exportJobs,
  batches,
  artifacts,
})
export const imports = new QueuedImportService({
  queue,
  jobs: importJobs,
  batches,
})
const exportWorker = new QueuedExportWorker({
  jobs: exportJobs,
  artifacts,
  columns: () => [{ key: 'title', label: 'Title' }],
  async *loadRows() {},
})
const importWorker = new QueuedImportWorker({
  jobs: importJobs,
  service: importsForJob,
})

export const transferWorker: ActionWorkerRuntime = {
  work(job) {
    if (job.action === IMPORT_EXPORT_ACTION) return exportWorker.work(job)
    if (job.action === IMPORT_ACTION) return importWorker.work(job)
    throw new Error(`Unsupported transfer action: ${job.action}`)
  },
}

Web and worker processes must share the queue, repositories, Drive disk, and resource declarations. progressEvery must be a positive integer; zero, negative, and fractional values throw E_ADONIA_INVALID_CONFIG at construction. Schedule source/artifact cleanup according to retention policy.

Fail-closed tenant and nested-resource pattern

Never accept panel, resource, actor, tenant, lens, parent, target IDs, or selected columns from standalone client fields. Resolve them from the authenticated request/action context. Every ImportSourceRecord contains panel, actor, tenant, optional parent, and resource; ImportService.confirm compares all of them to its server-owned scope before reading rows. Treat a missing record or any mismatch as not found.

The worker must independently reject unknown resources and reapply every serialized scope before reading rows. Start from an empty/denied query, require the current tenant and parent, re-authorize the action for the persisted actor if the application supports worker-side policy reconstruction, and apply ActionTargets only after those constraints. Never fall back to an unscoped model query when tenant or parent resolution fails.

import type {
  ImportEndpointScope,
  QueuedExportIdentity,
} from 'adonia-import-export'

export function requireNestedScope(
  scope: ImportEndpointScope | QueuedExportIdentity
): { tenantId: string; parentId: string | number } {
  if (
    typeof scope.tenant !== 'object'
    || scope.tenant === null
    || Array.isArray(scope.tenant)
    || typeof scope.tenant.id !== 'string'
    || scope.parent === undefined
  ) {
    throw new Error('Scoped resource not found')
  }
  return { tenantId: scope.tenant.id, parentId: scope.parent.id }
}

Security checklist

  • Authorize resource/action access before upload, confirmation, enqueue, history, retry, preset mutation, cancellation, and download.
  • Reapply panel, tenant, parent, lens, soft-delete, and selection/query scopes in every worker loader/writer.
  • Keep sources and artifacts private; expose only short-lived signed downloads and regenerate them from an owned persisted key.
  • Treat filenames, spreadsheet cells, formulas, queue payloads, persisted job state, mappings, match keys, and filters as untrusted.
  • Run expiry/retention cleanup and avoid storing sensitive values in safe validation messages or failure CSVs.

See adonia-import-export API for the complete server surface and its ./client subpath.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close