.md

Events reference

dcupl uses an event system to notify your application of changes. Subscribe to events using the on() method on either the dcupl instance or individual lists.

Subscribing to events

Global events (dcupl instance)

// Subscribe to all events
const unsubscribe = dcupl.on((message) => {
  console.log('Event:', message.type, message);
});

// Later: unsubscribe
unsubscribe();

List events

const list = dcupl.lists.create({ modelKey: 'Product' });

// Re-render when the list's result changes
const unsubscribe = list.onResultChange(() => {
  renderProducts(list.catalog.query.items());
});

// Or receive every event of the list, including reads
const off = list.on((message) => {
  console.log('List event:', message.type);
});

List events are delivered to list.on() only; they do not reach dcupl.on(). In the other direction, every list.on() also receives the instance's dcupl_updated_manually after each dcupl.update().


Event message structure

All events share a common message structure:

type DcuplUpdateMessage = {
  key: number; // Unique per event (assigned when the call starts, not in delivery order)
  type: DcuplUpdateType; // Event type (see below)
  scope: 'all' | 'global' | 'list' | 'list$'; // emitted events use 'global' or 'list'
  action?: 'update'; // Set on update messages (e.g. result_updated, dcupl_updated_manually)
  origin?: 'user' | 'loader';
  modelKey?: string; // Related model
  listKey?: string; // Related list
  method?: string; // Method that triggered the event
  args?: any[]; // Arguments passed to the method
  result?: any; // Return value of the call: on query_*, fn_* and filter_* events (not query_get, query_has)
  duration?: number; // Execution time in ms, never negative (see below)
  start?: number; // Start timestamp
  end?: number; // End timestamp
};

Some events carry their details in args instead of listKey/modelKey: for list_created, args[0] holds the create options (listKey, modelKey); for data_change, args[0] is the staged data container (model, type, data).

Every event carries a duration: the milliseconds the call or update took, never negative. On dcupl_partial_update it runs from the moment the update starts applying the staged changes. key is assigned when a call starts, so result_updated, which is emitted inside the call, is delivered before the query_apply that caused it although it has the higher key.


Event types

Lifecycle events

Event Scope Description
dcupl_initialized global dcupl.init() finished, also when it failed and resolved false
dcupl_destroyed global dcupl instance was destroyed
dcupl_updated_manually global dcupl.update() finished, also when it failed and resolved false
dcupl_partial_update global dcupl.update() applied staged data changes (partial or full rebuild) or a model definition change (models.set/update/remove after init()); not emitted when nothing was staged and no definition changed. Fires before dcupl_updated_manually
dcupl_handle_datacontainer global Data containers processed during init() or a full rebuild in update() (args[0] is the array of containers). A partial update emits it, with just the $DcuplErrorTrackingErrors and $DcuplErrorTrackingSummary containers, whenever the model has recorded data quality errors, whether or not they changed. While no list over $DcuplErrorTrackingErrors/Summary exists, their containers are processed at the first read of those models instead of during init()/update(), and the event for that build fires then

dcupl_partial_update carries the change in args[0]: { isPartialUpdate: boolean, changedModels: string[], changedAttributes: Map<string, Set<string>>, affectedModels: string[] }. isPartialUpdate is true for a partial update and false when the update fell back to a full rebuild (for example after data.set()). affectedModels adds the models that reference a changed model.

changedModels also names a model whose definition changed since the last init() or full update(): after models.set() or models.update() every property and reference of the new definition is in changedAttributes, after models.remove() the entry is an empty set. A definition change always rebuilds everything, so isPartialUpdate is false.

dcupl.on((msg) => {
  if (msg.type === 'dcupl_initialized') {
    // Fires even when init() failed: check the value init() resolved with
    console.log('dcupl.init() finished');
  }
  if (msg.type === 'dcupl_partial_update') {
    console.log('Changed models:', msg.args[0].changedModels);
  }
});

List events

Event Scope Description
list_created global New list was created (args[0] holds listKey and modelKey)
list_updated list list.update() re-derived the list — called by you, or by dcupl.update() for an auto-updating list
list_destroyed list List was destroyed

