Skip to content

Commit 6c05131

Browse files
committed
Merge branch 'plan-46': Plan 46 — context-via-substream resolution + matchesEvent
Slices 1 + 4. See CHANGELOG.md entry [0.10.0] for details.
2 parents ae884ce + 6959abf commit 6c05131

7 files changed

Lines changed: 276 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## [Unreleased]
44

5+
## [0.10.0] - 2026-04-30
6+
7+
### Added (plan 46 — context-via-substream resolution + new public API)
8+
- **`HDSModelItemsDefs.forEvent()`** — when no direct `(streamId, eventType)` match is found across `event.streamIds`, falls back to walking parents from `event.streamIds[0]` looking for a registered itemDef with the matching eventType. Closest ancestor wins. Lets a single itemDef registered at e.g. `treatment` resolve events placed at `treatment-fertility`, `treatment-oncology`, … without per-domain item definitions. Reuses the existing `streams.getParentsIds` chain helper consumed by `HDSModelAuthorizations`.
9+
- **`HDSItemDef.eventTemplate({ context })`** — optional `context` streamId per Plan 46 §2.1 (D3). Validates context is `itemDef.streamId` or a descendant; emits length-1 `streamIds: [context ?? streamId]`. Throws on context outside the itemDef's subtree.
10+
- **`HDSItemDef` constructor takes optional `model: HDSModel`** — used by `eventTemplate` to validate descendant streamIds via `model.streams.getParentsIds`. Backward-compatible: callers that build `HDSItemDef` directly without the model still work; they just can't use the `context` option.
11+
- **`HDSModel.loadFromObject(data, overload?)`** — loads model from an in-memory object, skipping `fetch`. Useful for tests, embedded apps, or environments where `fetch` can't reach the model URL (e.g. Node's `fetch` does not yet implement `file://`). `load()` is reimplemented in terms of `loadFromObject`.
12+
13+
### Notes
14+
- D3's walk-up is the third application of the same closest-ancestor algorithm in this lib: Plan 45's `resolveStream.ts` (account-level `clientData` lookup) and `HDSModelAuthorizations` (parent-covers-child de-dup) are the existing precedents. D3 applies the algorithm at the data-model itemDef layer.
15+
- 11 new `[CTXR]` tests in `tests/contextResolution.test.js` exercising the resolution + creation paths plus the legacy multi-streamId case.
16+
517
## [0.9.1] - 2026-04-28
618

