[data-reveal]{opacity:1!important;transform:none!important}
CMS Interfaces

Helpers

Overview

A few small surfaces sit beside the components and support them: the @Searchable decorator that tells a list route which columns free-text search covers, the export-job runner behind every long download, the tenant-export archive built on top of it, and two helper modules — file references and archive-mode defaults — that the table view uses internally and that are available when you build equivalent routes yourself.

import { Searchable, SearchableMeta } from "@antelopejs-private/cms/interfaces/cms-base/searchable";
import { runExportJob } from "@antelopejs-private/cms/interfaces/cms-base/export-jobs";
import { collectFileRefs } from "@antelopejs-private/cms/interfaces/cms-base/helpers/file-refs";
import { applyArchiveModeDefaultRules } from "@antelopejs-private/cms/interfaces/cms-base/helpers/archive-mode-helpers";
import { startTenantExportJob } from "@antelopejs-private/cms/interfaces/cms-base/tenant-export-archive";

Searchable, SearchableMeta and everything in export-jobs and tenant-export-archive are also exported from the package root, @antelopejs-private/cms/interfaces/cms-base. The two helpers/* modules are not in that barrel and are imported from their own subpath.

Make Columns Searchable

@Searchable(compareMode?) is a property decorator on a data controller field. It records the field, and the compare mode used to match it, on the controller's SearchableMeta. The list route reads that record when the request carries a search query parameter and ORs one condition per recorded field into the query. A controller with no searchable field ignores the parameter.

import { Listable } from "@antelopejs/interface-data-api/metadata";
import { DefaultDataTypes } from "@antelopejs-private/cms/interfaces/cms-base/data-types";
import { Searchable } from "@antelopejs-private/cms/interfaces/cms-base/searchable";
import {
  Column,
  Exported,
} from "@antelopejs-private/cms/interfaces/cms-base/table-view";

export class customerDataAPI extends DataController(/* … */) {
  @Searchable()
  @Listable()
  @Exported()
  @Column({ name: "Name", type: new DefaultDataTypes.StringType() })
  declare name: string;

  @Searchable("is")
  @Listable()
  @Column({ name: "Reference", type: new DefaultDataTypes.StringType() })
  declare reference: string;
}
ParameterDescription
compareModeId of a registered DataCompareMode used to match this field. Defaults to "contains".

The id is the one the mode was registered under with @RegisterDataCompareMode"contains", "is", "array_contains_string" and the rest of the catalogue are listed in Data Types.

SearchableMeta

SearchableMeta is the metadata object the decorator writes to. Read it with GetMetadata(controller, SearchableMeta) from @antelopejs/interface-core.

MemberDescription
searchableFieldsRecord<string, string> mapping field name to compare-mode id.
setSearchableField(key, compareMode)Records one field. Called by the decorator.
getSearchableFields()Returns the same record.

Run an Export Job

runExportJob starts a background job, hands you a ticket immediately, and leaves the generation running. The pattern is three routes: one that starts the job, one that reports its progress, one that streams the finished file. The table view's export action is built on exactly these three, and any route of your own can reuse them.

import fs from "node:fs";
import {
  Context,
  Get,
  Parameter,
  type RequestContext,
} from "@antelopejs/interface-api";
import {
  PageController,
  pagesCategory,
  RegisterPage,
} from "@antelopejs-private/cms/interfaces/cms/page";
import { AuthUser } from "@antelopejs-private/cms/interfaces/cms-auth";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";
import {
  downloadExportJob,
  getExportJobStatus,
  runExportJob,
} from "@antelopejs-private/cms/interfaces/cms-base/export-jobs";

const SCOPE = "customer-report";

@RegisterPage()
export class ReportPage extends PageController("report", {
  displayName: "Reports",
  icon: "i-ph-file-csv",
  category: pagesCategory,
}) {
  @Get("/report/start")
  start(@Context() ctx: RequestContext, @AuthUser() user: User) {
    return runExportJob<{ year: number }>({
      ctx,
      user,
      scope: SCOPE,
      context: { year: new Date().getFullYear() },
      filename: "customers",
      extension: "csv",
      contentType: "text/csv; charset=utf-8",
      generate: async ({ localPath, context, reportProgress }) => {
        fs.writeFileSync(localPath, "id,name\n");
        // … append rows, calling reportProgress(0-99) as you go
        await reportProgress(50);
      },
    });
  }

  @Get("/report/status/:jobId")
  status(
    @Context() ctx: RequestContext,
    @Parameter("jobId", "param") jobId: string,
    @AuthUser() user: User,
  ) {
    return getExportJobStatus(ctx, user, jobId, { scope: SCOPE });
  }

  @Get("/report/download/:jobId")
  download(
    @Context() ctx: RequestContext,
    @Parameter("jobId", "param") jobId: string,
    @AuthUser() user: User,
  ) {
    return downloadExportJob(ctx, user, jobId, { scope: SCOPE });
  }
}