list_updated is not the "result changed" signal: use result_updated / list.onResultChange() to re-render.

// Track all list creations
dcupl.on((msg) => {
  if (msg.type === 'list_created') {
    const { listKey, modelKey } = msg.args[0];
    console.log('New list:', listKey, 'for model:', modelKey);
  }
});

// Re-render a list when its result changes
const list = dcupl.lists.create({ modelKey: 'Product' });
list.onResultChange(() => {
  updateProductDisplay();
});

Query events

Event Scope Description
query_apply list Query condition added/modified (apply, addCondition, setCondition, addGroup, setGroup)
query_apply_options list Query options changed (applyOptions)
query_remove list Query condition removed (remove, removeCondition, removeGroup)
query_reset list Query cleared (reset, clear)
query_execute list Query executed (execute, items, first, any, count)
query_get list Query state retrieved (get, current, isEmpty)
query_has list Query existence checked (has)
query_one list Single item retrieved (one)
query_many list Multiple items retrieved (many)
result_updated list Query results changed

dcupl.query.execute(), dcupl.query.one() and dcupl.query.many() emit query_execute, query_one and query_many with scope global on dcupl.on(), with the return value in result.

list.on((msg) => {
  switch (msg.type) {
    case 'query_apply':
      console.log('Filter applied:', msg.args);
      break;
    case 'result_updated':
      console.log('Results updated, refreshing...');
      refreshUI();
      break;
  }
});

result_updated is the list's "result changed" signal:

Trigger result_updated on a list
Query change (addCondition, apply, remove, reset, …) once (never with { skipProcessing: true })
dcupl.update() with pending changes (data.update / data.set / remove) once per affected list: lists of changed models, of models referencing them, and lists whose query, facets or aggregations read a dotted path through a changed model
dcupl.update() with nothing pending, and re-init() once on every list
Read-only calls (execute, fn.facets, fn.aggregate, fn.groupBy, fn.suggest, …) never
Manual list.update() never (emits list_updated)

During dcupl.update(), every affected list is refreshed before result_updated is emitted, so a handler that re-queries sees the updated results.

list.onResultChange(cb) is a shortcut for list.on filtered to result_updated. It returns an unsubscribe function:

const unsubscribe = list.onResultChange(() => refreshUI());
// later
unsubscribe();

Data events

