Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,31 @@ verified.
- Single-module Gradle build (Kotlin DSL). Root: `build.gradle.kts`, `settings.gradle.kts`.
- Java **21** (`java` toolchain). Plugins: `java-library`, `maven-publish`.
- Version catalog: `gradle/libs.versions.toml`. `group`/`version` live in `gradle.properties`.
- Source root: `src/main/java/net/democracrycraft/pluginInfrastructure/`
- Source root: `src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/`
- `modules/` — runtime module system (load/init/reload/shutdown feature jars).
- `sql/` — `SchemaMigrator` (abstract) + `MigrationStep` for versioned schema migrations.
- `configurate/` — `MessageContainer` plus `TypeSerializer`s for `Component` / `SimpleDateFormat`.
- `util/` — stateless helpers (`DurationParserUtil`, `CurrencyFormatter`, `DateFormatter`).
- No tests yet. There is no `src/test` source set configured.
- Tests live in `src/test/java`, JUnit 5 with Mockito (module lifecycle) and H2 (schema migrator).
Migration fixtures are classpath resources under `src/test/resources/migrations/`.

## Build & verify

```bash
./gradlew build # compile + assemble; the primary check
./gradlew build # compile + assemble + test; the primary check
./gradlew test # tests only
./gradlew compileJava # faster compile-only loop while iterating
./gradlew publishToMavenLocal # sanity-check the published artifact shape
```

On Windows the shell is PowerShell; invoke `./gradlew` (the wrapper works from Git Bash too). Always
compile after a change — this is a library, so a clean `build` is the bar.
run `build` after a change — this is a library, so a clean `build`, tests included, is the bar.

Add tests with the change, not after it. Prefer a test that would have caught the specific bug over
one that restates the happy path, and assert what the code actually does — if a dependency turns out
to behave differently than expected (MiniMessage's default instance is lenient and does not throw on
a malformed tag, for one), pin the real behaviour and say so in the test, rather than asserting an
outcome that cannot occur.

## Conventions