RunExportJobOptions

function runExportJob<TContext = unknown>(
  opts: RunExportJobOptions<TContext>,
): Promise<ExportJobTicket>;
OptionTypeDescription
ctxRequestContextThe request. The job is stored in the schema of the tenant the request resolves to. Required.
userUserThe owner of the job. Required; a missing user fails with 401.
scopestringFree-form identifier for the origin of the job, checked again on status and download when you pass it there. Required.
contextTContextScope-specific payload stored with the job and handed back to generate and to isOwnedBy.
filenamestringBase name of the produced file, without extension or timestamp. Required.
extensionstringFile extension. Required.
contentTypestringMIME type used when uploading and when streaming the file back. Required.
deliverystringDelivery mode id. Defaults to DEFAULT_DELIVERY; an unknown id fails with 400.
deliveryPathstringClient-side path a delivered link points at. Required by email delivery.
retainUntilExpirybooleanKeep the record and its stored file after a download, until the stale-export sweep expires them.
maxExecutionTimeMsnumberAbort budget for generate. Defaults to 10 minutes.
generate(ctx: ExportJobGenerateContext<TContext>) => Promise<ExportJobResultSummary | void>Writes the file. Required.

runExportJob creates the job record, then starts generate without awaiting it and returns the ticket. When generate resolves, the file at localPath is uploaded to file storage and the job is marked completed; when it throws or the abort budget expires, the scratch file is removed and the job is marked failed.

A generator that assembled its output from several sources reports what was missing by returning a summary instead of nothing:

interface ExportJobResultSummary {
  partial: boolean;
  failures: ExportJobFailure[];
}

interface ExportJobFailure {
  source: string;
  error: string;
}

The summary is stored on the job and surfaced on its status, so a consumer can tell a complete export from one that succeeded with holes. source names the part that failed — a contributing module id, typically.

By default a job is deleted once it has been downloaded. Set retainUntilExpiry for a scope whose jobs outlive their first download: one that surfaces a job history, or one whose result is reached through a delivered link.

ExportJobTicket fieldTypeDescription
jobIdstringIdentifier to poll and download with.
extensionstringEcho of the requested extension.
filenamestringEcho of the requested base name.

ExportJobGenerateContext

FieldTypeDescription
jobIdstringIdentifier of this job.
tenantIdstringTenant the job belongs to.
localPathstringPath your generator writes the file to.
contextTContextThe payload passed as context to runExportJob.
reportProgress(progress: number) => Promise<void>Records progress. The value is floored and clamped to 099; repeating the last value writes nothing. Throws once the job is aborted.
signalAbortSignalAborted when the job exceeds maxExecutionTimeMs.

Status and Download

function getExportJobStatus<TContext = unknown>(
  ctx: RequestContext,
  user: User,
  jobId: string,
  access?: ExportJobAccessOptions<TContext>,
): Promise<ExportJobStatus>;

function downloadExportJob<TContext = unknown>(
  ctx: RequestContext,
  user: User,
  jobId: string,
  access?: ExportJobAccessOptions<TContext>,
): Promise<void>;

ExportJobStatus carries status: ExportStatus, progress: number, and the optional error, filename, extension, result and expiresAt. ExportStatus is the pending / completed / failed enum from Shared Types. result is the ExportJobResultSummary the generator returned, present once the job completed; expiresAt is when the sweep will drop the record and its stored file.

getExportJobExpiry(record) computes that moment from a record directly — updatedAt plus EXPORT_TTL_MS, which is one day.

downloadExportJob requires a completed job with a stored result — anything else fails with 400 or 500. It sets Content-Disposition (with a filename built by buildExportFilename), Content-Length when the size is known, and Access-Control-Expose-Headers, then pipes the stored object to the response. The function returns as soon as the pipe is set up rather than awaiting it; once the response stream closes, the stored object and the job record are both deleted.

loadExportJobForUser(ctx, user, jobId, access?) is the shared step underneath both: it resolves the tenant, loads the record, runs the access checks, and returns the model, the record and the parsed context. Use it when you need the record itself rather than its status.

downloadExportJob deletes the stored object and the job record once the response stream closes — unless the job was started with retainUntilExpiry, in which case both survive until the sweep.

List a Caller's Jobs

function listExportJobs(
  ctx: RequestContext,
  user: User,
  options?: ListExportJobsOptions,
): Promise<ExportJobSummary[]>;

Returns the caller's own jobs, most recent first — the history a scope such as a tenant data export surfaces next to its request action. ListExportJobsOptions narrows it: scope restricts to one scope, limit caps the count and defaults to 20.

