.md

DcuplAppLoader API

The DcuplAppLoader handles fetching and processing configuration and data from dcupl Console or custom sources.

Installation

npm install @dcupl/loader

Basic usage

import { Dcupl } from '@dcupl/core';
import { DcuplAppLoader } from '@dcupl/loader';

// projectId and apiKey belong to the dcupl instance, the loader reads them from there
const dcupl = new Dcupl({
  config: { projectId: 'my-project', apiKey: 'my-api-key' },
});
const loader = new DcuplAppLoader();

// Register the loader first: config.fetch() and process() need it
dcupl.loaders.add(loader, 'main');

// Fetch the loader configuration (dcupl.lc.json) from the dcupl CDN
await loader.config.fetch();

// Download the models and data listed in the configuration
await loader.process();

// Build models, references and indices
await dcupl.init();

The order matters: if the loader has not been added to a dcupl instance, config.fetch() throws and process() logs the error and resolves false. dcupl.init() does not process loaders. Call loader.process() before dcupl.init() (or before dcupl.update() when you load more data later).

process() without options loads every resource in the configuration. To load only the resources of one application, pass its key: see Applications. For a step-by-step setup against a console project (project ID, Browser key, version, environments), see Load from the console.

Setup errors

Call Throws
config.fetch() before dcupl.loaders.add() dcupl not connected. Did you add your loader to dcupl? -> dcupl.loaders.add(loader)
config.fetch() when the request fails or the response is not JSON Invalid Loader Configuration (the cause is logged first)
process(), resources.plan(), resources.set(), add() or delete() without a configuration There is no config present. Make sure to call loader.config.set(...) or loader.config.fetch(...) first
process({ applicationKey }) with an unknown key The applicationKey <key> does not exist

config

Manage loader configuration.

Methods

Method Signature Description
get () => ProjectConfiguration | undefined Get current configuration
set (config: ProjectConfiguration) => void Set configuration manually
fetch (options?: FetchOptions) => Promise<ProjectConfiguration | undefined> Fetch configuration from remote

get() returns a copy and set() stores a copy, so changing the object afterwards does not change the loader's configuration.

ProjectConfiguration

The shape of the loader configuration (dcupl.lc.json). Loader configuration explains tags, applications and environments.

type ProjectConfiguration = {
  resources: Resource[]; // see Resource Types below
  applications?: {
    key: string;
    name?: string;
    description?: string;
    resourceTags?: (string | string[])[];
    variables?: Variable[];
  }[];
  environments?: {
    key: string;
    description?: string;
    variables?: Variable[];
    headers?: HttpConfig[]; // request headers for resource downloads
    queryParams?: HttpConfig[]; // query parameters appended to resource urls
  }[];
  options?: ProcessOptions['options']; // see ProcessOptions below
};

type Variable = { key: string; value: string | undefined; description?: string };

type HttpConfig = {
  key: string;
  value: string; // may contain ${variables}
  tags?: string[]; // only for resources sharing one of these tags; without tags: every resource (see below for headers)
  condition?: { applyIfVariableHasValue?: string }; // only when this variable has a non-empty value
};

An environment's variables, headers and query parameters apply only to a process() run that selects it with environmentKeys; an application preset does not select environments. Headers are only sent with resources that have tags: an untagged resource gets no environment headers, even from a header without tags.

FetchOptions

type FetchOptions = {
  baseUrl?: string; // Where the config file lives (default: your project's draft version on the dcupl CDN)
  loaderFileName?: string; // Config file name (default: 'dcupl.lc.json')
  headers?: { key: string; value: string }[]; // Request headers, used when fetching from a custom baseUrl
  skipApply?: boolean; // Return the config without applying it to the loader
};

There are no projectId or apiKey fetch options. Pass them to new Dcupl({ config: { projectId, apiKey, version } }): dcupl.loaders.add() copies them into the loader's projectId, apiKey and version variables (version defaults to 'draft'), and the loader appends the API key to its requests to the dcupl CDN. A custom baseUrl also becomes the value of ${baseUrl} in resource URLs, unless an environment, process or global variable baseUrl overrides it (the loader then logs a warning).

Examples

// Fetch from the dcupl CDN (projectId and apiKey come from new Dcupl({ config }))
await loader.config.fetch();

