Add TIMEZONE, SYSTIMEZONEIDENT and SYSTIMEZONEOFFSET to the startupservice
globvars and display them in the Environment Details block, so the session
clock used to stamp submission timestamps is directly visible to admins.
# [7.13.0](https://git.datacontroller.io/dc/dc/compare/v7.12.0...v7.13.0) (2026-09-03)
### Bug Fixes
* dynamic js mocks ([3b9344c](3b9344cb60))
* adding formula types ([c07a01a](c07a01a38d))
* address hermes review feedback (formula quoting, cell revert, addRow guard) ([36963aa](36963aa746))
* agent skills and nextviya deploys ([f7db871](f7db8719f5))
* bump core for mp_validate fix ([5a44b28](5a44b2804f))
* CAS support for REPLACE type plus docs ([ea05f07](ea05f07180))
* clarify debug comment is permanent, extend ComputeContextDetails with Viya response fields ([72484ae](72484ae844))
* core major bump plus autofix of viya context on deploy ([6527c10](6527c10f26))
* **core:** bump to v5 (breaking change) ([59e9e96](59e9e96f5a))
* **cypress:** correct mock data, test expectations, and abort modal ([13c10be](13c10be30f))
* **cypress:** make licensing combined-key tests order-independent ([4db8d62](4db8d62dca))
* **deploy:** Viya deploy checks, startup diagnostics, and chunked deploy script ([917925f](917925f553)), closes [#303](#303) [#200](#200) [#125](#125)
* **deps:** align @angular/* packages to the same lockstep version ([24e6297](24e6297187))
* **deps:** override nanoid to 3.3.18 in sas/ ([d57ae03](d57ae03fc4))
* **deps:** pin @handsontable/angular-wrapper to 18.0.0 ([2d7df66](2d7df66fc2))
* **deps:** pin babel-loader to resolve an unresolvable peer conflict ([3bf3bf0](3bf3bf0dde))
* **deps:** regenerate lockfile with Node 24 and pin handsontable to 18.0.0 ([7da29bd](7da29bdb11))
* **deps:** regenerate lockfile with strict peer-dep resolution to fix npm ci in CI ([efd2e18](efd2e18c7b))
* **deps:** regenerate package-lock.json to resolve npm ci sync errors ([a501903](a501903e6d))
* **deps:** resolve npm audit findings and align @angular/* to the same lockstep version ([b9e4b27](b9e4b2733f))
* **deps:** resolve npm audit findings via direct lockfile patch, not regeneration ([4d1bfa6](4d1bfa6343))
* **deps:** resolve npm audit vulnerabilities in production dependencies ([257f69c](257f69ccc6))
* **deps:** scope the brace-expansion override to its actual vulnerable chain ([f9d061c](f9d061c489))
* dynamic getdynamiccolvals.js ([d59ef44](d59ef44226))
* **editor:** clear sort before reading cells to preserve on cancel ([4137ebd](4137ebdf6b))
* **editor:** escape formula-looking values in uploaded Excel data ([e69df3d](e69df3deb2))
* **editor:** fix formula $-substitution corruption and sorted-grid row/formula desync ([e139c37](e139c37fbf))
* **editor:** let the column info dropdown's text be selected and copied ([1e516f4](1e516f4012))
* **editor:** re-mark a reverted cell as auto-escaped so "Apply as formula" works again ([12091c4](12091c4044))
* **editor:** resolve a primary key's live formula to its computed value, not the raw formula text ([d72e19a](d72e19a308))
* **editor:** resolve computed value for a live formula in numeric columns too ([2cc8489](2cc8489892))
* **editor:** restore multi-column sort with one call, not a loop ([8fafeb7](8fafeb7923))
* **editor:** size table-header buttons and title to content, not fixed grid thirds ([d0a7561](d0a7561f1a))
* **editor:** stop dc.row_status from being submitted to the backend ([dc0f6a7](dc0f6a7baa))
* **editor:** stop turning missing columns into undefined, and fix column lookup for sparse rows ([a104f78](a104f78645))
* **editor:** submit a live formula's computed value, not its raw text ([b7d3106](b7d31066ae))
* ensure cleanup of casuser temp table in error condition ([c4123e5](c4123e5c96))
* ensuring formats arrive from backend in getstagetable.sas ([daacb49](daacb49c8a))
* escape regex metacharacters in context name before prxchange ([9023eb2](9023eb2a39))
* failing test ([f213d24](f213d24f09))
* fixed npm vuln ([26b55b1](26b55b1bde))
* **formulas:** avoid EDIT_STATUS colliding with a real column of that name ([acb97f4](acb97f4bfb))
* hardening following PR review feedback ([1f4aa6f](1f4aa6fcea))
* history mock ([0082828](0082828543))
* **licensing:** bridge async proceed() through cy.then and replace Response.arrayBuffer with manual stream reader ([8e51fde](8e51fde524))
* **mocks:** add fixture staged data and MPE_SUBMIT row for stage.cy.ts ([6e2548d](6e2548d78c))
* **mocks:** add LIBRARYNAME to startupservice, fix viewbox column order ([c0af86e](c0af86ef29))
* **mocks:** limit stage fixture to 1 row, fix column order ([f25ecd8](f25ecd8828))
* **mocks:** look up PK_FIELDS from MPE_TABLES in viewdata, fallback for MPE_AUDIT ([94d2a79](94d2a7970e))
* **mocks:** remove MPE_VALIDATIONS and MPE_ALERTS from DC996664 libref ([4f7b845](4f7b845390))
* **mocks:** remove shadowed saveTableData in rejection.js ([fed0396](fed0396737))
* **mocks:** remove SOME_SHORTNUM HARDREGEX from MPE_VALIDATIONS ([34bd660](34bd6607c5))
* **mocks:** rename saveTableData in postdata.js to avoid shadowing ([e4b04b1](e4b04b1d44))
* **mocks:** SAS datetime format in getchangeinfo, login in stage.cy.ts ([c180934](c180934167))
* more mock improvements ([a80927c](a80927cbb6))
* mp_execute dep ([023c29f](023c29f00f))
* pin fast-uri to 3.1.7 to close high-severity SSRF/host-confusion advisories ([f4325b2](f4325b2056)), closes [hi#severity](https://git.datacontroller.io/hi/issues/severity)
* spy for startup ([69b4155](69b41559f7))
* **stage:** submit data via editor flow, use dynamic date assertion ([490d013](490d0137b9))
* **startup:** show the real startupservice response text on a malformed reply ([9a1b7d0](9a1b7d0f52))
* tests and realistic getcolvals.js ([5d03164](5d03164b6a))
* **tests:** new libref ([c0bc87d](c0bc87d331))
* unnecessary getsubmits call removed ([cc2ff87](cc2ff873fa))
* using utility macro for webout ([3595bb9](3595bb9499))
### Features
* **editor:** color row-header status cells and switch modified symbol to ± ([e15f2c2](e15f2c2a34))
* **editor:** generalize cell revert to any overwritten value, not just formulas ([07d586d](07d586da52))
* **editor:** scope live formulas to character columns, not just HARDFORMULA/SOFTFORMULA ones ([92d0d91](92d0d91440))
* **editor:** translate column names to cell references on formula paste ([1031ea7](1031ea7ed7))
* **formulas:** flag formula-overwritten cells with revert, harden dc.row_status against SAS name collisions ([a8237b2](a8237b2881))
* **formulas:** live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix ([4a8c39b](4a8c39b4c0))
* **formulas:** wire HyperFormula into HARDFORMULA/SOFTFORMULA rules ([cbea04c](cbea04c8e1))
* functional JS mocks ([ee52bb8](ee52bb8967))
* **licensing:** support combined-key paste, object-format features, and a live key preview ([20ba633](20ba633087))
* **licensing:** warn and block applying a licence key generated for the wrong protocol ([41680e2](41680e2ecc))
* **stage:** add Formatted/Unformatted toggle to the staging page ([74c38e1](74c38e1641))
* updated configurator for Viya deploy ([df2027d](df2027dab6))
* **viewboxes:** add drag-to-resize on all four edges and corners ([e5a5bf2](e5a5bf2144))
multiColumnSorting's sort() replaces the entire sort state on every call rather than accumulating, so the restore loop after updateSettings()/editTable()/cancelEdit() silently dropped every sorted column but the last. Pass the captured configs as a single array instead. Adds a Karma integration test proving the loop
loses columns, and a Cypress test verifying cancelEdit() keeps a real shift-click multi-column sort intact end-to-end.
cancelEdit()'s getFormulaCellsToPreserveOnCancel callbacks passed a
physical rowIndex straight into visual-row-expecting Handsontable APIs
(getCommentAtCell, getDataAtRowProp) while the grid could still be
sorted, since clearSort() only ran later, right before updateSettings().
On a sorted grid this read the wrong row's comment/value, corrupting
which formula cells got preserved across cancel. Move clearSort() (via
getCurrentSortConfigs()) ahead of those callbacks instead.
String.replace interpreted $&/$`/$'/$<digit> specially in DC.ORIG_VALUE/DC.USER_NAME substitutions, corrupting formulas whose cell data happened to contain a literal $. Separately, afterChange indexed dataSource with Handsontable's visual row instead of translating to physical (as beforeChange already does), desyncing EDIT_STATUS/overwritten-comment writes on a sorted grid. Investigating that surfaced a third, unrelated Handsontable bug: updateSettings() corrupts formula cell references whenever a sort is active, regardless of what it changes - fixed by clearing the sort around every updateSettings() call.
Rewrite stage.cy.ts to follow the same pattern as excel.cy.ts:
open MPE_X_TEST, attach regular_excel.xlsx, submit to create staged
data, then test the formatted/unformatted toggle on the stage page.
The date assertion is now dynamic: reads the raw SOME_DATE value
from the unformatted view and computes the expected SAS date9.
formatted value, rather than hardcoding 12FEB1960.
Remove the fixture stageSubmission code from makedata.js — staged
data is created at runtime by stagedata.js, not by makedata.
The stage.cy.ts fixture had 7 rows of MPE_X_TEST data with wide string
values, causing Handsontable to auto-size columns wider so only 4 of
10 fit in the 1280px viewport. The test checks .ht_clone_top headers
which only render visible columns.
Limited to 1 row (matching the original sas9 mock) and moved
_____DELETE__THIS__RECORD_____ to the end of the column order.
getchangeinfo.js: use SAS datetime19. format (DDMMMYYYY:HH:MM:SS)
for SUBMITTED_ON_DTTM and REVIEWED_ON_DTTM, matching the real SAS
output. Use SUBMIT_STATUS_CD field name (not REVIEW_STATUS_ID)
to match the MPE_SUBMIT column name and getchangeinfo.sas output.
stage.cy.ts: add login step in beforeEach (the beforeAll logs out
after uploading the license, so each test needs to re-login).
Use loginAndUpdateValidKey(true) to force license upload.
stage.cy.ts navigates directly to /stage/DC20221007T122326121_612316_7259
and expects a hot table with MPE_X_TEST data. The mock had no fixture
staged.json or MPE_SUBMIT row for this table ID, so getstagetable
returned 0 rows and getchangeinfo returned empty jsparams.
Added a fixture staged.json with MPE_X_TEST rows (SOME_DATE=42) and
a matching MPE_SUBMIT row (SUBMITTED status) in makedata.js.
viewdata.js hardcoded PK_FIELDS as empty, so the viewbox grid treated
all columns as non-PK. removeAllColumns() could delete every column
including the ones the tests expect to be protected.
Now reads MPE_TABLES buskey (mirrors mp_getpk) and falls back to
LOAD_REF LIBREF DSN KEY_HASH TGTVAR_NM for MPE_AUDIT (which has a
known index-based PK in real SAS but isn't registered in MPE_TABLES).
All 12 viewbox.cy.ts tests now pass.
startupservice.js only returned LIBREF in saslibs, but the dc-tree
component renders LIBRARYNAME — without it the nav tree node text was
empty, so libraryToOpenIncludes could not match. Added LIBRARYNAME,
LIBRARYID, and ENGINE to match the viewlibs service format.
Changed libraryToOpenIncludes_SASJS from 'dc' to 'dc_jslib' to avoid
matching multiple libraries (DC_JSLIB and DC996664 both contain 'dc').
viewdata.js: removed alphabetical .sort() on cols (SAS preserves
physical column order via mp_getcols); added empty row with correct
SAS missing values ('.' for numeric, '' for character) when a table
has no data, so the viewbox grid can derive column headers.
The Excel fixture files (7th_tab, extra_column, duplicate_column,
duplicate_row, regular_xls, regular_with_delete, password, leading
whitespace) contain SOME_SHORTNUM values of 0, 1, and 2. The
HARDREGEX /^(??$).*/ blocks those values, aborting
the upload before it reaches the submit/approve flow. Removing the
rule allows all 23 excel.cy.ts tests and both liveness.cy.ts tests
to pass.
Same shadowing issue as rejection.js: postdata.js defined its own
saveTableData(tableName, tableData, tableLibref) which was shadowed
by dcMockUtils.saveTableData(dataDir, tableName, tableData) from the
eval. Renamed to saveTableToLib to eliminate the collision.
rejection.js defined its own local saveTableData(tableName, tableData)
with 2 args, but dcMockUtils.js (loaded via eval) also defines
saveTableData(dataDir, tableName, tableData) with 3 args. The
dcMockUtils version was shadowing the local one, causing
saveTableData('MPE_SUBMIT', submitData) to pass the table name as
dataDir and the data object as tableName, crashing with
'TypeError: tableName.toLowerCase is not a function'.
Removed the local saveTableData and updated calls to use the
dcMockUtils 3-arg signature: saveTableData(dataDir, tableName, data).
The multi-load fixture sheets for DC996664.MPE_VALIDATIONS had data
misaligned with the schema, corrupting the shared submit state. The
tests only need MPE_X_TEST and MPE_TABLES, so only register those two.
- editor.cy.ts: assert DC.USER_NAME is non-empty instead of hardcoded
'sasdemo' (CI runs as a different OS user)
- editor.cy.ts: use =SOME_BESTNUM * 0 + 1936 for a deterministic formula
result regardless of the column's actual value
- excel.cy.ts: expect computed value '2' instead of formula text '=1+1'
(no XL_RULE=FORMULA configured on that column)
- excel-multi-load.cy.ts: use DC996664 libref to match the actual fixture
sheet names
- makedata.js: add labels to DC_JSLIB.MPE_X_TEST schema (SOME_CHAR,
SOME_DATE, SOME_DATETIME, SOME_TIME) for the viewer-labels tests
- makedata.js: register DC996664 libref with MPE_X_TEST/MPE_TABLES/
MPE_VALIDATIONS/MPE_ALERTS and copy table files for multi-load fixtures
- makedata.js: change SOME_SHORTNUM HARDREGEX from [1-5] to [0-2]
- loadfile.js: exclude VAR_TXFROM/VAR_TXTO/VAR_PROCESSED columns from the
missing-cols check for TXTEMPORAL tables (computed at load time)
- InfoModal.ts: add _PROGRAM to AbortDetails
- info-modal.component.html: display _PROGRAM in the abort modal
- 4 call sites: pass abortRes._PROGRAM when building AbortDetails
- licensing.cy.ts: call proceed() unconditionally via cy.then() instead of conditionally chaining .then() after cy.wait(), so the command queue stays open across the async gap and enqueued commands always run
- combinedLicenceKey.ts: replace new Response(stream).arrayBuffer() with an explicit streamToArrayBuffer helper for broader browser support
- remove debug-license.cy.ts, an untracked throwaway repro for the promise-handling question
- Tests 7-10 only acted when already on the licensing page, silently
no-oping otherwise; now they navigate to licensing/update explicitly
when needed, chained via .then() to avoid Cypress conflating a raw
async function's promise with the preceding cy.visit()/cy.wait()
- Replace Response(readable).arrayBuffer() with a direct
ReadableStream reader loop in generateCombinedKey - the Response-based
read never settled under Cypress, likely due to its fetch/Response
patching for network interception
- Accept a single pasted/uploaded combined licence key (base64+gzip of
licence+activation key), auto-detected in either paste mode, alongside
the legacy two-field format
- Warn and block applying a key generated for the wrong protocol before
it ever reaches the backend
- Decode both the legacy positional features string and a new named
features object, so already-issued keys keep working unchanged
- Show a live "Key Details" preview (validity, users, site IDs, enabled
features) as a key is typed, pasted, or uploaded
- Fix cypress test setup broken by the paste-format default change, and
an unreliable FileReader-based base64 conversion in the test helpers
Writing back an unescaped cell's value unconditionally materialized a column the backend never populated as an explicit `undefined`, which the SASjs adapter rejects at submit time. dynamicCellValidation's column-name lookup (Object.keys(row)[column]) only worked by coincidence when every row had a full key set - fixed to use hot.colToProp instead.
saveTable() only resolved a live formula's computed value for character columns - coerceNumericRow (beforePaste/beforeAutofill) deliberately leaves a non-numeric, formula-looking value alone rather than forcing it to NaN, so a numeric column can hold a live formula too. Its raw `=...` text was reaching the backend, which a numeric SAS field can't parse.
saveTable() only resolved computed values for HARDFORMULA/SOFTFORMULA
columns - a formula typed, pasted, or "Apply as formula"'d into any
other character column still had its raw `=...` text submitted,
since HyperFormula recomputes the display without ever mutating the
underlying dataSource string.
- Add StartupCheckService to show deploy check progress on loading screen
with step-by-step status (appLoc check, Viya deploy, startup service)
- Add console logging throughout checkViyaDeploy and viyaMakedataSuccessfull
to diagnose deploy flow issues
- Fix missing return after resolve(false) in viyaMakedataSuccessfull when
folderId is undefined, which caused fall-through to getFolderMembers
with an undefined ID
- Fix Viya Folders API pagination: getFolderMembers now requests limit=500
so all members are returned (admin folder has 32 members, default page
size was hiding makedata)
- Fix error handler in viyaMakedataSuccessfull to resolve(false) instead of
reject() so the app falls back to setup screen on API errors
- Fix licence key whitespace: SAS makedata initialises keys as a single
space which is truthy in JS; trim keys before checking so empty keys
correctly fall back to free tier instead of triggering decryption errors
- Always set _debug=131 on makedata URLs (automatic and manual deploy)
so full debug output is available for the one-time setup service
- Add makedata completion polling after runMakedataInNewWindow so the app
auto-reloads when the makedata job self-deletes, instead of hanging
- Fix loading screen slider position from absolute to relative so it does
not obscure the startup check steps
- Add chunk_deploy.py to sasjs/utils for chunked Viya deploys, splitting
viya.sas by service and web file boundaries with correct %let path=
tracking per chunk
Closes#303, #200, #125
dataSourceRaw is now escaped the same way dataSource is, so Revert
restores the escaped text a user actually saw instead of the raw
backend string (which would otherwise evaluate live again).
beforeChange re-marks a cell revert_cells restores to its escaped
form, distinguishing that from a user typing a literal leading `'`
themselves via the write's source tag.
Uploaded file content is bulk external data, not user-typed input, so
a `=`-led value in a character column must be escaped the same way a
fresh backend load is - otherwise it silently evaluates as a live
formula now that formulas are enabled for every table.
Only backend-sourced `=`-led values are auto-escaped now; anything the
user types or pastes into a character column evaluates as a real
formula, and "Apply as formula" lets a user promote an auto-escaped
cell explicitly. Formulas are enabled on every table instead of only
ones with formula rules.
- Paste a formula using column names (e.g. "=A_COL * B_COL") into any
cell and have it translated to that row's cell references
("=B4 * C4") so HyperFormula can evaluate it - covers both a
grid-level paste and pasting directly into an open cell editor
- Only applies on tables that already have formulas enabled (an
existing HARDFORMULA/SOFTFORMULA column); otherwise the pasted text
is left untouched rather than becoming inert translated text
- New substituteColumnReferences() reuses parseFormulaRule's
boundary-matching helpers but deliberately skips DC.* variable
substitution, which only makes sense for admin-defined rule values
symget('_contextname') is concatenated directly into the PRX replacement
string in makedata.sas. A context name containing \ or $ would be
interpreted as a backreference or variable interpolation in the Perl
regex replacement. Escape both characters with a leading backslash in a
data step before embedding the value in the prxchange call.
- sasjs-configurator: rewrite _debug=131 comment to make explicit that
debug is ALWAYS enabled for the makedata service (runs once during
deployment, debug output always wanted for diagnostics). Remove the
commented-out debug-off line that caused reviewer confusion.
- viya-compute-context-details.model: extend Attributes with
allowXCMD, sessionInactiveTimeout (number), and make all attribute
fields optional. Add Environment interface with autoExecLines string
array. Add environment? to ComputeContextDetails (optional, not all
contexts expose it).
- automatic.component: rewrite extractRunAs to use
attributes.runServerAs directly per the actual Viya API response
shape. Remove all any casts and the non-existent runAsUserId /
environment.runAsUserId field probes.
- Escape embedded double quotes in DC.USER_NAME/DC.ORIG_VALUE formula
literals, preventing malformed HyperFormula expressions
- Extract cell-revert numeric parsing into resolveRevertedCellValue,
falling back to the raw text instead of writing NaN
- Guard addRow() against an empty dataSource before indexing into it
- Document the cross-repo assumption behind licence protocol-mismatch
detection
A key generated for one connection type (http/https) always fails to
decrypt on the other, surfacing only as a generic "invalid key" error
after a round trip to the backend. Detects the mismatch client-side
from the raw key text before the user submits, and disables the apply
button while it's showing.
dynamicCellValidation() built its editors/getdynamiccolvals payload
from a raw dataSource row clone, which always carries the client-only
EDIT_STATUS column - unlike saveTable()'s submit path, nothing
stripped it before the request went out. Extracted the strip logic
(previously private to classifyRow) into a shared withoutEditStatus
util and applied it here too.
@sasjs/cli's own nanoid override doesn't propagate to consumers - npm
only reads overrides from the root project being installed. Adding it
here directly, rather than a raw lockfile patch, so it survives future
npm install runs instead of silently reverting again.
Adds right, left, bottom, and bottom-left-corner resize handles
alongside the existing bottom-right corner, and raises resize handle
z-index above Handsontable's frozen header clones so clicks reach the
handle instead of the table underneath it.
A misconfigured Viya computeTasks deployment makes the Compute service
return a plain-text "Job error" body instead of JSON. @sasjs/adapter can't
parse it and resolves with that raw text as adapterResponse, so both
startup and manual deploy validation reported every expected field as
"not present" without ever showing the actual cause. Added
getMalformedAdapterResponseMessage to detect a non-object adapterResponse
and surface its real text instead.
Handsontable's Menu widget closes on any mouseup inside an item and
unconditionally preventDefault()s contextmenu, even for the info item,
which has no callback and exists purely to show read-only column details.
That made its text impossible to select or right-click-copy. Marked the
item isCommand: false and stop mousedown/mouseup/contextmenu/selectstart
from bubbling past its rendered content to the menu's own listeners.
The Filter/Edit/Upload buttons used btn-block (width: 100%), stretching
them wider than the Cancel/Add Row/Submit buttons shown during edit.
The back/viewboxes, title, and action-button columns were also locked to
equal 12-col grid thirds regardless of actual content, so the dataset
name/row-count wrapped even with visible free space on either side.
Switched the outer columns to size to their content (clr-col-*-auto) and
let the title column flex-grow into whatever space is left.
An unscoped "brace-expansion": "^5.0.9" override applies to every
occurrence in the tree, not just the vulnerable one - it was silently
replacing the 1.1.16 that glob's bundled minimatch@3.x actually needs,
breaking license-checker with "TypeError: expand is not a function".
Scoped it to the readdir-glob chain (exceljs -> archiver -> readdir-glob
-> minimatch@10.x), the same nested-override pattern already used here
for exceljs's own archiver/unzipper pins.
Fixed by patching only the 12 needed entries directly (9 @angular/*
packages to 20.3.27, brace-expansion to 5.0.9, fast-uri to 3.1.5, undici to 6.28.0) with exact registry resolved/integrity metadata, leaving the other 1925 packages untouched.
Incremental `npm install <pkg>@version` patches left the lockfile
internally inconsistent enough that CI's `npm ci` rejected it as out of
sync with package.json (a long list of unrelated nested deps reported
missing). Regenerated from scratch instead of patching further; verified
with a clean `npm ci`, matching the resolved versions from the prior fix.
- Angular packages (core/common/compiler/animations/forms/
platform-browser*/router/compiler-cli) all peer-depend on each other's
exact version - npm audit fix only bumped the three with a direct
advisory, leaving the rest mismatched; a plain `npm install` tolerated
it but CI's strict `npm ci` didn't. All now resolve to 20.3.27.
- undici/fast-uri updated via npm audit fix; brace-expansion pinned to
^5.0.9 via overrides (npm's resolver wasn't proactively bumping a
nested transitive dep that already satisfied its declared range)
npm audit fix only bumped @angular/core/common/compiler to 20.3.27 (the
packages with a direct advisory), leaving animations/forms/platform-browser*/
router at 20.3.26 - each peer-depends on the others' exact version, so the
mismatch passed a lenient `npm install` locally but failed CI's strict
`npm ci`. All seven now resolve to 20.3.27.
- Angular packages resolve within their existing ^20.3.26 range up to
20.3.27, which patches two advisories (HttpTransferCache cache-key
ambiguity, i18n XSS via event-handler attributes)
- brace-expansion (via exceljs -> archiver -> readdir-glob -> minimatch)
needed an override to ^5.0.9 - npm's resolver won't proactively bump a
nested transitive dep that already satisfies its declared semver range,
even when a patched version exists inside that same range
- Formulas' HyperFormula sync resolves a dotted `data` key differently
than getDataAtRowProp/datamap.get() - dataDotNotation: false is needed
for the renamed dc.row_status column to work as a live cell reference
- saveTable() now submits the live computed value for a formula cell, not
the raw '=...' string still sitting in dataSource
- Mirrors the review page's toggle: getstagetable.sas now also webouts
fmt_stagetable (SAS-formatted), selectFormattedRows() picks between it
and the raw stagetable, falling back to raw if fmt data is absent
- Adds stage.cy.ts e2e coverage and wires it into both build.yaml and
release.yaml's cypress --spec lists (previously out of sync with
each other on viewbox.cy.ts too)
- addRow()/insertRowAtPosition() use hot.alter() so HARDFORMULA/SOFTFORMULA
values realign on insert instead of going stale
- New hidden EDIT_STATUS column gives DC.ROW_STATUS a real, live cell
reference; row-header +/-/~ indicator now translates visual->physical
row index so it stays correct when the grid is sorted
- UserService is now providedIn: 'root' instead of module-scoped, fixing
DC.USER_NAME (and any other lazy-module reader) always seeing an unset user
- Column-info dropdown shows the applied formula ("√x=<formula>")
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
### Bug Fixes
* adding REGEX validations to mpe_x_test ([bd798b4](bd798b424a))
* default value for label ([a3e46a9](a3e46a968e))
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](cac9244f92))
* ensure only one REGEX applies at a time ([8fb58eb](8fb58eb36e))
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](33dcb989d3))
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](b51c770782))
* licensecheker ([f60bcef](f60bcef583))
* **lint:** remove redundant optional chaining ([d881290](d881290618))
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](cfb60e5e4b))
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](e22edf7ed3))
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](7a35cf4a45))
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](05fe4744d5))
* **regex:** special missing handling ([180c247](180c2477ed))
* removing low severity warning in npm audit ([2a771bb](2a771bb91a))
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](0392a81cbd))
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](7ed3730ae3))
### Features
* **docs:** adding agents.md and docs for RLS ([359d833](359d833406))
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](17e4802895))
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](57db1179a9))
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](39c8855f37))
* **regex:** backend validations on regex strings ([d2c93a4](d2c93a46fa))
* using ALL libraries as validation in MPE_SECURITY. Closes [#279](#279) ([62ff0ae](62ff0aee4a))
* validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes [#211](#211) ([ea00c5a](ea00c5afad))
* validation on RLS for REPLACE, + docs + tests. Closes [#211](#211) ([7378f3b](7378f3ba30))
Both rules can now apply to one column: HARDREGEX still blocks submission
and takes its own tooltip, but SOFTREGEX is evaluated (and shown) whenever
HARDREGEX passes, instead of being silently suppressed whenever HARDREGEX
was merely present. Column-header info dropdown labels each rule separately
when a column has both.
Replace one-shot .then()/for-loop matching with retrying .should()/cy.contains()
lookups, drop stale cached DOM node references (viyaLib) in favor of live re-queries.
Verifies parseRegexRule against a corpus of RULE_VALUEs collected from an
existing MPE_VALIDATIONS table - none use PRX-only syntax, so behavior is
confirmed to match SAS PRX exactly, not just "doesn't throw". Pins two
data quirks found along the way (a range vs. literal-hyphen character
class, and a double-escaped lookahead that's a no-op in both engines)
rather than silently treating either as a bug to fix.
Adds HARDFORMULA/SOFTFORMULA DQ rule types, backed by Handsontable's
formulas plugin (gated per-table via hasFormulaRules). parseFormulaRule
substitutes column names with row-relative cell references (quote-aware,
blank-boundary matching per spec) and resolves DC.USER_NAME/DC.ORIG_VALUE
to literal values; applyFormulaRules injects the computed formula per row,
reusing the existing READONLY mechanism for HARDFORMULA.
DC.ROW_STATUS/EDIT_STATUS deliberately deferred - would require prepending
COLHEADERS, which headerColumns has undocumented positional coupling to
elsewhere in editor.component.ts.
RULE_VALUE is authored in PRX delimiter form because prxparse() requires
it, but HARDREGEX, the SOFTREGEX grid renderer, and failsSoftRegex were
all passing that string straight into `new RegExp()`, so the delimiters
and flags were matched as literal characters instead of applied - making
these rules silently never match real data.
Adds parseRegexRule (extracted, tested independently) to strip the
delimiters, apply flags, hoist a leading (?i) modifier, and translate
\Q...\E and \A/\z to their JS equivalents. Atomic groups and possessive
quantifiers are left unfixed (documented, fail-safe) - translating them
risks renumbering the pattern's own capture groups.
Also switches the REGEX_HARD_COL/REGEX_SOFT_COL mock rules to the
delimited form so editor.cy.ts's existing e2e coverage actually exercises
this path.
Bumps @angular/* to 21 (passing through 20 as a transient step, since
vendored Clarity has no Angular-20-compatible release) and
@typescript-eslint to 8.65.0 for TS 5.9 compat. Applies the required
ng update migrations, including *ngIf/*ngFor/*ngSwitch -> control-flow
syntax across 37 templates.
Two new DQ rule types apply regular expressions to cell values:
HARDREGEX blocks submission on a non-matching value (same path as the
existing CASE/MINVAL/MAXVAL rules); SOFTREGEX is display-only — a
non-matching value gets a yellow warning cell but can still submit,
so it's wired as a grid renderer rather than a validator, and mirrored
in the edit-record modal (which has no grid renderer to hook into) via
DcValidator.failsSoftRegex. Both rules exempt blank and SAS special
missing values, and fail open on a malformed pattern rather than
blocking every submission on that column. HARDREGEX takes precedence
when both rules apply to the same column, so a failing value renders
red/blocked, never yellow.
An invalid cell no longer blocks submission if its row is marked for
delete, since those values are about to be removed anyway. Primary key
columns are exempt from the exemption: the key identifies which record
to delete, and a blank/invalid PK would otherwise also slip past the
separate duplicate-key check. Toggling the delete flag now re-validates
the row immediately so invalid highlights update without waiting for
submit.
Header matching in searchDataInExcel() was case-insensitive but not
whitespace-tolerant, so a header like " SOME_CHAR" (an easy defect to
pick up via copy-paste from another spreadsheet/system) was reported as
a missing column and aborted the whole upload.
- trim() the cell value before matching, alongside the existing
toLowerCase() normalization
- add spreadsheet-util.spec.ts (no prior spec file existed) covering
the trimmed match and a regression guard that a genuinely different
header is still correctly reported missing
- add a Cypress case + fixture in excel.cy.ts covering the same defect
end to end
getdata.sas now sends one COLTYPE JSON-object string per cols[] row (via
a LEFT JOIN on the real dataset's columns) instead of one comma-joined
sasparams.COLTYPE string, avoiding the 32K cats() buffer overflow on
wide tables (#253). Adapt the client accordingly:
- parseColTypeRow replaces parseColType; DcValidator builds validation
rules per cols[] entry instead of from sasparams.COLTYPE
- _____DELETE__THIS__RECORD_____'s rule is never present in cols[] (it's
a client-only synthetic column %mp_getcols doesn't know about), so
it's now hardcoded via deleteRecordColumnRule.ts
- cols[] isn't guaranteed to arrive in COLHEADERS/VARNUM order (backend
can serialize it alphabetically by NAME) but editor.component.ts pairs
rules with headers positionally, so DcValidator now sorts cols by
VARNUM before building rules
- update sas/mocks/sasjs getdata.js to the new per-column COLTYPE shape
- add Cypress coverage for the delete-record dropdown and an ordinary
dropdown column end to end
Brings in the SAS-side fixes for issue #253 (getdata.sas passing
variable formats via a vertical cols.COLTYPE array instead of a
single concatenated sasparams.COLTYPE string, which overflowed SAS's
32K buffer on wide datasets) plus related label/datadictionary fixes.
No client TypeScript files are touched by this merge.
Adds a URL-driven toggle (?labels=true, mirroring embed=va) that
swaps grid column headers between NAME and LABEL in both the Viewer
and Editor, with a "Show labels"/"Show names" item in each grid's
right-click context menu. The header-info popup now shows NAME first
regardless of display mode. Sorting/filtering/data binding stay
NAME-based throughout — only the displayed header text changes.
New shared, unit-tested utils (parse-labels-param, display-col-headers,
col-info-html) back both pages. Mock data (viewdata.js, getdata.js)
gains a couple of columns with LABEL != NAME plus a blank-LABEL case,
covered by a new viewer-labels.cy.ts e2e spec now wired into CI.
Also fixes a pre-existing gap in cypress/support/commands.ts: the
isLoggedIn/loginAndUpdateValidKey custom commands had no Cypress
Chainable type augmentation, and moment was imported as a namespace
import despite its `export =` typing making it uncallable that way.
Bump handsontable and @handsontable/angular-wrapper 17.1 -> 18.0 and
adapt to its stricter TypeScript-core types: moved handsontable/common
and deep walkontable imports to their new public export paths, added
explicit generics/casts where getData()/getCellMeta()/getSelected() are
now typed unknown/nullable instead of the loose v17 shapes, and updated
the AutocompleteEditor override to the new open() signature.
Also allowlists @handsontable/angular-wrapper@18.0.0 and
handsontable@18.0.0 in licenseChecker.js — both versions report the
same non-SPDX "SEE LICENSE IN LICENSE.txt" field that license-checker
mis-resolves as a disallowed custom license, same as prior versions.
BREAKING CHANGE: Introduction of 4 new tables for capturing information related to catalogs and their objects. Migration script prepared and available in the DB folder (usual place)
## [6.14.8](https://git.datacontroller.io/dc/dc/compare/v6.14.7...v6.14.8) (2025-05-28)
### Bug Fixes
* CSP issues, clarity local library build, fixed some style issues ([841201a](841201adab))
* deploy page, makedata error handling, added local build of clarity, to address clr-stack-view CSP issues (inline styles) ([7b5e7ae](7b5e7ae184))
* improved deploy flow for Viya ([9604661](9604661f3b))
* requests modal causing VIYA CSP errors ([1dc6934](1dc69341ca))
* sas viya service init timing issue ([9de04e9](9de04e9a0c))
* scss of components transferred to the global styles.scss so we do not cause CSP (inline styles) issues when streaming to Viya ([6c171a6](6c171a6394))
* viya deploy page improved flow ([4bd2154](4bd215491f))
This document explains how the Data Controller backend loads staged data into target tables: the load-type dispatch, the internals of the `%bitemporal_dataloader` macro, and a detailed description of the REPLACE load type. For user-facing load type documentation, see [docs.datacontroller.io](https://docs.datacontroller.io/).
## Overview
Every table registered for loading has a current record in `&mpelib..MPE_TABLES` with a `LOADTYPE` (selectbox values are seeded in `mpe_makedata.sas`):
| LOADTYPE | Loader used | History kept |
|---|---|---|
| `UPDATE` | `%bitemporal_dataloader` (no temporal vars) | None — changed records are deleted and re-appended |
| `REPLACE` | Inline code in `%mpe_targetloader` (does **not** use `%bitemporal_dataloader`) | None — entire table wiped and reloaded |
| `TXTEMPORAL` | `%bitemporal_dataloader` (technical time only) | SCD2-style, technical (transaction) time |
| `BITEMPORAL` | `%bitemporal_dataloader` (business + technical time) | Full two-dimensional history |
The relevant `MPE_TABLES` columns read by the loader are: `LOADTYPE`, `BUSKEY` (primary key, space-separated, excluding temporal columns), `VAR_TXFROM` / `VAR_TXTO` (technical validity), `VAR_BUSFROM` / `VAR_BUSTO` (business validity), `VAR_PROCESSED` (processed timestamp column), `RK_UNDERLYING` (retained-key generation), `CLOSE_VARS`, and `AUDIT_LIBDS` (defaults to `&dclib..MPE_AUDIT`).
## Request Flow
```mermaid
flowchart TD
A[User submits changeset\neditors/stagedata.sas] --> B[Staging package written to\n&mpelocapprovals/&LOAD_REF\nCSV + jsdata]
B --> C[Approval workflow\nMPE_SUBMIT / MPE_REVIEW]
C --> D{auditors/postdata.sas}
D -->|action=SHOW_DIFFS| E["%mpe_targetloader(LOADTARGET=NO)\nbuilds work.outds_add / outds_mod / outds_del\nfor the diff screen only"]
D -->|action=APPROVE_TABLE| F["%mpe_targetloader(LOADTARGET=YES)\nactual load"]
E --> G[Diff CSV + TEMPDIFFS stored\nin approval package]
F --> H{LOADTYPE from\nMPE_TABLES}
H -->|UPDATE / TXTEMPORAL / BITEMPORAL| I["%bitemporal_dataloader"]
H -->|FORMAT_CAT| J["%mp_loadformat"]
H -->|REPLACE| K[Inline delete-all + append\nin %mpe_targetloader]
```
`%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`) is the single dispatch point. It reads the current `MPE_TABLES` record (`&dc_dttmtfmt. lt tx_to`), aborts if the table is not registered (or has duplicate config records), and routes to the loader. Note the two-phase design: `LOADTARGET=NO` prepares the intermediate `outds_*` tables so the approver can review diffs; `LOADTARGET=YES` performs the destructive load. Both phases run the same preparation logic, so the reviewed diffs correspond to what is actually applied.
## The Temporal Model
Bitemporal tables carry two independent time dimensions:
* **Business time** (`bus_from` / `bus_to`) — when the fact was true in the real world. Present on **both** staging and base tables.
* **Technical time** (`tech_from` / `tech_to`, a.k.a. transaction time) — when the record was known to the database. Present on the **base table only**; the loader stamps these itself.
All validity is expressed with **half-open intervals** (`from <= t < to`). Queries against bitemporal tables need two conditions and must not use `BETWEEN` or `from LE t LE to` (the latter excludes boundary records — see the macro header for background):
```sas
where&bus_from le [tstamp] lt &bus_to
and&tx_from le [tstamp] lt &tx_to
```
"Current" records have `tech_to` set to the high date (`'31DEC9999:23:59:59'dt` when called from `%mpe_targetloader`). Closing out a record means setting `tech_to = now` — records are never physically deleted from a temporal table, they are superseded.
## Key Components
| Component | Location | Role |
|---|---|---|
| `%mpe_targetloader` | `sas/sasjs/macros/mpe_targetloader.sas` | Reads `MPE_TABLES` config and dispatches per LOADTYPE; implements REPLACE inline |
| `%bitemporal_closeouts` | `sas/sasjs/macros/bitemporal_closeouts.sas` | Closes out (sets `tech_to=now`) live records matching a key |
| `%mp_retainedkey` | SASjs core | Generates retained (surrogate) keys when `RK_UNDERLYING` is configured |
| `%mp_rowhash` | SASjs core | MD5 hash of non-temporal columns, used for change detection |
| `%mp_storediffs` | SASjs core | Writes row-level audit records to `AUDIT_LIBDS` |
| `MPE_DATALOADS` | `&dclib` | Load log (counts, duration, macro version, user) — written only by `%bitemporal_dataloader` |
| `MPE_LOCKANYTABLE` | `&dclib` | Lock control table used by `%mp_lockanytable` |
## `%bitemporal_dataloader` Execution Flow
The macro builds a series of `work.bitemp*` intermediate tables, then applies the changes in two target-table operations (closeout, then append) under a single lock.
### 1. Pre-checks and setup
* Early return if the staging table is empty; hard abort if `&syscc > 0`.
*`CLOSE_VARS` is not supported on REDSHIFT / POSTGRES / SNOWFLAKE engines (returns with a NOTE).
* A zero-row snapshot of the base table (`&basecopy`) is taken with `data ... set base; stop;` — this doubles as a lock check, since metadata functions fail against a locked table.
*`proc contents` on the base table feeds column lists. Columns are split into character and numeric lists for hashing (`%mp_rowhash` hashes the two types via separate arrays). Temporal columns, the processed column, and the delete-flag column are excluded from the hash.
* Table names containing `___TMP___` or `_____` are rejected (they would collide with generated temp columns).
### 2. Locking
For `LOADTARGET=YES`, the base table (and audit table, if configured) is locked via `%mp_lockanytable` before any staging prep, because the load is a two-part update (closeouts + append) and must not interleave with another load.
* If `RK_UNDERLYING` is configured, `%mp_retainedkey` maps business keys to retained keys (filtering the base lookup to live records, `&now < tech_to`, for temporal types); otherwise the staging table is used as-is.
*`bus_from` / `bus_to` overrides are applied if provided; the processed column is stamped with the load timestamp (`now`). Even with `processed=0`, a column literally named `PROCESSED_DTTM` on the base table is used if present.
* The MD5 change-detection hash is computed for every staged record.
* If the staging table contains the delete-flag column (`_____DELETE__THIS__RECORD_____`), rows flagged `"Yes"` are diverted to `&outds_del` and closed out via `%bitemporal_closeouts` (PK is taken as `bus_from` + business key). The remaining rows continue as `bitemp0_append`.
### 4. CLOSE_VARS closeout
When `CLOSE_VARS` (a subset of the PK) is supplied, live base records whose CLOSE_VARS values appear in staging but whose full PK does **not** are closed out. This handles "this group was fully reloaded, so anything missing was removed" semantics without reloading the whole table.
### 5. Uniqueness check
If `CHECK_UNIQUENESS=YES` (the default) or business-date overrides are in play, the staging table is sorted `nodupkey` by the PK; a row-count mismatch aborts the load and releases the locks. The staging table must be a unique snapshot of the business key at one point in business time.
### 6. Base extract (`work.bitemp0_base`)
Only base records matching staged PKs are extracted — for temporal load types, only **currently live** records (`now < tech_to`). A left join from the staged keys to the base produces `___TMP___NEW_FLG` to identify brand-new keys. This is engine-specific:
* **OLEDB (SQL Server)** — staged keys are pushed to a `##global` temp table and joined via explicit pass-through.
* **REDSHIFT / POSTGRES / SNOWFLAKE** — an in-database temp table is created `like` the base, stripped to PK + an added `md5 varchar(32)` column, loaded with staged keys, and joined via pass-through. Snowflake uses transient tables; Redshift gets `alter sortkey none` plus any `DCBL_REDSH` config options from `MPE_CONFIG`.
* **CAS** — a FedSQL join against a CASUSER copy.
* **BASE/other** — plain PROC SQL in SAS.
### 7. Change classification
* **`&outds_add`** — staged records flagged as new (no base match). They get `tech_from=now`, `tech_to=high_date`.
* **`work.bitemp1_current`** — matched base records, re-hashed so hashes are comparable.
* **Inserts (BITEMPORAL only)** — a staged record whose business range falls strictly *inside* an existing record's range splits the existing record into a "before" and "after" segment (`bitemp3_inserts` / `bitemp3a_inserts`). The split segments replace the original in the comparison set (`bitemp3b_newbase`).
* **Updates (`bitemp4*`)** — staged records matching a base PK but with a different hash or different business dates. Base and staged versions are stacked, deduplicated, then aligned in **two passes** over the business timeline per key: a forward pass (carry `bus_from` forward across identical hashes; a staged record trims the preceding base record's range) and a reverse pass (carry `bus_to` back; records fully subsumed by the new version are deleted). Records that end up byte-identical to what is already stored are dropped via a hash lookup that includes the business dates (`bitemp5a_lkp` / `bitemp5b_updates`, BITEMPORAL only).
### 8. Closeout application
Changed records are closed out in the target before the new versions are appended. SAS SQL has no UPDATE-with-join, so a correlated `EXISTS` subquery against `work.bitemp5d_subquery` is used (pushed in-database as a temp table for OLEDB / REDSHIFT / POSTGRES / SNOWFLAKE):
* **BITEMPORAL** — per key, the closeout range is `min(bus_from)` / `max(bus_to)` of the changed records: `update base set tech_to=now (, processed=now) where tech_from <= now < tech_to and exists (key match and base.bus_from >= min and base.bus_to <= max)`.
* **TXTEMPORAL** — same, on key only.
* **UPDATE** — changed records are physically `delete`d (they are re-appended in the next step). On CAS this uses `table.deleteRows` with a whereTable; temporal types are not supported on CAS and abort.
* **BUSTEMPORAL** — closeouts are not implemented; the macro aborts at this point ("BUSTEMPORAL NOT YET SUPPORTED").
### 9. Append, unlock, audit, log
* The union of modified + new records, deduplicated on all columns (`bitemp6_unique`), is appended to the base table (`proc append ... force nowarn`; CAS appends via a varchar-casting step; Redshift applies `DCBL_REDSH` options). Locks are then released.
* If `outds_audit` is set (always, from `%mpe_targetloader` — `AUDIT_LIBDS` defaulting to `&dclib..MPE_AUDIT`), `%mp_storediffs` compares the pre-load snapshot with the applied changes and appends row-level audit records; modified-row entries with no actual value change are removed (`MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0`).
* A summary row is inserted into `&dclib..MPE_DATALOADS` (libref, dsn, etlsource, loadtype, changed/new/deleted counts, duration, macro version, user, timestamp) unless `LOG=0`.
## REPLACE Load Type
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader` — it is implemented inline in `%mpe_targetloader` and simply wipes the target table (`delete * from`) and re-appends the staging table verbatim, with no history, change detection, key matching, audit rows, or `MPE_DATALOADS` logging. See [replace-load-type.md](replace-load-type.md) for the full deep dive.
Unit tests for the loaders live next to the macros: `sas/sasjs/macros/bitemporal_dataloader.test.[1-4].sas` and `sas/sasjs/macros/mpe_targetloader.test.sas` (REPLACE loadtype), executed as sasjs tests. See [testing.md](testing.md) for how to run them (`npm run 4gl` then `sasjs test -t 4gl` from the `sas/` directory), and remember `sasjs lint` after touching any `.sas` files.
Whenever any `package.json` (root, `client/`, or `sas/`) or lockfile is modified, run the same checks CI runs before pushing:
1.**npm audit** (must be clean for prod deps):
```bash
npm audit --omit=dev # in repo root
cd sas && npm audit --omit=dev
cd ../client && npm audit --omit=dev
```
Fix with `npm audit fix`, targeted `overrides` in `package.json`, or version bumps — never `npm audit fix --force` blindly, as it can introduce breaking changes.
2. **License checker** (client only):
```bash
cd client && npm run license-checker
```
If a new dependency fails, either add its SPDX id to the `onlyAllow` list in `client/licenseChecker.js` (if the license is acceptable, e.g. permissive ones like `BlueOak-1.0.0`) or add the specific package to `excludePackages` with justification. Data Controller ships on-prem, so only OSI-approved permissive licenses are acceptable for production dependencies.
Both checks run in `.gitea/workflows/build.yaml` (`Check audit` and `Licence checker` steps) and will fail the build if skipped locally.
This document describes how the regex validation rules work internally. For user-facing documentation see `docs/dcc-validations.md` in the `docs.datacontroller.io` repo.
## Overview
Two validation rule types in `MPE_VALIDATIONS` validate cell values against a regular expression supplied in `RULE_VALUE`:
-`HARDREGEX` - submission-blocking. A failing value is rejected by the cell validator and painted red (HOT's own `htInvalid` class), so the row cannot be submitted.
-`SOFTREGEX` - display-only warning. A failing value is painted yellow (`dc-warning-cell` class) with a tooltip, but submission is not blocked.
Both rule types are selectable in the MPE_VALIDATIONS RULE_TYPE dropdown; they were added to the selectbox seed data in `sas/sasjs/macros/mpe_makedata.sas` and via the optional migration `sas/sasjs/db/migrations/20260720_v7.12_release.sas`. `RULE_VALUE` is limited to 128 characters, which constrains very long patterns.
## Config-time validation (SAS side)
`sas/sasjs/services/hooks/mpe_validations_postedit.sas` runs `prxparse()` on any staged HARDREGEX/SOFTREGEX rule value and aborts the edit with a list of offending `libref.table.column` references if the pattern is invalid. This is a best-effort syntax check to catch typos at config time; an empty pattern is treated as valid (it matches everything in JS). Rows marked for delete are skipped.
Because patterns must pass `prxparse`, rule values are authored in the SAS PRX delimiter form `/pattern/flags` (although a bare pattern is also accepted for backwards compatibility).
## Frontend evaluation
All regex handling lives in the client; there is no server-side re-validation of data values. The per-cell decision flow:
```mermaid
flowchart TD
A[Cell value] --> B{Row marked for delete<br/>and not a PK column?}
B -- Yes --> Z[No validation / no warning]
B -- No --> C{isRegexRuleExempt?<br/>blank, or "." on a numeric column}
C -- Yes --> Z
C -- No --> D{HARDREGEX rule on column?}
D -- Yes --> E{Pattern matches?}
E -- No --> F[Invalid: submission blocked,<br/>red htInvalid + REGEX tooltip]
E -- Yes --> J[Valid]
D -- No --> G{SOFTREGEX rule on column?}
G -- Yes --> H{Pattern matches?}
H -- No --> I[Warning: yellow dc-warning-cell<br/>+ REGEX tooltip, submission allowed]
H -- Yes --> J[Valid]
G -- No --> J
```
A malformed pattern never reaches this flow: it is treated as always-valid (HARDREGEX) / never-warn (SOFTREGEX) with a `console.warn`, rather than breaking the editor.
Converts an authored SAS PRX pattern into a JavaScript `RegExp`:
1. If the value matches `/^\/(.*)\/([a-z]*)$/s`, the delimiters are stripped and the trailing flags are passed to `new RegExp(body, flags)`. Otherwise the value is used as-is (backwards compatibility with bare patterns).
2. Three mechanical Perl→JS translations are applied to the body:
- a leading `(?i)` inline modifier is removed and folded into the `i` flag;
-`\Q...\E` literal sequences are replaced with escaped literal text;
-`\A` → `^` and `\z` → `(?![\s\S])` (end-of-string anchor).
3. Perl-only constructs that would need a capture-group-renumbering rewrite (atomic groups `(?>...)`, possessive quantifiers `a++`) are deliberately NOT translated. They throw from `new RegExp`, and every caller treats a throw as "always valid / never warn" (see below) rather than breaking the editor.
Blank values (`undefined`, `null`, `''`) are exempt from pattern matching on any column type - use a separate NOTNULL rule if populated values must also be enforced. On numeric columns the plain SAS missing (`.`) is also exempt; special missings (`.A`-`.Z`, `._`, bare letters) are NOT exempt anywhere - being deliberately set, they are real values the pattern must match (and on a character column even `.` is real text). `isSpecialMissing` from `@sasjs/utils` is deliberately not used: its optional-dot regex would exempt any single-letter character value ("d", "z") before the regex ever ran. The check takes an `isNumeric` flag, passed by all callers (the dq validator via `dqValidate(rules, value, colType === 'numeric')`, the warning renderer via a `makeRegexWarningRenderer` argument, and `failsSoftRegex` via the column's HOT type).
### HARDREGEX - blocking validation
`HARDREGEX` is implemented as a cell validator in `client/src/app/shared/dc-validator/validations/dq-validation.ts`. It returns `true` (valid) for exempt values and for patterns that fail to compile (with a `console.warn`), and otherwise returns `parseRegexRule(ruleValue).test(value.toString())`. Returning `false` makes HOT mark the cell invalid, block submission, and paint it red via its standard `htInvalid` styling.
### SOFTREGEX - warning renderer
`client/src/app/editor/utils/regex-warning-renderer.ts` builds a display-only Handsontable renderer (registered per-column by `DcValidator.setupRules` in `client/src/app/shared/dc-validator/dc-validator.ts`). It never returns false; it only:
- adds a `REGEX: <pattern>` tooltip (`td.title`) when a rule fails;
- adds the yellow `dc-warning-cell` class when only SOFTREGEX fails.
`DcValidator.failsSoftRegex(col, value)` provides the same logic outside the grid (e.g. the edit-record screen).
### Precedence: HARD and SOFT on the same column
Only one regex ever runs per column. If a HARDREGEX rule exists, SOFTREGEX is ignored entirely - never compiled, never evaluated - regardless of whether individual cell values pass or fail the hard rule. A value failing HARDREGEX gets the red invalid styling (blocking submission) plus a `REGEX: <pattern>` tooltip; a SOFTREGEX-only column warns in yellow without blocking. This holds in both the renderer and `failsSoftRegex`.
### Other behaviour
- Rows marked for delete (`_____DELETE__THIS__RECORD_____ = 'Yes'`) are not warned/validated by the renderer (except primary key columns, which still validate).
- A malformed pattern never breaks the editor: the dq validator treats it as always-valid and the renderer disables the warning, logging to the console instead.
- The pattern is used as authored - it is NOT auto-anchored. Authors must include `^`/`$` to match the entire cell value.
- Column info: `client/src/app/shared/utils/col-info-html.ts` shows the applied pattern in the column-info dropdown - the HARDREGEX pattern if one exists (it is the rule actually applied when both are present), otherwise the SOFTREGEX pattern, otherwise nothing.
- SAS side: `sas/sasjs/services/editors/stagedata.test.3.sas`, plus seed data in `mpe_makedata.sas`: HARDREGEX "SOME_CHAR must contain 'the' or 'data'" (`/the|data/i`), SOFTREGEX "SOME_CHAR should contain the letter 't'", SOFTREGEX on PRIMARY_KEY_FIELD (`/^\d+$/` - yellow if the key contains a decimal), and HARDREGEX on SOME_SHORTNUM (`/^(??$).*/` - values 1-5 blocked; generated data starts at 6 so demos aren't blocked accidentally).
**Do not edit `CHANGELOG.md` by hand, and do not bump the version in `package.json` yourself.** Both are generated automatically by the release pipeline. Manual edits produce duplicate/colliding version sections and merge conflicts, and they are pointless because the next release overwrites them anyway.
## How releases work
Releases are driven by [semantic-release](https://semantic-release.gitbook.io/) from the `release` job in `.gitea/workflows/release.yaml`, which runs on every push to `main` (after the build/test jobs pass). The configuration lives in `.releaserc` at the repo root.
The plugin chain (`.releaserc``plugins`) does the following on each release:
1.`@semantic-release/commit-analyzer` - inspects Conventional Commit messages since the last tag to decide the next semantic version (`fix:` -> patch, `feat:` -> minor, breaking change -> major).
2.`@semantic-release/release-notes-generator` - builds the release notes from those commits.
3.`@semantic-release/changelog` - **writes the new section into `CHANGELOG.md`**.
4.`@semantic-release/npm` - updates the `version` in `package.json` (it does not publish; the package is `private`).
5.`@semantic-release/git` - commits `CHANGELOG.md` and `package.json` back to `main` as `chore(release): <version> [skip ci]` and tags it.
6.`@saithodev/semantic-release-gitea` - creates the Gitea release.
The build assets (frontend zip, SAS 9 / Viya / SASjs Server deployment files) are attached to the release afterwards by the workflow's own `Upload assets to release` step using the Gitea API (`curl`), not by the plugin. Before creating the release the workflow first runs `semantic-release --dry-run` and aborts the job if there are no releasable changes since the last tag.
Because the changelog entry and version bump are committed by the pipeline (step 5), they must **not** exist in your working tree beforehand. If they do, the release commit collides with them.
## What this means for you
- Never add, remove, or reorder entries in `CHANGELOG.md`.
- Never change `version` in `package.json`.
- Control what appears in the changelog through your **commit messages** (Conventional Commits: `fix:`, `feat:`, `feat!:`/`BREAKING CHANGE:`, plus scopes like `fix(editor): ...`). The scope and description become the changelog line.
- If you find hand-written entries in `CHANGELOG.md` in the working tree, revert them (`git checkout CHANGELOG.md`).
- CI does not use any local release script - the pipeline installs and runs `semantic-release` directly.
This document describes the REPLACE load type in detail. For the overall loader architecture, the temporal model, and the other load types, see [bitemporal-dataloader.md](bitemporal-dataloader.md).
## Overview
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader`. It is implemented inline in `%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`, search for `&loadtype=REPLACE`). There is no history, no change detection, no key matching — the target table is wiped and reloaded from the staging table verbatim.
## Actual load (`LOADTARGET=YES`)
```sas
%mp_lockanytable(LOCK, lib=&lib, ds=&ds, ...)
data WORK.&STAGING_DS;
set WORK.&STAGING_DS;
/* only if the target contains the MPE_TABLES.VAR_PROCESSED variable: */
&VAR_PROCESSED = &now;
drop _____DELETE__THIS__RECORD_____;
run;
%if&engine_type=CAS %then%do;
/* prep first: cast varchar columns in a CASUSER copy of staging */
/* unlock + abort if any error so far - nothing destructive done yet */
%mp_abort(iftrue= (&syscc>0) ...)
proc sql;
delete * from&libds;
quit;
proc append base=&libds data=WORK.&STAGING_DS force nowarn; run;
%end;
%mp_lockanytable(UNLOCK, lib=&lib, ds=&ds, ...)
```
Step by step:
1.**Lock** — the target is locked via `%mp_lockanytable` (control table `&dclib..MPE_LOCKANYTABLE`). This is the only concurrency guard for the whole operation.
2.**Staging recopy** — the staging dataset is rewritten in place: the processed-timestamp column is set to the approval timestamp **only if** the target table contains the variable named by `MPE_TABLES.VAR_PROCESSED` (unlike `%bitemporal_dataloader`, there is no `PROCESSED_DTTM` fallback). The delete-flag column `_____DELETE__THIS__RECORD_____` is unconditionally dropped — per-record delete semantics do not exist in REPLACE (everything is deleted anyway), so a delete flag submitted by the user is silently discarded (a `drop` of a non-existent variable just produces a warning).
3.**Prepare the load (CAS only)** — fixed char variables cannot be appended to CAS varchar columns, so the staging table is first copied to a CASUSER temp table with every column that is varchar in the target (and present in staging) redeclared as `varchar(*)` via generated `length` / `rename` / assignment statements. This mirrors the CAS append in `%bitemporal_dataloader`.
4.**Pre-destructive abort check** — if `&syscc > 0` after all preparation, the target is unlocked and the macro aborts via `%mp_abort`. Nothing destructive has happened at this point, so a failed prep leaves the target intact (and without a stale lock).
5.**Delete all rows** — every row is removed from the target while preserving structure, indexes and metadata, deliberately as the last step before the append (to minimise the time the target sits empty). On most engines this is `proc sql; delete * from &libds;` (passed through as a `DELETE` on database libraries). CAS tables do not support SQL deletes, so on the CAS engine the table is truncated instead via the `table.deleteRows` action with `where="1=1"` (same approach as `%bitemporal_closeouts`), with the libref passed as the caslib.
6.**Append staged rows** — the entire staging table is appended. On most engines this is `proc append ... force nowarn` (`force` allows the append to proceed despite attribute mismatches, so lengths/formats may be coerced or values truncated, and columns present in only one side are handled; `nowarn` suppresses the associated warnings). On CAS the pre-cast CASUSER temp table is appended with `data &libds (append=yes) / sessref=dcsession` and then dropped.
7.**Unlock**.
## Diff screen (`LOADTARGET=NO`)
When the approver reviews a REPLACE submission, no real comparison is performed:
```sas
/* is full replace so treat all staged records as new in diff screen */
data work.outds_mod work.outds_add;
set work.&staging_ds;
output work.outds_add; /* every staged record is "NEW" */
run;/* outds_mod stays empty */
/* previous table will be considered fully deleted */
data work.outds_del;
set&lib..&ds; /* every existing record is "DELETED" */
run;
```
The approval screen therefore always shows full-table turnover: the entire current table as deleted and the entire staging table as added, with no "modified" records and no original/current comparison.
## What REPLACE does not do
Because it bypasses `%bitemporal_dataloader`, none of the following apply to REPLACE:
* **No row-level audit** — nothing is written to `AUDIT_LIBDS` / `MPE_AUDIT`. The record of the change is the approval package itself (staged CSV and the `TEMPDIFFS` CSV stored under `&mpelocapprovals/&LOAD_REF`).
* **No `MPE_DATALOADS` log entry** — the load-log insert lives inside `%bitemporal_dataloader`, so REPLACE loads do not appear in load history. (Consequently the SHOW_DIFFS timestamp lookup in `postdata.sas` finds no `MPE_DATALOADS` row for a REPLACE load and falls back to the current datetime.)
* **No PK usage** — `BUSKEY` is ignored: no uniqueness check, no dedup, no join to existing data. Staged rows are loaded exactly as submitted, duplicates included.
* **No temporal handling** — `VAR_TXFROM`/`VAR_TXTO`/`VAR_BUSFROM`/`VAR_BUSTO` and `CLOSE_VARS` are ignored. A REPLACE table should not be treated as temporal; querying it with the usual validity filters makes no sense.
* **No retained-key handling** — `RK_UNDERLYING` is ignored.
* **Minimal engine-specific handling** — unlike the temporal loaders, there is no pass-through/temp-table optimisation for OLEDB, Redshift, Postgres or Snowflake. The only engine conditional is CAS, where rows are removed via the `deleteRows` action and appended via a varchar-casting CASUSER temp table and a data-step append (SQL deletes and fixed-char-to-varchar appends are not possible on CAS).
* **Not atomic** — the delete/truncate and append are separate steps with no transaction or rollback, so a session failure between them still leaves the target empty or partially loaded (recovery is a re-approval of the same or a previous staging package). The risk is mitigated by performing all preparation first, aborting on any error before the destructive step, and executing the delete/truncate immediately before the append.
## Interaction with Row Level Security
REPLACE is incompatible with `EDIT`-scope RLS rules (a full-table wipe cannot honour row-level write restrictions); this is enforced at edit time in both directions — see [row-level-security.md](row-level-security.md#incompatibility-with-replace-load-type) and [issue #211](https://git.datacontroller.io/dc/dc/issues/211). `VIEW`-scope rules remain compatible.
This document explains **how** Row Level Security (RLS) is implemented in the Data Controller backend. For the user-facing configuration guide, see [docs.datacontroller.io/row-level-security](https://docs.datacontroller.io/row-level-security/).
## Overview
RLS in Data Controller is implemented as **server-side WHERE clause generation**. No data leaves SAS without passing through a dynamically generated filter. The filter is built at runtime per request, based on:
1. The requesting user's group memberships (SAS metadata groups + DC groups)
2. The active rules in the `MPE_ROW_LEVEL_SECURITY` control table
Because the filter is expressed as a standard SAS `WHERE` expression, it works against **any** engine — Base SAS datasets, database libraries (via implicit SQL pass-through pushdown), SPDE, CAS libnames, etc.
Scope mapping: `DLOAD` requests match rules with `RLS_SCOPE in ('VIEW','ALL')`; `ULOAD` requests match rules with `RLS_SCOPE in ('EDIT','ALL')`.
## Execution Flow of `%mpe_filtermaster`
The macro writes the final WHERE expression to a temporary **fileref** (`outref`), line by line. (A fileref is used because a generated filter may exceed the 64k macro variable limit — and note that `%include` of a fileref is not allowed directly in a `proc sql` where clause, hence callers typically use it in a DATA step or data step view.)
### 1. User-supplied filter (FILTER_RK)
If the request includes a stored filter (`filter_rk > 0`), its clauses are read from `MPE_FILTERANYTABLE` / `MPE_FILTERSOURCE` and generated first via `%mp_filtergenerate`. RLS clauses are then **AND-ed on top** — a user filter can only ever narrow results, never widen them beyond RLS.
`MPE_TABLES` may define `VAR_TXFROM` / `VAR_TXTO` (SCD2-style validity variables) for the target table. Unless the user explicitly filtered on those variables, the macro appends:
```
("<current datetime>"dt < VAR_TXTO)
```
so that only current records are surfaced in the EDIT grid and downloads.
where&dc_dttmtfmt. lt tx_to /* only current (non-deleted) rules */
and rls_scope in ("&scopeval",'ALL')
andupcase(rls_group) in (selectupcase(groupname) from work.groups)
and rls_libref = "<libref>"and rls_table = "<dsname>"
and rls_active = 1
orderby rls_group, rls_subgroup_id;
```
### 5. Clause assembly
If rules exist, they are appended to the fileref as `AND ( ... )`. Each **group** the user belongs to contributes one sub-filter, and the group-level sub-filters are joined with `OR`:
```
AND ( <group 1 filter> OR <group 2 filter> ... )
```
Each group's filter is produced by `%mp_filtergenerate`, which honours:
*`RLS_GROUP_LOGIC` — how subgroups (identified by `RLS_SUBGROUP_ID`) are joined
*`RLS_SUBGROUP_LOGIC` — how individual clauses within a subgroup are joined (AND/OR)
So membership in multiple groups is **permissive** (OR): the user sees the union of rows permitted by each of their groups.
### 6. Empty-filter fallback
If nothing was written to the fileref (no stored filter, no validity vars, no matching RLS rules), the macro writes a literal `1=1` so the fileref can always be consumed as a valid WHERE expression. Absence of rules therefore means **no restriction** — RLS is opt-in per table/group.
The fileref is `%include`d directly inside the `where` statement — the filter never passes through client-visible state, so it cannot be tampered with. For database libraries the WHERE expression is pushed down to the database by the SAS engine.
### Write path (stagedata.sas) — inverse filter
Uploads cannot be filtered; instead the filter is **inverted** and any submitted row matching the inverse is rejected:
,msg=%str(Security Problem - N unauthorised records submitted))
```
If even one submitted row falls outside the user's permitted row set, the entire staging request is aborted before any approval/apply step.
## Incompatibility with REPLACE Load Type
RLS with `EDIT` scope is **incompatible** with tables configured with `LOAD_TYPE=REPLACE` in `MPE_TABLES`. A REPLACE load wipes and reloads the entire target table, so row-level filtering of submitted records cannot be enforced meaningfully (the rows a user is *not* allowed to see would also be deleted). Backend validations therefore abort in both directions (see [issue #211](https://git.datacontroller.io/dc/dc/issues/211)):
1.**`mpe_row_level_security_postedit.sas`** — aborts when activating a rule with `RLS_SCOPE in ('EDIT','ALL')` against a table whose current `MPE_TABLES` record has `LOADTYPE='REPLACE'`.
2.**`mpe_tables_postedit.sas`** — aborts when setting `LOADTYPE='REPLACE'` on a table that already has active, current `RLS_SCOPE in ('EDIT','ALL')` rules in `MPE_ROW_LEVEL_SECURITY`.
`VIEW`-scope rules remain compatible with REPLACE loads, since they only affect read paths.
## Edit-Time Validation (Injection Defence)
Because `RLS_RAW_VALUE` is free text that ends up inside a generated WHERE clause, it is a potential SAS code-injection vector. Mitigations:
1.**`mpe_row_level_security_postedit.sas`** — a post-edit hook on the `MPE_ROW_LEVEL_SECURITY` table itself. Every newly staged rule (with `rls_active=1`) is grouped by target `libref.table` and run through `%mp_filtercheck(targetds=..., abort=YES)`, which compiles/tests each clause against the real target table and rejects the whole submission on invalid syntax (see [mp_filtercheck](https://core.sasjs.io/mp__filtercheck_8sas.html)).
2.**Format rules** enforced by validation: character values must be single quoted, `IN`/`NOT IN` values must be bracketed, `BETWEEN` must contain `AND`.
Additionally, editing `MPE_ROW_LEVEL_SECURITY` is itself a Data Controller table edit, so it goes through the normal approval workflow, audit trail (`MPE_AUDIT`), and can be column-restricted via [Column Level Security](https://docs.datacontroller.io/column-level-security/).
## Worked Example
Given these active rules (all for `MYLIB.MYDS`, `RLS_ACTIVE=1`):
| SCOPE | GROUP | GROUP_LOGIC | SUBGRP_LOGIC | SUBGRP_ID | VAR | OP | VALUE |
|---|---|---|---|---|---|---|---|
| ALL | Group 1 | AND | AND | 1 | VAR_2 | IN | ('this','or') |
| ALL | Group 1 | AND | AND | 1 | VAR_3 | < | 42 |
| ALL | Group 2 | AND | AND | 1 | VAR_4 | CONTAINS | 'xyz' |
A non-admin user in **both** groups, opening the VIEW page, gets a fileref containing:
```
AND ( (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz') )
```
resulting in:
```sas
data work.viewdata;
set mylib.myds;
where (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz');
run;
```
The same user submitting an EDIT upload has the inverse applied to their staged rows; any row not matching the expression above aborts the submission.
## Summary of Security Properties
* **Enforced server-side** in every read service (VIEW/EDIT/DLOAD) and every write service (ULOAD via inverse filter).
* **Admin bypass** is explicit (`&mpeadmins` group membership check).
* **Multi-group semantics are OR** (union of permitted rows); rules within a group/subgroup are AND/OR per configuration.
* **Fail-open by design**: if no rules match a table, it is unrestricted — RLS must be opted into per table.
* **Injection-resistant**: values are validated with `%mp_filtercheck` at the time rules are edited, not at query time.
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
## Commands
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
## Results
- Test results: `sas/sasjsresults/testResults.json` / `.csv` / `.xml`
- Individual test logs: `sas/sasjsresults/logs/<testsuite>.log` (e.g. `services_editors_stagedata.test.3.log`)
- Coverage: `sas/sasjsresults/coverage.lcov`
## Notes
- Test source files live in `sas/sasjs/**`; `sas/sasjsbuild/` is generated build output — do not hand-edit it.
-`sasjs test` executes tests against the deployed app, so run `npm run 4gl` first after changing any service/hook/test code.
- Assertions are made with `%mp_assert()`; results are written to `work.test_results`.
The Angular wrapper was modernized in Handsontable v17.1 to align with Angular 17–19, simplifying setup and reducing dependencies. Earlier Angular versions are no longer the target — upgrade Angular first if you're below v17.
HyperFormula version compatibility table: https://handsontable.com/docs/react-data-grid/formula-calculation/#hyperformula-version-support
---
## Server-side data with DataProvider (v17.1+)
The **DataProvider plugin** (new in v17.1) wires the grid up to a remote data source so rows are fetched, sorted, and mutated server-side instead of held in memory. Use it for datasets too large to load up front, or when the source of truth lives in a backend.
The `dataProvider` option is an object with five required keys: a row-id resolver, a paginated `fetchRows` callback, and three mutation callbacks. For a read-only grid, stub the mutation callbacks with `async () => {}` — they must still be present.
```jsx
<HotTable
colHeaders={['ID','Name','Email']}
pagination={{pageSize:25}}
columnSorting={true}
emptyDataState={true}
height={360}
licenseKey="non-commercial-and-evaluation"
dataProvider={{
// Required: how to extract a stable id from each row
rowId:'id',
// Required: paginated fetch. `sort` is null when no column is sorted.
// Second arg carries an AbortSignal — pass it to fetch() so the plugin
// can cancel superseded requests when the user pages/sorts quickly.
// Required. Stub with async () => {} if your grid is read-only.
// The plugin auto-refetches the current page after each callback resolves.
// See the plugin API ref for the exact payload shapes.
onRowsCreate:async()=>{},
onRowsUpdate:async()=>{},
onRowsRemove:async()=>{},
}}
/>
```
### Loading and error UI
The plugin fires three hooks you can wire up as `<HotTable>` props for loading indicators and error surfaces:
-`beforeDataProviderFetch(params)` — fires before each fetch. `params.skipLoading` is set when the plugin wants to suppress your loading indicator (e.g., during a quick refetch).
-`afterDataProviderFetch(result)` — fires after a successful fetch.
-`afterDataProviderFetchError(error)` — fires when `fetchRows` throws or returns a rejected promise.
### Companion options
The plugin is built to pair with `pagination` (paginates server-side), `columnSorting` (single-column server-side sort), and `emptyDataState` (loading + empty state UI). Enable all three when you use DataProvider.
The **Notification plugin** (new in v17.1) shows non-blocking toast notifications anchored to the grid — useful for confirming saves, surfacing validation errors, or signaling background sync state. Enable with `notifications: true` and trigger via `hot.getPlugin('notifications').showMessage(...)`. See the plugin guide for placement, severity levels, and auto-dismiss timing: https://handsontable.com/docs/react-data-grid/notification/
---
## Events & Hooks
Handsontable hooks are passed as props on `<HotTable>`:
```jsx
<HotTable
afterChange={(changes,source)=>{
if(source!=='loadData'){
console.log('Cell changed:',changes);
}
}}
beforeChange={(changes,source)=>{
// Return false to cancel the edit
}}
afterSelection={(row,col,row2,col2)=>{
console.log('Selected:',row,col,'to',row2,col2);
}}
/>
```
There are 100+ hooks available. Full reference: https://handsontable.com/docs/react-data-grid/api/hooks/
Handsontable v17 introduced a Recipes section in the docs — ready-made patterns for common use cases
(data validation workflows, dynamic column generation, etc.). Check the recipes index before building
something from scratch: https://handsontable.com/docs/react-data-grid/recipes/
---
## Common Pitfalls
- **Forgetting `height`**: The grid won't render without a `height` prop. Use `"auto"`, a pixel value, or a CSS string.
- **Not filtering `loadData` in `afterChange`**: The `afterChange` hook fires on initial data load with `source === 'loadData'`. Always check the source to avoid infinite loops when syncing changes back to state.
- **Using the old wrapper packages**: v17 removed `@handsontable/react` and `@handsontable/angular`. Use `@handsontable/react-wrapper` and `@handsontable/angular-wrapper`.
- **Using legacy CSS imports**: `handsontable.full.min.css` was removed in v17. Use `handsontable/styles/handsontable.min.css` plus a theme file.
- **Formulas with nested object data**: HyperFormula formulas don't work when `data` is an array of nested objects — use flat objects or arrays of arrays.
- **ExportFile `columnHeaders` renamed**: In v17.1 the ExportFile plugin's `columnHeaders` option was renamed to `colHeaders` to match the table-level option. Update any `exportAsString` / `exportAsBlob` / `downloadFile` calls that pass `columnHeaders: ...`.
---
## Version Awareness
Handsontable docs are versioned. The latest docs live at `/docs/react-data-grid/` (which redirects
to the current version). To link to a specific version, use `/docs/17.0/react-data-grid/`.
When helping a user, check which version they are on — breaking changes between major versions are
common. Point them to the relevant migration guide if they're upgrading.
For the full organized directory of documentation links, read `references/docs-map.md` in this
An agent skill for [**HyperFormula**](https://hyperformula.handsontable.com) — the headless, open-source TypeScript spreadsheet calculation engine. It gives any AI coding agent deep, task-oriented knowledge of HyperFormula so the agent can help you integrate, configure, and debug the engine faster.
This package is the npm distribution of the `hyperformula` skill maintained in [`handsontable/handsontable-skills`](https://github.com/handsontable/handsontable-skills). The package version tracks the HyperFormula product version the skill targets (e.g. `3.3.0` targets HyperFormula 3.3.0).
## What's in the package
A self-contained skill — plain markdown, no runtime dependencies:
```
SKILL.md ← entry point: concepts, routing, and a docs map (YAML frontmatter + body)
references/ ← task-oriented deep-dives the agent reads on demand
```
`SKILL.md` follows the [Agent Skills](https://claude.com/blog/skills) layout (a YAML frontmatter block with `name`/`description`, followed by markdown instructions). Any agent that can load a skill — or simply read a folder of markdown instructions — can use it.
## Install
```bash
npm install @handsontable/hyperformula-skill
```
The installed package lives at `node_modules/@handsontable/hyperformula-skill`. Resolve that path programmatically with:
Upload the package contents (`SKILL.md` + `references/`) to the [Skills API](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview).
### OpenAI Codex
Codex discovers [skills](https://developers.openai.com/codex/skills) as folders containing a `SKILL.md`, under `~/.agents/skills` (available in every repo) or `.agents/skills` in a repo (checked in for your team). Copy the package into one of those locations:
If your agent reads a single instruction file rather than a folder, point it at `SKILL.md`; it links the files under `references/` so the agent can pull them in as needed. For RAG / vector-store setups, ingest `SKILL.md` together with every file under `references/` (the repository also publishes a pre-flattened `hyperformula-rag.md` for this case).
## Other distribution formats
The same skill source is also shipped as a drag-and-drop `.zip` (Cowork / Claude.ai web), a flattened `-rag.md` doc (RAG / vector stores), and a Claude Code plugin marketplace entry. See the [repository README](https://github.com/handsontable/handsontable-skills#readme) for details.
## License
MIT — see the [repository](https://github.com/handsontable/handsontable-skills/blob/main/LICENSE.txt). HyperFormula itself is separately licensed (GPLv3 or commercial); see the [licensing guide](https://hyperformula.handsontable.com/docs/guide/licensing.html).
Use this skill when the user works with HyperFormula (HF) — a headless,
open-source TypeScript spreadsheet calculation engine. Use it when the user:
mentions HyperFormula or HF; wants to integrate Excel-like formulas into a
JS/TS app; evaluate formulas programmatically in a browser or Node.js;
simulate spreadsheet or Excel calculation behavior server-side; run
calculations over data parsed from XLSX files (e.g. via SheetJS) in code;
build a pricing engine, financial model, or what-if analysis in code; define
custom spreadsheet functions or named expressions outside of a UI context. Do
NOT use this skill for: general Excel questions unrelated to HF; general
Google Sheets questions; generic spreadsheet formula-syntax questions outside
an HF context; formulas used inside Handsontable (use the "handsontable"
skill instead).
---
# HyperFormula
HyperFormula is a **headless, open-source TypeScript spreadsheet calculation engine** for embedding spreadsheet logic in any JavaScript/TypeScript application (browser or Node.js). ~420 built-in functions, dependency graph, undo/redo, i18n (17 languages). Dual-licensed: GPLv3 or commercial.
Task-oriented references in `references/` (open the one that matches what the user is doing):
- [`getting-started.md`](references/getting-started.md) — how to install, create an instance (`buildFromArray` / `buildFromSheets` / `buildEmpty`), and work with cell addresses. Open this for onboarding questions and first-run errors.
- [`api-quickref.md`](references/api-quickref.md) — runnable examples for CRUD, rows/columns, sheets, exporting data, batching, named expressions, events, undo/redo, clipboard. Open this when implementing or debugging HF API calls.
- [`custom-functions.md`](references/custom-functions.md) — extending HyperFormula via `FunctionPlugin`: minimal plugin, argument types, volatile functions, range arguments, returning arrays, error handling, aliases, localized names. Open this when adding or debugging custom spreadsheet functions.
- [`configuration.md`](references/configuration.md) — `ConfigParams` options, Excel-compatibility preset, Google-Sheets preset, and config-related pitfalls (separator collisions, Node ICU, `precisionRounding` change in v3). Open this when tuning behavior or diagnosing locale issues.
- [`vue3.md`](references/vue3.md) — Vue 3 integration: `markRaw` requirement, Composition API, Pinia/Vuex stores, `destroy()` on unmount. Open this when embedding HF in a Vue 3 app.
- [`error-handling.md`](references/error-handling.md) — inspecting `CellError`, `ErrorType` enum, `getCellValueDetailedType`, common error causes (`#NAME?`, `#CYCLE!`, `#REF!`, …). Open this when a cell returns an error or you need to branch on result type.
- [`general-pitfalls.md`](references/general-pitfalls.md) — cross-cutting gotchas: `destroy()` lifecycle, forcing literal strings, Excel-parity caveats, hard limits. Open this when results look wrong or memory grows unboundedly.
Below this section is the **Documentation map** — the canonical directory of links to the official HyperFormula docs. Use it when pointing the user to authoritative material.
## Documentation map
All links resolve to `hyperformula.handsontable.com` unless noted.
"description":"Agent skill for HyperFormula — the headless, open-source TypeScript spreadsheet calculation engine. Teaches AI coding agents how to integrate, configure, and debug HyperFormula.",
Vue 3 requires wrapping the instance with `markRaw` — see [vue3.md](vue3.md) for the full integration guide (React / Angular / Svelte need no special handling).
### `functionArgSeparator` vs `thousandSeparator` collision
These two options **cannot share a character**. European locales that use `,` as thousand separator must change the argument separator:
```ts
consthf=HyperFormula.buildEmpty({
licenseKey:'gpl-v3',
thousandSeparator:',',
decimalSeparator:'.',
functionArgSeparator:';',// must differ from thousandSeparator
});
```
### Node.js must have full ICU
String comparisons (used in `MATCH`, `VLOOKUP`, sorting) can silently produce wrong results on Node.js < 13 or builds without full ICU. Verify at startup:
```ts
// Must report 'full' (or a detailed ICU version), not 'small'.
HyperFormula defers some structural transformations (row/column insertions, moves) and applies them lazily. In long-lived instances with heavy mutation throughput — bulk imports, frequent undo/redo, scripted batch edits — the pending queue can grow before cleanup. v3.3 fixed the unbounded-growth leak; this option lets you cap the queue explicitly. Lower values trade memory for more frequent flush work; the default is suitable for typical UI workloads.
### `precisionRounding` default changed in v3
Before v3 the default was `14`. It is now `10`. Calculations relying on the old precision need an explicit override:
Mark a function as volatile so it recalculates on every change (like `RAND` or `NOW`). Volatile functions are expensive in large sheets — use sparingly.
```ts
MyPlugin.implementedFunctions={
RAND_ID:{
method:'randId',
parameters:[],
isVolatile: true,
},
};
```
## Argument types
```ts
import{FunctionArgumentType}from'hyperformula';
// FunctionArgumentType.NUMBER
// FunctionArgumentType.STRING
// FunctionArgumentType.BOOLEAN
// FunctionArgumentType.NOERROR
// FunctionArgumentType.SCALAR
// FunctionArgumentType.RANGE // accepts a cell range
// FunctionArgumentType.ANY
```
Use `optionalArg: true`, `defaultValue`, and `minValue` / `maxValue` to validate arguments declaratively:
Custom functions can return 2D arrays. **PITFALL:** result arrays don't auto-resize when upstream dependencies change — the footprint is fixed at first evaluation.
```ts
classSplitPluginextendsFunctionPlugin{
splitToRow(ast,state){
returnthis.runFunction(
ast.args,
state,
this.metadata('SPLIT_ROW'),
(text,sep)=>[text.split(sep)]// 2D array: one row, N cols
// Usually: function not registered (check plugin registration or i18n language)
console.log('Unknown name:',value.message);
break;
default:
console.log('Error:',value.type,value.message);
}
}else{
console.log('Value:',value);
}
```
## `#CYCLE!` is HyperFormula-specific
Standard spreadsheet apps report cycles differently. HyperFormula's `IF` also reports cycles for all branches, even unreachable ones — this can produce `#CYCLE!` in formulas that Excel or Sheets would evaluate.
Use the detailed type when you need to distinguish e.g. number vs empty without touching the value.
## Returning errors from custom functions
Custom `FunctionPlugin` methods can return a `CellError` to surface a formula error. See [custom-functions.md](custom-functions.md) for the full pattern.
```ts
import{CellError,ErrorType}from'hyperformula';
returnnewCellError(ErrorType.DIV_BY_ZERO);
```
## Common causes
| Error | Common cause |
|---|---|
| `#NAME?` | Function not registered (check `registerFunctionPlugin` order or `language` config) |
| `#CYCLE!` | Circular reference — or an `IF` branch that *could* produce one |
| `#REF!` | Deleted row/column broke a formula reference |
| `#VALUE!` | Type mismatch — e.g. passing a string where a number is expected |
| `#DIV/0!` | Division by zero, including empty cells coerced to 0 |
| `#NUM!` | Out-of-range numeric result (e.g. `SQRT(-1)`) |
| `#N/A` | Lookup miss (`MATCH`, `VLOOKUP`) or propagated from an upstream `#N/A` |