Commit 1ead32d794198565a9b44b8ee57d443ffa4c43b4
1 parent
7af11f2f
feat(metadata): P1.5 — metadata store seeding
Adds the foundation for the entire Tier 1 customization story. Core
PBCs and plug-ins now ship YAML files declaring their entities,
permissions, and menus; a `MetadataLoader` walks the host classpath
and each plug-in JAR at boot, upserts the rows tagged with their
source, and exposes them at a public REST endpoint so the future
SPA, AI-agent function catalog, OpenAPI generator, and external
introspection tooling can all see what the framework offers without
scraping code.
What landed:
* New `platform/platform-metadata/` Gradle subproject. Depends on
api-v1 + platform-persistence + jackson-yaml + spring-jdbc.
* `MetadataYamlFile` DTOs (entities, permissions, menus). Forward-
compatible: unknown top-level keys are ignored, so a future plug-in
built against a newer schema (forms, workflows, rules, translations)
loads cleanly on an older host that doesn't know those sections yet.
* `MetadataLoader` with two entry points:
loadCore() — uses Spring's PathMatchingResourcePatternResolver
against the host classloader. Finds every classpath*:META-INF/
vibe-erp/metadata/*.yml across all jars contributing to the
application. Tagged source='core'.
loadFromPluginJar(pluginId, jarPath) — opens ONE specific
plug-in JAR via java.util.jar.JarFile and walks its entries
directly. This is critical: a plug-in's PluginClassLoader is
parent-first, so a classpath*: scan against it would ALSO
pick up the host's metadata files via parent classpath. We
saw this in the first smoke run — the plug-in source ended
up with 6 entities (the plug-in's 2 + the host's 4) before
the fix. Walking the JAR file directly guarantees only the
plug-in's own files load. Tagged source='plugin:<id>'.
Both entry points use the same delete-then-insert idempotent core
(doLoad). Loading the same source twice produces the same final
state. User-edited metadata (source='user') is NEVER touched by
either path — it survives boot, plug-in install, and plug-in
upgrade. This is what lets a future SPA "Customize" UI add custom
fields without fearing they'll be wiped on the next deploy.
* `VibeErpPluginManager.afterPropertiesSet()` now calls
metadataLoader.loadCore() at the very start, then walks plug-ins
and calls loadFromPluginJar(...) for each one between Liquibase
migration and start(context). Order is guaranteed: core → linter
→ migrate → metadata → start. The CommandLineRunner I originally
put `loadCore()` in turned out to be wrong because Spring runs
CommandLineRunners AFTER InitializingBean.afterPropertiesSet(),
so the plug-in metadata was loading BEFORE core — the wrong way
around. Calling loadCore() inline in the plug-in manager fixes
the ordering without any @Order(...) gymnastics.
* `MetadataController` exposes:
GET /api/v1/_meta/metadata — all three sections
GET /api/v1/_meta/metadata/entities — entities only
GET /api/v1/_meta/metadata/permissions
GET /api/v1/_meta/metadata/menus
Public allowlist (covered by the existing /api/v1/_meta/** rule
in SecurityConfiguration). The metadata is intentionally non-
sensitive — entity names, permission keys, menu paths. Nothing
in here is PII or secret; the SPA needs to read it before the
user has logged in.
* YAML files shipped:
- pbc-identity/META-INF/vibe-erp/metadata/identity.yml
(User + Role entities, 6 permissions, Users + Roles menus)
- pbc-catalog/META-INF/vibe-erp/metadata/catalog.yml
(Item + Uom entities, 7 permissions, Items + UoMs menus)
- reference plug-in/META-INF/vibe-erp/metadata/printing-shop.yml
(Plate + InkRecipe entities, 5 permissions, Plates + Inks menus
in a "Printing shop" section)
Tests: 4 MetadataLoaderTest cases (loadFromPluginJar happy paths,
mixed sections, blank pluginId rejection, missing-file no-op wipe)
+ 7 MetadataYamlParseTest cases (DTO mapping, optional fields,
section defaults, forward-compat unknown keys). Total now
**92 unit tests** across 11 modules, all green.
End-to-end smoke test against fresh Postgres + plug-in loaded:
Boot logs:
MetadataLoader: source='core' loaded 4 entities, 13 permissions,
4 menus from 2 file(s)
MetadataLoader: source='plugin:printing-shop' loaded 2 entities,
5 permissions, 2 menus from 1 file(s)
HTTP smoke (everything green):
GET /api/v1/_meta/metadata (no auth) → 200
6 entities, 18 permissions, 6 menus
entity names: User, Role, Item, Uom, Plate, InkRecipe
menu sections: Catalog, Printing shop, System
GET /api/v1/_meta/metadata/entities → 200
GET /api/v1/_meta/metadata/menus → 200
Direct DB verification:
metadata__entity: core=4, plugin:printing-shop=2
metadata__permission: core=13, plugin:printing-shop=5
metadata__menu: core=4, plugin:printing-shop=2
Idempotency: restart the app, identical row counts.
Existing endpoints regression:
GET /api/v1/identity/users (Bearer) → 1 user
GET /api/v1/catalog/uoms (Bearer) → 15 UoMs
GET /api/v1/plugins/printing-shop/ping (Bearer) → 200
Bugs caught and fixed during the smoke test:
• The first attempt loaded core metadata via a CommandLineRunner
annotated @Order(HIGHEST_PRECEDENCE) and per-plug-in metadata
inline in VibeErpPluginManager.afterPropertiesSet(). Spring
runs all InitializingBeans BEFORE any CommandLineRunner, so
the plug-in metadata loaded first and the core load came
second — wrong order. Fix: drop CoreMetadataInitializer
entirely; have the plug-in manager call metadataLoader.loadCore()
directly at the start of afterPropertiesSet().
• The first attempt's plug-in load used
metadataLoader.load(pluginClassLoader, ...) which used Spring's
PathMatchingResourcePatternResolver against the plug-in's
classloader. PluginClassLoader is parent-first, so the resolver
enumerated BOTH the plug-in's own JAR AND the host classpath's
metadata files, tagging core entities as source='plugin:<id>'
and corrupting the seed counts. Fix: refactor MetadataLoader
to expose loadFromPluginJar(pluginId, jarPath) which opens
the plug-in JAR directly via java.util.jar.JarFile and walks
its entries — never asking the classloader at all. The
api-v1 surface didn't change.
• Two KDoc comments contained the literal string `*.yml` after
a `/` character (`/metadata/*.yml`), forming the `/*` pattern
that Kotlin's lexer treats as a nested-comment opener. The
file failed to compile with "Unclosed comment". This is the
third time I've hit this trap; rewriting both KDocs to avoid
the literal `/*` sequence.
• The MetadataLoaderTest's hand-rolled JAR builder didn't include
explicit directory entries for parent paths. Real Gradle JARs
do include them, and Spring's PathMatchingResourcePatternResolver
needs them to enumerate via classpath*:. Fixed the test helper
to write directory entries for every parent of each file.
Implementation plan refreshed: P1.5 marked DONE. Next priority
candidates: P5.2 (pbc-partners — third PBC clone) and P3.4 (custom
field application via the ext jsonb column, which would unlock the
full Tier 1 customization story).
Framework state: 17→18 commits, 10→11 modules, 81→92 unit tests,
metadata seeded for 6 entities + 18 permissions + 6 menus.
Showing
15 changed files
with
998 additions
and
19 deletions
distribution/build.gradle.kts
| ... | ... | @@ -24,6 +24,7 @@ dependencies { |
| 24 | 24 | implementation(project(":platform:platform-plugins")) |
| 25 | 25 | implementation(project(":platform:platform-security")) |
| 26 | 26 | implementation(project(":platform:platform-events")) |
| 27 | + implementation(project(":platform:platform-metadata")) | |
| 27 | 28 | implementation(project(":pbc:pbc-identity")) |
| 28 | 29 | implementation(project(":pbc:pbc-catalog")) |
| 29 | 30 | ... | ... |
docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
| ... | ... | @@ -12,18 +12,19 @@ |
| 12 | 12 | > previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant |
| 13 | 13 | > routing) units are gone. See CLAUDE.md guardrail #5 for the rationale. |
| 14 | 14 | > |
| 15 | -> **2026-04-08 update — P4.1, P5.1, P1.3, P1.7, P1.4, P1.2 all landed.** | |
| 16 | -> Auth (Argon2id + JWT + bootstrap admin), pbc-catalog (items + UoMs), | |
| 17 | -> plug-in HTTP endpoints (registry + dispatcher + DefaultPluginContext), | |
| 18 | -> event bus + transactional outbox + audit subscriber, plug-in-owned | |
| 19 | -> Liquibase changelogs (the reference plug-in now has its own database | |
| 20 | -> tables), and the plug-in linter (ASM bytecode scan rejecting forbidden | |
| 21 | -> imports) are all done and smoke-tested end-to-end. The reference | |
| 22 | -> printing-shop plug-in has graduated from "hello world" to a real | |
| 23 | -> customer demonstration: it owns its own DB schema, CRUDs plates and | |
| 24 | -> ink recipes via REST through `context.jdbc`, and would be rejected at | |
| 25 | -> install time if it tried to import internal framework classes. The | |
| 26 | -> framework is now at **81 unit tests** across 10 modules, all green. | |
| 15 | +> **2026-04-08 update — P4.1, P5.1, P1.3, P1.7, P1.4, P1.2, P1.5 all | |
| 16 | +> landed.** Auth, pbc-catalog, plug-in HTTP endpoints, event bus + | |
| 17 | +> outbox, plug-in-owned Liquibase changelogs, plug-in linter, and now | |
| 18 | +> **the metadata loader** are all done and smoke-tested end-to-end. | |
| 19 | +> Core PBCs and the reference plug-in ship YAML files declaring their | |
| 20 | +> entities, permissions, and menus; the loader walks the host | |
| 21 | +> classpath and each plug-in JAR at boot, upserts the rows tagged | |
| 22 | +> with `source = 'core'` or `source = 'plugin:<id>'`, and exposes | |
| 23 | +> them at the public `GET /api/v1/_meta/metadata` endpoint so the | |
| 24 | +> future SPA, OpenAPI generator, and AI-agent function catalog can | |
| 25 | +> introspect what entities and permissions exist. Idempotent across | |
| 26 | +> restarts (delete-by-source then insert). The framework is now at | |
| 27 | +> **92 unit tests** across 11 modules, all green. | |
| 27 | 28 | |
| 28 | 29 | --- |
| 29 | 30 | |
| ... | ... | @@ -96,11 +97,9 @@ These units finish the platform layer so PBCs can be implemented without inventi |
| 96 | 97 | **Module:** `platform-plugins` + `api.v1` (added `PluginJdbc`/`PluginRow`) |
| 97 | 98 | **What landed:** `PluginLiquibaseRunner` looks for `META-INF/vibe-erp/db/changelog.xml` inside the plug-in JAR via the PF4J classloader, applies it via Liquibase against the host's shared DataSource. Convention: tables use `plugin_<id>__*` prefix (linter will enforce in a future version). The reference printing-shop plug-in now has two real tables (`plugin_printingshop__plate`, `plugin_printingshop__ink_recipe`) and CRUDs them via REST through the new `context.jdbc` typed-SQL surface in api.v1. Plug-ins never see Spring's JdbcTemplate. **Deferred:** per-plug-in datasource isolation, table-prefix linter, rollback on uninstall. |
| 98 | 99 | |
| 99 | -### P1.5 — Metadata store seeding | |
| 100 | -**Module:** new `platform-metadata` module (or expand `platform-persistence`) | |
| 101 | -**Depends on:** — | |
| 102 | -**What:** A `MetadataLoader` Spring component that, on boot and on plug-in start, upserts rows into `metadata__entity`, `metadata__form`, `metadata__workflow`, etc. from YAML files in the classpath / plug-in JAR. Each row carries `source = 'core' | 'plugin:<id>' | 'user'`. Idempotent; safe to run on every boot. | |
| 103 | -**Acceptance:** integration test confirms that booting twice doesn't duplicate rows; uninstalling a plug-in removes its `source = 'plugin:<id>'` rows; user-edited rows (`source = 'user'`) are never touched. | |
| 100 | +### ✅ P1.5 — Metadata store seeding (DONE 2026-04-08) | |
| 101 | +**Module:** new `platform-metadata` module | |
| 102 | +**What landed:** `MetadataLoader` Spring component with two entry points: `loadCore()` uses Spring's `PathMatchingResourcePatternResolver` against the host classpath (`classpath*:META-INF/vibe-erp/metadata/*.yml`), and `loadFromPluginJar(pluginId, jarPath)` opens a specific plug-in JAR via `java.util.jar.JarFile` and walks its entries directly — critical because the plug-in's parent-first `PluginClassLoader` would otherwise pick up the host's metadata files via parent classpath. `MetadataYamlFile` DTOs cover `entities`, `permissions`, `menus` (extensible: `forms`, `workflows`, `rules`, `translations` are future top-level keys, ignored gracefully). Delete-by-source-then-insert pattern is naturally idempotent and never touches `source='user'` rows. `VibeErpPluginManager.afterPropertiesSet()` calls `loadCore()` first, then walks plug-ins and calls `loadFromPluginJar(...)` for each one. New `GET /api/v1/_meta/metadata` REST endpoint (public, no auth needed) returns the seeded metadata for SPA/AI-agent introspection. pbc-identity, pbc-catalog, and the reference plug-in each ship a metadata YAML. **Verified end-to-end:** 4 core entities + 2 plug-in entities, 13 core permissions + 5 plug-in permissions, 4 core menus + 2 plug-in menus, idempotent across restarts. 11 new unit tests. | |
| 104 | 103 | |
| 105 | 104 | ### P1.6 — `Translator` implementation backed by ICU4J |
| 106 | 105 | **Module:** new `platform-i18n` module |
| ... | ... | @@ -351,9 +350,9 @@ REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3 |
| 351 | 350 | ``` |
| 352 | 351 | |
| 353 | 352 | Sensible ordering for one developer (single-tenant world): |
| 354 | -**~~P4.1~~ ✅ → ~~P5.1~~ ✅ → ~~P1.3~~ ✅ → ~~P1.7~~ ✅ → ~~P1.4~~ ✅ → ~~P1.2~~ ✅ → P1.5 → P1.6 → P5.2 → P2.1 → P5.3 → P3.4 → R1 → ...** | |
| 353 | +**~~P4.1~~ ✅ → ~~P5.1~~ ✅ → ~~P1.3~~ ✅ → ~~P1.7~~ ✅ → ~~P1.4~~ ✅ → ~~P1.2~~ ✅ → ~~P1.5~~ ✅ → P5.2 → P1.6 → P3.4 → P5.3 → P2.1 → R1 → ...** | |
| 355 | 354 | |
| 356 | -**Next up after this commit:** P1.5 (metadata seeder) and P5.2 (pbc-partners) are the strongest candidates. Metadata seeder unblocks the entire Tier 1 customization story; pbc-partners adds another business surface (customers / suppliers) that sales orders will reference. Either is a clean ~half-day chunk. | |
| 355 | +**Next up after this commit:** P5.2 (pbc-partners) is the strongest next candidate — adds customers/suppliers/contacts which everything downstream (sales orders, purchase orders, deliveries) will reference, and validates the pbc-identity template scales. P1.6 (ICU4J translator) is another clean ~half-day chunk if breadth-first feels right. P3.4 (custom field application via JSONB ext column) is the natural pair to P1.5 — together they unlock the Tier 1 customization story. | |
| 357 | 356 | |
| 358 | 357 | Sensible parallel ordering for a team of three: |
| 359 | 358 | - Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1 | ... | ... |
gradle/libs.versions.toml
| ... | ... | @@ -33,6 +33,7 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " |
| 33 | 33 | kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } |
| 34 | 34 | jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } |
| 35 | 35 | jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } |
| 36 | +jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } | |
| 36 | 37 | |
| 37 | 38 | # Bytecode analysis |
| 38 | 39 | asm = { module = "org.ow2.asm:asm", version = "9.7.1" } | ... | ... |
pbc/pbc-catalog/src/main/resources/META-INF/vibe-erp/metadata/catalog.yml
0 → 100644
| 1 | +# pbc-catalog metadata. | |
| 2 | +# | |
| 3 | +# Loaded at boot by MetadataLoader, tagged source='core'. | |
| 4 | +# This declares what the catalog PBC offers to the rest of the framework | |
| 5 | +# and the SPA: which entities exist, which permissions guard them, | |
| 6 | +# which menu entries point at them. | |
| 7 | + | |
| 8 | +entities: | |
| 9 | + - name: Item | |
| 10 | + pbc: catalog | |
| 11 | + table: catalog__item | |
| 12 | + description: A thing that can be bought, sold, made, stored, or invoiced | |
| 13 | + | |
| 14 | + - name: Uom | |
| 15 | + pbc: catalog | |
| 16 | + table: catalog__uom | |
| 17 | + description: A unit of measure (kg, m, ea, sheet, …) | |
| 18 | + | |
| 19 | +permissions: | |
| 20 | + - key: catalog.item.read | |
| 21 | + description: Read catalog item records | |
| 22 | + - key: catalog.item.create | |
| 23 | + description: Create catalog items | |
| 24 | + - key: catalog.item.update | |
| 25 | + description: Update catalog items | |
| 26 | + - key: catalog.item.deactivate | |
| 27 | + description: Deactivate catalog items (preserved for historical references) | |
| 28 | + - key: catalog.uom.read | |
| 29 | + description: Read units of measure | |
| 30 | + - key: catalog.uom.create | |
| 31 | + description: Create custom units of measure | |
| 32 | + - key: catalog.uom.update | |
| 33 | + description: Update unit-of-measure display name or dimension | |
| 34 | + | |
| 35 | +menus: | |
| 36 | + - path: /catalog/items | |
| 37 | + label: Items | |
| 38 | + icon: package | |
| 39 | + section: Catalog | |
| 40 | + order: 200 | |
| 41 | + - path: /catalog/uoms | |
| 42 | + label: Units of measure | |
| 43 | + icon: ruler | |
| 44 | + section: Catalog | |
| 45 | + order: 210 | ... | ... |
pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml
0 → 100644
| 1 | +# pbc-identity metadata. | |
| 2 | +# | |
| 3 | +# Loaded at boot by org.vibeerp.platform.metadata.MetadataLoader, | |
| 4 | +# tagged source='core'. Re-loaded on every boot from the YAML below | |
| 5 | +# (delete-then-insert), so this file is the source of truth — edits | |
| 6 | +# made via the future SPA customization UI live in metadata__* rows | |
| 7 | +# tagged source='user' and are NEVER touched by the loader. | |
| 8 | + | |
| 9 | +entities: | |
| 10 | + - name: User | |
| 11 | + pbc: identity | |
| 12 | + table: identity__user | |
| 13 | + description: A user account in the framework | |
| 14 | + | |
| 15 | + - name: Role | |
| 16 | + pbc: identity | |
| 17 | + table: identity__role | |
| 18 | + description: A named bundle of permissions assignable to users | |
| 19 | + | |
| 20 | +permissions: | |
| 21 | + - key: identity.user.read | |
| 22 | + description: Read user records | |
| 23 | + - key: identity.user.create | |
| 24 | + description: Create new user records | |
| 25 | + - key: identity.user.update | |
| 26 | + description: Update user records | |
| 27 | + - key: identity.user.disable | |
| 28 | + description: Disable a user (soft-delete; row is preserved for audit) | |
| 29 | + - key: identity.role.read | |
| 30 | + description: Read role records | |
| 31 | + - key: identity.role.assign | |
| 32 | + description: Assign a role to a user | |
| 33 | + | |
| 34 | +menus: | |
| 35 | + - path: /identity/users | |
| 36 | + label: Users | |
| 37 | + icon: people | |
| 38 | + section: System | |
| 39 | + order: 100 | |
| 40 | + - path: /identity/roles | |
| 41 | + label: Roles | |
| 42 | + icon: shield-lock | |
| 43 | + section: System | |
| 44 | + order: 110 | ... | ... |
platform/platform-metadata/build.gradle.kts
0 → 100644
| 1 | +plugins { | |
| 2 | + alias(libs.plugins.kotlin.jvm) | |
| 3 | + alias(libs.plugins.kotlin.spring) | |
| 4 | + alias(libs.plugins.spring.dependency.management) | |
| 5 | +} | |
| 6 | + | |
| 7 | +description = "vibe_erp metadata loader — reads YAML files from classpath and plug-in JARs, upserts to metadata__* tables. INTERNAL." | |
| 8 | + | |
| 9 | +java { | |
| 10 | + toolchain { | |
| 11 | + languageVersion.set(JavaLanguageVersion.of(21)) | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 15 | +kotlin { | |
| 16 | + jvmToolchain(21) | |
| 17 | + compilerOptions { | |
| 18 | + freeCompilerArgs.add("-Xjsr305=strict") | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +dependencies { | |
| 23 | + api(project(":api:api-v1")) | |
| 24 | + api(project(":platform:platform-persistence")) | |
| 25 | + | |
| 26 | + implementation(libs.kotlin.stdlib) | |
| 27 | + implementation(libs.kotlin.reflect) | |
| 28 | + implementation(libs.jackson.module.kotlin) | |
| 29 | + implementation(libs.jackson.dataformat.yaml) | |
| 30 | + implementation(libs.jackson.datatype.jsr310) | |
| 31 | + | |
| 32 | + implementation(libs.spring.boot.starter) | |
| 33 | + implementation(libs.spring.boot.starter.web) // for the @RestController | |
| 34 | + implementation(libs.spring.boot.starter.data.jpa) // for DataSource + JdbcTemplate | |
| 35 | + | |
| 36 | + testImplementation(libs.spring.boot.starter.test) | |
| 37 | + testImplementation(libs.junit.jupiter) | |
| 38 | + testImplementation(libs.assertk) | |
| 39 | + testImplementation(libs.mockk) | |
| 40 | +} | |
| 41 | + | |
| 42 | +tasks.test { | |
| 43 | + useJUnitPlatform() | |
| 44 | +} | ... | ... |
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt
0 → 100644
| 1 | +package org.vibeerp.platform.metadata | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper | |
| 4 | +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory | |
| 5 | +import com.fasterxml.jackson.module.kotlin.registerKotlinModule | |
| 6 | +import org.slf4j.LoggerFactory | |
| 7 | +import org.springframework.core.io.support.PathMatchingResourcePatternResolver | |
| 8 | +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource | |
| 9 | +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate | |
| 10 | +import org.springframework.stereotype.Component | |
| 11 | +import org.springframework.transaction.annotation.Transactional | |
| 12 | +import org.vibeerp.platform.metadata.yaml.MetadataYamlFile | |
| 13 | +import java.nio.file.Files | |
| 14 | +import java.nio.file.Path | |
| 15 | +import java.sql.Timestamp | |
| 16 | +import java.time.Instant | |
| 17 | +import java.util.UUID | |
| 18 | +import java.util.jar.JarFile | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * Loads metadata YAML files (any `.yml` under `META-INF/vibe-erp/metadata/`) | |
| 22 | + * from the host classpath or from individual plug-in JARs and upserts | |
| 23 | + * their contents into the `metadata__*` tables. | |
| 24 | + * | |
| 25 | + * **Two entry points, deliberately different:** | |
| 26 | + * | |
| 27 | + * • [loadCore] uses Spring's `PathMatchingResourcePatternResolver` | |
| 28 | + * against the host's classloader. The host owns its classpath, so | |
| 29 | + * `classpath*:` resolution is exactly the right tool — every JAR | |
| 30 | + * contributing to the application gets scanned. | |
| 31 | + * | |
| 32 | + * • [loadFromPluginJar] opens ONE specific plug-in JAR file via | |
| 33 | + * [JarFile] and walks its entries directly. This is critical: a | |
| 34 | + * plug-in's `PluginClassLoader` is parent-first by default, so a | |
| 35 | + * `classpath*:` scan against it ALSO finds the host's metadata | |
| 36 | + * files via the parent classloader. Walking the JAR file directly | |
| 37 | + * guarantees only the plug-in's own files get loaded. | |
| 38 | + * | |
| 39 | + * **Idempotency.** Loading the same source twice produces the same | |
| 40 | + * final database state. The mechanism is "delete by source, then | |
| 41 | + * insert" inside one transaction: | |
| 42 | + * 1. `DELETE FROM metadata__entity WHERE source = :source` | |
| 43 | + * 2. `INSERT` every entity from every YAML file with that source | |
| 44 | + * 3. Same for `metadata__permission` and `metadata__menu` | |
| 45 | + * The whole load runs in a single transaction so a failure leaves the | |
| 46 | + * old metadata in place. User-edited rows (`source = 'user'`) are NEVER | |
| 47 | + * touched — they survive boot, plug-in install, and plug-in upgrade. | |
| 48 | + * | |
| 49 | + * **Why delete-and-insert instead of upsert.** Upsert needs a stable | |
| 50 | + * natural key per row, which would force the YAML to declare unique | |
| 51 | + * ids and would make renames painful. Delete-by-source treats the | |
| 52 | + * YAML as the authoritative declaration: whatever is in the YAML at | |
| 53 | + * boot time is what the database has. Plug-in uninstall is the same | |
| 54 | + * delete query. | |
| 55 | + * | |
| 56 | + * Reference: architecture spec section 8 ("The metadata store") and | |
| 57 | + * implementation plan unit P1.5. | |
| 58 | + */ | |
| 59 | +@Component | |
| 60 | +class MetadataLoader( | |
| 61 | + private val jdbc: NamedParameterJdbcTemplate, | |
| 62 | +) { | |
| 63 | + | |
| 64 | + private val log = LoggerFactory.getLogger(MetadataLoader::class.java) | |
| 65 | + | |
| 66 | + private val yamlMapper: ObjectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule() | |
| 67 | + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | |
| 68 | + | |
| 69 | + /** | |
| 70 | + * Load every metadata YAML on the host classpath as `source='core'`. | |
| 71 | + * Called once at boot from `VibeErpPluginManager.afterPropertiesSet()` | |
| 72 | + * BEFORE any plug-in is processed, so the metadata table contains | |
| 73 | + * the core entries by the time plug-in metadata layers on top. | |
| 74 | + */ | |
| 75 | + @Transactional | |
| 76 | + fun loadCore(): LoadResult { | |
| 77 | + val source = SOURCE_CORE | |
| 78 | + val resolver = PathMatchingResourcePatternResolver(javaClass.classLoader) | |
| 79 | + val resources = try { | |
| 80 | + resolver.getResources("classpath*:META-INF/vibe-erp/metadata/*.yml") | |
| 81 | + } catch (ex: Throwable) { | |
| 82 | + log.warn("MetadataLoader: failed to scan host classpath for metadata YAMLs: {}", ex.message, ex) | |
| 83 | + return doLoad(source, emptyList()) | |
| 84 | + } | |
| 85 | + val files = resources.mapNotNull { resource -> | |
| 86 | + try { | |
| 87 | + val parsed = resource.inputStream.use { stream -> | |
| 88 | + yamlMapper.readValue(stream, MetadataYamlFile::class.java) | |
| 89 | + } | |
| 90 | + ParsedYaml(resource.url?.toString() ?: resource.filename ?: "<unknown>", parsed) | |
| 91 | + } catch (ex: Throwable) { | |
| 92 | + log.error("MetadataLoader: failed to parse YAML resource {}: {}", resource.url, ex.message, ex) | |
| 93 | + null | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return doLoad(source, files) | |
| 97 | + } | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * Load metadata YAMLs from a specific plug-in JAR file as | |
| 101 | + * `source='plugin:<id>'`. Walks the JAR entries directly to avoid | |
| 102 | + * picking up host classpath files via the plug-in's parent-first | |
| 103 | + * `PluginClassLoader`. | |
| 104 | + * | |
| 105 | + * @param pluginId the plug-in id, used to tag the rows. | |
| 106 | + * @param jarPath path to the plug-in JAR on disk. Resolved by the | |
| 107 | + * host's `VibeErpPluginManager` from PF4J's `PluginWrapper`. | |
| 108 | + */ | |
| 109 | + @Transactional | |
| 110 | + fun loadFromPluginJar(pluginId: String, jarPath: Path): LoadResult { | |
| 111 | + require(pluginId.isNotBlank()) { "pluginId must not be blank" } | |
| 112 | + val source = "$SOURCE_PLUGIN_PREFIX$pluginId" | |
| 113 | + | |
| 114 | + if (!Files.isRegularFile(jarPath)) { | |
| 115 | + log.warn( | |
| 116 | + "MetadataLoader: plug-in '{}' jarPath {} is not a regular file; skipping (probably loaded as unpacked dir)", | |
| 117 | + pluginId, jarPath, | |
| 118 | + ) | |
| 119 | + return doLoad(source, emptyList()) | |
| 120 | + } | |
| 121 | + | |
| 122 | + val files = mutableListOf<ParsedYaml>() | |
| 123 | + JarFile(jarPath.toFile()).use { jar -> | |
| 124 | + val entries = jar.entries() | |
| 125 | + while (entries.hasMoreElements()) { | |
| 126 | + val entry = entries.nextElement() | |
| 127 | + if (entry.isDirectory) continue | |
| 128 | + val name = entry.name | |
| 129 | + if (!name.startsWith(METADATA_DIR) || !name.endsWith(YAML_SUFFIX)) continue | |
| 130 | + try { | |
| 131 | + val parsed = jar.getInputStream(entry).use { stream -> | |
| 132 | + yamlMapper.readValue(stream, MetadataYamlFile::class.java) | |
| 133 | + } | |
| 134 | + files += ParsedYaml(url = "$jarPath!$name", parsed = parsed) | |
| 135 | + } catch (ex: Throwable) { | |
| 136 | + log.error( | |
| 137 | + "MetadataLoader: plug-in '{}' failed to parse jar entry {}: {}", | |
| 138 | + pluginId, name, ex.message, ex, | |
| 139 | + ) | |
| 140 | + } | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + return doLoad(source, files) | |
| 145 | + } | |
| 146 | + | |
| 147 | + private fun doLoad(source: String, files: List<ParsedYaml>): LoadResult { | |
| 148 | + require(source.isNotBlank()) { "source must not be blank" } | |
| 149 | + | |
| 150 | + val merged = MetadataYamlFile( | |
| 151 | + entities = files.flatMap { it.parsed.entities }, | |
| 152 | + permissions = files.flatMap { it.parsed.permissions }, | |
| 153 | + menus = files.flatMap { it.parsed.menus }, | |
| 154 | + ) | |
| 155 | + | |
| 156 | + wipeBySource(source) | |
| 157 | + insertEntities(source, merged) | |
| 158 | + insertPermissions(source, merged) | |
| 159 | + insertMenus(source, merged) | |
| 160 | + | |
| 161 | + log.info( | |
| 162 | + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus from {} file(s)", | |
| 163 | + source, merged.entities.size, merged.permissions.size, merged.menus.size, files.size, | |
| 164 | + ) | |
| 165 | + | |
| 166 | + return LoadResult( | |
| 167 | + source = source, | |
| 168 | + entityCount = merged.entities.size, | |
| 169 | + permissionCount = merged.permissions.size, | |
| 170 | + menuCount = merged.menus.size, | |
| 171 | + files = files.map { it.url }, | |
| 172 | + ) | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** | |
| 176 | + * Result of one load() call. Used by callers for logging and by | |
| 177 | + * tests for assertions. | |
| 178 | + */ | |
| 179 | + data class LoadResult( | |
| 180 | + val source: String, | |
| 181 | + val entityCount: Int, | |
| 182 | + val permissionCount: Int, | |
| 183 | + val menuCount: Int, | |
| 184 | + val files: List<String>, | |
| 185 | + ) | |
| 186 | + | |
| 187 | + // ─── internals ───────────────────────────────────────────────── | |
| 188 | + | |
| 189 | + private fun wipeBySource(source: String) { | |
| 190 | + val params = MapSqlParameterSource("source", source) | |
| 191 | + jdbc.update("DELETE FROM metadata__entity WHERE source = :source", params) | |
| 192 | + jdbc.update("DELETE FROM metadata__permission WHERE source = :source", params) | |
| 193 | + jdbc.update("DELETE FROM metadata__menu WHERE source = :source", params) | |
| 194 | + } | |
| 195 | + | |
| 196 | + private fun insertEntities(source: String, file: MetadataYamlFile) { | |
| 197 | + val now = Timestamp.from(Instant.now()) | |
| 198 | + for (entity in file.entities) { | |
| 199 | + jdbc.update( | |
| 200 | + """ | |
| 201 | + INSERT INTO metadata__entity (id, source, payload, created_at, updated_at) | |
| 202 | + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) | |
| 203 | + """.trimIndent(), | |
| 204 | + MapSqlParameterSource() | |
| 205 | + .addValue("id", UUID.randomUUID()) | |
| 206 | + .addValue("source", source) | |
| 207 | + .addValue("payload", jsonMapper.writeValueAsString(entity)) | |
| 208 | + .addValue("now", now), | |
| 209 | + ) | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + private fun insertPermissions(source: String, file: MetadataYamlFile) { | |
| 214 | + val now = Timestamp.from(Instant.now()) | |
| 215 | + for (permission in file.permissions) { | |
| 216 | + jdbc.update( | |
| 217 | + """ | |
| 218 | + INSERT INTO metadata__permission (id, source, payload, created_at, updated_at) | |
| 219 | + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) | |
| 220 | + """.trimIndent(), | |
| 221 | + MapSqlParameterSource() | |
| 222 | + .addValue("id", UUID.randomUUID()) | |
| 223 | + .addValue("source", source) | |
| 224 | + .addValue("payload", jsonMapper.writeValueAsString(permission)) | |
| 225 | + .addValue("now", now), | |
| 226 | + ) | |
| 227 | + } | |
| 228 | + } | |
| 229 | + | |
| 230 | + private fun insertMenus(source: String, file: MetadataYamlFile) { | |
| 231 | + val now = Timestamp.from(Instant.now()) | |
| 232 | + for (menu in file.menus) { | |
| 233 | + jdbc.update( | |
| 234 | + """ | |
| 235 | + INSERT INTO metadata__menu (id, source, payload, created_at, updated_at) | |
| 236 | + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) | |
| 237 | + """.trimIndent(), | |
| 238 | + MapSqlParameterSource() | |
| 239 | + .addValue("id", UUID.randomUUID()) | |
| 240 | + .addValue("source", source) | |
| 241 | + .addValue("payload", jsonMapper.writeValueAsString(menu)) | |
| 242 | + .addValue("now", now), | |
| 243 | + ) | |
| 244 | + } | |
| 245 | + } | |
| 246 | + | |
| 247 | + private data class ParsedYaml( | |
| 248 | + val url: String, | |
| 249 | + val parsed: MetadataYamlFile, | |
| 250 | + ) | |
| 251 | + | |
| 252 | + companion object { | |
| 253 | + const val SOURCE_CORE: String = "core" | |
| 254 | + const val SOURCE_PLUGIN_PREFIX: String = "plugin:" | |
| 255 | + private const val METADATA_DIR: String = "META-INF/vibe-erp/metadata/" | |
| 256 | + private const val YAML_SUFFIX: String = ".yml" | |
| 257 | + } | |
| 258 | +} | ... | ... |
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt
0 → 100644
| 1 | +package org.vibeerp.platform.metadata.web | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper | |
| 4 | +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate | |
| 5 | +import org.springframework.web.bind.annotation.GetMapping | |
| 6 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 7 | +import org.springframework.web.bind.annotation.RestController | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Read-only REST endpoint that returns the seeded metadata. | |
| 11 | + * | |
| 12 | + * Mounted under `/api/v1/_meta/metadata` (next to the existing | |
| 13 | + * `/api/v1/_meta/info`) and explicitly **public** — the SPA needs | |
| 14 | + * to bootstrap its navigation, the AI-agent function catalog needs | |
| 15 | + * to discover entities, and external tooling needs to introspect | |
| 16 | + * the framework — all without holding a token. The metadata returned | |
| 17 | + * is intentionally non-sensitive: entity names, permission keys, menu | |
| 18 | + * paths. Nothing in here should ever be PII or secret. | |
| 19 | + * | |
| 20 | + * If a future version exposes more sensitive metadata (custom field | |
| 21 | + * definitions that mention column names of user data, for example), | |
| 22 | + * the controller can be split into a public surface and an | |
| 23 | + * authenticated-only surface without breaking the existing route. | |
| 24 | + */ | |
| 25 | +@RestController | |
| 26 | +@RequestMapping("/api/v1/_meta/metadata") | |
| 27 | +class MetadataController( | |
| 28 | + private val jdbc: NamedParameterJdbcTemplate, | |
| 29 | + private val objectMapper: ObjectMapper, | |
| 30 | +) { | |
| 31 | + | |
| 32 | + @GetMapping | |
| 33 | + fun all(): Map<String, Any> = mapOf( | |
| 34 | + "entities" to readPayloads("metadata__entity"), | |
| 35 | + "permissions" to readPayloads("metadata__permission"), | |
| 36 | + "menus" to readPayloads("metadata__menu"), | |
| 37 | + ) | |
| 38 | + | |
| 39 | + @GetMapping("/entities") | |
| 40 | + fun entities(): List<Map<String, Any?>> = readPayloads("metadata__entity") | |
| 41 | + | |
| 42 | + @GetMapping("/permissions") | |
| 43 | + fun permissions(): List<Map<String, Any?>> = readPayloads("metadata__permission") | |
| 44 | + | |
| 45 | + @GetMapping("/menus") | |
| 46 | + fun menus(): List<Map<String, Any?>> = readPayloads("metadata__menu") | |
| 47 | + | |
| 48 | + private fun readPayloads(table: String): List<Map<String, Any?>> { | |
| 49 | + return jdbc.query( | |
| 50 | + "SELECT source, payload FROM $table ORDER BY source", | |
| 51 | + emptyMap<String, Any?>(), | |
| 52 | + ) { rs, _ -> | |
| 53 | + val source = rs.getString("source") | |
| 54 | + val payloadJson = rs.getString("payload") ?: "{}" | |
| 55 | + @Suppress("UNCHECKED_CAST") | |
| 56 | + val payload = objectMapper.readValue(payloadJson, Map::class.java) as Map<String, Any?> | |
| 57 | + payload + mapOf("source" to source) | |
| 58 | + } | |
| 59 | + } | |
| 60 | +} | ... | ... |
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt
0 → 100644
| 1 | +package org.vibeerp.platform.metadata.yaml | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonIgnoreProperties | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The schema of a single metadata file under `META-INF/vibe-erp/metadata/` | |
| 7 | + * (any `.yml` filename). | |
| 8 | + * | |
| 9 | + * **Why one top-level file with multiple typed sections** instead of | |
| 10 | + * one file per type: | |
| 11 | + * - PBCs and plug-ins typically declare half a dozen tightly-related | |
| 12 | + * metadata items at once (an entity, three permissions, one menu | |
| 13 | + * entry). Forcing them into separate files would split a single | |
| 14 | + * feature across the filesystem and make code review harder. | |
| 15 | + * - The top-level keys (`entities`, `permissions`, `menus`) are the | |
| 16 | + * extension points. Adding a new key (`forms`, `workflows`, `rules`, | |
| 17 | + * `translations`) is non-breaking — old YAML files just don't have | |
| 18 | + * the new section, the loader skips it. | |
| 19 | + * | |
| 20 | + * **Source tagging.** The loader adds a `source` column to every row | |
| 21 | + * it persists: `core` for files on the host classpath, `plugin:<id>` | |
| 22 | + * for files inside a plug-in JAR, `user` for rows produced by the | |
| 23 | + * runtime customization UI (P3.x). The YAML itself never declares its | |
| 24 | + * source — that comes from where the file was loaded from, not from | |
| 25 | + * its content. Plug-ins cannot declare themselves as `core`. | |
| 26 | + * | |
| 27 | + * Reference: architecture spec section 8 ("The metadata store"). | |
| 28 | + */ | |
| 29 | +@JsonIgnoreProperties(ignoreUnknown = true) | |
| 30 | +data class MetadataYamlFile( | |
| 31 | + val entities: List<EntityYaml> = emptyList(), | |
| 32 | + val permissions: List<PermissionYaml> = emptyList(), | |
| 33 | + val menus: List<MenuYaml> = emptyList(), | |
| 34 | +) | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * A business entity exposed through the framework. | |
| 38 | + * | |
| 39 | + * Plug-ins and PBCs declare their entities here so the framework | |
| 40 | + * (and the future SPA, MCP server, AI agent function catalog, OpenAPI | |
| 41 | + * generator) can introspect what kinds of objects exist without | |
| 42 | + * scanning Hibernate or reading source code. | |
| 43 | + * | |
| 44 | + * @property name Display name. Convention: PascalCase singular ("User", | |
| 45 | + * "Item", "PrintingPlate"). | |
| 46 | + * @property pbc Owning Packaged Business Capability id. Convention: | |
| 47 | + * the Gradle subproject name without the `pbc-` prefix | |
| 48 | + * (`identity`, `catalog`, …) for core PBCs, the plug-in id for | |
| 49 | + * plug-in entities (`printing-shop`). | |
| 50 | + * @property table Underlying database table name. Used by the future | |
| 51 | + * custom-field machinery and audit log to know which JSONB `ext` | |
| 52 | + * column to write into. | |
| 53 | + * @property description Free-form one-line description shown in the | |
| 54 | + * customization UI and in the OpenAPI spec. | |
| 55 | + */ | |
| 56 | +@JsonIgnoreProperties(ignoreUnknown = true) | |
| 57 | +data class EntityYaml( | |
| 58 | + val name: String, | |
| 59 | + val pbc: String, | |
| 60 | + val table: String, | |
| 61 | + val description: String? = null, | |
| 62 | +) | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * A permission declared by a PBC or a plug-in. | |
| 66 | + * | |
| 67 | + * Permissions are how the framework's authorization layer (P4.3, not | |
| 68 | + * yet implemented) decides whether the current Principal can perform | |
| 69 | + * a given action. Declaring them in metadata serves three purposes: | |
| 70 | + * | |
| 71 | + * 1. The role editor in the SPA can list every available permission. | |
| 72 | + * 2. The framework can pre-check that every `@RequirePermission` | |
| 73 | + * annotation in code references a declared permission and warn | |
| 74 | + * on dangling references at boot. | |
| 75 | + * 3. The OpenAPI spec and the AI-agent function catalog can show | |
| 76 | + * "this endpoint requires X" without scraping code. | |
| 77 | + * | |
| 78 | + * @property key The permission key. Convention: | |
| 79 | + * `<pbc-or-plugin>.<resource>.<action>`. Lowercase, dot-separated. | |
| 80 | + * Examples: `identity.user.create`, `catalog.item.read`, | |
| 81 | + * `printingshop.plate.approve`. | |
| 82 | + * @property description One-line human-readable description. | |
| 83 | + */ | |
| 84 | +@JsonIgnoreProperties(ignoreUnknown = true) | |
| 85 | +data class PermissionYaml( | |
| 86 | + val key: String, | |
| 87 | + val description: String, | |
| 88 | +) | |
| 89 | + | |
| 90 | +/** | |
| 91 | + * A menu entry shown in the SPA's navigation. | |
| 92 | + * | |
| 93 | + * The framework's nav structure is data, not hard-coded React routes — | |
| 94 | + * adding a new top-level menu item from a plug-in is a YAML change, | |
| 95 | + * not a code change. The future SPA reads this list from | |
| 96 | + * `GET /api/v1/_meta/metadata` at boot and renders the navigation | |
| 97 | + * accordingly. | |
| 98 | + * | |
| 99 | + * @property path SPA route the entry navigates to. Convention: | |
| 100 | + * `/<pbc-or-plugin>/<resource>` (e.g. `/identity/users`). | |
| 101 | + * @property label Display label. Should be a translation key | |
| 102 | + * (`identity.menu.users`) once the i18n loader lands; for now it's | |
| 103 | + * a literal English string. | |
| 104 | + * @property icon Material/Tabler icon name. The SPA renders it; the | |
| 105 | + * framework just stores the string. | |
| 106 | + * @property section Top-level section the entry belongs to | |
| 107 | + * ("System", "Sales", "Production"). Entries with the same | |
| 108 | + * section group together in the nav. | |
| 109 | + * @property order Sort order within the section. Lower numbers | |
| 110 | + * appear first. | |
| 111 | + */ | |
| 112 | +@JsonIgnoreProperties(ignoreUnknown = true) | |
| 113 | +data class MenuYaml( | |
| 114 | + val path: String, | |
| 115 | + val label: String, | |
| 116 | + val icon: String? = null, | |
| 117 | + val section: String = "Other", | |
| 118 | + val order: Int = 1000, | |
| 119 | +) | ... | ... |
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.metadata | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.contains | |
| 6 | +import assertk.assertions.isEqualTo | |
| 7 | +import assertk.assertions.isInstanceOf | |
| 8 | +import io.mockk.every | |
| 9 | +import io.mockk.mockk | |
| 10 | +import io.mockk.slot | |
| 11 | +import io.mockk.verify | |
| 12 | +import org.junit.jupiter.api.BeforeEach | |
| 13 | +import org.junit.jupiter.api.Test | |
| 14 | +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource | |
| 15 | +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate | |
| 16 | +import java.nio.file.Files | |
| 17 | +import java.nio.file.Path | |
| 18 | +import java.util.jar.Attributes | |
| 19 | +import java.util.jar.JarEntry | |
| 20 | +import java.util.jar.JarOutputStream | |
| 21 | +import java.util.jar.Manifest | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * Unit tests for [MetadataLoader]. | |
| 25 | + * | |
| 26 | + * Strategy: build a tiny in-memory JAR containing a single | |
| 27 | + * `META-INF/vibe-erp/metadata/test.yml` file in a temp directory, | |
| 28 | + * load it via a `URLClassLoader`, point the loader at it with a | |
| 29 | + * mocked `NamedParameterJdbcTemplate`, and assert on the SQL the | |
| 30 | + * loader issues. We don't need a real database — the loader's | |
| 31 | + * contract is "given this YAML, issue these statements". | |
| 32 | + */ | |
| 33 | +class MetadataLoaderTest { | |
| 34 | + | |
| 35 | + private lateinit var jdbc: NamedParameterJdbcTemplate | |
| 36 | + private lateinit var loader: MetadataLoader | |
| 37 | + | |
| 38 | + @BeforeEach | |
| 39 | + fun setUp() { | |
| 40 | + jdbc = mockk(relaxed = false) | |
| 41 | + every { jdbc.update(any<String>(), any<MapSqlParameterSource>()) } returns 1 | |
| 42 | + loader = MetadataLoader(jdbc) | |
| 43 | + } | |
| 44 | + | |
| 45 | + @Test | |
| 46 | + fun `loadFromPluginJar on a missing file is a no-op wipe`() { | |
| 47 | + // The plug-in's pluginPath might not be a regular file (e.g. | |
| 48 | + // an unpacked dev directory). The loader should still issue | |
| 49 | + // the three DELETE statements so a previous-boot's leftovers | |
| 50 | + // for this plug-in get cleared, then return a zero result. | |
| 51 | + val nowhere = Path.of("/tmp/does-not-exist-${System.nanoTime()}.jar") | |
| 52 | + | |
| 53 | + val result = loader.loadFromPluginJar("ghost", nowhere) | |
| 54 | + | |
| 55 | + assertThat(result.entityCount).isEqualTo(0) | |
| 56 | + verify(atLeast = 1) { | |
| 57 | + jdbc.update(match<String> { it.contains("DELETE FROM metadata__entity") }, any<MapSqlParameterSource>()) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + fun `loadFromPluginJar with one entity issues one INSERT into metadata__entity`() { | |
| 63 | + val tempDir = Files.createTempDirectory("metadata-loader-test") | |
| 64 | + val jar = buildMetadataJar( | |
| 65 | + tempDir, | |
| 66 | + "META-INF/vibe-erp/metadata/test.yml", | |
| 67 | + """ | |
| 68 | + entities: | |
| 69 | + - name: Plate | |
| 70 | + pbc: printing-shop | |
| 71 | + table: plugin_printingshop__plate | |
| 72 | + description: An offset printing plate | |
| 73 | + """.trimIndent(), | |
| 74 | + ) | |
| 75 | + | |
| 76 | + val captured = slot<MapSqlParameterSource>() | |
| 77 | + every { | |
| 78 | + jdbc.update(match<String> { it.contains("INSERT INTO metadata__entity") }, capture(captured)) | |
| 79 | + } returns 1 | |
| 80 | + | |
| 81 | + val result = loader.loadFromPluginJar("printing-shop", jar) | |
| 82 | + | |
| 83 | + assertThat(result.entityCount).isEqualTo(1) | |
| 84 | + assertThat(result.permissionCount).isEqualTo(0) | |
| 85 | + assertThat(result.menuCount).isEqualTo(0) | |
| 86 | + assertThat(result.source).isEqualTo("plugin:printing-shop") | |
| 87 | + | |
| 88 | + val params = captured.captured | |
| 89 | + assertThat(params.getValue("source") as String).isEqualTo("plugin:printing-shop") | |
| 90 | + val payload = params.getValue("payload") as String | |
| 91 | + assertThat(payload).contains("Plate") | |
| 92 | + assertThat(payload).contains("printing-shop") | |
| 93 | + assertThat(payload).contains("plugin_printingshop__plate") | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Test | |
| 97 | + fun `loadFromPluginJar with mixed sections issues inserts into all three tables`() { | |
| 98 | + val tempDir = Files.createTempDirectory("metadata-loader-test") | |
| 99 | + val jar = buildMetadataJar( | |
| 100 | + tempDir, | |
| 101 | + "META-INF/vibe-erp/metadata/everything.yml", | |
| 102 | + """ | |
| 103 | + entities: | |
| 104 | + - name: User | |
| 105 | + pbc: identity | |
| 106 | + table: identity__user | |
| 107 | + permissions: | |
| 108 | + - key: identity.user.read | |
| 109 | + description: Read users | |
| 110 | + menus: | |
| 111 | + - path: /identity/users | |
| 112 | + label: Users | |
| 113 | + section: System | |
| 114 | + order: 100 | |
| 115 | + """.trimIndent(), | |
| 116 | + ) | |
| 117 | + | |
| 118 | + val result = loader.loadFromPluginJar("test-plugin", jar) | |
| 119 | + | |
| 120 | + assertThat(result.entityCount).isEqualTo(1) | |
| 121 | + assertThat(result.permissionCount).isEqualTo(1) | |
| 122 | + assertThat(result.menuCount).isEqualTo(1) | |
| 123 | + verify(exactly = 1) { | |
| 124 | + jdbc.update(match<String> { it.contains("INSERT INTO metadata__entity") }, any<MapSqlParameterSource>()) | |
| 125 | + } | |
| 126 | + verify(exactly = 1) { | |
| 127 | + jdbc.update(match<String> { it.contains("INSERT INTO metadata__permission") }, any<MapSqlParameterSource>()) | |
| 128 | + } | |
| 129 | + verify(exactly = 1) { | |
| 130 | + jdbc.update(match<String> { it.contains("INSERT INTO metadata__menu") }, any<MapSqlParameterSource>()) | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + @Test | |
| 135 | + fun `loadFromPluginJar rejects blank pluginId`() { | |
| 136 | + val nowhere = Path.of("/tmp/dummy.jar") | |
| 137 | + assertFailure { loader.loadFromPluginJar("", nowhere) } | |
| 138 | + .isInstanceOf(IllegalArgumentException::class) | |
| 139 | + assertFailure { loader.loadFromPluginJar(" ", nowhere) } | |
| 140 | + .isInstanceOf(IllegalArgumentException::class) | |
| 141 | + } | |
| 142 | + | |
| 143 | + // ─── helpers ─────────────────────────────────────────────────── | |
| 144 | + | |
| 145 | + private fun buildMetadataJar( | |
| 146 | + tempDir: Path, | |
| 147 | + entryName: String, | |
| 148 | + contents: String, | |
| 149 | + ): Path { | |
| 150 | + val jar = tempDir.resolve("metadata-test-${System.nanoTime()}.jar") | |
| 151 | + val manifest = Manifest().apply { | |
| 152 | + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" | |
| 153 | + } | |
| 154 | + Files.newOutputStream(jar).use { fos -> | |
| 155 | + JarOutputStream(fos, manifest).use { jos -> | |
| 156 | + // Spring's PathMatchingResourcePatternResolver with the | |
| 157 | + // `classpath*:` + `*.yml` pattern enumerates parent | |
| 158 | + // directories via ClassLoader.getResources(...). JAR files | |
| 159 | + // built by Gradle include explicit directory entries; a | |
| 160 | + // hand-rolled JAR like this one needs them too or the | |
| 161 | + // resolver returns nothing. | |
| 162 | + val segments = entryName.split("/").dropLast(1) | |
| 163 | + val accumulated = StringBuilder() | |
| 164 | + for (segment in segments) { | |
| 165 | + accumulated.append(segment).append("/") | |
| 166 | + val dirEntry = JarEntry(accumulated.toString()) | |
| 167 | + jos.putNextEntry(dirEntry) | |
| 168 | + jos.closeEntry() | |
| 169 | + } | |
| 170 | + jos.putNextEntry(JarEntry(entryName)) | |
| 171 | + jos.write(contents.toByteArray(Charsets.UTF_8)) | |
| 172 | + jos.closeEntry() | |
| 173 | + } | |
| 174 | + } | |
| 175 | + return jar | |
| 176 | + } | |
| 177 | +} | ... | ... |
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.metadata.yaml | |
| 2 | + | |
| 3 | +import assertk.assertThat | |
| 4 | +import assertk.assertions.containsExactly | |
| 5 | +import assertk.assertions.hasSize | |
| 6 | +import assertk.assertions.isEmpty | |
| 7 | +import assertk.assertions.isEqualTo | |
| 8 | +import assertk.assertions.isNotNull | |
| 9 | +import com.fasterxml.jackson.databind.ObjectMapper | |
| 10 | +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory | |
| 11 | +import com.fasterxml.jackson.module.kotlin.registerKotlinModule | |
| 12 | +import org.junit.jupiter.api.Test | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Snapshot tests on the YAML deserialization shape. They run in | |
| 16 | + * milliseconds and protect against accidental schema changes that | |
| 17 | + * would break every plug-in's metadata file. | |
| 18 | + */ | |
| 19 | +class MetadataYamlParseTest { | |
| 20 | + | |
| 21 | + private val mapper = ObjectMapper(YAMLFactory()).registerKotlinModule() | |
| 22 | + | |
| 23 | + @Test | |
| 24 | + fun `empty document parses to all-empty file`() { | |
| 25 | + val parsed = mapper.readValue("{}", MetadataYamlFile::class.java) | |
| 26 | + assertThat(parsed.entities).isEmpty() | |
| 27 | + assertThat(parsed.permissions).isEmpty() | |
| 28 | + assertThat(parsed.menus).isEmpty() | |
| 29 | + } | |
| 30 | + | |
| 31 | + @Test | |
| 32 | + fun `document with only entities works`() { | |
| 33 | + val yaml = """ | |
| 34 | + entities: | |
| 35 | + - name: User | |
| 36 | + pbc: identity | |
| 37 | + table: identity__user | |
| 38 | + description: A user account | |
| 39 | + """.trimIndent() | |
| 40 | + | |
| 41 | + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) | |
| 42 | + | |
| 43 | + assertThat(parsed.entities).hasSize(1) | |
| 44 | + val e = parsed.entities[0] | |
| 45 | + assertThat(e.name).isEqualTo("User") | |
| 46 | + assertThat(e.pbc).isEqualTo("identity") | |
| 47 | + assertThat(e.table).isEqualTo("identity__user") | |
| 48 | + assertThat(e.description).isEqualTo("A user account") | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Test | |
| 52 | + fun `entity description is optional`() { | |
| 53 | + val yaml = """ | |
| 54 | + entities: | |
| 55 | + - name: Foo | |
| 56 | + pbc: bar | |
| 57 | + table: bar__foo | |
| 58 | + """.trimIndent() | |
| 59 | + | |
| 60 | + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) | |
| 61 | + | |
| 62 | + assertThat(parsed.entities).hasSize(1) | |
| 63 | + assertThat(parsed.entities[0].description).isEqualTo(null) | |
| 64 | + } | |
| 65 | + | |
| 66 | + @Test | |
| 67 | + fun `permissions parse with key + description`() { | |
| 68 | + val yaml = """ | |
| 69 | + permissions: | |
| 70 | + - key: identity.user.create | |
| 71 | + description: Create a user | |
| 72 | + - key: identity.user.delete | |
| 73 | + description: Delete a user | |
| 74 | + """.trimIndent() | |
| 75 | + | |
| 76 | + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) | |
| 77 | + | |
| 78 | + assertThat(parsed.permissions).hasSize(2) | |
| 79 | + assertThat(parsed.permissions.map { it.key }).containsExactly( | |
| 80 | + "identity.user.create", | |
| 81 | + "identity.user.delete", | |
| 82 | + ) | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + fun `menus default section to Other and order to 1000`() { | |
| 87 | + val yaml = """ | |
| 88 | + menus: | |
| 89 | + - path: /x | |
| 90 | + label: X | |
| 91 | + """.trimIndent() | |
| 92 | + | |
| 93 | + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) | |
| 94 | + | |
| 95 | + val m = parsed.menus.single() | |
| 96 | + assertThat(m.path).isEqualTo("/x") | |
| 97 | + assertThat(m.label).isEqualTo("X") | |
| 98 | + assertThat(m.section).isEqualTo("Other") | |
| 99 | + assertThat(m.order).isEqualTo(1000) | |
| 100 | + assertThat(m.icon).isEqualTo(null) | |
| 101 | + } | |
| 102 | + | |
| 103 | + @Test | |
| 104 | + fun `unknown top-level keys are ignored`() { | |
| 105 | + // Forward-compat: a future plug-in built against a newer YAML | |
| 106 | + // schema (with `forms`, `workflows`, `rules`, …) loads cleanly | |
| 107 | + // on an older host that doesn't know those keys yet. | |
| 108 | + val yaml = """ | |
| 109 | + entities: | |
| 110 | + - name: Foo | |
| 111 | + pbc: bar | |
| 112 | + table: bar__foo | |
| 113 | + forms: | |
| 114 | + - id: future-form | |
| 115 | + unknown_section: | |
| 116 | + - foo | |
| 117 | + """.trimIndent() | |
| 118 | + | |
| 119 | + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) | |
| 120 | + | |
| 121 | + assertThat(parsed.entities).hasSize(1) | |
| 122 | + } | |
| 123 | + | |
| 124 | + @Test | |
| 125 | + fun `mixed file with all three sections parses`() { | |
| 126 | + val yaml = """ | |
| 127 | + entities: | |
| 128 | + - name: Item | |
| 129 | + pbc: catalog | |
| 130 | + table: catalog__item | |
| 131 | + permissions: | |
| 132 | + - key: catalog.item.read | |
| 133 | + description: Read items | |
| 134 | + menus: | |
| 135 | + - path: /catalog/items | |
| 136 | + label: Items | |
| 137 | + section: Catalog | |
| 138 | + order: 200 | |
| 139 | + """.trimIndent() | |
| 140 | + | |
| 141 | + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) | |
| 142 | + | |
| 143 | + assertThat(parsed.entities).hasSize(1) | |
| 144 | + assertThat(parsed.permissions).hasSize(1) | |
| 145 | + assertThat(parsed.menus).hasSize(1) | |
| 146 | + assertThat(parsed.menus[0].section).isEqualTo("Catalog") | |
| 147 | + assertThat(parsed.menus[0].order).isEqualTo(200) | |
| 148 | + } | |
| 149 | +} | ... | ... |
platform/platform-plugins/build.gradle.kts
| ... | ... | @@ -25,6 +25,7 @@ dependencies { |
| 25 | 25 | implementation(libs.kotlin.reflect) |
| 26 | 26 | implementation(libs.jackson.module.kotlin) |
| 27 | 27 | implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext |
| 28 | + implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...) | |
| 28 | 29 | |
| 29 | 30 | implementation(libs.spring.boot.starter) |
| 30 | 31 | implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher | ... | ... |
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
| ... | ... | @@ -9,6 +9,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties |
| 9 | 9 | import org.springframework.stereotype.Component |
| 10 | 10 | import org.vibeerp.api.v1.event.EventBus |
| 11 | 11 | import org.vibeerp.api.v1.plugin.PluginJdbc |
| 12 | +import org.vibeerp.platform.metadata.MetadataLoader | |
| 12 | 13 | import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry |
| 13 | 14 | import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar |
| 14 | 15 | import org.vibeerp.platform.plugins.lint.PluginLinter |
| ... | ... | @@ -56,6 +57,7 @@ class VibeErpPluginManager( |
| 56 | 57 | private val jdbc: PluginJdbc, |
| 57 | 58 | private val linter: PluginLinter, |
| 58 | 59 | private val liquibaseRunner: PluginLiquibaseRunner, |
| 60 | + private val metadataLoader: MetadataLoader, | |
| 59 | 61 | ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { |
| 60 | 62 | |
| 61 | 63 | private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) |
| ... | ... | @@ -68,6 +70,18 @@ class VibeErpPluginManager( |
| 68 | 70 | private val started: MutableList<Pair<String, VibeErpPlugin>> = mutableListOf() |
| 69 | 71 | |
| 70 | 72 | override fun afterPropertiesSet() { |
| 73 | + // Load core (host classpath) metadata FIRST, regardless of plug-in | |
| 74 | + // settings. The metadata table needs the core entries even if no | |
| 75 | + // plug-ins are present, and per-plug-in metadata layers on top. | |
| 76 | + try { | |
| 77 | + metadataLoader.loadCore() | |
| 78 | + } catch (ex: Throwable) { | |
| 79 | + log.error( | |
| 80 | + "VibeErpPluginManager: core metadata load failed; framework will boot without core metadata", | |
| 81 | + ex, | |
| 82 | + ) | |
| 83 | + } | |
| 84 | + | |
| 71 | 85 | if (!properties.autoLoad) { |
| 72 | 86 | log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") |
| 73 | 87 | return |
| ... | ... | @@ -131,6 +145,28 @@ class VibeErpPluginManager( |
| 131 | 145 | if (ok) migrated += wrapper |
| 132 | 146 | } |
| 133 | 147 | |
| 148 | + // Load the plug-in's metadata YAMLs from inside its JAR file | |
| 149 | + // AFTER migration but BEFORE start. We open the JAR directly | |
| 150 | + // (not via the plug-in's classloader) so a parent-first | |
| 151 | + // PluginClassLoader cannot pollute the plug-in's metadata | |
| 152 | + // with the host's core YAML files. Failure here is logged but | |
| 153 | + // does NOT abort the plug-in — metadata is informational. | |
| 154 | + for (wrapper in migrated) { | |
| 155 | + val jarPath = wrapper.pluginPath | |
| 156 | + if (jarPath == null) { | |
| 157 | + log.debug("plug-in '{}' has no jarPath; skipping metadata load", wrapper.pluginId) | |
| 158 | + continue | |
| 159 | + } | |
| 160 | + try { | |
| 161 | + metadataLoader.loadFromPluginJar(wrapper.pluginId, jarPath) | |
| 162 | + } catch (ex: Throwable) { | |
| 163 | + log.warn( | |
| 164 | + "plug-in '{}' metadata load failed; the plug-in will still start without metadata", | |
| 165 | + wrapper.pluginId, ex, | |
| 166 | + ) | |
| 167 | + } | |
| 168 | + } | |
| 169 | + | |
| 134 | 170 | startPlugins() |
| 135 | 171 | |
| 136 | 172 | // PF4J's `startPlugins()` calls each plug-in's PF4J `start()` | ... | ... |
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml
0 → 100644
| 1 | +# Printing-shop reference plug-in metadata. | |
| 2 | +# | |
| 3 | +# Loaded by MetadataLoader at plug-in start, tagged | |
| 4 | +# source='plugin:printing-shop'. The framework's REST endpoint at | |
| 5 | +# GET /api/v1/_meta/metadata returns these alongside the core entries | |
| 6 | +# so the SPA's navigation grows when the plug-in is installed and | |
| 7 | +# shrinks when it's uninstalled. | |
| 8 | + | |
| 9 | +entities: | |
| 10 | + - name: Plate | |
| 11 | + pbc: printing-shop | |
| 12 | + table: plugin_printingshop__plate | |
| 13 | + description: A printing plate (offset, flexo, screen) with dimensions and approval status | |
| 14 | + | |
| 15 | + - name: InkRecipe | |
| 16 | + pbc: printing-shop | |
| 17 | + table: plugin_printingshop__ink_recipe | |
| 18 | + description: A CMYK ink recipe used to mix the actual ink for a print run | |
| 19 | + | |
| 20 | +permissions: | |
| 21 | + - key: printingshop.plate.read | |
| 22 | + description: Read plate records | |
| 23 | + - key: printingshop.plate.create | |
| 24 | + description: Create new plates (initially in DRAFT status) | |
| 25 | + - key: printingshop.plate.approve | |
| 26 | + description: Approve a plate, moving it from DRAFT to APPROVED | |
| 27 | + - key: printingshop.ink.read | |
| 28 | + description: Read ink recipes | |
| 29 | + - key: printingshop.ink.create | |
| 30 | + description: Create new ink recipes | |
| 31 | + | |
| 32 | +menus: | |
| 33 | + - path: /plugins/printing-shop/plates | |
| 34 | + label: Plates | |
| 35 | + icon: stamp | |
| 36 | + section: Printing shop | |
| 37 | + order: 500 | |
| 38 | + - path: /plugins/printing-shop/inks | |
| 39 | + label: Inks | |
| 40 | + icon: palette | |
| 41 | + section: Printing shop | |
| 42 | + order: 510 | ... | ... |
settings.gradle.kts
| ... | ... | @@ -36,6 +36,9 @@ project(":platform:platform-security").projectDir = file("platform/platform-secu |
| 36 | 36 | include(":platform:platform-events") |
| 37 | 37 | project(":platform:platform-events").projectDir = file("platform/platform-events") |
| 38 | 38 | |
| 39 | +include(":platform:platform-metadata") | |
| 40 | +project(":platform:platform-metadata").projectDir = file("platform/platform-metadata") | |
| 41 | + | |
| 39 | 42 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── |
| 40 | 43 | include(":pbc:pbc-identity") |
| 41 | 44 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") | ... | ... |