diff --git a/di/config/config.md b/di/config/config.md new file mode 100644 index 00000000..97ee0cc7 --- /dev/null +++ b/di/config/config.md @@ -0,0 +1,265 @@ +# di.config + +Configuration loading and cascade resolution for the modular TorQ world. It replaces +TorQ's in-process config handling (`torq.q`: `loadf`, `loadconfig`, `loadaddconfig`, +`overrideconfig`). `di.config` resolves a settings **cascade** over a builtin root and an +app root — reading **both** flat `name:value` `.q` files and `.toml` files — and returns +the merged configuration as **one flat dict**. `di.torq` calls it once at startup +(`config:cascade[...]`) and hands that dict to each module's `init`. + +## The config model — one flat dict + +`di.config` resolves config into a **returned flat dict**; it does not execute settings +files into namespaces or hold any global store. This is the shape `di.torq` consumes: + +```q +config:use`di.config +cfg:config.cascade[builtinroot;approot;`rdb;`rdb1] / -> `subscribeto`rows`... !(...) +/ di.torq stamps identity and hands the whole dict to each module's init: +cfg:cfg,`proctype`procname!(`rdb;`rdb1) +rdb.init[cfg;`log`timer!(logdep;timerdep)] +``` + +`cascade` and `parsefile` are **pure** — no `init`, no logger, no globals — because +`di.torq` resolves config *before* the logger dependency has been built. Only +`overrideconfig` logs (it reports skipped/rejected overrides), so it — and only it — +requires `init`. + +## Precedence + +The cascade is resolved over **two roots** (`builtinroot`, `approot`) × a **three-tier +name sequence** (`default` → `{proctype}` → `{procname}`). Precedence is **name-major**: + +1. A more specific **name** always wins — `{procname}` beats `{proctype}` beats `default`. +2. **Within a name**, the later (app) root overrides the builtin root. +3. **Within a single tier** (one root + one name), the `.toml` file wins over the `.q` + file on a key clash — useful mid-migration when both formats coexist. + +So a key set in both `builtin/{proctype}` and `app/default` resolves to the **builtin +proctype** value, not the app default (name beats root). Effective load order, low → high +priority: + +``` +builtin/default(.q,.toml) → app/default → builtin/{proctype} → app/{proctype} + → builtin/{procname} → app/{procname} (each tier: .toml overrides .q) +``` + +`overrideconfig` sits on **top** of all of this — the command-line layer (see below). + +> **Note — name-major, not root-major.** The KDBX-POC's own vendored `di.config` used +> *root-major* precedence (all builtin tiers, then all app tiers), where `app/default` +> would beat `builtin/{proctype}`. `di.config` deliberately keeps the **name-major** rule +> TorQ has always used; `.toml` support was layered on without changing it. + +> **Note — avoid `-` in config file paths.** A source-level backtick symbol literal +> containing `-` parses as subtraction (`` `:/a-b.q `` → `` `:/a `` `- b.q`), not one +> symbol. The runtime string paths `cascade` builds internally are unaffected. + +## Settings file formats + +- **`.q`** — plain `name:value` lines. Split on the first `:`; the RHS is run through + `value`, so `` `:hdb ``, `` `trade`quote ``, `` `symbol$() `` all work. Blank lines and + `/`-comment lines are skipped. These are **flat** files (one process's config), **not** + the namespaced, executable settings files legacy TorQ `\l`-loaded. +- **`.toml`** — delegated to **`di.toml`**, loaded lazily (only when a `.toml` file is + actually present). TOML has no symbol type, so every TOML string comes back as a q + string, never a symbol — consumers that need a symbol normalise at the point of use + (`` `$ `` is a no-op on an already-a-symbol value). + +## Import and init + +`cascade` and `parsefile` need no `init` and no logger — call them directly: + +```q +config:use`di.config +cfg:config.cascade["/opt/torqx/di/torq/settings";"/opt/app/settings";`rdb;`rdb1] +``` + +`overrideconfig` logs, so wire a `log` dependency via `init` first — there is no silent +fallback. Pass an already-conforming binary `` `info`warn`error `` dict of `{[c;m]}` +loggers (context symbol, message string); the module performs **no** adaptation. + +```q +/ option 1: di.log — the standard logger (exports binary info/warn/error) +logger:use`di.log +logdep:`info`warn`error!(logger.info;logger.warn;logger.error) +config.init[enlist[`log]!enlist logdep] + +/ option 2: any hand-rolled binary {[c;m]} logger +mylog:`info`warn`error!( + {[c;m] -1 string[c],": INFO ",m;}; + {[c;m] -1 string[c],": WARN ",m;}; + {[c;m] -2 string[c],": ERROR ",m;}); +config.init[enlist[`log]!enlist mylog] +``` + +A logger that is *not* already binary `{[c;m]}` (e.g. a raw monadic +[`kx.log`](https://github.com/KxSystems/logging) instance) must be wrapped by the caller +first — di.config does no wrapping. + +## Exported functions + +| Function | Signature | Description | +|---|---|---| +| `cascade` | `cascade[builtinroot;approot;proctype;procname]` | Resolve the settings cascade over the two roots × the `default`/`{proctype}`/`{procname}` name sequence and **return one flat dict**. Pure — no `init`, no logger, no globals. Precedence is name-major with `.toml > .q` within a tier (see above). A key set nowhere is simply absent; an all-missing cascade returns an empty dict. | +| `parsefile` | `parsefile[path]` | Parse a single settings file into a flat dict. A `.toml` path is delegated to `di.toml` (loaded lazily, behind the guard rail below); a `.q` path is flat `name:value` lines. A missing file (either extension) → empty dict. An existing `.toml` file with no `di.toml` on `QPATH` signals a clear error naming the file. Pure. | +| `overrideconfig` | `overrideconfig[config;params]` | Apply the **command-line override layer** on top of a resolved config dict — the top tier of the cascade. `di.torq` parses the process command line (`.Q.opt .z.x`) and calls this after `cascade`, so launch-time flags (e.g. `-loglevel info`, `-rows 5000`) win over the settings files. `config` is the dict from `cascade`; `params` is a dict keyed by setting name (symbol) with string (or list-of-string) values, each parsed into that setting's **existing** type. Only keys already present with a basic type are overridden; unknown keys, non-basic types, and unparseable values are logged and skipped. Returns the **updated config dict**. Requires `init`. | +| `init` | `init[deps]` | Wire the injected logger for `overrideconfig`. `deps` is a dict with a required `log` key — an already-conforming binary `` `info`warn`error `` dict of `{[c;m]}` functions. Errors immediately if the log dependency is missing or malformed. | +| `getapimeta` | `getapimeta[]` | This module's API metadata — one `(name;public;descrip;params;return)` row per exported function, for `di.torq` to register with `di.api`. | + +Internal helpers — `parsetier` (per-tier `.q`+`.toml` merge), `applyoverride`, +`parsefailed`, and `hexchars` — are deliberately not exported. + +## Injectable dependencies + +| Injectable | Required keys | Function signature | +|---|---|---| +| `log` | `` `info`warn`error `` | `{[c;m]}` (context symbol, message string). Caller passes an already-conforming binary dict — built from `di.log` or hand-rolled. No adaptation is done in the module. Needed only by `overrideconfig`. | + +## Hard dependencies + +None mandatory. `cascade`/`parsefile` gain a **lazy, soft dependency on `di.toml`**: it is +`` use``d only when a `.toml` file is actually encountered, so the `.q`-only path needs +nothing. **di.toml does not need to be in the repo (or on `QPATH`) for di.config to load or +run** — it is required only at the moment a `.toml` settings file is actually parsed. + +### The `.toml` guard rail + +`parsefile` guards the di.toml load so a missing module produces a clear, actionable error +rather than a cryptic `notfound`. The order is deliberate: + +1. **Check the file exists first.** A missing file (either extension) → empty dict. So a + missing `.toml` tier *never* triggers the di.toml requirement — the internal + `parsetier`/`cascade` probe `base,".toml"` on every tier, and absent tiers cost nothing. +2. **Then, only for a `.toml` file that actually exists, require di.toml.** The internal + `requiretoml` helper attempts `` use`di.toml `` under protected evaluation; if it is not + found, it signals a clear error **naming the offending file**: + ``` + 'di.config: cannot parse TOML file '/path/to/x.toml' - the di.toml module was not found + on QPATH; a di.toml module is required to parse .toml settings (underlying: notfound: di.toml) + ``` +3. **Parse.** With di.toml resolved, the file is delegated to it. + +Because this signal happens at config-resolution time — *before* the logger dependency is +wired — it surfaces via the raised error (which aborts startup with the reason), not via the +injected logger. + +### Contract di.config expects from di.toml + +A di.toml module must export a **`parsefile`** function taking a settings-file path string +and returning a **flat dict** of `setting → value`: + +```q +(use`di.toml)[`parsefile] "/path/to/settings.toml" / -> `dir`rows!(":appdb";100) +``` + +TOML has no symbol type, so di.toml returns every TOML string as a **q char string** (never +a symbol); di.config applies no coercion, so consumers that need a symbol normalise at the +point of use (`` `$ `` is a no-op on an already-a-symbol value). + +> **⚠️ `di.toml` is not yet in kdbx-modules** — it currently lives only in the KDBX-POC and +> lands here with the PoC merge (a di.toml module is in development against the contract +> above). Until it is on `QPATH`, the `.toml` half of a tier is **dormant** in this repo: a +> `.toml` file that exists but cannot be parsed raises the guard error above; the `.q` path +> works standalone. Full `.toml` resolution is verified in the PoC (where `di.toml` is +> vendored); this repo's suite covers the guard's error path (di.toml absent) and the +> file-exists-before-delegation ordering. + +## Design notes & open gaps + +- **Returned dict, not a global store.** Resolution is a pure function returning a dict. + There is no queryable config store and no per-namespace `getmodule` — `di.torq` passes the + whole resolved dict to every module and each reads the keys it cares about. A missing key + is a caller bug (surfaced as a q null on an inline index), not something the config layer + silently papers over — every setting is expected to be booted with a default in the + `default` tier. +- **Retired: the executable-namespaced `.q` path.** An earlier revision also shipped a + `loadcascade`/`loadconfig`/`getmodule` path that `\l`-loaded namespaced, code-bearing `.q` + settings files (`.rdb.subscribeto:...`) into root namespaces and sliced them per module. + That parallel model was **removed** in favour of the single flat-dict cascade `di.torq` + actually consumes. If runtime config reload or an in-process store is ever needed, that is + a deliberate, separately-designed addition — not a silent revival of the globals path. +- **`overrideconfig` is the command-line layer, applied by `di.torq` — not a manual step.** + It mirrors TorQ's `overrideconfig`/`override[]`, which TorQ runs at startup off + `.Q.opt .z.x` so launch-time flags beat the settings files. di.config just does the + type-aware apply onto the resolved dict (which is why it stays env-free and never reads + `.z.x` itself). It deviates from TorQ deliberately on error handling: per-setting problems + (unknown name, non-basic type, value that won't parse to the target type) are logged and + skipped — a single bad override never aborts the batch and a null is never written. +- **`overrideconfig` parse-failure detection is type-aware.** Most basic types parse to a + null on a bad value, which `applyoverride` rejects via a null check. Boolean (`1h`) and + byte (`4h`) are the only in-scope basic types with **no null** — `"B"$"bad"` yields `0b`, + `"X"$"gg"` yields `0x00`, both non-null — so a bad value would slip past a null check and + silently corrupt config. `parsefailed` handles these two explicitly (boolean must be + `"0"`/`"1"`; byte must be even-length hex); anything else is rejected and logged, never + written. +- **String-typed (`10h`) settings are overridable — as text, no parse.** A char-string + setting's override value *is* already a string, so `applyoverride` takes it as-is rather + than casting it (any string is valid; there is no parse-failure case). This matters because + TOML has no symbol type, so a `.toml`-origin setting like `dir = ":appdb"` or + `loglevel = "info"` comes back as a string — without this, such settings could not be + command-line-overridden at all, whereas their `.q`-origin symbol equivalents (`` dir:`:appdb ``) + could. di.config stays policy-free: it preserves whatever type the setting already had + (string in, string out; symbol in, symbol out), leaving symbol-vs-string normalisation to + the consumer as everywhere else. +- **The sentinel merge key.** `cascade`'s accumulator is seeded with a sentinel + `` (enlist`)!enlist(::) `` key so its value list stays general from the first merge — a + same-typed value list coalesces to a typed vector that `,:` then refuses to widen (e.g. + adding a `` `symbol$() `` key to a dict whose values so far were all plain symbols). The + sentinel is dropped before the dict is returned. +- **Logging uses the three-flat-var convention** — `.z.m.loginfo`/`.z.m.logwarn`/`.z.m.logerr`, + called as `.z.m.loginfo[\`ctx;"msg"]` — matching `consistency.md` and `di.compression`. The + injected `log` value is still the same `` `info`warn`error `` dict; `init` fans it out into + the three module-local vars. + +## Tests + +```q +k4unit:use`di.k4unit +k4unit.moduletest`di.config +``` + +The `.toml` path is exercised in the KDBX-POC (where `di.toml` is vendored), not here — +see the hard-dependencies note above. + +## Follow-ups (deferred until other modules land) + +di.config is complete for v1 in isolation. The items below are intentionally deferred; +each is keyed to the module or milestone that unblocks it. + +### ⚠️ Logging call convention — NOT set in stone (ask before changing) + +di.config uses the **three-flat-var** convention (`.z.m.loginfo`/`.z.m.logwarn`/`.z.m.logerr`), +matching `consistency.md` and `di.compression`, but the project hasn't finalised this versus +the **single-dict** form (`.z.m.log[\`info][…]`). **Before changing di.config's logging — or +wiring logging in another module — stop and ask the user which convention is authoritative.** +Switching is mechanical (storage + call sites only; the injected input contract is identical). + +### When `di.toml` lands in kdbx-modules (with the PoC merge) + +- **Add committed `.toml` tests.** Today `di.toml` is not on this repo's `QPATH`, so a + committed `.toml`/mixed-tier test would fail here (verified in the PoC instead). Once + `di.toml` resolves, add tests covering: a `.toml` tier parsed into the dict, `.toml` + winning over `.q` within a tier, and a mixed `.q`+`.toml` cascade. + +### When `di.log` merges to main + +- **Add a committed `di.log` integration test.** The contract match (binary + `` `info`warn`error `` dict) is verified manually and stood in for by the kx.log-wrapper + emission test. Once `di.log` resolves on `QPATH`, add a test that builds the dict from + `di.log` and drives `overrideconfig` through it. + +### When `di.torq` is built + +- **Prove the end-to-end flow.** Add a multi-module integration test (under + `di.torq`/`di.inttest`, not here) exercising: `di.torq` resolves `KDBCONFIG`/`KDBAPPCONFIG` + → roots and `proctype`/`procname` → identity, calls `cascade`, then `overrideconfig` with + the command-line params, then hands the resolved dict to each module's `init`. +- **Hold the env-free boundary.** di.config reads no env vars or process identity — di.torq + owns that resolution and passes the roots and identity explicitly. + +### When `di.depcheck` is built (versioning rollout) + +- **Add a `version` export and `deps.q`.** When `di.depcheck` lands, add `version:"…"` to the + export and a minimal `deps.q` (di.config has no hard deps) as part of the coordinated + repo-wide rollout, not a di.config-only change. diff --git a/di/config/config.q b/di/config/config.q new file mode 100644 index 00000000..9a1b8b07 --- /dev/null +++ b/di/config/config.q @@ -0,0 +1,188 @@ +/ configuration loading and cascade resolution for the modular torq world - replaces +/ torq.q's loadf/loadconfig/loadaddconfig/overrideconfig. the module resolves a settings +/ cascade over a builtin root and an app root, reading BOTH flat name:value .q files and +/ .toml files, and RETURNS the merged config as a single flat dict - the shape di.torq +/ consumes (config:cascade[...] straight after use`di.config, then passed to each module's +/ init). the module reads no env/identity - the caller (di.torq) supplies the roots and the +/ process identity (proctype/procname). +/ public api: cascade (resolve the cascade over two roots x default/proctype/procname -> +/ merged flat dict), parsefile (parse one .q or .toml settings file -> flat dict), and +/ overrideconfig (apply the command-line override layer on top of the resolved dict). +/ precedence is name-major (a more specific NAME wins; within a name the later/app root wins) +/ and, within a single tier, .toml wins over .q on a key clash (useful mid-migration). +/ NOTE: di.toml is not yet in kdbx-modules (it lands with the PoC merge) - the .toml half of +/ a tier stays dormant here (parsefile delegates .toml to di.toml lazily, only when a .toml +/ file actually exists); the .q path needs it never. +/ cascade/parsefile are PURE - no init, no logger, no globals - because di.torq resolves +/ config before the logger dependency is even built. overrideconfig logs (it is the one place +/ that reports skipped/rejected overrides), so it - and only it - requires init. + +init:{[deps] + / wire the injected logger - required for overrideconfig, no silent fallback. deps: a dict + / with a `log key holding a binary `info`warn`error dict of {[c;m]} loggers (from di.log or + / hand-rolled). no adaptation here, so a monadic kx.log instance must be wrapped first. + / cascade/parsefile do not need init; overrideconfig does. e.g. config.init[enlist[`log]!enlist logdep] + if[99h<>type deps; + '"di.config: deps must be a dict with `log key"]; + if[not `log in key deps; + '"di.config: log dependency is required; pass `info`warn`error functions keyed on `log"]; + if[99h<>type deps`log; + '"di.config: log value must be a dict; pass `info`warn`error functions"]; + if[not all (`info`warn`error) in key deps`log; + '"di.config: log dict must have `info`warn`error keys; got: ",(", " sv string key deps`log)]; + .z.m.loginfo:deps[`log]`info; + .z.m.logwarn:deps[`log]`warn; + .z.m.logerr:deps[`log]`error; + .z.m.loginfo[`init;"di.config initialised"]; + }; + +/ --- settings cascade resolution (.q + .toml -> flat dict) --- + +requiretoml:{[path] + / internal - a .toml settings file (path) exists and must be parsed, which needs the di.toml + / module. di.toml is a SOFT dependency: it is not required to be in the repo/on QPATH and the + / .q-only path never touches it - it is needed ONLY once a .toml file actually appears. guard + / the load so a missing di.toml gives a clear, actionable error naming the file, instead of the + / cryptic `notfound: di.toml` that a bare use would raise. returns the resolved di.toml module. + :@[use;`di.toml;{[p;e] + msg:"di.config: cannot parse TOML file '",p,"' - the di.toml module was not found on QPATH; "; + msg,:"a di.toml module is required to parse .toml settings (underlying: ",e,")"; + 'msg + }[path;]]; + }; + +parsefile:{[path] + / parse ONE settings file into a flat dict. a .toml path is delegated to di.toml (via the + / requiretoml guard), loaded lazily - only when a .toml file actually appears, so the .q-only + / path never needs it. a .q file is plain `name:value` lines: split on the first ":", the RHS + / run through value (so `:hdb, `trade`quote, `symbol$() all work); blank and "/"-comment lines + / are skipped. a missing file of either extension contributes nothing (empty dict), so the + / cascade needs no presence checks. order matters: existence is checked FIRST, so a missing + / .toml tier never triggers the di.toml requirement; only a .toml file that actually EXISTS + / requires di.toml - if it is absent, requiretoml signals a clear error (di.toml lives only in + / the PoC today; the .q-only path needs it never). + fsym:`$":",path; + if[0=count key fsym; :()!()]; + if[path like "*.toml"; :(requiretoml[path])[`parsefile] path]; + lines:read0 fsym; + lines:lines where 00b, "X"$"gg"->0x00) slips past a null check and + / would corrupt config - validate their string form explicitly. other types null on failure. + :$[1h=abs t;not all raw in (enlist"0";enlist"1"); + 4h=abs t;not all {(0=count[x] mod 2) and all x in hexchars} each raw; + any null vals]; + }; + +applyoverride:{[name;cur;raw] + / internal - parse raw value(s) into cur's type and return (applied;newvalue). cur is the + / setting's current value from the config dict; its type drives the parse. raw is a string or + / list of strings. applied is 0b (and newvalue is cur, unchanged) if cur is not a basic type + / or a value failed to parse. + t:type cur; + if[not (abs t) within (1;-1+count .Q.t); + .z.m.logerr[`overrideconfig;"cannot override ",(string name),": not a basic type"]; + :(0b;cur); + ]; + raw:$[10h=type raw;enlist raw;raw]; + / a char-string (10h) setting is already text - take the override string as-is (nothing to + / parse, and any string is valid). this makes .toml-origin string settings overridable, the + / same way symbol (.q-origin) settings already are; di.config stays policy-free by preserving + / whatever type the setting already had. + if[10h=t; + vals:first raw; + .z.m.loginfo[`overrideconfig;"setting ",(string name)," to ",-3!vals]; + :(1b;vals); + ]; + vals:(upper .Q.t abs t)$'raw; + if[parsefailed[t;raw;vals]; + .z.m.logerr[`overrideconfig;"cannot override ",(string name),": value did not parse"]; + :(0b;cur); + ]; + / reduce to scalar only after parsefailed (which checks the full per-element list); runs + / before the log and return so both use the scalar form. + if[t<0;vals:first vals]; + .z.m.loginfo[`overrideconfig;"setting ",(string name)," to ",-3!vals]; + :(1b;vals); + }; + +overrideconfig:{[config;params] + / apply the command-line override layer on top of a resolved config dict, parsing each value + / into the matching setting's existing type. this is the TOP tier of the cascade - di.torq + / parses the process command line (.Q.opt .z.x) and calls this after cascade, so launch-time + / flags win over the settings files. config is the flat dict from cascade; params a dict keyed + / by setting name (symbol) with string (or list-of-string) values. only keys already present in + / config with a basic type are overridden; unknown keys, non-basic types, and unparseable + / values are logged and skipped (a single bad override never aborts the batch and a null is + / never written into config). returns the UPDATED config dict. + if[99h<>type config; + .z.m.logerr[`overrideconfig;err:"di.config: config must be a dict"]; + 'err; + ]; + if[99h<>type params; + .z.m.logerr[`overrideconfig;err:"di.config: params must be a dict keyed by setting name"]; + 'err; + ]; + vars:key params; + if[0 merged flat dict (name-major, .toml>.q)"; "[string: builtinroot; string: approot; symbol: proctype; symbol: procname]"; "dict: merged setting -> value"); + (`overrideconfig; 1b; "apply the command-line override layer onto a resolved config dict"; "[dict: resolved config; dict: setting name (symbol) -> string or list of strings]"; "dict: updated config"); + (`parsefile; 1b; "parse one .q or .toml settings file into a flat dict"; "[string: file path (.q flat name:value, or .toml via di.toml)]"; "dict: setting -> value (empty if the file is missing)"); + (`getapimeta; 0b; "this module's api metadata rows"; "[]"; "table: metadata rows")); + }; diff --git a/di/config/init.q b/di/config/init.q new file mode 100644 index 00000000..2e06d014 --- /dev/null +++ b/di/config/init.q @@ -0,0 +1,3 @@ +/ configuration loading and cascade resolution for the modular torq world. +\l ::config.q +export:([init;cascade;overrideconfig;parsefile;getapimeta]) diff --git a/di/config/test.csv b/di/config/test.csv new file mode 100644 index 00000000..52a22cd6 --- /dev/null +++ b/di/config/test.csv @@ -0,0 +1,70 @@ +action,ms,bytes,lang,code,repeat,minver,comment +before,0,0,q,cfg:use`di.config,1,1,load the module +before,0,0,q,"logtab:([]lvl:`symbol$();ctx:`symbol$();msg:())",1,1,capturing log table +before,0,0,q,"mocklog:`info`warn`error!({[c;m]`logtab upsert(`info;c;m)};{[c;m]`logtab upsert(`warn;c;m)};{[c;m]`logtab upsert(`error;c;m)})",1,1,define a capturing binary logger +before,0,0,q,cfg.init[enlist[`log]!enlist mocklog],1,1,init with the required log dependency +before,0,0,q,"system""mkdir -p /tmp/diconfigbuiltin""",1,1,builtin root for the cascade +before,0,0,q,"system""mkdir -p /tmp/diconfigapp""",1,1,app root for the cascade +before,0,0,q,"(`:/tmp/diconfigbuiltin/default.q) 0: (""diconfiga:1"";""diconfigb:1"")",1,1,builtin/default flat name:value (two settings) +before,0,0,q,"(`:/tmp/diconfigbuiltin/rdb.q) 0: enlist ""diconfigb:10""",1,1,builtin/rdb proctype tier sets diconfigb +before,0,0,q,"(`:/tmp/diconfigapp/default.q) 0: (""diconfiga:100"";""diconfigb:5"")",1,1,app/default flat name:value (two settings) +before,0,0,q,"(`:/tmp/diconfigapp/rdb1.q) 0: enlist ""diconfiga:999""",1,1,app/rdb1 procname tier sets diconfiga +before,0,0,q,"basecfg:`enabled`rows`syms`tab`byte`str!(0b;100;`a`b;([]a:`long$());0x01;"":olddb"")",1,1,base config dict for the overrideconfig tests (str is a char-string setting, as a .toml value would be) +before,0,0,q,"system""mkdir -p /tmp/diconfigtoml""",1,1,dir for the toml-guard test +before,0,0,q,"(`:/tmp/diconfigtoml/present.toml) 0: enlist ""a = 1""",1,1,a .toml file that exists but cannot be parsed without di.toml (absent in kdbx-modules) +comment,,,,,,,init - dependency validation +fail,0,0,q,cfg.init[(::)],1,1,init rejects a non-dictionary deps +fail,0,0,q,cfg.init[enlist[`foo]!enlist 1],1,1,init rejects deps without a log key +fail,0,0,q,cfg.init[enlist[`log]!enlist 42],1,1,init rejects a non-dict log value +fail,0,0,q,cfg.init[enlist[`log]!enlist ((enlist`info)!enlist {[c;m]})],1,1,init rejects a log dict missing warn/error +comment,,,,,,,parsefile - parse one flat .q settings file into a dict (.toml delegated to di.toml) +true,0,0,q,"1~cfg.parsefile[""/tmp/diconfigbuiltin/default.q""]`diconfiga",1,1,parsefile reads a flat name:value .q file into a dict +true,0,0,q,"2=count cfg.parsefile[""/tmp/diconfigbuiltin/default.q""]",1,1,parsefile returns every setting in the file +true,0,0,q,"0=count cfg.parsefile[""/tmp/diconfignope/default.q""]",1,1,parsefile returns an empty dict for a missing .q file +comment,,,,,,,parsefile - .toml guard rail (di.toml is a soft dep; absent in kdbx-modules so the error path is exercised here) +true,0,0,q,"0=count cfg.parsefile[""/tmp/diconfignope/absent.toml""]",1,1,a MISSING .toml contributes nothing and does NOT require di.toml (existence checked before delegation) +fail,0,0,q,"cfg.parsefile[""/tmp/diconfigtoml/present.toml""]",1,1,an EXISTING .toml with no di.toml on QPATH signals rather than parsing +true,0,0,q,"(@[cfg.parsefile;""/tmp/diconfigtoml/present.toml"";{x}]) like ""*di.toml module was not found*""",1,1,the .toml guard error names the missing di.toml module +comment,,,,,,,cascade - merged flat dict over two roots x default/proctype/procname (name-major; .toml>.q within a tier) +true,0,0,q,"100~(cfg.cascade[""/tmp/diconfigbuiltin"";""/tmp/diconfigapp"";`rdb;`rdbnofile])`diconfiga",1,1,within a name the later (app) root overrides the builtin root +true,0,0,q,"10~(cfg.cascade[""/tmp/diconfigbuiltin"";""/tmp/diconfigapp"";`rdb;`rdbnofile])`diconfigb",1,1,name-major: builtin proctype beats app default (would be 5 under root-major) +true,0,0,q,"999~(cfg.cascade[""/tmp/diconfigbuiltin"";""/tmp/diconfigapp"";`rdb;`rdb1])`diconfiga",1,1,name-major: the procname tier (app/rdb1) wins over every less-specific name +true,0,0,q,"10~(cfg.cascade[""/tmp/diconfigbuiltin"";""/tmp/diconfigapp"";`rdb;`rdb1])`diconfigb",1,1,a key absent from the procname tier keeps its most-specific set value (builtin proctype) +true,0,0,q,"not (`) in key cfg.cascade[""/tmp/diconfigbuiltin"";""/tmp/diconfigapp"";`rdb;`rdb1]",1,1,the sentinel merge key is dropped from the returned dict +true,0,0,q,"0=count cfg.cascade[""/tmp/diconfignope1"";""/tmp/diconfignope2"";`rdb;`rdb1]",1,1,an all-missing cascade resolves to an empty dict +comment,,,,,,,overrideconfig - command-line override layer applied onto a resolved config dict +true,0,0,q,"1b~(cfg.overrideconfig[basecfg;`enabled`rows!(enlist""1"";enlist""500"")])`enabled",1,1,bool parsed and set from string +true,0,0,q,"500~(cfg.overrideconfig[basecfg;`enabled`rows!(enlist""1"";enlist""500"")])`rows",1,1,long parsed and set from string +true,0,0,q,"basecfg~cfg.overrideconfig[basecfg;()!()]",1,1,empty params returns the config unchanged +true,0,0,q,"0b~(cfg.overrideconfig[basecfg;enlist[`enabled]!enlist ""bad""])`enabled",1,1,unparseable boolean is rejected - the value is left unchanged (not silently set to 0b via a null) +true,0,0,q,"any (exec msg from logtab) like ""*did not parse*""",1,1,rejected boolean override is logged +true,0,0,q,"0xff~(cfg.overrideconfig[basecfg;enlist[`byte]!enlist ""ff""])`byte",1,1,valid hex byte parsed and applied +true,0,0,q,"0x01~(cfg.overrideconfig[basecfg;enlist[`byte]!enlist ""gg""])`byte",1,1,invalid hex byte is rejected (no null - must not slip through as 0x00) +true,0,0,q,"`xx`yy~(cfg.overrideconfig[basecfg;enlist[`syms]!enlist (""xx"";""yy"")])`syms",1,1,symbol list parsed and set from strings +true,0,0,q,""":newdb""~(cfg.overrideconfig[basecfg;enlist[`str]!enlist "":newdb""])`str",1,1,a string-typed setting is overridden as-is (this is what makes .toml string settings overridable) +true,0,0,q,"10h~type (cfg.overrideconfig[basecfg;enlist[`str]!enlist "":newdb""])`str",1,1,string override preserves the string (10h) type +true,0,0,q,"not `nope in key cfg.overrideconfig[basecfg;enlist[`nope]!enlist enlist""1""]",1,1,an unknown setting is not added to the config dict +true,0,0,q,"(key basecfg)~key cfg.overrideconfig[basecfg;enlist[`nope]!enlist enlist""1""]",1,1,an unknown setting leaves the config keys unchanged +true,0,0,q,"any (exec msg from logtab) like ""*unknown setting*""",1,1,unknown setting logged +true,0,0,q,"(cfg.overrideconfig[basecfg;enlist[`tab]!enlist enlist""1""])[`tab]~basecfg`tab",1,1,a non-basic-type setting (table) is left unchanged +true,0,0,q,"any (exec msg from logtab) like ""*not a basic type*""",1,1,non-basic-type setting logged +fail,0,0,q,cfg.overrideconfig[42;()!()],1,1,overrideconfig rejects a non-dict config +fail,0,0,q,cfg.overrideconfig[basecfg;42],1,1,overrideconfig rejects non-dict params +fail,0,0,q,"cfg.overrideconfig[basecfg;enlist[1]!enlist enlist""x""]",1,1,overrideconfig rejects non-symbol params keys +comment,,,,,,,getapimeta - module api metadata +true,0,0,q,(asc key cfg)~asc exec name from cfg.getapimeta[],1,1,getapimeta documents exactly the module's exports +true,0,0,q,`name`public`descrip`params`return~cols cfg.getapimeta[],1,1,getapimeta rows carry the registry columns +true,0,0,q,1b~(1!cfg.getapimeta[])[`cascade]`public,1,1,a public API function (cascade) is marked public +true,0,0,q,0b~(1!cfg.getapimeta[])[`init]`public,1,1,framework plumbing (init) is not public +comment,,,,,,,logger emission - real logger via a caller-side binary wrapper (must run last; re-inits cfg) +run,0,0,q,kxlogger:use`kx.log,1,1,load the kx.log module +run,0,0,q,kxinst:kxlogger.createLog[],1,1,create a real kx.log instance +run,0,0,q,kxh:hopen `:/tmp/diconfigkxsink.txt,1,1,open a file sink to capture output +run,0,0,q,kxinst.add[kxh;`info],1,1,route info-level output to the file sink +run,0,0,q,"kxbinary:`info`warn`error!({[c;m]kxinst[`info][string[c],"": "",m]};{[c;m]kxinst[`warn][string[c],"": "",m]};{[c;m]kxinst[`error][string[c],"": "",m]})",1,1,wrap the monadic logger into the binary {[c;m]} contract +run,0,0,q,cfg.init[enlist[`log]!enlist kxbinary],1,1,re-init di.config with the conforming binary logger +run,0,0,q,"kxres:cfg.overrideconfig[enlist[`kxrows]!enlist 5;enlist[`kxrows]!enlist enlist""7""]",1,1,drive a real info-level log through the wrapped logger +run,0,0,q,hclose kxh,1,1,flush and close the file sink +true,0,0,q,7~kxres`kxrows,1,1,the override was applied (returned dict carries the new value) +true,0,0,q,"any (read0 `:/tmp/diconfigkxsink.txt) like ""*overrideconfig: setting*""",1,1,the override emitted an info line through the real logger +true,0,0,q,"any (read0 `:/tmp/diconfigkxsink.txt) like ""*kxrows*""",1,1,emitted message names the overridden setting