Event Scope Description
data_change global Data was staged (data.set/update/upsert/remove/reset/apply, loader data, internal $Dcupl… models)
model_add global A model definition was registered or changed (models.set/update, also for the loader's model files and the internal $Dcupl… models during init())
view_add global A view was registered or replaced (views.set)

data_change fires when the data is staged, before dcupl.update() or dcupl.init() applies it. To react to applied data, use dcupl_updated_manually or list.onResultChange().

models.remove() and views.remove() emit no global event (models.remove() destroys the model's lists, which emit list_destroyed to list.on()). After models.remove(), the next dcupl.update() names the removed model in dcupl_partial_update.

dcupl.on((msg) => {
  if (msg.type === 'data_change') {
    const { model, type } = msg.args[0]; // the staged data container
    console.log(`Staged ${type} for model:`, model);
  }
});

Analytics function events

Event Scope Description
fn_facets list Facets calculated
fn_aggregate list Aggregation computed
fn_suggest list Suggestions generated
fn_groupBy list GroupBy executed
fn_pivot list Pivot table computed
fn_metadata list Metadata retrieved
filter_one list catalog.filters.get()
filter_many list catalog.filters.getAll()

The global dcupl.fn.* functions emit the same fn_* types with scope global on dcupl.on(), with the return value in result.

list.on((msg) => {
  if (msg.type === 'fn_facets') {
    console.log(`Facets computed in ${msg.duration}ms`);
  }
});

Loader events

Event Reaches dcupl.on() Description
loader_added yes Loader registered with dcupl.loaders.add() (args[0] is its key)
loader_removed yes Loader removed
loader_config_fetched no Emitted on the loader itself when its config was fetched
loader_processed no Emitted on the loader itself when loader.process() finished
loader_models_fetched no Part of the type; not emitted
loader_data_fetched no Part of the type; not emitted
loader_scripts_fetched no Part of the type; not emitted

To track loading, use the loader's progress callback loader.on() and await loader.process().

dcupl.on((msg) => {
  if (msg.type === 'loader_added') {
    console.log('Loader registered:', msg.args[0]);
  }
});

// Loading progress comes from the loader itself
loader.on((progress) => {
  console.log(`${progress.processed}/${progress.total} resources processed`);
});

Common patterns

Loading indicator

showLoadingSpinner();

const unsubscribe = loader.on((progress) => {
  updateProgressBar(progress.processed, progress.total);
});

await loader.process();
await dcupl.init();

unsubscribe();
hideLoadingSpinner();

Performance monitoring

// duration: milliseconds the call took (see Event Message Structure)
dcupl.on((msg) => {
  if (msg.duration && msg.duration > 100) {
    console.warn(`Slow operation: ${msg.type} took ${msg.duration}ms`, {
      model: msg.modelKey,
      list: msg.listKey,
      args: msg.args,
    });
  }
});

Debugging

// Log all events in development
if (process.env.NODE_ENV === 'development') {
  dcupl.on((msg) => {
    console.log(`[dcupl] ${msg.type}`, {
      scope: msg.scope,
      model: msg.modelKey,
      list: msg.listKey,
      duration: msg.duration ? `${msg.duration}ms` : undefined,
    });
  });
}

Sync with external systems

data_change has no origin for data calls: it fires for your own data.* calls, for data the loader adds, and for dcupl's internal $Dcupl… models. Filter on the staged container's model:

dcupl.on(async (msg) => {
  if (msg.type !== 'data_change') return;

  const { model, type, data } = msg.args[0];
  if (model === 'Order') {
    // A change to Order was staged, sync it to the server
    await syncToServer(model, type, data);
  }
});

React integration

function useDcuplEvent(dcupl: Dcupl | null, eventType: DcuplUpdateType) {
  const [lastEvent, setLastEvent] = useState<DcuplUpdateMessage | null>(null);

  useEffect(() => {
    if (!dcupl) return;

    const unsubscribe = dcupl.on((msg) => {
      if (msg.type === eventType) {
        setLastEvent(msg);
      }
    });

    return unsubscribe;
  }, [dcupl, eventType]);

  return lastEvent;
}

// Usage: dcupl_updated_manually fires after each dcupl.update() has been applied
function ProductList() {
  const event = useDcuplEvent(dcupl, 'dcupl_updated_manually');

  useEffect(() => {
    if (event) {
      refetchProducts();
    }
  }, [event]);
}

For a component bound to one list, subscribe with list.onResultChange() instead.


All event types

type DcuplUpdateType =
  // Lifecycle
  | 'dcupl_initialized'
  | 'dcupl_destroyed'
  | 'dcupl_updated_manually'
  | 'dcupl_partial_update'
  | 'dcupl_handle_datacontainer'
  // Data
  | 'data_change'
  | 'model_add'
  | 'view_add'
  // Lists
  | 'list_created'
  | 'list_updated'
  | 'list_destroyed'
  // Queries
  | 'query_apply'
  | 'query_apply_options'
  | 'query_remove'
  | 'query_reset'
  | 'query_execute'
  | 'query_get'
  | 'query_has'
  | 'query_one'
  | 'query_many'
  | 'result_updated'
  // Analytics
  | 'fn_facets'
  | 'fn_aggregate'
  | 'fn_suggest'
  | 'fn_groupBy'
  | 'fn_pivot'
  | 'fn_metadata'
  | 'filter_one'
  | 'filter_many'
  // Loaders
  | 'loader_added'
  | 'loader_removed'
  | 'loader_config_fetched'
  | 'loader_data_fetched'
  | 'loader_models_fetched'
  | 'loader_scripts_fetched'
  | 'loader_processed';

See also