Migration guide
This guide helps you upgrade from dcupl SDK 1.x (1.11.x) to 2.0. 2.0 keeps every 1.x method, but some behaviour changed: check Behaviour Changes from 1.x first. The new query API method names are optional; the older names still work in 2.0.
Behaviour changes from 1.x
Upgrade @dcupl/core, @dcupl/common and @dcupl/loader together, to the same 2.0 version. If your app uses dcupl Connect (Debugging), upgrade @dcupl/connect with them: its 1.x release depends on @dcupl/core 1.11.
npm install @dcupl/core @dcupl/common @dcupl/loader
npm install @dcupl/connect # only with dcupl ConnectThen check your code for these changes (compared with 1.11.11):
Data
data.update()anddata.upsert()replace every attribute in the payload as a whole. 1.x deep-merged arrays andjsonobjects:tags: ['x', 'y']updated with['z']became['z', 'y'], now it is['z']. To change one key of ajsonattribute, send the whole object (update()).data.reset({ model })is staged like the other data methods: the rows are removed by the nextawait dcupl.update(). 1.x cleared the model at once, and the nextupdate()brought the rows back.autoGeneratePropertiesinfersint,float,booleananddate(strict ISO 8601) from string values, for example CSV columns. 1.x typed every string column asstring. Declare the property's type in the model to keep it astring.booleanproperties accept'true','false','1'and'0'(any case) and the numbers1and0, so'false'and'0'now load asfalse(1.x read every non-empty string astrue). Any other string, number, object or array is aWrongDataTypeerror and stays undefined. CSV columns follow the same rule, except that an empty cell counts as no value, not asWrongDataType.- Rows with the same key in one
data.set()payload or data file: the last row wins as a whole, with oneNonUniqueKeyerror per replaced row. 1.x merged the later rows into the first. - Keys compare as strings:
data.update([{ key: '1', … }])changes a row loaded withkey: 1(1.x ignored the update). A row without a key value (nokey, or no value for a customkeyProperty) is dropped and reported asMissingKey; 1.x stored it under the key'undefined'('null'for an explicitnull). See Rows without a key value. - An explicit
nullon a reference staysnull(aneq nullcondition matches it) and is reported asNullValueunless the reference setsquality: { nullable: true }. 1.x turned it intoundefined. See Error Types. - A
dcupl.update()afterinit()that takes the partial path gives the same result as a full rebuild: derived properties and references (also those on resolved references) are recomputed instead of keeping their old values, an update that can change a query reference's result runs as a full update, and the data quality records equal those of aninit()on the same data (Partial updates). - A list created with
listItemKeysorquerystays in sync with the data: after everydcupl.update(), partial or full, it serves the current values of its items and drops the removed ones. Items that start matching a seedquerystill join only withlist.update()or auto-update (create()).
Queries
invertapplies to every operator. 1.x ignored it ongt,gte,lt,lte,sizeandisTruthy.- Conditions on
Array<…>properties match without an index. 1.x returned[]until a facet had built the attribute's index. - Dotted paths through references (
customer.country) are new in 2.0 (Deep queries); in 1.x such a condition or facet matched nothing. - A group without
groupKeyadded inaddmode (addGroup(),apply(group)) is added as its own group. 1.x ignored such a group (one withgroupKey: undefinedwritten out was merged into the first such group, overwriting itsgroupType). - A nested group without
groupTypeis evaluated as'and'. 1.x could return the matches of a later condition when an earlier one matched nothing. A group you pass toaddGroup(),setGroup()orapply(group)withoutgroupTypestays an'or'group, as in 1.x. - The
inoperator is new; in 1.x anincondition matched nothing (Query Operators). - A condition whose
operatoris neither built in nor registered as an operator script throws anUnknownOperatorerror (Custom operators). 1.x matched nothing, silently. - A sort the SDK can't apply is ignored with a warning: an
ordervalue other than'ASC'or'DESC', anorderwithout exactly one entry per attribute, or a sort script key with no registered script. 1.x ignored such a sort silently (sort scripts). aggregate()countis the number of values the attribute holds (0 when no item has one): each value of anArray<…>property or of a multi-valued dotted path counts once, and on a number attribute it is the countavgdivides by. 1.x returned the number of items in the list, orundefinedwhen no item had a numeric value, socounton a string or date attribute was alwaysundefined.minandmaxskip items without a value (1.x could returnnullas the minimum).sumandavgare unchanged.
Models
models.set()replaces the whole definition registered under the key, asviews.set()already did in 1.x;models.update()merges into it (set(),update()). In 1.x a secondmodels.set()for the same key added its properties to the first one; now the first part is gone. The app loader still merges model files with the same key.- A
models.set()ormodels.update()afterinit()takes effect on the nextdcupl.update(), which rebuilds all data likeinit()(a full update, also when only one row changed). Lists created before the change keep working and serve the new definition. models.get()andviews.get()return a copy, andmodels.set()andviews.set()store one, so changing either object no longer changes the registered definition (1.x handed out the registered definition).- One app loader per model key: a loader whose
process()would load a model key that another loader of the instance already loads rejects withSharedModelKeyError(exported from@dcupl/loader).dcupl.loaders.remove(key)removes the models, views and rows that loader loaded on the nextupdate()(Processing).
Events and data quality
dcupl.update()emitsresult_updatedon every list whose result can have changed, and the globaldcupl_partial_updateevent when it applies staged data changes or a model definition change (models.set(),models.update()ormodels.remove()afterinit(), also with no data staged; see Events). 1.x emitted neither on a data update.dcupl_handle_datacontainernow fires whenupdate()rebuilds everything; a partial update emits it, with just the$DcuplErrorTrackingErrorsand$DcuplErrorTrackingSummarycontainers, whenever the model has recorded data quality errors, whether or not they changed. While no list over those two models exists, they are built at the first read instead of duringinit()/update(), and the event for that build fires then (Quality reports).dcupl.quality.errors.summary()is new: error totals per model, attribute and error type.- A CSV column that the model doesn't declare is reported as an
UnknownColumnerror (1.x dropped it without an error). A column whose values all fail type conversion is reported asWrongDataType(1.x:UndefinedAttribute). - A resource that fails to load is recorded as a loader error even with
quality: { enabled: false }.
Packages
- The packages are native ES modules (
"type": "module"), soimport { Dcupl } from '@dcupl/core'works in Node ESM. 1.x failed there withERR_UNSUPPORTED_DIR_IMPORT;require()still works. - The browser script builds are gone:
@dcupl/coreand@dcupl/loaderno longer shipdist/browser/*.umd.js. For a<script>tag, use the@dcupl/bundlepackage (dist/dcupl.bundle.min.js). It defines one global,initDcupl({ options, appLoaderConfig, processOptions }), which creates the instance and the loader, fetches the loader configuration, processes it, runsinit()and resolves{ dcupl, loader }(it also setswindow.dcupl). It doesn't check whatinit()resolved to. dcupl.cacheCtrlwas removed.- TypeScript 5.4 or later (Compatibility): the published types use
NoInfer.lists.create<T>()returns aDcuplList<T>(1.x: an untypedDcuplList), andlist.catalog.query.one()is typed| null(1.x:| undefined). See create().
Date values in distinct, facets and suggest
- On a
dateattribute, thedistinctvalues ofaggregate(), the facetvalueandentry.key, and thesuggest()values are ISO 8601 strings in UTC, the formtoISOString()writes ('2024-03-01T00:00:00.000Z'). 1.11.11 answeredDateobjects there. Their JSON output is unchanged:JSON.stringify()writes aDatein the same form. - The facet
keyof a date is the same ISO string. 1.11.11 answered the date'stoString()text in the local time zone of the host ('Fri Mar 01 2024 01:00:00 GMT+0100 (Central European Standard Time)'), also in JSON. Facet keys you stored or compared, for example in URL state or saved selections, no longer match. - Rows with the same date share one value: one
distinctentry with theircount, one facet with theirsize. 1.x listed each row's date as a value of its own. suggest()matches the start of the string, sovalue: '2024-03'suggests the dates in March 2024. 1.x answered[]for a search term on a date attribute.minandmaxon a date attribute (aggregate(),pivot()values,groupBy()aggregates) answer neither value in 2.0.0, without an error. From 2.1.0 they areDateobjects again, as in 1.x.
const distinct = list.catalog.fn.aggregate({ attribute: 'publishedAt', types: ['distinct'] }).distinct ?? [];
distinct[0].value; // a Date at runtime, typed string
(distinct[0].value as unknown as Date).getUTCFullYear(); // 2024const distinct = list.catalog.fn.aggregate({ attribute: 'publishedAt', types: ['distinct'] }).distinct ?? [];
distinct[0].value; // '2024-03-01T00:00:00.000Z'
new Date(distinct[0].value).getUTCFullYear(); // 2024
// The string itself filters on that date, as selecting the facet does
list.catalog.query.addCondition({ attribute: 'publishedAt', operator: 'eq', value: distinct[0].value });What to change: where your code calls Date methods on these values or checks instanceof Date, convert them with new Date(value). To filter on a value, pass the string unchanged to eq or in. For gt, gte, lt and lte, pass new Date(value) or epoch milliseconds: in 2.0.0 an ISO string there matches no row.
Also new or changed
- CSV parsing follows RFC 4180: blank lines are skipped (1.x made an empty item of them), a JSON cell keeps the delimiters inside it (1.x cut it at the first one), and
dynamicTypingtypes an empty cell asnull. Check CSV-based data after the upgrade (DcuplAppLoader). dcupl.scripts.get(key)returns the script's source (1.x returnedundefined). Thescriptingoption can turn scripts off or replace their compiler (scripts).- New init options:
performance.partialUpdates,performance.autoUpdateLists,performance.containerBatchSize,performance.cache.maxSize,quality.maxErrorsPerGroup,performance.cache.maxProjectionSizeandperformance.retainStagedData(DcuplInitOptions).performance.cache.maxSizebounds each list's result cache and each model's query indices (default 64 MiB, least recently used entries are dropped; see Cache Configuration). - Sealed mode is new in 2.0:
performance: { retainStagedData: false }makesinit()drop the staged raw rows after a successful build, anddcupl.seal()does the same on demand;dcupl.sealedreports it. Afterwards every data or model write and a loader'sprocess()throwDcuplSealedError(exported from@dcupl/common),init()/update()resolvefalse, and every read works as before. The default is unchanged. Measured: 15.6–21.8 % less retained heap afterinit()on a 135,880-row fixture (2026-10-15) and 2–24 % on customer catalogs of 1,000–44,000 rows (2026-10-16); see seal(). performance.cache.maxProjectionSize: deep facets and deep filters keep a projected copy of the list per distinct set of dotted attributes, bounded per list (default 256 MiB, least recently used copies are dropped,Infinityfor no bound), separately fromcache.maxSize(Cache configuration).- Error-tracking models: while no list over
$DcuplErrorTrackingErrorsor$DcuplErrorTrackingSummaryexists, they are built at the first read instead of in everyinit()andupdate(), and errors whose description does not depend on the row share one description string per group. Every read returns the same records. On a 23,754-row customer catalog with about 344,000 recorded errors, building them at the first read and sharing the descriptions took the retained heap afterinit()from 285 MB to 110 MB (2026-10-16); see Quality reports. @dcupl/common-internal/sandbox(new in 2.0) exportscreateCompartmentCompilerwithout loading the package's zod schemas, for hosts that compile scripts in a worker (The sandbox).- Model typings:
filteronintandfloatproperties takes onlyboolean(FilterNumberArrayis gone), a model'squality.attributesno longer takesvalidators(set them per attribute), andkeyPropertyaccepts an array for a composite key.
Rows without a key value
In 2.0 a row whose key has no value is dropped and reported as a MissingKey quality error, one per dropped row. This holds for both kinds of key:
- The default key: the row has no
keyfield, or itskeyisundefinedornull. - A custom
keyPropertyon the model or the data resource: the row has no value (undefinedornull) for it; for a composite key, for every part.
SDK 1.x stored such a row under the key 'undefined' ('null' for an explicit null), and several such rows collapsed into one item per key. With the same data, a model that has such rows holds one item fewer on 2.0, or two fewer if it had both kinds. An empty string is a value: a blank key cell in a CSV file gives the key '' and the row is kept. A composite key with only some parts missing is kept, with 'undefined' or 'null' for those parts (keyProperty).
Find the dropped rows. The rows are dropped whether quality validation is on or not; MissingKey is recorded only while it is on, which is the default. Count the errors with summary() (the errorType facet of $DcuplErrorTrackingErrors leaves MissingKey out):
const missingKeys = dcupl.quality.errors.summary().filter((group) => group.errorType === 'MissingKey');
// one entry per model and key attribute: group.model, group.attribute, group.totalCount (dropped rows)After init(), a keyless row in an upsert() that dcupl.update() applies on the partial path is currently dropped without the error; the next full rebuild reports it.
What to change. Give every row a key value before you load it:
- Set
keyPropertyon the model or the data resource to a column that has a value in every row. The error'sattributenames the key attribute that had none. - For data without a natural identifier and without a
keyProperty, setautoGenerateKey: true: rows without akeyget a generated one (autoGenerateKey). It doesn't fill a customkeyProperty. - Or remove the rows without a key at the source.
Query API migration (2.0)
The 2.0 query refactor adds clearer, intention-revealing method names next to the old ones. The old methods still work, so existing code keeps running; the new methods say what they do and carry JSDoc examples in your IDE.
Migration strategy
You don't need to migrate everything at once:
- Old methods keep working in 2.0 (a few are marked
@deprecated, see Deprecation Status) - Adopt new methods incrementally as you work on code
- Use new methods for all new code
Type aliases
The query types have alias names. Both sets are exported from @dcupl/common and are the same types (QueryCondition = DcuplQuery, QueryConditionGroup = DcuplQueryGroup, QueryExecutionContext = DcuplGlobalQueryOptions, ListQueryOptions = DcuplListQueryOptions); neither set is deprecated, so use whichever reads better.
import type { DcuplQuery, DcuplQueryGroup } from '@dcupl/common';
const condition: DcuplQuery = { operator: 'eq', attribute: 'status', value: 'active' };
const group: DcuplQueryGroup = { groupType: 'and', queries: [condition] };import type { QueryCondition, QueryConditionGroup } from '@dcupl/common';
const condition: QueryCondition = { operator: 'eq', attribute: 'status', value: 'active' };
const group: QueryConditionGroup = { groupType: 'and', queries: [condition] };Adding query conditions
// Magic applyQuery with mode options
builder.applyQuery(condition, { mode: 'add' });
builder.applyQuery([condition1, condition2], { mode: 'add' });
builder.applyQuery(group, { mode: 'add' });// Explicit methods that reveal intent
builder.addCondition(condition);
builder.addCondition([condition1, condition2]);
builder.addGroup(group);Replacing query conditions
builder.applyQuery(condition, { mode: 'set' });
list.catalog.query.apply(condition, { mode: 'set' });builder.setCondition(condition);
list.catalog.query.setCondition(condition);Both forms replace only the conditions on that condition's attribute; conditions on other attributes are kept. Use setGroup(group) to replace a group by its groupKey.
State management
builder.reset();
builder.removeAllQueries();
builder.getQuery();builder.clear();
builder.clearConditions();
builder.getCurrentQuery();Executing queries
const items = list.catalog.query.execute();
const limited = list.catalog.query.execute({ count: 10 });
const query = list.catalog.query.get();const items = list.catalog.query.items();
const limited = list.catalog.query.items({ count: 10 });
const first = list.catalog.query.first();
const exists = list.catalog.query.any();
const total = list.catalog.query.count();
const query = list.catalog.query.current();Complete migration example
import { DcuplQueryBuilder } from '@dcupl/common';
const builder = new DcuplQueryBuilder();
builder.init({ modelKey: 'Product' });
builder.applyQuery({ operator: 'eq', attribute: 'status', value: 'active' }, { mode: 'add' });
builder.applyQuery({ operator: 'gte', attribute: 'price', value: 100 }, { mode: 'add' });
builder.applyQuery(
{
groupKey: 'categories',
groupType: 'or',
queries: [
{ operator: 'eq', attribute: 'category', value: 'electronics' },
{ operator: 'eq', attribute: 'category', value: 'books' },
],
},
{ mode: 'add' },
);
const query = builder.getQuery();
const attrs = builder.getQueryAttributes(); // 2.0 only (deprecated), not in 1.ximport { DcuplQueryBuilder } from '@dcupl/common';
const builder = new DcuplQueryBuilder();
builder.init({ modelKey: 'Product' });
builder.addCondition({ operator: 'eq', attribute: 'status', value: 'active' });
builder.addCondition({ operator: 'gte', attribute: 'price', value: 100 });
builder.addGroup({
groupKey: 'categories',
groupType: 'or',
queries: [
{ operator: 'eq', attribute: 'category', value: 'electronics' },
{ operator: 'eq', attribute: 'category', value: 'books' },
],
});
const query = builder.getCurrentQuery();
const attrs = builder.getAttributeNames();
const count = builder.getConditionCount();
const isEmpty = builder.isEmpty();Interactive list migration
function applyStatusFilter(status: string) {
list.catalog.query.apply({ operator: 'eq', attribute: 'status', value: status }, { mode: 'add' });
return list.catalog.query.execute();
}
function clearFilters() {
list.catalog.query.reset();
return list.catalog.query.execute();
}
function getFirstProduct() {
const results = list.catalog.query.execute({ count: 1 });
return results[0];
}function applyStatusFilter(status: string) {
list.catalog.query.addCondition({ operator: 'eq', attribute: 'status', value: status });
return list.catalog.query.items();
}
function clearFilters() {
list.catalog.query.clear();
return list.catalog.query.items();
}
function getFirstProduct() {
return list.catalog.query.first();
}
// Additional new capabilities
function hasProducts() {
return list.catalog.query.any();
}
function getProductCount() {
return list.catalog.query.count();
}
function hasFilters() {
return !list.catalog.query.isEmpty();
}Quick reference tables
DcuplQueryBuilder methods
| Old Method | New Method | Purpose |
|---|---|---|
getQueryAttributes() (2.0 only) |
getAttributeNames() |
Get attribute name strings |
extractQueryAttributes() (2.0 only) |
getAttributeDetails() |
Get detailed attribute info |
applyQuery(..., {mode:'add'}) |
addCondition() |
Add condition(s) |
applyQuery(group, {mode:'add'}) |
addGroup() |
Add query group |
applyQuery(..., {mode:'set'}) |
setCondition() |
Replace condition(s) |
applyQuery(group, {mode:'set'}) |
setGroup() |
Replace a group by key |
reset() |
clear() |
Full reset |
removeAllQueries() |
clearConditions() |
Remove conditions only |
getQuery() |
getCurrentQuery() |
Get current state |
hasQueryGroup() |
hasGroup() |
Check group exists |
| - | isEmpty() |
Check if empty |
| - | hasCondition() |
Check condition exists |
| - | getConditionCount() |
Count conditions |
CatalogApi methods
| Old Method | New Method | Purpose |
|---|---|---|
apply(..., {mode:'add'}) |
addCondition() |
Add condition(s) |
apply(group, {mode:'add'}) |
addGroup() |
Add query group |
apply(..., {mode:'set'}) |
setCondition() |
Replace condition(s) |
apply(group, {mode:'set'}) |
setGroup() |
Replace a group by key |
remove(condition) |
removeCondition() |
Remove condition(s) |
remove({groupKey}) |
removeGroup(groupKey) |
Remove group |
reset() |
clear() |
Clear all conditions and options (sort, start, count) |
get() |
current() |
Get current query |
execute() |
items() |
Get all results |
| - | first() |
Get first result |
| - | any() |
Check if results exist |
| - | count() |
Count results (respects start/count) |
| - | isEmpty() |
Check if no conditions are set |
Deprecation status
Current status (2.0): every old method still works. On DcuplQueryBuilder, applyQuery(), removeAllQueries(), getQueryAttributes() and extractQueryAttributes() are marked @deprecated in JSDoc (getQueryAttributes() and extractQueryAttributes() were added in 2.0 and never existed in 1.x). The other old names — reset(), getQuery() and hasQueryGroup() on the builder, and apply(), remove(), reset(), get() and execute() on list.catalog.query — are not deprecated.
Recommendation: Start using new methods for new code, migrate existing code opportunistically. The quick reference tables list every old name next to its new one.