@memberjunction/dynamic-packages 0.0.0 → 6.1.0-edge.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,183 @@
1
+ Business Source License 1.1
2
+
3
+ License text copyright (c) 2024 MariaDB plc, All Rights Reserved.
4
+ "Business Source License" is a trademark of MariaDB plc.
5
+
6
+ -----------------------------------------------------------------------------
7
+
8
+ Parameters
9
+
10
+ Licensor: Blue Cypress, Inc.
11
+
12
+ Licensed Work: MemberJunction.
13
+ The Licensed Work is (c) 2023-2026 Blue Cypress, Inc.
14
+
15
+ Additional Use Grant: Subject to the terms of this License, Licensor grants
16
+ you the following additional rights to make Production
17
+ Use of the Licensed Work.
18
+
19
+ 1. Internal Use
20
+
21
+ You may make production use of the Licensed Work for
22
+ your own internal business or organizational operations.
23
+
24
+ 2. Nonprofit Use
25
+
26
+ If you are a Nonprofit, you may make production use of
27
+ the Licensed Work for the operations and activities of
28
+ your Organizational Family.
29
+
30
+ 3. MemberJunction Certified Program Use
31
+
32
+ If you are authorized by Licensor under the
33
+ MemberJunction Certified Program to provide professional
34
+ services using the Licensed Work, you may make
35
+ production use of the Licensed Work in providing such
36
+ professional services to a client, provided that:
37
+
38
+ (a) the Licensed Work is deployed in, and the applicable
39
+ production use occurs within, an environment owned,
40
+ leased, licensed, subscribed to, or otherwise controlled
41
+ by that client; and
42
+
43
+ (b) the production use is for that client's own internal
44
+ business or organizational operations or is otherwise
45
+ independently permitted to that client under this
46
+ Additional Use Grant.
47
+
48
+ 4. Definitions Applicable to the Additional Use Grant
49
+
50
+ "Affiliate" means, with respect to a specified Person,
51
+ any other Person that directly or indirectly Controls,
52
+ is Controlled by, or is under common Control with such
53
+ specified Person.
54
+
55
+ "Control" (including the terms "Controls," "Controlled
56
+ by," and "under common Control with") means the direct
57
+ or indirect possession of the power to direct or cause
58
+ the direction of the management and policies of a
59
+ Person, whether through ownership of voting interests,
60
+ by contract, or otherwise.
61
+
62
+ "Organizational Family" means, with respect to a Person,
63
+ (a) such Person and its Affiliates, and (b) any
64
+ nonprofit organization, governmental entity, chapter,
65
+ division, local affiliate, regional affiliate, state
66
+ affiliate, national affiliate, or other entity that is
67
+ formally affiliated with such Person through governing
68
+ documents, a charter, bylaws, a membership agreement, or
69
+ another written organizational instrument, and is
70
+ recognized under such documents as part of the same
71
+ organizational structure.
72
+
73
+ "Nonprofit" means a Person recognized by the Internal
74
+ Revenue Service as exempt from federal income taxation
75
+ under Section 501(c)(3), 501(c)(4), 501(c)(5), or
76
+ 501(c)(6) of the Internal Revenue Code, or a foreign
77
+ organization recognized under substantially equivalent
78
+ laws.
79
+
80
+ A Person claiming eligibility as a Nonprofit shall, upon
81
+ Licensor's reasonable request, provide documentation
82
+ reasonably sufficient to demonstrate that it qualifies
83
+ as a Nonprofit. If such Person materially misrepresents,
84
+ or is unable to demonstrate, its qualification as a
85
+ Nonprofit, the rights granted to such Person under
86
+ Section 2 of this Additional Use Grant shall terminate.
87
+
88
+ "MemberJunction Certified Program" means Licensor's
89
+ then-current program for certifying and authorizing a
90
+ Person to provide professional services using the
91
+ Licensed Work.
92
+
93
+ "Person" means any individual, corporation, limited
94
+ liability company, partnership, association, nonprofit
95
+ organization, governmental entity, or other legal or
96
+ organizational entity.
97
+
98
+ Change Date: Four (4) years from the date the Licensed Work is first
99
+ made available.
100
+
101
+ Change License: MIT License.
102
+
103
+ For information about alternative licensing arrangements for the Licensed
104
+ Work, please contact Blue Cypress, Inc.
105
+
106
+ -----------------------------------------------------------------------------
107
+
108
+ Terms
109
+
110
+ The Licensor hereby grants you the right to copy, modify, create derivative
111
+ works, redistribute, and make non-production use of the Licensed Work. The
112
+ Licensor may make an Additional Use Grant, above, permitting limited
113
+ production use.
114
+
115
+ Effective on the Change Date, or the fourth anniversary of the first publicly
116
+ available distribution of a specific version of the Licensed Work under this
117
+ License, whichever comes first, the Licensor hereby grants you rights under
118
+ the terms of the Change License, and the rights granted in the paragraph
119
+ above terminate.
120
+
121
+ If your use of the Licensed Work does not comply with the requirements
122
+ currently in effect as described in this License, you must purchase a
123
+ commercial license from the Licensor, its affiliated entities, or authorized
124
+ resellers, or you must refrain from using the Licensed Work.
125
+
126
+ All copies of the original and modified Licensed Work, and derivative works
127
+ of the Licensed Work, are subject to this License. This License applies
128
+ separately for each version of the Licensed Work and the Change Date may vary
129
+ for each version of the Licensed Work released by Licensor.
130
+
131
+ You must conspicuously display this License on each original or modified copy
132
+ of the Licensed Work. If you receive the Licensed Work in original or
133
+ modified form from a third party, the terms and conditions set forth in this
134
+ License apply to your use of that work.
135
+
136
+ Any use of the Licensed Work in violation of this License will automatically
137
+ terminate your rights under this License for the current and all other
138
+ versions of the Licensed Work.
139
+
140
+ This License does not grant you any right in any trademark or logo of
141
+ Licensor or its affiliates (provided that you may use a trademark or logo of
142
+ Licensor as expressly required by this License).
143
+
144
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
145
+ AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
146
+ EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
147
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
148
+ TITLE.
149
+
150
+ MariaDB hereby grants you permission to use this License's text to license
151
+ your works, and to refer to it using the trademark "Business Source License",
152
+ as long as you comply with the Covenants of Licensor below.
153
+
154
+ -----------------------------------------------------------------------------
155
+
156
+ Covenants of Licensor
157
+
158
+ In consideration of the right to use this License's text and the "Business
159
+ Source License" name and trademark, Licensor covenants to MariaDB, and to all
160
+ other recipients of the licensed work to be provided by Licensor:
161
+
162
+ 1. To specify as the Change License the GPL Version 2.0 or any later version,
163
+ or a license that is compatible with GPL Version 2.0 or a later version,
164
+ where "compatible" means that software provided under the Change License
165
+ can be included in a program with software provided under GPL Version 2.0
166
+ or a later version. Licensor may specify additional Change Licenses without
167
+ limitation.
168
+
169
+ 2. To either: (a) specify an additional grant of rights to use that does not
170
+ impose any additional restriction on the right granted in this License, as
171
+ the Additional Use Grant; or (b) insert the text "None".
172
+
173
+ 3. To specify a Change Date.
174
+
175
+ 4. Not to modify this License in any other way.
176
+
177
+ -----------------------------------------------------------------------------
178
+
179
+ Notice
180
+
181
+ The Business Source License (this document, or the "License") is not an Open
182
+ Source license. However, the Licensed Work will eventually be made available
183
+ under an Open Source License, as stated in this License.
package/README.md CHANGED
@@ -1,45 +1,402 @@
1
1
  # @memberjunction/dynamic-packages
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Process-agnostic loader for packages whose names are only known at runtime:
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ - Open App server / client packages recorded in `mj.config.cjs` `dynamicPackages.server[]` /
6
+ `dynamicPackages.client[]` by `mj app install`
7
+ - a host's own generated packages under `codeGeneration.packages`
8
+ - the packages of the Open App whose repository a process is standing in (`mj-app.json`)
6
9
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
10
+ Importing one of these packages fires its `@RegisterClass` decorators, so `Metadata.GetEntityObject`
11
+ returns the app's entity subclass (validation, `Save()` overrides, lifecycle hooks) instead of a
12
+ generic `BaseEntity`. MJAPI has always done this at boot. Every other MJ process — the `mj` CLI
13
+ (`sync push`, `app …`, `test`, …), the MCP and A2A servers, the integration-test bootstrap, an ad-hoc
14
+ script — needs exactly the same behaviour, and this package is where it lives so each host is one call.
8
15
 
9
- ## Purpose
16
+ The repo-wide story (why it exists, how a downstream app and an MJ install configure it, how to add
17
+ it to a new host) is in [`guides/DYNAMIC_PACKAGE_LOADING_GUIDE.md`](../../guides/DYNAMIC_PACKAGE_LOADING_GUIDE.md).
18
+ This README is the package reference.
10
19
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@memberjunction/dynamic-packages`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
20
+ ## Contents
15
21
 
16
- ## What is OIDC Trusted Publishing?
22
+ 1. [The problem in one picture](#the-problem-in-one-picture)
23
+ 2. [Usage](#usage)
24
+ 3. [How a call proceeds](#how-a-call-proceeds)
25
+ 4. [Discovery sources and merge rules](#discovery-sources-and-merge-rules)
26
+ 5. [Process IDs](#process-ids)
27
+ 6. [Scoping entries per process](#scoping-entries-per-process)
28
+ 7. [Mode: turning it off](#mode-turning-it-off)
29
+ 8. [Nested hosts](#nested-hosts)
30
+ 9. [Running inside an Open App repository](#running-inside-an-open-app-repository)
31
+ 10. [Resolution: how a package is found](#resolution-how-a-package-is-found)
32
+ 11. [Loading twice in one process](#loading-twice-in-one-process)
33
+ 12. [The report](#the-report)
34
+ 13. [Loggers](#loggers)
35
+ 14. [Contract for `StartupExport`](#contract-for-startupexport)
36
+ 15. [API](#api)
37
+ 16. [Testing a host against it](#testing-a-host-against-it)
17
38
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
39
+ ## The problem in one picture
19
40
 
20
- ## Setup Instructions
41
+ `MJGlobal.ClassFactory` is a registry keyed by `(baseClass, key)`. `@RegisterClass(BaseEntity, 'MJ_BizApps_Orders: Orders')`
42
+ registers a subclass under that key **as a side effect of importing the module that declares it**.
43
+ Nothing else ever puts it there. So a process that has not imported the app's package gets the fallback:
21
44
 
22
- To properly configure OIDC trusted publishing for this package:
45
+ ```mermaid
46
+ flowchart LR
47
+ subgraph MJAPI["MJAPI (always loaded app packages)"]
48
+ A1["import '@mj-biz-apps/orders-server'"] --> A2["@RegisterClass fires"]
49
+ A2 --> A3["ClassFactory: Orders → OrderEntityServer"]
50
+ A3 --> A4["GetEntityObject('…Orders')<br/>→ OrderEntityServer<br/>Save() override runs ✅"]
51
+ end
52
+ subgraph CLI["mj sync push (before this package)"]
53
+ B1["no import"] --> B3["ClassFactory: Orders → (nothing)"]
54
+ B3 --> B4["GetEntityObject('…Orders')<br/>→ plain BaseEntity<br/>Save() override skipped ❌"]
55
+ end
56
+ ```
23
57
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
58
+ The fallback is silent: the push reports success, the row is written, and the app's validation and
59
+ lifecycle logic simply never ran (MemberJunction/MJ#4199). The loader closes that gap for every process
60
+ by making "import whatever the configuration names" a single call with one set of rules.
28
61
 
29
- ## DO NOT USE THIS PACKAGE
62
+ ## Usage
30
63
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
64
+ ```ts
65
+ import { LoadDynamicPackages, DiscoverMJConfig } from '@memberjunction/dynamic-packages';
36
66
 
37
- ## More Information
67
+ // 1. Discover the RAW mj.config.cjs (Zod-parsed configs usually strip `dynamicPackages`).
68
+ const { config, configFilePath } = DiscoverMJConfig();
38
69
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
70
+ // 2. Load BEFORE any database provider is set up, exactly where MJAPI does it.
71
+ const report = await LoadDynamicPackages({ processId: 'mcp', config, configFilePath });
42
72
 
43
- ---
73
+ report.Loaded; // [{ Entry, Source, Module, RanStartupExport }]
74
+ report.Skipped; // disabled / process-filter / mode-none / duplicate
75
+ report.NotFound; // not resolvable from any anchor (expected before `npm install`)
76
+ report.Failed; // resolved but threw while loading — its own error, never masked
77
+ ```
44
78
 
45
- **Maintained for OIDC setup purposes only**
79
+ Call it **after** the host's own class-registration manifest (`@memberjunction/server-bootstrap`,
80
+ `@memberjunction/server-bootstrap-lite`) has been imported: the ClassFactory's load-order priority
81
+ means the app's registrations land last and win. Call it **before** the provider is created: a
82
+ `StartupExport` may rely on nothing but the ClassFactory.
83
+
84
+ The package has one runtime dependency (`cosmiconfig`) and no MJ dependencies, so it can be the first
85
+ MJ-shaped thing a process imports.
86
+
87
+ ## How a call proceeds
88
+
89
+ ```mermaid
90
+ sequenceDiagram
91
+ autonumber
92
+ participant Host as Host process<br/>(mjapi, cli:sync:push, mcp, …)
93
+ participant L as LoadDynamicPackages
94
+ participant D as Discovery
95
+ participant M as Mode
96
+ participant R as Resolution
97
+ participant CF as ClassFactory
98
+
99
+ Host->>L: { processId, config, configFilePath, tier }
100
+ L->>M: ResolveDynamicPackagesMode(env, option, policy)
101
+ M-->>L: 'load' | 'none' (+ source)
102
+ L->>D: codeGeneration.packages → dynamicPackages.<tier>[] → mj-app.json
103
+ D-->>L: ordered candidates (tagged generated / config / manifest)
104
+ L->>L: mergeCandidates() — dedupe by PackageName, config entry is authority
105
+ alt mode 'none'
106
+ L-->>Host: every candidate in Skipped (mode-none)
107
+ else mode 'load'
108
+ loop each candidate, in order
109
+ L->>L: Enabled === false? → Skipped (disabled)
110
+ L->>L: MatchesProcess(processId, entry)? no → Skipped (process-filter)
111
+ L->>L: already loaded in this process? → Loaded (RanStartupExport: false)
112
+ L->>R: importFromHost(name, configFilePath)
113
+ R-->>L: module | resolution failure | load error
114
+ opt manifest entry, resolution failed
115
+ L->>R: FindWorkspacePackageDir → import entry file by path
116
+ end
117
+ Note over CF: importing the module fires @RegisterClass
118
+ L->>L: run StartupExport (if named and exported)
119
+ end
120
+ L-->>Host: DynamicPackagesReport
121
+ end
122
+ ```
123
+
124
+ Two properties of that loop are deliberate and worth knowing:
125
+
126
+ - **Filters run before the cache check.** An entry that is disabled or out of scope stays skipped
127
+ even when an earlier call in the same process loaded the package. The cache is not a bypass.
128
+ - **Order is priority.** `@RegisterClass` uses load-order priority — the last registration for a key
129
+ wins — so discovery is arranged generic-to-specific (see next section).
130
+
131
+ ## Discovery sources and merge rules
132
+
133
+ Three sources, always in this order:
134
+
135
+ | # | Source | Comes from | `Source` tag | Why this position |
136
+ |---|---|---|---|---|
137
+ | 1 | Generated packages | `codeGeneration.packages.{entities,actions,graphqlResolvers}.name` (server tier) / `{entities,actions,angularForms}` (client tier) | `generated` | The host's own schema subclasses — the most generic layer |
138
+ | 2 | Installed Open Apps | `dynamicPackages.server[]` / `dynamicPackages.client[]`, written by `mj app install` | `config` | Apps layered on the host |
139
+ | 3 | The app you are standing in | `mj-app.json` next to the config (or `appManifestDir`): `packages.shared` then `packages.<tier>` | `manifest` | The most local definition overrides everything |
140
+
141
+ ```mermaid
142
+ flowchart TD
143
+ G["1 · generated<br/>codeGeneration.packages"] --> C["2 · config<br/>dynamicPackages.&lt;tier&gt;[]"] --> M["3 · manifest<br/>mj-app.json packages.shared + packages.&lt;tier&gt;"]
144
+ M --> MG["mergeCandidates()"]
145
+ MG --> OUT["one candidate per PackageName<br/>+ duplicates → Skipped (duplicate)"]
146
+ style G fill:#eef,stroke:#88a
147
+ style C fill:#efe,stroke:#8a8
148
+ style M fill:#fee,stroke:#a88
149
+ ```
150
+
151
+ `mergeCandidates` collapses entries that name the same package:
152
+
153
+ - The **`config` entry is the operator's authority**. It carries `Enabled` (what `mj app disable`
154
+ writes) and the `Processes` / `ExcludeProcesses` scoping, so when an `mj-app.json` beside the
155
+ config names a package the config also names, the config entry decides whether and where it loads.
156
+ - The **manifest's on-disk location is kept as the resolution fallback** (`WorkspaceHome`), so a
157
+ package the host cannot `require.resolve` still loads from the workspace.
158
+ - Every later duplicate is returned separately and lands in `report.Skipped` with `Reason: 'duplicate'`.
159
+
160
+ Manifest discovery reads `mj-app.json` from the config file's directory by default; pass
161
+ `appManifestDir` to point elsewhere, or `discoverAppManifest: false` to skip it. `includeGeneratedPackages: false`
162
+ skips source 1.
163
+
164
+ ## Process IDs
165
+
166
+ Every host names itself with a lowercase, colon-separated ID. IDs are hierarchical: a pattern matches
167
+ an ID when it equals the ID or names one of its ancestor **segments**.
168
+
169
+ ```mermaid
170
+ flowchart TD
171
+ ANY["'*' (matches everything)"]
172
+ ANY --> MJAPI["mjapi"]
173
+ ANY --> MCP["mcp"]
174
+ ANY --> A2A["a2a"]
175
+ ANY --> IT["integration-tests"]
176
+ ANY --> CR["component-registry"]
177
+ ANY --> CLI["cli"]
178
+ CLI --> SYNC["cli:sync"]
179
+ SYNC --> SP["cli:sync:push"]
180
+ SYNC --> SPL["cli:sync:pull"]
181
+ CLI --> CG["cli:codegen"]
182
+ CLI --> APP["cli:app"]
183
+ APP --> AI["cli:app:install"]
184
+ CLI --> CAI["cli:ai"]
185
+ CAI --> CAIR["cli:ai:agents:run"]
186
+ ```
187
+
188
+ | Pattern | Matches | Does not match |
189
+ |---|---|---|
190
+ | `cli` | `cli`, `cli:sync:push`, `cli:codegen` | `mjapi` |
191
+ | `cli:sync` | `cli:sync`, `cli:sync:push` | `cli:syncother`, `cli:migrate` |
192
+ | `cli:sync:push` | `cli:sync:push` only | `cli:sync:pull` |
193
+ | `*` | everything | — |
194
+
195
+ `CliProcessId('sync push')` and `CliProcessId('sync:push')` both build `cli:sync:push` from an oclif
196
+ command ID. `NormalizeProcessId` trims, lowercases and collapses whitespace around the separators.
197
+ `ProcessIdMatches`, `MatchesProcess` and `ResolveMostSpecific` are the three matching primitives, all
198
+ exported for hosts that need to evaluate scoping themselves.
199
+
200
+ ## Scoping entries per process
201
+
202
+ `dynamicPackages.server[]` stays the single source of truth that `mj app install` writes. Two optional,
203
+ hand-authored fields scope an entry:
204
+
205
+ ```js
206
+ dynamicPackages: {
207
+ server: [
208
+ // written by `mj app install` — no Processes = loads wherever the server tier loads
209
+ { PackageName: '@mj-biz-apps/orders-server', StartupExport: 'LoadBizAppsOrdersServer', AppName: 'mj-bizapps-orders', Enabled: true },
210
+ // only when the CLI runs any `mj sync` command
211
+ { PackageName: '@acme/demo-seed-server', StartupExport: 'LoadDemoSeed', Processes: ['cli:sync'] },
212
+ // everywhere except CodeGen
213
+ { PackageName: '@acme/audit-server', StartupExport: 'LoadAudit', ExcludeProcesses: ['cli:codegen'] },
214
+ ],
215
+ // optional per-process on/off switch; the most specific key wins
216
+ policy: { 'cli:codegen': 'none' },
217
+ }
218
+ ```
219
+
220
+ `Processes` is evaluated first (omitted or empty means everywhere the tier loads), then
221
+ `ExcludeProcesses`. An entry that fails either lands in `report.Skipped` with `Reason: 'process-filter'`.
222
+
223
+ Scoping only applies to processes that run the loader. The `mj` CLI's *light* commands — `mj migrate`
224
+ (and `migrate create` / `migrate convert`), `mj install`, `mj clean`, `mj bundle`, `mj doctor`,
225
+ `mj version`, `help` and the `usage` pages — skip the class-registration manifest for instant
226
+ startup and never load app packages, so a `Processes: ['cli:migrate']` entry or a `cli:migrate`
227
+ policy key has no effect: migrations never run app entity subclasses in any mode.
228
+
229
+ ## Mode: turning it off
230
+
231
+ Mode is resolved once per call, highest precedence first:
232
+
233
+ ```mermaid
234
+ flowchart LR
235
+ ENV["MJ_DYNAMIC_PACKAGES<br/>env var"] -->|unset or invalid| OPT["mode option<br/>(programmatic, e.g. a CLI flag)"]
236
+ OPT -->|unset| POL["dynamicPackages.policy<br/>most specific process key"]
237
+ POL -->|no match or invalid| DEF["'load'"]
238
+ ENV -->|"load / none"| DONE["resolved<br/>(ModeSource: env)"]
239
+ OPT -->|set| DONE2["resolved<br/>(ModeSource: option)"]
240
+ POL -->|"load / none"| DONE3["resolved<br/>(ModeSource: policy)"]
241
+ ```
242
+
243
+ `MJ_DYNAMIC_PACKAGES=none` (also `off`, `skip`, `false`, `0`; the positive spellings `on`, `true`,
244
+ `1`, `full` mean `load`) disables loading for a single invocation. The `mj` CLI exposes it as the
245
+ global `--no-app-packages` flag. An invalid value never crashes a process: it is reported on the warn
246
+ path (`Ignoring invalid …`) and precedence falls through to the next source.
247
+
248
+ Mode `none` skips every dynamic package: the installed Open App packages *and* the host's own
249
+ `codeGeneration.packages` (generated entities/actions/resolvers), so a host that relies on the latter
250
+ loses its custom entity subclasses too for that run. MJ core's own server subclasses still register
251
+ through the host's manifest, so a "raw" run is raw for app and host entities only — which is why the
252
+ CLI flag is not called `--raw`.
253
+
254
+ ## Nested hosts
255
+
256
+ Some MJ processes host other MJ processes: `mj ai agents run` imports `@memberjunction/ai-cli`, and
257
+ `mj test …` imports `@memberjunction/testing-cli`, each of which has its own provider bootstrap that
258
+ calls the loader. Without coordination the inner host would evaluate scoping under its standalone
259
+ identity (`ai-cli`, `testing-cli`) a moment after the outer host evaluated it under `cli:ai:agents:run`,
260
+ and an entry the operator excluded from `cli` would load anyway.
261
+
262
+ ```mermaid
263
+ sequenceDiagram
264
+ participant U as operator
265
+ participant CLI as mj (prerun hook)
266
+ participant ENV as process.env
267
+ participant AI as @memberjunction/ai-cli bootstrap
268
+
269
+ U->>CLI: mj ai agents run …
270
+ CLI->>CLI: LoadDynamicPackages({ processId: 'cli:ai:agents:run' })
271
+ CLI->>ENV: MJ_DYNAMIC_PACKAGES_PROCESS = 'cli:ai:agents:run'
272
+ CLI->>AI: import + run command
273
+ AI->>ENV: EffectiveProcessId('ai-cli') → 'cli:ai:agents:run'
274
+ AI->>AI: LoadDynamicPackages({ processId: 'cli:ai:agents:run' })
275
+ Note over AI: same scoping decisions as the outer host;<br/>already-loaded packages come back cached, startup export not re-run
276
+ ```
277
+
278
+ The outer host publishes its ID through `DYNAMIC_PACKAGES_PROCESS_ENV_VAR` (`MJ_DYNAMIC_PACKAGES_PROCESS`);
279
+ the inner host reads it with `EffectiveProcessId(hostDefault)`, which falls back to `hostDefault` when
280
+ the variable is unset (the standalone `mj-ai` case). The mode env var propagates the same way, since it
281
+ is plain `process.env`.
282
+
283
+ ## Running inside an Open App repository
284
+
285
+ When `mj-app.json` sits next to the config (or in `appManifestDir`), the loader also imports that
286
+ app's `packages.shared` libraries and its `packages.server` / `packages.client` bootstrap packages,
287
+ running each `startupExport`. Under pnpm's strict layout nothing at the repo root can
288
+ `require.resolve` a workspace member, so the loader finds the package under `code.sourceDirectory`
289
+ (default `packages/`) and imports it by path. A member that exists but has not been built yet (its
290
+ entry file is missing) is reported as **not found**, with the file the build is expected to produce,
291
+ on the info path — the expected state before the app's own build, not an error.
292
+
293
+ Dependency apps are **not** walked from the manifest: installed ones are already in the host's
294
+ `dynamicPackages`, and dev-linked ones ride the workspace's env-supplied entries.
295
+
296
+ ## Resolution: how a package is found
297
+
298
+ A bare `import(name)` resolves from *this* package, which cannot declare packages whose names come from
299
+ configuration. npm's hoisted layout lets that work by accident; pnpm's strict layout does not, because
300
+ the packages are declared by (and linked into) the **host** application. `importFromHost` therefore
301
+ tries the bare import first and then retries from each host anchor:
302
+
303
+ ```mermaid
304
+ flowchart TD
305
+ S["import(name)"] -->|ok| OK["module"]
306
+ S -->|resolution failure| A1["createRequire(configFilePath).resolve(name)"]
307
+ A1 -->|ok| IMP["import(resolved file)"]
308
+ A1 -->|cannot see it| A2["createRequire(cwd/package.json).resolve(name)"]
309
+ A2 -->|ok| IMP
310
+ A2 -->|cannot see it| A3["createRequire(process.argv[1]).resolve(name)"]
311
+ A3 -->|ok| IMP
312
+ A3 -->|cannot see it| MF{"manifest entry with<br/>WorkspaceHome?"}
313
+ MF -->|yes| WS["FindWorkspacePackageDir → entry file<br/>(package.json exports / main / index.js)"]
314
+ WS -->|file exists| IMP
315
+ WS -->|not built| NF["NotFound (info: 'build it first')"]
316
+ MF -->|no| NF2["NotFound (info: run npm install?)"]
317
+ IMP -->|throws| FAIL["Failed (warn: the module's OWN error)"]
318
+ IMP -->|ok| OK
319
+ ```
320
+
321
+ Resolution and evaluation are kept apart on purpose: an anchor that cannot *see* the package means
322
+ "try the next anchor", but once an anchor resolves it, anything that fails while loading (a missing
323
+ transitive dependency, a throw in top-level code) is the module's own problem and surfaces as-is —
324
+ never masked by the original "cannot find package" error. `isOwnResolutionFailure` checks that the
325
+ quoted subject of the error is *this* package name, so a missing transitive dependency is reported as
326
+ `Failed`, not `NotFound`.
327
+
328
+ `resolvePackageJsonFromHost` uses the same anchors to find a package's `package.json` (for hosts that
329
+ read `memberjunction.serverExtensions` off it), tolerating an exports map that omits `./package.json`
330
+ by resolving the main entry and walking up.
331
+
332
+ ## Loading twice in one process
333
+
334
+ The loader remembers what it has loaded, on a `globalThis` symbol store rather than a module variable,
335
+ so two physical copies of this package (two dist paths under pnpm, a bundled and an unbundled copy)
336
+ still agree. A second call in the same process gets the cached module back in `Loaded` so it can still
337
+ read `RESOLVER_PATHS` / `MJ_SERVER_EXTENSIONS`, but the `StartupExport` is not run again
338
+ (`RanStartupExport: false`). ESM already caches the module; this only prevents the double hook.
339
+ `ResetLoadedDynamicPackages()` clears the store — a test seam, not something a host calls.
340
+
341
+ ## The report
342
+
343
+ `LoadDynamicPackages` never throws for a package problem (it throws only for a missing `processId`).
344
+ Everything it decided is in the returned `DynamicPackagesReport`:
345
+
346
+ | Field | Meaning |
347
+ |---|---|
348
+ | `ProcessId`, `Tier` | Normalized inputs |
349
+ | `Mode`, `ModeSource` | What was resolved and from where (`env` / `option` / `policy` / `default`) |
350
+ | `Loaded[]` | `{ Entry, Source, WorkspaceHome?, Module, RanStartupExport }` — `Module` is the namespace object hosts read conventions off |
351
+ | `Skipped[]` | `{ …, Reason }` with `disabled`, `process-filter`, `mode-none`, or `duplicate` |
352
+ | `NotFound[]` | No anchor could resolve it, or an unbuilt workspace member — logged on the info path |
353
+ | `Failed[]` | `{ …, Error }` — resolved but threw while loading; logged on the warn path |
354
+
355
+ A named `StartupExport` that the module does not export is a real misconfiguration (renamed export,
356
+ stale config) and is warned about; the package still counts as `Loaded` with `RanStartupExport: false`.
357
+
358
+ ## Loggers
359
+
360
+ | Logger | Use when |
361
+ |---|---|
362
+ | `ConsoleDynamicPackagesLogger` (default) | Plain console, the way MJAPI has always logged boot |
363
+ | `StderrDynamicPackagesLogger` | Stdout is a machine-readable envelope (`--format=json`, `--output=json`) — progress and warnings go to stderr, verbose detail is dropped |
364
+ | `SilentDynamicPackagesLogger` | The host reads the report and renders it itself |
365
+
366
+ Any object with `info(message)` and `warn(message, error?)` (and optionally `verbose(message)`)
367
+ satisfies `DynamicPackagesLogger`; the `mj` CLI passes one that routes through its own output.
368
+
369
+ ## Contract for `StartupExport`
370
+
371
+ The loader runs before any database provider exists. A startup export registers classes and returns —
372
+ it may be sync or async, and it must not touch a provider, exactly the contract MJAPI has always
373
+ imposed. It runs at most once per process for a given package.
374
+
375
+ ## API
376
+
377
+ | Export | Purpose |
378
+ |---|---|
379
+ | `LoadDynamicPackages(options)` | The loader. Never throws for a package problem; returns a `DynamicPackagesReport`. |
380
+ | `DiscoverMJConfig(searchFrom?, { searchStrategy })` | cosmiconfig discovery of the raw `mj.config.cjs` + its path. `searchStrategy` defaults to `'global'` (walk up to the home directory, what MJAPI / the CLI / CodeGen do); pass `'none'` when the host's own config loader only looks in the working directory, so the packages come from the same file the database settings came from. |
381
+ | `mergeCandidates(candidates)` | The dedupe rule described above, exported for hosts that assemble their own candidate lists. |
382
+ | `ResetLoadedDynamicPackages()` | Test seam: forget what has been loaded. |
383
+ | `ConsoleDynamicPackagesLogger`, `StderrDynamicPackagesLogger`, `SilentDynamicPackagesLogger` | Ready-made loggers. |
384
+ | `importFromHost`, `resolvePackageJsonFromHost`, `isResolutionFailure` | Host-anchored resolution (moved here from `@memberjunction/server-bootstrap`). |
385
+ | `CliProcessId`, `NormalizeProcessId`, `ProcessIdMatches`, `MatchesProcess`, `ResolveMostSpecific`, `ANY_PROCESS` | Process-ID utilities. |
386
+ | `EffectiveProcessId`, `DYNAMIC_PACKAGES_PROCESS_ENV_VAR` | Nested-host identity propagation. |
387
+ | `ResolveDynamicPackagesMode`, `DYNAMIC_PACKAGES_MODE_ENV_VAR` | Mode precedence. |
388
+ | `DiscoverGeneratedPackages`, `DiscoverAppManifestPackages`, `FindWorkspacePackageDir`, `ReadDynamicPackagesConfig`, `GENERATED_PACKAGE_TYPES_BY_TIER`, `APP_MANIFEST_FILE_NAME` | Discovery primitives. |
389
+
390
+ Every option and report field is documented inline in `src/types.ts`.
391
+
392
+ ## Testing a host against it
393
+
394
+ Unit tests for the loader itself live in `src/__tests__/` and cover discovery, matching, mode
395
+ precedence, host-anchored resolution and the loader loop with stubbed imports.
396
+
397
+ The proof that a package **not statically imported anywhere** really registers into the calling
398
+ process's ClassFactory lives with the first host that needed it: `packages/MJCLI/src/__tests__/dynamic-packages.registration.test.ts`
399
+ loads a committed miniature Open App (`fixtures/fixture-open-app`, an `mj-app.json` plus plain-ESM
400
+ entities and server packages) through both the `mj-app.json` path and a `dynamicPackages.server[]`
401
+ entry resolved through a throwaway host `node_modules`, then asserts on `MJGlobal.Instance.ClassFactory.GetRegistration(BaseEntity, …)`.
402
+ Copy that shape to prove the same thing for a new host.
@@ -0,0 +1,38 @@
1
+ import type { DiscoveredDynamicPackage, DynamicPackageTier, DynamicPackagesConfig } from './types.js';
2
+ /**
3
+ * `codeGeneration.packages.<type>.name` keys that make sense for each tier. `angularForms` is
4
+ * a browser library and must never be imported into a Node process; `graphqlResolvers` has no
5
+ * meaning in a browser bundle.
6
+ */
7
+ export declare const GENERATED_PACKAGE_TYPES_BY_TIER: Record<DynamicPackageTier, readonly string[]>;
8
+ /** The manifest file every Open App repository carries at its root. */
9
+ export declare const APP_MANIFEST_FILE_NAME = "mj-app.json";
10
+ /** Reads the `dynamicPackages` section off a raw config object, tolerating any shape. */
11
+ export declare function ReadDynamicPackagesConfig(config: Record<string, unknown> | null | undefined): DynamicPackagesConfig;
12
+ /** Entries for the host's own generated packages (`codeGeneration.packages`). */
13
+ export declare function DiscoverGeneratedPackages(config: Record<string, unknown> | null | undefined, tier: DynamicPackageTier): DiscoveredDynamicPackage[];
14
+ export interface AppManifestDiscovery {
15
+ /** Directory the manifest was read from. */
16
+ RepoDir: string;
17
+ /** `mj-app.json` `name`. */
18
+ AppName: string;
19
+ /** Directory under the repo that holds the app's workspace packages (`code.sourceDirectory`, default `packages`). */
20
+ SourceDirectory: string;
21
+ Entries: DiscoveredDynamicPackage[];
22
+ }
23
+ /**
24
+ * Reads `mj-app.json` from `repoDir` and returns the packages a process of `tier` should
25
+ * load: `shared` libraries first (entities/actions register on import), then the tier's own
26
+ * packages, with each `startupExport` carried through. Returns `null` when there is no
27
+ * manifest, and throws only when a manifest exists but is not valid JSON — a corrupt file is
28
+ * a problem the operator must see, an absent one is the common case.
29
+ */
30
+ export declare function DiscoverAppManifestPackages(repoDir: string, tier: DynamicPackageTier): AppManifestDiscovery | null;
31
+ /**
32
+ * Locates a workspace package by name under an app repo's source directory (one level deep,
33
+ * matching `code.sourceDirectory`), returning its directory. Used when the process runs inside
34
+ * the app's own repository: the package is a workspace member there, but under pnpm's strict
35
+ * layout nothing at the repo root can `require.resolve` it, so we find it on disk instead.
36
+ */
37
+ export declare function FindWorkspacePackageDir(repoDir: string, sourceDirectory: string, packageName: string): string | null;
38
+ //# sourceMappingURL=discover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,wBAAwB,EAAuB,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAE3H;;;;GAIG;AACH,eAAO,MAAM,+BAA+B,EAAE,MAAM,CAAC,kBAAkB,EAAE,SAAS,MAAM,EAAE,CAGzF,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD,yFAAyF;AACzF,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,qBAAqB,CAWnH;AAkDD,iFAAiF;AACjF,wBAAgB,yBAAyB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,EAClD,IAAI,EAAE,kBAAkB,GACzB,wBAAwB,EAAE,CAc5B;AAcD,MAAM,WAAW,oBAAoB;IACjC,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,qHAAqH;IACrH,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,wBAAwB,EAAE,CAAC;CACvC;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,kBAAkB,GAAG,oBAAoB,GAAG,IAAI,CAwClH;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA0BpH"}