// Fetch from custom URL
await loader.config.fetch({
  baseUrl: 'https://my-cdn.example.com/config',
  loaderFileName: 'dcupl.loader.json',
  headers: [{ key: 'Authorization', value: 'Bearer my-token' }],
});

// Get current config
const config = loader.config.get();
console.log(config?.resources.length);

// Set config manually (models are loaded from 'model' resources too)
loader.config.set({
  resources: [
    { type: 'model', url: '/models/product.dcupl.json' },
    { key: 'products', type: 'data', url: '/data/products.json', model: 'Product' },
  ],
});

resources

Manage data resources (models, data files, transformers, operators, scripts).

Methods

Method Signature Description
get (key: string) => Resource | undefined Get resource by key
getAll () => Resource[] Get all resources
getWithTag (tag: string) => Resource[] Get resources with specific tag
set (resources: Resource[]) => void Replace all resources
add (resource: Resource) => void Add a resource
delete (key: string) => void Delete a resource by key
plan (options?: ProcessOptions) => ResourcePlan Resolve what a process(options) call would load, without loading it

get and delete match the optional key of a resource. set, add and delete need a configuration (fetched or set) first.

Resource types

type Resource = ModelResource | DataResource | TransformerResource | CustomOperatorResource | CustomScriptResource;

// Fields shared by every resource type
type ResourceBase = {
  key?: string; // Optional; used by resources.get()/delete() and as the plan id
  url: string; // May contain ${variables}
  type: 'model' | 'data' | 'transformer' | 'operator' | 'script';
  tags?: string[]; // For filtering resources and matching transformers
};

type ModelResource = ResourceBase & { type: 'model' }; // the file holds a model definition, an array of them, or a view definition

type DataResource = ResourceBase & {
  type: 'data';
  model: string; // Target model
  options?: {
    updateType?: 'set' | 'update' | 'upsert' | 'remove';
    keyProperty?: string | string[];
    keyPropertySeparator?: string;
    autoGenerateKey?: boolean;
    autoGenerateProperties?: boolean | { deep?: boolean; sampleSize?: number };
    csvParserOptions?: {
      delimiter?: string;
      quoteChar?: string;
      escapeChar?: string;
      commentChar?: string;
    };
  };
};

type TransformerResource = ResourceBase & {
  type: 'transformer';
  applyTo?: string[]; // Tags of the data resources it runs on
  transformerType?: 'rawFileTransformer' | 'parsedFileTransformer'; // Default: 'parsedFileTransformer'
};

type CustomOperatorResource = ResourceBase & {
  type: 'operator';
  operator: string; // The operator name usable in queries
};

type CustomScriptResource = ResourceBase & {
  type: 'script';
  scriptKey: string; // The key the script is registered under (see /sdk/guides/scripts)
};

Data resources default to updateType: 'set': the resource's rows replace all rows of its model. When several data resources feed one model, the last one wins, so give the others options: { updateType: 'upsert' } (or pass options: { defaultDataContainerUpdateType: 'upsert' } to process()).

Resource plan

plan() answers "what would process(options) load?" without fetching anything. It performs no I/O and mutates no state, so it is safe to call repeatedly. Application preset, environments and variables are resolved exactly as process() resolves them.

The plan is total: every entry in config.resources comes back either in scheduled or in skipped with a reason — nothing is dropped silently. This is what lets you tell "loaded everything" apart from "loaded everything this application was allowed to see".

type ResourcePlan = {
  scheduled: ResourceItem[]; // what process() would load — tag-filtered, urls resolved
  skipped: SkippedResource[]; // declared but not scheduled for this run
  variables: Record<string, any>; // the variables the scheduled urls were resolved with
  run: RunContext; // the applicationKey / resourceTags / environmentKeys used
};

type SkippedResource = {
  resource: Resource;
  reason: 'no-tags' | 'tag-mismatch';
  id?: string;
};

Scheduled items are Resource plus:

