Skip to content

fix(sql): wire sql.engine/allowFullScan/maxSortRows/maxHashRows to real config - #2484

Open
kriszyp wants to merge 11 commits into
mainfrom
fix/wire-sql-config-globalthis
Open

fix(sql): wire sql.engine/allowFullScan/maxSortRows/maxHashRows to real config#2484
kriszyp wants to merge 11 commits into
mainfrom
fix/wire-sql-config-globalthis

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 3, 2026

Copy link
Copy Markdown
Member

sqlEngine/config.ts read sql.engine/allowFullScan/maxSortRows/maxHashRows from globalThis.harperConfig, which nothing in production ever assigned — only three unit-test files set it as scaffolding. A value under sql: in harperdb-config.yaml never reached the SQL engine; sql.engine only appeared to work because it also has a HARPER_SQL_ENGINE env fallback, and the other three caps had no fallback at all and were permanently stuck at their compiled-in defaults. This registers the four keys with the real config layer (CONFIG_PARAMS + configUtils.getConfigValue(), the same accessor every other config domain uses) and deletes the dead globalThis branch, plus adds Joi validation for the section so a malformed value fails loudly instead of silently keeping the default.

Making sql: a validated core section collided with the fact that Harper's root config namespace also holds application entries: deploy_component project=sql package=… wrote sql: {package: …}, reported success, and made the next boot fail config validation with no recovery but hand-editing the YAML. So sql is now a reserved component name, refused at every ingress that creates a component under it, and an application already deployed under the name is grandfathered by a shape-switched schema plus a rename warning.

For the human reviewer

