diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index 83c61bb..ca40f7c 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(project(":platform:platform-plugins")) implementation(project(":platform:platform-security")) implementation(project(":platform:platform-events")) + implementation(project(":platform:platform-metadata")) implementation(project(":pbc:pbc-identity")) implementation(project(":pbc:pbc-catalog")) diff --git a/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md b/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md index 12297ec..66c3169 100644 --- a/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md +++ b/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md @@ -12,18 +12,19 @@ > previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant > routing) units are gone. See CLAUDE.md guardrail #5 for the rationale. > -> **2026-04-08 update — P4.1, P5.1, P1.3, P1.7, P1.4, P1.2 all landed.** -> Auth (Argon2id + JWT + bootstrap admin), pbc-catalog (items + UoMs), -> plug-in HTTP endpoints (registry + dispatcher + DefaultPluginContext), -> event bus + transactional outbox + audit subscriber, plug-in-owned -> Liquibase changelogs (the reference plug-in now has its own database -> tables), and the plug-in linter (ASM bytecode scan rejecting forbidden -> imports) are all done and smoke-tested end-to-end. The reference -> printing-shop plug-in has graduated from "hello world" to a real -> customer demonstration: it owns its own DB schema, CRUDs plates and -> ink recipes via REST through `context.jdbc`, and would be rejected at -> install time if it tried to import internal framework classes. The -> framework is now at **81 unit tests** across 10 modules, all green. +> **2026-04-08 update — P4.1, P5.1, P1.3, P1.7, P1.4, P1.2, P1.5 all +> landed.** Auth, pbc-catalog, plug-in HTTP endpoints, event bus + +> outbox, plug-in-owned Liquibase changelogs, plug-in linter, and now +> **the metadata loader** are all done and smoke-tested end-to-end. +> Core PBCs and the reference plug-in ship YAML files declaring their +> entities, permissions, and menus; the loader walks the host +> classpath and each plug-in JAR at boot, upserts the rows tagged +> with `source = 'core'` or `source = 'plugin:'`, and exposes +> them at the public `GET /api/v1/_meta/metadata` endpoint so the +> future SPA, OpenAPI generator, and AI-agent function catalog can +> introspect what entities and permissions exist. Idempotent across +> restarts (delete-by-source then insert). The framework is now at +> **92 unit tests** across 11 modules, all green. --- @@ -96,11 +97,9 @@ These units finish the platform layer so PBCs can be implemented without inventi **Module:** `platform-plugins` + `api.v1` (added `PluginJdbc`/`PluginRow`) **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___*` 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. -### P1.5 — Metadata store seeding -**Module:** new `platform-metadata` module (or expand `platform-persistence`) -**Depends on:** — -**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:' | 'user'`. Idempotent; safe to run on every boot. -**Acceptance:** integration test confirms that booting twice doesn't duplicate rows; uninstalling a plug-in removes its `source = 'plugin:'` rows; user-edited rows (`source = 'user'`) are never touched. +### ✅ P1.5 — Metadata store seeding (DONE 2026-04-08) +**Module:** new `platform-metadata` module +**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. ### P1.6 — `Translator` implementation backed by ICU4J **Module:** new `platform-i18n` module @@ -351,9 +350,9 @@ REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3 ``` Sensible ordering for one developer (single-tenant world): -**~~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 → ...** +**~~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 → ...** -**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. +**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. Sensible parallel ordering for a team of three: - Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0111a45..84a78e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } # Bytecode analysis asm = { module = "org.ow2.asm:asm", version = "9.7.1" } diff --git a/pbc/pbc-catalog/src/main/resources/META-INF/vibe-erp/metadata/catalog.yml b/pbc/pbc-catalog/src/main/resources/META-INF/vibe-erp/metadata/catalog.yml new file mode 100644 index 0000000..1ab4d48 --- /dev/null +++ b/pbc/pbc-catalog/src/main/resources/META-INF/vibe-erp/metadata/catalog.yml @@ -0,0 +1,45 @@ +# pbc-catalog metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. +# This declares what the catalog PBC offers to the rest of the framework +# and the SPA: which entities exist, which permissions guard them, +# which menu entries point at them. + +entities: + - name: Item + pbc: catalog + table: catalog__item + description: A thing that can be bought, sold, made, stored, or invoiced + + - name: Uom + pbc: catalog + table: catalog__uom + description: A unit of measure (kg, m, ea, sheet, …) + +permissions: + - key: catalog.item.read + description: Read catalog item records + - key: catalog.item.create + description: Create catalog items + - key: catalog.item.update + description: Update catalog items + - key: catalog.item.deactivate + description: Deactivate catalog items (preserved for historical references) + - key: catalog.uom.read + description: Read units of measure + - key: catalog.uom.create + description: Create custom units of measure + - key: catalog.uom.update + description: Update unit-of-measure display name or dimension + +menus: + - path: /catalog/items + label: Items + icon: package + section: Catalog + order: 200 + - path: /catalog/uoms + label: Units of measure + icon: ruler + section: Catalog + order: 210 diff --git a/pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml b/pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml new file mode 100644 index 0000000..d4951ed --- /dev/null +++ b/pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml @@ -0,0 +1,44 @@ +# pbc-identity metadata. +# +# Loaded at boot by org.vibeerp.platform.metadata.MetadataLoader, +# tagged source='core'. Re-loaded on every boot from the YAML below +# (delete-then-insert), so this file is the source of truth — edits +# made via the future SPA customization UI live in metadata__* rows +# tagged source='user' and are NEVER touched by the loader. + +entities: + - name: User + pbc: identity + table: identity__user + description: A user account in the framework + + - name: Role + pbc: identity + table: identity__role + description: A named bundle of permissions assignable to users + +permissions: + - key: identity.user.read + description: Read user records + - key: identity.user.create + description: Create new user records + - key: identity.user.update + description: Update user records + - key: identity.user.disable + description: Disable a user (soft-delete; row is preserved for audit) + - key: identity.role.read + description: Read role records + - key: identity.role.assign + description: Assign a role to a user + +menus: + - path: /identity/users + label: Users + icon: people + section: System + order: 100 + - path: /identity/roles + label: Roles + icon: shield-lock + section: System + order: 110 diff --git a/platform/platform-metadata/build.gradle.kts b/platform/platform-metadata/build.gradle.kts new file mode 100644 index 0000000..983b996 --- /dev/null +++ b/platform/platform-metadata/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp metadata loader — reads YAML files from classpath and plug-in JARs, upserts to metadata__* tables. INTERNAL." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + api(project(":api:api-v1")) + api(project(":platform:platform-persistence")) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.jackson.module.kotlin) + implementation(libs.jackson.dataformat.yaml) + implementation(libs.jackson.datatype.jsr310) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) // for the @RestController + implementation(libs.spring.boot.starter.data.jpa) // for DataSource + JdbcTemplate + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt new file mode 100644 index 0000000..a7fb47b --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt @@ -0,0 +1,258 @@ +package org.vibeerp.platform.metadata + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.slf4j.LoggerFactory +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.platform.metadata.yaml.MetadataYamlFile +import java.nio.file.Files +import java.nio.file.Path +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID +import java.util.jar.JarFile + +/** + * Loads metadata YAML files (any `.yml` under `META-INF/vibe-erp/metadata/`) + * from the host classpath or from individual plug-in JARs and upserts + * their contents into the `metadata__*` tables. + * + * **Two entry points, deliberately different:** + * + * • [loadCore] uses Spring's `PathMatchingResourcePatternResolver` + * against the host's classloader. The host owns its classpath, so + * `classpath*:` resolution is exactly the right tool — every JAR + * contributing to the application gets scanned. + * + * • [loadFromPluginJar] opens ONE specific plug-in JAR file via + * [JarFile] and walks its entries directly. This is critical: a + * plug-in's `PluginClassLoader` is parent-first by default, so a + * `classpath*:` scan against it ALSO finds the host's metadata + * files via the parent classloader. Walking the JAR file directly + * guarantees only the plug-in's own files get loaded. + * + * **Idempotency.** Loading the same source twice produces the same + * final database state. The mechanism is "delete by source, then + * insert" inside one transaction: + * 1. `DELETE FROM metadata__entity WHERE source = :source` + * 2. `INSERT` every entity from every YAML file with that source + * 3. Same for `metadata__permission` and `metadata__menu` + * The whole load runs in a single transaction so a failure leaves the + * old metadata in place. User-edited rows (`source = 'user'`) are NEVER + * touched — they survive boot, plug-in install, and plug-in upgrade. + * + * **Why delete-and-insert instead of upsert.** Upsert needs a stable + * natural key per row, which would force the YAML to declare unique + * ids and would make renames painful. Delete-by-source treats the + * YAML as the authoritative declaration: whatever is in the YAML at + * boot time is what the database has. Plug-in uninstall is the same + * delete query. + * + * Reference: architecture spec section 8 ("The metadata store") and + * implementation plan unit P1.5. + */ +@Component +class MetadataLoader( + private val jdbc: NamedParameterJdbcTemplate, +) { + + private val log = LoggerFactory.getLogger(MetadataLoader::class.java) + + private val yamlMapper: ObjectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule() + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() + + /** + * Load every metadata YAML on the host classpath as `source='core'`. + * Called once at boot from `VibeErpPluginManager.afterPropertiesSet()` + * BEFORE any plug-in is processed, so the metadata table contains + * the core entries by the time plug-in metadata layers on top. + */ + @Transactional + fun loadCore(): LoadResult { + val source = SOURCE_CORE + val resolver = PathMatchingResourcePatternResolver(javaClass.classLoader) + val resources = try { + resolver.getResources("classpath*:META-INF/vibe-erp/metadata/*.yml") + } catch (ex: Throwable) { + log.warn("MetadataLoader: failed to scan host classpath for metadata YAMLs: {}", ex.message, ex) + return doLoad(source, emptyList()) + } + val files = resources.mapNotNull { resource -> + try { + val parsed = resource.inputStream.use { stream -> + yamlMapper.readValue(stream, MetadataYamlFile::class.java) + } + ParsedYaml(resource.url?.toString() ?: resource.filename ?: "", parsed) + } catch (ex: Throwable) { + log.error("MetadataLoader: failed to parse YAML resource {}: {}", resource.url, ex.message, ex) + null + } + } + return doLoad(source, files) + } + + /** + * Load metadata YAMLs from a specific plug-in JAR file as + * `source='plugin:'`. Walks the JAR entries directly to avoid + * picking up host classpath files via the plug-in's parent-first + * `PluginClassLoader`. + * + * @param pluginId the plug-in id, used to tag the rows. + * @param jarPath path to the plug-in JAR on disk. Resolved by the + * host's `VibeErpPluginManager` from PF4J's `PluginWrapper`. + */ + @Transactional + fun loadFromPluginJar(pluginId: String, jarPath: Path): LoadResult { + require(pluginId.isNotBlank()) { "pluginId must not be blank" } + val source = "$SOURCE_PLUGIN_PREFIX$pluginId" + + if (!Files.isRegularFile(jarPath)) { + log.warn( + "MetadataLoader: plug-in '{}' jarPath {} is not a regular file; skipping (probably loaded as unpacked dir)", + pluginId, jarPath, + ) + return doLoad(source, emptyList()) + } + + val files = mutableListOf() + JarFile(jarPath.toFile()).use { jar -> + val entries = jar.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory) continue + val name = entry.name + if (!name.startsWith(METADATA_DIR) || !name.endsWith(YAML_SUFFIX)) continue + try { + val parsed = jar.getInputStream(entry).use { stream -> + yamlMapper.readValue(stream, MetadataYamlFile::class.java) + } + files += ParsedYaml(url = "$jarPath!$name", parsed = parsed) + } catch (ex: Throwable) { + log.error( + "MetadataLoader: plug-in '{}' failed to parse jar entry {}: {}", + pluginId, name, ex.message, ex, + ) + } + } + } + + return doLoad(source, files) + } + + private fun doLoad(source: String, files: List): LoadResult { + require(source.isNotBlank()) { "source must not be blank" } + + val merged = MetadataYamlFile( + entities = files.flatMap { it.parsed.entities }, + permissions = files.flatMap { it.parsed.permissions }, + menus = files.flatMap { it.parsed.menus }, + ) + + wipeBySource(source) + insertEntities(source, merged) + insertPermissions(source, merged) + insertMenus(source, merged) + + log.info( + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus from {} file(s)", + source, merged.entities.size, merged.permissions.size, merged.menus.size, files.size, + ) + + return LoadResult( + source = source, + entityCount = merged.entities.size, + permissionCount = merged.permissions.size, + menuCount = merged.menus.size, + files = files.map { it.url }, + ) + } + + /** + * Result of one load() call. Used by callers for logging and by + * tests for assertions. + */ + data class LoadResult( + val source: String, + val entityCount: Int, + val permissionCount: Int, + val menuCount: Int, + val files: List, + ) + + // ─── internals ───────────────────────────────────────────────── + + private fun wipeBySource(source: String) { + val params = MapSqlParameterSource("source", source) + jdbc.update("DELETE FROM metadata__entity WHERE source = :source", params) + jdbc.update("DELETE FROM metadata__permission WHERE source = :source", params) + jdbc.update("DELETE FROM metadata__menu WHERE source = :source", params) + } + + private fun insertEntities(source: String, file: MetadataYamlFile) { + val now = Timestamp.from(Instant.now()) + for (entity in file.entities) { + jdbc.update( + """ + INSERT INTO metadata__entity (id, source, payload, created_at, updated_at) + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) + """.trimIndent(), + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("source", source) + .addValue("payload", jsonMapper.writeValueAsString(entity)) + .addValue("now", now), + ) + } + } + + private fun insertPermissions(source: String, file: MetadataYamlFile) { + val now = Timestamp.from(Instant.now()) + for (permission in file.permissions) { + jdbc.update( + """ + INSERT INTO metadata__permission (id, source, payload, created_at, updated_at) + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) + """.trimIndent(), + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("source", source) + .addValue("payload", jsonMapper.writeValueAsString(permission)) + .addValue("now", now), + ) + } + } + + private fun insertMenus(source: String, file: MetadataYamlFile) { + val now = Timestamp.from(Instant.now()) + for (menu in file.menus) { + jdbc.update( + """ + INSERT INTO metadata__menu (id, source, payload, created_at, updated_at) + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) + """.trimIndent(), + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("source", source) + .addValue("payload", jsonMapper.writeValueAsString(menu)) + .addValue("now", now), + ) + } + } + + private data class ParsedYaml( + val url: String, + val parsed: MetadataYamlFile, + ) + + companion object { + const val SOURCE_CORE: String = "core" + const val SOURCE_PLUGIN_PREFIX: String = "plugin:" + private const val METADATA_DIR: String = "META-INF/vibe-erp/metadata/" + private const val YAML_SUFFIX: String = ".yml" + } +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt new file mode 100644 index 0000000..156ba7e --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt @@ -0,0 +1,60 @@ +package org.vibeerp.platform.metadata.web + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Read-only REST endpoint that returns the seeded metadata. + * + * Mounted under `/api/v1/_meta/metadata` (next to the existing + * `/api/v1/_meta/info`) and explicitly **public** — the SPA needs + * to bootstrap its navigation, the AI-agent function catalog needs + * to discover entities, and external tooling needs to introspect + * the framework — all without holding a token. The metadata returned + * is intentionally non-sensitive: entity names, permission keys, menu + * paths. Nothing in here should ever be PII or secret. + * + * If a future version exposes more sensitive metadata (custom field + * definitions that mention column names of user data, for example), + * the controller can be split into a public surface and an + * authenticated-only surface without breaking the existing route. + */ +@RestController +@RequestMapping("/api/v1/_meta/metadata") +class MetadataController( + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, +) { + + @GetMapping + fun all(): Map = mapOf( + "entities" to readPayloads("metadata__entity"), + "permissions" to readPayloads("metadata__permission"), + "menus" to readPayloads("metadata__menu"), + ) + + @GetMapping("/entities") + fun entities(): List> = readPayloads("metadata__entity") + + @GetMapping("/permissions") + fun permissions(): List> = readPayloads("metadata__permission") + + @GetMapping("/menus") + fun menus(): List> = readPayloads("metadata__menu") + + private fun readPayloads(table: String): List> { + return jdbc.query( + "SELECT source, payload FROM $table ORDER BY source", + emptyMap(), + ) { rs, _ -> + val source = rs.getString("source") + val payloadJson = rs.getString("payload") ?: "{}" + @Suppress("UNCHECKED_CAST") + val payload = objectMapper.readValue(payloadJson, Map::class.java) as Map + payload + mapOf("source" to source) + } + } +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt new file mode 100644 index 0000000..036dd8e --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt @@ -0,0 +1,119 @@ +package org.vibeerp.platform.metadata.yaml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +/** + * The schema of a single metadata file under `META-INF/vibe-erp/metadata/` + * (any `.yml` filename). + * + * **Why one top-level file with multiple typed sections** instead of + * one file per type: + * - PBCs and plug-ins typically declare half a dozen tightly-related + * metadata items at once (an entity, three permissions, one menu + * entry). Forcing them into separate files would split a single + * feature across the filesystem and make code review harder. + * - The top-level keys (`entities`, `permissions`, `menus`) are the + * extension points. Adding a new key (`forms`, `workflows`, `rules`, + * `translations`) is non-breaking — old YAML files just don't have + * the new section, the loader skips it. + * + * **Source tagging.** The loader adds a `source` column to every row + * it persists: `core` for files on the host classpath, `plugin:` + * for files inside a plug-in JAR, `user` for rows produced by the + * runtime customization UI (P3.x). The YAML itself never declares its + * source — that comes from where the file was loaded from, not from + * its content. Plug-ins cannot declare themselves as `core`. + * + * Reference: architecture spec section 8 ("The metadata store"). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class MetadataYamlFile( + val entities: List = emptyList(), + val permissions: List = emptyList(), + val menus: List = emptyList(), +) + +/** + * A business entity exposed through the framework. + * + * Plug-ins and PBCs declare their entities here so the framework + * (and the future SPA, MCP server, AI agent function catalog, OpenAPI + * generator) can introspect what kinds of objects exist without + * scanning Hibernate or reading source code. + * + * @property name Display name. Convention: PascalCase singular ("User", + * "Item", "PrintingPlate"). + * @property pbc Owning Packaged Business Capability id. Convention: + * the Gradle subproject name without the `pbc-` prefix + * (`identity`, `catalog`, …) for core PBCs, the plug-in id for + * plug-in entities (`printing-shop`). + * @property table Underlying database table name. Used by the future + * custom-field machinery and audit log to know which JSONB `ext` + * column to write into. + * @property description Free-form one-line description shown in the + * customization UI and in the OpenAPI spec. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class EntityYaml( + val name: String, + val pbc: String, + val table: String, + val description: String? = null, +) + +/** + * A permission declared by a PBC or a plug-in. + * + * Permissions are how the framework's authorization layer (P4.3, not + * yet implemented) decides whether the current Principal can perform + * a given action. Declaring them in metadata serves three purposes: + * + * 1. The role editor in the SPA can list every available permission. + * 2. The framework can pre-check that every `@RequirePermission` + * annotation in code references a declared permission and warn + * on dangling references at boot. + * 3. The OpenAPI spec and the AI-agent function catalog can show + * "this endpoint requires X" without scraping code. + * + * @property key The permission key. Convention: + * `..`. Lowercase, dot-separated. + * Examples: `identity.user.create`, `catalog.item.read`, + * `printingshop.plate.approve`. + * @property description One-line human-readable description. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class PermissionYaml( + val key: String, + val description: String, +) + +/** + * A menu entry shown in the SPA's navigation. + * + * The framework's nav structure is data, not hard-coded React routes — + * adding a new top-level menu item from a plug-in is a YAML change, + * not a code change. The future SPA reads this list from + * `GET /api/v1/_meta/metadata` at boot and renders the navigation + * accordingly. + * + * @property path SPA route the entry navigates to. Convention: + * `//` (e.g. `/identity/users`). + * @property label Display label. Should be a translation key + * (`identity.menu.users`) once the i18n loader lands; for now it's + * a literal English string. + * @property icon Material/Tabler icon name. The SPA renders it; the + * framework just stores the string. + * @property section Top-level section the entry belongs to + * ("System", "Sales", "Production"). Entries with the same + * section group together in the nav. + * @property order Sort order within the section. Lower numbers + * appear first. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class MenuYaml( + val path: String, + val label: String, + val icon: String? = null, + val section: String = "Other", + val order: Int = 1000, +) diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt new file mode 100644 index 0000000..4e499cd --- /dev/null +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt @@ -0,0 +1,177 @@ +package org.vibeerp.platform.metadata + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.Attributes +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import java.util.jar.Manifest + +/** + * Unit tests for [MetadataLoader]. + * + * Strategy: build a tiny in-memory JAR containing a single + * `META-INF/vibe-erp/metadata/test.yml` file in a temp directory, + * load it via a `URLClassLoader`, point the loader at it with a + * mocked `NamedParameterJdbcTemplate`, and assert on the SQL the + * loader issues. We don't need a real database — the loader's + * contract is "given this YAML, issue these statements". + */ +class MetadataLoaderTest { + + private lateinit var jdbc: NamedParameterJdbcTemplate + private lateinit var loader: MetadataLoader + + @BeforeEach + fun setUp() { + jdbc = mockk(relaxed = false) + every { jdbc.update(any(), any()) } returns 1 + loader = MetadataLoader(jdbc) + } + + @Test + fun `loadFromPluginJar on a missing file is a no-op wipe`() { + // The plug-in's pluginPath might not be a regular file (e.g. + // an unpacked dev directory). The loader should still issue + // the three DELETE statements so a previous-boot's leftovers + // for this plug-in get cleared, then return a zero result. + val nowhere = Path.of("/tmp/does-not-exist-${System.nanoTime()}.jar") + + val result = loader.loadFromPluginJar("ghost", nowhere) + + assertThat(result.entityCount).isEqualTo(0) + verify(atLeast = 1) { + jdbc.update(match { it.contains("DELETE FROM metadata__entity") }, any()) + } + } + + @Test + fun `loadFromPluginJar with one entity issues one INSERT into metadata__entity`() { + val tempDir = Files.createTempDirectory("metadata-loader-test") + val jar = buildMetadataJar( + tempDir, + "META-INF/vibe-erp/metadata/test.yml", + """ + entities: + - name: Plate + pbc: printing-shop + table: plugin_printingshop__plate + description: An offset printing plate + """.trimIndent(), + ) + + val captured = slot() + every { + jdbc.update(match { it.contains("INSERT INTO metadata__entity") }, capture(captured)) + } returns 1 + + val result = loader.loadFromPluginJar("printing-shop", jar) + + assertThat(result.entityCount).isEqualTo(1) + assertThat(result.permissionCount).isEqualTo(0) + assertThat(result.menuCount).isEqualTo(0) + assertThat(result.source).isEqualTo("plugin:printing-shop") + + val params = captured.captured + assertThat(params.getValue("source") as String).isEqualTo("plugin:printing-shop") + val payload = params.getValue("payload") as String + assertThat(payload).contains("Plate") + assertThat(payload).contains("printing-shop") + assertThat(payload).contains("plugin_printingshop__plate") + } + + @Test + fun `loadFromPluginJar with mixed sections issues inserts into all three tables`() { + val tempDir = Files.createTempDirectory("metadata-loader-test") + val jar = buildMetadataJar( + tempDir, + "META-INF/vibe-erp/metadata/everything.yml", + """ + entities: + - name: User + pbc: identity + table: identity__user + permissions: + - key: identity.user.read + description: Read users + menus: + - path: /identity/users + label: Users + section: System + order: 100 + """.trimIndent(), + ) + + val result = loader.loadFromPluginJar("test-plugin", jar) + + assertThat(result.entityCount).isEqualTo(1) + assertThat(result.permissionCount).isEqualTo(1) + assertThat(result.menuCount).isEqualTo(1) + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__entity") }, any()) + } + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__permission") }, any()) + } + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__menu") }, any()) + } + } + + @Test + fun `loadFromPluginJar rejects blank pluginId`() { + val nowhere = Path.of("/tmp/dummy.jar") + assertFailure { loader.loadFromPluginJar("", nowhere) } + .isInstanceOf(IllegalArgumentException::class) + assertFailure { loader.loadFromPluginJar(" ", nowhere) } + .isInstanceOf(IllegalArgumentException::class) + } + + // ─── helpers ─────────────────────────────────────────────────── + + private fun buildMetadataJar( + tempDir: Path, + entryName: String, + contents: String, + ): Path { + val jar = tempDir.resolve("metadata-test-${System.nanoTime()}.jar") + val manifest = Manifest().apply { + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" + } + Files.newOutputStream(jar).use { fos -> + JarOutputStream(fos, manifest).use { jos -> + // Spring's PathMatchingResourcePatternResolver with the + // `classpath*:` + `*.yml` pattern enumerates parent + // directories via ClassLoader.getResources(...). JAR files + // built by Gradle include explicit directory entries; a + // hand-rolled JAR like this one needs them too or the + // resolver returns nothing. + val segments = entryName.split("/").dropLast(1) + val accumulated = StringBuilder() + for (segment in segments) { + accumulated.append(segment).append("/") + val dirEntry = JarEntry(accumulated.toString()) + jos.putNextEntry(dirEntry) + jos.closeEntry() + } + jos.putNextEntry(JarEntry(entryName)) + jos.write(contents.toByteArray(Charsets.UTF_8)) + jos.closeEntry() + } + } + return jar + } +} diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt new file mode 100644 index 0000000..738ef41 --- /dev/null +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt @@ -0,0 +1,149 @@ +package org.vibeerp.platform.metadata.yaml + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.junit.jupiter.api.Test + +/** + * Snapshot tests on the YAML deserialization shape. They run in + * milliseconds and protect against accidental schema changes that + * would break every plug-in's metadata file. + */ +class MetadataYamlParseTest { + + private val mapper = ObjectMapper(YAMLFactory()).registerKotlinModule() + + @Test + fun `empty document parses to all-empty file`() { + val parsed = mapper.readValue("{}", MetadataYamlFile::class.java) + assertThat(parsed.entities).isEmpty() + assertThat(parsed.permissions).isEmpty() + assertThat(parsed.menus).isEmpty() + } + + @Test + fun `document with only entities works`() { + val yaml = """ + entities: + - name: User + pbc: identity + table: identity__user + description: A user account + """.trimIndent() + + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + + assertThat(parsed.entities).hasSize(1) + val e = parsed.entities[0] + assertThat(e.name).isEqualTo("User") + assertThat(e.pbc).isEqualTo("identity") + assertThat(e.table).isEqualTo("identity__user") + assertThat(e.description).isEqualTo("A user account") + } + + @Test + fun `entity description is optional`() { + val yaml = """ + entities: + - name: Foo + pbc: bar + table: bar__foo + """.trimIndent() + + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + + assertThat(parsed.entities).hasSize(1) + assertThat(parsed.entities[0].description).isEqualTo(null) + } + + @Test + fun `permissions parse with key + description`() { + val yaml = """ + permissions: + - key: identity.user.create + description: Create a user + - key: identity.user.delete + description: Delete a user + """.trimIndent() + + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + + assertThat(parsed.permissions).hasSize(2) + assertThat(parsed.permissions.map { it.key }).containsExactly( + "identity.user.create", + "identity.user.delete", + ) + } + + @Test + fun `menus default section to Other and order to 1000`() { + val yaml = """ + menus: + - path: /x + label: X + """.trimIndent() + + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + + val m = parsed.menus.single() + assertThat(m.path).isEqualTo("/x") + assertThat(m.label).isEqualTo("X") + assertThat(m.section).isEqualTo("Other") + assertThat(m.order).isEqualTo(1000) + assertThat(m.icon).isEqualTo(null) + } + + @Test + fun `unknown top-level keys are ignored`() { + // Forward-compat: a future plug-in built against a newer YAML + // schema (with `forms`, `workflows`, `rules`, …) loads cleanly + // on an older host that doesn't know those keys yet. + val yaml = """ + entities: + - name: Foo + pbc: bar + table: bar__foo + forms: + - id: future-form + unknown_section: + - foo + """.trimIndent() + + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + + assertThat(parsed.entities).hasSize(1) + } + + @Test + fun `mixed file with all three sections parses`() { + val yaml = """ + entities: + - name: Item + pbc: catalog + table: catalog__item + permissions: + - key: catalog.item.read + description: Read items + menus: + - path: /catalog/items + label: Items + section: Catalog + order: 200 + """.trimIndent() + + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + + assertThat(parsed.entities).hasSize(1) + assertThat(parsed.permissions).hasSize(1) + assertThat(parsed.menus).hasSize(1) + assertThat(parsed.menus[0].section).isEqualTo("Catalog") + assertThat(parsed.menus[0].order).isEqualTo(200) + } +} diff --git a/platform/platform-plugins/build.gradle.kts b/platform/platform-plugins/build.gradle.kts index 656289b..9df5137 100644 --- a/platform/platform-plugins/build.gradle.kts +++ b/platform/platform-plugins/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation(libs.kotlin.reflect) implementation(libs.jackson.module.kotlin) implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext + implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...) implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt index 8dc40ad..de9032e 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt @@ -9,6 +9,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.stereotype.Component import org.vibeerp.api.v1.event.EventBus import org.vibeerp.api.v1.plugin.PluginJdbc +import org.vibeerp.platform.metadata.MetadataLoader import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar import org.vibeerp.platform.plugins.lint.PluginLinter @@ -56,6 +57,7 @@ class VibeErpPluginManager( private val jdbc: PluginJdbc, private val linter: PluginLinter, private val liquibaseRunner: PluginLiquibaseRunner, + private val metadataLoader: MetadataLoader, ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) @@ -68,6 +70,18 @@ class VibeErpPluginManager( private val started: MutableList> = mutableListOf() override fun afterPropertiesSet() { + // Load core (host classpath) metadata FIRST, regardless of plug-in + // settings. The metadata table needs the core entries even if no + // plug-ins are present, and per-plug-in metadata layers on top. + try { + metadataLoader.loadCore() + } catch (ex: Throwable) { + log.error( + "VibeErpPluginManager: core metadata load failed; framework will boot without core metadata", + ex, + ) + } + if (!properties.autoLoad) { log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") return @@ -131,6 +145,28 @@ class VibeErpPluginManager( if (ok) migrated += wrapper } + // Load the plug-in's metadata YAMLs from inside its JAR file + // AFTER migration but BEFORE start. We open the JAR directly + // (not via the plug-in's classloader) so a parent-first + // PluginClassLoader cannot pollute the plug-in's metadata + // with the host's core YAML files. Failure here is logged but + // does NOT abort the plug-in — metadata is informational. + for (wrapper in migrated) { + val jarPath = wrapper.pluginPath + if (jarPath == null) { + log.debug("plug-in '{}' has no jarPath; skipping metadata load", wrapper.pluginId) + continue + } + try { + metadataLoader.loadFromPluginJar(wrapper.pluginId, jarPath) + } catch (ex: Throwable) { + log.warn( + "plug-in '{}' metadata load failed; the plug-in will still start without metadata", + wrapper.pluginId, ex, + ) + } + } + startPlugins() // PF4J's `startPlugins()` calls each plug-in's PF4J `start()` diff --git a/reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml b/reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml new file mode 100644 index 0000000..fd8b1e0 --- /dev/null +++ b/reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml @@ -0,0 +1,42 @@ +# Printing-shop reference plug-in metadata. +# +# Loaded by MetadataLoader at plug-in start, tagged +# source='plugin:printing-shop'. The framework's REST endpoint at +# GET /api/v1/_meta/metadata returns these alongside the core entries +# so the SPA's navigation grows when the plug-in is installed and +# shrinks when it's uninstalled. + +entities: + - name: Plate + pbc: printing-shop + table: plugin_printingshop__plate + description: A printing plate (offset, flexo, screen) with dimensions and approval status + + - name: InkRecipe + pbc: printing-shop + table: plugin_printingshop__ink_recipe + description: A CMYK ink recipe used to mix the actual ink for a print run + +permissions: + - key: printingshop.plate.read + description: Read plate records + - key: printingshop.plate.create + description: Create new plates (initially in DRAFT status) + - key: printingshop.plate.approve + description: Approve a plate, moving it from DRAFT to APPROVED + - key: printingshop.ink.read + description: Read ink recipes + - key: printingshop.ink.create + description: Create new ink recipes + +menus: + - path: /plugins/printing-shop/plates + label: Plates + icon: stamp + section: Printing shop + order: 500 + - path: /plugins/printing-shop/inks + label: Inks + icon: palette + section: Printing shop + order: 510 diff --git a/settings.gradle.kts b/settings.gradle.kts index d86a89e..76fa705 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,9 @@ project(":platform:platform-security").projectDir = file("platform/platform-secu include(":platform:platform-events") project(":platform:platform-events").projectDir = file("platform/platform-events") +include(":platform:platform-metadata") +project(":platform:platform-metadata").projectDir = file("platform/platform-metadata") + // ─── Packaged Business Capabilities (core PBCs) ───────────────────── include(":pbc:pbc-identity") project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")