Field Description
id Stable identity. An authored resource.key wins (k:<key>); otherwise a hash of the raw url plus type/model (h:<hash>). Derived from the raw url so config and runtime compute the same id — use it to join a runtime item back to its config entry instead of matching urls. Also available standalone as resolveResourceId / resolveResourceIds from @dcupl/common.
rawUrl The url as authored, before ${variable} substitution (url holds the resolved value).
unresolvedVariables Variables referenced by rawUrl that are not defined in any variable layer. Such a variable is substituted with an empty string, so a non-empty array here distinguishes a broken config from a missing file. A defined variable without a value (for example the built-in projectId when none is configured) also becomes an empty string but is not listed.

The same information reaches progress callbacks: ProgressItem carries skippedItems and run alongside the existing totalItems / processedItems / downloadedItems.

Examples

// Preview what a run would load, without loading it
const plan = loader.resources.plan({ applicationKey: 'catalog', resourceTags: ['critical'] });

console.log(plan.scheduled.map((r) => r.id));
plan.skipped.forEach((s) => console.log(`${s.resource.url} skipped: ${s.reason}`));

// Catch a misconfigured variable before it becomes a download failure
const broken = plan.scheduled.filter((r) => r.unresolvedVariables?.length);

// Get all resources
const resources = loader.resources.getAll();

// Get resources by tag
const criticalResources = loader.resources.getWithTag('critical');

// Add a new data resource
loader.resources.add({
  key: 'products-update',
  type: 'data',
  url: '/api/products/latest',
  model: 'Product',
  options: { updateType: 'upsert' },
  tags: ['live-data'],
});

// Delete a resource
loader.resources.delete('products-update');

variables

Access configuration variables with environment support. Variables are referenced as ${name} in resource URLs, headers and query parameters. When the same key is defined in several places, the later source wins: defaults < environment < process options < global. config.fetch({ baseUrl, loaderFileName }) writes its options into the defaults, so an environment, process or global variable of the same name wins over them; when that happens with a different value, the loader logs a warning naming the variable, the fetch value and the winning layer (once per process() run).

The built-in default variables are projectId, apiKey and version ('draft'), filled from new Dcupl({ config }); cdnBaseUrl ('https://cdn.dcupl.com/files/projects'); baseUrl ('${cdnBaseUrl}/${projectId}/${version}', or the baseUrl passed to config.fetch()); modelUrl, dataUrl and transformerUrl (${baseUrl}/models, /data, /transformers); loaderFileName ('dcupl.lc.json'); cdnVersion; and consoleApiUrl.

Variables inside variable values are substituted one level deep. With the default CDN baseUrl (${cdnBaseUrl}/${projectId}/${version}), ${baseUrl} resolves fully, but ${modelUrl}, ${dataUrl} and ${transformerUrl} keep placeholders, and unresolvedVariables does not flag them:

${dataUrl}/products.csv  ->  ${cdnBaseUrl}/${projectId}/${version}/data/products.csv
${baseUrl}/data/products.csv  ->  https://cdn.dcupl.com/files/projects/<projectId>/draft/data/products.csv

Write resource URLs with ${baseUrl}/data/... instead. When baseUrl is a literal URL (passed to config.fetch({ baseUrl }) or set as a variable), ${modelUrl}, ${dataUrl} and ${transformerUrl} resolve fully as well.

Methods

Method Signature Description
get (key: string, env?: string) => Variable | undefined Get variable by key
getAll (env?: string) => Variable[] Get all variables
getAllAsObject (env?: string) => Record<string, any> Get variables as key-value object

Nested variable APIs

// Built-in default variables (projectId, apiKey, version, baseUrl, ...)
loader.variables.default.get(key);
loader.variables.default.getAll();

// Environment variables (from config environments)
loader.variables.environment.get(env, key);
loader.variables.environment.getAll(env);

// Process variables (passed during processing)
loader.variables.process.get(key);
loader.variables.process.getAll();

// Global variables (set at runtime)
loader.variables.global.get(key);
loader.variables.global.getAll();
loader.variables.global.set(key, value);
loader.variables.global.delete(key);

Examples

// Get API URL variable, resolved for the production environment
const apiUrl = loader.variables.get('API_URL', 'production');
console.log(apiUrl?.value);

// Get all variables for production environment
const prodVars = loader.variables.getAll('production');

// Get variables as object for easy access
const vars = loader.variables.getAllAsObject('production');
console.log(vars.API_URL);

// Set global variable at runtime
loader.variables.global.set('AUTH_TOKEN', 'Bearer xyz');