Framing: the planning gate returned better-alternative-exists and the alternative was adopted, rewriting the chosen approach: the reservation is a shared predicate in utility/componentNames.ts (the module the CLI and server already share for name derivation) applied to every component-creation ingress, rather than a check on the two obvious operations. That is what caught the set_configuration sql_package=x bypassfindUnrecognizedParams() deliberately admits arbitrary <component>_package/_port names and updateConfigValue() maps them straight into a root entry, so the deploy guard alone would not have held.

  1. Reserve only sql, or every core root section? Only sql is reserved. Every other section (storage, node, replication, models…) inherits the top-level allowUnknown: true, so a component deployed under one of those names validates and boots today — it silently replaces that section's settings instead of failing, which is a real but different bug. Widening the set is additive and cheap later; each widening is a new rejection for someone who has such a component deployed now. Left for a follow-up rather than decided here (not yet filed as an issue).
  2. A second, absolute reservation next to the existing force-overridable protected-core-component list. deploy_component already refuses graphql, http, … with a 409 that force: true overrides. This adds a distinct rule with a 400 that force cannot override, because there is no core component to overwrite — forcing would only let the operator write config that breaks their next boot. Two mechanisms with different force semantics now coexist; collapsing them later changes what force means for operators.
  3. A grandfathered sql application is frozen for deploys, editable for migration. set_component_file and set_env_value refuse the name only when the project directory does not exist yet — a filesystem probe inside a Joi validator, and a second "is this legacy?" heuristic beside the config-key one. The uniform alternative (refuse always, migrate through drop/redeploy) is simpler but leaves an operator unable to patch the app they are being told to migrate.
  4. A grandfathered sql application is frozen for redeploys. It keeps loading, but it cannot be redeployed or reconfigured — deploy_component refuses the name unconditionally, force included. That is deliberate friction to force the rename, and the boot warning is the only migration guidance shipped. The alternative (allow redeploy while the entry exists) keeps the ambiguity alive indefinitely.
  5. A mixed entry is rejected rather than grandfathered. An entry carrying both an application key and an engine setting fails validation naming the key; false/null, componentLoader's spelling for a disabled component, is accepted so an operator who had already turned such an app off still boots. The pre-push review asked for historical mixed entries to be accepted and new ones blocked in mutation validation instead; declined, because "historical" is only distinguishable at a mutation — a hand-edited YAML, a restored backup or a replicated write reaching boot validation has no prior state to diff against, so the rule would be unenforceable exactly where the ambiguity does its damage. No supported path produces a mixed entry: deploy_component rebuilds the whole entry from request fields, and sql.engine was unreachable in production before this PR.
  6. Sibling key shape, not sql.engine.*. sql.engine ships as its own scalar mode string, with allowFullScan/maxSortRows/maxHashRows as siblings under sql — not nested under engine as sqlEngine/PLAN.md (now corrected) used to describe. This matches SqlEngineConfig's pre-existing shape and every existing test's scaffolding. Reversible only before this ships: once a scalar sql.engine is in a customer's YAML, moving to a sql.engine.* section later is a breaking rename.
  7. Config resolved per call site, not once per statement. getSqlEngineConfig() is still read fresh at each of its ~5 call sites (router, scan validator, hash-join/hash-aggregate build, sort) rather than resolved once into a per-statement snapshot. The alternative — attaching a resolved config to SqlEngineContext — only covers the physical-execution half of the pipeline; the router and the optimizer's validateScannable rule run before any SqlEngineContext exists, so it would need new plumbing invented for those two call sites too. Cost of not doing this: a set_configuration write landing mid-query could theoretically see allowFullScan/caps change between planning and execution within one statement — accepted, since config is restart-required everywhere else in Harper already, and the per-call cost is a handful of map lookups per statement, not per row.
  8. sql.* Joi validation uses convert: false. validateConfig() only writes Joi's coerced value back into configDoc for threads/componentsRoot/logging/storage/operationsApi — never sql — so leaving Joi's default convert: true on would have accepted a quoted allowFullScan: "true" and then silently dropped it (the accessor's typeof guard rejects the still-unconverted string). convert: false makes that combination fail loudly at boot/set_configuration instead.
  9. Registering sql_engine also exposes the bare env var SQL_ENGINE. Every CONFIG_PARAMS entry is reachable as an unprefixed environment variable on harper run (bin/run.ts's assignCMDENVVariables), and SQL_ENGINE is also a common external convention (Django, other ORM tooling) that could plausibly already be set, unrelated to Harper, in a shared .env/compose file — with the new Joi enum validation, such a collision now makes harper run abort instead of silently no-op. Discussed directly with the task owner: accepted as consistent with ~150 other existing bare CONFIG_PARAMS names carrying the same class of risk; a general "opt a param out of the bare-env-var scan" mechanism is left for a follow-up rather than special-cased here (not yet filed as an issue).
  10. set_configuration sql_* still requires a restart, like every other config param: the write lands in the file without updating the in-memory flat config. The engine flag is the kind of knob an operator might expect to flip live during a cutover, but no config domain in Harper propagates live across threads.
  11. Not covered by the reservation, deliberately: a components-root directory named sql that already exists (it has no root-config entry, so nothing fails validation; its operator finds out at the next redeploy, which is refused), and HARPER_BUILTIN_COMPONENTS=sql, which registers a trusted plugin by name and bypasses operation validation by design.

Verification

  • npm run build, npm run lint:required, npm run format:write clean.
  • npm run test:unit:main: 5334 passing, 1 failing — a pre-existing domain-socket-path-length assertion that resolves a relative rootPath against process.cwd() and fails from any agent worktree, identical on origin/main. npm run test:unit:resources 2004 passing / 0 failing; npm run test:unit:bin 252 passing.
  • npm run test:integration -- "integrationTests/deploy/**/*.test.ts": 61 passing / 0 failing — real deploys still run through the new validator unchanged.
  • Fails-on-base for the reservation: emptying RESERVED_COMPONENT_NAMES turns all 10 reservation assertions red across unitTests/components/reservedComponentName.test.js, unitTests/config/setConfigurationSql.test.js and the deploy handler cases; the grandfather schema cases fail on the previous commit, where a sql: {package} entry is rejected with 'sql.package' is not allowed. The four sql.* accessor assertions in unitTests/sqlEngine/router.test.js stay red on origin/main.
  • Reservation coverage: deploy_component with an explicit sql, a canonicalized sql.tgz, a package-derived sql, the payload form, and force: true (400, through the real handler, which derives project before validating); add_component; set_component_file and set_env_value against a pinned temporary components root, covering both sides of the grandfather check; set_configuration sql_package/sql_port; and the CLI deploy setup=true name check.
  • Config schema matrix: well-formed settings, a bad engine enum, a quoted allowFullScan, non-positive/non-integer row caps, an unknown key, {}, false/null (componentLoader's disabled-component spelling, which the closed settings schema would otherwise have failed at boot — found by the pre-push review), an application-shaped entry, and a mixed entry.
  • Not proven end-to-end: no test drives a sql_* value from a set_configuration body through castConfigValue/flattenConfig to getSqlEngineConfig(); each side is covered in isolation, and a break there fails loudly at validation rather than silently. The boot rename warning is covered only through its predicate — validateConfig() is not exported and AGENTS.md forbids new sinon/rewire uses, so the one logger.warn call behind the tested predicate has no direct test. No API-to-restart integration test was added.

Complexity: medium

Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=9 @ 6a66d45

Human-Review-Need: 3 @ 6a66d45

kriszyp and others added 5 commits September 2, 2026 22:07
sqlEngine/config.ts read engine/allowFullScan/maxSortRows/maxHashRows
from globalThis.harperConfig.sql, which nothing in production ever
assigned — only three unit-test files set it as scaffolding. A value
set under sql.* in harperdb-config.yaml never reached the SQL engine;
sql.engine only appeared to work because it also has a
HARPER_SQL_ENGINE env fallback.

Register the four keys in CONFIG_PARAMS (utility/hdbTerms.ts) and read
them via configUtils.getConfigValue(), the same accessor every other
config domain uses. getConfigValue() returns undefined pre-boot rather
than eagerly initializing from disk, preserving the "works without a
fully booted config" property the globalThis branch existed for, and
it self-initializes correctly per worker thread with no new boot hook
to wire in. Delete the globalThis branch entirely.

Switch the three scaffolding test files (join/mutation/aggregate) from
mutating globalThis.harperConfig to configUtils.updateConfigObject(),
the already-sanctioned in-memory config override unit tests use
elsewhere. Add router.test.js coverage proving sql.engine/allowFullScan/
maxSortRows/maxHashRows are actually read from Harper config (not just
the env var), and that the env var still wins for sql.engine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ytbJQ44LZsFyZ1C1fFdNk
Addresses a plan-mode cross-model review (Framing-Verdict:
better-alternative-exists) of the prior commit's accessor-swap fix:

- Add a scoped `sql` Joi schema (validation/configValidator.ts) so a
  malformed sql.* value (bad engine enum, wrong-typed allowFullScan,
  non-positive/non-integer row caps, an unknown key) is rejected loudly
  at boot or on set_configuration, instead of silently keeping the
  default — the top-level schema's allowUnknown:true previously let an
  entire malformed `sql:` section through unvalidated.
- Correct sqlEngine/PLAN.md's stale `sql.engine.allowFullScan` /
  `sql.engine.maxSortRows` / `sql.engine.maxHashRows` phrasing to match
  the actual sibling-key shape SqlEngineConfig has always used — the
  review flagged this as a real doc/code contradiction an operator
  could be misled by.
- Switch the sql.* test scaffolding (join/mutation/aggregate.test.js,
  and this fix's own router.test.js coverage) from blindly resetting to
  `undefined` to snapshot/restore, so a suite doesn't clobber a value
  set by another one sharing the same mocha process.
- Add getSqlEngineConfig() coverage for wrong-typed/unrecognized config
  values (defense-in-depth: Joi's coercion at validate time is never
  written back into flatConfigObj, so the accessor's own typeof guards
  are what actually protect a live read).
- Add registration + set_configuration rejection tests
  (unitTests/config/setConfigurationSql.test.js) and Joi schema tests
  (unitTests/validation/configValidator.test.js), following existing
  precedents (replicationReceiveQueueParam.test.js's registration
  pattern, the blob-gap-floor schema tests) rather than exercising
  setConfiguration()'s full success path, which would write to this
  box's shared on-disk test config.

Deliberately not adopted, with disqualifiers recorded in the PR body's
"For the human reviewer" section: resolving one config snapshot per
SQL statement (the review's hot-path suggestion), and a full HTTP
integration boot test for sql.engine/allowFullScan specifically (the
'auto' engine's legacy fallback masks the config-driven difference at
the HTTP-observable level, so a naive version of that test would pass
on both the fixed and the reverted code).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ytbJQ44LZsFyZ1C1fFdNk
Adopts the concrete, fixable findings from the full pre-push review
(gemini + cursor-composer + Harper domain adjudication) of the
previous two commits:

- sqlSchema now sets convert:false, so a quoted allowFullScan:"true"
  or maxSortRows:"500" in harperdb-config.yaml is REJECTED at boot
  instead of silently passing Joi (which coerces it) and then being
  dropped by getSqlEngineConfig()'s typeof guard — validateConfig()
  never writes the coerced value back into configDoc for sql the way
  it does for threads/logging/storage, so leaving convert:true on
  would have made the new schema's strictness a no-op for exactly the
  scenario it exists to catch.
- Tighten maxSortRows/maxHashRows's defense-in-depth guard from
  typeof === 'number' to isPositiveInteger (rejects NaN/negative/
  fractional caps too — NaN in particular defeats PhysicalSort's
  `buf.length >= cap` guard entirely, since every comparison against
  NaN is false).
- router.test.js: clear the four sql.* keys before each test instead
  of only snapshotting, so the default-value assertions can't go red
  on a machine whose own harper-config.yaml already sets one of them;
  add a flattenConfig() unit test covering the nested-to-flat key
  derivation the other tests bypass via updateConfigObject().
- join.test.js: drop three per-test SQL_ALLOWFULLSCAN=true
  reassignments already covered by the describe's beforeEach.
- Trim added comments that narrated intent/history rather than
  documenting a non-obvious invariant, per Harper's zero-new-comments
  default.

Not adopted, both already covered as open decisions carried into the
PR body's "For the human reviewer": resolving one config snapshot per
SQL statement instead of per-call reads (unchanged from the plan
review — no per-statement context exists at the router/optimizer
layer to hang it on), and registering sql_engine's bare-env-var
reachability (SQL_ENGINE), which the domain leg flagged as colliding
with a common external convention (e.g. Django) — a real, if
graduated, availability risk shared in kind with ~150 other existing
bare CONFIG_PARAMS names, surfaced to the task owner rather than
resolved unilaterally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ytbJQ44LZsFyZ1C1fFdNk
Both the codex and gemini legs of the delta review flagged several
added comments as narrating test/bug history or restating what the
test names already say rather than documenting a non-obvious
invariant. Trims those; keeps the two comments codex specifically
called out as explaining a real invariant (the sql Joi schema's
convert:false rationale, and why the set_configuration rejection test
needs no on-disk config fixture).

Also independently re-verified (not adopted) two other delta-round
gemini findings against the actual code and the passing test suite:
the claimed ReferenceError from bare string/boolean/number in
configValidator.ts (destructured from Joi.types() at the top of the
file — 508 tests exercising that schema all pass) and the claimed
Joi abortEarly:true truncating the maxSortRows/maxHashRows rejection
test (validateConfig() explicitly passes abortEarly: false — the test
asserting both messages together already passes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ytbJQ44LZsFyZ1C1fFdNk
Codex's third delta round flagged the remaining setConfigurationSql.test.js
preamble as restating what the parameterized test names already say.
Also independently verified (not adopted) gemini's round-3 "blocker"
claim that getConfigValue()/flattenConfig() have a casing mismatch —
both explicitly lowercase before the flatConfigObj lookup
(config/configUtils.ts's getConfigValue return line and flattenConfig's
squashObj), and this PR's own flattenConfig() derivation test already
exercises and passes that exact path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ytbJQ44LZsFyZ1C1fFdNk

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request flattens the SQL engine configuration parameters (such as engine, allowFullScan, maxSortRows, and maxHashRows) under the main configuration utility instead of nesting them under globalThis.harperConfig. It updates configuration resolution, validation schemas, and associated unit tests to use these new flat keys. Feedback on the changes suggests importing plain 'node:assert' instead of 'node:assert/strict' in the new test file to comply with the repository's linting rules.

Comment thread unitTests/config/setConfigurationSql.test.js Outdated
@kriszyp kriszyp added this to the v5.3 milestone Sep 3, 2026
gemini-code-assist flagged unitTests/config/setConfigurationSql.test.js's
node:assert/strict import as against repo house style (.gemini/styleguide.md:
plain node:assert + explicit assert.strictEqual/deepStrictEqual). The file
already only calls .strictEqual/.rejects, so the swap is semantically a
no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ytbJQ44LZsFyZ1C1fFdNk
@kriszyp
kriszyp removed the request for review from dawsontoth September 3, 2026 22:09
@kriszyp
kriszyp marked this pull request as ready for review September 3, 2026 22:09
@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Comment thread unitTests/sqlEngine/router.test.js
Snapshot and clear HARPER_SQL_ENGINE alongside the sql.* config keys so external environment settings cannot override the config-integration assertions. Also isolate the existing router tests from a machine-local sql.engine value now that the router reads live Harper config.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Comment thread validation/configValidator.ts Outdated
kriszyp and others added 4 commits September 4, 2026 09:42
…boot

`sql` is now a validated core config section, but the root config namespace is
shared with application entries: `deploy_component project=sql package=x` wrote
`sql: {package: x}` and reported success, and the next restart failed config
validation with no way out but hand-editing the YAML.

Reserve the name at every ingress that creates a component under it — the
deploy/add validators, `set_component_file` (creation only), and
`set_configuration`'s `<component>_package`/`_port` escape, which maps straight
into a root entry without passing through either operation. `force` does not buy
the name: there is no core component to overwrite, only config to break.

An application deployed under the name before it was reserved still boots. The
`sql` entry validates as an application entry when it carries one of the keys a
deploy writes, and as the settings schema otherwise, so a typo'd setting still
fails loudly; boot warns to rename. The two shapes cannot be mixed.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…rved name

Review round 2. `set_env_value` creates the project directory the same way
`set_component_file` does, and `harper deploy setup=true` would seal a credential
for a component name the server then refuses — both now go through the
reservation. The grandfather check treats an unresolvable components root as
"not there" so it fails closed to the reservation instead of erroring, which is
also what the Windows unit job (no ambient install) exercises.

Widens the legacy-application key list to every deployment key componentLoader
reads off a root entry, so a grandfathered entry cannot be mistaken for engine
settings and fail boot. Drops the sinon/rewire tests the house style forbids: the
deploy handler cases now call the real operation, and the file-writer cases pin
a temporary components root and cover both sides of the grandfather check.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…ation

Review round 3. `sql: false` (and `sql:`) is how componentLoader spells a
disabled component, so an operator who had already turned a pre-reservation
`sql` application off would have hit the very boot failure this change exists to
prevent; both are now accepted. The reservation matches case, like the config
param lookup it protects.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…rotects

Review round 4: matching case-insensitively refused a redeploy of an existing
component named `SQL` — a distinct root key that collides with nothing — and did
it with a message naming a configuration section that does not exist.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants