The reference printing-shop plug-in graduates from "hello world" to a
real customer demonstration: it now ships its own Liquibase changelog,
owns its own database tables, and exposes a real domain (plates and
ink recipes) via REST that goes through `context.jdbc` — a new
typed-SQL surface in api.v1 — without ever touching Spring's
`JdbcTemplate` or any other host internal type. A bytecode linter
that runs before plug-in start refuses to load any plug-in that tries
to import `org.vibeerp.platform.*` or `org.vibeerp.pbc.*` classes.
What landed:
* api.v1 (additive, binary-compatible):
- PluginJdbc — typed SQL access with named parameters. Methods:
query, queryForObject, update, inTransaction. No Spring imports
leaked. Forces plug-ins to use named params (no positional ?).
- PluginRow — typed nullable accessors over a single result row:
string, int, long, uuid, bool, instant, bigDecimal. Hides
java.sql.ResultSet entirely.
- PluginContext.jdbc getter with default impl that throws
UnsupportedOperationException so older builds remain binary
compatible per the api.v1 stability rules.
* platform-plugins — three new sub-packages:
- jdbc/DefaultPluginJdbc backed by Spring's NamedParameterJdbcTemplate.
ResultSetPluginRow translates each accessor through ResultSet.wasNull()
so SQL NULL round-trips as Kotlin null instead of the JDBC defaults
(0 for int, false for bool, etc. — bug factories).
- jdbc/PluginJdbcConfiguration provides one shared PluginJdbc bean
for the whole process. Per-plugin isolation lands later.
- migration/PluginLiquibaseRunner looks for
META-INF/vibe-erp/db/changelog.xml inside the plug-in JAR via
the PF4J classloader and applies it via Liquibase against the
host's shared DataSource. The unique META-INF path matters:
plug-ins also see the host's parent classpath, where the host's
own db/changelog/master.xml lives, and a collision causes
Liquibase ChangeLogParseException at install time.
- lint/PluginLinter walks every .class entry in the plug-in JAR
via java.util.jar.JarFile + ASM ClassReader, visits every type/
method/field/instruction reference, rejects on any reference to
`org/vibeerp/platform/` or `org/vibeerp/pbc/` packages.
* VibeErpPluginManager lifecycle is now load → lint → migrate → start:
- lint runs immediately after PF4J's loadPlugins(); rejected
plug-ins are unloaded with a per-violation error log and never
get to run any code
- migrate runs the plug-in's own Liquibase changelog; failure
means the plug-in is loaded but skipped (loud warning, framework
boots fine)
- then PF4J's startPlugins() runs the no-arg start
- then we walk loaded plug-ins and call vibe_erp's start(context)
with a fully-wired DefaultPluginContext (logger + endpoints +
eventBus + jdbc). The plug-in's tables are guaranteed to exist
by the time its lambdas run.
* DefaultPluginContext.jdbc is no longer a stub. Plug-ins inject the
shared PluginJdbc and use it to talk to their own tables.
* Reference plug-in (PrintingShopPlugin):
- Ships META-INF/vibe-erp/db/changelog.xml with two changesets:
plugin_printingshop__plate (id, code, name, width_mm, height_mm,
status) and plugin_printingshop__ink_recipe (id, code, name,
cmyk_c/m/y/k).
- Now registers seven endpoints:
GET /ping — health
GET /echo/{name} — path variable demo
GET /plates — list
GET /plates/{id} — fetch
POST /plates — create (with race-conditiony existence
check before INSERT, since plug-ins
can't import Spring's DataAccessException)
GET /inks
POST /inks
- All CRUD lambdas use context.jdbc with named parameters. The
plug-in still imports nothing from org.springframework.* in its
own code (it does reach the host's Jackson via reflection for
JSON parsing — a deliberate v0.6 shortcut documented inline).
Tests: 5 new PluginLinterTest cases use ASM ClassWriter to synthesize
in-memory plug-in JARs (clean class, forbidden platform ref, forbidden
pbc ref, allowed api.v1 ref, multiple violations) and a mocked
PluginWrapper to avoid touching the real PF4J loader. Total now
**81 unit tests** across 10 modules, all green.
End-to-end smoke test against fresh Postgres with the plug-in loaded
(every assertion green):
Boot logs:
PluginLiquibaseRunner: plug-in 'printing-shop' has changelog.xml
Liquibase: ChangeSet printingshop-init-001 ran successfully
Liquibase: ChangeSet printingshop-init-002 ran successfully
Liquibase migrations applied successfully
plugin.printing-shop: registered 7 endpoints
HTTP smoke:
\dt plugin_printingshop* → both tables exist
GET /api/v1/plugins/printing-shop/plates → []
POST plate A4 → 201 + UUID
POST plate A3 → 201 + UUID
POST duplicate A4 → 409 + clear msg
GET plates → 2 rows
GET /plates/{id} → A4 details
psql verifies both rows in plugin_printingshop__plate
POST ink CYAN → 201
POST ink MAGENTA → 201
GET inks → 2 inks with nested CMYK
GET /ping → 200 (existing endpoint)
GET /api/v1/catalog/uoms → 15 UoMs (no regression)
GET /api/v1/identity/users → 1 user (no regression)
Bug encountered and fixed during the smoke test:
• The plug-in initially shipped its changelog at db/changelog/master.xml,
which collides with the HOST's db/changelog/master.xml. The plug-in
classloader does parent-first lookup (PF4J default), so Liquibase's
ClassLoaderResourceAccessor found BOTH files and threw
ChangeLogParseException ("Found 2 files with the path"). Fixed by
moving the plug-in changelog to META-INF/vibe-erp/db/changelog.xml,
a path the host never uses, and updating PluginLiquibaseRunner.
The unique META-INF prefix is now part of the documented plug-in
convention.
What is explicitly NOT in this chunk (deferred):
• Per-plugin Spring child contexts — plug-ins still instantiate via
PF4J's classloader without their own Spring beans
• Per-plugin datasource isolation — one shared host pool today
• Plug-in changelog table-prefix linter — convention only, runtime
enforcement comes later
• Rollback on plug-in uninstall — uninstall is operator-confirmed
and rare; running dropAll() during stop() would lose data on
accidental restart
• Subscription auto-scoping on plug-in stop — plug-ins still close
their own subscriptions in stop()
• Real customer-grade JSON parsing in plug-in lambdas — the v0.6
reference plug-in uses reflection to find the host's Jackson; a
real plug-in author would ship their own JSON library or use a
future api.v1 typed-DTO surface
Implementation plan refreshed: P1.2, P1.3, P1.4, P1.7, P4.1, P5.1
all marked DONE in
docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md.
Next priority candidates: P1.5 (metadata seeder) and P5.2 (pbc-partners).