// Use in resource URLs with interpolation
// URL: "${API_URL}/products" becomes "https://api.example.com/products"

transformers

Transformers reshape a data resource's payload before dcupl ingests it. Declare them as transformer resources in the configuration: the url points to a JavaScript file whose body returns a (resource, data) => data function. process() loads them before the data and runs each one on every data resource whose tags share a tag with its applyTo (a transformer with an empty applyTo runs only on untagged data resources).

loader.config.set({
  resources: [
    { type: 'model', url: '${baseUrl}/models/product.dcupl.json' },
    {
      type: 'transformer',
      url: '${baseUrl}/transformers/normalize-prices.js',
      applyTo: ['products'], // runs on data resources tagged 'products'
      transformerType: 'parsedFileTransformer', // default
    },
    { type: 'data', url: '${baseUrl}/data/products.csv', model: 'Product', tags: ['products'] },
  ],
});
normalize-prices.js
return (resource, items) =>
  items.map((item) => ({
    ...item,
    price: Number(item.price) || 0,
    currency: item.currency || 'USD',
  }));

A parsedFileTransformer receives the parsed items. A rawFileTransformer runs before parsing: it receives the CSV text of a CSV file, or the parsed body of a JSON file.

Methods

Method Signature Description
get (options: { appyTo: string[] }) => TransformerConfigObject[] Registered transformers whose applyTo shares a tag with the list
getAll () => TransformerConfigObject[] All registered transformers
set (transformer: TransformerConfigObject) => void Register a transformer (replaces one with the same key)
remove (key: string) => void Remove a transformer by key
Note

The option of transformers.get() is spelled appyTo. { applyTo: [...] } matches nothing.

Transformer structure

type TransformerConfigObject = {
  key: string; // A transformer loaded from a resource is registered under its resolved url
  applyTo: string[]; // Tags of the data resources it runs on
  type: 'rawFileTransformer' | 'parsedFileTransformer';
  transformer: (resource: AppLoaderConfiguration.Resource, input: any) => any;
};

process() only runs the transformers of the transformer resources in the current run. A transformer registered with transformers.set() is used when its key equals the resolved url of such a resource: the loader then uses your function instead of downloading the file. Under any other key it is stored but not applied.

Examples

// Supply the function for a declared transformer resource instead of downloading the file
loader.transformers.set({
  key: 'https://cdn.example.com/transformers/normalize-prices.js', // the resource's resolved url
  applyTo: ['products'],
  type: 'parsedFileTransformer',
  transformer: (resource, items) =>
    items.map((item: Record<string, any>) => ({ ...item, currency: item.currency || 'USD' })),
});

// Get registered transformers for a tag (note the spelling: appyTo)
const productTransformers = loader.transformers.get({
  appyTo: ['products'],
});

// Remove a transformer
loader.transformers.remove('https://cdn.example.com/transformers/normalize-prices.js');

Errors

  • A parsedFileTransformer that throws is recorded as a TransformerError loader error. The resource's data is still loaded, untransformed.
  • A rawFileTransformer that throws fails the whole data resource (InvalidResource).
  • With new Dcupl({ scripting: { enabled: false } }) none of your scripts run: transformer, operator and script resources are skipped, each recorded as a ScriptingDisabled loader error and reported as a failed item in the progress.

Loader errors are recorded whether or not quality checks are enabled:

const loaderErrors = dcupl.quality.errors.summary().filter((group) => group.type === 'loader');
// e.g. [{ key: 'Product::::TransformerError', model: 'Product', errorGroup: 'ResourceError', errorType: 'TransformerError', type: 'loader', totalCount: 1, ... }]

Processing

Process the loaded configuration and apply to dcupl.

process()

await loader.process(options?: ProcessOptions); // Promise<boolean>

Downloads the scheduled resources and hands models and data to dcupl. Call it after config.fetch() or config.set() (it throws without a configuration) and before dcupl.init(). To load more resources later, call process() again and then await dcupl.update(). On a sealed instance (performance.retainStagedData: false or dcupl.seal(), see seal()) process() rejects with DcuplSealedError before it fetches anything, so await it before dcupl.init().

