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,6 +24,7 @@ dependencies { | ||
| 24 | implementation(project(":platform:platform-plugins")) | 24 | implementation(project(":platform:platform-plugins")) |
| 25 | implementation(project(":platform:platform-security")) | 25 | implementation(project(":platform:platform-security")) |
| 26 | implementation(project(":platform:platform-events")) | 26 | implementation(project(":platform:platform-events")) |
| 27 | + implementation(project(":platform:platform-metadata")) | ||
| 27 | implementation(project(":pbc:pbc-identity")) | 28 | implementation(project(":pbc:pbc-identity")) |
| 28 | implementation(project(":pbc:pbc-catalog")) | 29 | implementation(project(":pbc:pbc-catalog")) |
| 29 | 30 |
docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
| @@ -12,18 +12,19 @@ | @@ -12,18 +12,19 @@ | ||
| 12 | > previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant | 12 | > previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant |
| 13 | > routing) units are gone. See CLAUDE.md guardrail #5 for the rationale. | 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,11 +97,9 @@ These units finish the platform layer so PBCs can be implemented without inventi | ||
| 96 | **Module:** `platform-plugins` + `api.v1` (added `PluginJdbc`/`PluginRow`) | 97 | **Module:** `platform-plugins` + `api.v1` (added `PluginJdbc`/`PluginRow`) |
| 97 | **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 | **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 | ### P1.6 — `Translator` implementation backed by ICU4J | 104 | ### P1.6 — `Translator` implementation backed by ICU4J |
| 106 | **Module:** new `platform-i18n` module | 105 | **Module:** new `platform-i18n` module |
| @@ -351,9 +350,9 @@ REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3 | @@ -351,9 +350,9 @@ REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3 | ||
| 351 | ``` | 350 | ``` |
| 352 | 351 | ||
| 353 | Sensible ordering for one developer (single-tenant world): | 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 | Sensible parallel ordering for a team of three: | 357 | Sensible parallel ordering for a team of three: |
| 359 | - Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1 | 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,6 +33,7 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " | ||
| 33 | kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } | 33 | kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } |
| 34 | jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } | 34 | jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } |
| 35 | jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } | 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 | # Bytecode analysis | 38 | # Bytecode analysis |
| 38 | asm = { module = "org.ow2.asm:asm", version = "9.7.1" } | 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,6 +25,7 @@ dependencies { | ||
| 25 | implementation(libs.kotlin.reflect) | 25 | implementation(libs.kotlin.reflect) |
| 26 | implementation(libs.jackson.module.kotlin) | 26 | implementation(libs.jackson.module.kotlin) |
| 27 | implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext | 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 | implementation(libs.spring.boot.starter) | 30 | implementation(libs.spring.boot.starter) |
| 30 | implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher | 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,6 +9,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties | ||
| 9 | import org.springframework.stereotype.Component | 9 | import org.springframework.stereotype.Component |
| 10 | import org.vibeerp.api.v1.event.EventBus | 10 | import org.vibeerp.api.v1.event.EventBus |
| 11 | import org.vibeerp.api.v1.plugin.PluginJdbc | 11 | import org.vibeerp.api.v1.plugin.PluginJdbc |
| 12 | +import org.vibeerp.platform.metadata.MetadataLoader | ||
| 12 | import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry | 13 | import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry |
| 13 | import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar | 14 | import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar |
| 14 | import org.vibeerp.platform.plugins.lint.PluginLinter | 15 | import org.vibeerp.platform.plugins.lint.PluginLinter |
| @@ -56,6 +57,7 @@ class VibeErpPluginManager( | @@ -56,6 +57,7 @@ class VibeErpPluginManager( | ||
| 56 | private val jdbc: PluginJdbc, | 57 | private val jdbc: PluginJdbc, |
| 57 | private val linter: PluginLinter, | 58 | private val linter: PluginLinter, |
| 58 | private val liquibaseRunner: PluginLiquibaseRunner, | 59 | private val liquibaseRunner: PluginLiquibaseRunner, |
| 60 | + private val metadataLoader: MetadataLoader, | ||
| 59 | ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { | 61 | ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { |
| 60 | 62 | ||
| 61 | private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) | 63 | private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) |
| @@ -68,6 +70,18 @@ class VibeErpPluginManager( | @@ -68,6 +70,18 @@ class VibeErpPluginManager( | ||
| 68 | private val started: MutableList<Pair<String, VibeErpPlugin>> = mutableListOf() | 70 | private val started: MutableList<Pair<String, VibeErpPlugin>> = mutableListOf() |
| 69 | 71 | ||
| 70 | override fun afterPropertiesSet() { | 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 | if (!properties.autoLoad) { | 85 | if (!properties.autoLoad) { |
| 72 | log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") | 86 | log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") |
| 73 | return | 87 | return |
| @@ -131,6 +145,28 @@ class VibeErpPluginManager( | @@ -131,6 +145,28 @@ class VibeErpPluginManager( | ||
| 131 | if (ok) migrated += wrapper | 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 | startPlugins() | 170 | startPlugins() |
| 135 | 171 | ||
| 136 | // PF4J's `startPlugins()` calls each plug-in's PF4J `start()` | 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,6 +36,9 @@ project(":platform:platform-security").projectDir = file("platform/platform-secu | ||
| 36 | include(":platform:platform-events") | 36 | include(":platform:platform-events") |
| 37 | project(":platform:platform-events").projectDir = file("platform/platform-events") | 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 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── | 42 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── |
| 40 | include(":pbc:pbc-identity") | 43 | include(":pbc:pbc-identity") |
| 41 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") | 44 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") |