ExportJobSummary extends ExportJobStatus with jobId, scope and createdAt. Listing is scoped to the caller, so it needs no access options: another user's jobs are never returned.

Access Checks

OptionTypeDescription
scopestringWhen set, the job's stored scope must match.
isOwnedBy(record, context, user) => booleanExtra ACL evaluated after the ownership and scope checks.

ExportJobOwnership<TContext> carries only isOwnedBy; ExportJobAccessOptions<TContext> extends it with scope. The job's userId is always compared against the caller regardless of what you pass, and every failed check answers 403.

Formats and Delivery

An export format is an Exporter, and a delivery mode is a Deliverer. Both are looked up by id.

import {
  DEFAULT_DELIVERY,
  DEFAULT_EXPORT_FORMAT,
  getDeliverer,
  getExporter,
} from "@antelopejs-private/cms/interfaces/cms-base/export-jobs";

const exporter = getExporter(DEFAULT_EXPORT_FORMAT); // "csv"
const writer = exporter.createWriter(localPath);
writer.writeHeaders(["id", "name"]);
writer.appendRow(["id", "name"], { id: "1", name: "Ada" });
writer.close();
SymbolShapeDescription
Exporter{ extension, contentType, createWriter(filePath): ExportWriter }One output format.
ExportWriter{ writeHeaders(headers), appendRow(headers, row), close() }Incremental writer for one file.
Deliverer{ validate?(options: ExportJobDeliveryOptions): void; deliver(exportRecord, resultPath): Promise<void> }One delivery mode. validate runs when the job is created, so an unusable delivery fails fast rather than after generation.
ExportJobDeliveryOptions{ delivery: string; deliveryPath?: string }What validate is given.
getExporter(format)ExporterThrows 400 for an unknown format.
getDeliverer(delivery)DelivererThrows 400 for an unknown delivery mode.
DEFAULT_EXPORT_FORMAT"csv"The format csv writes a BOM-prefixed comma-separated file; array values are joined with ; and dates written as ISO strings.
DEFAULT_DELIVERY"download"Keeps the result in storage for the download route.
EMAIL_DELIVERY"email"Mails the recipient a link instead. Requires deliveryPath: the link is that path with ?exportJob=<jobId> appended.
EXPORT_JOB_QUERY_PARAM"exportJob"The query parameter carrying the job id on a delivered link. Read it on the target page to resume the download.
EXPORT_TTL_MSone dayHow long a job and its stored file survive before the sweep drops them.

buildExportFilename(base, extension) produces the name sent to the browser: the base and an ISO timestamp, both stripped of anything outside [a-zA-Z0-9._-], joined by - and suffixed with the extension.

Storage and Maintenance

FunctionSignatureDescription
getExportLocalScratchPath(exportId, extension) => stringPath in the OS temp directory a job writes to.
uploadExportToStorage(localPath, tenantId, exportId, extension, contentType) => Promise<string>Uploads the scratch file through a presigned URL, deletes it locally, and returns the resource key.
streamExportFromStorage(resourceKey) => Promise<StreamedExport>Opens a read stream. StreamedExport is { body: ReadableStream<Uint8Array>; size: number | undefined }.
deleteExportFromStorage(resourceKey) => Promise<void>Removes a stored result, ignoring failures.
sweepStaleExportsAllTenants(ttlMs) => Promise<void>Deletes every job whose updatedAt is older than ttlMs, in every tenant, along with its stored file.
deleteAllExportsForTenant(tenantId) => Promise<void>Deletes every job of one tenant and its stored files.

Export a Tenant's Data

startTenantExportJob runs the tenant data export as a background job: it asks every registered contributor for its slice, assembles the answers into one ZIP, and hands you the same ticket runExportJob does.

import { Context, Get, type RequestContext } from "@antelopejs/interface-api";
import { AuthTenantOwner } from "@antelopejs-private/cms/interfaces/cms/guards";
import {
  PageController,
  RegisterPage,
  settingsCategory,
} from "@antelopejs-private/cms/interfaces/cms/page";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";
import {
  EMAIL_DELIVERY,
  listExportJobs,
} from "@antelopejs-private/cms/interfaces/cms-base/export-jobs";
import { startTenantExportJob } from "@antelopejs-private/cms/interfaces/cms-base/tenant-export-archive";

@RegisterPage()
export class ExportsPage extends PageController("exports", {
  displayName: "Exports",
  icon: "i-ph-archive",
  category: settingsCategory,
}) {
  @Get("/exports/start")
  start(@Context() ctx: RequestContext, @AuthTenantOwner() user: User) {
    return startTenantExportJob({
      ctx,
      user,
      scope: "tenant-export",
      filename: "tenant-data",
      delivery: EMAIL_DELIVERY,
      deliveryPath: "/settings/exports",
    });
  }

  @Get("/exports/history")
  history(@Context() ctx: RequestContext, @AuthTenantOwner() user: User) {
    return listExportJobs(ctx, user, { scope: "tenant-export" });
  }
}