Running process() again on the same loader rebuilds the models it loads from their current files (changed types apply, removed attributes disappear, a model generated for a data file follows the current columns; properties are not duplicated), drops the resources that left the loader configuration since the last run (their views are unregistered, their models and rows removed, the rows of a removed data file gone), and leaves resources still in the configuration that the run does not select as they are. So the documented pattern of loading more with other resourceTags keeps working. After await dcupl.update() every existing list serves the new data. Data resources a run loads replace their model's rows again unless they use updateType: 'upsert'. A resource without a key is identified by its URL, so editing a literal URL counts as removing that resource and adding a new one; URLs built from ${baseUrl} are not affected. Replacing the configuration (config.set() or config.fetch() with other resources) and processing again drops the resources the new configuration does not have.

Watch out

One app loader per model key. When a loader's process() would load model files, view files or data for a key that another loader of the same instance already loads, it rejects with SharedModelKeyError (exported from @dcupl/loader, with modelKey, owner and loader) and changes nothing. Split models across loaders by key, not by file. dcupl.loaders.remove(key) frees the loader's keys: its models, views and rows go on the next update(), and models and views defined in code go back to their code definition.

ProcessOptions

type ProcessOptions = {
  applicationKey?: string; // Select an application preset (its resourceTags and variables), see Applications
  environmentKeys?: string[]; // Select environment configurations
  resourceTags?: (string | string[])[]; // Filter resources by tags
  variables?: { key: string; value: string | undefined; description?: string }[]; // Override variables
  options?: {
    defaultDataContainerUpdateType?: 'set' | 'update' | 'upsert' | 'remove';
    csvParserOptions?: CsvParserOptions;
    missingModelHandling?: {
      // default: true — a data resource whose model has no definition gets a model with inferred properties
      autoGenerateProperties?: boolean | { deep?: boolean; sampleSize?: number };
    };
  };
};

resourceTags entries are combined with OR; an entry that is itself an array is an AND group. ['critical', ['eu', 'live']] loads resources tagged critical or tagged both eu and live. When you filter by tags, untagged resources are skipped.

The CSV parser reads csvParserOptions from the resource's options or from the configuration's top-level options. csvParserOptions passed to process() has no effect.

options passed to process() replace the configuration's top-level options for that run; they are not merged. Repeat any configured option you still need, for example defaultDataContainerUpdateType.

An options.missingModelHandling object replaces the default one as a whole, so missingModelHandling: {} turns missing-model handling off, the same as autoGenerateProperties: false: the rows of a data resource whose model isn't declared then load into no model, unless the resource's own options.autoGenerateProperties is true.

Examples

// Process with specific environment
await loader.process({
  environmentKeys: ['production'],
});

// Process only tagged resources
await loader.process({
  resourceTags: ['critical', 'models'],
});

// Process with variable overrides
await loader.process({
  variables: [{ key: 'API_URL', value: 'https://staging-api.example.com' }],
});

CSV files

A data resource is parsed as CSV when its resolved URL ends with .csv or the response's content-type contains text/csv. Any other response is parsed as JSON. The cells are converted against the target model:

  • The delimiter is detected from the first line (,, ;, tab or |) unless csvParserOptions.delimiter is set.
  • Key columns are kept as written.
  • In every other column, null becomes null and an empty cell becomes undefined.
  • int and float use parseInt / parseFloat, so 12abc becomes 12. boolean accepts true, false, 1 and 0 in any case (an empty cell is no value, anything else is a WrongDataType). json cells are parsed with JSON.parse.
  • Array<...> properties and multiValued references are split on the attribute's separator (default ,), or parsed as JSON when the cell starts with [.
  • A column the model doesn't declare is dropped and reported once as an UnknownColumn error, unless the model has autoGenerateProperties.
  • A cell that can't be converted is dropped and reported as WrongDataType. Parsing never throws.

With autoGenerateProperties (also on a model the loader generates for a data resource without one), undeclared columns arrive as strings and are converted by the inferred type (TRUE / False columns infer as boolean, 1 / 0 columns stay int; see autoGenerateProperties).


Applications

An application is a named preset in the configuration's applications[]: a key, an optional name and description, the resourceTags it loads and the variables for its runs. process({ applicationKey }) loads the resources of that application; resources.plan({ applicationKey }) shows which ones they are.