719
### Fixed — `CollectorRequestSection` round-trip for `customFieldKeys[]`

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hds-lib",
3-
"version": "0.9.1",
3+
"version": "0.10.0",
44
"description": "Health Data Safe - Library",
55
"type": "module",
66
"engines": {

tests/contextResolution.test.js

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import { HDSModel } from '../ts/HDSModel/HDSModel.ts';
3+
import fs from 'fs';
4+
import path from 'path';
5+
6+
/**
7+
* Plan 46 §2.1 — Context-via-substream resolution (D3).
8+
*
9+
* Validates the data-model + hds-lib-js implementation end-to-end by loading
10+
* the locally-built data-model pack.json (so it includes the new treatment-* /
11+
* procedure-* items registered at parent streams with -fertility context
12+
* children). Uses loadFromObject to bypass fetch, since Node's fetch does
13+
* not support file:// URLs.
14+
*/
15+
const localPackPath = path.resolve(
16+
process.cwd(),
17+
'../../data-model/data-model/dist/pack.json'
18+
);
19+
20+
describe('[CTXR] Context-via-substream (Plan 46 D3)', () => {
21+
let model;
22+
before(() => {
23+
const packJson = JSON.parse(fs.readFileSync(localPackPath, 'utf8'));
24+
model = new HDSModel('local-test');
25+
model.loadFromObject(packJson);
26+
});
27+
28+
describe('forEvent — walk-up resolution', () => {
29+
it('[CTXR-A] direct (streamId, eventType) match still works', () => {
30+
const itemDef = model.itemsDefs.forEvent({
31+
type: 'treatment/basic',
32+
streamIds: ['treatment']
33+
});
34+
assert.equal(itemDef.key, 'treatment-basic');
35+
});
36+
37+
it('[CTXR-B] descendant streamId resolves via parent walk-up', () => {
38+
const itemDef = model.itemsDefs.forEvent({
39+
type: 'treatment/basic',
40+
streamIds: ['treatment-fertility']
41+
});
42+
assert.equal(itemDef.key, 'treatment-basic');
43+
});
44+
45+
it('[CTXR-C] coded variant resolves via walk-up from procedure-fertility', () => {
46+
const itemDef = model.itemsDefs.forEvent({
47+
type: 'procedure/coded-v1',
48+
streamIds: ['procedure-fertility']
49+
});
50+
assert.equal(itemDef.key, 'procedure-coded');
51+
});
52+
53+
it('[CTXR-D] returns null when type+stream cross trees with no match', () => {
54+
const itemDef = model.itemsDefs.forEvent(
55+
{ type: 'procedure/coded-v1', streamIds: ['treatment-fertility'] },
56+
false
57+
);
58+
assert.equal(itemDef, null);
59+
});
60+
61+
it('[CTXR-E] throws by default when nothing resolves', () => {
62+
assert.throws(
63+
() => model.itemsDefs.forEvent({ type: 'no-such/type', streamIds: ['treatment'] }),
64+
/Cannot find definition/
65+
);
66+
});
67+
});
68+
69+
describe('eventTemplate({ context })', () => {
70+
it('[CTXR-F] no context falls back to itemDef streamId', () => {
71+
const itemDef = model.itemsDefs.forKey('treatment-basic');
72+
const tmpl = itemDef.eventTemplate();
73+
assert.deepEqual(tmpl.streamIds, ['treatment']);
74+
assert.equal(tmpl.type, 'treatment/basic');
75+
});
76+
77+
it('[CTXR-G] context equal to itemDef streamId emits same value', () => {
78+
const itemDef = model.itemsDefs.forKey('treatment-basic');
79+
const tmpl = itemDef.eventTemplate({ context: 'treatment' });
80+
assert.deepEqual(tmpl.streamIds, ['treatment']);
81+
});
82+
83+
it('[CTXR-H] descendant context produces length-1 streamIds with that context', () => {
84+
const itemDef = model.itemsDefs.forKey('procedure-basic');
85+
const tmpl = itemDef.eventTemplate({ context: 'procedure-fertility' });
86+
assert.deepEqual(tmpl.streamIds, ['procedure-fertility']);
87+
assert.equal(tmpl.type, 'procedure/basic');
88+
});
89+
90+
it('[CTXR-I] context outside subtree throws', () => {
91+
const itemDef = model.itemsDefs.forKey('treatment-basic');
92+
assert.throws(
93+
() => itemDef.eventTemplate({ context: 'procedure-fertility' }),
94+
/not a descendant/
95+
);
96+
});
97+
98+
it('[CTXR-J] unknown context streamId throws', () => {
99+
const itemDef = model.itemsDefs.forKey('treatment-basic');
100+
assert.throws(
101+
() => itemDef.eventTemplate({ context: 'no-such-stream' }),
102+
/not a descendant/
103+
);
104+
});
105+
});
106+
107+
describe('legacy multi-streamId resolution still works (no regression)', () => {
108+
it('[CTXR-K] event with multiple streamIds resolves via direct match', () => {
109+
// bridge-athenahealth pattern: streamIds carry both the canonical home
110+
// and a secondary index stream. Resolution should succeed via direct
111+
// match on either streamId, before walk-up triggers.
112+
const itemDef = model.itemsDefs.forEvent({
113+
type: 'treatment/basic',
114+
streamIds: ['treatment-fertility', 'treatment']
115+
});
116+
assert.equal(itemDef.key, 'treatment-basic');
117+
});
118+
});
119+
120+
describe('matchesEvent (D3-aware event matching)', () => {
121+
it('[CTXR-L] direct (streamId, eventType) match returns true', () => {
122+
const itemDef = model.itemsDefs.forKey('treatment-basic');
123+
assert.equal(itemDef.matchesEvent({
124+
type: 'treatment/basic',
125+
streamIds: ['treatment']
126+
}), true);
127+
});
128+
129+
it('[CTXR-M] descendant context resolves via walk-up returns true', () => {
130+
const itemDef = model.itemsDefs.forKey('treatment-coded');
131+
assert.equal(itemDef.matchesEvent({
132+
type: 'treatment/coded-v1',
133+
streamIds: ['treatment-fertility']
134+
}), true);
135+
});
136+
137+
it('[CTXR-N] cross-tree mismatch returns false', () => {
138+
const itemDef = model.itemsDefs.forKey('treatment-basic');
139+
assert.equal(itemDef.matchesEvent({
140+
type: 'procedure/basic',
141+
streamIds: ['procedure-fertility']
142+
}), false);
143+
});
144+
145+
it('[CTXR-O] missing streamIds or type returns false', () => {
146+
const itemDef = model.itemsDefs.forKey('treatment-basic');
147+
assert.equal(itemDef.matchesEvent({}), false);
148+
assert.equal(itemDef.matchesEvent({ type: 'treatment/basic' }), false);
149+
assert.equal(itemDef.matchesEvent({ streamIds: ['treatment'] }), false);
150+
});
151+
});
152+
});

ts/HDSModel/HDSItemDef.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { localizeText } from '../localizeText.ts';
2+
import type { HDSModel } from './HDSModel.ts';
23

34
export interface ReminderConfig {
45
cooldown?: string;
@@ -11,10 +12,18 @@ export interface ReminderConfig {
1112
export class HDSItemDef {
1213
#data: any;
1314
#key: string;
15+
/**
16+
* Optional model handle, used to validate descendant streamIds in
17+
* `eventTemplate({ context })`. Constructed by HDSModelItemsDefs which has
18+
* the model handle available; older callers that build HDSItemDef directly
19+
* still work — they just can't use the `context` option.
20+
*/
21+
#model: HDSModel | null;
1422

15-
constructor (key: string, definitionData: any) {
23+
constructor (key: string, definitionData: any, model: HDSModel | null = null) {
1624
this.#key = key;
1725
this.#data = definitionData;
26+
this.#model = model;
1827
}
1928

2029
get eventTypes (): string[] {
@@ -60,15 +69,65 @@ export class HDSItemDef {
6069

6170
/**
6271
* a template event with eventType and streamIds
72+
*
73+
* @param opts.context — optional context streamId per Plan 46 §2.1 (D3).
74+
* Must equal `this.streamId` or be a descendant. Lets a single itemDef
75+
* registered at e.g. `treatment` produce events placed at `treatment-fertility`,
76+
* `treatment-oncology`, etc., without per-domain item definitions.
77+
* When omitted, falls back to the itemDef's canonical streamId.
78+
* Throws if the context isn't in the itemDef's subtree.
79+
*
6380
* // TODO handle variations
6481
*/
65-
eventTemplate (): {
82+
eventTemplate (opts: { context?: string } = {}): {
6683
streamIds: [string];
6784
type: string;
6885
} {
86+
let chosenStreamId = this.#data.streamId as string;
87+
if (opts.context != null && opts.context !== chosenStreamId) {
88+
this.#assertDescendantOf(opts.context, chosenStreamId);
89+
chosenStreamId = opts.context;
90+
}
6991
return {
70-
streamIds: [this.#data.streamId],
92+
streamIds: [chosenStreamId],
7193
type: this.eventTypes[0]
7294
};
7395
}
96+
97+
/**
98+
* Throws if `candidate` is not a descendant of `ancestor` in the model's
99+
* stream tree. Requires the model handle (passed at construction).
100+
*/
101+
#assertDescendantOf (candidate: string, ancestor: string): void {
102+
if (!this.#model) {
103+
throw new Error(`HDSItemDef "${this.#key}" was constructed without a model handle; cannot validate context "${candidate}"`);
104+
}
105+
// getParentsIds returns ancestors (excluding self). Treat candidate as
106+
// valid iff `ancestor` is in its parent chain.
107+
const ancestors = this.#model.streams.getParentsIds(candidate, false);
108+
if (!ancestors.includes(ancestor)) {
109+
throw new Error(`Context streamId "${candidate}" is not a descendant of itemDef "${this.#key}" streamId "${ancestor}"`);
110+
}
111+
}
112+
113+
/**
114+
* D3-aware event-matching. Returns true if the given event resolves to
115+
* this itemDef via `model.itemsDefs.forEvent(event)` — covering both the
116+
* direct (streamId, eventType) match and the closest-ancestor walk-up.
117+
*
118+
* Useful in form-engine code that needs to check whether an event belongs
119+
* to a particular itemDef without re-implementing the resolution rule.
120+
* Falls back to plain `(streamId in event.streamIds, type === eventType)`
121+
* if the model handle isn't available.
122+
*/
123+
matchesEvent (event: { type?: string; streamIds?: string[] }): boolean {
124+
if (!event || event.type == null || !Array.isArray(event.streamIds)) return false;
125+
if (this.#model) {
126+
const resolved = this.#model.itemsDefs.forEvent(event, false);
127+
return resolved !== null && resolved.key === this.#key;
128+
}
129+
// Fallback: direct match against this itemDef's streamId + eventType.
130+
if (!this.eventTypes.includes(event.type)) return false;
131+
return event.streamIds.includes(this.#data.streamId);
132+
}
74133
}

ts/HDSModel/HDSModel-ItemsDefs.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,22 @@ export class HDSModelItemsDefs {
6161
if (throwErrorIfNotFound) throw new Error('Cannot find item definition with key: ' + key);
6262
return null;
6363
}
64-
this.#itemsDefs[key] = new HDSItemDef(key, defData);
64+
this.#itemsDefs[key] = new HDSItemDef(key, defData, this.#model);
6565
return this.#itemsDefs[key];
6666
}
6767

6868
/**
6969
* get a definition for an event
70+
*
71+
* Resolution rule (Plan 46 §2.1 — context-via-substream / D3):
72+
* 1. Try direct match on every entry in event.streamIds (legacy: events
73+
* may carry multiple streamIds, e.g. bridge-athenahealth credentials).
74+
* 2. If no direct match, walk parents of streamIds[0] looking for a
75+
* matching itemDef. Closest ancestor wins.
76+
*
77+
* The walk-up reuses `HDSModelStreams.getParentsIds`, the same helper
78+
* Authorizations consumes (HDSModel-Authorizations.ts:86) and the same
79+
* algorithm Plan 45's resolveStream.ts uses for clientData lookup.
7080
*/
7181
forEvent (event: any, throwErrorIfNotFound: boolean = true): HDSItemDef | null {
7282
const candidates: any[] = [];
@@ -75,6 +85,23 @@ export class HDSModelItemsDefs {
7585
const candidate = this.#modelDataByStreamIdEventTypes[keyStreamIdEventType];
7686
if (candidate) candidates.push(candidate);
7787
}
88+
if (candidates.length === 0 && event.streamIds && event.streamIds.length > 0) {
89+
const primary = event.streamIds[0];
90+
// getParentsIds returns root-first ancestors of `primary` (excludes self).
91+
// We need closest-ancestor-wins, so iterate leaf-to-root: walk parents
92+
// in reverse, then check `primary` is already covered by the direct-match
93+
// pass above — so we only need ancestors here.
94+
const ancestorsRootFirst = this.#model.streams.getParentsIds(primary, false);
95+
for (let i = ancestorsRootFirst.length - 1; i >= 0; i--) {
96+
const ancestorId = ancestorsRootFirst[i];
97+
const keyStreamIdEventType = ancestorId + ':' + event.type;
98+
const candidate = this.#modelDataByStreamIdEventTypes[keyStreamIdEventType];
99+
if (candidate) {
100+
candidates.push(candidate);
101+
break;
102+
}
103+
}
104+
}
78105
if (candidates.length === 0) {
79106
if (throwErrorIfNotFound) throw new Error('Cannot find definition for event: ' + JSON.stringify(event));
80107
return null;

ts/HDSModel/HDSModel.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,21 @@ export class HDSModel {
7373
const response = await fetch(this.#modelUrl);
7474
const resultText = await response.text();
7575
const result = JSON.parse(resultText);
76+
this.loadFromObject(result, overload);
77+
}
78+
79+
/**
80+
* Load model from an in-memory object (skips fetch). Useful when the
81+
* pack.json content is already in memory — tests, embedded apps, or
82+
* environments where fetch can't reach the model URL (e.g. Node with a
83+
* file:// URL, which Node's fetch does not yet implement).
84+
*/
85+
loadFromObject (data: any, overload: HDSModelOverload | null = null): void {
7686
if (overload) {
77-
validateOverload(result, overload);
78-
applyOverload(result, overload);
87+
validateOverload(data, overload);
88+
applyOverload(data, overload);
7989
}
80-
this.#modelData = result;
90+
this.#modelData = data;
8191
// add key to items before freezing;
8292
for (const [key, item] of Object.entries(this.#modelData.items)) {
8393
(item as any).key = key;

ts/appTemplates/itemLabels.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export interface ItemCustomization {
3333
repeatable?: string;
3434
reminder?: Record<string, unknown>;
3535
labels?: ItemLabels;
36+
/**
37+
* Plan 46 (D3) — optional context streamId for events created from this
38+
* item in this section. Must be `itemDef.streamId` or a descendant; the
39+
* itemDef's `eventTemplate({ context })` enforces the constraint.
40+
* Lets a single itemDef registered at e.g. `treatment` produce events
41+
* placed at `treatment-fertility`, `treatment-oncology`, … per section.
42+
*/
43+
context?: string;
3644
[key: string]: unknown;
3745
}
3846

0 commit comments

Comments
 (0)