StartTenantExportJobOptions is ctx, user, scope, filename, and the optional delivery, deliveryPath and maxExecutionTimeMs — the same fields runExportJob takes, minus the ones the archive fixes itself: the extension is TENANT_EXPORT_EXTENSION ("zip"), the content type TENANT_EXPORT_CONTENT_TYPE ("application/zip"), and retainUntilExpiry is always on, since the archive is reachable from a delivered link and from the job history long after the first download.

buildTenantExportArchive(options) is the step underneath, for a caller that runs the archive outside a job. Its BuildTenantExportArchiveOptions are tenantId, the localPath to write the ZIP to, an AbortSignal, and an optional reportProgress; it resolves to the ExportJobResultSummary describing which contributors failed.

Contributing to the archive is the other side of this: a module registers a handler on the TENANT_DATA_EXPORT hook of the cms interface, and that hook's page covers the archive sink, the manifest and what a failing contributor does to the result.

Track File References

A file or image column stores keys into file storage, not the bytes. The helpers in helpers/file-refs turn a row and a column map into the list of keys it references, which is what lets a route promote staged uploads on write and delete orphaned objects afterwards.

import {
  collectFileRefs,
  dedupeRefs,
  diffRemovedRefs,
  type FileRef,
  hasFileColumns,
  remapRecordFileKeys,
} from "@antelopejs-private/cms/interfaces/cms-base/helpers/file-refs";
import { GetMetadata } from "@antelopejs/interface-core";
import { TableViewMeta } from "@antelopejs-private/cms/interfaces/cms-base/table-view";

const { columns } = GetMetadata(customerDataAPI, TableViewMeta);

if (hasFileColumns(columns)) {
  const removed: FileRef[] = diffRemovedRefs(
    collectFileRefs(columns, rowBeforeEdit),
    collectFileRefs(columns, rowAfterEdit),
  );
  // removed now holds the objects no row references any more
}

The three functions that inspect columns take the column map first. An entry only needs a type, so TableViewMeta.columns is passed straight in; columns whose data type is neither file nor image are skipped. dedupeRefs and diffRemovedRefs work on the resulting ref lists alone.

FunctionSignatureDescription
hasFileColumns(columns) => booleanWhether any column's data type is file or image.
collectFileRefs(columns, row) => FileRef[]Every key the row holds in its file and image columns. Nested objects and arrays are walked; an image value contributes its key property, a file value contributes the string itself.
dedupeRefs(refs) => FileRef[]Drops duplicates, comparing storage and key together.
diffRemovedRefs(before, after) => FileRef[]Deduplicated refs present in before and absent from after.
remapRecordFileKeys(columns, record, mapKey) => Promise<Record<string, unknown>>Returns a copy of the record with every key in a file or image column replaced by await mapKey(key, storage), preserving the surrounding shape.

FileRef is { key: string; storage?: string }. The storage is read from the data type's storage option when it declares one, and left undefined otherwise, so a ref always carries the storage its key belongs to.

Archive-Mode Defaults

applyArchiveModeDefaultRules(rowActions, archiveField) derives the row-action rules that make archive mode coherent: archived rows offer restore and delete, live rows offer edit, duplicate and archive. TableView calls it for you when archiveMode is set; call it directly only when you assemble TableViewRowActionOptions yourself.

import { applyArchiveModeDefaultRules } from "@antelopejs-private/cms/interfaces/cms-base/helpers/archive-mode-helpers";
import { startTenantExportJob } from "@antelopejs-private/cms/interfaces/cms-base/tenant-export-archive";

const rowActions = applyArchiveModeDefaultRules(
  { edit: true, delete: true, archive: true, restore: true },
  "archived",
);
ActionDefault rule
edit{ field: archiveField, notEquals: true }
duplicate{ field: archiveField, notEquals: true }
archive{ field: archiveField, notEquals: true }
restore{ field: archiveField, equals: true }
delete{ field: archiveField, equals: true }

The function returns a new options object and never rewrites a decision you made explicitly:

Value you passedResult
Nothing for that action{ isEnabled: true, rule: <default> }
true{ isEnabled: true, rule: <default> }
false, or a config with isEnabled: falseLeft as is; no rule added.
A config without a ruleThe config, plus the default rule.
A config with a ruleLeft as is.

Passing undefined as rowActions yields an object carrying only the five defaults. The rule shapes themselves are documented in Shared Types, and how the table view consumes them in Tables.