dcupl.lc.json
{
  "applications": [
    { "key": "catalog", "resourceTags": ["catalog"] },
    { "key": "default", "name": "Default" }
  ],
  "resources": [
    { "type": "model", "url": "${baseUrl}/models/product.dcupl.json", "tags": ["catalog"] },
    { "type": "data", "url": "${baseUrl}/data/products.csv", "model": "Product", "tags": ["catalog"] },
    { "type": "model", "url": "${baseUrl}/models/order.dcupl.json", "tags": ["orders"] },
    { "type": "data", "url": "${baseUrl}/data/orders.csv", "model": "Order", "tags": ["orders"] }
  ]
}
await loader.process({ applicationKey: 'catalog' }); // Product only
await loader.process({ applicationKey: 'default' }); // no resourceTags: every resource
await loader.process(); // no application: every resource
  • Without applicationKey, process() loads every resource (narrowed only by resourceTags you pass).
  • An application without resourceTags loads every resource. With tags, untagged resources are skipped.
  • resourceTags or variables passed to process() replace the application's own.
  • An unknown key throws The applicationKey <key> does not exist, also when the configuration has no applications at all.
  • Projects from dcupl init and the console's starter projects define default (without tags, so it loads every resource). A configuration you write yourself may have none, and then process({ applicationKey: 'default' }) throws. Pass a key only when your configuration defines that application.

An application does not select environments: pass environmentKeys for that. Loader configuration shows how tags and applications fit together in dcupl.lc.json.


Progress tracking

Subscribe to loader progress events.

on()

import type { AppLoaderProgress } from '@dcupl/loader';

const unsubscribe = loader.on((progress: AppLoaderProgress.ProgressItem) => {
  console.log(`${progress.processed}/${progress.total} resources processed`);
});

// Later: unsubscribe
unsubscribe();

ProgressItem

The callback receives a snapshot of the whole run, not a single event — every list below is the current state, re-sent on each update.

type ProgressItem = {
  total: number; // resources scheduled for this run
  processed: number;
  remaining: number; // 0 once the run is finished
  progress: number; // percentage, 0-100
  context: string; // the type of the resource just handled: 'model', 'data', 'transformer', ...
  currentItem: ResourceItem; // the resource this update is about (undefined in the first update)

  totalItems: ResourceItem[]; // scheduled — tag-filtered, urls resolved
  processedItems: ResourceItem[];
  downloadedItems: ResourceItem[];
  failedItems: ResourceItem[];
  remainingItems: ResourceItem[];

  skippedItems?: SkippedResource[]; // declared but never scheduled, with a reason
  run?: RunContext; // applicationKey / resourceTags / environmentKeys
};

The first update is sent when the run starts, before any resource is handled, so guard currentItem with ?.. A resource's outcome lives on the item, not on the event: success is a boolean and status is the HTTP status code. There is no completion event — a run is done when remaining === 0.

loader.on((progress) => {
  if (progress.remaining !== 0) return;

  for (const failure of progress.failedItems) {
    // status is only set when a response arrived (not on a network error or a bad url)
    console.error(`${failure.key ?? failure.url} failed with ${failure.status ?? 'no response'}`);
  }
});

Complete example

import { Dcupl } from '@dcupl/core';
import { DcuplAppLoader } from '@dcupl/loader';

// Runs in the browser (navigator.language below)
async function initializeDcupl() {
  const dcupl = new Dcupl({
    config: {
      projectId: 'my-project',
      apiKey: 'my-read-only-api-key', // a scoped read-only key: it ships to every visitor
    },
  });
  const loader = new DcuplAppLoader();

  // Register with dcupl before fetching
  dcupl.loaders.add(loader, 'main');

  // Track progress
  loader.on((progress) => {
    const current = progress.currentItem?.key ?? progress.currentItem?.url;
    console.log(`Loading: ${current ?? 'starting'} (${progress.processed}/${progress.total})`);
  });

  // Fetch configuration
  await loader.config.fetch();

  // Set runtime variables, usable as ${USER_LOCALE} in resource urls
  loader.variables.global.set('USER_LOCALE', navigator.language);

  // Download models and data with the production environment
  await loader.process({
    environmentKeys: ['production'],
  });

  // Initialize
  await dcupl.init();

  return dcupl;
}

See also