Expand All @@ -51,12 +60,31 @@ compile after a change — this is a library, so a clean `build` is the bar.
- **`SchemaMigrator` is abstract and dialect-agnostic.** It provides no SQL of its own — subclasses
implement `bootstrapDdl()`, `selectVersion()`, `insertVersion()`. Migration resources load from a
caller-supplied `ClassLoader` (the consuming plugin's), not this library's.
- **Version tables are namespaced per plugin.** Subclasses must not use a bare `schema_version`:
two plugins sharing a database would read each other's version, and one of them would refuse to
start while the other re-applied migrations that were never its own.
- **Migration files are split client-side.** `splitStatements` is quote- and comment-aware so a `;`
inside a string literal is not a delimiter; statements are executed one at a time so failures are
attributed correctly and no `allowMultiQueries=true` is required. Don't reintroduce whole-file
execution as the default. Note the default splitter lexes **MySQL/MariaDB**, not standard SQL
(`#` comments, backtick identifiers, backslash escapes) — it is `protected` so another dialect can
replace it, and that is the one dialect concern the compiler won't prompt for.
- **Runtime values are never parsed as MiniMessage.** `MessageContainer.value(...)` wraps
`Placeholder.unparsed`; `Placeholder.parsed` would let a stored, player-authored string carry
`<click:run_command:…>` that runs as whoever clicks it. Do not add a helper that deserializes an
arbitrary runtime string, and do not build templates by concatenating values into
`miniMessageFormattedFor`. `markup(...)` with a pre-built `Component` is the way to get formatting
into a slot.
- **User-facing parsers treat their input as hostile.** `DurationParserUtil` is fed straight from
commands: every rejection path is an `IllegalArgumentException`, arithmetic is `Math.*Exact`, and
case folding is `Locale.ROOT` (a Turkish default locale otherwise breaks the `min` unit). Keep
that shape for anything else that parses player input.

## Porting code from sibling projects

Sibling plugins in the workspace are the source of much of this code. When lifting something in:

1. Repackage under `net.democracrycraft.pluginInfrastructure.*`.
1. Repackage under `com.minecraftcitiesnetwork.pluginInfrastructure.*`.
2. Remove app-specific coupling (domain types, framework data sources) — generalize to JDK or
Paper types so the result is a genuine reusable utility.
3. Leave behind app-specific data (e.g. a concrete migration list or schema SQL); this library
Expand Down
97 changes: 82 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ migration** and **formatting/parsing** helpers.

- **Java:** 21
- **Server API:** Paper `1.21.8` (compile-only)
- **Coordinates:** `net.democracrycraft:plugin-infrastructure:1.0.0-SNAPSHOT`
- **Coordinates:** `com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT`

## Installation

Expand All @@ -20,7 +20,7 @@ repositories {
}

dependencies {
implementation("net.democracrycraft:plugin-infrastructure:1.0.0-SNAPSHOT")
implementation("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT")
}
```

Expand All @@ -31,9 +31,10 @@ already provides it at runtime.

| Package | Purpose |
|---------|---------|
| `net.democracrycraft.pluginInfrastructure.modules` | Load, initialize, reload, and shut down feature modules bundled as jars. |
| `net.democracrycraft.pluginInfrastructure.sql` | Ordered, versioned SQL schema migrations against a JDBC `DataSource`. |
| `net.democracrycraft.pluginInfrastructure.util` | Small stateless helpers: duration parsing, currency and date formatting. |
| `com.minecraftcitiesnetwork.pluginInfrastructure.modules` | Load, initialize, reload, and shut down feature modules bundled as jars. |
| `com.minecraftcitiesnetwork.pluginInfrastructure.sql` | Ordered, versioned SQL schema migrations against a JDBC `DataSource`. |
| `com.minecraftcitiesnetwork.pluginInfrastructure.configurate` | MiniMessage message store loaded from Configurate, plus `TypeSerializer`s for `Component` and `SimpleDateFormat`. |
| `com.minecraftcitiesnetwork.pluginInfrastructure.util` | Small stateless helpers: duration parsing, currency and date formatting. |

## Module system

Expand Down Expand Up @@ -73,42 +74,108 @@ lifecycle.stop(); // shut down all modules
## SQL schema migrations

`SchemaMigrator` applies an ordered list of `MigrationStep`s against a JDBC `DataSource`, recording
progress in a `schema_version` table. It is **abstract** and dialect-agnostic: a subclass supplies
the three bookkeeping statements.
progress in a version table. It is **abstract** and dialect-agnostic: a subclass supplies the three
bookkeeping statements.

- `MigrationStep(int version, String description, String resourcePath)` — one migration; its SQL is
loaded as a classpath resource relative to a base directory.
- `SchemaMigrator` — implement `bootstrapDdl()`, `selectVersion()`, and `insertVersion()`, then call
`migrate(dataSource, baseResourceDir, steps, resourceLoader, logger)`.
`migrate(dataSource, baseResourceDir, steps, resourceLoader, logger)`. It throws only
`SQLException`, including for a missing or unreadable resource, so a plugin disabling itself on a
failed migration has one thing to catch.

Migration SQL files load from the **caller-supplied `ClassLoader`**, so they live in the consuming
plugin's jar. Multi-statement migration files require a `DataSource` configured for multi-statement
execution (for MariaDB, the `allowMultiQueries=true` connection property).
plugin's jar.

```java
class MariaSchemaMigrator extends SchemaMigrator {
@Override protected String bootstrapDdl() { return "CREATE TABLE IF NOT EXISTS schema_version (...)"; }
@Override protected String selectVersion() { return "SELECT COALESCE(MAX(version), 0) FROM schema_version"; }
@Override protected String insertVersion() { return "INSERT INTO schema_version (version, description) VALUES (?, ?)"; }
@Override protected String bootstrapDdl() { return "CREATE TABLE IF NOT EXISTS myplugin_schema_version (...)"; }
@Override protected String selectVersion() { return "SELECT COALESCE(MAX(version), 0) FROM myplugin_schema_version"; }
@Override protected String insertVersion() { return "INSERT INTO myplugin_schema_version (version, description) VALUES (?, ?)"; }
}

var steps = List.of(new MigrationStep(1, "initial schema", "V1__initial.sql"));
new MariaSchemaMigrator().migrate(dataSource, Path.of("sql/migrations"), steps,
getClass().getClassLoader(), getLogger());
```

> **Name the version table per plugin** — `myplugin_schema_version`, not `schema_version`. Two
> plugins sharing a database and a table name read each other's version: the one that is behind
> trips the "database is newer than this build" guard and refuses to start, and the one that is
> ahead re-applies migrations that were never its own.

> **Write every migration to be re-runnable.** Each step's statements and its version row share one
> transaction, but MySQL and MariaDB have no transactional DDL — an `ALTER TABLE` implicitly commits
> — so in practice that only protects the version row. Guard structural changes on
> `information_schema` lookups and make data fixes idempotent; a crash mid-step then leaves the
> recorded version unchanged and the step replays safely on the next start.

**Multi-statement files** work without any server-side flag. Statements are split client-side by
`splitStatements(String)` and executed one at a time, so `allowMultiQueries=true` need not appear in
the operator's JDBC arguments and each failure is reported against the statement that caused it. The
default splitter strips `--`, `#` and `/* */` comments and tracks quoted literals and backtick
identifiers, so a `;` inside a string is not a delimiter. It is `protected`: return `List.of(sql)` to
send each file to the server whole.

> **The default splitter lexes MySQL/MariaDB, not standard SQL** — `#` line comments, backtick
> identifiers, and backslash escapes inside literals are all dialect-specific. On PostgreSQL, SQLite
> or H2 you must override it. Only the three bookkeeping methods are abstract, so the compiler will
> not remind you about this one.

## Messages

`MessageContainer` holds MiniMessage templates keyed by dotted path, loads them from a Configurate
node tree, and renders them to Adventure `Component`s. A message stored under `prefix` is exposed to
every other message as `<prefix>`.

```java
container.load(loader.load()); // nested nodes flatten to "errors.no-permission"

sender.sendMessage(container.messageFor("punish.banned",
MessageContainer.value("player", target.getName()), // literal text, never markup
MessageContainer.markup("time", container.messageFor("time.permanent"))));
```

> **Runtime values must go through `value(...)`.** It delegates to `Placeholder.unparsed`, so markup
> inside the value is inserted as text. MiniMessage's `Placeholder.parsed` re-parses its value *as
> markup*: text authored by one player, persisted, and re-rendered to another — a punishment reason,
> a shop description, a mail subject — could then carry `<click:run_command:'/op evil'>`, which runs
> as whoever clicks it. When a slot needs formatting, build a `Component` and inject it with
> `markup(...)`; a component is structured data and is never re-parsed.

There is deliberately no "render this arbitrary string as MiniMessage" helper. Concatenating runtime
values into `miniMessageFormattedFor(key)` and deserializing the result is the same hole with extra
steps.

`load` is a **reload, not a merge**: keys absent from the node tree are dropped, so a message deleted
from the file stops being served. Merge defaults and overrides at the `ConfigurationNode` level
(Configurate's `mergeFrom`) and load the result once. It never throws — an unreadable node is skipped
and its key renders as its own name in game, rather than one bad node costing every string in the
file.

## Utilities

- `DurationParserUtil.parse("2w5d12h30m10s")` → `Duration`. Units: `s`, `m`/`min`, `h`/`hr`, `d`,
`w`/`wk`; combinable; must be positive.
`w`/`wk`; combinable; case-insensitive; must be positive. The whole string must be
`<digits><unit>` segments and nothing else — `"1h2y"` is rejected, not read as `"1h"`. Built for
untrusted input: **every** rejection is an `IllegalArgumentException` (never a
`NumberFormatException` or `ArithmeticException`), overflow is caught rather than wrapped, and the
total is capped at ~1000 years.
- `DurationParserUtil.parseAllowingPermanent(input, keywords)` → `Optional<Duration>`. Empty means
*permanent*, present means a real positive duration, and invalid input throws — three distinct
outcomes, so no `-1` sentinel that a parse failure could masquerade as. The keywords are yours to
choose; `DurationParserUtil.PERMANENT_KEYWORDS` is the conventional `perm`/`permanent` set, offered
as a default rather than imposed, so a plugin whose commands say `forever` (or that reads the
wording from a language file) is not stuck with English constants baked into shared infrastructure.
- `CurrencyFormatter.format(double)` → `"1,234.56"` (`#,##0.00`). Thread-safe.
- `DateFormatter.format(DateFormat, LocalDateTime)` → formats in the JVM default time zone. Note
`DateFormat` is not thread-safe; do not share one instance across threads.

## Building

```bash
./gradlew build # compile + assemble jar and sources jar
./gradlew build # compile + assemble jar and sources jar, and run the tests
./gradlew test # tests only
./gradlew publishToMavenLocal # install to ~/.m2 for local testing
```

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
group=net.democracrycraft
group=com.minecraftcitiesnetwork
version=1.0.0-SNAPSHOT
org.gradle.configuration-cache=true
org.gradle.parallel=true
Expand Down
Empty file modified gradlew
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package net.democracrycraft.pluginInfrastructure.configurate;
package com.minecraftcitiesnetwork.pluginInfrastructure.configurate;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
Expand Down
Loading
Loading