Commit Graph
1849 Commits
Author SHA1 Message Date
dc 92ff21b8f5 test(licensing): wait for the app to settle instead of sleeping through it
Build / Build-and-ng-test (pull_request) Successful in 3m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m22s
Build / Build-and-test-development (pull_request) Failing after 48m15s
Every one of the spec's 52 cy.wait() calls is gone: they ranged from 1s to
20s, 350 seconds of declared sleeping in a spec that runs in 87. Each stood in
for something the app does asynchronously, and each has been replaced by the
state it was waiting for:

- a key change leaves the app re-bootstrapping its licence state, so
  waitForAppSettled() waits for .app-loading to clear and for the app to land
  on a known screen (nav tree, demo banner, key error or login form);
- the paste form exists only on licensing/update - asking whether the app is
  anywhere under licensing/ also matches the error page a bad key produces,
  which has no paste button, so gotoLicensingUpdate() navigates unless that is
  where it already is;
- a rejected key redirects to the licensing page, so verifyLicensingPage()
  waits for that URL instead of sleeping and then checking once, and fails
  there rather than hanging when the redirect never comes;
- the log-out, approve and paste-button paths already retry, so the sleeps in
  front of them were pure padding.

Measured on the local stack against the same estate: 10 passing in 45s twice,
against 1:27 before (and 1:33 in CI).
2026-11-06 16:11:52 +00:00
allan 78b7de7120 Merge pull request 'refactor(mocks): build the control library makedata.sas builds' (#487) from feat/mocks-makedata-parity into release/7.23
Sites / deploy (push) Successful in 3m58s
Build / Build-and-ng-test (pull_request) Successful in 5m5s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m5s
Build / Build-and-test-development (pull_request) Failing after 48m9s
Reviewed-on: #487
2026-11-06 14:43:40 +00:00
dc 6e0866d876 fix(mocks): give MPE_X_SEARCH its column metadata back
Build / Build-and-ng-test (pull_request) Successful in 4m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m13s
Build / Build-and-test-development (pull_request) Failing after 49m40s
The fixture's schema entry lived in makedata's schema object (as a second,
later key in the same literal) and did not survive the move to createdemodata.
saveTable falls back to the file already on the drive, so a reseed looked
fine - but CI deletes the appLoc before seeding, and a fresh estate wrote
mpe_x_search.json with columns: [], losing the column order the
full-table-search spec derives its counts from.

Carry the nine columns (name/type/length/format/label/notnull) in
createdemodata's schema, and add a case that every fixture table is written
with column metadata covering its rows, plus the MPE_X_SEARCH order. It fails
against the previous service.
2026-11-06 14:42:56 +00:00
allan 01d54041a2 Merge pull request 'test(viewbox): select a picker table without walking the tree' (#486) from perf/viewbox-spec into release/7.23
Sites / deploy (push) Canceled after 1m1s
Build / Build-and-ng-test (pull_request) Canceled after 28s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Reviewed-on: #486
2026-11-06 14:39:59 +00:00
dc 0e0e79ef63 test(viewbox): match the configured viewbox by its full lib.dataset
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m1s
Build / Build-and-test-development (pull_request) Successful in 24m20s
The panel heading reads 'Configuration (#2 DC_JSLIB.MPE_ALERTS)', so the check
parses lib.dataset out of it and compares it whole. A substring test accepted
another library's table of the same name - this estate seeds MPE_X_TEST under
two libraries - and any name that merely contains the target, which is the
mix-up the check exists to catch.

Verified on the local stack: 12 passing in 2:24 and 2:22.
2026-11-06 14:33:28 +00:00
dc 112e8e4b8b fix(mocks): keep the derived rows makedata owns
Build / Build-and-ng-test (pull_request) Successful in 4m0s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m43s
Build / Build-and-test-development (pull_request) Canceled after 31m43s
createdemodata assigned tables.MPE_DERIVED outright, so any row makedata wrote
there would be wiped by a fixture run - and the idempotency test did not count
the table, so nothing would have said so. Filter the TESTDATA rows out and
append, in the same shape as the tables above, and count MPE_DERIVED in the
idempotency test.

New case: a non-TESTDATA derived row survives a createdemodata run exactly
once. It fails against the previous service and passes now.
2026-11-06 14:02:33 +00:00
dc b5fe46931f test(viewbox): scope the picker lookup, escape the library name, outwait the re-select
Build / Build-and-ng-test (pull_request) Successful in 3m49s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m7s
Build / Build-and-test-development (pull_request) Successful in 24m26s
The table link is looked up inside the matched library node rather than across
the whole picker, so a table of the same name under another expanded library
cannot be clicked instead, and the library fragment is escaped before it
becomes a RegExp - a dot or a bracket in a library name no longer matches more
than the intended node.

The configuration panel is checked after the ~600-800ms window in which
loadData() re-selects the viewbox it just loaded, rather than 400ms into it,
so a moved panel is caught at the click instead of in the column list that
follows.

Verified on the local stack: 12 passing in 2:24 and 2:22.
2026-11-06 14:00:48 +00:00
dc 5f89314aaf fix(mocks): answer the review on #487
Build / Build-and-ng-test (pull_request) Successful in 3m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m13s
Build / Build-and-test-development (pull_request) Canceled after 22m0s
- createdemodata drops the unused servicesDir binding.
- the DC996664 copy no longer swallows a failure: a broken multi-load fixture
  now fails the seed where it happened instead of surfacing in a Cypress spec.
- makedata answers OUTFMT=JSON with a makedata_response member (as the SAS
  service does) and the CLI payload asks for it, so the `|| true` comes off
  makedata in build.yaml, release.yaml and sites.yaml - a failed seed names the
  service that failed instead of reappearing as "createdemodata needs the
  control library".
- the makedata comment points at sas/mocks/test/makedata-parity.test.js, not a
  macro that does not exist.
2026-11-06 13:36:26 +00:00
dc a6cfb12579 refactor(mocks): build the control library makedata.sas builds
Build / Build-and-ng-test (pull_request) Successful in 4m26s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 4m56s
makedata seeded two things at once: the control library, and the tables the
Cypress suite needs. The estate it produced was therefore not the estate a
SAS deployment produces - and it had drifted: MPE_MAXKEYVALUES and
MPE_SIGNOFFS were never created, and MPE_CONFIG, MPE_VALIDATIONS, MPE_TABLES,
MPE_SELECTBOX and MPE_XLMAP_RULES were missing seed rows that mpe_makedata.sas
inserts.

makedata now transcribes mpe_makedatamodel.sas and mpe_makedata.sas: every
model table with its columns, and every seed row the macro writes. The
Cypress fixtures move to services/admin/createdemodata.js, which the test
estate runs after makedata; it strips and re-adds its rows so a reseed does
not accumulate.

Checked against real SAS output on the shared estate (fresh deploy of the
server target, makedata, then getrawdata for each control table and a
dictionary.columns probe): all 40 tables and 339 columns match on name, type,
length, format and label, and the seeded rows match row for row apart from
the environment (libref, user id, group names) and the sample rows. That run
found six defects a source-only comparison could not: INT columns are 8 bytes
not 4, one label had lost a double space, the DC_MACROS setting held the SAS
expression instead of a path, the email templates held an escaped newline,
75 selectbox rows held %mf_increment(rk) instead of their key, and one
validation row was truncated at a ';' inside its regex.

makedata-parity.test.js parses both macros and compares them with the seeded
estate, and fails on a value that does not fit its column type, so the next
drift fails a test instead of shipping. The CI workflows, the Sites demo seed
and CONTRIBUTING call createdemodata after makedata.
2026-11-06 13:29:14 +00:00
dc acf44dae74 test(viewbox): select the config panel by its own heading
Build / Build-and-ng-test (pull_request) Successful in 3m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m2s
Build / Build-and-test-development (pull_request) Canceled after 26m52s
loadData() re-selects a viewbox about 600ms after its data lands, and that can
land after the row click that opens its configuration - leaving the panel on
the other viewbox and the test looking for a column that table does not have.
The panel's heading names the viewbox it is showing, so the click is repeated
until the heading matches rather than trusting the click once.

The row is only clicked when the panel is not already on that viewbox, since
selectViewbox() toggles and a second click would deselect it.

Verified on the local stack: 12 passing in 2:30 and 2:20, against 5:22 for the
same spec before the change.
2026-11-06 13:27:25 +00:00
allan 712f74db6d Merge pull request 'feat(logs): number the lines of the log, the source and the generated code' (#485) from feat/logs-line-numbers into release/7.23
Sites / deploy (push) Successful in 3m55s
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m57s
Build / Build-and-test-development (pull_request) Successful in 26m51s
Reviewed-on: #485
2026-11-06 12:37:08 +00:00
dc d3f1b2ac0a test(viewbox): select a picker table without walking the tree
Build / Build-and-ng-test (pull_request) Successful in 3m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m19s
Build / Build-and-test-development (pull_request) Successful in 26m30s
openTableFromViewboxTree paused 300ms before looking at each rendered node,
which on a library of this size cost ~13.4s of the ~20s a viewbox test takes.
The helper now expands the library and clicks the table's link, retrying the
expansion when the picker re-renders underneath it.

It also waits for the picker's own state before returning: every requested
table listed, no row spinner, and the ~600ms window in which loadData()
re-selects the box that just loaded. Without those, the next step read the
other viewbox's column list and failed on a column it does not have.

Measured on the local stack: the spec runs 2:18-3:15 against 5:22 before, with
all 12 tests passing in both.
2026-11-06 12:10:28 +00:00
dc 7a5286fca8 feat(logs): number the lines of the log, the source and the generated code
Build / Build-and-ng-test (pull_request) Successful in 4m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m17s
Build / Build-and-test-development (pull_request) Successful in 33m3s
The SAS Logs modal rendered the log, the source code and the generated code as one text block each, so no line could be referred to and the error, warning and note links had to locate a line by arithmetic. Render one row per line instead: every line carries its number, a wrapped line hangs under the text rather than under the number, and the numbers stay in view when a long log line is scrolled sideways.

The links now scroll to the row itself. The old offset was the measured line height multiplied by the line index, and two things were wrong with it: clientHeight rounds the height to a whole pixel, and the wrapper's padding was not counted. In a 168-line log at the stylesheet's own metrics the early links landed within a few pixels of their line while the later ones did not; at an 18.5px line height, which is what 16px monospace at 1.15 produces, the scroll for line 43 was 10.5px out and for line 127 was 52.5px out, so the highlight sat above the line it named and the drift grew with the line number. A row's own position cannot drift: every link lands its line at the top of the log, less the wrapper's 1px border, and flashes the right row.

The flash is a class on the rows rather than an absolutely positioned overlay, so a message SAS wrapped over several lines flashes every line it runs to.

One row per line costs about 0.25ms in Angular - a binding plus a sanitizer pass each - so rendering a whole log would freeze the modal: 20,000 lines took 5.3s to open. Each pane renders a window of 400 lines with a "Show more" line underneath, a jump renders through to the line it was given before scrolling, and opening is now flat at 165-280ms for 1,000, 5,000 and 20,000 lines.
2026-11-06 11:25:50 +00:00
allan 25ae900f06 Merge pull request 'ci(build): keep the mock tree's own tests out of the Cypress gate' (#482) from ci/skip-cypress-for-mock-tests into release/7.23
Sites / deploy (push) Successful in 3m21s
Build / Build-and-ng-test (pull_request) Successful in 3m59s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m3s
Build / Build-and-test-development (pull_request) Successful in 27m6s
Reviewed-on: #482
2026-11-06 10:32:42 +00:00
allan 7133c97ad4 Merge pull request 'fix(version): fall back to package.json when no tag is reachable' (#484) from fix/483-version-dropdown into release/7.23
Sites / deploy (push) Successful in 3m7s
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Build / Build-and-test-development (pull_request) Canceled after 6m18s
Lighthouse Checks / lighthouse (pull_request) Canceled after 9m9s
Reviewed-on: #484
2026-11-06 10:19:19 +00:00
dc da8e7d343c fix(version): fall back to package.json when no tag is reachable
Build / Build-and-ng-test (pull_request) Successful in 4m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m28s
Build / Build-and-test-development (pull_request) Successful in 29m8s
git-describe only sees tags reachable from HEAD. CI checks out a single commit with no tags, so it reports the bare hash and every version in the app reads "v.<hash>" - including the production release builds. The root package.json carries the released version (semantic-release bumps it before the shipped frontend is built), so use it as the tag when git-describe found none, and fill semverString from it for the browser_info diagnostics.
2026-11-06 09:46:31 +00:00
dc 88616c8f62 ci(build): reuse the client npm cache and the Cypress binary in the Cypress job
Build / Build-and-ng-test (pull_request) Successful in 3m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m37s
Build / Build-and-test-development (pull_request) Successful in 32m31s
The job installed the same lockfile the build job had just installed and paid
the downloads again (92s), and npx cypress install fetched its binary on every
run. Both now come from the cache the build job already fills, with the
Cypress binary keyed on the lockfile.
2026-11-06 09:44:42 +00:00
dc 68dc790698 ci(build): keep the mock tree's own tests out of the Cypress gate
Build / Build-and-ng-test (pull_request) Successful in 4m21s
Build / Build-and-test-development (pull_request) Canceled after 8m50s
Lighthouse Checks / lighthouse (pull_request) Canceled after 9m22s
sas/mocks/test/ holds the node:test harnesses that read the payload files, and
the job deploys sas/mocks/sasjs/ and nothing else - so a pull request that
changes only those tests cannot change what the browser is served, yet it
started the Cypress suite. The file list is filtered before the match, so any
other file under sas/mocks/ still counts, a change that touches the tests next
to a service included.

The matcher's cases cover both directions.
2026-11-06 09:30:50 +00:00
allan 8646381044 Merge pull request 'fix(mocks): restore the sas9 separators and convert the sasjs lineage capture' (#479) from fix/463-sas9-getdata-separators into release/7.23
Sites / deploy (push) Successful in 4m12s
Build / Build-and-ng-test (pull_request) Successful in 4m42s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m26s
Build / Build-and-test-development (pull_request) Successful in 27m10s
Reviewed-on: #479
2026-11-06 09:07:21 +00:00
dc caa58771bf fix(mocks): emit the sasjs lineage capture through webout()
Build / Build-and-ng-test (pull_request) Successful in 3m42s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m16s
Build / Build-and-test-development (pull_request) Successful in 29m9s
The capture assigned its body to _webout in one template literal, and that
literal was not JSON: raw line breaks inside the graphviz label and an
unescaped quote in labelloc = "t". fromsas, clickableids, info and flatdata
are members now, written as string literals that keep the label's quotes and
line breaks, with the envelope supplied by the runtime.

sas/mocks/test/sasjs-webout.test.js holds the six services that set their own
content headers, and pins this capture's members. CONTRIBUTING.md lists the
same six.
2026-11-06 09:03:01 +00:00
dc 67631133f9 test(mocks): pin the missing-separator branches and check a delimited drive
Build / Build-and-ng-test (pull_request) Successful in 4m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m56s
Build / Build-and-test-development (pull_request) Successful in 32m56s
Each fault shape the tree repairs now has a case that builds it - drive,
OMSOBJ, BUILTIN and DOMAIN - so a heuristic that stops detecting one fails the
suite instead of leaving only the exact-value assertion behind.

A drive letter is path-initial when nothing word-like precedes it, so a path
after <, >, =, [ or a comma is checked too; prose that puts a word before a
colon still is not a drive.
2026-11-06 02:53:11 +00:00
dc f1ba6362cc test(mocks): build the UNC cases from the code point
Build / Build-and-ng-test (pull_request) Successful in 4m17s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m8s
Build / Build-and-test-development (pull_request) Canceled after 15m30s
The two cases added for a path after a tag and after a comma were written as
single backslashes in the source, so each one evaluated to a value with no
separator at all and pinned nothing. The value is now assembled from
String.fromCharCode, with an assertion that it starts with the two separators
the case needs.
2026-11-06 02:33:19 +00:00
dc 755083bb49 test(mocks): fail a service that emits no member, and drop the history from the header
Build / Build-and-ng-test (pull_request) Successful in 6m17s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m30s
Build / Build-and-test-development (pull_request) Canceled after 13m34s
A service whose payload does not arrive as (kind, name, rows) contributed no
members and passed the tree-wide test with nothing checked; the loop now
records the file with no members captured.

The header describes what the values and the test do. The ticket numbers it
carried are gone: AGENTS.md allows a tracker reference only as a link, and the
behaviour reads without one.
2026-11-06 02:13:24 +00:00
dc 0e74ab58a1 test(mocks): pin the tree's scope and name the service that cannot run
Build / Build-and-ng-test (pull_request) Successful in 3m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m1s
Build / Build-and-test-development (pull_request) Successful in 29m25s
A file that does not emit through webout() and is not a listed helper fails a
scope test, so a renamed emitter shrinks the run set loudly instead of leaving
the replay over an empty set. Every webout kind is captured, a service that
needs a global the sandbox lacks names itself in the error, and the header
states the scope the scan covers.
2026-11-06 01:34:26 +00:00
dc c9119101a4 test(mocks): describe the separator rule as it applies to the webout() tree
Build / Build-and-ng-test (pull_request) Successful in 3m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m5s
Build / Build-and-test-development (pull_request) Successful in 29m15s
The header described the captures as live template literals, which this tree
no longer carries; the values now travel through webout() and the same
backslash is one escape away from the same damage.
2026-11-06 00:55:17 +00:00
dc 676cdf6fdd test(mocks): run only the services that emit, and widen the UNC exception
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Build / Build-and-test-development (pull_request) Canceled after 2m39s
Lighthouse Checks / lighthouse (pull_request) Canceled after 8m1s
The replay covers the files that call webout(), so a shared module added to
the tree is skipped instead of failing with a require error, while the
_webout assertion still reads every file - a service that writes its own body
cannot slip past the filter.

A UNC prefix is legal after any character that is not a word character, a
colon or another separator, so a path inside a tag or after a comma keeps its
prefix instead of reading as a doubled separator.
2026-11-06 00:47:13 +00:00
dc 88cc68e2f9 test(mocks): anchor the doubled-separator rule and resolve sibling requires
Build / Build-and-ng-test (pull_request) Successful in 3m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m2s
Build / Build-and-test-development (pull_request) Successful in 26m56s
The whole-tree check treated any two consecutive backslashes as a fault, so a
UNC library path (\\server\share\...) failed on a correct value. A run of two
is now legal only where a UNC path puts it - the start of a value or of a quoted
path - and two separators anywhere else, plus any run of three, are still a
fault. faultsIn gains a case for each direction.

The sandbox require delegated relative requests to this test file's own
require, so a service requiring a sibling module resolved it under
sas/mocks/test. A relative request now resolves from the service file; builtins
and absolute paths delegate unchanged.
2026-11-06 00:15:03 +00:00
dc bafbe201ce fix(mocks): restore the two separators the sas9 getdata recording lost
Build / Build-and-ng-test (pull_request) Successful in 4m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m51s
Build / Build-and-test-development (pull_request) Successful in 29m4s
getdata's ENGINEHOST metadata rows carried a Windows path and an account
name with no separators, which SAS writes and the rest of this tree
already has: the rows read C:DataControllerDC996664mpe_x_test.sas7bdat
and BUILTINAdministrators, against refreshlibinfo's C:\DataController\
DC996664 library and BUILTIN\Administrators owner in the same recording.

Both values now carry single separators.

sas/mocks/test/sas9-separators.test.js replays all 36 files of the tree
under a webout() stub and fails on a doubled separator, or a missing one
in an OMSOBJ URI, a drive path or a BUILTIN/DOMAIN account. It also pins
the two corrected rows and asserts that no service assigns _webout.
2026-11-05 23:18:52 +00:00
dc 109711b9a0 fix(viewer): key the export menu so Handsontable keeps it
Sites / deploy (push) Successful in 3m19s
The CSV / Excel / PDF export submenu never reached the screen. HOT's
ExportFile plugin contributes a context-menu entry under the reserved key
`export_file`, and ContextMenu's ItemsFactory.setPredefinedItems folds the
plugin's entry over the grid's entry at the same key. That folded entry
carries `hidden() { return this.getSettings().exportFile === undefined }`,
and no grid in this app sets `exportFile`, so the whole item - submenu and
all - was filtered out of the menu. Seen on the 7.22.0 build: the viewer's
context menu offers Copy with header, Copy header only and Show labels, and
nothing else, so the client-side PDF export could not be reached at all.

Give the item a key of the app's own, EXPORT_MENU_KEY, and use it at the
three grids that build the menu (viewer, editor, viewboxes). The submenu
keys move with it.

export-menu.spec.ts pins both halves against a real grid, because the
merge happens inside the plugin at menu-build time and no unit test of the
item object can see it: configured under `export_file` the item is absent,
configured under EXPORT_MENU_KEY it is present.
2026-11-05 22:24:45 +00:00
dc 6bf7f9ba0d ci(docs): give the SAS docs deploy the Cloudron password that exists
Sites / deploy (push) Successful in 3m19s
The 7.22 release's sas-docs job failed with surfer's "Run surfer config first,
or provide --server ... --password" message, and its log shows SURFER_PASSWORD
empty - the CODE_DATACONTROLLER_IO secret resolves to nothing. Authenticate
with WEBDOC_DATACONTROLLER_IO, the password the typedoc job in this same
workflow already uses: a Cloudron app password belongs to the user, and the
same user owns both docs apps, so one credential covers both.
2026-11-05 21:29:35 +00:00
dc 205adf0a2b ci: put the remaining workflows on the node version the build uses
build.yaml, lighthouse.yaml and sites.yaml were moved to Node 24.15.0 on
2026-10-28 (d84a1d50); release.yaml, release-assets.yaml and docs.yaml were left
on 24.5.0. The release workflow builds the same client and runs the same Cypress
suite as build.yaml, so it should not be the one place pinning a different
runtime - and docs.yaml builds the frontend for typedoc with it too.
2026-11-05 21:29:23 +00:00
dc f3d1eb11d1 ci(release): attach cypress evidence only on failure
Sites / deploy (push) Successful in 3m17s
The release workflow recorded video for every spec and uploaded
cypress-videos.zip on every run, green or red. Recording eleven specs costs
encode time each run and the recording is only ever opened after a failure, so
run with video=false,screenshotOnRunFailure=true - the same pair build.yaml
already passes - and zip screenshots plus videos only under if: failure().
2026-11-05 21:17:10 +00:00
semantic-release-bot e07246ac68 chore(release): 7.22.0 [skip ci]
# [7.22.0](https://git.datacontroller.io/dc/dc/compare/v7.21.0...v7.22.0) (2026-11-05)

### Bug Fixes

* **browse:** accept legal folder names, cap the walk, and lock the read ([3572d56](3572d56cfb))
* **build:** build the mock sites with a CLI whose runtime provides webout() ([b1eab98](b1eab98200))
* **build:** publish the changed-path decision and pin the matcher with tests ([ecc8702](ecc87021c3))
* **build:** treat a sas/package.json bump as a Cypress input ([48df795](48df795202))
* **client:** cancel a queued dropdown reposition on destroy ([590b841](590b841f24))
* **client:** drop the synthetic window resize dispatches ([2535baa](2535baa5f7))
* **client:** keep the ARIA pass on the viewbox and multi-dataset grids too ([cb30469](cb30469e79))
* **client:** restrict the table pass to Handsontable's own tables, add the viewer fallback ([46c6aec](46c6aec37a))
* **client:** scope the ARIA pass to each grid and drive it from the render hooks ([bf1e7d6](bf1e7d6b15))
* **client:** stop synthesising aria-labels; name the filter modal's submit in the test ([32d3d49](32d3d49b59))
* **client:** strip only this grid's own wrapper, and pin the re-strip after a data load ([f282d1a](f282d1a44b))
* **deploy:** ACTION cannot seed by default, and the probe stops overwriting ([e84cfaa](e84cfaaabd))
* **deploy:** ask for the admin group before the staging directory on SASjs ([5c64691](5c646913e4))
* **deploy:** await nothing, and encode the values in the makedata URL ([d67a0e5](d67a0e5d5f))
* **deploy:** drop the deprecated service-pack deploy from the setup screen ([59e09e2](59e09e26a5))
* **deploy:** gate the browse actions on the settings file ([5eb8492](5eb8492742))
* **deploy:** make the folder picker work on both estates ([fb38309](fb38309e98))
* **deploy:** match the documented SYSSCPL set and stop guessing at the cap ([5f338ff](5f338ff437))
* **deploy:** read a URL-supplied ACTION before defaulting, and show locked folders ([addb4a5](addb4a56ea))
* **deploy:** stop the makedata poll outliving its screen, and align the release gates ([c808c41](c808c41333)), closes [#407](#407)
* **deploy:** unclamp the folder picker row and probe in the chosen context ([1330925](133092502c))
* **deps:** bump @sasjs/core to 7.0.1 so special missings can be restored ([3d2eefd](3d2eefdd7b)), closes [sasjs/core#499](sasjs/core#499)
* **deps:** bump @sasjs/core to 7.1.3 so a filter abort names the clause ([c7c1815](c7c181518c))
* **editor:** a container resize must not clear an active sort ([5bd7b5d](5bd7b5d724)), closes [#246](#246) [#246](#246)
* **editor:** drop the stale case hint, and narrow the strip to our own form ([bd2739c](bd2739cde4))
* **editor:** one predicate for the CASE marker, and no stacking in the tooltip ([18b0546](18b0546dcb))
* **embed:** a rejected mapping must not block the page ([d845b9e](d845b9e9b7)), closes [dc/dc#404](#404)
* **embed:** read a month-name date from VA as a date, not a character value ([30cefcf](30cefcf2dc))
* **getgroupmembers:** widen membername to $256 for long names ([196d6c9](196d6c931d))
* **lint:** enable noSingleAsteriskComments and noUnusedLibnames ([9071801](9071801ac5)), closes [#376](#376)
* **lint:** make noBannedStrings an error and clear its 13 findings ([0b85f7b](0b85f7bf1e)), closes [#376](#376)
* **lint:** make noUndeclaredMacros an error, and fix what it found ([af28a79](af28a79bd5)), closes [#376](#376)
* **lint:** make noUnusedMacros an error and clear its 54 findings ([c8c0858](c8c0858295)), closes [#376](#376)
* **lint:** rename the short and mixed-case helper macros, and clear the last warnings ([7da67eb](7da67eb17b)), closes [#376](#376)
* **makedata:** let the browse actions live as long as the service does ([912fae4](912fae448b))
* **mocks:** 0 is a closed TX_TO, a libref boundary, and the union of the row keys ([67d9cfd](67d9cfd1bd))
* **mocks:** abort like mp_abort, lock like mp_lockanytable, derive saslibs ([ca60208](ca602082ff))
* **mocks:** answer the deploy screen's DIRLIST and WRITECHECK actions ([e1c3c6e](e1c3c6e872))
* **mocks:** derive startupservice's maps and timezone instead of hardcoding ([87fd46c](87fd46c825))
* **mocks:** fold the audit window, and append MPE_AUDIT under its lock ([5ecc7be](5ecc7bebec))
* **mocks:** keep the OMS and path separators the captures lost ([7d9d466](7d9d466c3e))
* **mocks:** one sasDatetimeNow, a documented TX_TO=0, and a shaped fallback export ([e53c50d](e53c50d4e2))
* **mocks:** read hook columns in either case, and guard duplicates first ([349a2f3](349a2f314c))
* **mocks:** read the metadata member from the response, not the function's arity ([09e9f19](09e9f192c5))
* **mocks:** refuse a runtime whose webout() cannot carry column metadata ([482435f](482435f318))
* **mocks:** resolve the audit table once, record lower-case md5, and report a failed audit write ([1a9f594](1a9f59478b))
* **mocks:** run approval hooks, as mpe_runhook does ([f0208bb](f0208bb0b0))
* **mocks:** separate a body read failure from a missing showmeta floor ([744c89b](744c89b919))
* **mpe_abortmessage:** decide once whether the payload was escaped ([af89126](af89126acf))
* **mpe_abortmessage:** read program and state by name, and trim the prefix ([68b89ca](68b89caf63))
* **mpe_assert_abort:** anchor the cut on the whole escaped separator ([e74a7ba](e74a7ba115))
* **mpe_assert_abort:** compare the hook's message, not the log extract ([8b426c2](8b426c27fd))
* **mpe_assert_abort:** compare the unescaped message, cut at the last marker ([e68d221](e68d221354))
* **mpe_assert_abort:** cut at the separator, not the last marker ([4c7e869](4c7e869369))
* **mpe_assert_abort:** name the separator and tolerate a carriage return ([7b09ade](7b09ade97c))
* **mpe_assert_abort:** size the capture buffer for the whole message ([5d32de9](5d32de9df9))
* **mpe_assert_abort:** strip the separator tail, not every trailing escape ([b29bc1e](b29bc1e7ae))
* **mpe_execute:** assemble the abort text in a macro that can be tested ([e2e9103](e2e91039cb))
* **mpe_execute:** keep a child abort message intact when reporting it ([ebfd395](ebfd39582f))
* **mpe_execute:** keep the child message in the fallback, and test the doubled separator ([11587e2](11587e2f24))
* **mpe_execute:** make the abort-text fallback reachable ([24b8cbe](24b8cbe9dc))
* **mpe_execute:** mask a child abort message before it aborts the caller ([20dbf00](20dbf00fb0))
* **mpe_execute:** one symbol for the abort text, and no values in the call ([700b906](700b906d69))
* **multi-dataset:** a sheet that failed to extract is not a clean finish ([3129648](312964864e))
* **multi-dataset:** show the file size and loading stage while the file loads ([45d6c02](45d6c025c9)), closes [#121](#121)
* **report:** treat a blank PDF footer parameter as its default ([0f729d7](0f729d7101))
* **test:** let the dirlist and writecheck results reach the caller on Viya ([ed678f0](ed678f0b46))
* **test:** run metanav.cy.ts in CI and open a metadata object ([02a8590](02a8590310))
* **va-host:** bound the early replay even when the frame is not postable ([f6d5a04](f6d5a0495e))
* **va-host:** deliver in load mode, honour dataType, and survive odd prompt names ([7d4155d](7d4155dc6c))
* **va-host:** deliver the load-mount message once, and document report.json from the file ([90784b3](90784b342f))
* **va-host:** keep the deploy password off argv, and verify the app is served ([7c63a5c](7c63a5c93f))
* **va-host:** post to the frame's own origin, and key the replay to the document ([9cec292](9cec292681))
* **viewer:** clamp over-tall PDF rows, always emit an otherwise-empty page ([10229d5](10229d5efe))
* **viewer:** defer the blob URL revoke, reject a selection inside skipped columns ([b5bb704](b5bb704a4f))
* **viewer:** keep the PDF export working where hiddenColumns is not enabled ([f84d4ca](f84d4ca7af))
* **viewer:** review findings - CSV skipped column, PDF row cap before reading ([2c4be5e](2c4be5e94b))

### Features

* **admin:** find and test a writeable location during deploy ([442c595](442c595c2b))
* **deploy:** pick and validate the DC location from the deploy screens ([6eeab2e](6eeab2ed16))
* **editor:** convert CASE rule values on entry ([c4eef49](c4eef498b2))
* **logs:** read the logs modal with the shared log parser ([aabc578](aabc578d5e))
* **mocks:** add the control-table services the screens call ([a1615ee](a1615ee8a7))
* **mocks:** build every JS stored program's response with webout() ([ce6bb5d](ce6bb5d4cd))
* **mocks:** emit the lineage, usernav and libinfo responses through webout() ([1f54fad](1f54fad3fe))
* **mocks:** emit the metanav responses through webout() ([e1d877f](e1d877f21d))
* **mocks:** emit the sas9 services through webout() ([7be3f84](7be3f84073)), closes [#461](#461)
* **mocks:** restore a load, and record the diffs it needs ([1a60c68](1a60c689f4))
* **mocks:** use the server's webout() in the JS stored programs ([ccb67ed](ccb67ed5a0))
* **va-host:** emulate a VA report that embeds DC, as a deployable AppStream app ([2185db9](2185db911d))
* **viewer:** download a server-side PDF report ([67ca129](67ca129c4d))
* **viewer:** export the grid as PDF from the context menu ([e58e735](e58e735610))

### Reverts

* keep the helpers nested inside makedata_body ([4424171](4424171b2f))
v7.22.0
2026-11-05 21:11:44 +00:00
allan 39e8ffdbdd Merge pull request 'feat: Release/7.22' (#407) from release/7.22 into main
Release / Build-production-and-ng-test (push) Successful in 4m25s
Release / Build-and-test-development (push) Successful in 21m8s
Release / release (push) Successful in 4m37s
Reviewed-on: #407
2026-11-05 20:42:41 +00:00
allan 6f7f2d189f Merge pull request 'fix(deploy): drop the browse gate, and ask for the admin group before the directory' (#478) from fix/7.22-browse-window-and-order into release/7.22
Sites / deploy (push) Successful in 3m6s
Build / Build-and-ng-test (pull_request) Successful in 3m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m4s
Build / Build-and-test-development (pull_request) Successful in 29m18s
Reviewed-on: #478
2026-11-05 18:54:47 +00:00
dc 5c646913e4 fix(deploy): ask for the admin group before the staging directory on SASjs
Build / Build-and-ng-test (pull_request) Successful in 4m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m31s
Build / Build-and-test-development (pull_request) Successful in 33m3s
The SASjs Server / SAS9 screen put the DC Staging Directory first and the admin
group second, the reverse of the Viya screen, so the operator was asked to pick
a location before being told who administers DC. It now asks for the admin group
first and the directory last, with the copy reordered to match.

A rendered-order spec covers it: it reads the labels off the rendered template
and fails if the directory comes first again.
2026-11-05 18:21:54 +00:00
dc 912fae448b fix(makedata): let the browse actions live as long as the service does
DIRLIST and WRITECHECK were refused once the appLoc had a settings.sas, on the
argument that a failed run could leave that file behind. That is what makes the
gate wrong: a failed run is exactly when the deploy screen still needs to list
and probe directories, and any appLoc configured earlier refuses them for good.
The window needs no separate clock - a successful run deletes this program, so
once DC is configured there is nothing left here to call.

Removed from both variants (server checks the drive over HTTP, Viya checks the
filesystem; neither is needed), and the two tests that asserted the refusal go
with it. The macros themselves are covered by mpe_dirlist.test.sas and
mpe_writecheck.test.sas.

Seen on sas.4gl.io: a deployment to /Public/app/dc477 answered BROWSE with
"DIRLIST and WRITECHECK are only available until DC is configured".
2026-11-05 18:21:54 +00:00
allan eef91ce989 Merge pull request 'fix(7.22): clear the makedata poll on destroy, and close the release-gate findings' (#477) from fix/7.22-release-followups into release/7.22
Sites / deploy (push) Successful in 3m6s
Build / Build-and-ng-test (pull_request) Successful in 6m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m47s
Build / Build-and-test-development (pull_request) Successful in 27m24s
Reviewed-on: #477
2026-11-05 17:58:28 +00:00
dc c808c41333 fix(deploy): stop the makedata poll outliving its screen, and align the release gates
Build / Build-and-ng-test (pull_request) Successful in 4m20s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m36s
Build / Build-and-test-development (pull_request) Successful in 33m14s
The release pipeline went red at 28157fae: Karma aborted with "Some of your
tests did a full page reload!" after test 753 of 861. The new-window spec calls
runMakedataInNewWindow, which starts the five-second makedata poll; the service
stub answers that makedata is gone, so the first tick assigns window.location.href
and the page reloads under the running spec. A local run finishes in under five
seconds, so the timer never fires there - only CI saw it.

- the poll is cleared in ngOnDestroy, so a timer cannot reload the page after
  the user has moved on; a spec covers that
- the spec that exercises the new-window URL no longer starts the real poll

Three open findings on #407, all taken:

- lighthouse.yaml: sas/package.json joins the change-detection pattern, because
  the job pins @sasjs/cli from that file
- release-assets.yaml: webpack is installed at the version the client lockfile
  resolves rather than whatever is current at release time
- build.yaml: the client audit runs with --audit-level=low, which is what the
  comment above it has always claimed
2026-11-05 17:09:52 +00:00
dc ed678f0b46 fix(test): let the dirlist and writecheck results reach the caller on Viya
Both test programs pointed _webout at a temporary file so the macros under
test could write their JSON answer, then tried to hand the fileref back with
`filename _webout "&_webout_path" mod;`. On Viya's JES _webout is a filesrvc
fileref, whose pathname() is empty, so that statement fails with "Invalid
physical name" and the temporary file stays in place. The term program's
%webout(OPEN) assigns _webout itself (filesrvc on JES, temp elsewhere), so the
restore is not needed at all; the two programs now leave the fileref to it.

Observed on the nextviya estate: the suite reports 64/64 of the tests that
produced results, with mpe_dirlist and mpe_writecheck absent from the results
JSON and "Invalid physical name" in their logs.
2026-11-05 17:09:49 +00:00
allan 28157faeca Merge pull request 'fix(deploy): unclamp the folder picker row and probe in the chosen context' (#476) from fix/deploy-folder-overlay into release/7.22
Build / Build-and-ng-test (pull_request) Failing after 2m13s
Build / Build-and-test-development (pull_request) Skipped
Sites / deploy (push) Successful in 3m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m36s
Reviewed-on: #476
2026-11-05 15:52:29 +00:00
dc e8cdc37a05 test(deploy): match the picker's requests with regexps, and sign in on a secured estate
Build / Build-and-ng-test (pull_request) Successful in 5m10s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m34s
Build / Build-and-test-development (pull_request) Successful in 32m58s
The spec failed on CI: the browse button never appeared. Two causes, both
found by reproducing the CI estate locally (seeded drive):

- The intercept pattern '**/drive/folder**' does not match the request the
  app makes ('/SASjsApi/drive/folder?_folderPath=...') in this Cypress
  version, so the app saw the real drive, loaded the startup service and
  never routed to the deploy screen. Both intercepts are now regexps, which
  match deterministically. Verified on the seeded estate: the app routes to
  #/deploy and the agreement card renders.

- A secured estate shows a login form first. The boot helper now waits for
  one of the states it can act on (a visible login form, the agreement card,
  or the deploy screen), signs in when the form is visible, then waits for
  the deploy screen. The login form stays in the DOM hidden on other routes,
  so visibility - not presence - decides.
2026-11-05 15:10:51 +00:00
dc 677b03eddf test(deploy): bind the picker in the Viya order spec, and drop fixed waits
Build / Build-and-ng-test (pull_request) Successful in 4m13s
Lighthouse Checks / lighthouse (pull_request) Successful in 7m56s
Build / Build-and-test-development (pull_request) Failing after 37m51s
Review findings on #476.

- The 'hands the picker the context' test declared only AutomaticComponent
  and HostComponent with NO_ERRORS_SCHEMA, so app-dc-location stayed an
  unknown element: the [computeContextName] binding was never applied and
  the assertion just repeated the fallback case. DcLocationComponent is
  now declared (SasService is already stubbed), and the test asserts the
  picker's own input - both before a selection (the configured fallback)
  and after one is chosen. Verified it bites: removing the binding from
  the template fails it.

- The Cypress spec set the path nowhere before clicking Validate, so the
  assertion leaned on the field arriving pre-filled; a blank field would
  short-circuit with 'Type or pick a folder first' and fail for a reason
  unrelated to the layout. It now types the path explicitly.

- The spec slept a fixed 4s after accepting the agreement, which turns a
  loaded CI runner into an intermittent failure. Both tests now share one
  boot helper whose readiness gate is the Browse button appearing.
2026-11-05 14:13:16 +00:00
dc 133092502c fix(deploy): unclamp the folder picker row and probe in the chosen context
Build / Build-and-ng-test (pull_request) Successful in 3m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m35s
Build / Build-and-test-development (pull_request) Canceled after 24m24s
Two defects on the deploy screens, found on staging.

1. The folder picker's outer row reused Clarity's 'clr-input-wrapper'
   class, whose 'max-height: var(--clr-forms-input-wrapper-height)'
   clamps the row to the input's height. The row is flex-wrap: wrap, so
   once the Browse/Validate buttons wrapped (or the row was simply
   taller than one input) they overflowed the clamp, and the folder
   browser panel - positioned after the collapsed box - rendered on
   top of the input, the buttons and the surrounding text. Measured on
   staging: panel top 256 against input 248-272 and buttons 280-304;
   with the class dropped the row contains all its children and the
   panel starts at 312.

2. The Viya screen asked for the DC directory before the compute
   context, but the folder listing and the write check run inside the
   chosen context, as its batch identity - so the probes could inspect
   a file system the deployment itself would not use. The fields are
   reordered (admin group, then batch id / compute context, then DC
   Loc last) and the picker now runs DIRLIST and WRITECHECK with the
   selected context in the adapter request config, which every Viya
   executor path honours. The SAS 9 / SASjs screens pass no context
   and behave exactly as before.

Tests: a Cypress spec drives the real deploy screen estate - the
picker row must contain its own children and the panel must start
below them; both tests fail with the clamp reintroduced. A rendered
TestBed spec pins the Viya field order (admin group < compute context
< DC Loc) and fails with the old order. Unit specs pin the probe
config: no context on SAS 9 / SASjs, the chosen context on Viya.
2026-11-05 13:44:31 +00:00
allan 1ea1965721 Merge pull request 'feat(mocks): restore a load, and record the diffs it needs' (#474) from feat/mocks-restore into release/7.22
Sites / deploy (push) Successful in 3m23s
Build / Build-and-ng-test (pull_request) Successful in 3m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m36s
Build / Build-and-test-development (pull_request) Successful in 33m22s
Reviewed-on: #474
2026-11-05 10:54:20 +00:00
allan 3573ce260f Merge pull request 'feat(mocks): add the control-table services the screens call' (#473) from feat/mocks-control-table-services into release/7.22
Sites / deploy (push) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Reviewed-on: #473
2026-11-05 10:54:10 +00:00
dc 1a9f59478b fix(mocks): resolve the audit table once, record lower-case md5, and report a failed audit write
Build / Build-and-ng-test (pull_request) Successful in 6m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m32s
Build / Build-and-test-development (pull_request) Successful in 27m3s
Three review findings.

- KEY_HASH was upper-cased after the digest. SAS md5() writes lower-case hex,
  so the mock's key hash did not match what a real audit table holds. The
  digest is stored as it comes out, and the test asserts lower case.

- restore resolved MPE_TABLES.AUDIT_LIBDS per table, while the approval always
  appended to the control library's MPE_AUDIT: a table with its own audit table
  had its diffs written where restore never looked, and restore then refused
  with "No entry for <load>", which no later approval could fix. Resolution now
  lives in dcMockUtils.auditLibdsFor, called by both sides, and the approval
  reads and saves that table in its own libref.

- An audit failure was only logged to the server console, leaving an approval
  with no diffs and no visible reason. It is still not allowed to undo the
  approval, but once the control tables are written the failure is raised as an
  abort that names the table and says the load can no longer be rewound.

Two tests: one approves a table whose MPE_TABLES row names a different audit
table and asserts the rows land there and not in the control library; one points
AUDIT_LIBDS at a libref with no data folder and asserts the approval still
lands and the response says the diffs could not be recorded.
2026-11-05 10:44:40 +00:00
dc 5ecc7bebec fix(mocks): fold the audit window, and append MPE_AUDIT under its lock
Two review findings on this branch.

- editors/restore.js spread every audit row for the load into Math.min(...).
  MPE_AUDIT is append-only, so a long-lived estate would eventually exceed the
  engine's argument limit and the RangeError would surface as a failed restore
  of a perfectly restorable load. Folded into a reduce with an Infinity seed.

- auditors/postdata.js appended to MPE_AUDIT as a read-modify-write outside any
  lock. postdata.sas locks the table it writes - MPE_REVIEW around its proc
  append, MPE_SUBMIT around its update - so the audit append now takes the
  MPE_AUDIT lock around a fresh read, the concat and the save, rather than
  reading the table up front and saving a copy that may be missing another
  approval's rows.

53 mock tests pass (node --test sas/mocks/test/).
2026-11-05 10:44:40 +00:00
dc 1a60c689f4 feat(mocks): restore a load, and record the diffs it needs
`editors/restore` had no mock, and the reason it could not have had one is that
`postdata` wrote no audit rows: an approval applied the load and left nothing
to rewind.  Both halves are here.

postdata (final approval): writes MPE_AUDIT the way %mp_storediffs does - one
row per column, keyed by the md5 of the business-key values joined by '|', with
move_type A/D/M, is_pk on the key columns, is_diff on a changed one, and the
old and new value on their own typed columns.  An append carries every column
on the new side, a delete every column on the old side (which is what lets a
restore put the row back), and a modification the keys plus only the columns
that actually changed.  A failure writing the audit is logged and does not undo
an approval that has already landed.

restore: mpe_checkrestore's two decidable checks (the table has an audit table;
the load has entries in it) with their messages, then mp_stripdiffs' window -
the load's timestamp and every change to that table from then on, because a
restore reverts this load *and all subsequent ones*.  Per business key the
pre-window state is the earliest event: an append means the row is staged for
deletion (`_____DELETE__THIS__RECORD_____=YES`), a delete means it is staged
with the values the delete recorded, and a modification means each column takes
its earliest old value while untouched columns keep the current one.  The
result goes through the same stageSubmission the editors use, so it arrives as
a new submission with the reason `Restoring <loadref>` and is approved like any
other - restore.sas never writes the base table either.

The access and group/RLS checks are not emulated, consistent with the rest of
the mocks: nothing enforces MPE_SECURITY there.

Tests: 52 pass across the seven mock suites, 7 of them new.  They drive a real
approve and then read the staged rows back: append staged for deletion,
modification staged with the pre-load value, delete staged with the row's
values, and restoring the first of two loads reverting the second as well.
With the base postdata and no restore service, all 7 fail.
2026-11-05 10:44:40 +00:00
dc 67d9cfd1bd fix(mocks): 0 is a closed TX_TO, a libref boundary, and the union of the row keys
Build / Build-and-ng-test (pull_request) Successful in 5m40s
Lighthouse Checks / lighthouse (pull_request) Successful in 7m54s
Build / Build-and-test-development (pull_request) Successful in 32m44s
Four review findings.

- isCurrentOn counted TX_TO 0 as current. 0 is 01JAN1960 in SAS, so a real row
  with that window has been superseded; only a missing, null or blank TX_TO is
  open besides 31DEC9999. The fixtures are seeded with 31DEC9999 (253717919999)
  through an OPEN constant instead, and the 0 clause is gone.

- The MPE_DATADICTIONARY filter compared DD_SOURCE against the libref without a
  boundary, so DC_JSLIB2.TABLE was excluded along with DC_JSLIB.TABLE. It now
  tests for the libref plus the dot, as the libds rewrite does.

- The schema-less export took its column list from Object.keys(rows[0]), so a
  column that first appears in a later row was silently missing from the backup.
  The list is the union of the keys across the exported rows, in first-seen
  order.

- control-tables.test.js imported readTable and never used it.
2026-11-05 10:44:28 +00:00